Transformer 实现机制 · 第 8 课

自注意力 II —— 打分、缩放与 softmax

⏱ 约 30 分钟 · 前置:第 7 课(Q/K/V 投影)、第 5 课(点积) · 主干教材:Vizuara《The Transformers》1.12、1.15 · 本课目标:四步把注意力从头算到尾;搞懂缩放为什么除 √d_k;softmax 怎么把分数变成配比;亲手调出上下文向量;读懂论文那行著名公式

上一课你拿到了三副眼镜:每个词的嵌入 x,分别投影成 Q(提问)、K(标签)、V(内容)。但眼镜戴上只算"备齐了装备"——注意力本体一次都还没算。这一课把算盘打到每颗珠子:四个步骤,从原始嵌入一路算到上下文向量。算完你会发现,论文开头那行最唬人的公式,你每个符号都认识。

一、总览:四步把注意力算到底

整个自注意力就是下面这张"四步人话表"(Vizuara 1.12 的计算流程,用人话说一遍):

① 打分 q · k(点积) "我和他有多相关?" ② 缩放 ÷ √d_k "分数别随维度膨胀" ③ softmax 指数化 + 归一化 "分数 → 配比(和=1)" ④ 加权 V Σ 配比 × v = 上下文向量! 每个词(作为 Query)都走一遍这四步 → 每个词得到一个上下文向量。矩阵形式下,全部词并行一次算完。
自注意力四步:打分 → 缩放 → softmax → 加权 V。本课全程用「猫 吃 鱼」一个例子算到底。
贯穿全课的例子:「猫 吃 鱼」 三个词,d_k = 4 维,所有数字都是 0/1/2 的小整数,全程可心算。为了让你盯住四步本身,投影我们"已经替你做好了"——每个词的 q、k、v 直接给在下面(真实模型里,它们由这个词的嵌入向量 x——第 4 课查表得到的那一行——分别过三套 W 投影而来;全句各词的 x 按行堆成矩阵 X,一次矩阵乘法 X@W_q → Q 就把所有词的 q 同时算出,上一课讲过):
词q(提问)k(标签)v(内容)
猫[1, 1, 0, 0][1, 0, 1, 0][1, 0, 1, 0]
吃[0, 2, 0, 2][0, 1, 0, 1][0, 1, 0, 1]
鱼[0, 0, 1, 1][1, 1, 0, 0][2, 0, 0, 2]

二、第 ① 步:打分——为什么是点积

猫(作为提问者)要给句里每个词打一个"相关分",用的武器是点积(第 5 课的老朋友):两个向量方向越合拍,点积越大。所以 q·k 就是"我找的东西"和"他挂出的标签"有多对得上。手算猫这一行:

手算:猫行打分(3 次点积) 猫 → 猫: [1,1,0,0]·[1,0,1,0] = 1+0+0+0 = 1
猫 → 吃: [1,1,0,0]·[0,1,0,1] = 0+1+0+0 = 1
猫 → 鱼: [1,1,0,0]·[1,1,0,0] = 1+1+0+0 = 2
猫的原始分数 = [1, 1, 2]——鱼最合拍,猫和吃打平。(吃行、鱼行同法,留给你练手,下面代码对答案。)

顺带把第 5 课埋的伏笔收掉:为什么用点积,不用余弦?点积 = 余弦 × 两个模长——差就差在没除长度。注意力实践选点积,一是快(省两次除法);二是有意为之:长度本身就是模型可以用的表达("我很重要,我把自己的 k 拉长"),第 5 课说的"长度污染"在这里反而是特性。至于点积数值会随维度越滚越大——这正是第 ② 步要治的病。

三、第 ② 步:缩放——为什么除 √d_k(方差直觉)

符号解读:d_k 是什么?(新记号先认全) d = dimension(维度),下标 k = key(键)的首字母——d_k = 键向量(以及查询向量)的维度,一个设计选择,论文标准记号,念"d 下标 k"。三个约定:① q 和 k 必须同维——点积要逐维对应相乘,维数不等没法算;② v 的维度单独记作 d_v,允许不同(第 7 课形状实验里 d_out=6 ≠ d_k=4 就是它);③ 与词嵌入原始维度(记作 d_model,即第 7 课的 d_in)的关系:单头注意力常取 d_k = d_model;多头时 d_k = d_model ÷ 头数(第 10 课)。本课「猫吃鱼」取 d_k = 4,q、k、v 都是 4 维。

