bobofamilyNIU CHUN
24/36

只改一行:注意力公式里的每一个符号是怎么来的

2026-09-2321 分钟—阅读

这一篇的终点是一个公式:

Attention(Q, K, V) = softmax( QKᵀ / √dₖ ) · V

论文里一行,代码里五行。这一篇从最笨的办法出发,把它的每一个符号一个一个挣出来 —— 到最后回头看,会发现从「求平均」到「注意力」,代码只改了一行。


现状:hou、you、thou 查的是同一行

第二十三篇那个 bigram 的 forward 只有一句:

logits = self.token_embedding_table(idx)

每个位置的预测只取决于那个位置自己的字符。 拿三个上下文完全不同的词试:

for s in ['hou', 'you', 'thou']:
logits, _ = m(torch.tensor([encode(s)]))
print(s, [itos[i.item()] for i in logits[0, -1].topk(3).indices])

三行输出完全相同。 三个 idx 的最后一个数都是 59(u),bigram 只看它:

torch.equal(logits_hou[0, -1], logits_you[0, -1]) # True,一个数都不差

故意传三个字符而不是只传 u,是为了证明「多给了也没用」—— 信息就在那里,模型用不上。 它不知道自己在拼 house 还是 you 还是 thou,这就是它写不出真词的原因。


硬约束:位置 t 只能看 0…t

要的是「位置 2 的输出 = 综合 h、o、u」,不只是 u。但有一条硬约束。

第二十三篇说过一个长度 8 的片段同时在训练 8 道题:xb[0] = "Let's he", 位置 3 看到 Let' 要猜 s。如果位置 3 能看到位置 4 的 s,它就直接看到答案了。

🔑 位置 t 只能看位置 0…t。 下面每个方案都必须满足这一条。 第二十三篇结尾说「下三角 mask 就是为了保证这 T 条互不作弊」—— 这一篇兑现它。


先把表示和分数拆开

bigram 的 nn.Embedding(65, 65) 查出来的向量身兼两职:既是「字符的表示」,又直接是「预测的分数」。 这样有三个问题:中间插不进任何东西;表示被绑成了答案的形状;维度被词表绑死(GPT-2 词表 5 万,表示就得 5 万维)。

拆开:

self.token_embedding_table = nn.Embedding(vocab_size, n_embd) # (65, 32) 入口:字符 → 表示
self.lm_head = nn.Linear(n_embd, vocab_size) # (32, 65) 出口:表示 → 分数

入口负责「是什么」,中间负责「看上下文」,出口负责「猜什么」。 神经网络视角是从「65 → 65」一层 变成「65 → 32 → 65」两层。

参数量几乎没变:65×65 = 4225,拆开后 65×32 + 32×65 + 65 = 4225。 拆开本身不带来任何提升,它只是腾出了位置。

💡 lm_head 是行业通用名:lm = language model,head = 头。主干负责理解、通常很大; 头负责给具体任务的答案、通常很小。打开 Qwen 的模型结构,最后一层就叫 lm_head。 同一个主干换个分类头,就是微调的基本思路。

顺带解释第二十三篇那个「初始 loss 比瞎猜还差一半」为什么会变:bigram 的 logits 直接来自 Embedding(标准正态), 现在要经过 lm_head,而 nn.Linear 默认用 U(−1/√32, 1/√32) = U(−0.177, 0.177) 初始化 —— 权重很小,初始 logits 贴近 0,初始 loss 更接近 ln 65 = 4.17。 第十四篇手动乘 0.01 的那件事,nn.Linear 的默认初始化替你做了。


方案一:求平均

最直接的「综合前面所有位置」:

x̄ₜ = (1/(t+1)) · Σᵢ₌₀ᵗ xᵢ

三个向量走一遍:

