多头机制详解:多头分割、拼接、线性变换与特征增强
说到多头注意力,我理解它就像是一组专家同时审视同一个问题。每个专家关注的视角不同,最后把他们的发现汇总起来,就能得到更全面的认识。这个机制,说白了就是把单头注意力拆成多个子空间,让模型自己学会从不同维度提取特征。
1. 多头分割:拆解特征子空间
输入序列的每个token经过线性变换得到Q、K、V后,我们要做的第一件事就是“分割”。
假设我们的模型维度是d_model = 512,头数设为h = 8。那么每个头的维度就是d_k = d_model / h = 64。为什么要这样分割?说白了,就是让每个头只负责一小块特征空间,降低单头压力。
我在项目中曾经直接用.view()做reshape,结果在deploy时发现显存占得特别大。嗯,后来改成.reshape()并用.transpose()调整维度顺序,问题就解决了。
典型的分割实现如下:
def split_heads(x, h, d_k):
# x: [batch, seq_len, d_model]
batch, seq_len, _ = x.shape
x = x.reshape(batch, seq_len, h, d_k)
# 转置为 [batch, h, seq_len, d_k]
return x.transpose(1, 2)
我习惯把分割和后续计算放在一起,这样代码更干净。
2. 并行注意力计算
分割完每个head,接下来就是各算各的注意力。注意,这里是并行计算的——每个头独立计算softmax(Q*K^T / sqrt(d_k)) * V。因为矩阵运算可以批处理,所以实际计算时所有头同时进行。
计算完每个头的注意力输出,shape是 [batch, h, seq_len, d_k]。
3. 拼接与线性变换
所有头算完后,需要把他们的结果拼回一个完整的特征空间。拼接很简单:把 [batch, h, seq_len, d_k] 转置回 [batch, seq_len, h, d_k],然后reshape成 [batch, seq_len, d_model]。
def combine_heads(x, h, d_k):
# x: [batch, h, seq_len, d_k]
batch, _, seq_len, _ = x.shape
x = x.transpose(1, 2).reshape(batch, seq_len, -1)
return x
但拼接完还不算完——每个头的输出只是各自视角的“报告”,我们需要一个最终的线性变换层来融合这些信息。这个线性变换的权重矩阵 W_O 维度是 d_model × d_model。它的作用就是让模型学习如何把8份64维的局部信息,组合成一份512维的全局信息。
我曾经遇到过一个问题:拼接头之后直接使用全连接层,结果训练初期loss降得很慢。后来我发现,在线性变换前加一个LayerNorm,效果好了很多。你想想看,多头输出本身就是不同分布的,不加归一化,线性层学起来会很吃力。
4. 特征增强的奥秘
为什么多头机制能增强特征?我自己的理解是:每个头相当于一个独立的“特征过滤器”,它们可以关注序列中不同的模式。比如在文本中,某个头可能专门捕捉句法结构,另一个头关注语义相似性,还有一个头可能对位置信息敏感。这种分工让模型能同时表达多种关系,表达能力自然比单头强。
我参与过一个语音识别项目,使用8头注意力。实验发现,有2个头几乎完全关注了音素边界信息,另外3个头集中处理了音调特征。这就是多头的实际效果——你甚至可以不看模型细节,光看头部的注意力图就能猜出它们在做什么。
下面我画了一张图,帮大家快速理解整个流程:
为了更直观对比单头和多头的差异,我整理了一个表格:
| 对比项 | 单头注意力 | 多头注意力 |
|---|---|---|
| 特征空间 | 单个全局空间 | 多个子空间并行 |
| 表达能力 | 一次只能学习一种关系 | 可同时学习多种关系 |
| 参数量 | 较少,线性变换少 | 每个头独立W_Q/W_K/W_V,但总参数量与单头相当(因为d_model不变) |
| 训练稳定性 | 容易陷入局部模式 | 多个头互补,更稳定 |
| 典型应用场景 | 小模型、快速实验 | 中大型模型,如BERT、T5 |
我在调参时总结过一条经验:如果头数增加后效果没有明显提升,先检查一下每个头的注意力分布是不是太相似。如果所有头的关注区域都差不多,那说明多头没有学到差异化,这时候增加头数只会浪费计算资源。可以尝试加大dropout或者初始化时给不同头分配不同的W_Q初始值,来强制多样性。
最后说一句,多头机制的魅力在于,你不需要手动告诉模型该关注什么——它自己会通过训练找到最优的分工方式。你只需要给足够的头数和合适的维度,剩下的交给梯度下降就好。