先看问题有多严重。q 和 k 都是 d_k 维向量,点积是 d_k 个乘积的和:维度越高,和的波动范围就越大。概率论里,若各分量独立,这个和的方差正比于 d_k——典型大小约 ±√d_k。4 维时分数波动 ±2,无伤大雅;64 维时波动 ±8,分数动辄拉开好几档。而下一步 softmax 恰恰对"差距"极度敏感:差距一大,它就输出一家独大的极端配比。先实测:

# ============================================================ # 【演示目标】实测"分数为什么会爆炸",以及除 √d_k 怎么救回来: # ① q·k 的波动幅度随维度 d_k 增大(标准差 ≈ √d_k) # ② 分数一大,softmax 就"一家独大";除以 √d_k 后恢复"配比" # 【思路】 # 1. 造随机 q、k(各分量 ~N(0,1)),维度取 4 / 16 / 64 # 2. 大量采样统计 q·k 的标准差,和 √d_k 理论值对表 # 3. 模拟 8 个候选词打分 → softmax,对比"未缩放 vs 除 √d_k" # 的最大权重:谁还像"配比",谁已经退化成"独裁" # 读法: d_k=64 时未缩放的最大权重 ≈0.90(其余 7 个词加起来才 10%), # softmax 接近 one-hot——梯度断流的前兆;除以 √d_k=8 后回到 ≈0.38。 # ============================================================ import numpy as np rng = np.random.RandomState(0) def softmax(x): e = np.exp(x - np.max(x)) # 减去最大值防指数溢出(工程惯例) return e / e.sum() print("维度 d_k | q·k 标准差(实测) | √d_k(理论)") for d in [4, 16, 64]: dots = [float(rng.randn(d) @ rng.randn(d)) for _ in range(20000)] print(" %2d | %5.2f | %.2f" % (d, np.std(dots), np.sqrt(d))) print("\n8 个候选词时,softmax 最大权重(20 轮平均):") for d in [4, 16, 64]: raw, scaled = [], [] for _ in range(20): q = rng.randn(d) K = rng.randn(8, d) # 8 个候选词的 k s = K @ q raw.append(softmax(s).max()) # 不缩放:直接 softmax scaled.append(softmax(s / np.sqrt(d)).max()) # ② 除以 √d_k 再 softmax print(" d_k=%2d: 未缩放 %.2f | ÷√d_k %.2f" % (d, np.mean(raw), np.mean(scaled))) # 读法:标准差一栏与 √d_k 理论值几乎重合——分数波动的"锅"就是维度。 # 未缩放一栏随 d_k 一路涨到 0.90;缩放一栏稳在 0.3~0.4 的"配比"区间。

治法简单粗暴:把分数统一除以 √d_k,把尺度拧回"1 维标准"。猫这一行手算:

手算:猫行缩放 原始分数 [1, 1, 2] ÷ √4 = ÷2 → [0.5, 0.5, 1]。数字变"碎"了?——要的就是这个:分数之间的相对差距回到温和区间,下一步 softmax 才不会一步跳到全有或全无。

四、第 ③ 步:softmax——把分数变成配比

softmax 做两件事:指数化($e^x$:拉开差距、全变正数)再归一化(除以总和,加和恰好 = 1)。输出就是一张"注意力配比表":每个候选词分到多少比例的注意力,总共 100%。和第 1 课的概率表对照一下——大模型每一层的注意力,其实都在现场重算一张小小的概率表。写成公式就是:

