Module 09 · Leçon 4

Construire un GPT complet

Assembler attention, réseau à propagation avant, connexions résiduelles et LayerNorm en un bloc Transformer, en empiler plusieurs, ajouter embedding et couche de sortie : voilà un GPT complet. Lire gpt.py section par section, et compter où se trouvent ses 1,6 million de paramètres.

  • Environ 50 minutes
  • Niveau : Approfondi
  • Testé : 2026-09-15 torch 2.14, CPU, graine aléatoire fixée

Le code et les sorties des programmes sont reproduits tels qu’ils ont tourné : commentaires et sorties sont donc en chinois.

Les deux leçons précédentes ont fabriqué les pièces : l'attention permet à chaque caractère de recueillir des informations auprès des caractères précédents, les têtes multiples de recueillir plusieurs types d'informations à la fois, et l'embedding de position de connaître l'ordre. Cette leçon assemble les pièces pour obtenir un GPT complet.

Le code complet se trouve dans code/09-transformer/gpt.py, moins de 150 lignes. Sa structure est celle de GPT-2, en beaucoup plus petit. L'entraînement et la génération des deux leçons suivantes l'utilisent.

python inspect_gpt.py

Structure d'ensemble

  词元编号
     │
  词元嵌入 + 位置嵌入
     │
  ┌──────────────────────────┐
  │  LayerNorm → 多头注意力   │ ─┐
  │        + ←────────────────── ┘ 残差
  │  LayerNorm → 前馈网络     │ ─┐
  │        + ←────────────────── ┘ 残差
  └──────────────────────────┘
     │   (这样的块叠 4 层)
  LayerNorm
     │
  输出层:对词表里每个字打分

De bas en haut : chaque caractère devient d'abord un vecteur (embedding de token plus embedding de position), traverse plusieurs blocs Transformer de même structure, puis la couche de sortie note le caractère suivant. L'attention du bloc, nous l'avons déjà écrite ; il reste trois choses nouvelles : le réseau à propagation avant, les connexions résiduelles et LayerNorm.

Le réseau à propagation avant : chaque caractère réfléchit de son côté

L'attention transmet des informations d'un caractère à l'autre. Une fois les informations recueillies, chaque caractère doit encore les « digérer » de son côté : c'est le réseau à propagation avant (feedforward) :

self.mlp = nn.Sequential(  # 前馈网络:先放大 4 倍,过激活函数,再缩回来
    nn.Linear(cfg.n_embd, 4 * cfg.n_embd),
    nn.GELU(),
    nn.Linear(4 * cfg.n_embd, cfg.n_embd),
    nn.Dropout(cfg.dropout),
)

C'est le réseau à deux couches le plus ordinaire du module 08 : on élargit de 128 à 512 dimensions, on passe par une fonction d'activation, on revient à 128 dimensions. GELU est une fonction d'activation très proche de ReLU, mais dont la courbe est lisse autour de 0 ; c'est celle qu'utilise GPT-2.

Le réseau à propagation avant calcule chaque position séparément, sans interaction entre positions. La répartition du travail dans un bloc Transformer est donc : l'attention pour « échanger », le réseau à propagation avant pour « réfléchir ». On verra plus bas que l'essentiel des paramètres du modèle se trouve en fait dans le réseau à propagation avant.

Les connexions résiduelles : modifier un peu l'existant

def forward(self, x, cache=None):
    x = x + self.attn(self.ln1(x), cache)  # 残差连接:在原来的基础上加一点修改
    x = x + self.mlp(self.ln2(x))
    return x

Remarquez qu'on écrit x = x + ..., et non x = self.attn(x). L'attention et le réseau à propagation avant ne calculent pas un nouveau vecteur, mais une « modification » du vecteur d'origine, qu'on lui ajoute. C'est la connexion résiduelle.

