只改一行:注意力公式里的每一个符号是怎么来的
这一篇的终点是一个公式:
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] ← hx₁ = [6, 4] ← ox₂ = [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)) # ① 分数全为 0wei = wei.masked_fill(tril == 0, float('-inf')) # ② 上三角填 −∞wei = F.softmax(wei, dim=-1) # ③ 每行 softmaxout = 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.0h 看 u:q_h · k_u = 1.0u 很想看 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_k | wei | |
|---|---|---|
| 是什么 | 参数 | 中间计算结果 |
| 怎么来 | 初始化时随机生成 | 每次前向由 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 决定「看到之后拿到什么」。
手算一遍
x 用 one-hot(T=3、C=3、head_size=2),三个权重矩阵手动设成整数,每一步都能手算:
x = torch.eye(3) # h o uquery.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 停下来补了六条注解。没有新模型,每一条回答一个「为什么要这么设计」。
① 注意力是有向图上的通信
每个节点做同一件事:把指向自己的边上的 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) # 只换掉序列 1torch.equal(out1[0], out2[0]) # True —— 序列 0 一个数都没变「训练时每步梯度调整,序列之间肯定也有影响吧?」—— 对,但那是另一种影响:
| 发生在 | 注意力 | BatchNorm | |
|---|---|---|---|
| 前向时的影响 | 同一步里,参数固定 | ❌ 没有 | ✅ 有 |
| 通过参数更新的影响 | 跨步,梯度改了参数 | ✅ 有 | ✅ 有 |
第二种是学习本身,所有网络都有,也必须有。区别只在第一种。 第十六篇测到的 「同一个样本放进两批,输出 −3.8143 和 −4.3548」就是第一种:参数没动,只换了同批的邻居,输出就变了。
为什么这一条要紧:推理时只有前向。服务器把很多用户的请求打包成一个 batch 一起算 —— 如果用 BN,你的回答会因为「刚好跟谁一起被算」而不同。这是 Transformer 用 LayerNorm 不用 BatchNorm 的原因之一。
④ encoder 和 decoder:也只差一行
mask 存在的唯一理由是任务:我们在预测下一个字符,不能让位置 t 看到答案。删掉那一行,每个位置就能看到所有位置:
| 有 mask | 没有 mask | |
|---|---|---|
| 名字 | decoder | encoder |
| 图的形状 | 下三角(只看过去) | 全连接(看所有) |
| 适合 | 生成:写文章、对话 | 理解:分类、检索、提取特征 |
| 代表 | GPT、Llama、Qwen | BERT、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,标准差 ×4softmax(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、V | query(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) | 方案三 |
| softmax | F.softmax(wei, dim=-1) | 方案三,按行归一化 |
| · V | wei @ 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 往下走了。