Requête, Clé, Valeur
Trois Transformations Linéaires à Partir de la Même Entrée
Après l'embedding (activité 4), chaque position porte un vecteur 768-dimensionnel x_t. L'attention commence par produire trois projections distinctes de x :
Q (requête) : que veut savoir cette position ?
K (clé) : que cette position offre-t-elle aux autres positions ?
V (valeur) : quel contenu cette position fournit-elle si elle est prise en compte ?
Chaque projection provient d'une matrice de poids apprise :
Q = x · W_Q # Forme de W_Q : (d_model, d_k)
K = x · W_K # Forme de W_K : (d_model, d_k)
V = x · W_V # Forme de W_V : (d_model, d_k)
Trois matrices, toutes entraînées par rétropropagation. Un modèle apprend : à cette position, quelle requête récupère le mieux le contexte passé utile ? Quelle clé annonce bien le contenu de cette position ? Quelle valeur fournit si sélectionnée ?
Une analogie avec une bibliothèque
Imaginez un catalogue de cartes de bibliothèque. Vous entrez avec un sujet en tête (votre query). Chaque carte liste des mots-clés (une key). Quand votre sujet correspond aux mots-clés d'une carte, vous prenez le contenu du livre (une value). L'attention fait cela pour chaque token en parallèle : chaque position interroge chaque autre position, classe l'alignement, & récupère une combinaison pondérée de vecteurs de valeurs.
Dimensions ANDREA-120M
| Quantité | Valeur | Notes |
|---|---|---|
| d_model | 768 | Taille du vecteur à chaque position |
| n_head | 12 | Têtes d'attention parallèles |
| d_k | 64 | Dimension par tête (= d_model / n_head) |
| T | 1024 | Longueur du contexte |
d_k = d_model / n_head = 768 / 12 = 64. Chaque tête voit une tranche 64-dimensionnelle d'un espace complet de 768 dimensions. L'activité 6 (grow_a_language_model_multi_head) couvre en détail la division par tête.
Calculer d_k
Pourquoi diviser par sqrt(d_k)
Une matrice de scores
Une fois que Q & K existent (chacune de forme (T, d_k)), l'attention calcule une matrice de scores :
scores = Q · K^T # forme : (T, T)
scores[i, j] = à quel point la requête de la position i s'aligne fortement avec la clé de la position j. Chaque paire (i, j) obtient un score : 1024 × 1024 = 1 048 576 scores par tête d'attention par passage avant.
Pourquoi diviser
Les produits scalaires de deux vecteurs unitaires aléatoires de dimension d ont une magnitude de l'ordre de sqrt(d). Sans mise à l'échelle, les scores croissent avec d_k :
- d_k = 64 : produits scalaires typiques de l'ordre de 8.
- d_k = 256 : produits scalaires typiques de l'ordre de 16.
- d_k = 4096 : produits scalaires typiques de l'ordre de 64.
Des scores élevés produisent un softmax pointu (une position domine, les gradients disparaissent ailleurs). L'entraînement s'arrête. Le scaling corrige une magnitude :
scaled_scores = (Q · K^T) / sqrt(d_k)
Pour ANDREA-120M, sqrt(d_k) = sqrt(64) = 8. Chaque score est divisé par 8. Les magnitudes restent à une échelle unitaire approximativement, indépendamment de d_k. Softmax reste bien comporté. Les gradients circulent.
Justification originale de Vaswani
Extrait de Attention Is All You Need (2017) : « Pour de grandes valeurs de d_k, les produits scalaires deviennent grands en magnitude, poussant la fonction softmax dans des régions où elle a des gradients extrêmement petits. » Un diviseur sqrt(d_k) contrebalance cette croissance.
Une vue en code
À l'intérieur de microgpt_cuda.cu, cet échelonnage apparaît comme une division littérale :
scores[i][j] = dot(Q[i], K[j]) * (1.0f / sqrtf(d_k));
Une multiplication float par score. Peu coûteux. Critique.
Échelle à d_model = 4096
Pourquoi la position i ne peut pas voir la position j > i
Une contrainte née de la génération
ANDREA génère un token à la fois. À l'inférence, la position 0 produit un premier token, puis la position 1 voit la sortie de la position 0 & produit un second token, & ainsi de suite. Un modèle n'a jamais accès aux tokens futurs pendant la génération.
L'entraînement doit refléter cela. Si pendant l'entraînement la position 5 pouvait prêter attention à la position 6, un modèle apprendrait un raccourci : « prédire le token 6 en lisant le token 6 ». À l'inférence, ce raccourci disparaît (le token 6 n'existe pas encore). Le comportement entraînement-versus-inférence d'un modèle divergerait de manière catastrophique.
Un Masque
Un masque causal bloque l'attention de toute position i vers toute position j > i. Implémentation : définir scaled_scores[i][j] = -infini partout où j > i. Après softmax, ces entrées deviennent exp(-inf) = 0. Le masque annule proprement l'attention aux positions futures.
pour i dans range(T):
pour j dans range(T):
si j > i:
scaled_scores[i][j] = -1e9 # effectivement -inf
Après softmax (par ligne), chaque ligne somme à 1, mais seules les entrées [0, i] portent la masse de probabilité. La position i mélange l'information uniquement des positions passées.
Visualisation d'un Masque
Une matrice de scores de forme (T, T) avec masque appliqué ressemble à une structure triangulaire inférieure :
scaled_scores après masque, softmax ligne par ligne :
ligne 0 : [1.0, 0, 0, 0, ...] # ne voit que lui-même
ligne 1 : [0.4, 0.6, 0, 0, ...] # voit les positions 0, 1
ligne 2 : [0.2, 0.3, 0.5, 0, ...] # voit 0, 1, 2
ligne 3 : [0.1, 0.2, 0.3, 0.4, ...] # voit 0, 1, 2, 3
...
Distribution de probabilité strictement triangulaire inférieure par ligne. Le futur reste invisible.
Pourquoi un Transformer Decoder-Only en a besoin
Les modèles decoder-only comme ANDREA, GPT et LLaMA partagent tous un objectif : prédire le token suivant à partir des précédents. Un masque causal rend cet objectif entraînable en parallèle : chaque position calcule sa propre prédiction du token suivant en même temps, et aucune position ne triche en regardant en avant.
Masque & Saveur
Des scores à la sortie
Softmax : Scores en Probabilités
Les scores masqués et mis à l'échelle varient encore sur les nombres réels. Softmax convertit chaque ligne en une distribution de probabilités :
A[i][j] = exp(scaled_scores[i][j]) / sum_k exp(scaled_scores[i][k])
Trois propriétés en résultent :
- A[i][j] >= 0 pour tout (i, j).
- sum_j A[i][j] = 1 pour chaque ligne i.
- Des scores bruts plus grands produisent des probabilités plus grandes (monotone).
Le vecteur de probabilités de la ligne i indique au modèle : à quel point la position i doit prêter attention à chaque position antérieure lors du calcul de sa sortie ?
Somme pondérée de V
Une sortie d'attention finale pour la position i :
output[i] = sum_j A[i][j] · V[j]
Chaque vecteur de valeur V[j] est pondéré par la probabilité d'attention A[i][j], puis sommé. La sortie de la position i combine les vecteurs de valeur de toutes les positions précédentes, pondérés par leur pertinence.
En forme matricielle, toutes les positions en même temps :
Attention(Q, K, V) = softmax(mask(Q · K^T / sqrt(d_k))) · V
Une ligne. Un mécanisme d'attention complet. Vaswani et al. ont écrit cette ligne en 2017 ; les transformers n'ont pas fondamentalement changé depuis.
Forme de sortie par tête
Sortie d'une tête d'attention : forme (T, d_k). Pour ANDREA-120M : (1024, 64). Toutes les 12 têtes calculent en parallèle ; leurs sorties se concatènent en (1024, 768) & alimentent une projection linéaire finale (W_O), puis passent au MLP d'un bloc transformer.
L'activité 6 (grow_a_language_model_multi_head) couvre une division multi-têtes. L'activité 7 (grow_a_language_model_transformer_block) couvre tout ce qui entoure l'attention : connexions résiduelles, normalisation de couche, MLP.