3、自注意力机制(Self-Attention)基础回顾:QKV计算、多头注意力、位置编码、Transformer
好,咱们进入正题。自注意力机制,说白了就是让模型学会「自己看自己」。你想想看,一段话里每个词都不是孤立的,它们之间有关系。自注意力要解决的,就是怎么把这种关系给量化出来。
我记得刚接触这个概念时,总觉得它很玄乎。后来亲手搭了一遍才发现,核心逻辑其实非常清晰。咱们一步步拆开看。
3.1 QKV 计算:三个角色的分工
自注意力的核心,就是三个矩阵:Query(查询)、Key(键)、Value(值)。
你可以这样理解:
- Query:你在问「谁跟我有关系?」
- Key:每个词回答「我有什么特征?」
- Value:真正要传递的信息内容
计算过程其实就三步:
- Q 和 K 做点积,得到注意力分数
- 用 softmax 归一化,变成权重
- 权重乘以 V,得到加权后的输出
公式长这样:
Attention(Q, K, V) = softmax(Q × K^T / √d_k) × V
这里有个小细节:除以 √d_k 是为了防止点积结果太大,导致 softmax 梯度消失。我在项目中遇到过因为忘记加这个缩放因子,训练直接炸掉的情况。嗯,这个坑踩过一次就忘不了了。
核心要点:Q 和 K 的维度必须一致,V 的维度可以不同。实际中为了计算方便,通常让它们相等。
3.2 多头注意力:让模型从不同角度看问题
单头注意力有个问题——它只能捕捉一种关系模式。但现实中,词与词之间的关系是多样的。比如「苹果」这个词,可能跟「吃」有关,也可能跟「手机」有关。
多头注意力就是解决这个问题的。它把 Q、K、V 分别拆成 h 个头,每个头独立计算注意力,最后再拼起来。
我个人的习惯是设 h=8,这是 Transformer 里的经典配置。当然,如果你资源紧张,4 个头也能用,效果会差一些。
# 伪代码示意
def multi_head_attention(Q, K, V, h=8):
# 拆头
Q_split = split_heads(Q, h) # [batch, h, seq_len, d_k]
K_split = split_heads(K, h)
V_split = split_heads(V, h)
# 每个头独立计算
heads = []
for i in range(h):
head = attention(Q_split[i], K_split[i], V_split[i])
heads.append(head)
# 拼接并投影
concat = concat_heads(heads)
output = linear_projection(concat)
return output
小技巧:实际实现时,不要用循环。用 reshape 和 transpose 一次性搞定,速度能快 10 倍以上。
3.3 位置编码:给序列注入「顺序感」
自注意力有个「致命」问题——它不关心词的顺序。你想想看,「我打你」和「你打我」,在自注意力眼里是一样的,因为词袋里都是这三个词。
这显然不行。所以我们需要位置编码,给每个位置一个独特的「身份标识」。
Transformer 用的是正弦余弦编码:
PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
为什么用这个?因为它有几个好性质:
- 每个位置的编码是唯一的
- 不同位置之间的相对距离可以计算
- 可以外推到更长的序列(虽然效果会变差)
我曾经试过用可学习的位置编码替代正弦余弦,在小数据集上效果差不多。但一旦序列长度超过训练时的最大长度,可学习编码就崩了。所以,如果你要处理变长序列,还是老老实实用正弦余弦吧。
注意:位置编码是加在输入 embedding 上的,不是拼接。维度必须一致。
3.4 Transformer 架构:编码器-解码器结构
Transformer 把自注意力用到了极致。它的整体结构分为编码器和解码器两部分。
咱们用一张图来理解:
编码器这边,输入序列经过多头自注意力和前馈网络,每个子层都带着残差连接和层归一化。解码器那边多了一个交叉注意力,用来关注编码器的输出。
这里有个关键点:解码器的自注意力是「掩码」的。什么意思?就是预测第 t 个词时,不能看到后面的词。我刚开始做翻译任务时,忘了加掩码,结果模型学会了「作弊」——直接复制后面的词,训练损失低得离谱,但推理时完全不能用。
经验之谈:如果你自己实现 Transformer,建议先在小数据集上跑通,验证掩码是否正确。一个简单的方法:检查解码器第 i 个位置的注意力权重,看它是否只关注前 i 个位置。
3.5 实际应用中的注意事项
说了这么多,最后分享几个实战中的坑:
- 显存爆炸:自注意力的复杂度是 O(n²),序列长度翻倍,显存占用翻 4 倍。我处理过 2048 长度的序列,单层注意力就吃了 8G 显存。解决方案:用稀疏注意力或者分块计算。
- 初始化要小心:QKV 的权重初始化不当,会导致注意力分数要么太大要么太小。我习惯用 xavier 初始化,然后加一个小的缩放因子。
- 层数不是越多越好:6 层编码器 + 6 层解码器是经典配置。我试过堆到 12 层,效果提升有限,但训练时间翻倍。
我的建议:刚开始学的时候,先用单头注意力跑通流程,再切到多头。这样调试起来方便很多。
好了,自注意力机制的基础就回顾到这里。这些东西看着简单,但每个细节都值得深挖。下一节咱们聊聊怎么把卷积和自注意力结合起来,那才是真正的重头戏。
可可精品资料库