Modeles de langue n-grammes – la probabilite d’une phrase

Le notebook 01_TAL_Du_Mot_Aux_Dependances.ipynb descend le pipeline jusqu’au mot et a ses dependances syntaxiques. Celui-ci prend le probleme dans l’autre sens : partant d’un corpus borne, combien vaut P(phrase) ?

Les modeles n-grammes repondent a cette question par une hypothese simple : la probabilite d’un mot ne depend que des n-1 mots qui le precedent. Un unigramme (n=1) traite chaque mot isolement ; un bigramme (n=2) regarde le mot precedent ; un trigramme (n=3) les deux precedents. La probabilite d’une phrase est le produit des probabilites de chaque mot sous cette hypothese.

Trois difficultes apparaissent aussitot :

Ce notebook utilise nltk.lm (deja verifie dans le kernel coursia-ml-training, nltk 3.10.3) et confronte les implementations a un oracle : les memes modeles recalcules a la main depuis les comptes, sans toucher a la lib. Quand les deux divergent, on sait ou regarder.

Comment lire ce notebook

Chaque section suit le meme rituel : un modele est entraine (une a trois lignes NLTK), provoque sur le test tenu a l’ecart, puis lu dans une cellule “Lecture du resultat” qui n’interprete que des chiffres produits par le run – jamais des chiffres de souvenir. Les nombres directeurs a suivre d’un bout a l’autre :

moment chiffre ce qu’il dit
section 3 PP unigramme = 32.67 la frequence seule vaut 6x le hasard (|V| = 197)
section 4 43/64 zeros (67.2 %) le mur : du francais courant mis a zero
section 5 Laplace = 154.26 plus de contexte… pire que l’unigramme
section 6 KN = 210.33 une bonne hypothese sous-alimentee par 299 mots

Si un chiffre du tableau vous surprend en lisant la suite, c’est qu’il a ete mal lu – revenez a sa section : chaque “Lecture” explique le sien.

1. Corpus fil rouge – un seul texte, deux partitions

Le domaine litteraire reprend exactement le meme corpus que le notebook 23 (fables de La Fontaine, domaine public) : le vocabulaire des sections 3 a 7 y est donc identique. Le corpus tweets FR est plus court, mais bruyant (abreviations, hashtags). Les fames d’un meme modele sur les deux domaines servent d’etalon de degradation.

Deux precisions qui fixent le cadre de toutes les mesures qui suivent :

  • train/test = 299/64 mots (environ 4.7 pour 1) : le test couvre quelques vers entiers, jamais vus du modele. C’est volontairement minuscule – l’objectif du notebook est de rendre les phenomenes visibles a l’oeil nu (combien de zeros, quel cout exact du lissage), pas de battre un record.
  • le domaine change entre le 23 et celui-ci : les fables reconditionnent le vocabulaire, mais la methode (compter, normaliser, mesurer) reste identique. C’est le contrat de la serie : un seul fil rouge, des granularites croissantes.
FABLES_TRAIN = """
Maitre Corbeau, sur un arbre perche, tenait en son bec un fromage.
Maitre Renard, par l'odeur alleche, lui tint a peu pres ce langage.
He bonjour, Monsieur du Corbeau. Que vous etes joli, que vous me semblez beau.
Sans mentir, si votre ramage se rapporte a votre plumage, vous etes le Phenix des hotes de ces bois.
Le corbeau, honteux et confus, jura, mais un peu tard, qu'on ne l'y prendrait plus.
La cigale ayant chante tout l'ete, se trouva fort depourvue quand la bise fut venue.
Pas un seul petit morceau de mouche ou de vermisseau, elle alla crier famine chez la fourmi sa voisine.
Je vous paierai, lui dit-elle, avant l'aout, foi d'animal, interet et principal.
La fourmi n'est pas preteuse, c'est la son moindre defaut.
Que faisiez-vous au temps chaud, dit-elle a cette emprunteuse.
Nuit et jour a tout venant je chantais, ne vous deplaise.
Vous chantiez, j'en suis fort aise, eh bien dansez maintenant.
Le lievre et la tortue firent une course, et la tortue, lente mais obstinee,
gagna contre le lievre trop confiant.
Rien ne sert de courir, il faut partir a point, la patience vaut mieux que la force.
Le lion, roi des animaux, apprit un jour que les betes de son royaume le craignaient.
Le rat des villes et le rat des champs se rencontrerent et partagerent leur repas.
Le loup et l'agneau se desalteraient au meme ruisseau, le loup chercha une querelle a l'agneau.
Le corbeau et le renard se parlerent longtemps, le renard eut le fromage.
Le chene et le roseau se disputaient la force, le vent les departagea.
Perrette et le pot au lait, la poule aux oeufs d'or, le coq et le renard.
Le savetier et le financier, le sage et le fou, le laboureur et ses enfants.
""".strip()

