Skip to content
The loss curve

Chapitre 1 · 18 min

Le modèle le plus idiot qui existe

Démarre ton projet LLM local, puis construis le modèle de langage le plus simple possible : un générateur bigramme que tu peux lancer sur ta machine.

Ouvre un livre. Lis la première ligne. Peux-tu deviner le mot suivant ? Probablement, parce que tu l’as déjà vu mille fois ailleurs.

Construisons un modèle qui ne sait faire que ça. À la fin du chapitre, tu auras lancé quatre petites fonctions, vu comment elles se combinent en générateur, et sauvegardé le premier fichier de ton propre projet my-llm/.

Le utilisé ici est un fragment de comptine. Il est volontairement petit et répétitif : assez petit pour vérifier le code à l’œil, assez répétitif pour que le modèle dise quelque chose de presque cohérent.

the cat sat on the mat the dog sat on the rug the cat watched the dog the dog watched the cat

0. Démarre ton projet local

Les cellules dans le navigateur sont le banc d’essai rapide. Le dossier local est ce que tu gardes.

Crée-le maintenant :

mkdir -p my-llm/llm my-llm/scripts my-llm/data && cd my-llm && python3 -m venv .venv && source .venv/bin/activate
mkdir my-llm; mkdir my-llm\\llm,my-llm\\scripts,my-llm\\data; cd my-llm; py -m venv .venv; .\\.venv\\Scripts\\Activate.ps1
mkdir -p my-llm/llm my-llm/scripts my-llm/data && cd my-llm && python3 -m venv .venv && source .venv/bin/activate

Ajoute ensuite le marqueur de package vide :

touch llm/__init__.py
New-Item llm\\__init__.py -ItemType File
touch llm/__init__.py

Tu utiliseras ce dossier pendant tout le livre. Chaque chapitre y ajoute une brique.

1. Découper le texte en tokens

Avant de modéliser quoi que ce soit, il faut couper le texte en unités manipulables. Le choix le plus simple : chaque mot séparé par un espace est un . On met tout en minuscules pour que The et the comptent comme le même mot.

Lance la cellule. Elle retourne le tableau de , et la visualisation montre exactement ce que le modèle voit.

Code · JavaScript

C’est tout. C’est la préparation des données d’ dans sa forme minimale. Les vrais font quelque chose de bien plus malin, ce qu’on verra au chapitre 3, mais pour l’instant espaces + minuscules suffit.

2. Compter les paires

Tout le “modèle” sera une table de comptage : pour chaque paire de adjacents (a, b) dans le , combien de fois b a suivi a ? Cette table est le modèle. L’ consiste simplement à la remplir.

Le est l’ensemble des uniques, dans leur ordre d’apparition. La table de comptage est une matrice carrée de taille vocab.length × vocab.length, où counts[i][j] est le nombre de fois où vocab[i] a été immédiatement suivi par vocab[j].

Lance la boucle et inspecte la matrice qu’elle construit.

Code · JavaScript

La matrice produite est le modèle entier. La plupart des cellules sont à zéro : la plupart des paires de mots ne sont jamais apparues côte à côte. Les cellules non nulles disent ce que le modèle a appris. La ligne de the est probablement la plus remplie ; la ligne de mat est très pauvre, car mat n’est apparu qu’une fois, suivi par the.

En une ligne, les maths sont :

P(wtwt1)=C(wt1,wt)C(wt1)P(w_t \mid w_{t-1}) = \frac{C(w_{t-1}, w_t)}{C(w_{t-1})}

La probabilité que le mot suivant soit wtw_t, sachant que le mot précédent était wt1w_{t-1}, est le nombre de fois où tu as vu (wt1,wt)(w_{t-1}, w_t) divisé par le nombre total de fois où tu as vu wt1w_{t-1}. Chaque ligne de la matrice, divisée par sa somme, est exactement une .

3. Échantillonner une distribution

Nous avons des probabilités. Il faut les transformer en choix concrets. Si une ligne dit que the est suivi de cat 40 % du temps et de dog 60 % du temps, on veut tirer dog environ six fois sur dix.

L’astuce standard : tirer un nombre aléatoire dans [0, 1), parcourir la somme cumulée des probabilités, puis choisir le premier index dont la somme cumulée dépasse le tirage.

Code · JavaScript

Distribution

thecatdogrug

Empirical counts

Click Run several times to fill this side.

Lance plusieurs fois. Regarde le graphe des comptages empiriques se remplir. Après quelques dizaines de tirages, la droite devrait commencer à ressembler à la gauche. C’est la loi des grands nombres qui s’invite discrètement dans le chapitre.

