LTAL, TP 16.11

Comme je vous ai informé, vos devez travailler sur l'exercice de la semaine dernière, la césure. J'aimerais bien voir vos résultats ! Si vous vous bloquez, demandez de l'aide, mais ne laissez pas des exercices sans rien à montrer. Cette habitude peut détruire votre vie professionnelle et aussi amoureuse...

Mais pour ceux qui préfèrent travailler sur un autre sujet, j'ai encore une proposition...


Codage de Huffman.

(Code à caractères de longueur variable)

Entropie

Ceci est un sujet fondamental, il touche au sens du mot "information", et devrait être discuté en début de vos études. Au moins : tout au début de la discussion des propriétés statistiques des documents textuels (et quelconques). La présentation ci-dessous est un peu informelle, pas toujours quantitative.

L'information contenue dans un document est une propriété indéfinissable, car contextuelle. Nous parlerons de l'information statistique, structurelle, sans engager l'interprétation quelconque. Nous pouvons considérer que c'est la mesure de la possibilité d'identifier le document, de le distinguer des autres, semblables. Par exemple, le nombre de question dichotomiques, Oui/Non, permettant de l'identifier, sans aucune information extérieure. Par la suite (mais temporairement) nous considérons que l'on puisse traiter les caractères dans le document comme des entités indépendantes, sans corrélations avec les voisins (ce qui est manifestement faux dans des vrais documents, et on y reviendra). Alors, si la taille du document est N, l'information sera égale à N fois l'information concernant UN caractère. Supponsons, pour simplicité, que les caractères appartiennent à un alphabet de taille L = 2P.

L'algorithme rationnel d'identification serait de découper l'alphabet en deux moitiés, et de poser la question "est-ce que le caractère appartient à la première moitié?" La réponse donne un bit d'information, 0 ou 1 si vous voulez. On répète la même procédure sur la moitié choisie ... et finalement on identifie le caractère après P questions. L'information pertinente est de P bits. Et ceci est la longueur de codage standard d'un caractère : P bits, = log2 L. Si L n'est pas égal à une puissance de deux, on l'arrondi, car pas de bits fractionnels. Si l'alphabet à la taille entre 33 et 64, il faudra 6 bits.

Cependant, le procédé est opérationnellement correct seulement si les caractères sont équiprobables. Si un d'eux, disons 'a', est si fréquent, que ses occurrences dépassent 50%, on a plus d'une chance sur deux de l'identifier en une seule question : "est-ce 'a'?" L'information définie ainsi, paradoxalement, est une mesure d'incertitude... Bien sûr, tout ceci s'applique également aux autres documents que textuels, l'organisation en octets est assez universelle. La technique de Huffman est utilisée comme accessoire dans la compression des images.

Pour d'autres distributions probabilistes qu'uniforme, le calcul de cette information est plus difficile. Elle est égale à l'entropie de Shannon, voir ce site, et des centaines d'autres pages et livres. Le nombre de bits indispensable est inférieur au logarithme de la longueur de l'alphabet.

Supposons que la configuration de notre modèle textuel est le suivant, il y a 10 caractères, avec les probabilités a priori suivantes :
a, b, c, d, e, f, g, h, i, j. Dans notre "langage", le tableau de fréquences de ces lettres est le suivant :

a b c d e f g h i j
0.021978 0.109890 0.054945 0.230769 0.043956 0.120879 0.175824 0.087912 0.087912 0.065934
2 10 5 21 4 11 16 8 8 6