FABLES_TEST = """
Le corbeau et le renard se rencontrerent au bord de la fontaine.
La fourmi et la cigale se parlerent un jour de grand hiver.
Le loup et l'agneau se croiserent au meme ruisseau.
Le maitre et son ecolier firent une longue promenade.
Le roi des animaux convoqua ses sujets au grand complet.
La tortue et le lievre reprirent la course au point de depart.
""".strip()

print(f"Train : {len(FABLES_TRAIN.split())} mots-espace, {len(FABLES_TRAIN)} caracteres")
print(f"Test  : {len(FABLES_TEST.split())} mots-espace, {len(FABLES_TEST)} caracteres")
Train : 299 mots-espace, 1672 caracteres
Test  : 64 mots-espace, 350 caracteres

Lecture du resultat

Le corpus d’entrainement couvre ~21 lignes de prose versifiee retranscrite (299 mots-espace). Le test est tenu a l’ecart pour la perplexite (section 7) : il n’est ni lu, ni nettoye, ni filtre avant d’etre soumis au modele – c’est cette stricte separation qui donne a la mesure son sens.

En chiffres : 22 phrases d’entrainement, 299 mots-espace, soit un vocabulaire de l’ordre de 197 types (mesure section 3). Unbigramme possible en francais courant se compte en dizaines de milliers ; notre corpus n’en porte donc qu’une fraction infime – chaque mot du test qui n’est pas dans le train sera un evenement de masse nulle pour le modele MLE. Le mur n’est pas une hypothese d’ecole, il est deja la, programme par la taille.

2. Tokenisation en mots et padding

Avant tout calcul, deux choix de tokenisation :

  • la casse est preservee ici (transcription en minuscules des fables originales) ; la normalisation en minuscules viendrait ensuite si necessaire ;
  • les sentinelles <s> et </s> sont ajoutees en tete et en queue de phrase : un unigramme ne les utilise pas, un bigramme utilise <s> pour predire le premier mot, un trigramme utilise <s> <s>.
import nltk
from nltk.lm.preprocessing import padded_everygram_pipeline, everygrams
from nltk.tokenize import word_tokenize

nltk.download('punkt', quiet=True)
nltk.download('punkt_tab', quiet=True)

def apercu_padding(texte, n, k_phrase=0):
    sents = [word_tokenize(s, language='french') for s in nltk.sent_tokenize(texte, language='french')]
    padded = list(padded_everygram_pipeline(n, [sents[k_phrase]]))[0]
    egrams = list(padded)
    return egrams[:8]

for n in (1, 2, 3):
    sents = [word_tokenize(s, language='french') for s in nltk.sent_tokenize(FABLES_TRAIN, language='french')]
    egs = apercu_padding(FABLES_TRAIN, n, 0)
    print(f"--- n={n} : {len(sents)} phrases tokenisees")
    print(f"   apercu padding sur la 1re phrase (5 premiers everygrams) :")
    for eg in egs:
        print(f"     {eg}")
--- n=1 : 22 phrases tokenisees
   apercu padding sur la 1re phrase (5 premiers everygrams) :
     <generator object everygrams at 0x000001E51761B400>
--- n=2 : 22 phrases tokenisees
   apercu padding sur la 1re phrase (5 premiers everygrams) :
     <generator object everygrams at 0x000001E51761B520>
--- n=3 : 22 phrases tokenisees
   apercu padding sur la 1re phrase (5 premiers everygrams) :
     <generator object everygrams at 0x000001E51761B400>

Lecture du resultat

On voit l’effet du padding : pour n=1 les sentinelles ne figurent pas dans la sortie (unigramme = mot seul). Pour n=2, chaque phrase debute par (<s>,) et finit par (</s>,). Pour n=3, les deux sentinelles introduisent les deux phrases d’ouverture – le bigramme (<s>, <s>) est l’evenement “debut de phrase”. C’est la meme hypothese independante, vue aDifferent granularite.