x₀ = [2, 7] ← h
x₁ = [6, 4] ← o
x₂ = [6, 5] ← u
位置 0:x̄₀ = [2, 7]
位置 1:x̄₁ = ([2,7] + [6,4]) / 2 = [4, 5.5]
位置 2:x̄₂ = ([2,7] + [6,4] + [6,5]) / 3 = [4.67, 5.33]

位置 2 的新向量里混进了 h、o、u 三个字符的信息。这叫词袋(bag of words)—— 「通信」最弱的形式:丢掉顺序,每个过去的位置同等重要。

这里的向量是 2 维,是为了能手算;实际是 n_embd = 32。求平均对每一维是独立的, 维度变了,代码一个字不用改 —— 就是 (B, T, C) 里那个 C。

问题是慢:双重 for 循环,B=64、T=256 时每一步要跑 16384 次 Python 循环,没法在 GPU 上并行。


方案二:矩阵乘就是加权求和

★ 推导:c 的每一行,是在组合 b 的各行

设 c = a · b。按矩阵乘的定义,c 第 i 行第 d 列是

c[i][d] = Σⱼ a[i][j] · b[j][d]

把 d 那一维收起来,整行一起看:

c[i] = Σⱼ a[i][j] · b[j] ← b[j] 是 b 的第 j 行(一个向量)

🔑 c 的第 i 行,是 b 各行的加权和,权重就是 a 的第 i 行。 所以只要把「用什么比例混合」写进 a,一次矩阵乘法就完成了全部位置的混合。

还是那三个向量,排成 b,换三种 a:

① 全 1 = 全部加起来(但违反了「不能看未来」):

a = b = c = a @ b =
[[1, 1, 1], [[2, 7], [[14, 16],
[1, 1, 1], [6, 4], [14, 16],
[1, 1, 1]] [6, 5]] [14, 16]]

② 下三角 = 只加过去:

a = b = c = a @ b =
[[1, 0, 0], [[2, 7], [[ 2, 7], ← 只有 x₀
[1, 1, 0], [6, 4], [ 8, 11], ← x₀ + x₁
[1, 1, 1]] [6, 5]] [14, 16]] ← x₀ + x₁ + x₂

上三角的 0 把「未来」的权重设成了零。 这是推导那一行的直接后果: a[i][j] = 0 就意味着 b[j] 对 c[i] 毫无贡献。

③ 每行归一化 = 求平均:

a = c = a @ b =
[[1.00, 0, 0 ], [[2.00, 7.00], ← x₀
[0.50, 0.50, 0 ], [4.00, 5.50], ← (x₀+x₁)/2
[0.33, 0.33, 0.33]] [4.67, 5.33]] ← (x₀+x₁+x₂)/3

和方案一手算的结果完全一样。 一次矩阵乘 = 方案一的全部 for 循环,而且 GPU 上高度并行。

wei = torch.tril(torch.ones(T, T)) # (8, 8) 下三角
wei = wei / wei.sum(1, keepdim=True) # 每行归一化
xbow2 = wei @ x # (8,8) @ (4,8,2) → (4,8,2)
torch.allclose(xbow, xbow2) # True

形状为什么对得上:(8,8) 和 (4,8,2) 维数不同,PyTorch 把 wei 广播成 (4,8,8), 对每个序列各做一次 (8,8) @ (8,2) —— 批量矩阵乘。

⚠️ keepdim=True 不能漏:sum(1) 是 (8,),会按列广播,结果错了但不报错。 和第十九篇第 ③ 步是同一个坑。

问题:权重只能是均匀的

预测 hou 的下一个字符时,h 最重要(它决定了是 house 不是 you)。需要不均匀的权重,而且最好由数据算出来。 但把任意分数塞进方案二:

分数 [2, −1, 3] → 除以行和 4 → [0.5, −0.25, 0.75] ❌ 出现负权重
分数 [1, −1, 0] → 行和 = 0 → 除零 ❌ 直接炸

再加上屏蔽未来:任意分数的上三角不一定是 0,必须有办法强制它变成 0。


方案三:softmax 和 −∞