公式 + 逐符号讲解(每个符号什么意思、数字从哪来) $$\text{softmax}(s_j) = \frac{e^{s_j}}{\sum_i e^{s_i}}$$ 逐个符号读(以猫行缩放分 $[0.5,\ 0.5,\ 1]$ 为例):
$s_j$ = 候选词 j 的分数。j 只是"候选编号"(猫/吃/鱼 → j = 0/1/2);算鱼的配比时,$s_j = 1$。
$e^{s_j}$ = 分子的"指数化"。e ≈ 2.718 是固定的数学常数,它的性质是"输入越大,输出被放得越大越多"——$s_j=1 \to e^1 \approx 2.72$,$s_j=0.5 \to 1.65$。
$\sum_i e^{s_i}$ = 分母,把全部候选的指数化结果加起来:猫行 = 1.65 + 1.65 + 2.72 ≈ 6.02。除它就是"归一化"——让三个数从任意大小变成加和为 1 的配比。
整体效果:每个候选拿到"自己的指数 ÷ 大家指数之和",猫行 = [0.27, 0.27, 0.45]。分数大的拿大头,但谁都不清零——这就是"注意力"而不是"选择"。
防混淆:分母求和号里的下标 i、分子下标 j 都只是"候选词编号",与 d_k 的 k 无关——不同公式里字母复用很常见,认符号要看它在本公式里的角色,不要背字母。为什么用 e 不用别的底:用 2、用 10 结果只差一个常数倍(等价于把分数整体缩放),e 的微积分性质最干净,纯数学偏好。

softmax 的"脾气"是:输入分数之间的差距决定配比的平与尖。差距小 → 雨露均沾;差距大 → 一家独大。上一节除 √d_k,本质上就是给 softmax 的输入"降温"。拖拖看这个旋钮:

动画 · softmax 配比旋钮:分数尺度决定"均沾"还是"独大"

回到课文例子,猫行配比手算(计算器帮你按 e 也行):

手算:猫行 softmax 缩放分 [0.5, 0.5, 1] → 指数化 [e^0.5, e^0.5, e^1] ≈ [1.65, 1.65, 2.72] → 求和 ≈ 6.02 → 归一化:
[0.27, 0.27, 0.45] —— 鱼 45%、猫 27%、吃 27%。猫果然眼睛盯着鱼,但每个词都分到注意力:不是选一个,而是按比例全都要(第 7 课 JOIN 类比的兑现)。

五、第 ④ 步:加权 V——调出上下文向量

最后一步兑现承诺:按配比,把每个词的内容 v 加权混合,调出猫的上下文向量。这就是一台调酒台——配比是 softmax 给的,酒是三个 v:

动画 · 上下文向量调酒台:按配比混合 v

手算猫这一行(用上面动画"猫的配比"预设同款数字):

手算:猫的上下文向量 0.27×v_猫 + 0.27×v_吃 + 0.45×v_鱼
= 0.27×[1,0,1,0] + 0.27×[0,1,0,1] + 0.45×[2,0,0,2]
= [0.27, 0, 0.27, 0] + [0, 0.27, 0, 0.27] + [0.90, 0, 0, 0.90]
= [1.18, 0.27, 0.27, 1.18]
看第 0 维:0.27 + 0.90 = 1.18——里头有大半是鱼的内容。猫的静态嵌入是查表查来的一行死数;现在这个 4 维向量,装着"猫在看着鱼"的语境。第 7 课立的使命,四步走完。

值得回看一眼四步的分工:前三步(打分、缩放、softmax)产出的只是一张「该看谁、看多少」的计划表——它本身不让任何信息挪动;第 ④ 步 ×V 才把计划执行成真正的搬运:猫的向量从这一刻起,里面真的写进了鱼的内容。少掉这一步,前面辛苦算出的配比只是一份没人读的报告。

吃行、鱼行同法炮制(下面代码全对答案)。值得停一秒的是吃行:吃的最大配比给了它自己(0.67)。别意外——自注意力的候选里包括自己,"动作和自身关系最紧"完全是合法的注意力模式;多头注意力(第 10 课)里不同头还会各有所爱。最后把玩具放大回真实:d_k 从 4 换回 64、3 个词换成整段上下文,四步流程一步不变——只是更大、更并行。你在纸笔上走过的,就是 GPT 每一层每时每刻在做的动作。

六、拼起来:论文那行公式,逐符号解码

现在把四步焊回论文里的著名公式(缩放点积注意力,Attention Is All You Need §3.2):

