Skip to content
The loss curve

Chapitre 2 · 15 min

Compter ne suffit pas

Ajoute un jeu de validation, du lissage et un vrai score à ton bigramme local au lieu de laisser les paires inconnues casser la génération.

Au chapitre 1, tu as écrit une boucle generate qui s’arrêtait sur les “impasses” : des dont la ligne dans la table de comptage était entièrement à zéro. Ce n’est pas seulement un problème d’interface. Le modèle répond honnêtement à “qu’est-ce qui vient après X ?” par : je n’en ai aucune idée, la ligne est vide.

Cette réponse passe pour un chapitre. Elle ne passe pas à l’échelle. En pratique, un modèle ne peut pas abandonner dès qu’il voit une paire inconnue : il doit assigner une probabilité à chaque prochain mot possible, même jamais vu dans ce contexte. Le nom général de ce geste est .

On va le construire depuis zéro. Même type de que le chapitre précédent, mais assez grand pour le couper en /. Dans le navigateur, tu verras ce que fait le ; dans ton repo my-llm/, tu transformeras la baseline en modèle évaluable.

1. Regarder le problème en face

Prends une ligne de la table de comptage : celle du qui a le plus de continuations. Beaucoup de cellules seront positives ; certaines seront exactement à zéro.

La cellule transforme les comptages en conditionnelle : chaque cellule divisée par la somme de la ligne.

Code · JavaScript

Les barres couleur loss sont les zéros. Pour le modèle, ces continuations ont une probabilité nulle. Si l’une apparaît dans le , la devient mathématiquement infinie : log(0) est indéfini.

Voilà tout le problème. Le sert à contourner cette falaise.

2. Ajouter un peu partout : Laplace

La correction la plus simple existe depuis le XVIIIe siècle. Prends chaque cellule de la table — y compris les zéros — et ajoute une petite constante α. Puis renormalise :

Pα(wtwt1)=C(wt1,wt)+αC(wt1)+αVP_\alpha(w_t \mid w_{t-1}) = \frac{C(w_{t-1}, w_t) + \alpha}{C(w_{t-1}) + \alpha\,V}

VV est la taille du . Avec α = 1, c’est le “add-one”. En pratique, des valeurs plus petites fonctionnent souvent mieux.

La cellule écrit cette formule comme une fonction. Elle reçoit (count, rowSum, alpha, V), retourne une probabilité, et la visualisation affiche la même ligne qu’avant avec un slider α.

Code · JavaScript

Fais glisser α de 0.001 à 2. Deux choses arrivent :

  • Les zéros deviennent positifs. Le modèle n’est plus surpris par rien.
  • Les barres non nulles rétrécissent. Le modèle est aussi moins confiant sur ce qu’il avait vu.

C’est le coût du : trop en ajouter revient à dire que tous les mots sont presque également probables.

3. Trouver le bon compromis : perplexité vs α

Si α = 0 fait exploser le modèle sur les paires inconnues, et si α énorme rend le modèle uniforme, il existe un meilleur compromis au milieu. La méthode standard : calculer la de pour plusieurs α et choisir le point le plus bas.

La cellule calcule la à partir de transitions de , d’un alpha et de la taille du V. Le script inclut directement la fonction laplaceProbability de la cellule précédente.

Code · JavaScript

Tu devrais voir une courbe en U. À gauche, α est trop petit : une paire rare suffit à exploser la . À droite, α est trop grand : le modèle dépense sa masse de probabilité sur des continuations plausibles et absurdes à parts presque égales.

4. Une famille plus maligne : Kneser-Ney

La paresse de , c’est de traiter toutes les paires inconnues pareil. Mais un mot comme Francisco devrait surtout suivre San, pas n’importe quel contexte.

Le est un membre plus intelligent de la même famille :

  • Soustraire une petite constante d à chaque comptage observé : le discount.
  • Redistribuer cette masse via une de repli. Le vrai utilise une table de continuation ; ici on prend 1/V pour rester lisible.

