技术博客

从零手写注意力:60 行 NumPy 跑通自注意力

第 62 / 147 篇 · 2025-10-15 · ai · 注意力机制动手实现NumPy

前面多篇把注意力的每个组件拆开了讲,这一篇把它们装回去——不用任何深度学习框架,纯 NumPy 写一个完整的因果多头自注意力,再和 PyTorch 的 F.scaled_dot_product_attention 对拍误差。要求的先验只有:会矩阵乘法、知道 softmax 和 mask 是什么。

第一步:缩放点积注意力

先写核心公式 $\mathrm{softmax}(QK^\top/\sqrt{d_k})V$。三个容易写错的点先立住:缩放因子除的是每个头的维度 $d_k$ 的平方根(不是模型维度);softmax 要做数值稳定化(先减行最大值,否则大 logits 下 exp 溢出);mask 要在 softmax 之前把非法位置置为 $-\infty$(softmax 之后置零是错的,归一化分母已经把它们算进去了)。

import numpy as np

def softmax(x, axis=-1):
    x = x - x.max(axis=axis, keepdims=True)   # 数值稳定
    e = np.exp(x)
    return e / e.sum(axis=axis, keepdims=True)

def sdpa(Q, K, V, mask=None):
    # Q: (n, d_k)  K: (m, d_k)  V: (m, d_v)
    S = Q @ K.T / np.sqrt(Q.shape[-1])        # (n, m) 打分
    if mask is not None:                      # mask: (n, m),True = 允许
        S = np.where(mask, S, -np.inf)
    P = softmax(S)                            # (n, m) 行归一
    return P @ V                              # (n, d_v)

五行核心代码,前面讲的所有理论都能在这里对上号:S 就是会爆显存的 $n\times n$ 矩阵;-np.inf 那些行在 softmax 里指数为零——这就是"掩码位置拿不到梯度"的代码形态。

第二步:QKV 投影与因果掩码

自注意力的 Q/K/V 不是输入本身,是三个投影。设输入 $X\in\mathbb{R}^{n\times d}$(这里跳过训练,投影矩阵用固定随机值代替),$W^Q, W^K\in\mathbb{R}^{d\times d_k}$,$W^V\in\mathbb{R}^{d\times d_v}$。

rng = np.random.default_rng(0)
n, d, d_k, d_v = 8, 16, 4, 6
X = rng.normal(size=(n, d))
Wq = rng.normal(size=(d, d_k))
Wk = rng.normal(size=(d, d_k))
Wv = rng.normal(size=(d, d_v))

Q, K, V = X @ Wq, X @ Wk, X @ Wv
causal = np.tril(np.ones((n, n), dtype=bool))   # 下三角:位置 i 只能看 ≤ i
O = sdpa(Q, K, V, mask=causal)

因果掩码就是单位下三角矩阵的逐元素版本——np.tril 生成它的时候,"信息不能从未来流回过去"这条约束就从一个抽象原则变成了一张布尔表。验证掩码正确性的最快办法:把最后一行的 attention 分布打出来,最后一行是全可见的,它的权重应该和"不带 mask 直接 softmax"一致:

assert np.allclose(O[-1], sdpa(Q, K, V)[-1])    # 最后一行不受因果掩码影响

第三步:多头重组

多头不是循环,是一次 reshape。$n_h$ 个头各自在 $d_k$ 维子空间做注意力,实现上把 $d = n_h\cdot d_k$ 的通道维折叠:

n_h = 4
Wh  = rng.normal(size=(d, d))                   # 简化为 n_h*d_k = d
Wq_h = rng.normal(size=(d, n_h * d_k))          # 每头独立:输出 n_h*d_k 维
Wk_h = rng.normal(size=(d, n_h * d_k))
Xh = X @ Wh                                     # (n, d)
Qh = (Xh @ Wq_h).reshape(n, n_h, d_k).transpose(1, 0, 2)   # (n_h, n, d_k)
Kh = (Xh @ Wk_h).reshape(n, n_h, d_k).transpose(1, 0, 2)
Wv_h = rng.normal(size=(d, n_h * d_v))          # V 要输出 n_h*d_v 维再按头切
Vh = (Xh @ Wv_h).reshape(n, n_h, d_v).transpose(1, 0, 2)
O_h = np.stack([sdpa(Qh[h], Kh[h], Vh[h], causal) for h in range(n_h)])  # (n_h, n, d_v)
O = O_h.transpose(1, 0, 2).reshape(n, n_h * d_v)         # 拼回头维

两个 reshape 是整个多头机制的全部秘密:第一个把"按头分段"的通道维挪到最前面变成 batch 维,逐头算注意力就是一次循环(或 einsum);第二个把结果拼回 $n\times(n_h d_v)$。概念上"每个头有独立的 K/V 空间",代码上只是 reshape 前后谁挨着谁。真正的独立投影(每头一份 $W^Q_h$)写成代码也只是把 Wq 从 $(d, d_k)$ 换成 $(d, n_h d_k)$。

对拍:和手算、和 PyTorch

三层验证,每层抓不同的 bug:

# 1) 手写 softmax 对拍 scipy 无关实现:逐行和为 1
P = softmax(Q @ K.T / np.sqrt(d_k))
assert np.allclose(P.sum(-1), 1.0)

# 2) 数值稳定:把 logits 放大 1e4 倍,naive 版本溢出,稳定版仍有限且归一
x = Q @ K.T / np.sqrt(d_k) * 1e4
naive = np.exp(x); naive = naive / naive.sum(-1, keepdims=True)
stable = softmax(x)
assert np.isnan(naive).any() and np.isfinite(stable).all()
assert np.allclose(stable.sum(-1), 1.0)

# 3) 与 PyTorch 的 F.scaled_dot_product_attention 对拍(float64 下应到 1e-12)
import torch
ref = torch.nn.functional.scaled_dot_product_attention(
    torch.tensor(Qh), torch.tensor(Kh), torch.tensor(Vh), is_causal=True)
assert np.allclose(O_h, ref.numpy(), atol=1e-12)

第 2 条值得展开:$10^4$ 倍放大后 logits 量级在数万,naive softmax 的 exp 直接溢出成 inf 再算出 nan;减行 max 的版本全程有限。这也是混合精度训练里一切 loss scale 机制要解决的问题的迷你版。第 3 条用 is_causal=True 顺带验证了我们的下三角掩码语义和 PyTorch 一致(PyTorch 的 is_causal 等价于下三角为 1 的掩码)。

三步组装:从公式到多头(shape 变化标注在箭头上方) X @ Wq/Wk/Wv (n,d) → Q,K,V 打分 S = QKᵀ/√d_k (n, m) 掩码 mask 置 -∞ softmax 之前 P @ V (n, d_v) 多头 = 两次 reshape: reshape (n, n_h·d_k) → (n_h, n, d_k):通道维折叠成 batch 维,逐头独立算 transpose + reshape 拼回 (n, n_h·d_v):概念上"各头独立",代码上只是维序 对拍抓 bug:行和为 1 / 大 logits 稳定 / atol=1e-12 对齐 PyTorch

小结

60 行 NumPy 复现自注意力的意义不在代码本身,而在三个"概念到形状"的对照被亲手验证过:掩码是 softmax 前的布尔表、多头是两次 reshape、数值稳定是减一行 max。写完后回头看框架里的 nn.MultiheadAttention,它不再是一个黑盒,而是这 60 行经过向量化、融合、 fused kernel 之后的工业形态——下一篇就拆那个工业形态。