工具解决什么
softmax任意分数(正负都行)→ 全正、加起来为 1
−∞未来位置填 −∞,softmax 之后恰好是 0,因为 e^(−∞) = 0
原始分数: [ 2, −1, 3, 5 ] ← 位置 3 的 5 是未来
① 未来填 −∞: [ 2, −1, 3, −∞ ]
② 取 exp: [ 7.39, 0.37, 20.09, 0 ]
③ 除以总和: [ 0.27, 0.01, 0.72, 0 ] 总和 27.84

全正、和为 1、未来恰好是 0 —— 不管那个未来分数本来有多大。而且分数越高权重越大。

分数全是 0 时呢:

[0, 0, 0, −∞] → exp [1, 1, 1, 0] → [0.33, 0.33, 0.33, 0]

🔑 方案二是方案三在「分数全为 0」时的特例。 所以方案三既能复现均匀平均,又能处理任意分数。

tril = torch.tril(torch.ones(T, T))
wei = torch.zeros((T, T)) # ① 分数全为 0
wei = wei.masked_fill(tril == 0, float('-inf')) # ② 上三角填 −∞
wei = F.softmax(wei, dim=-1) # ③ 每行 softmax
out = wei @ x # ④ 加权求和

softmax 本身第七篇讲过。和这里有关的是它那条平移不变性(所有分数加同一个数,eᶜ 在分子分母里约掉)—— 第十九篇第 ⑧ 步「dlogit_maxes 必须恰好是 0」就是它。

★ 矩阵归一化必须先说「沿哪个方向」

同一个矩阵 [[1,1,0],[2,1,1],[3,3,2]],按行归一化(行和 2、4、8)得第三行 [0.38, 0.38, 0.25]; 按列归一化完全是另一个矩阵;整体归一化又是一个。不说方向,「归一化」这个词就没有意义。

注意力里是按行:wei 的第 t 行是位置 t 对所有位置的打分,每个位置要决定「我的注意力怎么分给大家」, 所以它那一行加起来是 1。按列就成了「位置 0 被大家看的比例加起来是 1」—— 不是要的东西。

🔑 判断方法:问「谁在分配、分配给谁」。分配者是行,被分配的是列 → 按行归一化。 每行非负且和为 1 的矩阵叫行随机矩阵(row-stochastic),wei 就是这种。

同样的方向问题也在标准化里:BatchNorm 按列(跨样本,x.mean(0)),LayerNorm 按行(跨特征,x.mean(-1))。 BN 按列所以样本耦合、batch=1 会崩 —— 这是后面 Transformer 用 LayerNorm 的伏笔。


位置嵌入:注意力是集合操作

注意力是加权求和,加法不关心顺序。dogs 和 gods 的位置 3 看到的是同一堆向量 {d, o, g, s},结果一模一样。 因果 mask 只规定了「能看哪些」,没规定「它们的顺序」。

self.position_embedding_table = nn.Embedding(block_size, n_embd) # (8, 32)
tok_emb = self.token_embedding_table(idx) # (B,T,C)
pos_emb = self.position_embedding_table(torch.arange(T, device=device)) # (T,C)
x = tok_emb + pos_emb # (B,T,C)

和 token 表结构完全一样,只是查表的钥匙换成了位置编号。 缩小演示(d=[1,0]、g=[2,1]、pos0=[0.1,0]、pos2=[0.3,0]):

dog: d+pos0 = [1.1, 0.0] … g+pos2 = [2.3, 1.0]
god: g+pos0 = [2.1, 1.0] … d+pos2 = [1.3, 0.0]

同一个 d,在位置 0 是 [1.1, 0]、在位置 2 是 [1.3, 0] —— x 里同时编码了「是谁」和「在哪」。

为什么加不拼接:加法维度不变,后面的层不用改;32 维空间够大,训练会让两种信息占用不同方向。