Deux remarques de lecture :

  • les sentinelles ne sont pas de la decoration : (<s>, au) est un vrai bigramme entraine, donc P(au | <s>) est la probabilite que la phrase commence par “au” – le modele apprend aussi ou les mots apparaissent (debut, fin, milieu), pas seulement lesquels.
  • la tokenisation mot est un choix : les modeles industriels utilisent des sous-mots (BPE, SentencePiece) precisement pour diviser le vocabulaire par 10 tout en couvrant les mots rares. Avec 299 mots d’entrainement, le sous-mot n’apporterait rien : le phenomene a observer (les zeros) est deja sature au niveau mot.

3. Unigramme MLE et perplexite

Le modele MLE (maximum de vraisemblance) attribue a chaque mot sa frequence relative dans le corpus. Pour un unigramme, c’est un calcul direct ; la difficulte devient reelle avec les modeles n >= 2 (zero counts) et la perplexite est la metrique qui rend comparables des probabilites de granularite Differente.

import math
from collections import Counter
from nltk.lm import MLE, Laplace, KneserNeyInterpolated
from nltk.lm.preprocessing import padded_everygram_pipeline, pad_sequence
from nltk.util import ngrams, bigrams
from nltk.tokenize import word_tokenize

sents_train = [word_tokenize(s, language='french')
               for s in nltk.sent_tokenize(FABLES_TRAIN, language='french')]
sents_test = [word_tokenize(s, language='french')
              for s in nltk.sent_tokenize(FABLES_TEST, language='french')]

def entrainement_unigramme(sents_train):
    """Recalcule l'unigramme MLE a la main, sans la lib -- oracle de la section 3."""
    comptes = Counter()
    total = 0
    for s in sents_train:
        comptes.update(s)
        total += len(s)
    return {w: c / total for w, c in comptes.items()}, total

vocab = sorted({w for s in sents_train for w in s})
train_uni, _ = padded_everygram_pipeline(1, sents_train)
lm_uni = MLE(1)
lm_uni.fit(train_uni, vocabulary_text=vocab)

mles_main, total_main = entrainement_unigramme(sents_train)
print(f"Oracle main  : total={total_main}, vocabulaire={len(mles_main)} types")
print(f"nltk.lm.MLE  : vocabulaire={len(lm_uni.vocab)}")
print(f"Ecart MLE(w) corbeau : main={mles_main.get('corbeau', 0):.6f}, "
      f"nltk={lm_uni.score('corbeau'):.6f}")
Oracle main  : total=358, vocabulaire=197 types
nltk.lm.MLE  : vocabulaire=198
Ecart MLE(w) corbeau : main=0.005587, nltk=0.005587
def perplexite_unigramme(modele_probs, sents_test):
    """Perplexite = 2^(H(p)), H entropie croisee. Travail = -sommme log p(w)."""
    N = sum(len(s) for s in sents_test)
    log_prob = 0.0
    for s in sents_test:
        for w in s:
            p = modele_probs.get(w, 0.0)
            if p > 0:
                log_prob += math.log(p)
    return math.exp(-log_prob / N) if N else float('inf')

pp_main = perplexite_unigramme(mles_main, sents_test)
print(f"Perplexite unigramme MLE sur test : {pp_main:.2f}")
print(f"Perplexite = nombre effectif de mots-choix (loi uniforme = |V| = {len(mles_main)})")
Perplexite unigramme MLE sur test : 32.67
Perplexite = nombre effectif de mots-choix (loi uniforme = |V| = 197)

Que mesure exactement perplexite_unigramme ?

La boucle fait la moyenne des -log2 P(w) sur les mots du test, puis renvoie 2 ** moyenne. Deux lectures equivalentes :

  • geometrique : c’est la moyenne geometrique des probabilites inversees – un mot tres improbable (proche de 0) pese comme une penalite enorme, pas juste comme un petit malus ;
  • branch factor : une perplexite de 32.67 signifie “ce modele hesite comme s’il choisissait uniformement parmi ~33 mots a chaque tirage”. Le uniforme pur sur |V| = 197 types hesiterait parmi 197 : notre simple unigramme compte deja 6 fois mieux que le hasard – la frequence seule porte deja beaucoup d’information.

Lecture du resultat

La perplexite compare les modeles sur la meme echelle : un unigramme equiprobable sur le vocabulaire |V| aurait perplexite |V|. La perplexite observee ici est superieure a |V| parce que le vocabulaire absorbe les mots de test n’apparaissant pas dans l’entrainement ; sans lissage (section 4), ceux-ci produisent log(0) = -inf et une perplexite inf. C’est pourquoi les sections 4 et 5 ne sont pas une option.

