Aller au contenu
The loss curve

Chapitre 8 · 18 min

Une tête d’attention à la main

Écris Q, K, V, les scores causaux, le softmax et les valeurs pondérées, puis ajoute le cœur de l’attention à ton modèle local.

Jusqu’au chapitre 7, le modèle savait ajuster des fonctions sur des exemples individuels. Les courbes de descendaient, les résolvaient XOR, les faisaient leur travail. Le problème restant : un n’a aucun moyen de regarder les autres . Le ne voyait que le mot précédent ; un appliqué à un fixe ne voit que ce vecteur.

L’ est ce mécanisme, et elle porte tous les de pointe depuis 2017. Le papier d’origine la disait « tout ce dont vous avez besoin » — une exagération qui s’est révélée approximativement juste.

La mécanique est mécanique. Une fois que tu as exécuté chaque morceau et inspecté les formes, tu peux expliquer l’ à un ami au comptoir. C’est exactement ce qu’on va faire : quatre cellules exécutables qui construisent une tête d’ de l’intérieur, sur une phrase jouet de 5 avec des en 4 dimensions, pour que les matrices tiennent à l’écran. Ensuite, tu sauvegarderas une fonction d’ causale minimale en local.

Phrase : the cat sat on mat. Cinq , 4D faits à la main.

1. Projeter en Q, K, V

Une seule tête d’ a trois matrices apprises : W_Q, W_K, W_V, chacune de taille [d × d_head]. Multiplier l’entrée X (de forme [seq_len × d]) par chacune produit les queries, les keys et les values — trois vues de la même entrée, calculées chacune par un jeu de poids différent :

Q=XWQ,K=XWK,V=XWVQ = X \cdot W_Q, \quad K = X \cdot W_K, \quad V = X \cdot W_V

Intuition :

  • Queries : “qu’est-ce que je cherche ?”
  • Keys : “voilà ce que j’ai”
  • Values : “voilà ce que j’apporterai si tu me choisis”

Écris la pour Q. (On réutilise la même routine pour K et V — une fois que tu l’as écrite une fois, c’est fini.)

À toi de jouer · JavaScript

Le résultat est [seq_len × d_head], la même forme que l’entrée dans notre cas jouet. Chaque ligne est le vecteur query d’un de la phrase.

2. Scorer chaque paire

On demande maintenant : à quel point chaque query s’intéresse à chaque key ? La réponse standard est un produit scalaire — grand quand deux vecteurs pointent dans la même direction, petit quand ils sont orthogonaux, négatif quand ils s’opposent.

Sij=QiKjS_{ij} = Q_i \cdot K_j

S est une matrice [seq_len × seq_len]. S[i][j] signifie : « à quel point le j est-il pertinent pour le i ? » La ligne i est la ligne des scores vus depuis le i.

À toi de jouer · JavaScript

La heatmap montre les scores bruts. La matrice n’est pas symétrique en général : i qui interroge j n’est pas la même question que j qui interroge i, parce qu’elles passent par des W_Q et W_K différents. C’est cette asymétrie qui permet à l’ d’exprimer des relations orientées, du type « ce verbe est gouverné par ce sujet ».

3. Échelle et softmax

Les scores ne sont pas encore des probabilités : ils peuvent avoir n’importe quel signe, n’importe quelle magnitude. Deux transformations les convertissent en une distribution de probabilité, ligne par ligne :

  1. Mettre à l’échelle chaque entrée par 1/√d_k. Sans ça, les produits scalaires de vecteurs en grande dimension grandiraient assez pour saturer le et faire disparaître les à l’.
  2. Appliquer le à chaque ligne. Chaque ligne somme alors à 1.
A=softmax ⁣(Sdk)A = \text{softmax}\!\left(\frac{S}{\sqrt{d_k}}\right)

À toi de jouer · JavaScript

( par ligne, appliqué indépendamment à chacune.)

Regarde la ligne i dans la heatmap. Ses cellules te disent, pour le i, quelle part de sa représentation mise à jour viendra de chaque autre . Si le 2 a une valeur forte en colonne 4, le modèle considère le 4 comme très pertinent en tant que source pour la mise à jour du 2. Les lignes somment à 1 : ce sont de vraies .

4. Mélanger les values

Dernière étape : la sortie de chaque est la somme des vecteurs value pondérée par l’. Le i prend une moyenne pondérée des values de tous les , avec les poids de sa ligne d’ A[i].

outputi=jAijVj\text{output}_i = \sum_j A_{ij} \cdot V_j

Sous forme matricielle, output = A · V, de forme [seq_len × d]. La même forme que l’entrée X — la tête réarrange la représentation de chaque en y tirant des morceaux des autres.

À toi de jouer · JavaScript

C’est tout le calcul d’une à une tête. Cinq lignes de maths, quatre opérations matricielles. Empile la même machinerie quelques dizaines de fois (c’est ce que font les chapitres suivants), entraîne-la sur un milliard de de texte, et tu obtiens GPT.

Pourquoi ça marche