这是学习式位置嵌入(GPT-2 用这种)。原版 Transformer 用正弦式(固定公式、不训练); Llama、Qwen 用 RoPE(旋转位置编码,作用在 q、k 上)。学习式的表只有 block_size 行、超出就查不到, 这是现代模型换掉它的主要原因。所以 generate() 要加一行 idx_cond = idx[:, -block_size:]。

⚠️ 这一节改完,loss 不降是对的(仍约 2.49):还没有注意力,每个位置的 logits 只取决于「这个字符 + 第几位」。 这一步只是搭好架子。


只改一行:q 和 k

方案三的第一行 wei = torch.zeros((T, T)),分数全是 0。现在要让它由内容算出来:

wei = q @ k.transpose(-2, -1)

这就是整个注意力机制和「求平均」的全部区别。 mask、softmax、乘上去那三行一个字不改。

向量含义类比
query(q)我在找什么读者的搜索词
key(k)我有什么(供别人匹配)书的编目标签

位置 t 对位置 s 的打分 = qₜ · kₛ。点积衡量方向一致的程度:一致则大、垂直为 0、相反为负。

q 和 k 由同一个 x 分别乘两个矩阵得来:

key = nn.Linear(C, head_size, bias=False)
query = nn.Linear(C, head_size, bias=False)
k = key(x) # (B,T,32) → (B,T,16)
q = query(x)

每个位置独立计算、共用同一个 W,这一步还没有位置之间的通信。

为什么要两个矩阵,不能直接用 x:q = k = x 时只能衡量「两个字符像不像」。 u 要找的是「开头的辅音」,而它自己是元音 —— 两个独立的矩阵才能让「找的」和「有的」不一样。

bias=False 是一种简化:k 的偏置会给同一行所有分数加同一个常数,而 softmax 平移不变,加了等于没加; q 的偏置其实不白加(它给每个被看的位置加一个固定分数,第二十六篇展开算),只是关掉影响不大。

★ 推导:qₜ · kₛ 可以写成 xₜ W_q W_kᵀ xₛᵀ

q 和 k 是行向量:qₜ = xₜ W_q,kₛ = xₛ W_k。两个行向量的点积可以写成 a · b = a bᵀ,于是

qₜ · kₛ = (xₜ W_q)(xₛ W_k)ᵀ
= xₜ W_q · W_kᵀ xₛᵀ ← 用 (AB)ᵀ = BᵀAᵀ
= xₜ · M · xₛᵀ 其中 M = W_q W_kᵀ

形状 (1×32)(32×16)(16×32)(32×1) = 一个数。

🔑 M = W_q W_kᵀ 是一张 32×32 的「兼容性表」:M[i][j] 是提问方第 i 维和被问方第 j 维之间有多相互吸引。 单看 W_q 或 W_k 意义有限,两者的乘积才决定谁会注意谁。

M 一般不对称,所以注意力不对称。 用后面那个手算 demo 的数直接看得见:

u 看 h:q_u · k_h = 4.0
h 看 u:q_h · k_u = 1.0

u 很想看 h,h 却不太想看 u。如果 W_q = W_k,M = WWᵀ 就对称了,这种单向的关注学不出来。

✅ 两种算法各算一遍核过:先算 q、k 再点积,和先算 M 再算 xₜ M xₛᵀ,u 对 h 都是 4.0。

为什么不直接学 M:先压到 16 维再两两比较更便宜;而且 rank(M) ≤ 16,自由度受控 —— 后面多头时,每个头只在自己的小子空间里判断相关性。

wei 不是参数,是算出来的

W_q、W_kwei
是什么参数中间计算结果
怎么来初始化时随机生成每次前向由 q、k 算出
存在模型里吗✅❌ 算完就扔
换个输入会变吗不变完全不同

⚠️ 同一个变量名 wei 存了三个阶段:原始分数 → mask 后 → softmax 后。读代码要看它上面那一行做了什么。

