Structure lexicale. Reconnaissance approximative.

Une partie intégrale et essentielle de l'ingénierie linguistique est le stockage et le transfert d'énormes quantités d'information textuelle, souvent critique et protégée, et qui est soumise en permanence aux procédures de recherche et analyse. Ceci a plusieurs implications.

"Distance" entre les entités textuelles

La similitude entre les mots ou phrases est souvent conventionnelle, basée sur notre compréhension des textes. Puisque l'analyse sémantique est très onéreuse, on cherchera des critères facilement formalisables et implémentables. La mesure standard dans la théorie d'information est la distance de Hamming, qui compte le nombre de bits/caractères altérés dans un signal.

Ceci s'avère peu utile dans le TAL. Les "distances sémantiques" structurelles sont nombreuses (1 mois de cours...), nous allons parler d'une technique concrère, bien formalisée et utile.

L'utilité d'une telle mesure est, par exemple, de corriger automatiquement un mot qui est immédiatemment corrigible (distance très petite avec un mot connu, et pas trop de variantes), et la reconnaissance d'une faute, si on trouve un mot correct "pas trop loin". Sinon, on considère le mot comme inconnu.

Distance de Levenshtein - Damerau

Cette mesure nous informe combien d'opérations élémentaires MINIMUM : l'ajout, le remplacement, ou la suppression d'une lettre, ou l'échange entre deux lettres voisines – est nécessaire, afin de transformer un mot en son voisin. Par exemple, la distance vue comme ça entre "université" et "niversitéu" est 2, tandis que la comparaison caractère par caractère donne le résultat moins joyeux : aucune des 10 lettres ne correspond...

On l'appelle parfois "la distance d'édition". Elle peut nous aider à proposer des corrections à un mot légèrement erroné, si on connaît les mots corrects voisins. Elle a été introduite par Владимир Иосифович Левенштейн en 1965 et ne parlait pas de transpositions (échanges), seulement des additions, suppressions et substitutions. Frederick Damerau a ajouté aux opérations de Levenshtein la tranpsosition de deux caractères, et a affirmé que ces quatre cas couvrent plus de 80% d'erreurs humaines lors de l'écriture d'un mot.

Le calcul de la distance de Levenshtein ou celle de Damerau–Levenshtein a été publiée dans plusieurs variantes. Pour nous la compréhension est plus importante que l'efficacité, mais cette dernière ne sera pas oubliée.

Dans la pratique on utilise une matrice de taille étant le produit des longueurs des deux chaînes à comparer. Notre code n'en a pas besoin, mais il utilise la récursivité de manière assez avancée, et le programme devient très court. Supposons que s1, s2 sont les deux chaînes (variables globales). Voici la procédure qui calcule la distance de Levenshtein (sans transpositions).

Initialement i=n1=len(s1)-1; j=n2=len(s2)-1

def rec(i,j):
    if i==-1: return j+1
    if j==-1: return i+1
    if s1[i]==s2[j]: return rec(i-1,j-1)
    return 1+min(rec(i-1,j-1),rec(i,j-1),rec(i-1,j))
C'est tout, et nous avons un algorithme qui marche, même s'il est "presque aveugle"... Voici la procédure avec l'extension de Damerau (un garde-fou manque, i,j > 0). Le noyau devient une procédure interne.

def vfd(s1,s2):
    n1=len(s1)-1; n2=len(s2)-1
    
    def rec(i,j):
        if i==-1: return j+1
        if j==-1: return i+1
        if s1[i]==s2[j]: return rec(i-1,j-1)
        lev = 1+min(rec(i-1,j-1),rec(i,j-1),rec(i-1,j))
        if s1[i]==s2[j-1] and s1[i-1]==s2[j]:  # transp.
           return min(lev,1+rec(i-2,j-2))
        return lev
        
    return rec(n1,n2)
Le problème principal avec cette solution est son inefficacité à cause de l'évaluation répétitive des mêmes données, on appelle plusieurs fois la fonction avec les mêmes paramètres (la "calamité Fibonacci" pour ceux qui savent de quoi je parle...)

La solution traditionnelle est l'usage d'une matrice rectangulaire afin de stocker statiquement les résultats des appels. Mais nous utiliserons un dictionnaire de mémoïsation où on sauvegarde les paramètres et les résultats déjà vus, et donc, récupérés immédiatement.

