2. 自注意力与Transformer:Scaled Dot-Product Attention,Multi-Head Attention机制详解
好,咱们进入正题。自注意力机制,说白了就是让模型学会「自己看自己」。你想想看,一段文字里,每个词都不是孤立的。比如「他打碎了那个杯子,然后把它捡起来」——这里的「它」指的是谁?人一眼就看出来了,但机器不行。
自注意力要解决的,就是这个问题。让每个词都能「看到」句子里的其他词,然后决定谁更重要。
2.1 Scaled Dot-Product Attention:最基础的自注意力
先看最核心的公式:
Attention(Q, K, V) = softmax(Q × K^T / √d_k) × V
嗯,别被公式吓到。我拆开给你讲。
Q、K、V 分别代表 Query、Key、Value。你可以这么理解:
- Query:当前词在问「谁跟我有关系?」
- Key:其他词在回答「我跟你有没有关系?」
- Value:真正要提取的信息
计算过程分三步:
- Q × K^T:算相似度。每个词跟所有词算一遍,得到一个注意力分数矩阵
- 除以 √d_k:这就是 Scaled 的含义。为什么要除?我踩过坑——如果不除,当 d_k 很大时,点积结果会非常大,softmax 会进入梯度饱和区,训练基本不动
- softmax + × V:归一化成概率,然后加权求和
核心要点:除以 √d_k 这个操作,我建议你永远不要省略。我在一个 NLP 项目里试过不除,结果 loss 死活降不下去,折腾了两天才发现是这个问题。
2.2 为什么叫「自」注意力?
其实很简单。因为 Q、K、V 都来自同一个输入序列。如果是机器翻译里的 Encoder-Decoder Attention,Q 来自解码器,K、V 来自编码器,那就不叫「自」了。
自注意力的好处很明显:
- 能捕捉长距离依赖——不像 RNN 那样有遗忘问题
- 计算可以并行——不像 RNN 必须一步步走
- 每个位置都能直接看到全局
我的经验:自注意力虽然好,但计算复杂度是 O(n²)。n 是序列长度。如果你处理的是 1000 个 token 的文本,还好。但如果是 10000 个 token 的长文档,显存会爆炸。我建议你考虑 Longformer 或者稀疏注意力方案。
2.3 Multi-Head Attention:让模型从多个角度「看」
单头注意力有个问题——它只能学习一种注意力模式。但现实中,词与词的关系是复杂的。比如「苹果」这个词,可能既跟「吃」有关(语义),又跟「红色」有关(颜色),还跟「水果」有关(类别)。
多头注意力就是让模型同时学习多种关系。公式如下:
MultiHead(Q, K, V) = Concat(head_1, ..., head_h) × W^O
其中 head_i = Attention(Q × W_i^Q, K × W_i^K, V × W_i^V)
说白了,就是把 Q、K、V 分别投影到 h 个不同的子空间,每个子空间独立做自注意力,最后拼起来再投影回去。
我习惯用 h=8,也就是 8 个头。每个头的维度 d_k = d_model / h。比如 d_model=512,那每个头就是 64 维。
| 参数 | 常见取值 | 说明 |
|---|---|---|
| h(头数) | 8 或 16 | 头数越多,模型越能捕捉细粒度关系 |
| d_model | 512 或 768 | 模型总维度,必须能被 h 整除 |
| d_k | d_model / h | 每个头的维度,通常 64 或 96 |
注意:头数不是越多越好。我曾经试过 h=32,结果每个头学到的模式高度重复,反而浪费计算资源。一般来说 8-16 个头就够用了。
2.4 代码实现:从零写一个 Multi-Head Attention
光说不练假把式。我给你看一个我常用的实现片段:
import torch
import torch.nn as nn
import torch.nn.functional as F
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, num_heads):
super().__init__()
self.num_heads = num_heads
self.d_k = d_model // num_heads
# 线性投影层
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.W_o = nn.Linear(d_model, d_model)
def forward(self, Q, K, V, mask=None):
batch_size = Q.size(0)
# 1. 线性投影 + 分头
Q = self.W_q(Q).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
K = self.W_k(K).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
V = self.W_v(V).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
# 2. Scaled Dot-Product Attention
scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
attn = F.softmax(scores, dim=-1)
output = torch.matmul(attn, V)
# 3. 合并头 + 输出投影
output = output.transpose(1, 2).contiguous().view(batch_size, -1, self.num_heads * self.d_k)
return self.W_o(output)
这段代码我用了很多次。有几个细节你注意一下:
- view + transpose:这是分头的标准操作,把 (batch, seq_len, d_model) 变成 (batch, num_heads, seq_len, d_k)
- masked_fill:处理 padding 位置,让它们不参与注意力计算
- contiguous:transpose 后内存不连续,必须调用一下
避坑指南:我曾经在合并头的时候忘了调用 contiguous(),结果模型训练时 loss 出现 NaN。排查了半天才发现是内存布局的问题。嗯,这个坑我替你踩过了。
2.5 自注意力在 Transformer 中的位置
Transformer 的编码器里,每个子层包含两个部分:
- 多头自注意力:让每个位置看到所有位置
- 前馈神经网络:对每个位置独立做非线性变换
解码器里多了一个 Masked Multi-Head Attention,用来防止看到未来的词。这个我后面会细讲。
我个人觉得,自注意力是 Transformer 最精妙的设计。它让模型不再受限于序列顺序,而是真正理解了「关系」本身。你想想看,一个词跟另一个词的关系,不就是靠注意力权重来体现的吗?
2.6 总结与思考
好,咱们回顾一下今天的内容:
- Scaled Dot-Product Attention 是基础,除以 √d_k 这个缩放操作很关键
- Multi-Head Attention 让模型从多个子空间学习不同的关系模式
- 代码实现时要注意分头、mask、内存布局等细节
最后留个思考题给你:为什么 Transformer 不用 RNN 那种循环结构,而是全靠注意力?你能想到几个原因?
下一章我会讲位置编码——因为自注意力本身没有顺序感,我们需要给模型注入位置信息。到时候见。