注意力机制这个东西,我最早是在Transformer的文章里认识的,公式背得滚瓜烂熟:softmax(QK^T / √d_k)乘V。但“为什么是softmax?为什么除以√d_k?Q、K、V三个矩阵到底在干嘛”这些问题,我并没有真正想通。直到后来接触非参数统计,读到Nadaraya-Watson核回归,一下子像被点醒了:这不就是注意力机制的雏形吗?
这篇文章想把这条线完整理一遍。从一个比较朴素的核回归实验出发,看它如何一步步长成如今深度学习里无处不在的注意力机制,中间每一步到底改了什么东西、为什么要这样改。同时我会给出可以直接运行的PyTorch代码和对比实验,让你不只是“听懂”,还能亲手把这个联系做出来。
适合谁看?如果你学过Transformer的公式但总觉得隔着一层,或者你在实际项目中调过注意力参数却不太清楚手感,这篇会很有帮助。我不依赖复杂的数学推导,所有公式都压到最小必要程度,重点放在直觉和可复现代码上。
1. 先用一个最朴素的回归问题,理解Nadaraya-Watson核回归
1.1 核回归在干什么:相似度加权平均
先说场景。给定一堆散点(x_i, y_i),你想知道任意一个新点x对应的y是多少。最传统的方法是拟合一个线性模型或者多项式,但问题来了:你根本不知道数据的函数形式。它可能是带噪声的正弦波,也可能有突变,甚至可能是一个分类问题里的概率。这时候强行选一个参数模型,很容易选错。
核回归走了另一条路:我什么都不假设,只看“和x接近的那些训练样本,它们的y应该和x的y差不多”。于是预测值写成:
f(x) = (Σ_i K((x - x_i) / h) · y_i) / (Σ_i K((x - x_i) / h))
K是核函数,最常用的高斯核长这样:
K(u) = exp(-u² / 2)
带进去之后,对每个训练样本计算一个权重:离x越近,权重越大;离x越远,权重越小。最后把y_i按权重加起来,再除以总权重做归一化。整个公式说的就是一句话:按相似度做加权平均。
这句话建议你记住,注意力机制的全部秘密都在这里面。Transformer里的注意力权重,说穿了也是一个“相似度加权平均”,只是它把前面那套东西全部换成了可学习的版本,而且维度从一维推广到了高维向量。
1.2 核函数和带宽:注意力集中的“温度旋钮”
高斯核里有个h,叫带宽。这个参数极其关键,它直接回答了“什么样的样本算近”。
可以这么理解:h相当于一个放大镜的焦距。h很小,只有紧贴着的点才能拿到权重,预测曲线就会剧烈振荡,几乎穿过每个训练点;h很大,远近样本的权重差别越来越小,最后曲线会被抹成一条接近常数的平线;h取一个合适值,曲线才能既贴合数据又不过度敏感。
如果你熟悉softmax温度,会发现h和温度T是一家人。softmax(x/T)里T越小、分布越尖锐,T越大、分布越平缓。核回归里的h干的同样是这件事:控制加权的集中程度。后面你会看到Transformer里的√d_k也是一个类似角色——只是缩放方向相反,h是做除法,√d_k也是做除法,它们都在调节分数分布的“钝锐程度”。
带宽怎么选?比较正经的办法是交叉验证,但实操中你只要把h从小到大多跑几组,看图就能确定大概范围。麻烦的是,在真正的Transformer训练里,这个“带宽感”需要通过梯度下降自己学出来,这也是注意力机制比核回归难训的一个原因。
1.3 最小实现:h=0.05、0.5、10三种状态对比
动手之前先说清楚,这段代码不涉及任何神经网络,就是纯粹的Nadaraya-Watson估计。我用PyTorch生成带噪声的正弦数据,然后实现核回归。
import torch import matplotlib.pyplot as plt torch.manual_seed(42) # 生成数据:带噪声的 sin 曲线 N = 80 x_train = torch.rand(N) * 10 y_train = torch.sin(x_train) + 0.2 * torch.randn(N) def nadaraya_watson(x_query, x_train, y_train, h): # x_query: (M,) 或标量,一维核回归 diff = x_query.unsqueeze(-1) - x_train.unsqueeze(0) # (M, N) weights = torch.exp(-(diff ** 2) / (2 * h * h)) weights = weights / weights.sum(dim=-1, keepdim=True) y_pred = (weights * y_train.unsqueeze(0)).sum(dim=-1) return y_pred, weights x_grid = torch.linspace(-1, 11, 200) for h in [0.05, 0.5, 10.0]: y_pred, _ = nadaraya_watson(x_grid, x_train, y_train, h) plt.plot(x_grid.numpy(), y_pred.numpy(), label=f"h={h}") plt.scatter(x_train.numpy(), y_train.numpy(), alpha=0.4, label="data") plt.legend() plt.show()跑出来你会看到非常直观的三种状态:
| 带宽h | 曲线形态 | 对应到注意力机制的感受 |
|---|---|---|
| 0.05 | 曲线穿过每个点,剧烈振荡 | 注意力分数过于尖锐,几乎只关注最近邻,相当于softmax温度趋近0 |
| 0.5 | 平滑拟合正弦曲线,噪声被过滤 | 注意力分数适中,加权范围合理,类似默认温度 |
| 10.0 | 曲线接近常数,细节全部丢失 | 注意力分数趋于均匀,相当于大家都在做全局均值池化 |
我建议你把这三个h都跑一遍,亲身感受一下“带宽=注意力的集中程度”。这比看一百篇论文都管用。代码里没有梯度、没有优化器,它唯一的价值就是让你亲眼看到,所谓“注意”是怎么从一次加权平均里涌现出来的。
2. 三步把核回归改造成注意力机制
2.1 从负距离到正点积:相似度的两种写法
前面核回归的核心是exp(-距离²)。这个形式有一个隐含倾向:距离越小、权重越大,因为它用的是负距离。但“相似度”不一定非得用距离表达。如果样本本身是向量,可以用点积来刻画对齐程度:两个向量方向越一致、模越大,点积越大,说明它们越相似。
于是把指数里的负距离平方换成点积,权重变成:
w_i = exp(q · k_i)
其中q是查询向量,k_i是第i个键向量。再对所有w_i做归一化,等价于:
weights = softmax([q·k_1, q·k_2, ..., q·k_N])
这一步做完,你已经得到注意力机制的雏形了。这时候权重仍然来自固定规则,不包含任何可学习参数,本质上还是一个核回归,只是相似度的度量从L2距离换成了点积。
之所以用点积而不是距离,一个很重要的原因是点积天然适合高维向量比较,而且写成矩阵乘法后可以一次性并行计算所有位置之间的相似度。这也是Transformer能高效训练的原因之一。
2.2 引入QKV:让核函数和输出可学习
核回归里,x_i直接参与相似度计算,y_i直接作为加权对象。但真实数据里,输入向量不一定适合直接拿来比较。比如句子里的词向量,它混合了语义、句法、位置各种信息,直接算点积可能分不清该关注哪部分。
于是引入三个可学习的线性投影:
q = W_Q · x k_i = W_K · x_i v_i = W_V · x_i
这里W_Q、W_K、W_V就是常说的QKV矩阵。加上投影之后,注意力机制获得了两个能力:
- 比较的视角可以学习:模型自己决定在什么子空间里算相似度,而不是拿原始向量硬比;
- 输出的内容可以学习:V经过投影后,不再是原始y_i,而是“值得被聚合的信息”。
这一步是核回归和注意力最大的分水岭。核回归的核函数是固定的高斯核,注意力机制的核函数则是数据驱动、可以训练的。用个不太严谨但很好记的说法:注意力是一个“学出来的”Nadaraya-Watson核回归。
这里也给个小提示:Q、K、V虽然名字玄乎,但它们的角色很清晰——Q负责提问“我要找什么”,K负责回答“我这里有什么”,V负责提供“找到之后读出什么内容”。
2.3 除以√d_k:给注意力打分定一个温度
大多数教程讲到softmax(QK^T/√d_k)时,只会说“防止点积过大导致softmax饱和”。这句话没错,但如果你从核回归的角度看,会有更深的体会。
当d_k变大时,q·k的点积是d_k个独立乘法相加,方差会随维度线性增长。举例说,假设q和k各分量是均值为0、方差为1的随机变量,那么点积的方差大约是d_k,标准差是√d_k。如果不做缩放,维度64的时候分数标准差8,维度512的时候标准差22,softmax的输入很容易进入两端饱和区,梯度接近0,模型学不动。
除以√d_k,本质上是把打分方差拉回常数级别,相当于给softmax设置了一个合理的“温度”,让分布既不过于尖锐也不过于平坦。从核回归的类比看,√d_k起到的作用和带宽h类似,都是在控制加权的集中程度,只是它固定为一个与维度相关的常数。
实操建议:如果你在某些变体里看到不用softmax、改用其他归一化的注意力,比如线性注意力,它的“温度”往往也是靠缩放因子来控制的。理解√d_k的这一层含义,之后分析各种注意力变体都会轻松很多。
3. Transformer里的自注意力,其实是一组核回归
3.1 逐位置拆解自注意力
很多人背过自注意力的定义,但背完就忘。现在我们回到核回归的视角,把自注意力拆开。
假设输入序列是[x_1, x_2, ..., x_T],每个x_t都是一个向量。经过Q、K、V投影之后,得到Q、K、V三个矩阵。自注意力对每个位置t做下面这件事:
- 取出q_t,把它当作核回归里的“查询点x”;
- 对所有位置j计算分数 q_t·k_j,归一化得到权重;
- 用权重对V的所有行做加权平均,得到输出z_t。
这个过程和第一节里的核回归完全同构:每个位置都在以自身为查询点,对序列内所有其他位置做一次相似度加权平均。差别只在于:
- 维度从一维变成高维;
- 核函数不再是固定的exp(-距离²),而是softmax(q·k);
- 参与比较的内容和用于聚合的内容,由可学习的投影决定。
换句话说,自注意力就是“序列中每个位置各做一次核回归,而且核函数本身由数据动态生成”。这也是为什么序列模型可以自动学到长距离依赖:只要两个位置在语义上对齐,不管它们中间隔多远,注意力权重都能给得很大。
3.2 多头注意力=多次核回归的集成
单头注意力虽然灵活,但只有一个视角。比如在句子里,你可能既想关注主谓一致,又想关注指代关系,这两个需求对“相似度”的定义很可能不一样。多头注意力做的事,就是同时开h个核回归,每个头在自己的子空间里算相似度。
从核回归类比看,多头注意力有三层含义:
- 集成效应:多个核回归器各做各的,最后拼起来,相当于多专家投票,误差更稳;
- 特征子空间分工:每个头可以在不同维度上判断“谁和谁该互相注意”,这是模型自己学会的分工;
- 表达能力增强:单头注意力的加权平均本质上还是低秩结构,多头把多个低秩结构拼起来,表达能力上限显著提高。
实操里,头的数量并不是越多越好。头数太多会把d_model劈得太碎,每个头的维度太小,学不到稳定的相似度度量。我一般会先保证每个头的维度不低于32,再去调头数。
3.3 位置编码是在给核回归补“坐标”
核回归天生没有“位置”概念。它只认特征距离:两个点特征越近,权重越高。自注意力也是一样,如果不加位置信息,它就是一个对序列内容做置换不变的加权平均器——把句子里的词随便换顺序,注意力结果完全不变。
这显然不行。自然语言里“猫追狗”和“狗追猫”的意思完全不同。解决办法也直白:把位置信息加到输入embedding里。位置编码本质上就是给每个位置加一个“坐标”,让相似度计算既看内容是否相似,又看位置是否靠近。
用核回归的语言说,位置编码等效于在高维特征空间里额外增加了位置维度。如果你把每个token表示成“内容向量”和“位置向量”的组合,那核回归在比较两个token时,同时会在内容轴和位置轴上打分,模型就可以选择“只看内容相似”或“同时要求位置接近”。
这也是为什么相对位置编码在很多任务里效果更好的原因:它相当于让每个查询点自带一个可学习的“局部窗口感受野”,类似于把核回归的带宽h变成了随位置变化的形式。
3.4 从零实现一个最小自注意力
理解了原理,代码就水到渠成。下面是一个最小但完整可运行的自注意力实现,不追求效率,只求可读性。
import math import torch import torch.nn as nn import torch.nn.functional as F class SelfAttention(nn.Module): def __init__(self, d_model, d_k, d_v): super().__init__() self.W_Q = nn.Linear(d_model, d_k, bias=False) self.W_K = nn.Linear(d_model, d_k, bias=False) self.W_V = nn.Linear(d_model, d_v, bias=False) self.d_k = d_k def forward(self, x): # x: (T, d_model) Q = self.W_Q(x) # (T, d_k) K = self.W_K(x) # (T, d_k) V = self.W_V(x) # (T, d_v) scores = Q @ K.T / math.sqrt(self.d_k) # (T, T) weights = F.softmax(scores, dim=-1) # 每行是归一化后的注意力权重 out = weights @ V # (T, d_v) return out, weights这段代码只有十几行,但包含了自注意力最核心的骨架。你可以把第一节的核回归代码和这段对照着看:
- 核回归里的
exp(-距离²/2h²),变成了这里的softmax(QK^T/√d_k); - 核回归里的y_i,变成了这里的V;
- 核回归里的x和x_i,变成了这里的q和k。
想更直观地看注意力学到的模式,可以把weights打印出来,对权重矩阵画热力图。你会看到对角线附近往往权重较高(因为自己和自己通常最相似),但模型也会学到跨位置的强关联——那些就是它认为应该重点关注的“远处邻居”。
模型训练时,在序列长度不长的情况下,我们可以直接观察不同头关注的区域。你会发现有的头倾向于关注相邻位置,有的头则跨越长距离捕捉关系。这时候再回头看核回归的带宽h,你会理解其中联系:每个头其实都在自己那份“核回归”里学了一个动态带宽。
4. 实操中常见的问题与排查技巧
4.1 注意力分数退化:权重全变成均匀分布
新手训练Transformer时经常遇到一个现象:训练初期甚至整个训练过程中,某些头的注意力权重都接近于均匀分布——所有位置几乎同样的权重,模型退化成了全局平均池化。
从核回归的角度看,这相当于带宽h设置得太大,高斯核几乎不区分远近。注意力层面最常见的原因是初始化不好或训练步数不够。如果持续出现,可以试试:
- 调整QK投影的初始化方差,让初始点积分数分布更分散;
- 加大训练步数或调整学习率,让Q、K更快拉开差异;
- 换用温度可学习的softmax变体,相当于允许模型自行调整“带宽”。
如果你的模型有特殊任务,比如机器翻译里某些头均匀化,不一定代表模型坏了。很多情况下均匀头承担着“全局残差连接”的作用,是正常现象。判断方法很简单:看去掉这个头之后性能掉多少,不掉就说明它确实可有可无。
4.2 数值稳定性:softmax溢出不只在日志里出现
手写注意力时最容易踩的坑是softmax的数值稳定性。当分数矩阵里出现特别大的值,exp之后可能溢出成inf,反向传播时直接NaN。
PyTorch里的F.softmax已经内置了max-shift技巧,所以大部分人不会遇到。但如果你自己实现类似逻辑,比如做论文复现,建议手动加一下:
def stable_softmax(scores, dim=-1): scores_max = scores.max(dim=dim, keepdim=True).values exp_scores = torch.exp(scores - scores_max) return exp_scores / exp_scores.sum(dim=dim, keepdim=True)这个技巧的原理很简单:softmax对整体平移不变,减掉最大值再算指数,既能保证最大项不溢出,又不会改变最终的分布结果。实际训练里,除了手动实现,还有可能是梯度爆炸导致分数越推越大。遇到NaN时,先检查loss是否在某个step突然起飞,再检查前向有无异常。
4.3 缩放因子与学习率的关系
很多人把√d_k当成一个无关紧要的细节,实际上它直接影响可训练性。前面说过,如果不缩放,高维点积分数方差很大,softmax会进入饱和区,梯度变小甚至消失。但缩放太小也有问题:分数分布太平坦,梯度同样很小,只是因为“太平”而不是“太陡”。
实操中你会发现,缩放因子和学习率是耦合的。如果你把缩放因子改了,原来调好的学习率很可能要重新调。一个常见组合是:使用默认√d_k缩放时,学习率可以开到1e-4到3e-4;如果你去掉缩放,可能需要把学习率降一个量级才能稳住训练。
这里给个排查思路:如果训练中注意力权重很快变成one-hot分布,大概率是分数尺度太大;如果注意力权重长期均匀,大概率是分数尺度太小或初始化不够好。两种极端都先从缩放因子下手检查,而不是盲目调学习率。
4.4 什么时候该用固定核,什么时候该用学出来的注意力
最后分享一个我在实践中养成的习惯:遇到新任务,先别急着上完整Transformer。我会先用固定核函数的加权平均做一个基线,比如就是一个可选的指数核或高斯核,设定一两个手工带宽。
这个固定核基线能回答一个问题:这个任务里“相似度加权平均”到底有没有用,带宽大概在什么量级。如果固定核效果已经不错,说明任务主要依赖局部或全局的相似结构,那上注意力大概率有收益;如果固定核效果很差,说明要么相似度定义不适合,要么模型需要更强的非线性,那就要谨慎评估是不是Transformer真的对路。
在时间序列、表格数据这类任务上,固定核甚至可能比学出来的注意力更稳,因为它没有额外的参数方差,不容易过拟合。这也提醒我们:注意力机制最大的优势不是它天生神奇,而是它把“选择什么核、在哪比较、聚合什么内容”全部交给了优化器。理解这一点之后,你会对各种注意力变体有自己的判断力,而不是每个paper都看不懂。
最后说点个人体会。我自己的学习过程特别曲折,注意力公式背了好几年,真正有豁然开朗的感觉,是在我亲手把Nadaraya-Watson核回归写出来、又把带宽h从0.05调到10之后。那一刻我才意识到,很多看起来高深的深度学习组件,根上都是统计里非常朴素的思想。
如果你也在挣扎,建议你按这篇文章的顺序复现一遍:先跑核回归,感受带宽;再写最小自注意力,对照结构;最后拿一个小数据集训练,观察不同头的注意力热力图。这个过程全套下来,比单看十篇综述都更能建立直觉。深度学习里的大部分组件都是这样,真正理解它们的方法,不是记住公式,而是自己动手做一次。