4. Tout assembler : générer

On chaîne maintenant les pièces. On part d’un . On trouve sa ligne dans la matrice. On normalise cette ligne en . On le suivant. Celui-ci devient le nouveau . Et on répète.

La cellule met maintenant le , le , la longueur voulue et les trois fonctions auxiliaires directement dans le script. La boucle s’arrête tôt si elle tombe sur une impasse : un dont la ligne est entièrement à zéro.

Code · JavaScript

Clique sur Run plusieurs fois. Tu obtiendras des séquences différentes, parce que chaque étape est un , pas un choix déterministe. Certaines seront cohérentes, d’autres absurdes. Elles ne sont pas de simples répétitions du , sauf hasard.

Structurellement, tous les modernes font la même chose : des précédents entrent, une probabilité sur les suivants sort, puis on . Le mécanisme qui produit la est infiniment plus expressif que counts[i] / row_sum[i], mais l’interface est identique.

Tu viens de construire un . Il est très mauvais. Le reste du livre consiste à remplacer chaque partie par quelque chose qui l’est moins.

5. Mettre la baseline dans ton repo

Sauvegarde maintenant la même idée localement. Crée llm/bigram.py :

"""A tiny bigram language model."""
from __future__ import annotations
 
import random
from collections import Counter, defaultdict
 
 
# [1]
def tokenize(text: str) -> list[str]:
    return [part for part in text.lower().split() if part]
 
 
# [2]
def train(tokens: list[str]) -> dict[str, Counter[str]]:
    counts: dict[str, Counter[str]] = defaultdict(Counter)
    # [3]
    for left, right in zip(tokens, tokens[1:]):
        counts[left][right] += 1
    return counts
 
 
# [4]
def sample_next(row: Counter[str]) -> str | None:
    total = sum(row.values())
    if total == 0:
        return None
 
    roll = random.random() * total
    acc = 0.0
    for token, count in row.items():
        acc += count
        if roll <= acc:
            return token
    return next(reversed(row))
 
 
# [5]
def generate(model: dict[str, Counter[str]], seed: str, steps: int) -> list[str]:
    out = [seed]
    for _ in range(steps - 1):
        nxt = sample_next(model.get(out[-1], Counter()))
        if nxt is None:
            break
        out.append(nxt)
    return out

Lis ce fichier par petits morceaux :

  • [1] tokenize est volontairement ennuyeuse : minuscules, split, suppression des morceaux vides. Sa sortie est la seule chose que le modèle peut voir.
  • [2] train construit un dictionnaire de dictionnaires : précédent à l’extérieur, suivants possibles à l’intérieur.
  • [3] zip(tokens, tokens[1:]) est la boucle d’ en miniature : (the, cat), puis (cat, sat), puis (sat, on).
  • [4] sample_next ne prend pas le plus grand comptage. Il tire un nombre aléatoire et parcourt la ligne : les fréquents gagnent souvent, pas toujours.
  • [5] generate répète ce pas d’un . Le dernier devient l’entrée de la prédiction suivante.

Crée scripts/train_bigram.py :

from llm.bigram import generate, 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"
 
# [1]
tokens = tokenize(TEXT)
# [2]
model = train(tokens)
 
# [3]
for _ in range(5):
    print(" ".join(generate(model, seed="the", steps=12)))

Ce script est volontairement fin :

  • TEXT est la donnée d’.
  • [1] transforme le texte brut en unités du modèle.
  • [2] remplit la table de comptage.
  • [3] cinq continuations pour montrer que la est stochastique.

Lance-le :

python -m scripts.train_bigram
python -m scripts.train_bigram
python -m scripts.train_bigram

Tu as maintenant le premier artefact réel du cours : un local avec et . Minuscule, brut, mais à toi.

Recap

  • Tu as lancé quatre fonctions : tokenize, buildCounts, sampleNext, puis une boucle generate. - Tu as démarré my-llm/ et sauvegardé le premier module réutilisable : llm/bigram.py. - Le modèle est la table de comptage. L’ la remplit ; l’ choisit une ligne, la normalise et . - La est un répété. Différents runs donnent différentes sorties. - C’est quand même un . Mauvais, sans mémoire au-delà d’un , sans généralisation. Mais il a la même forme entrée/sortie qu’un vrai .

Pour aller plus loin

Prochaine étape : compter ne suffit pas — que se passe-t-il quand ton modèle local rencontre une continuation qu’il n’a jamais vue ?