← 返回首页

手搓注意力机制:NumPy 逐行实现与 PyTorch 对照

TransformerAttentionPyTorch

读 Transformer 的论文,大多数人卡在同一页:Attention(Q, K, V) = softmax(QKᵀ / √dk)V。公式能背,手却写不出来——Q、K、V 从哪来,dk 除在哪一步,掩码加在 softmax 之前还是之后,多头那次 reshape 的轴序凭什么是那样。

我实际把这套东西用 NumPy 从零写了一遍,再拿 PyTorch 2.14.1 的 scaled_dot_product_attention 和 nn.MultiheadAttention 对答案:同一组权重、同一份输入,两边的最大绝对差是 2.220446049250313e-16,也就是浮点误差量级。文中的每个数字都是 2026 年 10 月 12 日在本机 CPU 上跑出来的,代码可以直接复制运行。

关于商业内容:本文不含任何联盟商品链接,正文里没有需要你下单的东西,也没有厂商赞助。

太长不看

公式里那一步除法,才是真正要看的地方

《Attention Is All You Need》(arXiv:1706.03762,v1 提交于 2017 年 6 月 12 日,引用版本 v7 修订于 2023 年 8 月 2 日)第 3.2.1 节把公式写成:

Attention(Q, K, V) = softmax(QKᵀ / √dk) V

同一节里作者给了除以 √dk 的理由,原文是:"We suspect that for large values of dk, the dot products grow large in magnitude, pushing the softmax function into regions where it has extremely small gradients."

脚注把这件事算清楚了:q 和 k 的各个分量如果独立、均值 0、方差 1,那么 q·k = Σ qi·ki 的均值是 0,方差是 dk。维度越高,点积的绝对值越大,softmax 越容易被推进"梯度极小"的区域——输出几乎变成 one-hot,反向传播几乎没有信号。

论文 3.2.2 节给出的基准配置是:d_model = 512,h = 8 个头,dk = dv = d_model / h = 64,编码器与解码器各堆 N = 6 层,dropout 率 0.1。这些数字后面会用到。

不除会怎样:把 softmax 跑崩给你看

我不打算复述结论,直接跑。下面这段代码对 dk = 16 / 64 / 512 三种维度各生成 2000 个 key,比较缩放前后 softmax 的形态:

import numpy as np

def softmax(x):
    x = x - x.max()
    e = np.exp(x)
    return e / e.sum()

for dk in (16, 64, 512):
    r = np.random.default_rng(7)
    q = r.normal(size=(dk,))
    k = r.normal(size=(2000, dk))
    dots = k @ q
    w_raw = softmax(dots)
    w_scl = softmax(dots / np.sqrt(dk))
    print(f"dk={dk:4d}  dot 标准差={dots.std():6.2f}  "
          f"不缩放最大权重={w_raw.max():.6f}  缩放后最大权重={w_scl.max():.6f}  "
          f"不缩放有效项数={1/np.sum(w_raw**2):7.2f}  缩放后={1/np.sum(w_scl**2):7.2f}")

本机输出:

dk=  16  dot 标准差=  2.47  不缩放最大权重=0.227685  缩放后最大权重=0.004241  不缩放有效项数=  14.85  缩放后=1341.59
dk=  64  dot 标准差=  7.32  不缩放最大权重=0.921946  缩放后最大权重=0.009399  不缩放有效项数=   1.17  缩放后= 829.74
dk= 512  dot 标准差= 20.99  不缩放最大权重=0.933175  缩放后最大权重=0.005796  不缩放有效项数=   1.15  缩放后= 917.82

"有效项数"是我自己取的指标,定义是 1 / Σ wᵢ²,可以理解为"这 2000 个 key 里有多少个真正在被关注"。

dk=64 这一行最说明问题:不缩放时最大权重 92.19%,有效项数 1.17——一个 2000 长的序列,注意力几乎全压在一个位置上,其余位置梯度趋近于 0。缩放之后最大权重降到 0.94%,有效项数 829.74,模型才真的在"综合"信息。dk=16 时差距没那么夸张(有效项数 14.85 对 1341.59),这也对得上论文里"for small values of dk the two mechanisms perform similarly"那句话。