公式 + 逐符号讲解(读法同 softmax 那框,数字全来自「猫吃鱼」) $$\text{Attention}(Q, K, V) = \text{softmax}\!\left(\dfrac{QK^\top}{\sqrt{d_k}}\right)V$$ 先认清三个大写字母——它们不是单个向量,是整队向量排成的矩阵。形状记号"L×d_k"读作"L 行、d_k 列"(这里的 × 是尺寸写法,不是乘法):一行 = 一个词,一列 = 向量的一个分量。把三个词的提问向量上下摞起来,就得到 Q: $$Q = \begin{bmatrix} \;1 & 1 & 0 & 0\; \\ \;0 & 2 & 0 & 2\; \\ \;0 & 0 & 1 & 1\; \end{bmatrix} \begin{matrix} \leftarrow q_{猫}\\ \leftarrow q_{吃}\\ \leftarrow q_{鱼} \end{matrix}$$ 竖着数:3 行——每行是一个词的提问,行数 = 词数 L = 3;横着数:4 列——每个 q 有 4 个数(第 7 课 $x @ W_q$ 投影出来的维度),列数 = d_k = 4。所以 Q 的尺寸 = L×d_k = 3×4。K、V 一模一样:每个词的 k、v 各自摞成一摞,尺寸 L×d_k 和 L×d_v。
$QK^\top$:上标 ⊤ 读"转置"——行变列。$K$ 是 3×4,转置后 $K^\top$ 是 4×3,它的第 j 列恰好是第 j 个词的 $k_j$。矩阵乘法"行配列"的每个格子 = 行·列,所以结果第 i 行第 j 列 = $q_i \cdot k_j$ = "词 i 给词 j 的打分"。形状:(3×4)@(4×3) → 3×3 分数表——9 对词关系一次算完: $$S = \begin{bmatrix} 1 & 1 & 2 \\ 0 & 4 & 2 \\ 1 & 1 & 0 \end{bmatrix}$$ 行 = 提问者(猫/吃/鱼),列 = 被问者(猫/吃/鱼)。比如第 1 行第 3 格 = $q_{猫} \cdot k_{鱼} = 2$(猫最关注鱼),第 2 行第 2 格 = $q_{吃} \cdot k_{吃} = 4$(吃和自己最合拍)。
$\,/\,\sqrt{d_k}$:整张表每个格子都除以 √4 = 2 → [0.5, 0.5, 1] / [0, 2, 1] / [0.5, 0.5, 0](第 ② 步,防大维度分数爆炸)。
$\text{softmax}$:按行归一——每一行是一个提问者的注意力配比,行和 = 1(第 ③ 步,逐符号讲解见上文的 softmax 框)。得到配比表 A(3×3),猫行 = [0.27, 0.27, 0.45]。
$\cdots V$:配比表(3×3)@(3×4) → 上下文矩阵 Z(3×4)。Z 的第 i 行 = 用第 i 行配比去加权混合三个 v = 词 i 的上下文向量(第 ④ 步);猫行 = [1.18, 0.27, 0.27, 1.18],与手算一致。
形状链一图流:$Q, K\,(3{\times}4) \;\to\; QK^\top/\sqrt{d_k}\;(3{\times}3) \;\to\; \text{softmax}\,(3{\times}3) \;\to\; \cdot\,V \;\to\; Z\,(3{\times}4)$。L 个词的全部词对关系一次并行算完——这就是"整句并行"的准确含义,也是 LSTM 逐词串行被埋葬的地方。

最后把四步焊成一个类(Vizuara 1.15 的 torch 版 SelfAttention 同构,numpy 等价物;torch 正式上手在第 15 课):

