1. 先看公式:注意力机制里那个不起眼的除数
当年我第一次手写Transformer时,注意力公式里那个√d_k,我一度觉得它就是论文作者随手加的一个“规范化小把戏”。毕竟softmax本身就是归一化函数,输出的注意力权重加起来等于1,除不除一个数,好像只是改变一下分布的“胖瘦”而已。直到我把√d_k去掉跑了一个小翻译模型,才发现事情远没那么简单——训练loss像心电图一样上下乱跳,我盯着训练曲线看了半天,最后意识到问题就出在这个被大多数人忽视的除数上。
这篇文章就围绕“为什么Transformer的注意力必须用√d_k缩放点积”展开,从方差推导、softmax梯度饱和到实操中的训练陷阱,一次性讲透。适合正在从零实现Transformer、遇到训练不收敛,或者想深入理解注意力机制底层原理的读者。
1.1 拆开看:Q、K、V三点分别干什么
标准缩放点积注意力(Scaled Dot-Product Attention)长这样:
Attention(Q, K, V) = softmax(QK^T / √d_k) V三个输入矩阵的含义,我用一个生活化的类比来说。想象你在图书馆找资料:
- Q(Query,查询):就是你现在脑子里的问题。每个token都会生成一个query向量,代表“我要找什么”。
- K(Key,键):是每本图书的标签。每个token的key向量代表“我是什么内容”。
- V(Value,值):是书本身的内容。一旦确定哪些书跟你最相关,你就把那些书的内容提取出来。
Q和K做点积,衡量“问题”和“标签”的匹配程度,得到一个分数(logit)。然后softmax把这些分数变成权重,表示“每本书该看多少”。最后用权重去加权求和V,得到输出。
这个流程本身不复杂,关键就在中间那一步:为什么要在QK^T之后除以√d_k?如果把这一步去掉,公式变成softmax(QK^T)V,看起来也“能算”。但实际训练起来,问题就来了。
1.2 直观直觉:softmax不是“老好人”,它有自己的脾气
很多刚开始学的朋友觉得softmax是个很稳的函数——输入任何实数,输出都是(0,1)之间的概率分布,不会出幺蛾子。这没错,但softmax对输入尺度极其敏感。你给它输入[0, 1, 2],它输出大概[0.09, 0.24, 0.67],比较温和;但如果你把输入放大成[0, 10, 20],它输出几乎就是[0, 0.000045, 0.999955],直接梭哈给最大值。
这就是softmax的“脾气”:输入越分散(数值差距越大),输出越尖锐。输入尺度一旦上去,输出就从一个合理的概率分布,变成一个几乎one-hot的硬分布。而Transformer的QK^T点积,恰好会随着维度d_k的增大而变得越来越大、越来越分散。
这个问题的根源,就是点积的方差在膨胀。所以严格来说,softmax本身没有“崩盘”,崩的是它输出的梯度信号。
2. 隐藏杀手的源头:点积的方差随维度膨胀
2.1 一个合理的假设:Q和K的每个元素是零均值、单位方差的随机变量
要理解点积为什么会“越算越大”,得从初始化的源头看。Transformer在默认初始化下(比如常见框架里默认的正态初始化,均值为0、标准差为1),Q和K的每个维度元素大致可以看作独立同分布的随机变量,均值为0,方差为1。
这里有个关键点:不是说整个训练过程都这样,而是在初始化和训练早期,这个假设比较可靠。这也是为什么缩放问题在训练起步阶段就决定生死——你根本没有机会把模型从错误的初始状态中救回来。
注意:如果你手动改过初始化方式,比如把Q/K的初始化方差调大或调小,那么“是否缩放”“缩放到多少”的结论也会跟着变。这也是为什么很多复现失败的案例,最终都能追溯到初始化上。
2.2 一步步推导:Var(q·k) = d_k
假设q和k都是d_k维向量,每个元素q_i、k_i独立且满足E[q_i]=E[k_i]=0,Var(q_i)=Var(k_i)=1。我们来看点积的方差。
点积定义为:
q·k = q_1·k_1 + q_2·k_2 + ... + q_{d_k}·k_{d_k}先看单个乘积项q_i·k_i。因为q_i和k_i独立且均值都为0:
E[q_i·k_i] = E[q_i]·E[k_i] = 0
再看这一项的方差。利用Var(X) = E[X²] - (E[X])²:
Var(q_i·k_i) = E[(q_i·k_i)²] - (E[q_i·k_i])² = E[q_i²]·E[k_i²] - 0
因为Var(q_i)=E[q_i²] - (E[q_i])² = 1,且E[q_i]=0,所以E[q_i²]=1。同理E[k_i²]=1。于是:
Var(q_i·k_i) = 1×1 = 1
接下来对d_k个这样的独立项求和。根据方差的可加性(独立变量求和时方差直接相加):
Var(q·k) = Σ Var(q_i·k_i) = d_k×1 = d_k
标准差就是√d_k。这里的关键结论是:点积的方差和维度d_k成正比,标准差和√d_k成正比。
2.3 d_k=64时,点积分布在什么范围
标准Transformer的d_model通常是512,多头注意力分成8个头,每个头的d_k = 512/8 = 64。这个64就是论文里的典型值。
当标准差是√64=8时,如果点积近似服从正态分布(维度高了以后中心极限定理会帮我们保证这一点),那么绝大多数点积会落在±3σ = ±24的范围内,偶尔还会出现更大的值。
乍看±24也没什么大不了。但别忘了,后面跟的是exp()函数:
exp(24) ≈ 2.6×10^10 exp(0) = 1 exp(-24) ≈ 3.8×10^-11这完全不是一个量级。softmax分母里exp(24)一项就占了绝对统治地位,其他所有项加起来都是它的“零头”。结果就是注意力权重变成几乎one-hot,最大的那个score对应的位置拿走几乎100%的权重。
3. softmax崩盘的深层机制:不是NaN,是梯度消失
3.1 先澄清:崩盘不等于报错
很多人在只听说过“不缩放会崩”时,以为崩盘就是loss变成NaN、程序直接中断。实际训练中,不缩放确实有可能在长时间训练后数值溢出变成NaN,但这往往不是最先出现的症状。最先出现的、也最隐蔽的症状是:loss降不下去,模型学不动,梯度基本消失。
这才是它被称为“隐藏杀手”的原因。你不认真查,根本不会联想到是注意力这边出了毛病。有位朋友跟我说他训练一个文本分类模型,loss在2.3附近卡了整整两天,怀疑是学习率问题,调了七八组参数都没用。后来我让他把注意力logits的标准差打印出来一看,好家伙,初始就是15左右,softmax早就饱和了。这属于典型的“scale失调”导致的训练瘫痪。
3.2 梯度消失的数学视角
softmax的梯度有一个优雅的闭合形式。设输入logits为x_1...x_n,输出概率为p_i = exp(x_i)/Σexp(x_j)。那么:
∂p_j / ∂x_i = p_j · (δ_ij - p_i)其中δ_ij当i=j时取1,否则取0。这个公式里暗藏杀机:当某个p_i接近1时(one-hot分布),我们来算梯度:
- 对i=j(对角线元素):∂p_i/∂x_i = p_i(1 - p_i) ≈ 1×(1-1) = 0
- 对i≠j(非对角线元素):∂p_j/∂x_i = -p_i·p_j ≈ -1×0 = 0
看到了吗?当softmax输出越接近one-hot,梯度越接近全0。也就是说,如果注意力权重被点积直接“砸成”硬分布,那么模型在反向传播时,关于logits的梯度几乎什么都不携带。Q和K收到的梯度接近于零,它们根本不知道该怎么调整自己。
这就是“softmax崩盘”的真正含义——数值上一切正常,但梯度上已经死了。训练过程看起来在跑,实际上模型在原地打转。
3.3 雪上加霜:注意力模式的“尖峰化”还带来表征退化
梯度消失是第一个问题,第二个问题是训练初期注意力模式就被锁死。想象一下,如果在训练刚开始,因为随机初始化,某个位置的query和某个位置的key碰巧点积大了一些,经过不加缩放的softmax后,这个位置直接吃掉99%的注意力权重。那么这个token的输出几乎就等于那个位置的V。
之后即使其他地方的信息对当前任务可能更有用,梯度也无法纠正这个分配——因为就像上面算的,其他位置的梯度已经被p_j的小值压得微乎其微。模型只能沿着“已经在错的道路上”继续走,学出来的attention map往往是稀疏、尖锐且不合理的。
我在实验里见过最极端的例子:一个只有两层的Transformer,不缩放时训练完,所有注意力头的模式几乎一模一样,全部聚焦在句尾token上。这意味着多头注意力完全失去了“多头”的意义——八个头退化成同一个头。缩放版本则完全没有这个问题,八个头学了八种不同的关注模式。
4. √d_k缩放为什么能救命
4.1 把方差拉回1,让softmax留在“温和区”
既然问题出在点积方差是d_k,那解决办法就很简单:除以√d_k,把方差重新归一化。
Var(q·k / √d_k) = (1/d_k) · Var(q·k) = d_k/d_k = 1这样一来,点积的分布保持在标准差为1的量级,绝大多数值落在[-3, 3]区间。在这个区间里softmax的输入差距不会过大,输出分布不是极端one-hot,保留了一定的“软度”。
exp(3) ≈ 20.09 exp(-3) ≈ 0.05最大差距约400倍,虽然也不算小,但比起不缩放时exp(24)的10^10量级完全是两回事。这个量级下,softmax各个位置的梯度都在合理范围内,反向传播的信息能够顺利流动到Q和K。
4.2 原论文的说法和后来研究的补充
Transformer原论文(2017年提出Transformer的那篇)其实只用一句话带过了这个问题:当d_k较小时,加性注意力和缩放点积注意力表现类似;但当d_k较大时,不加缩放的点积注意力效果明显更差。作者怀疑,大d_k导致点积幅度变大,把softmax推进了梯度极小的区域。
这个“怀疑”后来被大量实验和后续研究证实。不过也有更细致的争论:单纯从方差角度看,除以√d_k确实能让初始点积方差回到1,但有人指出,这个固定缩放不一定是所有场景下的最优解。比如某些工作尝试用可学习的缩放系数或按层自适应地调整缩放,在小部分任务上能取得微弱的提升,但大多数情况下的提升不显著,还引入了额外的不稳定因素。
我个人的建议是:标准模型老老实实用1/√d_k。这不仅是数学上最稳的默认选择,也是生态里所有预训练模型的默认配置。除非你有非常明确的理由和充分对比实验,否则没必要为了那零点几个点的收益自找麻烦。
4.3 换个角度:√d_k本质上是一个固定的softmax温度
如果你熟悉知识蒸馏里的温度参数T,会发现除以√d_k其实就是在softmax上施加了一个温度为T=√d_k的效果:
softmax(x / T),其中 T = √d_k温度越高,概率分布越平滑;温度越低,分布越尖锐。√d_k作为温度,恰好把原本“过冲”的logits拉到一个合理的工作区间。这个类比特别有用,因为当你排查注意力是否“太尖锐”或“太平滑”时,可以把√d_k当成一个基准温度来思考:如果注意力权重几乎one-hot,可能是logits尺度过大;如果注意力权重看不出差异,可能是logits尺度过小。
正常训练中,模型会让Q和K的尺度“自我调节”到合适状态,但有了√d_k这个锚点,调节起点就友好得多。你也可以在调试时临时把缩放系数改大或改小,观察注意力分布怎么变——这是最快理解缩放作用的方式。
5. 实操验证:去掉√d_k会发生什么
5.1 实验设计:小模型对比
我用一个极简的实验来验证这个“隐藏杀手”。环境就用常见的深度学习框架,模型是个单层解码器Transformer,d_model=128,8个注意力头(所以d_k=16),训练一个字符级语言模型小任务。d_k=16不算大,按理论说点积标准差是4,问题还没那么严重;但我也跑了一组d_k=64的配置,对照组分别是“缩放”和“不缩放”。
代码层面,两种实现唯一的区别就是一行除法:
import torch import torch.nn.functional as F import math def attention_with_scale(Q, K, V, mask=None): d_k = Q.size(-1) scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k) if mask is not None: scores = scores.masked_fill(mask == 0, float('-inf')) weights = F.softmax(scores, dim=-1) return torch.matmul(weights, V), weights def attention_without_scale(Q, K, V, mask=None): # 把除以 sqrt(d_k) 去掉,其他完全一样 scores = torch.matmul(Q, K.transpose(-2, -1)) if mask is not None: scores = scores.masked_fill(mask == 0, float('-inf')) weights = F.softmax(scores, dim=-1) return torch.matmul(weights, V), weights如果要复现更贴近真实场景的Transformer,可以直接用框架自带的注意力函数,但为了验证缩放的影响,手写两个版本更有说服力。
5.2 训练曲线的差异:肉眼可见的分水岭
先说结论:在d_k=64、不加缩放的配置下,训练loss从一开始就高高挂起,前几个step甚至会出现inf/NaN,勉强稳定后loss也在一个很高的水平震荡。加了缩放的对照组,loss平滑下降,15个epoch后明显收敛到更低的loss。
具体数字上,同一份数据、同样的初始化种子,缩放版最终验证loss比不缩放版低了一大截,训练过程中的loss曲线也稳定得多。
可能有朋友会问:既然d_k=64时点积标准差才8,不至于真的爆数值吧?答案是:单次前向不会爆,但从梯度的角度看,模型每一步都在“半死不活”地更新。而且训练后期随着参数scale漂移,点积尺度可能进一步膨胀,最后真的触发数值溢出。
5.3 注意力热力图:从“软”到“硬”的直观证据
训练结束后,我拎出一个样本,把最后一层注意力权重画成热力图。缩放版的注意力图有明显的“多峰”结构,不同头会关注不同的位置,有些头关注前几个token,有些头关注当前位置附近。而不缩放版的注意力图,几乎每个位置都只盯着一个离得很近的token,其他位置全是接近0的值。
这个现象在机器学习里叫“注意力坍缩”(attention collapse),本质上就是因为logits尺度过大,softmax直接梭哈。
为什么这个现象有害?因为注意力的意义在于“选择性聚焦”——模型需要根据上下文动态决定关注哪里。如果每个token都死盯着固定的位置,那注意力机制就退化成了一种固定的稀疏连接,丧失了上下文相关的建模能力,Transformer的表达力也就大打折扣。
6. 常见问题与排查技巧实录
6.1 注意力输出全是NaN怎么办
NaN往往不是softmax本身爆的,而是exp()在数值溢出时产生的。在fp32下,exp的最大输入大约能到88,超过就inf;在fp16下,最大输入只有约11.09,超过就爆。如果你在混合精度训练中遇到NaN,首先要检查logits的分布是否过大。
排查方向有四个:一是确认缩放系数是否生效(很多人改了代码,但可能在后续重构中丢了);二是检查初始化是否合理(过大的初始化会让Q/K尺度失控);三是看是否有mask把-inf加进去,softmax前-inf本身没问题,但fp16下要小心;四是做梯度裁剪,通常能缓解但不解决根本问题。
6.2 训练不收敛,loss降不下去
如果loss一直是平的,先看一眼attention权重的分布。在训练脚本里按step把attention矩阵打印出来,如果看到权重很快变成one-hot,那大概率就是logits尺度过大。最快的修复方式就是恢复1/√d_k缩放。
另一种排查方式:统计logits的均值、标准差、最大最小值。正常情况下一开始的logits标准差应该在1附近,如果标准差远大于这个值,比如变成了10甚至100,那说明缩放或者初始化出问题了。
6.3 混合精度训练:fp16下的缩放更关键
混合精度训练把很多张量存成fp16,fp16的数值范围和精度都比fp32差一大截。在fp16下,不缩放点积导致logits很容易超过11这个阈值,softmax直接inf。所以哪怕你觉得“我的模型小、d_k也小、不缩放好像没事”,只要切到fp16,问题就会立刻暴露。
我用一张表总结不同情况下的表现:
| 配置 | fp32下表现 | fp16下表现 |
|---|---|---|
| 缩放√d_k | 正常收敛 | 正常收敛 |
| 不缩放,d_k=16 | 收敛慢,但勉强能跑 | 偶尔NaN |
| 不缩放,d_k=64 | 明显恶化,loss卡住 | 频繁NaN |
| 不缩放,d_k=128 | 几乎无法训练 | 基本直接爆 |
6.4 其他注意力变体的缩放处理
如果你用到的不是标准点积注意力,几个常见变体也要注意:
- 多头注意力:每个头单独除以自己头的√d_k,不是除以√d_model。
- 线性注意力(Linear Attention):通常没有softmax,但这个变体也有自己的scale问题,不能直接套用√d_k的结论。
- 使用旋转位置编码(RoPE)时,q和k会被旋转,但维度不变,√d_k缩放依然需要。
- 若在Q/K之前使用了LayerNorm,等于人为地把输入约束到单位尺度,此时缩放依然保留为好。
我踩过一个坑:当时为了加快实验,把注意力里的√d_k换成了一个可学习的常数,初始化为1。结果训练前期就出现attention collapse,后来把初始化改成√d_k的量级才稳定。可学习缩放不是不行,但初始化必须遵循方差守恒原则。
最后再分享一个小技巧:怀疑注意力scale有问题时,不需要重新训练。只需要在一个已经训练好的模型上,临时把缩放系数改掉跑一次前向,比较attention权重的熵。缩放正常时,注意力权重的熵应该在一个合理区间,不会出现一大堆0和1;如果熵特别低,基本可以确认scale失调。这个检查方法三分钟就能出结果,比反复训练试错高效得多。