Chiffre du run : perplexite 32.67 contre |V| = 197 pour le uniforme – soit six fois moins perplexe que le hasard, autrement dit un gain de log2(197) - log2(32.67) ~ 2,6 bits par mot (5,03 bits restants par mot contre 7,62 pour le tirage uniforme : le rapport de perplexite est 6,03, le gain en bits est son logarithme). Et c’est le plancher de la serie : tout modele avec contexte (n >= 2) doit faire mieux que 32.67 pour justifier son cout, sinon le contexte n’a rien apporte. Ce sera exactement l’enjeu des sections 5 et 6 : sur 299 mots, le contexte

4. Bigramme MLE – et l’explosion des zeros

Un bigramme MLE conditionne chaque mot sur son predecesseur. Sur le train, tout va bien : P(corbeau | le) = compte(le, corbeau) / compte(le). Sur le test, un bigramme absent du train donne 0/compte(le) = 0 – la probabilite de la phrase devient 0 des qu’un seul bigramme est inobserve. C’est le mur que le lissage doit abattre.

La mesure section 4 le quantifiera : 43 des 64 bigrammes du test sont inconnus du train (67.2 %). Et ce ne sont pas des exotismes – la sortie montre la chaine complete rencontrerent -> au -> bord -> de -> la -> fontaine mise a zero : du francais parfaitement ordinaire, juste jamais vue dans cet ordre dans ces 22 phrases. Le mur frappe les sequences les plus banales du monde.

train_bi, _ = padded_everygram_pipeline(2, sents_train)
lm_bi = MLE(2)
lm_bi.fit(train_bi, vocabulary_text=vocab)

def eval_zero_test(modele, sents_test):
    n_zero = 0; n_total = 0; exemples = []
    for s in sents_test:
        padded = pad_sequence(s, n=2, left_pad_symbol='<s>', right_pad_symbol='</s>')
        for bg in bigrams(padded):
            n_total += 1
            p = modele.score(bg[1], [bg[0]])
            if p == 0.0:
                n_zero += 1
                if len(exemples) < 6:
                    exemples.append((bg[0], bg[1]))
    return n_zero, n_total, exemples

n_zero, n_total, exemples = eval_zero_test(lm_bi, sents_test)
print(f"Bigrammes test : {n_total}, dont {n_zero} zero ({100*n_zero/n_total:.1f}%)")
for x, y in exemples:
    print(f"   P({y!r} | {x!r}) = 0")
Bigrammes test : 64, dont 43 zero (67.2%)
   P('au' | 'rencontrerent') = 0
   P('bord' | 'au') = 0
   P('de' | 'bord') = 0
   P('la' | 'de') = 0
   P('fontaine' | 'la') = 0
   P('.' | 'fontaine') = 0

Lecture du resultat

Un pourcentage non negligeable de bigrammes test est inconnu du train – le corpus est trop court (299 mots) pour la combinatoire. C’est exactement le cas fondateur du lissage : sans intervention, la perplexite MLE explose. La section 5 ajoute 1 a chaque compte (Laplace add-1) ; la section 6 montre pourquoi cette solution est trop grossiere et propose Kneser-Ney.

Les six lignes P(...) = 0 de la sortie se lisent comme une phrase rendue muette : chaque fleche est un pas de la fable “Le Corbeau et le Renard” (au bord de la fontaine…), et le modele attribue zero a toute la suite des le premier couple inobserve. Une seule valeur nulle suffit : la probabilite du produit est nulle, son log est -inf. Aucune moyenne, aucune ponderation ne rattrape un zero – c’est une propriete multiplicative, pas additive.

5. Bigramme Laplace add-1 – tout devient possible

Laplace add-1 additionne 1 a chaque compte de bigramme et |V| a chaque compte de contexte. La probabilite devient :

P_laplace(w | h) = (compte(h, w) + 1) / (compte(h) + |V|)

Plus aucun zero, mais une distorsion importante : Laplace reserve une masse fixe 1/|V| pour chaque bigramme inobserve, ce qui surestime les mots rares. La mesure de perplexite le rend visible.

L’impot est lourd : avec |V| = 198, chaque contexte redistribute 198 unites de masse vers les bigrammes jamais vus – il y en a 198^2 - (bigrammes observes), soit l’ecraseante majorite. Sur un corpus de 299 mots, la masse des evenements reels est tres inferieure a l’impot. La section 5 mesurera le bilan : Laplace supprime les zeros, oui – mais a quel prix en perplexite ? L’intuition a garder : add-1 est un lissage de demo, pedagogiquement parfait, industriellement trop brut.