# ============================================================ # 【演示目标】把四步焊成一个类(numpy 版 SelfAttention,与 Vizuara 1.15 # 的 torch 类同一副骨架),并复算「猫吃鱼」的三行上下文向量。 # 【思路】 # A. 类体检:随机 X(3 词 × 8 维)过类,打印每一步的形状链 # (真实实现:X 过三套 W 得到 Q/K/V——第 7 课的投影) # B. 手算复算:直接喂「猫吃鱼」手算用的 Q/K/V(小整数),验证 # 猫行配比 [0.27,0.27,0.45]、上下文 [1.18,0.27,0.27,1.18] # 读法: forward 里 4 行代码 = 本课 4 步一一对应;A 的形状链告诉你 # "矩阵形式 = 全部行并行",B 证明类和你手算的数字严丝合缝。 # ============================================================ import numpy as np def softmax(x, axis=-1): # 按行(最后一维)softmax e = np.exp(x - x.max(axis=axis, keepdims=True)) # 减最大值防溢出 return e / e.sum(axis=axis, keepdims=True) class SelfAttention: """缩放点积注意力:softmax(QK^T/√d_k)V —— 1.15 的 numpy 等价物""" def __init__(self, d_k): self.d_k = d_k # 真实模型里,这里还有三套可训练的 W_q/W_k/W_v(第 7 课) def forward(self, Q, K, V): S = Q @ K.T / np.sqrt(self.d_k) # ① 打分 ② 缩放(整表一次算完!) A = softmax(S) # ③ 每行 → 注意力配比(行和 = 1) Z = A @ V # ④ 按配比加权混合 v return Z, A print("---- A. 形状体检(随机 3 词 × 8 维,d_k=4,d_v=6)----") rng = np.random.RandomState(7) X = rng.randn(3, 8) # 3 个词,每个 8 维嵌入 Wq, Wk = rng.randn(8, 4), rng.randn(8, 4) # 两副眼镜(示意) Wv = rng.randn(8, 6) # 第三副(输出维度可以不同) Q, K, V = X @ Wq, X @ Wk, X @ Wv print("X", X.shape, "-> Q", Q.shape, "K", K.shape, "V", V.shape) attn = SelfAttention(d_k=4) Z, A = attn.forward(Q, K, V) print("分数表 S:", (3, 3), " 配比行和:", A.sum(axis=1).round(2), "(每行=1 ✓)") print("输出 Z:", Z.shape, "(3 个词,每人一个新的上下文向量)") print("\n---- B. 手算复算:猫吃鱼(直接喂手算的 Q/K/V)----") Qh = np.array([[1,1,0,0],[0,2,0,2],[0,0,1,1]], float) # 三行 q Kh = np.array([[1,0,1,0],[0,1,0,1],[1,1,0,0]], float) # 三行 k Vh = np.array([[1,0,1,0],[0,1,0,1],[2,0,0,2]], float) # 三行 v Z2, A2 = attn.forward(Qh, Kh, Vh) # d_k=4,√d_k=2 np.set_printoptions(precision=3, suppress=True) print("猫行配比 A[0] =", A2[0], "(手算 [0.27, 0.27, 0.45] ✓)") print("猫行上下文 Z[0] =", Z2[0], "(手算 [1.18, 0.27, 0.27, 1.18] ✓)") print("吃行配比 A[1] =", A2[1], " 鱼行配比 A[2] =", A2[2]) # 读法:类和你手算的数字完全一致。torch 版只是把 W 换成 nn.Linear、 # 前向换成张量运算——骨架一模一样,第 15 课见。
本课胜利 ① 四步人话表:打分 → 缩放 → softmax → 加权 V;② 点积打分 = 第 5 课点积回场,不用余弦是"长度可学"的有意设计;③ 除 √d_k 治方差膨胀(实测 √d_k 理论 ≈ 实测,未缩放 d_k=64 时 softmax 退化独裁 0.90);④ softmax = 分数→配比,脾气是"差距越大越尖";⑤ 上下文向量 = 按配比调出的鸡尾酒,猫行手算 [1.18, 0.27, 0.27, 1.18];⑥ 论文公式 Attention(Q,K,V)=softmax(QKᵀ/√d_k)V 四个符号全部认识——你今天用纸笔亲手算过的四步,就是这行公式的全部分量。下一课:这只注意力还缺两块刹车——因果掩码(不能偷看未来)与 dropout。

检索练习

本课主读材料

去读(约 15 分钟)

Vizuara《The Transformers》1.12、1.15 节:1.12 是注意力计算的完整走算(打分 → 缩放 → softmax → 加权 V),1.15 是 SelfAttention 类的 torch 实现总结。重点对图:注意力逐步计算的流程图与 SelfAttention 类代码,把今天的四步和原文的箭头一一对上。

原典:Attention Is All You Need §3.2——本课那行公式的出处,只有三行,现在读得懂了。

顺手:附录 · Transformer 全景图已上线——一张图看清你走到哪了、前方还有什么(已走过的站都能点回课文)。

我是你的老师,别客气 "softmax 为什么用 e 而不用 2 次方""缩放为什么是 √d_k 不是 d_k"——这类问题都在正道上,贴回来问。下一课给这只注意力装上刹车。