Skip to content
The loss curve

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 LayerNorm et les embeddings, mais en écrivant nous-mêmes l’attention 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 257
  • block_size = 64
  • n_layer = 4
  • n_head = 4
  • n_embd = 128
  • ffn_mult = 4

Combien de paramètres au total ? Décompose par source.

Code · JavaScript

Environ 14 millions de paramètres, surtout dans les matrices embedding/unembedding. Les sous-couches d’attention sont petites ; le FFN 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, loss

Lis le modèle de bas en haut, puis de haut en bas :

  • GPT.forward est l’interface du modèle : ids de tokens en entrée, logits en sortie.
  • tok_emb(idx) transforme les ids en vecteurs.
  • pos_emb(...) donne un vecteur appris à chaque position.
  • Chaque Block met à jour le flux résiduel sans changer sa forme.
  • CausalSelfAttention fait Q/K/V, sépare les têtes, masque le futur, mélange les values et reprojette.
  • FFN est le MLP par token du chapitre 10.
  • F.cross_entropy compare les logits aux vrais prochains tokens.

3. Installer PyTorch

Depuis le virtualenv du projet :

pip install torch
pip install torch
pip install torch

L’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_model
python -m scripts.check_model
python -m scripts.check_model

Trois 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 weight tying, dropout, 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 paramètres vivent dans le FFN et les embeddings. - Le masque causal distingue un decoder GPT d’un encoder type BERT. - Les embeddings positionnels donnent l’ordre aux tokens. - Le llm/model.py du 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 logits. Maintenant, on les rend corrects.