Le mécanisme d'attention
Pour comprendre son propre sens, un caractère doit regarder quels caractères le précèdent. Partir du plus simple, « la moyenne de tous les caractères précédents », puis ajouter pas à pas requête, clé et valeur, masque causal et mise à l'échelle, pour écrire une tête d'attention complète, vérifiée avec l'implémentation de PyTorch.
- 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.
Le « 明 » de « 明月 » (la lune claire) et celui de « 明天 » (demain) n'ont pas le même sens. Comment comprendre un caractère dépend des caractères qui l'entourent.
Le modèle que nous voulons entraîner doit, à chaque position, prédire le caractère suivant. Pour écrire « 光 » (la clarté) après « 床前明月 » (devant mon lit, la lune claire…), il doit savoir que « 明月 » est apparu plus tôt. Chaque position a donc besoin d'un moyen de recueillir des informations auprès des caractères précédents.
L'attention (attention) est ce moyen ; c'est le cœur du Transformer. Cette leçon part de la méthode la plus simple et la construit pas à pas.
python attention.py
La méthode la plus simple : la moyenne
Chaque caractère est d'abord représenté par un vecteur (les embeddings de la leçon 5 du module 01). La façon la plus simple de « recueillir les informations précédentes » est de faire la moyenne de son propre vecteur et de ceux de tous les caractères précédents.
Cela se fait en une seule multiplication matricielle. On construit une matrice triangulaire inférieure, dont chaque ligne n'a de valeurs qu'aux positions précédentes, de somme 1 :
x = torch.randn(T, 4) # 5 个词,每个词一个 4 维向量(先随便取)
weights = torch.ones(T, T).tril() # 下三角:第 i 行只有前 i+1 个位置是 1
weights = weights / weights.sum(dim=1, keepdim=True) # 每行除以个数,变成平均
out = weights @ x
== 1. 用一个矩阵乘法,让每个位置得到自己和前面所有位置的平均
tensor([[1.00, 0.00, 0.00, 0.00, 0.00],
[0.50, 0.50, 0.00, 0.00, 0.00],
[0.33, 0.33, 0.33, 0.00, 0.00],
[0.25, 0.25, 0.25, 0.25, 0.00],
[0.20, 0.20, 0.20, 0.20, 0.20]])
第 3 个位置('明')的结果 = 前三个向量的平均?True
La ligne i indique combien le i-ième caractère prend à chaque position : le 1er ne voit que lui-même, le 3e regarde les trois premiers à parts égales, le 5e regarde les cinq à parts égales. weights @ x calcule d'un coup le résultat de toutes les positions.
Retenez cette écriture en produit matriciel : l'attention qui suit a la même forme, une matrice de poids multipliée par un ensemble de vecteurs. Seule change la manière d'obtenir les poids.
Le problème de la moyenne saute aux yeux : tous les caractères sont traités à égalité. « 光 » veut savoir si « 月 » (la lune) apparaît avant lui ; « 床 » (le lit) et « 前 » (devant) comptent moins pour lui. Les poids devraient dépendre du contenu.
Requête et clé : chaque caractère décide qui regarder
La solution : donner à chaque caractère deux vecteurs :
- La requête (query, q) : quel type d'information je cherche.
- La clé (key, k) : quel type d'information j'ai.
« À quel point » un caractère prête attention à un autre se note par le produit scalaire de sa propre q et de la k de l'autre. Plus deux vecteurs pointent dans des directions proches, plus leur produit scalaire est grand.
Voyons d'abord l'effet avec des vecteurs fabriqués à la main. Supposons des vecteurs à deux dimensions, la première signifiant « lié au ciel », la seconde « lié au lieu ». La requête de « 光 » est [1, 0] : il cherche quelque chose de lié au ciel :
k = torch.tensor([[0.1, 0.9], # 床:一个地点
[0.0, 1.0], # 前:一个方位
[0.9, 0.1], # 明:和天空、光有关
[1.0, 0.0], # 月:天空里的东西
[0.8, 0.2]]) # 光
q_guang = torch.tensor([1.0, 0.0]) # "光"想找的是:和天空有关的东西
scores = k @ q_guang
== 2. 点积打分:查询(q)和每个键(k)越像,分数越高
'光' 对每个词的分数: {'床': 0.1, '前': 0.0, '明': 0.9, '月': 1.0, '光': 0.8}
softmax 之后的权重: {'床': 0.12, '前': 0.11, '明': 0.26, '月': 0.29, '光': 0.23}
分数放大 5 倍再 softmax: {'床': 0.01, '前': 0.0, '明': 0.3, '月': 0.5, '光': 0.18}
Les scores passent par softmax pour devenir des poids (softmax, vu à la leçon 5 du module 08), de somme 1. « 月 » a le score le plus élevé et reçoit le poids le plus grand.
Remarquez la dernière ligne : si l'on multiplie tous les scores par 5, les poids après softmax deviennent bien plus concentrés, et « 月 » en prend à lui seul la moitié. La taille des scores décide si l'attention est « répartie sur tous » ou « fixée sur un seul ». C'est important pour la mise à l'échelle, plus bas.
Dans un vrai modèle, q et k ne sont pas fabriqués à la main, mais calculés à partir du vecteur de chaque caractère par deux matrices, dont les paramètres sont appris à l'entraînement. Le modèle apprend lui-même quel type de caractère doit chercher quel type d'information.
La valeur : le contenu réellement transmis
Une fois les scores attribués, on sait combien prendre à chaque position ; mais que prend-on ? Un troisième vecteur : la valeur (value, v), qui signifie « si tu me prêtes attention, voici l'information que je te donne ».
Pourquoi ne pas prendre directement le vecteur d'origine ? Parce que « l'information qui sert à la correspondance » et « l'information à transmettre » ne sont pas forcément les mêmes. Les séparer rend le modèle plus souple.
Les trois vecteurs réunis, voilà toute l'attention :
权重 = softmax(q 和每个 k 的点积)
输出 = 用这些权重,把每个位置的 v 加起来
Le masque causal : interdit de regarder plus loin
À l'entraînement, le modèle doit prédire le caractère suivant à chaque position. Si la 3e position pouvait voir le 4e caractère, elle pourrait directement « copier la réponse » et n'apprendrait rien. Chaque position ne peut donc voir qu'elle-même et les positions précédentes.
Pour cela, avant softmax, on met à moins l'infini les scores des positions suivantes. L'exponentielle de moins l'infini vaut 0, et après softmax, leur poids est 0 :
scores = q @ k.T / math.sqrt(8)
mask = torch.ones(T, T, dtype=torch.bool).tril()
scores = scores.masked_fill(~mask, float("-inf"))
== 3. 因果掩码:把后面位置的分数设成负无穷,softmax 之后权重就是 0
tensor([[1.00, 0.00, 0.00, 0.00, 0.00],
[0.96, 0.04, 0.00, 0.00, 0.00],
[0.24, 0.26, 0.50, 0.00, 0.00],
[0.09, 0.39, 0.43, 0.09, 0.00],
[0.02, 0.14, 0.02, 0.09, 0.73]])
Comparez avec la matrice de moyenne de la première section : même forme, triangulaire inférieure, chaque ligne de somme 1. La différence : les poids de chaque ligne ne sont plus égaux, ils sont décidés par q et k. Ici, q et k sont aléatoires, et les poids semblent sans logique ; après l'entraînement, ils prendront un sens.
À cause de ce masque, cette attention s'appelle auto-attention causale : auto-attention parce que q, k et v viennent tous du même texte, causale parce qu'on ne voit que le passé, pas l'avenir. Les modèles comme GPT, qui « écrivent caractère après caractère », l'utilisent tous.
Pourquoi diviser par la racine de la dimension
Le code ci-dessus contient un / math.sqrt(8), où 8 est la dimension de q et k. Cette étape s'appelle la mise à l'échelle ; voyons ce qui se passe sans elle :
== 4. 为什么要除以 √d:维度越大,点积的数值越大,softmax 会变得非常极端
d= 16 点积的标准差 3.89(√d = 4.00) 每行最大权重的平均:不缩放 0.79,除以 √d 后 0.53
d= 64 点积的标准差 8.17(√d = 8.00) 每行最大权重的平均:不缩放 0.86,除以 √d 后 0.47
d= 256 点积的标准差 16.08(√d = 16.00) 每行最大权重的平均:不缩放 0.95,除以 √d 后 0.47
d= 1024 点积的标准差 32.53(√d = 32.00) 每行最大权重的平均:不缩放 0.92,除以 √d 后 0.39
Le produit scalaire de deux vecteurs aléatoires est une somme de d produits. Plus il y a de termes, plus le résultat fluctue, avec un écart type d'environ racine de d : pour d=256, l'écart type du produit scalaire vaut 16.
Quand les scores fluctuent beaucoup, comme avec la « multiplication par 5 » de la deuxième section, softmax devient extrême : sans mise à l'échelle, le plus grand poids de chaque ligne est en moyenne entre 0,8 et 0,95, presque toute l'attention va à une seule position. C'est très mauvais au début de l'entraînement : là où les poids sont proches de 0 ou de 1, le gradient de softmax est minuscule, et le modèle a du mal à apprendre.
En divisant par la racine de d, l'écart type des scores revient autour de 1, le plus grand poids de chaque ligne est entre 0,4 et 0,5, l'attention est répartie, les gradients sont normaux, et le modèle peut apprendre peu à peu à qui prêter attention.
Une tête d'attention complète
Réunissons tout ce qui précède :
d_model, d_head = 16, 8
x = torch.randn(T, d_model)
W_q, W_k, W_v = (torch.randn(d_model, d_head) / math.sqrt(d_model) for _ in range(3))
q, k, v = x @ W_q, x @ W_k, x @ W_v
scores = (q @ k.T / math.sqrt(d_head)).masked_fill(~mask, float("-inf"))
out = F.softmax(scores, dim=-1) @ v
== 5. 一个完整的注意力头:q、k、v 都由同一个输入经过不同的矩阵得到
输入 (5, 16) → q、k、v 各 (5, 8) → 输出 (5, 8)
和 PyTorch 自带的 scaled_dot_product_attention 比,最大差别 2.4e-07
Cinq lignes de code : trois matrices transforment l'entrée en q, k et v ; notation, mise à l'échelle, masque ; softmax ; somme pondérée. Le résultat concorde avec le scaled_dot_product_attention intégré à PyTorch (l'écart est à la septième décimale, une erreur due à un ordre différent des calculs en virgule flottante).
W_q, W_k et W_v sont les paramètres que cette tête d'attention doit apprendre. À l'entraînement, ils sont mis à jour par rétropropagation et descente de gradient, comme les autres paramètres.
Cette formule vient de l'article de 2017 « Attention Is All You Need » et s'écrit mathématiquement softmax(QKᵀ/√d)V. Vous savez maintenant à quoi sert chacun de ses symboles.
Le coût de l'attention
Chaque position doit être notée par rapport à toutes les positions précédentes. Pour une séquence de longueur T, la table des scores fait T×T. En doublant la longueur, le calcul et la mémoire occupée par cette table sont multipliés par quatre.
C'est l'une des raisons fondamentales du coût élevé des longs contextes (contexte et coût, leçon 4 du module 01). Pour prendre en charge des contextes de centaines de milliers, voire de millions de tokens, les fournisseurs ont beaucoup optimisé le calcul de l'attention, mais l'idée de base reste ces quelques lignes.
Exercices
- Dans la deuxième section, donnez à « 光 » la requête
[0, 1](il cherche quelque chose lié au lieu). Comment les poids changent-ils ? - Dans la cinquième section, retirez le masque causal et comparez avec
scaled_dot_product_attention(..., is_causal=False). - Passez la longueur de séquence de la cinquième section de 5 à 1000, 2000 et 4000, et mesurez avec
time.time()le temps pour chaque longueur. Croît-il à peu près comme le carré ?
Auto-test
1. Quel rôle jouent respectivement la requête, la clé et la valeur ?
La requête signifie « ce que je cherche », la clé « ce que j'ai ». Le produit scalaire de la requête d'une position avec les clés de toutes les positions donne ses scores d'attention pour chaque position, que softmax transforme en poids. La valeur signifie « l'information qu'on obtient en me prêtant attention » ; la sortie est la somme des valeurs de toutes les positions pondérée par ces poids.
2. Comment le masque causal est-il réalisé ? Pourquoi est-il nécessaire ?
Avant softmax, on met à moins l'infini les scores de toutes les positions situées après chaque position ; après softmax, leur poids vaut 0. Il est nécessaire parce que le modèle doit apprendre à prédire le caractère suivant ; s'il pouvait voir les caractères suivants, il copierait directement la réponse et n'apprendrait rien.
3. Pourquoi divise-t-on les scores d'attention par la racine de la dimension ?
Le produit scalaire de deux vecteurs de dimension d a un écart type d'environ racine de d ; plus la dimension est grande, plus les scores fluctuent, et les poids après softmax se concentrent de façon extrême sur une seule position, les gradients deviennent minuscules et le modèle est difficile à entraîner. Diviser par la racine de d ramène l'écart type des scores autour de 1.
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…