三角形状是 mask 定的,里面的数值是内容算的。 q @ k.T 先算出完整的分数矩阵(包括看未来的 —— 在 GPU 上算满再遮,比只算下三角快),再由 mask 把上三角设成 −∞。

k.transpose(-2, -1)

交换最后两个维度。q (B,T,16) @ k.T (B,16,T) → wei (B,T,T),第 [t][s] 格恰好是 qₜ · kₛ。

写 (-2, -1) 不写 .T:负下标从后往前数,永远只转最后两维,batch 维不动。 后面多头时 k 变成 4 维,这一行一个字不用改。

wei 的形状从方案三的 (T,T) 共用,变成了 (B,T,T) —— 每个序列各有一张。


v:被找到之后给出什么

方案三最后一步是 wei @ x,被关注的位置把原始表示原样交出去。第三个投影把它换掉:

value = nn.Linear(C, head_size, bias=False)
v = value(x) # (B,T,32) → (B,T,16)
out = wei @ v # 混合的是 v,不是 x
作用参与哪一步
k决定我会不会被找到和 q 点积 → 算分数
v决定被找到之后给出什么按分数加权 → 算输出

图书馆类比:靠标签(k)找到书,带回家的是内容(v)。

🔑 改 W_v,wei 一个数都不变,只有 out 变。 q、k 决定「看谁、看多少」,v 决定「看到之后拿到什么」。


手算一遍

一个注意力头的六步,全部用同一组 hou 的数。u 把 80% 的注意力给了 h,于是 u 的新表示里装进了「前面有个 h」—— 这正是 bigram 缺的东西。

x 用 one-hot(T=3、C=3、head_size=2),三个权重矩阵手动设成整数,每一步都能手算:

x = torch.eye(3) # h o u
query.weight.copy_(torch.tensor([[1., 0., 2.], [0., 1., 0.5]]))
key.weight.copy_( torch.tensor([[2., 0., 1.], [0., 1., 1. ]]))
value.weight.copy_(torch.tensor([[10., 0., 5.], [0., 10., 5.]]))
步骤h 行o 行u 行
q[1, 0][0, 1][2, 0.5]
k[2, 0][0, 1][1, 1]
v[10, 0][0, 10][5, 5]
① 原始分数[2, 0, 1][0, 1, 1][4, 0.5, 2.5]
② mask[2, −∞, −∞][0, 1, −∞][4, 0.5, 2.5]
③ softmax[1, 0, 0][0.27, 0.73, 0][0.80, 0.02, 0.18]
④ out[10, 0][2.69, 7.31][8.87, 1.13]

手算 u 的输出:0.80×[10,0] + 0.02×[0,10] + 0.18×[5,5] = [8.87, 1.13],几乎就是 v_h —— u 从 h 那里拿到了信息。 换一个序列(you),同一套权重算出来的 wei 就不同 —— 这就是「由内容算出」。

✅ 整张表用纯 Python 从头复算过,每一格都对得上。

为什么叫「自」注意力:q、k、v 全部来自同一个 x,序列自己跟自己做注意力。


六条注解

写完一个头,Karpathy 停下来补了六条注解。没有新模型,每一条回答一个「为什么要这么设计」。

① 注意力是有向图上的通信

把 wei 画成图:每个位置是节点,每个非零格是一条边,边的粗细就是权重。同样的注意力机制,换一个 mask 就是换一种图的形状。

每个节点做同一件事:把指向自己的边上的 v 收过来,按边的粗细加权求和。 矩阵里灰色的格子,在图上根本不存在。

形状边怎么连mask 矩阵谁在用
因果(本集)节点 t 只接收 0…t下三角decoder:GPT、Llama、Qwen 的语言部分
全连接每个节点接收所有节点满的encoder:BERT、ViT
局部窗口只接收左右邻居对角带一些长文本模型省算力的做法

🔑 注意力机制本身只负责一件事:沿着边,按内容算出的权重搬运信息。图长什么样,由 mask 那一行决定。

② 没有空间概念

