
1. 为什么Self-Attention值得你花半小时搞明白如果你最近两年翻过任何一篇深度学习论文或者在技术群里潜水过一段时间肯定见过Self-Attention这个词。它几乎成了Transformer架构的代名词从自然语言处理一路杀到计算机视觉、语音识别、推荐系统甚至在图像超分辨率这种看起来跟序列没多大关系的任务里也开始大量出现它的身影比如最近冒出来的自适应稀疏自注意力adaptive sparse self-attention就被用在了高效图像超分辨率上。问题在于很多人第一次看到Q、K、V三个字母的时候脑子是懵的——这仨到底是干嘛的为什么点积一下就能注意了softmax又是怎么冒出来的这篇内容就是冲着这个来的。我不打算堆公式吓人也不打算从信息论讲起而是用最直白的生活类比加上手算一遍的方式把Self-Attention从能看懂带到能自己写出来。不管你是刚入门深度学习的小白还是已经调过几次Transformer但一直没深究原理的工程师看完之后应该都能在草稿纸上把整个过程推一遍。全文涉及的核心关键词就是Self-Attention围绕它的机制、计算、实现和衍生思路展开最后还会顺带聊聊它在图像超分这种非序列任务里怎么变形使用。先说清楚这篇内容适合谁如果你完全没接触过神经网络可能需要先补一下矩阵乘法和softmax的基本概念如果你已经会调用nn.MultiheadAttention但不知道里面发生了什么这篇正好补上那块缺口如果你是被adaptive sparse self-attention这类新词搞得一头雾水的算法工程师第五部分会让你明白它的来龙去脉。我不讲废话直接进入正题。2. 用一个生活场景把Self-Attention的骨架搭起来2.1 从翻译一句话开始理解注意力的动机想象你在翻译一句话英文是The animal didnt cross the street because it was too tired.。中文翻译时要决定it指的是什么。你会自然而然地往回看发现it大概率指animal而不是street。这个往回看、判断该关注哪个词的动作就是注意力机制的直觉来源。传统的循环神经网络RNN是把句子一个词一个词地读前面的信息要靠隐状态一路传下来传到最后容易丢。而Self-Attention的做法更直接句子里的每个词都去跟包括自己在内的所有词打招呼算一下彼此的相关性然后根据相关性大小把其他词的信息按比例拿过来融合到自己身上。这样it在编码自己的时候会大量吸收animal的信息少量吸收street的信息最后它的表示就带上了正确的语义倾向。关键点在于自己跟自己打招呼这件事。如果Query来自解码器、Key和Value来自编码器那叫交叉注意力Cross-Attention。而当Q、K、V全部来自同一个序列的时候就是我们说的Self-Attention——自己注意自己。2.2 Query、Key、Value到底是什么关系很多人卡在Q、K、V这三个名字上。我换个说法Query查询我想找什么。比如it这个词想知道我到底指谁。Key键我身上贴的标签。每个词都挂一个标签说明自己是什么角色比如animal的标签偏向可被指代的生物。Value值我实际能提供的信息内容。一旦某个词被匹配上它真正贡献出来的那部分信息就是Value。用一个更生活的例子你去图书馆找书。你心里想的检索词是Query每本书书脊上的分类标签是Key书里的实际内容是Value。你拿检索词去跟每本书的标签比对匹配度高的书你就把它的内容搬回家。Self-Attention就是把这件事对句子里每个词都做一遍而且是并行的。这三者其实都是从同一个输入向量线性变换出来的给输入X分别乘三个可学习的权重矩阵W_Q、W_K、W_V就得到Q、K、V。为什么不让它们直接用X本身因为让模型自己去学我该用什么方式提问、什么方式标记、什么方式提供内容表达能力更强。这是设计上的一个关键取舍用可学习参数换灵活性。3. 手算一遍Self-Attention到底是怎么算出来的3.1 打分、归一化、加权求和三步走整个Self-Attention的数学流程拆开就是三步打分用Q和K做点积得到每个词对其他词的关注分数。scores Q K.T归一化把分数除以√d_kd_k是Key的维度再过一个softmax变成加起来等于1的权重。加权求和用这些权重去乘V得到每个词的新表示。output softmax(scores) V写成公式就是大家最常见的那一行Attention(Q, K, V) softmax(QK^T / sqrt(d_k)) V看起来简单但每一处都有讲究。我们拿一组具体数字走一遍。假设一句话有3个词每个词用4维向量表示方便手算。为了不失一般性我直接设定Q、K、V如下实际中它们是学出来的Q [[1, 0, 1, 0], [0, 1, 0, 1], [1, 1, 0, 0]] K [[1, 0, 0, 1], [0, 1, 1, 0], [1, 0, 1, 0]] V [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]第一步算QK^T。以第一个词Q第一行[1,0,1,0]对第一个词K第一行[1,0,0,1]为例点积 1×1 0×0 1×0 0×1 1。对第二个词K第二行[0,1,1,0]1×00×11×10×0 1。对第三个词K第三行[1,0,1,0]1×10×01×10×0 2。所以第一个词的分数是[1, 1, 2]。第二步除以√d_k。这里d_k4√42。分数变成[0.5, 0.5, 1]。为什么要除后面单独讲先记住这个动作。第三步softmax。对[0.5, 0.5, 1]做softmaxexp(0.5)1.6487exp(0.5)1.6487exp(1)2.7183和 6.0157权重 [0.274, 0.274, 0.452]意思是第一个词在编码自己时27.4%吸收自己的信息27.4%吸收第二个词45.2%吸收第三个词。第四步加权V。用[0.274, 0.274, 0.452]去乘V的三行第一维0.274×1 0.274×5 0.452×9 0.274 1.37 4.068 5.712后面三维同理可得0.274×20.274×60.452×10 6.7120.274×30.274×70.452×11 7.7120.274×40.274×80.452×12 8.712所以第一个词的新表示是[5.712, 6.712, 7.712, 8.712]。其余两个词照同样流程各算一遍。这就是一次完整的Self-Attention全部是矩阵乘法天然可以并行。提示手算时一定要自己列一遍光看公式不会有肌肉记忆。我第一次真正理解就是在纸上把3×4的矩阵乘完那一下。3.2 为什么非得除以根号d_k这是面试里最常被问、也最容易答错的点。除以√d_k是为了防止点积结果过大导致softmax进入饱和区。假设Q和K的每个分量都是均值0、方差1的独立随机变量那么它们的点积d_k项求和的方差就是d_k。d_k越大点积的数值范围就越宽。当数值很大时softmax的输出会趋近于one-hot也就是某个位置接近1其余接近0。这会导致两个后果一是梯度几乎消失因为softmax在饱和区导数极小二是模型过早地把注意力死锁在个别位置上丧失探索能力。除以√d_k相当于把点积的方差重新拉回到1附近让softmax工作在一个梯度健康的区间。这不是玄学是可以推导的Var(q·k) d_k除以√d_k后Var就变成1。所以这个除法不是随便找个数而是精确地对应了把方差归一化这个目的。我见过有人改成除以d_k结果训练明显变慢甚至不收敛就是因为过度压缩了分数差异。记住是√d_k不是d_k。3.3 多头注意力不是玄学就是多个人同时看一个注意力头只能学到一种关注模式。但语言里的关系是多样的——有语法依赖、有指代关系、有语义相似一个头忙不过来。多头注意力Multi-Head Attention的做法是把Q、K、V在特征维度上切成h份每份单独做一次Self-Attention最后把结果拼接再线性变换。好处很直观不同的头可以关注不同类型的关系。有的头盯着主谓一致有的头盯着远距离指代有的头盯着邻近词。这跟卷积网络里多个卷积核各看各的纹理是一个思路。代价是要控制好每个头的维度。假设模型维度d_model512头数h8那每个头的维度d_k 512/8 64。总计算量跟单头差不多但表达能力提升明显。这也是为什么Transformer原论文里d_model必须是头数的整数倍——不然切不均匀。4. 亲手写一个Self-Attention30行PyTorch代码4.1 环境准备与依赖确认代码部分我用PyTorch装好基础的1.x或2.x任意版本都行。检查环境python -c import torch; print(torch.__version__)如果你还没装用pip装CPU版本足够跑通这个例子pip install torch numpy不需要GPU我们这个规模的数据在CPU上毫秒级出结果。为什么选PyTorch而不是别的因为它的张量操作语义清晰直接对应矩阵乘法跟公式一一对得上读代码就像读数学。4.2 从零实现核心计算下面这段代码我把每一步都对应到公式方便你对照import torch import torch.nn as nn import torch.nn.functional as F import math class SelfAttention(nn.Module): def __init__(self, d_model): super().__init__() self.d_model d_model # 三个线性层把输入投影成Q、K、V self.W_q nn.Linear(d_model, d_model) self.W_k nn.Linear(d_model, d_model) self.W_v nn.Linear(d_model, d_model) self.scale math.sqrt(d_model) def forward(self, x): # x shape: [batch, seq_len, d_model] Q self.W_q(x) # [B, L, D] K self.W_k(x) # [B, L, D] V self.W_v(x) # [B, L, D] # 打分Q K^T注意转置最后两维 scores torch.matmul(Q, K.transpose(-2, -1)) # [B, L, L] # 缩放 scores scores / self.scale # 归一化 attn F.softmax(scores, dim-1) # 加权求和 out torch.matmul(attn, V) # [B, L, D] return out, attn # 测试一下 torch.manual_seed(0) x torch.randn(2, 3, 4) # batch2, 序列长度3, 维度4 sa SelfAttention(d_model4) out, attn sa(x) print(输出形状:, out.shape) # 应为 [2, 3, 4] print(注意力矩阵:\n, attn[0]) # 第一个样本的3x3权重关键细节说明K.transpose(-2, -1)是把最后两个维度交换这样[B, L, D] [B, D, L] [B, L, L]得到每个位置对每个位置的分数。dim-1的softmax保证每一行加起来是1。attn就是可视化注意力时画的那张热力图。4.3 加掩码、加多头把玩具变成能用的模块上面是单头且不带掩码的版本。真正用在语言模型里还得处理两件事。掩码Mask解码器不能看到未来的词要做一个上三角为负无穷的掩码。def forward_with_mask(self, x): Q, K, V self.W_q(x), self.W_k(x), self.W_v(x) scores torch.matmul(Q, K.transpose(-2, -1)) / self.scale # 上三角置为 -infsoftmax后变0 L x.size(1) mask torch.triu(torch.ones(L, L), diagonal1).bool() scores scores.masked_fill(mask, float(-inf)) attn F.softmax(scores, dim-1) return torch.matmul(attn, V), attn多头不需要自己写循环PyTorch的nn.MultiheadAttention已经封装好了。mha nn.MultiheadAttention(embed_dim64, num_heads8, batch_firstTrue) x torch.randn(2, 10, 64) out, weights mha(x, x, x) # 自注意力QKVx print(out.shape) # [2, 10, 64]注意batch_firstTrue是很多坑的来源。老版本默认序列维在前写成[L, B, D]调不对形状时优先怀疑这里。5. 从文本到图像Self-Attention在超分任务里的变形5.1 为什么图像超分也盯上了注意力图像超分辨率Super-Resolution, SR要做的是把低分辨率图还原成高分辨率图补出丢失的高频细节。传统卷积网络靠堆层扩大感受野但卷积的局部性决定了它一次只看一小块远处的纹理关系得靠很深的网络才能间接捕捉到。而Self-Attention天生就是全局的每个像素位置都能跟图上任意位置直接发生关联这对纹理重复这类现象特别有效——比如草地、砖墙、织物规律性纹理在远处也能找到参照。所以把Transformer搬进超分任务直觉上是合理的。但问题也来了图像的像素数远大于句子的词数。一张256×256的图展平就是65536个token注意力矩阵是65536×65536显存直接爆炸。这就是为什么原生的Self-Attention在图像任务里不能硬套。5.2 自适应稀疏自注意力在解决什么最近这类工作adaptive sparse self-attention for efficient image super-resolution的核心思路就是别让每个位置都跟所有位置算注意力只挑真正相关的少数位置算。具体来说大致有这么几个方向窗口化只在局部窗口内做注意力比如Swin Transformer那样把图切成小块在块内自注意力再通过移位实现跨块交互。这是用局部性换效率。稀疏化不预先定死窗口而是让网络自己学哪些位置值得关注。可能是通过学习一个稀疏掩码或者用top-k只保留分数最高的若干连接。自适应稀疏模式不是固定的而是根据输入内容动态变化。平坦区域可能不需要太多注意力纹理密集区域才需要加大力度。这就叫自适应。这三者叠加就是自适应稀疏自注意力。它的价值在于把一个O(N²)的问题压到接近O(N)同时在关键区域保留全局建模能力。相比固定窗口自适应稀疏能更好地处理图像里结构分布不均的情况。代价是什么实现复杂度上去了。稀疏操作往往涉及不规则索引GPU上的并行效率不如规整的稠密矩阵乘法写得不好反而更慢。所以工程上的功夫在于怎么让稀疏既省内存又真的省时间。这是这类方法落地时最容易翻车的地方。6. 踩坑记录与常见问题速查6.1 新手最容易绕晕的六个概念困惑点一句话解答Q、K、V为什么不用输入本身用可学习投影能学出更适合提问/标记/提供内容的表示为什么除以√d_k归一化点积方差防止softmax饱和导致梯度消失softmax为什么作用在最后一个维度因为要对每个位置对所有位置的分数做归一化行和为1多头是把QKV切分还是复制切分每个头维度是d_model/hSelf-Attention和Cross-Attention区别Q、K、V是否来自同一个序列位置信息去哪了Self-Attention本身无序需要额外加位置编码这张表建议存下来遇到概念模糊时扫一眼。说实话我见过太多人把多头理解成复制多份做平均那完全是错的——多头是切分维度各自独立计算最后拼接再投影。6.2 实操里的四个真实坑坑一维度对不上。最常见的报错是矩阵乘法维度不匹配。记住Self-Attention里Q和K的最后一维必须相同那是d_kV的维度可以和它们不同d_v。写代码时养成打印shape的习惯比盯着报错猜快十倍。坑二softmax方向搞反。如果你发现注意力矩阵每列和为1而不是每行和为1说明dim写错了。行代表某个位置关注其他所有位置所以归一化应该在最后那个维度。坑三忘了scale。有人写着写着把除法漏了小数据上可能看不出问题一上大规模训练就发散。建议把scale直接写进__init__里别每次手写。坑四图像任务直接套文本实现。把图像展平成超长序列去算全局注意力显存分分钟爆。要么用窗口要么用稀疏要么用线性注意力变体。这一点在做超分、检测、分割时尤其重要别拿NLP的模板硬套。提示调试Self-Attention时先用序列长度3、维度4这种迷你尺寸跑通再放大。小尺寸能手动验证结果出错了也容易定位。最后分享一个我自己验证实现是否正确的小技巧把输入设成完全相同的一批向量如果实现正确注意力权重应该趋近于均匀分布因为没有哪个位置比其他位置更特别。如果权重明显偏斜那多半是scale或者投影初始化有问题。这个自检办法救过我好几次尤其在改动掩码逻辑之后。再往深了走你可以去研究线性注意力、Performer那类用核函数近似softmax的工作以及前面提到的自适应稀疏路线。它们的共同目标都是一个在尽量不损失全局建模能力的前提下把计算和内存降下来。理解了最基础的这个Attention(Q,K,V) softmax(QK^T/√d_k)V这些变体看起来就不会那么面目可憎了。