(L'ordre des colonnes n'a pas d'importance, la suite alphabétique est uniquement pour faciliter la lecture). La dernière ligne représente les fréquences non-normalisées, plus faciles de traiter à la main. la somme des fréquences vaut 91. Puisque la longueur de l'alphabet dépasse 8, mais est inférieure à 16, on aurait besoin d'un code de 4 bits : 0000, 0001, 0010, ...., 1001. pour représenter tous. 6 codes seront inutilisés. Ce qui compte, c'est la possibilité de réaliser effectivement ces "octets fractionnels", représenter le document de manière à le compresser, d'allouer un nombre variable de bits à chaque élément atomique, et de programmer un algorithme de codage et de décodage pas trop difficile.

Codage de Huffman, l'algorithme

L'arbre binaire ci-dessous représente la stratégie en question. Les lettres sont les feuilles, les noeuds internes sont accessoires, les arcs portent des étiquettes qui spécifient le codage. Pour le moment les seuls noeuds sont les feuilles, on procédéra vers la racine.


Exercice 1.

  1. Comparez cette valeur avec l'entropie de Shannon de l'alphabet : \(S = -\sum{p(c) \cdot \log_2(p(c))}\). Je vous rappelle que \(\log_2 X = \log X/\log 2\), où \(\log\) est pris à une base quelconque. (La fonction log dans Python/math est le logarithme naturel. Mais en Numpy vous avez la fonction log2).
  2. Écrivez un programme qui construit le code de Huffman pour l'alphabet exemplaire. Le programme est très court, mais non-trivial, à cause de la récursivité assez avancée.

    Exercice 2.

    Construisez un document artificiel composé de lettres 'a' – 'j', aléatoires, avec la distribution de fréquences qui suit les chiffres ci-dessus. On a travaillé sur ce problème, vous devez pouvoir le faire vite. La longueur : autour de 1000 caractères (donc, 8000 bits. Ceci n'est pas trop important).

    Codez votre document selon la stratégie de Huffman sous forme de chaîne de caractères '0' et '1', et vérifiez sa longueur. Elle ne doit pas dépasser, disons, 3200, sinon quelque chose a mal marché.

    Décodez cette chaîne, comparez avec l'original.


    Attention, votre dernier exercice obligatoire.

    Correction lexicale ; recherche approximative avec BK-trees

    Les arbres BK [Burkhard-Keller] sont des structures arborescentes N-aires (arité quelconque) qui stockent des mots en fonction de leurs distances de Levenshtein, ce qui permet de scanner un dictionnaire et chercher des mots proches de la requête, sans parcourir le dictionnaire entier. Ils ont été inventés en 1973 et continuent d'être utilisés dans les correcteurs, et moteurs de recherche. Voir aussi Wikipédia. Vous trouverez aussi leur implémentation en Python, mais mes avertissements habituels s'appliquent...

    Vous devez bien comprendre l'idée de distance de Levenshtein (-Damerau), si ceci vous pose encore des problèmes. Revisez mon cours, copiez éventuellement mon programme leven0.py, lisez le texte de Norvig) mentionné également en cours, ou/et profitez de l'assemblage de plusieurs implémentations de la métrique de Lev-Dam sur le Web.

    Supposons que vous cherchez un terme, par ex. "trover". Pardon? "trouer"? "trouver"?... Peut-être trover tout court? On doit trouver une concordance (éventuellement inexacte) avec le dictionnaire (ou vocabulaire) à notre disposition. Dans Textes vous trouverez quelques listes de mots en français, de longueur variée : liste2000.txt, liste_mots.txt, motsfrgut.txt (la seule qui contient "trouer", mais elle est énorme...), et peut-être autre chose, par ex; words2000.txt en anglais. Prenez un vocabulaire plus grand, si votre ordinateur et votre patience résistent, mais pour les tests, choisissez de préférence un petit.

    Comme il a été dit in cours, la technique la plus superficielle, serait de comparer tous les mots du vocabulaire avec la requête, et retourner la réussite si le mot est proche, éventuellement arrêter la comparaison, si la distance L-D dépasse un seuil, disons 2 ou 3. Mais ceci serait aussi inefficace, surtout si les requêtes se succèdent. J'ai dit en cours qu'une méthode d'optimisation serait de "gonfler" (en pré-traitement) le vocabulaire avec des mots distordus, mais ceci est explosif, demande une très grande mémoire même pour les mots très proches. On procédéra autrement.

    Vocabulaire arborescent conceptuel

    Dans le domaine de recherche informatisée (en général) les arbres sont des structures fondamentales, à cause de la "logarithmisation" du temps de parcours. Au lieu de parcourir séquentiellement une structure linéaire, si un mécanisme décisionnel approprié est implémentable, alors on construit un arbre avec les données, et on suit (dans le cas optimal) une seule branche, ce qui logarithmise la complexité du procédé. Un exemple banal, connu de tous, est la structuration d'un dictionnaire classique (ou d'une encyclopédie). Le dictionnaire est trié, et notre "algorithme humain" nous permet d'"aller à gauche" ou "à droite", et trouver le mot assez rapidement (c'est mieux que \(\log_2\) de la longueur, car nous savons qu'un mot en "d" se trouve pas loin du début, etc. ; on n'a pas besoin d'ouvrir le dictionnaire au milieu). Ce sujet a été discuté en cours de Python maintes fois, déjà en L1.

    On n'a pas besoin toujours de la construction physique d'un arbre, pour appliquer un algorithme arborescent, parfois c'est la dynamique récursive qui fait l'affaire. Cependant ici, c'est l'approche la plus naturelle.

    L'ordre alphabétique peut être très utile, mais il n'est pas pertinent pour la recherche approximative, car même la première lettre peut être erronée. On utilisera la distance L-D pour l'algorithme de construction et de recherche. La distance de Levenshtein servira pour étiqueter les branches dans l'arbre.

    Construction de l'arbre BK

    Les noeuds de notre vocabulaire arborescent sont des mots, et les branches étiquetées par \(N\) mènent vers les noeuds "distants" de \(N\) du noeud en question. La structure globale de l'arbre dépend de l'ordre de l'insertion des nouveaux noeuds. L'arbre sera \(N\)-aire, chaque noeud sera associé à une liste de \(N\) descendants.

    Si vous voulez utiliser une classe d'arbres Python, faitez-le, mais une solution plus simple est possible.

    Exemple.

    Prenons la liste ['book','rook', 'row', 'boom', 'how']. La racine de l'arbre est 'book'. L'insertion de 'rook' et ensuite 'row', donne le résultat à gauche.

    Dans notre représentation, le dictionnaire contient {'row': [], 'rook': [], 'book': [(1, 'rook'), (3, 'row')]}.

    Si on insère 'boom', on constate que la branche 1 est déjà occupée par 'rook', et donc il faudra insérer le nouveau mot ici. On calcule la distance entre 'rook' et 'boom', qui est 2. Le mot 'how' est associé à 'row' avec distance 1.

    Le dictionnaire devient {'boom': [], 'row': [(1, 'how')], 'how': [], 'rook': [(2, 'boom')], 'book': [(1, 'rook'), (3, 'row')]}.

    Voici sa forme graphique :

    L'arbre final est assez irrégulier, mais normalement devra économiser beaucoup de temps de recherche. Par contre, la construction est assez onéreuse, la complexité est proportionnelle à \(m^2\), où \(m\) est la taille du vocabulaire. Dans mes tests, un vocabulaire de 22600 mots demande quelques dizaines de secondes pour la conversion en arbre [on peut faire mieux !], mais pas plus. Donc, ne commencez pas par une liste longue de plusieurs centaines de milliers de mots !


    La recherche

    La procédure devra trouver tous les mots dans le vocabulaire, dont la distance de la requête ne dépasse pas un certain seuil \(d\), par ex. 2 ou 3. On commence par la racine. Chaque mot trouvé est accumulé dans la liste - résultat. Cette liste est retournée comme le résultat de recherche.

    La procédure de recherche est récursive. On vérifie si la distance \(n\) entre le mot cherché et la racine de l'arbre est inférieure ou égale à \(d\). Si oui, comme il a été dit, on accumule le mot - racine et on continue, sinon on continue sans ajouter quoi que ce soit.

    La continuation est le parcours par toutes les branches de la racine, et la recherche parmi les descendants. Mais on ne vérifie pas la liste complète ! Si la distance entre la racine et le mot cherché est \(n\), alors les candidats éligibles ont la distance entre \(n-d\) et \(n+d\). Seulement les branches étiquetées avec ces nombres seront pris en considération, les autres sont ignorées. Pour chaque mot descendant, on relance la procédure récursive. (La liste - résultat est globale, et accumule tout, mais on peut aussi concaténer les listes locales). L'efficacité de la procédure est raisonnable même en Python.

    Par ex., si on cherche dans la liste de 23000 mots mentionnée ci-dessus le mot "schtroumpf" avec la tolérance 4, on ne trouve rien, et la recherche dure approx. 6 secondes. (Avec la tolérance 1, la réponse est immédiate).

    Plusieurs optimisations sont possibles, on peut trier la liste - branche, utiliser les tableaux avec l'accès plus rapide, et autres dispositifs, mais l'essentiel est que ça marche.


    Exercice 3.

    Récapitulatifs, Tests

    Vous avez le temps jusqu'à la fin du semestre (date à préciser, provisoirement : fin des cours, début des vacances de Noël)


    Solutions (à venir)


    Semaine suivante
    Retour à l'index