def vfd1(s1,s2):
    n1=len(s1)-1; n2=len(s2)-1;  mem={}
    for i in range(-1,n1+1):mem[(i,-1)]=i+1 # optimization
    for j in range(-1,n2+1):mem[(-1,j)]=j+1
    def rec(i,j):
        t=(i,j) # abréviation, peu importe.
        if t in mem: return mem[t]
        if s1[i]==s2[j]: 
            r=rec(i-1,j-1); mem[t]=r; return r
        lev = 1+min(rec(i-1,j-1),rec(i,j-1),rec(i-1,j))
        if i>0 and j>0:
           if s1[i]==s2[j-1] and s1[i-1]==s2[j]:
               r=min(lev,1+rec(i-2,j-2))
               mem[t]=r; return r
        mem[t]=lev; return lev
    return rec(n1,n2)
Les boucles for en bleu optimisent mem et remplacent :
if i==-1: mem[t]=j+1; return j+1
if j==-1: mem[t]=i+1; return i+1
L'application de ce calcul dépend du problème. Supposons que la correction ortho nous intéresse. Alors si pour chaque mot qui n'est pas trouvé dans le vocabulaire on calcule la distance L-D, et on choisit les mots où elle est petite pour les suggestions, ce serait extrêmement coûteux, irréalisable. On peut modifier l'algorithme de manière à ce que le calcul est abandonné dès que la distance minimale dépasse un seuil, disons 2, mais ceci n'est pas facile.

Norvig propose simplement de prendre le mot erroné, et d'exploiter le raisonnement de Levenshtein-Damerau pour engendrer tous les mots à distance 1. Le programme ci-dessous doit être facile à comprendre

alphabet = 'abcdefghijklmnopqrstuvwxyzàâæçéèêëîïôœùûüÿ'
def edits1(w):
 splits  = [(w[:i], w[i:]) for i in range(len(w) + 1)]
 dels    = [a + b[1:] for a, b in splits if b]
 transp  = [a + b[1] + b[0] + b[2:] for a, b in splits 
            if len(b)>1]
 repl    = [a + c + b[1:] for a, b in splits 
            for c in alphabet if b]
 insr    = [a + c + b     for a, b in splits 
            for c in alphabet]
 return set(dels + transp + repl + insr)
Alors sorted(list(edits1('mots'))) produit une liste de 378 mots...
['amots', 'aots', 'bmots', 'bots', 'cmots', 'cots', 'dmots',
..., 'motsy', 'motsz', 'motsà', 'motsâ', 'motsæ', 'motsç',...
'mîots', 'mîts', 'mïots', 'mïts', 'môots', 'môts', 'mùots',
'ûots', 'ümots', 'üots', 'ÿmots', 'ÿots', 'œmots', 'œots']
et on peut vérifier leur présence dans le dictionnaire de mots connus. On peut aussi appliquer les distorsions aux éléments de cet ensemble, et ainsi localiser les mots à distance 2. Ensuite on peut proposer des corrections ; il serait souhaitable d'avoir un dictionnaire qui contient les fréquences des mots, ainsi on pourra suggérer les corrections plus probables. Bien sûr, il n'est pas rationnel d'engendrer d'abord l'ensemble complet des mots distants de 2, et vérifier ensuite leur présence ; il faut le faire incrémentalement, afin d'économiser la mémoire.

Plusieurs variantes ont vu le jour. Par exemple, on stocke dans le dictionnaire les mots distordus (marqués comme tels ; pour ne pas exploser le dictionnaire : seulement les supressions des caractères), etc.

Aussi, on utilise des techniques considérablement plus élaborées, par ex. des structures de données probabililistes connues comme les filtres de Bloom, avec plusieurs fonctions simultanées de hachage. Un devoir de l'année dernière a été consacré à un correcteur orthographique. Il est évident que vous avez à votre disposition des dizaines de solutions implémentées dans tous les langages, et styles. Lisez l'article de Norvig (utilisé dans mon cours).

Peut-être, sans copier quoi que ce soit de cet article, je vous demanderai de tester/implémenter quelques idées algorithmiques très limitées...

(Peut-être vous travillerez sur les filtres de Bloom – extrêmement faciles à réaliser ; peut-être sur les arbres BK [Burkhard-Keller].)


La structuration phrasale des textes est un sujet aussi important (voire plus) que les raisonnements et analyses statistiques. On peut donc passer au volet suivant, les grammaires.

Les idées mijotées par Chomsky au milieu des années '50 ont subi des modifications et généralisations très profondes, mais le concept de base reste : les structures linguistiques dans nos têtes ne sont pas linéaires, forment une sorte de graphe (surtout arborescent), dont plusieurs éléments sont universels, les mêmes dans plusieurs langues naturelles... Dans une introduction superficielle on se concentre surtout sur les grammaires non-contextuelles, essentielles en L3 dans le cours de compilation.