注意力到底在算什么:打分表 → 概率 → 混合 value
🎯 先猜一猜(下注解锁)
注意力公式里,Q·Kᵀ 算完不直接 softmax,非要先除以一个 √head_dim。这个除法看着多余,去掉会怎样?
先押一个假设:如果去掉 √head_dim 这个缩放,最可能出什么问题?
先补的知识
- 先记一个生活类比:注意力就像“查资料”——你心里的问题是 Query,每份资料的标题是 Key,资料的正文是 Value。标题越对得上你的问题,就越多地去读它的正文。
- Q/K/V 都是同一个输入 x 各乘一个不同的 Linear 矩阵得到的。同一句话,从“我想问什么(Q)/我能回答什么(K)/我有什么内容(V)”三个角度各投影一次。
- “多头”= 把每个 token 的长向量切成 H 段,每段单独做一遍上面的查资料,让不同的“头”各盯一种规律(有的盯语法、有的盯指代)。计算时摆成 [B, H, S, D]:批次、头、序列长度、每头维度。
- softmax:把一排任意大小的分数压成“加起来等于 1 的概率”,分数越高拿到的概率越大。
图解原理
一句话版:每个 token 拿自己的 Query 去和所有 token 的 Key 比一比“我跟你多相关”,得到一张打分表;softmax 把每一行变成一组概率;再按这组概率去混合所有 token 的 Value,混出来的就是这个 token 的新表示。下面把这 4 步拆开、每步配图,并在关键处停下来让你先想一想再看解释。
读“它躺在桌上”时,你要搞清楚“它”指什么,就得回头看前面的词。注意力做的就是这件事:让每个 token 都能按需去看序列里的其他 token,再把看到的信息揉进自己的新表示里。“按需”——就是下面那张打分表决定的。
想一想:为什么 Q、K、V 要用三个不同的矩阵,而不是直接拿 x 本身去两两点积?
如果直接用 x 和 x 点积,那“我作为提问方”和“我作为被查方”用的是同一个向量,模型没法分别学习“我想找什么”和“我能提供什么匹配特征”这两件不同的事。
拆成 Q、K 两个独立投影后,同一个词可以“以一种姿态发问、以另一种姿态被检索”,匹配关系才学得灵活。V 再单独一个矩阵,是因为“被选中后该交出什么内容”又是第三件事。三个角色、三个矩阵,各司其职。
投影后 Q 是 [B, S, H·D],先 view 成 [B, S, H, D](把最后一根长向量切成 H 段),再 transpose(1,2) 成 [B, H, S, D]。为什么要转置?因为下一步要让每个头(H)各自独立地在它自己的 [S, D] 小表上做矩阵乘法,把 H 提到前面、和 batch 并排当“批量维”最顺手。
直觉:H 个头像 H 个分工不同的读者,有的盯语法、有的盯指代,各读各的,最后再汇总。
Q 是 [.., S, D],K 转置成 [.., D, S],相乘得到 [.., S, S]。第 i 行第 j 列 = 第 i 个 query 和第 j 个 key 的点积 = “token i 该多关注 token j”。
点积 = 把 D 个数乘起来再相加。D 越大,这个和的“波动范围”(方差)就越大,分数越容易出现某个特别大的极端值。直接喂给 softmax,它会几乎把全部概率压到那一个 token 上(接近非 0 即 1 的 one-hot),别的位置概率趋近 0、梯度也趋近 0,模型就学不动了。除以 √D 正好把方差拉回到大约 1 的稳定区间,softmax 才平滑、可训练。
想一想:为什么偏偏是除以“√D”,而不是除以 D 或别的数?
假设 Q、K 的每个分量都是均值 0、方差 1 的独立随机数。点积是 D 个“乘积项”相加,每项方差约为 1,独立相加后总方差 ≈ D,于是标准差 ≈ √D。
我们想把分数的“典型尺度”拉回到 1 左右,所以除以标准差 √D 最自然——除以 D 会矫枉过正、把分数压得太小,反而让 softmax 太平、谁都差不多。这就是原论文选 √D 的来历。
Causal Mask:生成任务里给打分表的“上三角”填 −∞(代表“还没出现的未来 token”),softmax 后这些位置概率自然变 0,保证每个 token 只能看自己和左边。
probs [.., S, S] 乘 V [.., S, D] 得 [.., S, D]:每个 token 的新向量 = 按注意力概率混合所有 token 的 value。最后 transpose(1,2) 把 H 放回去、contiguous().view 合并成 [B, S, H·D],再过 o_proj 回到 hidden_dim。
易错点:view 前必须 .contiguous(),因为 transpose 只是改了“怎么读这块内存”的步长、并没真正搬动数据,直接 view 会因内存不连续报错。
注意力之所以打败了 RNN,关键在于它一步就能让任意两个 token 直接相连(RNN 要一格一格传,远距离信息会衰减),而且整张打分表能在 GPU 上并行算完。这是 Transformer 能堆大、能记长上下文的根本。
但代价也在这张表:它的大小是 S×S,序列翻倍、显存和计算就翻 4 倍。这就是为什么“长上下文”这么贵,也催生了 FlashAttention(不把整张表落地到显存,分块边算边累加)这类工程优化——本仓库后面的 FlashAttention 关就是专门拆这个的。今天你手写的这版是“教科书版”,理解它才能看懂工业版在优化什么。
语法热身:批量矩阵乘法只动最后两维
import torch.nn.functional as F
# [B, H, S, D] 想象成 B*H 个独立的 [S, D] 小矩阵
q = torch.randn(2, 4, 5, 8) # 5 个 query,每个 8 维
k = torch.randn(2, 4, 7, 8) # 7 个 key
# K 最后两维转置 [.., D, S],再和 Q 相乘 → 打分表 [.., S, S']
scores = q @ k.transpose(-2, -1) # [2, 4, 5, 7]
probs = F.softmax(scores / 8**0.5, dim=-1) # 沿 key 维归一化
# 前面的 batch、head 维(2,4)原样保留,只有最后两维参与乘法读法:@ 对 4D 张量只在最后两维做矩阵乘法,前面的维度当“批量”广播。softmax(dim=-1) 表示“对每一行的 key 打分做归一化”。
巩固一下
巩固(动手算):q=[2,4,5,8]、k=[2,4,7,8],scores = q @ k.transpose(-2,-1) 的 shape 是?
回到 notebook 的作业
这里不直接写答案。你已经拿到足够输入,最后用 notebook 的 TODO 做举一反三。