Lance la formule. Même forme que , avec l’argument supplémentaire distinctContexts.

Code · JavaScript

On balaie d et on superpose le résultat à la courbe . Sur un vrai , l’écart compte ; sur ce jouet, il est subtil. Le point important : “” désigne une famille de choix, pas une seule astuce.

5. Améliorer ta baseline locale

Ouvre llm/bigram.py et ajoute une fonction de probabilité lissée :

def laplace_probability(
    model: dict[str, Counter[str]],
    previous: str,
    token: str,
    vocab: set[str],
    alpha: float = 0.1,
) -> float:
    # [1]
    row = model.get(previous, Counter())
    # [2]
    count = row[token]
    # [3]
    total = sum(row.values())
    # [4]
    return (count + alpha) / (total + alpha * len(vocab))

Lis directement la formule dans le code :

  • [1] row est tout ce que le modèle a vu après previous.
  • [2] count est la paire que tu scores : previous -> token.
  • [3] total est la somme de la ligne.
  • [4] donne à chaque possible une masse alpha, puis renormalise.

Crée ensuite scripts/eval_bigram.py :

from __future__ import annotations
 
import math
 
from llm.bigram import laplace_probability, tokenize, train
 
TEXT = "the cat sat on the mat the dog sat on the rug the cat watched the dog the dog watched the cat"
 
tokens = tokenize(TEXT)
# [1]
split = int(len(tokens) * 0.8)
train_tokens = tokens[:split]
val_tokens = tokens[split:]
 
# [2]
model = train(train_tokens)
vocab = set(tokens)
 
loss = 0.0
n = 0
# [3]
for previous, token in zip(val_tokens, val_tokens[1:]):
    p = laplace_probability(model, previous, token, vocab, alpha=0.1)
    # [4]
    loss -= math.log(p)
    n += 1
 
# [5]
print(f"validation perplexity: {math.exp(loss / n):.2f}")

Ce script force le modèle à répondre sur du texte qu’il n’a pas vu :

  • [1] crée un split /.
  • [2] seulement sur train_tokens.
  • [3] parcourt chaque paire de .
  • [4] accumule la next-.
  • [5] transforme la moyenne en .

Lance-le :

python -m scripts.eval_bigram
python -m scripts.eval_bigram
python -m scripts.eval_bigram

Le nombre n’est pas impressionnant. C’est le but : tu as maintenant une baseline mesurable à battre.

Pourquoi tout ce chapitre ressemble à un patch

Parce que c’en est un.

Le est ce qu’on fait quand on a décidé de modéliser le langage avec une table de comptage. Le vrai problème n’est pas qu’il faudrait un meilleur lisseur : c’est que le modèle n’a aucune mémoire au-delà d’un mot. Il ne sait pas que la phrase parle de chats, ni que the est souvent suivi d’un nom.

Les prochains chapitres remplacent la table par quelque chose qui peut généraliser : des mots similaires doivent se comporter de façon similaire, même dans des contextes jamais vus. Cette idée a un nom : . On y arrive au chapitre 4. Avant, il faut apprendre à découper l’entrée correctement.

Recap

  • Le remplace des zéros durs par des probabilités finies. Sans lui, une seule paire inconnue met toute la séquence à probabilité zéro. - add-α ajoute une constante à chaque cellule et renormalise. - La est la moyenne géométrique de l’inverse des probabilités, calculée via log-sum. - retire de la masse aux paires vues et la redistribue plus intelligemment. - Ton modèle local a maintenant un score. Tu peux améliorer un nombre, pas seulement regarder des . - Rien de tout ça ne règle le vrai problème des : ils ne voient qu’un en arrière.

Pour aller plus loin

Prochaine étape : entraîner tes propres tokens — et si the cat et thecat devaient partager quelque chose ? Et si running et run aussi ?