Pourquoi ? La leçon 3 du module 08 l'a montré : en rétropropagation, les gradients sont multipliés couche après couche. Avec beaucoup de couches, les gradients multipliés de nombreuses fois deviennent facilement minuscules (plus rien n'apprend) ou énormes (l'entraînement explose). Avec une connexion résiduelle, la dérivée de x + f(x) par rapport à x contient toujours un 1, et le gradient peut revenir directement aux couches précédentes par le chemin du « plus », sans traverser la transformation de chaque couche. C'est l'une des clés qui permettent d'entraîner des réseaux profonds de dizaines ou de centaines de couches.

On peut aussi le voir autrement : tout le modèle contient une « route principale » qui va directement de l'entrée à la sortie, et chaque bloc ne fait qu'y ajouter un peu.

LayerNorm : garder les valeurs stables

La leçon 2 du module 08 l'a montré : quand les plages de valeurs des entrées diffèrent trop, l'entraînement est difficile. En calculant couche après couche, les nombres des vecteurs grandissent ou rapetissent eux aussi. LayerNorm, avant chaque sous-couche, ramène chaque vecteur à une moyenne de 0 et un écart type de 1, puis le multiplie et lui ajoute deux jeux de paramètres appris (pour que le modèle décide lui-même de la plage adaptée).

C'est la même idée que la standardisation du module 08, appliquée séparément au vecteur de chaque position, et répétée au milieu du réseau.

Remarquez que LayerNorm est placée avant l'attention et le réseau à propagation avant (self.attn(self.ln1(x))), et non après. C'est le Pre-LN ; depuis GPT-2, presque tous les modèles la placent ainsi, car l'entraînement est plus stable. Avant la sortie finale vient encore une LayerNorm (ln_f).

Entrée et sortie

self.tok_emb = nn.Embedding(cfg.vocab_size, cfg.n_embd)
self.pos_emb = nn.Embedding(cfg.block_size, cfg.n_embd)
...
self.head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
self.head.weight = self.tok_emb.weight  # 输出层和词元嵌入共用一套参数,GPT-2 也是这样做的

nn.Embedding est simplement une table : la ligne i est le vecteur du i-ième caractère. La table d'embedding des tokens a 6289 lignes (la taille du vocabulaire), celle des positions 128 lignes (au plus 128 positions).

La couche de sortie transforme le vecteur de 128 dimensions en 6289 scores, un par caractère. Il y a ici une petite astuce : la couche de sortie et l'embedding des tokens partagent la même matrice. L'embedding transforme un « caractère » en « vecteur », la couche de sortie juge à l'inverse de quel « caractère » un « vecteur » est le plus proche ; utiliser les mêmes paramètres est logique, et économise un gros bloc de paramètres.

Le calcul en avant enchaîne tout cela :

def forward(self, idx, targets=None, caches=None, start=0):
    B, T = idx.shape
    pos = torch.arange(start, start + T, device=idx.device)
    x = self.drop(self.tok_emb(idx) + self.pos_emb(pos))
    for i, block in enumerate(self.blocks):
        x = block(x, None if caches is None else caches[i])
    logits = self.head(self.ln_f(x))  # (B, T, 词表大小):每个位置对下一个词元的打分
    loss = None
    if targets is not None:
        loss = F.cross_entropy(logits.reshape(-1, logits.size(-1)), targets.reshape(-1))
    return logits, loss

caches et start servent au cache KV de la leçon 6 ; ignorons-les pour l'instant. La perte est l'entropie croisée de la leçon 5 du module 08 : chaque position est un problème de classification « choisir un caractère parmi 6289 ».

Les formes

== 2. 一批数据流过模型时的形状
  输入的词元编号        (2, 6)
  嵌入之后              (2, 6, 128)
  经过 4 个块之后        (2, 6, 128)
  输出层                (2, 6, 6289):每个位置对 6289 个字各打一个分

Deux vers de 6 caractères chacun : l'entrée est (2, 6). Après l'embedding, chaque caractère devient un vecteur de 128 dimensions. Après 4 blocs, la forme est strictement inchangée : c'est justement pour cela qu'on peut empiler autant de blocs qu'on veut. À la fin, chaque position obtient 6289 scores.

Remarquez qu'il y a une sortie à chaque position : un texte de 6 caractères fournit à l'entraînement 6 questions « devine le caractère suivant » à la fois. La 1re position voit « 白 » et devine « 日 », la 2e voit « 白日 » et devine « 依 »… Grâce au masque causal, aucune question ne peut regarder la réponse. Cela rend l'entraînement bien plus efficace.

Où sont les paramètres

== 1. 参数都在哪里(词表 6289 个字符)
  词元嵌入   6289 × 128 = 804,992(输出层和它共用,不另算)
  位置嵌入   128 × 128 = 16,384
  每个块     198,272:注意力 66,048,前馈网络 131,712,两个 LayerNorm 512
  4 个块共  793,088
  合计       1,614,720,其中词元嵌入占 50%

1,61 million de paramètres au total. On peut les recompter soi-même :

  • Attention : qkv fait 128×384 plus 384 biais, proj 128×128 plus 128, soit 66 048 au total.
  • Réseau à propagation avant : 128×512 plus 512, 512×128 plus 128, soit 131 712, deux fois l'attention.
  • LayerNorm : 128 facteurs d'échelle et 128 décalages chacune, 512 pour les deux.

Dans notre petit modèle, la moitié des paramètres se trouve dans la table d'embedding des tokens, car le vocabulaire compte 6289 caractères pour des vecteurs de seulement 128 dimensions. Dans les grands modèles, la proportion est tout autre : des milliers de dimensions de vecteur, des dizaines de couches, et les paramètres des blocs dépassent de loin ceux de l'embedding. Dans chaque bloc, le réseau à propagation avant représente environ deux tiers, l'attention un tiers, une proportion à peu près la même dans les grands modèles.

Avant l'entraînement

== 3. 没训练过的模型,损失应该接近随便猜
  损失 8.775,ln(6289) = 8.747
  下一个字最可能是:骠蔫潮涨湄(随机初始化,毫无道理)

Un modèle non entraîné note presque également les 6289 caractères, ce qui revient à deviner au hasard ; l'entropie croisée devrait être ln(6289) = 8,747. Elle vaut en réalité 8,775, très proche. La leçon 5 du module 08 l'a dit : si la perte au début de l'entraînement est loin de cette valeur, il y a sans doute une erreur quelque part. C'est la première vérification à faire après avoir construit un modèle.

Les valeurs initiales des paramètres sont tirées au hasard selon une loi normale de std=0.02 (méthode _init), si bien qu'au départ tous les caractères ont à peu près le même score, et la perte est proche du hasard.

En quoi ce GPT diffère des vrais grands modèles

Ce GPT a essentiellement la même structure que GPT-2. En septembre 2026, les grands modèles open source majeurs ont apporté quelques améliorations à cette ossature, dont les plus courantes :

  • L'information de position passe à l'encodage de position rotatif (RoPE) évoqué à la leçon précédente.
  • LayerNorm est remplacée par RMSNorm, plus simple à calculer.
  • Le réseau à propagation avant adopte une structure à « porte » (par exemple SwiGLU).
  • Plusieurs têtes d'attention partagent K et V (attention à requêtes groupées), ce qui réduit la mémoire occupée par le cache KV de la leçon suivante.
  • Le réseau à propagation avant est remplacé par de nombreux « experts », dont chaque caractère n'utilise que quelques-uns (mélange d'experts, MoE) ; les modèles de DeepSeek ont cette structure.