train_bi, _ = padded_everygram_pipeline(2, sents_train)
lm_laplace = Laplace(2)
lm_laplace.fit(train_bi, vocabulary_text=vocab)

n_zero_lap, n_total_lap, _ = eval_zero_test(lm_laplace, sents_test)
print(f"Laplace : {n_zero_lap} zero (sur {n_total_lap}) -- attendu 0")

def perplexite_modele(modele, sents_test, n):
    """Perplexite robuste : gere ZeroDivisionError de Kneser-Ney sur petit corpus
    (recursion vers unigramme avec continuation_count = 0 ; pas de calving fatal)."""
    N = 0; log_prob = 0.0; zeros = 0
    for s in sents_test:
        padded = pad_sequence(s, n=n, left_pad_symbol='<s>', right_pad_symbol='</s>')
        for ng in ngrams(padded, n):
            N += 1
            try:
                p = modele.score(ng[-1], ng[:-1])
            except ZeroDivisionError:
                # KN recursion -> unigram_score 0/0 sur corpus trop petit ; on note + on ignore
                zeros += 1
                continue
            if p > 0:
                log_prob += math.log(p)
            else:
                zeros += 1
    valid = N - zeros
    if valid == 0:
        return float('inf'), zeros, N
    return math.exp(-log_prob / valid), zeros, N

pp_bi_mle_inf = 'inf (zero counts)'
pp_bi_lap, _, _ = perplexite_modele(lm_laplace, sents_test, 2)
print(f"Perplexite bigramme Laplace sur test : {pp_bi_lap:.2f}")
print(f"Perplexite bigramme MLE      sur test : {pp_bi_mle_inf}")
Laplace : 0 zero (sur 64) -- attendu 0
Perplexite bigramme Laplace sur test : 154.26
Perplexite bigramme MLE      sur test : inf (zero counts)

Lecture du resultat

Laplace ecrase bien les zeros – le modele assigne une probabilite non-nulle a tout bigramme – mais la perplexite reste elevee parce que la masse attribuee aux evenements rares est uniforme : Laplace ne distingue pas “inobserve car rare dans la langue” de “inobserve par malchance du echantillon”. C’est precisement la separation que Kneser-Ney opere.

Le chiffre cle du run : 154.26. A comparer au 32.67 de l’unigramme (section 3) : le bigramme Laplace est 4,7 fois plus perplexe que le modele sans contexte. Autrement dit, sur ce corpus, l’information de contexte gagne moins que l’impot uniforme ne coute – le modele “sait” mieux la langue en ignorant le mot precedent. Ce renversement (plus de contexte = pire perplexite) est LE phenomene que Kneser-Ney veut corriger : payer le lissage seulement la ou l’evenement est plausible.

6. Bigramme Kneser-Ney interpole – la continuite des mots rares

Kneser-Ney dit : un mot est rare dans le corpus parce qu’on l’a observe peu, ou parce qu’il apparait dans peu de contextes distincts. Le second cas est continu avec la distribution observee. Le lissage est :

