3. Transformer基础:自注意力机制、多头注意力
聊到Transformer,最核心的东西就两个:自注意力和多头注意力。很多人一上来就被各种公式吓住了,其实说白了,它要解决的就是一句话:让模型在生成分子序列时,知道该「看」输入的哪些部分。
我做AI制药这些年,最深的体会是:小分子和自然语言本质上是同构的——一个是一串原子,一个是一串token。所以理解自注意力,就是理解Transformer为什么能折腾出那么多新分子。
3.1 自注意力是什么?
自注意力(Self-Attention)的核心操作,就是对输入序列中每个位置,根据其他所有位置的信息,重新计算这个位置的表示。
举个例子,假设我们有一个分子序列:C C = O (丙酮的简化表示)。
- 看到第一个C,需要知道它和第二个C是单键还是双键。
- 看到第二个C,需要留意后面有个O双键。
- 看到O,要回头确认它前面到底连了几个C。
自注意力就是干这个的——每个位置去「查询」所有位置,然后加权融合信息。
3.1.1 核心计算:Q、K、V
每个输入token会生成三个向量:Query(查询)、Key(键)、Value(值)。
- Query:问我当前这个位置「想找谁」。
- Key:每个位置回答「我有什么信息」。
- Value:每个位置真正要贡献的「内容」。
注意力分数 = Query 与所有 Key 的点积,然后 softmax 得到权重,最后用权重加权求和所有 Value。
公式(简化记忆级)
Attention(Q,K,V) = softmax( Q × K^T / √d_k ) × V
其中 √d_k 是为了防止点积过大导致softmax梯度消失。d_k 是Key的维度。
我的一点经验:刚开始调参时,我总喜欢把d_k设得很大,以为信息多。结果发现注意力分数直接炸了,softmax输出几乎变成one-hot。后来才明白,scale是必须的。如果你像我一样踩过这个坑,记得检查一下这里的缩放系数。
3.2 多头注意力(Multi-Head Attention)
一个注意力头可能只学到了「这个C和那个O关系很强」,但分子里还有键角、环结构、手性等等信息。怎么办?多头注意力就是并行跑多个自注意力头,每个头关注不同的子空间。
3.2.1 多头怎么组合?
流程很简单:
- 将输入的Q、K、V分别线性投影到h个不同的低维空间(每个头维度 = d_model / h)
- 每个头独立计算自注意力
- 将所有头的输出拼接起来,再做一个线性变换
MultiHead(Q,K,V) = Concat(head_1, head_2, ..., head_h) × W_O
其中 head_i = Attention(Q × W_i^Q, K × W_i^K, V × W_i^V)
这里的 W_i^Q、W_i^K、W_i^V 是每个头的投影矩阵,W_O 是输出投影矩阵。
我曾经踩过的坑:头数太多(比如64个头),每个头的维度只剩个位数,注意力几乎退化成了随机权重。我个人习惯是:d_model=512时用8个头,每个头64维;如果序列很长(比如蛋白质),12~16个头也够用,再大就不太行了。
3.3 自注意力在分子生成中的关键作用
在分子序列生成中,自注意力帮模型做三件事:
- 长程依赖捕捉:例如预测一个遥远侧链对活性位点的影响。
- 局部结构构建:例如一个六元环内的C-C键顺序。
- 消歧义:比如同一个原子符号在不同环境下(芳香环 vs 脂肪链)意义不同。
你看,这跟自然语言里的“词义消歧”是不是很像?
3.3.1 自注意力的矩阵维度
假设输入序列长度 = n,每个token的表示维度 = d_model。
| 组件 | 维度变化 |
|---|---|
| 输入 X | (n, d_model) |
| Q、K、V(每个头) | (n, d_k) 或 (n, d_v) |
| 注意力矩阵 (Q×K^T) | (n, n) — 空间复杂度O(n²) |
| 单个头输出 | (n, d_v) |
| 多头拼接后 | (n, h×d_v) = (n, d_model) |
| 最终输出 | (n, d_model) |
这里有个明显的瓶颈:注意力矩阵是n×n。分子序列如果很长(比如几千个原子),显存直接炸了。我遇到过几次OOM,后来要么截断序列,要么换用稀疏注意力。
3.4 自注意力的变体:我们为什么不用原始版本?
原始Transformer里的自注意力是「全局全连接」的——每个位置看所有位置。但在分子生成里,很多长程信息其实没必要(比如两个相距50个原子的碳原子,一般不会直接成键)。
常见的改进:
- 局部注意力:每个位置只看前后窗口大小内的token。
- 稀疏注意力:预先定义一些连接模式(比如对角线、膨胀模式)。
- 线性注意力:用核技巧把O(n²)降到O(n)。
实际建议:如果你刚开始做分子生成,先用全局注意力把基础跑通。后面再根据你的序列长度和GPU显存去换稀疏版本。我在项目里常用的是「局部+全局稀疏」的混合模式,效果和速度都不错。
3.5 动手实现一个小例子
用PyTorch写一个自注意力模块,也就几十行。我建议你自己敲一遍,比看十遍文章都管用。
import torch
import torch.nn as nn
import torch.nn.functional as F
class SelfAttention(nn.Module):
def __init__(self, d_model, d_k=None, d_v=None):
super().__init__()
self.d_model = d_model
self.d_k = d_k if d_k is not None else d_model
self.d_v = d_v if d_v is not None else d_model
self.W_q = nn.Linear(d_model, self.d_k, bias=False)
self.W_k = nn.Linear(d_model, self.d_k, bias=False)
self.W_v = nn.Linear(d_model, self.d_v, bias=False)
def forward(self, x, mask=None):
# x: (batch, seq_len, d_model)
Q = self.W_q(x)
K = self.W_k(x)
V = self.W_v(x)
scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5)
if mask is not None:
scores = scores.masked_fill(mask == 0, float('-inf'))
attn_weights = F.softmax(scores, dim=-1)
output = torch.matmul(attn_weights, V)
return output, attn_weights
# 用一下
seq = torch.randn(2, 10, 512) # batch=2, seq_len=10, d_model=512
attn = SelfAttention(d_model=512)
out, weights = attn(seq)
print(out.shape) # (2, 10, 512)
关键点:
- 线性层不加bias,因为后续有LayerNorm。
- mask用来屏蔽padding位置(在分子生成中,不同长度的序列需要padding)。
- 输出形状和输入一致,这是Transformer残差连接的前提。
然后多头注意力就是把这个SelfAttention跑h次,再把结果拼起来。我习惯用一个包装类:
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, h=8):
super().__init__()
assert d_model % h == 0, "d_model must be divisible by h"
self.h = h
self.d_k = d_model // h
self.heads = nn.ModuleList([
SelfAttention(d_model, d_k=self.d_k, d_v=self.d_k)
for _ in range(h)
])
self.W_o = nn.Linear(d_model, d_model, bias=False)
def forward(self, x, mask=None):
outputs = []
for head in self.heads:
out, _ = head(x, mask)
outputs.append(out) # 每个 out: (batch, seq, d_k)
concat = torch.cat(outputs, dim=-1) # (batch, seq, d_model)
return self.W_o(concat)
mha = MultiHeadAttention(d_model=512, h=8)
out_mha = mha(seq)
print(out_mha.shape) # (2, 10, 512)
小技巧:实际项目中不要像我这样一个一个for循环头,效率太低。应该用合并的线性投影 + reshape + transpose并行算。但为了理解原理,上面的写法更清晰。
3.6 一些避坑和个人习惯
- 关于scale:√d_k这个分母千万别忘了。我见过有人实现时写成了√d_model,结果注意力全糊了。
- 关于mask:分子生成通常是自回归的,需要因果mask(每个位置只看之前的位置)。实现时注意一下上三角矩阵的掩码。
- 关于头数选择:如果d_model=256,头数选4或8;如果d_model=1024,选16。不是越多越好。
- 关于初始化:建议用xavier均匀初始化或kaiming正态初始化。我用默认的线性层初始化有时会导致训练初期注意力全均分。
嗯,到这里自注意力和多头注意力的核心你已经掌握了。后面我们会用它搭出完整的Transformer编码器和解码器,然后就能生成分子了。