完整题目
请手动实现一个多头注意力(Multi-Head Attention)模块,要求包括QKV线性变换、缩放点积注意力、多头拼接及输出投影,并说明各部分的作用和维度变化过程。
import math
import torch
from torch import nn
class MultiHeadAttention(nn.Module):
def __init__(self, d_model: int, num_heads: int, dropout: float = 0.0):
super().__init__()
if d_model % num_heads != 0:
raise ValueError("d_model must be divisible by num_heads")
self.num_heads = num_heads
self.head_dim = d_model // num_heads
self.qkv = nn.Linear(d_model, 3 * d_model)
self.out_proj = nn.Linear(d_model, d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x: torch.Tensor, mask: torch.Tensor | None = None):
# x: [B, L, D];mask为bool,可广播到[B, H, L, L],True表示可见
b, l, d = x.shape
qkv = self.qkv(x).view(b, l, 3, self.num_heads, self.head_dim)
q, k, v = qkv.permute(2, 0, 3, 1, 4).unbind(0) # [B,H,L,Dh]
scores = q @ k.transpose(-2, -1) / math.sqrt(self.head_dim)
if mask is not None:
mask = mask.to(torch.bool)
scores = scores.masked_fill(~mask, torch.finfo(scores.dtype).min)
weights = torch.softmax(scores, dim=-1)
if mask is not None:
# 避免整行均被mask时产生无意义的均匀权重
weights = weights * mask.to(weights.dtype)
weights = weights / weights.sum(-1, keepdim=True).clamp_min(torch.finfo(weights.dtype).tiny)
weights = self.dropout(weights)
context = weights @ v # [B,H,L,Dh]
context = context.transpose(1, 2).contiguous().view(b, l, d)
return self.out_proj(context), weights # [B,L,D]维度与作用
[B,L,D] -> Q/K/V [B,H,L,Dh] -> scores [B,H,L,L] -> context [B,H,L,Dh] -> [B,L,D],其中 Dh=D/H。除以 sqrt(Dh) 是为了控制点积方差,避免softmax过早饱和。多头让不同投影子空间并行建模关系;out_proj负责跨头线性混合和恢复模型维度,它本身不增加非线性。
生产实现还要把因果mask与padding mask组合,并确认广播方向;长序列的主要瓶颈是 [L,L] 分数矩阵的计算和存储,可换用框架提供的scaled-dot-product attention或FlashAttention kernel。
全遮罩行先把权重清零,再用当前dtype可表示的正数保护分母;固定1e-9在FP16中会下溢为0并产生0/0。这里该行context为0,经过带bias的out_proj后输出可为bias,并非保证最终输出全零。以上是教学forward实现,不替代生产kernel与反向/性能测试。
参考资料
继续追问 · 3 个问题
- 为什么需要除以sqrt(d_k)?如果不除会怎样?
- 如果序列很长,这个实现的计算瓶颈在哪里?如何优化?
- 多头注意力的"多头"设计有什么好处?单头行不行?
换一种问法,检查是否真正理解
场景切入
假设输入 x 的形状为 [B,L,D],D 能被头数 H 整除。请实现 QKV 投影、拆头、缩放点积、mask、拼头和输出投影,并逐步标出形状。
层层追问
Q、K、V 怎样从 [B,L,D] 变成 [B,H,L,D/H]?……QK 转置后分数矩阵是什么形状?……为什么除以 sqrt(d_k)?……mask 与整行遮蔽怎样处理?……最后为什么需要 out projection?
直球技术
手写多头注意力模块并解释所有维度变化,重点说明缩放控制点积方差、避免 softmax 饱和,而不是笼统归因于梯度消失。
练习口述回答 · 约4分钟
- 先约定输入和头维
- 依次讲QKV、分头、注意力、拼接和输出投影
- 解释缩放因子
- 说明mask与全mask行
- 指出长序列优化边界
我会先约定输入x的形状是B乘L乘D,分别表示batch、序列长度和模型维度。头数是H,每个头的维度Dh等于D除以H,所以初始化时必须检查D能被H整除。Q、K、V可以用三个线性层,也可以像代码里一样用一个输出三倍维度的融合线性层;两者在数学上等价,融合只是常见工程实现。
做完投影后,我把结果从B、L、三倍D reshape成B、L、3、H、Dh,再调整轴得到Q、K、V各自的B、H、L、Dh。接着计算Q乘K转置,分数形状是B、H、L、L,再除以根号Dh。这个缩放不是为了改变表达能力,而是控制点积的方差,避免维度较大时softmax输入幅值过大、梯度过早饱和。
如果有mask,我会先明确语义:布尔值True表示这个位置可见,并要求它能广播到B、H、L、L。因果mask负责挡住未来token,padding mask负责挡住补齐位置,实际使用时要组合。直接用负无穷mask时,如果某一整行全部不可见,softmax会产生NaN;示例代码用有限最小值,再把mask位置乘零并重新归一化;分母保护要用当前dtype可表示的正数,固定1e-9在FP16会变成0。这样全mask行的权重和context为零,但经过带bias的输出投影后可等于bias,并非最终输出必然全零。生产代码也可以使用框架已经处理好这些边界的算子。
softmax后的权重乘V,得到B、H、L、Dh。然后把头维转回来,先变成B、L、H、Dh,再连续化并reshape成B、L、D。最后经过输出投影W_O。这里要特别纠正一个常见说法:W_O是线性层,它做的是跨头线性混合并映射回模型维度,本身不会增加非线性。模型的非线性来自MLP激活等其他组件。
多头的价值也不应该说成单头忙不过来。更准确地说,不同头拥有不同的QKV投影,可以在不同表示子空间里并行形成不同关系;但是否形成语法、位置或实体等可解释模式,需要分析而不能预设。attention权重上可以加dropout,模块外通常还有残差和归一化。
最后说复杂度。这个朴素实现会显式构造L乘L分数矩阵,计算和辅助存储在长序列下是主要瓶颈。工程上我会优先调用PyTorch的scaled dot product attention,让后端选择合适kernel,或者使用FlashAttention类实现。优化不会改变多头注意力的数学定义,重点是减少中间矩阵的HBM读写,并仍然正确处理mask和精度。