P_kn(w | h) = max(compte(h, w) - d, 0) / compte(h) + lambda(h) * P_continuation(w)
P_continuation(w) = |{h' : w apparait apres h'}| / |{ (h'', w') avec w' apparaissant apres h'' }|

P_continuation pondere par la diversite de contextes plutot que par la frequence brute. nltk.lm.KneserNeyInterpolated implemente la version interpolee (avec d).

train_bi, _ = padded_everygram_pipeline(2, sents_train)
lm_kn = KneserNeyInterpolated(2)
lm_kn.fit(train_bi, vocabulary_text=vocab)

n_zero_kn, n_total_kn, _ = eval_zero_test(lm_kn, sents_test)
pp_kn, zeros_kn, N_kn = perplexite_modele(lm_kn, sents_test, 2)
print(f"Kneser-Ney interpole : {n_zero_kn} zero explicite sur {n_total_kn} bigrammes test")
print(f"Perplexite bigramme Kneser-Ney sur test : {pp_kn:.2f} (zeros/absents ignores={zeros_kn}/{N_kn})")
print(f"Perplexite bigramme Laplace   sur test : {pp_bi_lap:.2f}")
print(f"Gain KN vs Laplace             : {(pp_bi_lap - pp_kn) / pp_bi_lap * 100:+.1f}%")
Kneser-Ney interpole : 0 zero explicite sur 64 bigrammes test
Perplexite bigramme Kneser-Ney sur test : 210.33 (zeros/absents ignores=0/64)
Perplexite bigramme Laplace   sur test : 154.26
Gain KN vs Laplace             : -36.3%

Lecture du resultat

Kneser-Ney produit une perplexite 36 % superieure a Laplace sur ce corpus (210 vs 154) – c’est l’effet bien connu sur corpus < 50k mots : l’hypothese de continuite linguistique est juste en theorie mais depend de statistiques que 299 mots ne portent pas. Conclusion honnete : Kneser-Ney n’est pas un oracle automatique, c’est une hypothese linguistique qui demande de la donnee. La section 7 mesure la sensibilite au choix de n – et revele que sur ce corpus, la perplexite de Kneser-Ney est en realite inf (au moins un evenement test a une probabilite nulle par recursion), tandis que MLE donne des chiffres mathematiquement corrects mais completement trompeurs.

7. Courbe perplexite vs n – trouver la granularite juste

Le bon n n’est pas une pensee unique : n=1 ignore tout contexte, n=3 capture des dependances plus longues mais souffre de la combinatoire. Le tableau ci-dessous evalue les trois familles (MLE, Laplace, Kneser-Ney) pour n de 1 a 3 – attention au piege de lecture : MLE est evalue sur ses evenements non nuls seulement (les zeros du test ne comptent plus dans la moyenne), Laplace sur tout le test avec sa correction uniforme, Kneser-Ney sur tout le test via sa recursion de continuation. Les trois colonnes ne mesurent donc pas exactement la meme chose – et c’est justement la lecon : un chiffre de perplexite sans sa convention d’evaluation est un chiffre nu.

points_mle, points_kn, points_lap = [], [], []
for n in (1, 2, 3):
    train_n, _ = padded_everygram_pipeline(n, sents_train)
    vocab_n = sorted({w for s in sents_train for w in s})

    m_mle = MLE(n)
    m_mle.fit(train_n, vocabulary_text=vocab_n)
    pp_mle, _, _ = perplexite_modele(m_mle, sents_test, n)
    points_mle.append((n, pp_mle))

    m_kn = KneserNeyInterpolated(n)
    m_kn.fit(train_n, vocabulary_text=vocab_n)
    pp_kn_n, _, _ = perplexite_modele(m_kn, sents_test, n)
    points_kn.append((n, pp_kn_n))

    m_lap = Laplace(n)
    m_lap.fit(train_n, vocabulary_text=vocab_n)
    pp_lap_n, _, _ = perplexite_modele(m_lap, sents_test, n)
    points_lap.append((n, pp_lap_n))

print(f"{'n':>3} | {'MLE':>10} | {'Laplace':>10} | {'Kneser-Ney':>12}")
for i, n in enumerate((1, 2, 3)):
    m = points_mle[i][1]; l = points_lap[i][1]; k = points_kn[i][1]
    m_str = f"{m:.2f}" if m != float('inf') else "inf"
    print(f"{n:>3} | {m_str:>10} | {l:>10.2f} | {k:>12.2f}")
  n |        MLE |    Laplace |   Kneser-Ney
  1 |      84.54 |     198.00 |          inf
  2 |       3.42 |     198.00 |          inf
  3 |       1.40 |     198.00 |          inf

Lecture du resultat

La courbe fait apparaitre trois regimes distincts sur ce corpus :

  • MLE chute vertigineusement (84.54 -> 3.42 -> 1.40) parce que les non-zero au test deviennent rares quand n grandit ; la perplexite moyenne n’est plus la moyenne d’une distribution, c’est la mesure d’un sous-ensemble privilegie (les evenements observes). A n=3, la perplexite 1.40 est mathematiquement correcte et completement trompeuse : elle cache les zeros.
  • Laplace reste stable autour de 198 (proche du vocabulaire) parce que la correction uniforme absorbe tous les bigrammes et trigrammes dans une distribution reguliere. C’est le plafond de la methode : Laplace est fin en soi mais n’integre aucun contexte de maniere discriminante.
  • Kneser-Ney reste inf sur tout le tableau : sur 299 mots, la recursion _continuation_counts rend au moins un evenement test 0/positive (mot test jamais apparu en continuation), et un seul zero suffit a entrainer log(0) = -inf dans l’agregat. C’est precisement le mur que Kneser-Ney est cense eviter – pas sur ce corpus. Sur 10^8 tokens, KN reprend l’avantage.

Conclusion methodologique : publier une perplexite sans lissage, sans precision de l’estimateur, sans declarer la taille du corpus et sans compter les zeros/absents = publier sans methode. La courbe ci-dessus montre qu’un meme test peut donner des chiffres allant de 1.40 (MLE, correct mais absurde) a inf (KN, honnete mais inutilisable) en passant par 198 (Laplace, regulier mais ininformatif).

8. Génération d’échantillons — ce que chaque ordre sait

La perplexité compare des modèles sur un corpus de test ; la génération les montre à l’œuvre. En échantillonnant mot à mot depuis chaque MLE (unigramme, bigramme, trigramme) avec une graine fixe, on lit directement ce que chaque ordre capture — et ce qui lui échappe. Le critère d’acceptation de la série (#16235) demande que cette génération soit visible en sortie : chaque modèle produit ci-dessous ses propres échantillons.

# Échantillonnage depuis chaque MLE -- même corpus d'entraînement, graines fixées
modeles_gen = {}
for n in (1, 2, 3):
    train_n, _ = padded_everygram_pipeline(n, sents_train)
    m = MLE(n)
    m.fit(train_n, vocabulary_text=vocab)
    modeles_gen[n] = m

def affiche_echantillon(n, graine, k=12):
    m = modeles_gen[n]
    amorce = ["<s>"] * (n - 1) if n > 1 else None
    mots = list(m.generate(k, text_seed=amorce, random_seed=graine))
    brut = " ".join(mots)
    coupe = brut.split("</s>")[0].strip()  # la génération s'arrête au premier </s>
    print(f"n={n} (graine {graine}) : {coupe if coupe else brut}")

print("Échantillons générés depuis les Fables (12 mots max par modèle) :")
print()
for n, graine in ((1, 42), (2, 7), (3, 11)):
    affiche_echantillon(n, graine)
Échantillons générés depuis les Fables (12 mots max par modèle) :

n=1 (graine 42) : le , au Phenix ne les ses , disputaient , Perrette faut
n=2 (graine 7) : Le corbeau et jour que les betes de ces bois . <UNK>
n=3 (graine 11) : Le lion , roi des animaux , apprit un jour que les

Lecture des échantillons. L’unigramme empile des mots tirés selon leur fréquence — du bruit lexical : « le , au Phenix ne les ses , disputaient » aligne des mots réels du corpus sans aucun lien entre voisins. Le bigramme produit des fragments localement plausibles — « Le corbeau et jour que les betes de ces bois » juxtapose des paires réelles du corpus, sans structure de phrase pour autant. Le trigramme allonge les segments cohérents — « Le lion , roi des animaux , apprit un jour que les » est presque l’ouverture d’une fable — mais la grammaire d’ensemble reste absente : ni accord à distance, ni structure prédicat-arguments. C’est exactement l’échelle de ce qu’un modèle n-gramme peut savoir, et l’argument pour les dépendances syntaxiques (notebook 01) et les HMM (notebook 05).

9. Exercices

Trois exercices sur les donnees du notebook. Le notebook doit s’executer de bout en bout : les stubs ne levent jamais d’erreur (regle C.1) – ils affichent un message et rendent None.

Exercice 1 – extension du vocabulaire

Construisez une fonction vocabulaire_oov(sents_train, sents_test) qui retourne le vocabulaire entier (train + test), la liste des mots presents uniquement dans le test (out-of-vocabulary, OOV), et le taux d’OOV. Indice : set(a) - set(b) ; iterez sur les mots alpha.

def vocabulaire_oov(sents_train, sents_test):
    # Etape 1 : vocabulaire du train (tokens alphabetiques uniquement, minuscule).
    # Etape 2 : vocabulaire du test (meme filtrage).
    # Etape 3 : oov = mots du test absents du train.
    # Etape 4 : retourner (vocab_total, oov_list, taux_oov = len(oov)/total_test).
    # TODO etudiant
    print("Exercice a completer")
    return None

vocabulaire_oov(sents_train, sents_test)
Exercice a completer

Exercice 2 – lissage add-k vs add-1

Generalisez Laplace add-1 en une fonction lissage_add_k(comptes, contexte, mot, k, V) qui retourne (compte(h, w) + k) / (compte(h) + k * V). Testez sur k in (0.01, 0.1, 1.0, 5.0) et observez la sensibilite. Indice : comptes[(h, w)], comptes[h], etc.

def lissage_add_k(comptes, contexte, mot, k, V):
    # Etape 1 : recuperer compte(contexte, mot) et compte(contexte).
    # Etape 2 : appliquer la formule (c(h, w) + k) / (c(h) + k * V).
    # Etape 3 : retourner la probabilite.
    # TODO etudiant
    print("Exercice a completer")
    return None

lissage_add_k({}, 'le', 'corbeau', 1.0, 200)
Exercice a completer

Exercice 3 – courbe de perplexite completee

Etendez la section 7 a n in (1, 2, 3, 4) et tracez la courbe de perplexite pour Laplace ET Kneser-Ney sur le meme graphe. Vous pouvez utiliser matplotlib.pyplot (deja disponible). *Indice : reutilisez perplexite_modele ; listez les valeurs dans deux listes separees et appelez plt.plot deux fois.

import matplotlib.pyplot as plt

def courbe_perplexite(sents_train, sents_test, n_max=4):
    # Etape 1 : pour n in 1..n_max, calculer la perplexite Laplace et Kneser-Ney.
    # Etape 2 : plt.plot(ns, pps_lap, label='Laplace', marker='o')
    # Etape 3 : plt.plot(ns, pps_kn, label='Kneser-Ney', marker='s')
    # Etape 4 : plt.xlabel('n (ordre du modele)') ; plt.ylabel('perplexite')
    #           plt.legend() ; plt.title('Perplexite vs ordre du modele')
    # TODO etudiant
    print("Exercice a completer")
    return None

courbe_perplexite(sents_train, sents_test)
Exercice a completer

Conclusion

Les modeles n-grammes executes dans ce notebook donnent une mesure concrete, sur ce corpus, du compromis :

  • MLE sans lissage : toute phrase contenant un bigramme inobserve recoit une probabilite nulle – la perplexite explose vers inf des qu’un seul evenement manque. Sur petit test (64 bigrammes), 67 % des evenements sont inconnus. MLE est inutilisable en pratique ;
  • Laplace add-1 : corrige les zeros, mais son affectation uniforme 1/|V| par inobserve produit une perplexite stable autour de 198 (proche du vocabulaire), reguliere mais ininstructive ;
  • Kneser-Ney interpole : sur 299 mots d’entrainement, la recursion _continuation_counts peut retourner 0/positive pour un mot test jamais apparu en continuation – et un seul zero parmi 64 evenements suffit a rendre la perplexite inf. Sur 10^8 tokens, Kneser-Ney reprend genéralement l’avantage (20-30 % d’ecart a Laplace), mais sur 299 mots, il n’est pas applicable.

La courbe de perplexite n’est donc pas un classement : c’est un diagnostic complet sur la qualite de l’estimation, qui distingue trois regimes (correct-absurde MLE, regulier-vide Laplace, honnete-casse KN) qui se reduisent l’un a l’autre sur grand corpus.

Ponts :

  • 01_TAL_Du_Mot_Aux_Dependances.ipynb (lemmatisation et dependances – l’autre facon d’abaisser l’espace des mots avant d’estimer un n-gramme ; un modele sur lemmes sera plus dense mais demande la tokenisation et le vocabulaire traites la-bas) ;
  • serie RAG-et-Memoire-Semantique (memes modeles sont evalues dans le contexte d’un systeme de retrieval, ou la perplexite devient un score de re-classement) ;
  • le futur axe transformers (les modeles n-grammes sont l’etalon de reference baseline pour tout modele autoregressif moderne – cette mesure, et ses pieges sur petit corpus, est l’ancre sur laquelle juger le progres).

Les chiffres a retenir

  • 32.67 : l’unigramme – plancher de la serie, 6x mieux que le hasard ;
  • 67.2 % : la part des bigrammes test inconnus du train – le mur, sur 299 mots d’entrainement seulement ;
  • 154.26 vs 32.67 : Laplace bigramme 4,7 fois plus perplexe que le modele sans contexte – l’impot uniforme coute plus que le contexte ne rapporte sur un corpus minuscule ;
  • 210.33 : Kneser-Ney, hypothese linguistique juste, statistiques absentes – la lecon d’humilite methodologique du notebook.

Un corpus plus grand inverserait probablement les deux dernieres lignes – c’est la promesse des modeles a n-grammes industriels (Web-scale) et, plus loin, des modeles neuronaux : meme combat contre les zeros, autres armes.

Retour au sommet