Mais l'ossature n'a pas changé : embedding, plusieurs blocs « attention + réseau à propagation avant », connexions résiduelles, normalisation, couche de sortie. Une fois ce GPT de 150 lignes compris, en lisant le code de ces modèles, vous en reconnaîtrez l'essentiel.

Exercices

  1. Passez n_layer à 8 et n_embd à 256. Combien de paramètres cela fait-il ? Quelle est désormais la part de l'embedding des tokens ? Calculez d'abord avec les formules de cette leçon, puis vérifiez en exécutant.
  2. Retirez les deux connexions résiduelles de Block.forward (x = self.attn(self.ln1(x))), entraînez 300 pas avec le train.py de la leçon suivante, et comparez avec la perte d'origine.
  3. Supprimez la ligne qui fait partager les paramètres entre couche de sortie et embedding des tokens. Combien de paramètres de plus ?

Auto-test

1. De quoi se compose un bloc Transformer ? Comment l'attention et le réseau à propagation avant se répartissent-ils le travail ?

Deux sous-couches : l'auto-attention causale multi-têtes et le réseau à propagation avant, chacune précédée d'une LayerNorm et entourée d'une connexion résiduelle. L'attention transmet des informations entre positions différentes ; le réseau à propagation avant calcule chaque position séparément et traite les informations recueillies.

2. Pourquoi les connexions résiduelles aident-elles à entraîner des réseaux très profonds ?

La connexion résiduelle ajoute la sortie de chaque sous-couche à son entrée ; la dérivée de x + f(x) par rapport à x contient toujours un 1. En rétropropagation, le gradient peut revenir aux couches précédentes par ce chemin direct, sans devenir minuscule ou énorme à force d'être multiplié par de nombreuses couches.

3. Pourquoi la perte d'un modèle non entraîné devrait-elle être proche de ln(taille du vocabulaire) ?

Les paramètres initialisés au hasard sont petits, le modèle note à peu près également chaque caractère, et après softmax, la probabilité de chaque caractère vaut environ 1/taille du vocabulaire. L'entropie croisée vaut -log(probabilité du bon caractère), soit -log(1/taille du vocabulaire) = ln(taille du vocabulaire).

Questions et discussion

Bloqué sur cette leçon ? Posez votre question ici. Et si vous pouvez répondre à quelqu'un, n'hésitez pas.

Une question rapporte 3 points, une réponse 6. Les messages paraissent après vérification.

Chargement de la discussion…