上面「位置嵌入」那一节讲过。补一个对照:卷积天生知道谁在左谁在右(左边乘这个权重、右边乘那个), 所以不需要位置编码;注意力按内容算权重、不分左右,必须加。ViT 要给每个图像块加位置编码也是这个原因。

③ batch 之间不通信

wei 是 (B, T, T),每个序列有自己独立的一张表,没有指向其他序列的边:

x2 = x.clone(); x2[1] = torch.randn(T, C) # 只换掉序列 1
torch.equal(out1[0], out2[0]) # True —— 序列 0 一个数都没变

「训练时每步梯度调整,序列之间肯定也有影响吧?」—— 对,但那是另一种影响:

发生在注意力BatchNorm
前向时的影响同一步里,参数固定❌ 没有✅ 有
通过参数更新的影响跨步,梯度改了参数✅ 有✅ 有

第二种是学习本身,所有网络都有,也必须有。区别只在第一种。 第十六篇测到的 「同一个样本放进两批,输出 −3.8143 和 −4.3548」就是第一种:参数没动,只换了同批的邻居,输出就变了。

为什么这一条要紧:推理时只有前向。服务器把很多用户的请求打包成一个 batch 一起算 —— 如果用 BN,你的回答会因为「刚好跟谁一起被算」而不同。这是 Transformer 用 LayerNorm 不用 BatchNorm 的原因之一。

④ encoder 和 decoder:也只差一行

mask 存在的唯一理由是任务:我们在预测下一个字符,不能让位置 t 看到答案。删掉那一行,每个位置就能看到所有位置:

有 mask没有 mask
名字decoderencoder
图的形状下三角(只看过去)全连接(看所有)
适合生成:写文章、对话理解:分类、检索、提取特征
代表GPT、Llama、QwenBERT、ViT

🔑 名字听起来像两种架构,代码只差一行。 这是这一篇第二个「只改一行」。

名字从哪来:2017 年原版 Transformer 是个翻译模型。encoder 一次读完整句中文、「编码」成表示 —— 中文一次性全给,每个字能看所有字;decoder 根据表示把英文逐词「解码」出来 —— 只能看已经生成的。 名字描述的是它在翻译流水线里的角色,mask 是这个角色的自然结果。 后来两半被拆开单用:BERT 只要 encoder,GPT 只要 decoder。

什么时候才需要 mask:只有一种情况 —— 答案就藏在输入序列里,而且在当前位置的右边。

任务答案在哪注意力 mask
预测下一个字(GPT)输入里,右边一格必须有
情感分类不在输入里(一个标签)不要
完形填空(BERT)输入里,但已被抠掉换成 [MASK]不要
看图(ViT)图片一次性全给不要

完形填空的防作弊方式不是 mask 注意力,而是直接把答案从输入里抠掉 —— 所以注意力可以放心看两边。

今天的大模型多是 decoder-only,因为理解也能包装成生成(「这部电影一点也不好看。情感是:」→「负面」)。 但 encoder 没消失:看图的 ViT、做向量检索的 embedding 模型都是它。Qwen2.5-VL 两种都用: 图片先过视觉 encoder,再交给语言 decoder。

一个能跑的反例:在 decoder 里注释掉 masked_fill 那一行,train loss 会迅速掉到接近 0(每个位置直接抄右边的答案), 但生成一塌糊涂 —— 生成时右边根本没有东西可抄。这就是「作弊」的具体样子。

⑤ 自注意力与交叉注意力

翻译时生成英文 love,需要去看中文里的「爱」—— 提问的是英文序列,被查的是中文序列:

q = query(x_英文) # 我在找什么
k = key(x_中文) # 我有什么
v = value(x_中文)
wei = q @ k.transpose(-2, -1) # (T_英, T_中),可以不是方阵
自注意力交叉注意力
q 来自x序列 A
k、v 来自x序列 B
wei 形状(T, T)(T_A, T_B)