最小可运行实现:NumPy,不到 40 行

不多解释,看代码。scaled_dot_product_attention 就是论文公式的一比一翻译,多出来的只有 np.where(mask, -np.inf, scores) 和 softmax 里那句减最大值(数值稳定性,不是花架子):

import numpy as np

def softmax(x, axis=-1):
    x = x - np.max(x, axis=axis, keepdims=True)      # 防止 exp 溢出
    e = np.exp(x)
    return e / np.sum(e, axis=axis, keepdims=True)

def scaled_dot_product_attention(Q, K, V, mask=None):
    """Q:(..., Tq, dk)  K:(..., Tk, dk)  V:(..., Tk, dv)
    mask: bool,True 表示该位置要被屏蔽(会被替换成 -inf)
    """
    dk = Q.shape[-1]
    scores = Q @ np.swapaxes(K, -1, -2) / np.sqrt(dk)
    if mask is not None:
        scores = np.where(mask, -np.inf, scores)
    w = softmax(scores, axis=-1)
    return w @ V, w

跑一个 5 个 token、dk=4 的最小例子(随机种子固定为 20261012),得到的注意力权重第 0 行是:

[0.027843 0.167136 0.246698 0.211041 0.347282]

五个位置,五个权重,和为 1。我顺手断言了一下:每行权重和与 1 的最大偏差是 1.1102230246251565e-16。这个偏差建议你自己也测一次——如果哪天偏差到了 1e-3 量级,先怀疑 softmax 的轴取错了,别急着怀疑 NumPy。

多头:拆开、各算各的、再拼回去

论文 3.2.2 节的说法是:与其用 d_model 维做一次注意力,不如把 Q、K、V 各做 h 次不同的线性投影,投影到 dk、dk、dv 维,并行算完再拼接,最后再投影一次。写成代码只有 reshape + transpose 两下:

def multi_head_attention(X, WQ, WK, WV, WO, h, causal=False):
    T, d_model = X.shape
    dh = d_model // h
    Q, K, V = X @ WQ, X @ WK, X @ WV
    # (T, d_model) -> (h, T, dh)
    Qh = Q.reshape(T, h, dh).transpose(1, 0, 2)
    Kh = K.reshape(T, h, dh).transpose(1, 0, 2)
    Vh = V.reshape(T, h, dh).transpose(1, 0, 2)
    mask = np.triu(np.ones((T, T), dtype=bool), k=1) if causal else None
    heads, w = scaled_dot_product_attention(Qh, Kh, Vh, mask=mask)
    # (h, T, dh) -> (T, d_model)
    return heads.transpose(1, 0, 2).reshape(T, d_model) @ WO, w

用 5 个 token、d_model=16、h=4 跑一遍,输出形状 (5, 16),权重形状 (4, 5, 5)——4 个头,每个头一张 5×5 的注意力表。

reshape(T, h, dh) 这个切法有个容易踩的点:它按最后一维顺序切,第 0 个头拿到的是第 0 到 dh-1 列。所以 WQ、WK、WV 应该整体保持 d_model × d_model,让每个头自己学自己的投影子块,而不是先手动把权重切成 4 份再各自随机初始化——后者也能跑,但那不是论文里的做法。

论文 3.2.3 节还列了注意力的三种用法:编码器自注意力(Q、K、V 同源)、解码器带因果掩码的自注意力、编码器-解码器注意力(Q 来自解码器上一层,K、V 来自编码器输出栈)。上面这份实现把 X 同时传给 Q、K、V,覆盖前两种;第三种只要把 K、V 换成编码器的输出,函数一行都不用改。

因果掩码:论文原文写的是 -inf,不是 0

3.2.3 节讲解码器时说得很直白:"We need to prevent leftward information flow in the decoder to preserve the auto-regressive property. We implement this inside of scaled dot-product attention by masking out (setting to −∞) all values in the input of the softmax which correspond to illegal connections."

