4. Transformer 基础:自注意力机制与多头注意力
你问我 Transformer 里最核心的是什么?我会毫不犹豫地说:自注意力机制。没有它,Transformer 跟 RNN 就没多大区别。我个人习惯把自注意力理解为“模型自己跟自己开会”——每个 token 都去问其他 token:“咱俩有关系吗?关系多强?” 然后根据答案重新调整自己的表示。
但光开会还不够。会场只有一个?信息太单一了。所以就有了多头注意力——开多个分会场,每个会场关注不同的关系,最后汇总讨论结果。说白了,就是用多个不同的视角去理解同一个句子。
嗯,现在我们从最底层的原理讲起。我在做分子生成项目时,发现很多同学把注意力机制当黑盒用,一遇到长序列就崩。你理解了底下这堆矩阵运算,才能在分子生成里跑得稳。
4.1 自注意力机制:让每个 token 都能看到全局
想象你有 4 个 token:"The cat sat down"。没有自注意力时,每个词只知道自己的 embedding。有了自注意力,“sat”就能知道主语是“cat”,动作是“down”。这是怎么做到的?
先定义三个矩阵:Q(查询)、K(键)、V(值)。每个 token 都会生成自己的 Q、K、V。怎么生成?很简单——拿 token 的原始 embedding 乘三个不同的权重矩阵。
拿分子生成打个比方。假设我们有分子 SMILES 序列 CCO(乙醇)。每个原子 symbol 经过 embedding 后,变成一个向量 x。然后:
q = x * Wq
k = x * Wk
v = x * Wv
这三个 W 矩阵是模型要学的参数。我刚开始做分子项目时,习惯把 dk 设成 64,因为分子序列不长,64 足够捕捉化学键信息和官能团之间的依赖。
4.2 注意力分数的计算:点积、缩放与 Softmax
有了 Q、K、V,我们就要计算注意力分数了。核心公式就一句话:
注意力输出 = Softmax( (Q × KT) / sqrt(dk) ) × V
我给你拆开来看:
- Q × KT:每个 token 的 Q 和其他所有 token 的 K 做点积。点积越大,说明这两个 token 越“相关”。
- 除以 sqrt(dk):防止点积数值太大,导致 Softmax 后的梯度消失。为什么是 sqrt?嗯,假设 Q 和 K 是独立同分布的均值为 0 方差为 1 的随机变量,点积的方差就是 dk。除以 sqrt(dk) 让方差变回 1,稳定训练。
- Softmax:把分数归一化成概率,和为 1。
- 乘以 V:用概率加权求和,得到新的表示。
来看一段简化的 PyTorch 计算代码:
import torch
import torch.nn.functional as F
def scaled_dot_product_attention(Q, K, V, mask=None):
"""
Q, K, V: [batch_size, seq_len, d_k]
mask: [batch_size, seq_len, seq_len], 可选
"""
d_k = Q.size(-1)
# [batch, seq_len, seq_len]
scores = torch.matmul(Q, K.transpose(-2, -1)) / (d_k ** 0.5)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
attn_weights = F.softmax(scores, dim=-1)
output = torch.matmul(attn_weights, V)
return output, attn_weights
4.3 多头注意力:并行开会,多视角理解
单头注意力把整个序列的注意力分数压缩成一个加权和。但缺陷很明显——一个头只能关注一种类型的依赖。比如在分子中,C-C 单键和 C=C 双键同时存在,一个头很难同时捕捉键长和键角信息。
多头注意力就是解决方案。你把 Q、K、V 切成 h 份,每一份独立做注意力,最后拼起来再投影回去。
具体步骤是这样:
- 输入 X 分别过 WQ、WK、WV,得到 Q、K、V。
- 把 Q、K、V 按最后一维切分成 h 个头。比如 dmodel=512,h=8,每个头的维度 dk=64。
- 每个头独立做缩放点积注意力。
- 把所有头的结果拼接起来,再过一个线性投影 WO。
用代码实现更直观:
class MultiHeadAttention(torch.nn.Module):
def __init__(self, d_model, n_heads, dropout=0.1):
super().__init__()
assert d_model % n_heads == 0
self.d_model = d_model
self.n_heads = n_heads
self.d_k = d_model // n_heads
self.W_q = torch.nn.Linear(d_model, d_model)
self.W_k = torch.nn.Linear(d_model, d_model)
self.W_v = torch.nn.Linear(d_model, d_model)
self.W_o = torch.nn.Linear(d_model, d_model)
self.dropout = torch.nn.Dropout(dropout)
def forward(self, Q, K, V, mask=None):
batch_size = Q.size(0)
# 1. 线性投影 + 分头
Q = self.W_q(Q).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
K = self.W_k(K).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
V = self.W_v(V).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
# 2. 每个头独立做 attention
attn_output, attn_weights = scaled_dot_product_attention(Q, K, V, mask)
# 3. 拼接所有头
attn_output = attn_output.transpose(1, 2).contiguous()
attn_output = attn_output.view(batch_size, -1, self.d_model)
# 4. 线性投影输出
output = self.W_o(attn_output)
return output, attn_weights
4.4 为什么多头比单头强?
我们用一个表格来对比:
| 维度 | 单头注意力 | 多头注意力 |
|---|---|---|
| Q / K / V 数量 | 1 组 | h 组(并行) |
| 计算量 | 低 | 略高(但可并行) |
| 表达能力 | 只能捕捉一种模式 | 捕捉 h 种不同模式 |
| 对长距离依赖 | 弱,容易注意力涣散 | 鲁棒,多个头互补聚焦 |
| 实际应用 | 少量简单任务 | 几乎所有 SOTA Transformer |
你知道最有趣的是什么吗?我曾经把训练好的 8 头注意力可视化,发现有的头专门关注“当前的 token 和下一个 token”的关系(类似 N-gram),有的头则关注远程的官能团——这正好对应了分子结构中的局部键合和远程空间效应。
4.5 自注意力的计算复杂度
最后提一嘴复杂度。自注意力的计算量跟序列长度 L 的平方成正比,O(L² · dk)。为什么?因为每个 token 都要跟其他 L 个 token 算点积,所以是 L × L = L²。
这有什么实际影响?我做过一个分子生成实验,序列长度从 100 到 800,单头的显存占用从 2GB 暴涨到 32GB。如果序列太长,要考虑用 sparse attention 或者 local attention,但这些是后话了。
好在分子 SMILES 序列通常不超过 200 个 token,L² 完全扛得住。如果你做蛋白质多序列,那才真要头疼。