Chapitre 12 · 15 min
Le code minimal
Remplace le squelette lisible en listes par un modèle PyTorch compact qui garde la même architecture et peut réellement s’entraîner.
C’est le chapitre où le llm/model.py lisible du chapitre 10 devient un modèle PyTorch réellement entraînable. On garde le code sous 150 lignes, en laissant PyTorch gérer les couches linéaires, la et les , mais en écrivant nous-mêmes l’ et le bloc pour préserver la correspondance avec les chapitres précédents.
Le résultat est un llm/model.py autonome qui définit un décodeur proche de GPT, entraînable sur data/train.bin du chapitre 11.
1. Quelle taille fait ce modèle ?
Le modèle a ces knobs :
vocab_size= 50 257block_size= 64n_layer= 4n_head= 4n_embd= 128ffn_mult= 4
Combien de au total ? Décompose par source.
À toi de jouer · JavaScript
Environ 14 millions de , surtout dans les matrices /unembedding. Les sous-couches d’ sont petites ; le domine souvent le budget par bloc.
2. Le code du modèle
Remplace llm/model.py par cette version PyTorch :
"""llm/model.py — a teaching-scale decoder transformer."""
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from dataclasses import dataclass
@dataclass
class GPTConfig:
vocab_size: int = 50257
block_size: int = 64
n_layer: int = 4
n_head: int = 4
n_embd: int = 128
ffn_mult: int = 4
dropout: float = 0.0
# Switches we'll use later. Defaults preserve chapter-12 behavior.
bias: bool = False # bias on attention linears (ch.15 → True for GPT-2)
tied_lm_head: bool = False # share tok_emb ↔ head (ch.15 → True for GPT-2)
gelu_approximate: str = "none" # "none" or "tanh" (ch.15 → "tanh" for GPT-2)
class CausalSelfAttention(nn.Module):
"""Multi-head attention with a causal mask. Chapters 8-9."""
def __init__(self, cfg):
super().__init__()
assert cfg.n_embd % cfg.n_head == 0
self.n_head = cfg.n_head
self.n_embd = cfg.n_embd
self.qkv = nn.Linear(cfg.n_embd, 3 * cfg.n_embd, bias=cfg.bias)
self.proj = nn.Linear(cfg.n_embd, cfg.n_embd, bias=cfg.bias)
self.register_buffer(
"mask",
torch.tril(torch.ones(cfg.block_size, cfg.block_size)).view(1, 1, cfg.block_size, cfg.block_size),
)
def forward(self, x, past_kv=None):
B, T, C = x.shape
q, k, v = self.qkv(x).split(self.n_embd, dim=2)
q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
if past_kv is not None:
past_k, past_v = past_kv
k = torch.cat([past_k, k], dim=2)
v = torch.cat([past_v, v], dim=2)
present_kv = (k, v)
att = (q @ k.transpose(-2, -1)) / math.sqrt(k.size(-1))
if past_kv is None:
att = att.masked_fill(self.mask[:, :, :T, :T] == 0, float("-inf"))
att = F.softmax(att, dim=-1)
out = att @ v
out = out.transpose(1, 2).contiguous().view(B, T, C)
return self.proj(out), present_kv
class FFN(nn.Module):
"""Per-token MLP with GELU. Chapter 10."""
def __init__(self, cfg):
super().__init__()
self.fc1 = nn.Linear(cfg.n_embd, cfg.ffn_mult * cfg.n_embd)
self.fc2 = nn.Linear(cfg.ffn_mult * cfg.n_embd, cfg.n_embd)
self.approximate = cfg.gelu_approximate
def forward(self, x):
return self.fc2(F.gelu(self.fc1(x), approximate=self.approximate))
class Block(nn.Module):
"""One transformer block, pre-norm. Chapter 10."""
def __init__(self, cfg):
super().__init__()
self.ln1 = nn.LayerNorm(cfg.n_embd)
self.attn = CausalSelfAttention(cfg)
self.ln2 = nn.LayerNorm(cfg.n_embd)
self.ffn = FFN(cfg)
def forward(self, x, past_kv=None):
attn_out, present_kv = self.attn(self.ln1(x), past_kv=past_kv)
x = x + attn_out
x = x + self.ffn(self.ln2(x))
return x, present_kv
class GPT(nn.Module):
"""Full decoder transformer. Chapters 8-10 + token + position embeddings."""
def __init__(self, cfg):
super().__init__()
self.cfg = cfg
self.tok_emb = nn.Embedding(cfg.vocab_size, cfg.n_embd)
self.pos_emb = nn.Embedding(cfg.block_size, cfg.n_embd)
self.blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.n_layer)])
self.ln_f = nn.LayerNorm(cfg.n_embd)
self.head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
if cfg.tied_lm_head:
self.head.weight = self.tok_emb.weight
def forward(self, idx, targets=None, past_kvs=None):
B, T = idx.shape
offset = 0 if past_kvs is None else past_kvs[0][0].size(2)
tok = self.tok_emb(idx)
pos = self.pos_emb(torch.arange(offset, offset + T, device=idx.device))
x = tok + pos
present_kvs = []
past_kvs = past_kvs or [None] * len(self.blocks)
for block, past_kv in zip(self.blocks, past_kvs):
x, present_kv = block(x, past_kv=past_kv)
present_kvs.append(present_kv)
x = self.ln_f(x)
logits = self.head(x)
if targets is None:
return logits, present_kvs
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))
return logits, lossLis le modèle de bas en haut, puis de haut en bas :
GPT.forwardest l’interface du modèle : ids de en entrée, en sortie.tok_emb(idx)transforme les ids en vecteurs.pos_emb(...)donne un vecteur appris à chaque position.- Chaque
Blockmet à jour le flux sans changer sa forme. CausalSelfAttentionfait Q/K/V, sépare les têtes, masque le futur, mélange les values et reprojette.FFNest le par du chapitre 10.F.cross_entropycompare les aux vrais prochains .
3. Installer PyTorch
Depuis le virtualenv du projet :
pip install torchpip install torchpip install torchL’installation CPU est lourde ; sur Mac Apple Silicon, elle active aussi MPS.
4. Vérifier le modèle
Sauvegarde scripts/check_model.py :
"""check_model.py — confirm the model's contract on a fake batch."""
import math
import torch
from llm.model import GPT, GPTConfig
cfg = GPTConfig()
model = GPT(cfg)
# [1]
n_params = sum(p.numel() for p in model.parameters())
assert 13_500_000 < n_params < 14_500_000, f"expected ~14M params, got {n_params:,}"
# [2]
idx = torch.randint(0, cfg.vocab_size, (2, cfg.block_size))
logits, _ = model(idx)
expected_shape = (2, cfg.block_size, cfg.vocab_size)
assert tuple(logits.shape) == expected_shape, (
f"expected output shape {expected_shape}, got {tuple(logits.shape)}"
)
# [3]
targets = torch.randint(0, cfg.vocab_size, (2, cfg.block_size))
_, loss = model(idx, targets=targets)
expected_loss = math.log(cfg.vocab_size)
assert abs(loss.item() - expected_loss) < 1.0, (
f"expected loss near log(vocab) = {expected_loss:.2f}, got {loss.item():.2f}"
)
print(f"✓ {n_params:,} params")
print(f"✓ forward shape: {tuple(logits.shape)}")
print(f"✓ initial loss: {loss.item():.2f} (expected ~{expected_loss:.2f})")Le script verrouille trois garanties sur lesquelles tout le reste du livre repose :
- [1] asserte que le nombre de est dans la bonne fourchette (~14M). En sortir signifie une couche dupliquée, manquante, ou avec la mauvaise dimension.
- [2] asserte la forme du forward :
(batch, time, vocab)— un score par à chaque position. C’est le contrat que tous les chapitres suivants supposent. - [3] asserte que la initiale sur des cibles aléatoires est proche de
log(vocab_size) ≈ 10,83— la baseline distribution uniforme d’un modèle non entraîné. Trop loin = le modèle est cassé d’une manière que l’ ne réparera pas.
Lance :
python -m scripts.check_modelpython -m scripts.check_modelpython -m scripts.check_modelTrois ticks attendus :
✓ 13,665,280 params
✓ forward shape: (2, 64, 50257)
✓ initial loss: 10.84 (expected ~10.83)Si une assertion se déclenche à la place, le message dit quel contrat est cassé.
Ce qu’on saute par rapport à nanoGPT
nanoGPT ajoute , , quelques biais, chargement de config, checkpointing plus complet. L’architecture est la même ; on échange un peu d’efficacité contre de la clarté.
Recap
- Toute l’architecture tient en environ 100 lignes de PyTorch.
- Beaucoup de vivent dans le et les .
- Le distingue un decoder GPT d’un encoder type BERT.
- Les donnent l’ordre aux . - Le
llm/model.pydu chapitre est complet et exécutable ; le prochain chapitre l’entraîne.
Pour aller plus loin
Prochaine étape : la boucle d’entraînement — le modèle produit des . Maintenant, on les rend corrects.
Questions fréquentes
Combien de lignes de code fait un GPT fonctionnel ?
Environ 80 lignes de code réel, moins de 150 avec la configuration et les commentaires. Cela couvre l'auto-attention causale, le réseau feed-forward, le bloc transformer, les embeddings de tokens et de positions, et la loss de cross-entropy. PyTorch fournit les couches linéaires, la layer norm et la table d'embeddings ; tout ce qui est architecturalement intéressant est écrit explicitement.
Où vivent la plupart des paramètres d'un modèle de langage ?
Dans les matrices d'embedding et d'unembedding quand le vocabulaire est grand, et dans le réseau feed-forward de chaque bloc. Les sous-couches d'attention sont étonnamment petites en comparaison. Pour le modèle de 14 millions de paramètres de ce chapitre, le vocabulaire de 50 257 tokens domine le budget.
Pourquoi le modèle est-il volontairement sous-entraîné ?
La loi d'échelle Chinchilla place le ratio compute-optimal autour de 20 tokens d'entraînement par paramètre : un modèle de 14 millions de paramètres en demanderait donc environ 280 millions. Le cours s'entraîne sur environ 272 000, soit à peu près 1000 fois moins. C'est délibéré : ça maintient une exécution complète sous 10 minutes en CPU et rend l'écart train/validation bien visible, ce qui enseigne plus qu'une courbe de loss parfaitement réglée.
Quelle loss initiale attendre d'un modèle non entraîné ?
Proche de log(taille du vocabulaire), soit environ 10,83 pour un vocabulaire de 50 257 tokens. C'est la loss d'une distribution uniforme : le modèle n'a rien appris et répartit la probabilité également. Une loss de départ très éloignée de ce nombre signale une casse que l'entraînement ne corrigera pas.