两个信息点:掩码作用在 softmax 的输入上,值是 −∞。

我实测验证了一遍(还是那个 5 token 的例子,加因果掩码):被屏蔽位置的最大权重是 0.0,第 2 行只有 3 个位置的权重大于 0,也就是位置 2 只能看到 0、1、2——符合"up to and including that position"的定义。

如果你实现出来被屏蔽位置不是精确的 0 而是 1e-9 这种小量,那多半是用了 float 掩码加 -1e9 而不是 -inf。数值上能过,但它们是两类东西:-inf 让 exp 精确得到 0,-1e9 只是让 exp 得到一个很小的数。

和 PyTorch 官方实现对答案

自己写的东西对不对,得拿官方实现比。用同一组权重、同一份输入(float64),我的 NumPy 版和 F.scaled_dot_product_attention 的最大绝对差是 2.220446049250313e-16;换成完整的多头、把 WQ/WK/WV 直接灌进 nn.MultiheadAttention 的 in_proj_weight、WO 灌进 out_proj.weight,最大绝对差是 4.440892098500626e-16。

顺带一个真实的性能数字(同样本机 CPU):T=128、d=64 时,逐行 for 循环版耗时 5.669 ms,矩阵版 1.050 ms,快 5.4 倍,两者输出最大差 4.00e-15。循环版不是"错"的,它只是把本来可以一次矩阵乘法做完的事拆成了 128 次——这也是论文里 dot-product attention "can be implemented using highly optimized matrix multiplication code" 的实际含义。

但这里有个坑,PyTorch 2.14 官方文档在 scaled_dot_product_attention 页面里专门用 Note 标了出来:布尔掩码的语义和 MultiheadAttention 的 key_padding_mask 相反——在 scaled_dot_product_attention 里 True 表示"参与计算",在 MultiheadAttention 里 True 表示"被屏蔽"。

我把两种写法都跑了一遍(同一个因果掩码):

掩码约定正确时(取反后传入)最大差: 2.220446049250313e-16
掩码约定搞反时(直接传入)最大差:  3.668259537831219

差 3.67,而且不会报错——模型会安静地只去看未来的 token。这是本文里我最想让人记住的一条。

踩坑录:5 个真实报错

下面五条都是我写完代码时实际遇到、并原样复制下来的信息,不是"典型错误示例"。

报错一:忘了转置 K(NumPy)

ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0,
with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 5 is different from 4)

Q 是 (5, 4),K 也是 (5, 4),Q @ K 当然不成立。正确写法是 Q @ K.T,批量维度用 np.swapaxes(K, -1, -2)。自注意力里 Q 和 K 形状完全一样,所以报错不会提示你"该转置",它只会说维度不符——看到 (size 5 is different from 4) 先检查转置。

报错二:忘了转置 K(PyTorch)

RuntimeError: mat1 and mat2 shapes cannot be multiplied (5x4 and 5x4)

同一个错误,PyTorch 的提示更直白。养成习惯:写注意力就把 K.transpose(-2, -1) 写全。

报错三:is_causal 和 attn_mask 同时传

RuntimeError: _scaled_dot_product_attention: Explicit attn_mask should not be set when is_causal=True

官方文档在参数说明里写了"An error is thrown if both attn_mask and is_causal are set"。两个只能选一个:标准方形的因果掩码用 is_causal=True 最省事;非方形或者自定义掩码就传 attn_mask,is_causal 留默认。

报错四:float32 下 exp 下溢,softmax 直接出 nan

dot = -1802.824
exp(dot) = 0.0
e/e = nan
RuntimeWarning: invalid value encountered in scalar divide

这是用 1024 维、分量乘 10 的 float32 向量实测出来的。点积 -1802.824,exp 之后直接下溢成 0.0,分子分母都是 0,除法得到 nan;如果点积是正的大数,则是上溢成 inf,inf/inf 同样 nan。修法不是换 float64,是 softmax 先减最大值:x - x.max() 之后 exp 的最大参数是 0,exp(0)=1,分母至少为 1,不会塌。上面的实现里已经这么做了。

