第4章:Multi-Head Attention:多头并行的原理与实现,为什么有效?
好,咱们继续。上一章我们把 Scaled Dot-Product Attention 讲透了。但说实话,单靠一个注意力头,能力有限。就像你只用一只眼睛看世界,虽然也能看见,但深度和广度都不够。
这一章,我们来聊聊 Transformer 里真正厉害的设计——多头注意力(Multi-Head Attention)。我个人觉得,这是整个 Transformer 架构中最有灵性的部分之一。
4.1 为什么需要多个头?
先问一个问题:一个注意力头能学到什么?
嗯,它只能学到一种“注意力模式”。比如,有的头可能只关注相邻的词,有的头可能只关注句子的主语。但现实世界的文本关系是复杂的。一个词可能同时和多个词有不同维度的关联。
举个例子:
“他把球踢给了队友,队友又传给了他。”
这句话里,“他”和“队友”有指代关系,“球”和“踢”有动作关系,“队友”和“传”也有动作关系。一个注意力头,很难同时捕捉所有这些关系。
多头注意力就是来解决这个问题的。 它让模型同时用多个“头”去关注不同的信息子空间。每个头学到的模式不一样,最后把结果拼起来,模型就能看到更全面的信息。
核心思想: 与其用一个复杂的注意力函数,不如用多个简单的注意力头,每个头关注不同的方面,最后合并结果。这其实就是“分而治之”的思想。
4.2 多头注意力的数学原理
原理其实不复杂。说白了,就是把 Q、K、V 分别做 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 或 16。
- W_Q_i, W_K_i, W_V_i 是第 i 个头的线性变换矩阵,维度是 d_model × d_k。
- W_O 是输出线性变换矩阵,维度是 h*d_v × d_model。
你看,每个头都有自己的 Q、K、V 变换矩阵。它们把原始的 d_model 维向量映射到不同的子空间(d_k 维)。然后各自做注意力,最后拼起来再映射回 d_model 维。
这里有个关键点: 为了保持计算量不变,通常会让 d_k = d_model / h。这样,所有头的计算量加起来,和单头注意力的计算量差不多。
我的经验: 我在项目中遇到过一个问题——头数设得太多,每个头的维度太小,反而学不到有效信息。一般来说,d_k 不要小于 32。如果 d_model=512,头数 h=8,d_k=64,效果通常不错。
4.3 代码实现:从零开始写多头注意力
光说不练假把式。我们直接上代码。我会用 PyTorch 风格实现,但核心逻辑是框架无关的。
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__()
assert d_model % num_heads == 0, "d_model must be divisible by num_heads"
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads # 每个头的维度
# 定义 Q、K、V 的线性变换
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. 线性变换并拆分成多头
# 形状: (batch, seq_len, d_model) -> (batch, seq_len, num_heads, d_k)
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. 计算注意力分数
# 形状: (batch, num_heads, seq_len_q, seq_len_k)
scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5)
# 3. 应用 mask(如果有)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
# 4. Softmax 得到注意力权重
attn_weights = F.softmax(scores, dim=-1)
# 5. 加权求和得到输出
# 形状: (batch, num_heads, seq_len_q, d_k)
context = torch.matmul(attn_weights, V)
# 6. 合并多头
# 形状: (batch, seq_len_q, d_model)
context = context.transpose(1, 2).contiguous().view(
batch_size, -1, self.d_model
)
# 7. 输出线性变换
output = self.W_O(context)
return output
代码看着长,其实逻辑很清晰:
- 线性变换并拆头: 把 Q、K、V 分别映射到 d_model 维,然后拆成 num_heads 个 d_k 维的子向量。
- 计算注意力: 每个头独立计算 Scaled Dot-Product Attention。
- 合并多头: 把所有头的输出拼起来,再经过一次线性变换。
注意: 代码里有个小细节——transpose(1, 2) 和 contiguous()。这是因为 PyTorch 的 view 操作要求内存连续。我曾经在这里踩过坑,不 contiguous 的话会报错。嗯,写代码时一定要小心。
4.4 为什么多头注意力有效?
这个问题我问过很多学生。有人说是“集成学习”,有人说是“多视角”。我觉得都有道理,但不够本质。
我个人理解,多头注意力有效的核心原因有三个:
- 子空间表示: 每个头在不同的子空间里学习表示。这就像让多个专家从不同角度分析同一个问题。有的专家关注语法,有的关注语义,有的关注指代。最后综合起来,决策更全面。
- 降低过拟合风险: 你想想看,如果只有一个头,它必须学会所有模式。这很容易过拟合。但多个头分担了学习任务,每个头只学一部分。这其实是一种隐式的正则化。
- 并行计算效率高: 虽然头数多了,但每个头的维度小了。而且所有头的计算可以并行进行。在 GPU 上,这几乎不增加额外的时间开销。
我记得有一次做机器翻译实验,单头注意力的 BLEU 值是 27.3,换成 8 头后直接涨到了 28.9。效果提升很明显。
一句话总结: 多头注意力让模型“同时看到多个角度的世界”,而不是“只从一个角度看”。这就是它有效的根本原因。
4.5 实践中的注意事项
最后,分享几个我在项目中积累的经验:
- 头数怎么选? 一般 8 头是默认配置。如果模型很小(d_model=256),4 头就够了。头数太多反而会稀释每个头的表达能力。
- 不同头的注意力模式: 我建议你训练完后可视化一下每个头的注意力权重。你会发现,有的头关注相邻词,有的头关注句首句尾,有的头关注特定词性。很有意思。
- 内存占用: 多头注意力的内存占用是 O(h * n^2),其中 n 是序列长度。如果序列很长(比如 4096),头数太多会导致 OOM。这时候可以考虑减少头数或使用稀疏注意力。
避坑指南: 我曾经在训练一个长文本模型时,把头数设成了 16,结果显存直接爆了。后来改成 8 头,效果没差多少,内存却省了一半。所以,头数不是越多越好,够用就行。
好了,这一章就到这里。多头注意力是 Transformer 的基石之一,理解它对你后续学习编码器-解码器结构、BERT、GPT 等模型都很有帮助。下一章,我们会讲位置编码——为什么 Transformer 需要它,以及如何实现。