多模态的核心问题就是「文字怎么看图」:q 来自文字 token,k/v 来自图像特征。 有的模型显式用交叉注意力层;Qwen-VL 这类把图像 token 和文字 token 拼成一个序列,再用自注意力让它们互相看。

⑥ 为什么要除以 √dₖ

真正的实现是

wei = q @ k.transpose(-2, -1) * head_size ** -0.5

实测:q、k 都是方差 1 的随机数,head_size = 4、16、64、256 时,wei 的方差分别约是 4、16、64、255。 方差等于维度。

★ 推导:Var(q · k) = d

点积是 d 项乘积之和:

q · k = Σᵢ₌₁ᵈ qᵢ kᵢ

第一步,各项彼此独立,方差可以相加(第十五篇推过这条规则):

Var(q · k) = Σᵢ Var(qᵢ kᵢ)

第二步,算每一项。qᵢ 和 kᵢ 独立、均值 0、方差 1:

Var(qᵢ kᵢ) = E[(qᵢ kᵢ)²] − (E[qᵢ kᵢ])²
= E[qᵢ²] · E[kᵢ²] − (E[qᵢ] · E[kᵢ])² ← 独立,期望可以拆开
= 1 · 1 − (0 · 0)²
= 1

第三步,d 个 1 加起来:

Var(q · k) = d

所以除以 √d,方差就回到 1:

Var( (q · k) / √d ) = d / d = 1

✅ 纯 Python 模拟核过(每个维度 2 万次):d = 4、16、64、256 时,Var(q·k) 是 3.91、15.98、63.76、258.20, 除以 √d 之后是 0.978、0.999、0.996、1.009。

🔑 这就是第十四篇那条公式换了个地方用:当时是 x @ W 的输出方差 = fan_in × 输入方差, 结论是权重要乘 1/√fan_in。这里是 q @ kᵀ 的输出方差 = head_size × 1,结论是分数要乘 1/√head_size。完全同一件事。

不除会怎样:softmax 变成 one-hot

同一组分数,放大到对应的标准差:

s = [0.1, −0.2, 0.3, −0.2, 0.5]
softmax(s) → [0.19, 0.14, 0.24, 0.14, 0.29] 比较均匀
softmax(s × 4) → [0.11, 0.03, 0.25, 0.03, 0.56] ← head_size=16,标准差 ×4
softmax(s × 8) → [0.03, 0.00, 0.16, 0.00, 0.80] ← head_size=64,几乎只看一个

两个后果:信息只从一个节点来 —— 本来要综合前面,结果成了只抄一个;梯度消失 —— softmax 在 one-hot 附近饱和, 改分数几乎不改概率。和第十四篇的 tanh 饱和是同一类问题。 而且初始化时最致命:还没学会看谁,就被随机锁死在某一个位置上。


公式里的每一个符号

现在回到开头那一行:

Attention(Q, K, V) = softmax( QKᵀ / √dₖ ) · V
符号代码在这一篇哪里挣来的
Q、K、Vquery(x)、key(x)、value(x)「只改一行」与「v」两节
QKᵀq @ k.transpose(-2, -1)同上,外加 xWxᵀ 那个推导
1/√dₖ* head_size ** -0.5注解 ⑥,Var(q·k) = d
mask(公式里省略了)masked_fill(tril == 0, -inf)方案三
softmaxF.softmax(wei, dim=-1)方案三,按行归一化
· Vwei @ v方案二的「矩阵乘就是加权求和」

论文里 Scaled Dot-Product Attention 的 Scaled,指的就是这个 √dₖ。 公式里每一个符号都认识了。

回头看:从方案三的「求平均」到注意力,代码只改了一行 wei = torch.zeros((T, T)) → wei = q @ k.transpose(-2, -1), 外加 x 换成 v。mask 和 softmax 那两行,从方案三起就没动过。

下一步是把这个头装进模型、加上缩放,loss 终于要从 2.49 往下走了。