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...
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.
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.
'a'
et 'e'
, les fréquences 2 et 4, ensemble 6.
b c d * f g h i j (a e)
'c'
de poids 5, et nous avons le choix, soit 'j'
, soit le nouveau noeud "*". Ceci n'a pas trop d'importance, il faut mieux choisir le caractère individuel, car ainsi l'arbre devient plus équilibré. Le résultat est
b d * f g h i $ (a e) (c j)où le dernier noeud a le poids 11.
a | b | c | d | e | f | g | h | i | j |
00000 | 110 | 0110 | 10 | 00001 | 010 | 001 | 0001 | 111 | 0111 |
La longueur moyenne par caractère dans un texte qui respecte les fréquences utilisées, et avec notre code, est la somme pondérée : \(\bar{l} = \sum{l(c)\cdot p(c)}\), où \(l(c)\) est la longueur du caractère \(c\), et \(p(c)\) – sa probabilité. Dans notre exemple cette longueur moyenne est d'autour de 3.1, c'est-à-dire, 75% de la longueur fixe.
Exercice 1.
log2
).
key=lambda x:x[1]
). Avec ...,key=fff, la procédure de tri utilise la fonction fff qui doit retourner la valeur selon laquelle les éléments x sont triés. Donc, par ex. le second composant de l'élément. J'ai utilisé une fonction (définie à côté) un peu plus compliquée, si deux éléments ont la même valeur, et un d'eux est une feuille, alors on prend cette feuille. Par exemple, on vérifie le type(x[0]), si c'est str, c'est la fréquence x[1], sinon, c'est x[1]+0.5. Pour l'instant il n'y a que des feuilles, mais cela changera.
On combine les deux premiers items de la liste LL
en les combinant dans un tuple, et en calculant le poids correspondant, la somme. Ici on obtient ((('a', 2), ('e', 4))
, 6). On répète l'opération, la seconde réduction est ((('c', 5), ('j', 6))
, 11). La troisième construit ((((('a', 2), ('e', 4)), 6), ('h', 8))
, 14). Après chaque réduction, le nouveau noeud remplace les deux anciens, premiers, et la liste est re-triée. La réduction se termine quand sa longueur devient égale à 1. C'est l'arbre de Huffman.
P
est le poids (pour la racine : 91 dans notre exemple), et D
– le tuple des branches descendantes, "gauche" et "droite". Si D
n'est pas un tuple, c'est un caractère (la feuille). L'idée est de descendre de la racine vers les feuilles récursivement, et à chaque descente d'un niveau on ajoute '0' à une veriable tampon si c'est la branche gauche, et '1' si c'est la branche droite. Quand on arrive "en bas", le tampon contient le code, et on peut le placer dans un dictionnaire global, par ex. hc['g']='001'.
Le parcours récursif des arbres (et des graphes) est une des techniques fondamentales dans l'algorithmique, vous la verrez encore une dizaine de fois. Quand la procédure récursive se termine, le dictionnaire contient tous les codes.
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.
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.
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.
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.
bkdict={Rac : []}
, où Rac est la racine, le premier mot inséré dans le dictionnaire.
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 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.
import pickle as pk # Le module de sérialisation/récupération fp=open('data.dp',"wb") # Extension arbitraire ; évitez la confusion avec d'autres formats pk.dump(X,fp) # Lisez la doc du module pickle.sauvegarde une structure complexe Python sous une forme récupérable. Dans un autre programme, vous exécutez :
import pickle as pk
fp=open('data.dp',"rb")
X=pk.load(fp) # Dans le même endroit que "dump"
maxd
variant de 1 jusqu'à 4 ou 5.