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/activatemkdir my-llm; mkdir my-llm\\llm,my-llm\\scripts,my-llm\\data; cd my-llm; py -m venv .venv; .\\.venv\\Scripts\\Activate.ps1mkdir -p my-llm/llm my-llm/scripts my-llm/data && cd my-llm && python3 -m venv .venv && source .venv/bin/activateAjoute ensuite le marqueur de package vide :
touch llm/__init__.pyNew-Item llm\\__init__.py -ItemType Filetouch llm/__init__.pyTu 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.
La cellule contient déjà le texte. Mets-le en minuscules, découpe-le, et retourne le tableau de — la visualisation te montrera exactement ce que le modèle voit.
À toi de jouer · 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].
Écris la boucle qui remplit la table, puis inspecte la matrice qu’elle construit.
À toi de jouer · 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 :
La probabilité que le mot suivant soit , sachant que le mot précédent était , est le nombre de fois où tu as vu divisé par le nombre total de fois où tu as vu . 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.
À toi de jouer · JavaScript
Distribution
Empirical counts
Click Run several times to fill this side.
Une fois ta version au point, exécute-la 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.
À toi de jouer · JavaScript
Une fois ta version au point, exécute-la 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.
4b. Tourner les boutons
Tu as écrit les quatre fonctions. Manipule maintenant le pipeline entier d’un coup : change le , plafonne le , avance dans la par et regarde la qui a décidé chaque tirage.
Your text
Generate
Step 0 / 12
Counts table
Rows = previous token · Columns = next token.
Output
Click Next token → to start generating.
Last distribution
The distribution shows up here once you take a step.
Tape un plus long et plus varié : la matrice devient plus creuse — plus de mots distincts, et la plupart des paires jamais vues. Tape-en un très répétitif et la devient vite ennuyeuse. Cet arbitrage est tout le sujet du chapitre 2.
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 outLis ce fichier par petits morceaux :
- [1]
tokenizeest volontairement ennuyeuse : minuscules, split, suppression des morceaux vides. Sa sortie est la seule chose que le modèle peut voir. - [2]
trainconstruit 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_nextne prend pas le plus grand comptage. Il tire un nombre aléatoire et parcourt la ligne : les fréquents gagnent souvent, pas toujours. - [5]
generateré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 :
TEXTest 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_bigrampython -m scripts.train_bigrampython -m scripts.train_bigramTu 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 bouclegenerate. - 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
- makemore, épisode 1, par Karpathy construit le même modèle en Python, en version caractère.
- Step by Token, chapitre 1 couvre les mêmes idées depuis l’angle compréhension.
- L’implémentation de référence complète vit dans
lib/ml/bigram/.
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 ?
Questions fréquentes
Qu'est-ce qu'un modèle de langage bigramme ?
Un modèle qui prédit le mot suivant en n'utilisant que le mot immédiatement précédent. On compte combien de fois chaque paire de mots adjacents apparaît dans un corpus, on normalise chaque ligne de comptages en distribution de probabilité, et on échantillonne dedans. La table de comptage *est* le modèle : l'entraînement consiste seulement à la remplir.
Un compteur de bigrammes est-il vraiment un modèle de langage ?
Oui, structurellement. Il prend des tokens précédents et renvoie une distribution de probabilité sur les tokens suivants, ce qui est exactement l'interface qu'expose GPT-4. Ce qui diffère, c'est la fonction au milieu : une division entre deux comptages ici, des milliards de paramètres et de l'attention sur des milliers de tokens là-bas.
Pourquoi commencer par quelque chose d'aussi simple ?
Parce que chaque chapitre suivant corrige une de ses faiblesses, et qu'on n'apprécie une correction qu'après avoir senti le problème. Le bigramme n'a aucune mémoire au-delà d'un token, aucune notion de mots similaires, et aucune idée de quoi faire d'une paire jamais vue — c'est précisément l'objet des chapitres 2, 4 et 8.
Quelle différence entre entraînement et inférence ici ?
L'entraînement, c'est compter les paires adjacentes et remplir la table. L'inférence, c'est choisir la ligne du token courant, la diviser par sa somme pour obtenir une distribution, et y tirer. Rien n'est appris par descente de gradient à ce stade : ça commence au chapitre 5.