报错五:softmax 的轴取错——不报错,但结果是错的

axis=0 时各行和: [0.838357 1.937897 0.662364 0.992172 0.569209]
axis=-1 时各行和: [1. 1. 1. 1. 1.]
两种写法输出最大差: 1.2147038513899666

np.exp(s) / np.exp(s).sum(axis=0) 这种写法跑得通、不报警、形状也对,但归一化的是列而不是行。判断方法很简单:加一行断言检查 w.sum(axis=-1) 是不是全 1。这是本文里最该写断言的一处,因为它的失败是静默的。

读完之后可以接着看

FAQ

Q1:手搓的版本能直接拿去训练吗?

不能,也不该。这份实现的价值是让你确认自己理解对了:我用它和 PyTorch 官方实现对拍到 1e-16 量级,说明公式、轴序、掩码位置都没错。真要训练,用 F.scaled_dot_product_attention,它会自动选 FlashAttention、Memory-Efficient 或 C++ 后端(官方文档列的三种实现),并支持 GQA。

Q2:为什么我算出来和官方实现差 1e-7 而不是 1e-16?

先看 dtype。上面所有对照都在 float64 下做的;float32 的量级大约就是 1e-7,属正常。另外官方文档明确写了:由于融合浮点运算,不同后端 kernel 的输出可能有差异,C++ 后端支持 float64。

Q3:掩码里用 -1e9 代替 -inf 行不行?

能跑,但不精确。-inf 让 exp 的输出精确为 0;-1e9 在 float32 下 exp 的结果是一个极小但非零的数。我在 5 token 的例子里测到被屏蔽位置的最大权重是 0.0,前提就是用了 -inf。另外如果你要传给 PyTorch,float 版的 attn_mask 是加到分数上的,加 -inf 才是"屏蔽"。

Q4:dk 一定要等于 d_model / h 吗?

论文是这么设的(dk = dv = d_model/h = 64,h=8,d_model=512),原文给的理由是每个头维度降低后,"the total computational cost is similar to that of single-head attention with full dimensionality"。这不是数学上的必须,是实现上的惯例;现代模型里两者不相等的情况也有。

关于本文的数据与来源

公式、除以 √dk 的理由、h=8 / dk=dv=64 / d_model=512 / N=6 / dropout 0.1、掩码用 -inf 的做法,均出自 arXiv:1706.03762《Attention Is All You Need》第 3.2.1–3.2.3 节(v1 提交于 2017-06-12,本文引用版本 v7,2023-08-02 修订)。

所有实测数字(2.22e-16、4.44e-16、0.921946、有效项数 1.17、5.4 倍、3.668259537831219、5 条报错原文)均为 2026 年 10 月 12 日在本机 Windows CPU 上跑出,环境 Python 3.13 + NumPy 2.5.4 + PyTorch 2.14.1+cpu;随机种子已在代码中写死,可复现。

PyTorch 布尔掩码语义、is_causal 与 attn_mask 互斥、后端选择等说法来自 PyTorch 2.14 官方文档的 scaled_dot_product_attention 页面,并与本机实测结果交叉核对。

本文不含联盟链接与商品推荐。性能数字受 CPU 型号与负载影响,换机器会变,只用来说明量级差异,不用来做性能结论。

👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务

👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速

📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测

🔗 精选推荐工具

使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):

☁️ DigitalOcean 云服务器 ⚡ Vultr 高性能 VPS 🤖 QoderWork 中国版(推荐有奖) ☁️ 阿里云爆款 AI 产品 📚 WordPress 实用书单 🔍 WordPress SEO 书单 🌐 虚拟主机书单 🐳 Docker 书单 🐧 Linux 书单 🐍 Python 书单 💰 联盟营销书单 💵 被动收入书单 🖥️ 服务器书单 ☁️ 云计算书单 🚀 DevOps 书单 🤖 小米 MiMo 开放平台
← 返回首页