自注意力机制——Transformer 的“心脏”
说实话,我第一次接触 Transformer 的时候,最让我眼前一亮的就是自注意力机制。它不像 RNN 那样按时间步一个一个处理,也不像 CNN 那样只看局部窗口。自注意力一次就把序列里所有位置的关系都摸清了。我记得在做一个基因表达数据矫正的项目时,序列长度有几千个位点,RNN 跑起来慢得让人抓狂,换成自注意力之后,速度和效果都有了质的飞跃。
自注意力的核心思想很简单:让每个位置都去“关注”序列中其他所有位置。它通过三个可学习的权重矩阵——Query、Key、Value——来完成这个任务。你想想看,这就像你在图书馆里找书:Query 是你想查的关键词,Key 是每本书的标签,Value 是书的内容。系统根据 Query 和 Key 的匹配程度,把最相关的 Value 加权提取出来。
从公式到直觉
标准自注意力的计算分四步走,我建议你把它们记在脑子里:
- 线性投影:输入序列 \( X \) 分别乘以三个权重矩阵 \( W_Q, W_K, W_V \),得到 Query、Key、Value 矩阵。
- 计算注意力分数:用 \( Q \) 和 \( K^T \) 做点积,得到相似度矩阵。
- 缩放与归一化:点积结果除以 \( \sqrt{d_k} \)(防止数值爆炸),再经过 softmax 得到注意力权重。
- 加权求和:用注意力权重去加权 Value,得到最终输出。
整个过程的数学表达式就是:
Attention(Q, K, V) = softmax( QK^T / sqrt(d_k) ) V
这里 \( d_k \) 是 Query/Key 的维度。嗯,这里要注意:如果不做缩放,当 \( d_k \) 很大的时候,点积结果会很大,softmax 的梯度会变得非常小——这就叫“梯度饱和”。我在一次训练蛋白质序列模型时踩过这个坑,输出全变成 NaN 了,后来一查就是忘加缩放因子。
自注意力的两个“亲戚”
很多人把自注意力(Self-Attention)和传统的注意力搞混。我在这里给你列个对比表,方便你记:
| 类型 | Query 来源 | Key/Value 来源 | 典型应用 |
|---|---|---|---|
| 自注意力 (Self-Attention) | 序列自身 | 序列自身 | Transformer 编码器、BERT |
| 交叉注意力 (Cross-Attention) | 一个序列 (如解码器) | 另一个序列 (如编码器输出) | 机器翻译解码器、T5 |
| 因果注意力 (Causal Attention) | 序列自身 (带 mask) | 序列自身 (只看到过去) | GPT 等自回归模型 |
我们做批次效应矫正时,用的主要是自注意力——因为我们想让每个基因位点的表达量,参考其他位点的表达模式来调整。
多头注意力:让模型“多角度看问题”
单头注意力只能学到一种“关注模式”。但现实数据里,一个位点可能需要同时关注它的局部邻居、远距离调控区域、甚至同源基因。多头注意力就是为此而生的。
它把 Query、Key、Value 分成 \( h \) 组,每组独立做自注意力,然后拼接起来再经过一个线性层。公式写出来就是:
MultiHead(Q, K, V) = Concat(head_1, ..., head_h) W_O
其中 head_i = Attention( Q W_Q^i, K W_K^i, V W_V^i )
我习惯把 \( h \) 设成 8 或 12,具体看序列长度。序列越长,头数可以适当多些——但别太多,不然每个头关注的信息会重叠。我踩过的一个坑是头数设到 64,结果训练时显存暴涨,效果反而还不如 8 头。
位置编码:给序列带上“顺序感”
自注意力本身是“置换等变的”——打乱输入顺序,输出也会跟着乱。但批次效应矫正的数据中,位点是有顺序的(比如基因组坐标)。所以我们得给模型注入位置信息。
最经典的方法是正弦波位置编码:
PE(pos, 2i) = sin( pos / 10000^(2i/d_model) )
PE(pos, 2i+1) = cos( pos / 10000^(2i/d_model) )
这个公式的好处是:不同位置之间的距离关系能被模型隐式学到。当然,现在也有很多用可学习位置编码的,但在处理超长序列时,正弦波依然是不错的选择。
实战避坑指南
我曾经在训练一个 10000 位点的基因表达矫正模型时,softmax 之前的值直接溢出了 Python 的 float16 精度。后来加了两层保护:
- 在 softmax 之前减去最大值(注意是沿着最后一个维度),即
scores -= scores.max(dim=-1, keepdim=True).values - 缩放因子不要忘记乘以
1/sqrt(d_k)
如果你使用 PyTorch,可以直接调用 torch.nn.MultiheadAttention,它内置了缩放、dropout 和 mask 支持。我建议你自己动手写一个自注意力层,理解透彻后再用封装好的——我在面试实习生时发现很多人只会调包,一问细节就卡壳。
一个最小可用的自注意力代码
下面是我常用的一个简单自注意力模块,去掉注释后不到 20 行:
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)
self.W_K = nn.Linear(d_model, d_k)
self.W_V = nn.Linear(d_model, d_v)
self.scale = d_k ** 0.5
def forward(self, x, mask=None):
# x shape: (batch, seq_len, d_model)
Q = self.W_Q(x) # (b, n, d_k)
K = self.W_K(x)
V = self.W_V(x)
scores = torch.matmul(Q, K.transpose(-2, -1)) / self.scale # (b, n, n)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
attn = F.softmax(scores, dim=-1)
out = torch.matmul(attn, V)
return out, attn
这个模块可以直接用在你的批次效应矫正模型里。注意 mask 参数:在解码器里用它来防止看到未来的 token;在编码器里一般不用 mask(或者用 padding mask 只忽略填充位)。
一张图总结自注意力计算流程
下面这张 SVG 图展示了自注意力从输入到输出的完整流水线。我把它画得尽量清楚,方便你对照代码理解。
从这张图你可以看到:输入 X 经过三个线性层变成 Q、K、V;Q 和 K 做点积、缩放、softmax 得到权重;然后用权重去加权 V,得到最终的输出。整个过程就是一次“全局推理”。
我个人的习惯是画这种流程图的时候,一定把“缩放”和“softmax”分开画。因为很多新手容易把这两个步骤合并理解,结果调试时找不到数值溢出的根源。你在自己实现的代码里也建议把每一步的中间结果打印出来看看——相信我,这能帮你省下不少 debug 时间。
好了,本章的自注意力机制就讲到这里。它的核心概念——QKV、缩放点积、多头、位置编码——在后续的 Transformer 架构搭建和批次效应矫正实战中都会反复用到。你不需要一下子全记住,但至少要理解“每个位置怎么去关注其他位置”这件事。下次我们就要用这些组件组装出一个能矫正批次效应的 Transformer 模型了。