Le mécanisme n’est pas profond, mais ses implications le sont. Une seule tête d’ peut implémenter, selon les poids qu’elle a appris :

  • Une copie — chaque regarde le précédent, la sortie est la value du d’avant. (Utile pour la répétition.)
  • Un lookup — pour chaque « the », regarder le nom qui suit. (Fréquent en modélisation du langage.)
  • Un accord — pour chaque verbe, regarder son sujet. (Dépendance à longue portée.)
  • Un résumé — chaque regarde à peu près également tous les autres, ce qui moyenne la séquence. (Utile dans la dernière couche.)

La procédure d’ ( sur la next-) découvre quels motifs le réseau a besoin d’avoir. On ne les lui dit jamais. Un moderne a des dizaines de ces têtes par couche et des dizaines de couches ; chaque tête se spécialise pendant l’.

5. Ajouter l’attention causale localement

Crée llm/attention.py :

"""Readable attention helpers before the PyTorch version."""
from __future__ import annotations
 
import math
 
 
Vector = list[float]
Matrix = list[Vector]
 
 
def dot(a: Vector, b: Vector) -> float:
    return sum(x * y for x, y in zip(a, b))
 
 
def softmax(values: Vector) -> Vector:
    m = max(values)
    exps = [math.exp(v - m) for v in values]
    total = sum(exps)
    return [v / total for v in exps]
 
 
def matmul(x: Matrix, w: Matrix) -> Matrix:
    columns = list(zip(*w))
    return [[dot(row, list(col)) for col in columns] for row in x]
 
 
def causal_attention(x: Matrix, wq: Matrix, wk: Matrix, wv: Matrix) -> Matrix:
    # [1]
    q = matmul(x, wq)
    k = matmul(x, wk)
    v = matmul(x, wv)
    scale = math.sqrt(len(k[0]))
 
    out: Matrix = []
    for i, query in enumerate(q):
        # [2]
        scores = [
            dot(query, key) / scale if j <= i else -1e9
            for j, key in enumerate(k)
        ]
        # [3]
        weights = softmax(scores)
        # [4]
        out.append([
            sum(weight * value[d] for weight, value in zip(weights, v))
            for d in range(len(v[0]))
        ])
    return out

Lis-le comme quatre passages sur la même séquence :

  • [1] q, k et v sont trois vues apprises de x. Mêmes , questions différentes.
  • [2] scores compare la query du i à chaque key. j <= i est le : les passés et courant sont visibles, les futurs reçoivent -1e9.
  • [3] softmax(scores) fabrique une pour le i.
  • [4] construit une moyenne pondérée des vecteurs value. C’est la nouvelle représentation du i.

L’ajout important, c’est le : le i ne peut lire que les 0..i. Sans lui, le modèle pourrait tricher pendant l’ next- en regardant la réponse.

Recap

  • Trois projections de la même entrée — Q (queries), K (keys), V (values).
  • Les scores sont les produits scalaires par paires entre queries et keys : à quel point chaque autre est-il pertinent ?
  • Mise à l’échelle et transforment les scores en une par .
  • La sortie est une somme pondérée des vecteurs value, pondérée par l’.
  • Ton projet local a maintenant llm/attention.py avec l’ causale.
  • Une tête est un unique motif de routage d’information. Plusieurs têtes (chapitre suivant) permettent à plusieurs routes de coexister.

Pour aller plus loin

Prochaine étape : multi-têtes et résidus — une tête ne suffit pas, et il faut une connexion pour en empiler beaucoup.

Questions fréquentes

Que signifient query, key et value dans l'attention ?

Trois vues différentes de la même entrée, chacune produite par sa propre matrice apprise. La query demande « qu'est-ce que je cherche », la key annonce « voilà ce que j'ai », et la value porte « voilà ce que j'apporterai si tu me choisis ». Les poids d'attention viennent de la correspondance entre queries et keys ; ce qui circule réellement, ce sont les values.

Pourquoi diviser le score d'attention par la racine de d_k ?

Parce que la variance d'un produit scalaire croît linéairement avec sa dimension. À d_k = 64, les scores bruts atteignent des dizaines voire des centaines, ce qui pousse le softmax en saturation : presque toute la masse va sur un seul token et le gradient s'annule. Diviser par √d_k ramène les scores à une échelle unitaire et garde le softmax dans le régime où il peut encore apprendre.

Que fait concrètement le masque causal ?

Il empêche le token i de regarder les tokens qui le suivent, en mettant ces scores à moins l'infini avant le softmax pour qu'ils en ressortent à probabilité exactement nulle. Sans lui, un modèle entraîné à prédire le token suivant pourrait simplement lire la réponse. C'est la seule contrainte qui fait d'un transformer un décodeur plutôt qu'un encodeur.

La matrice d'attention est-elle symétrique ?

Non, et c'est important. Le token i qui interroge le token j passe par W_Q et W_K, tandis que j qui interroge i passe par les mêmes matrices dans les rôles inverses — calcul différent, score différent. C'est cette asymétrie qui permet à l'attention d'exprimer des relations orientées comme « ce verbe est gouverné par ce sujet ».