2、维度分配原理:总维度d_model与头数h的关系
好,咱们直接进入正题。
多头注意力机制里,维度分配是最基础、也最容易踩坑的地方。我刚开始接触Transformer时,就被这个d_model、h、d_k的关系绕晕过。今天咱们把它彻底讲明白。
2.1 核心公式:d_k = d_model / h
说白了,就是一句话:
每个头的维度 d_k = 总维度 d_model ÷ 头数 h
举个例子:
- 如果 d_model = 512,h = 8,那么 d_k = 512 / 8 = 64
- 如果 d_model = 768,h = 12,那么 d_k = 768 / 12 = 64
- 如果 d_model = 1024,h = 16,那么 d_k = 1024 / 16 = 64
你发现没?很多经典模型里d_k都等于64。这不是巧合,是设计上的经验值。
我个人习惯把d_k看作每个头的“视野宽度”。64维的向量,刚好能捕捉到一些细粒度特征,又不会太稀疏。
2.2 为什么需要整除?
这个问题,我曾经在项目里吃过亏。
有一次我图省事,设了d_model=512,h=7。你算算,512/7≈73.14,不是整数。结果模型跑起来,报错信息看得我头皮发麻。
为什么会这样?原因有三:
- 张量形状必须对齐:每个头的输出要拼接回d_model维度。如果d_k不是整数,拼接后的形状就乱套了。
- 计算效率问题:GPU对规整的张量形状有优化。非整数维度会触发大量padding操作,慢得你想哭。
- 语义完整性:每个头应该分配到等量的维度。不均匀分配,有的头“看得多”,有的头“看得少”,这就不公平了。
避坑指南:我曾经在调参时,随手写了h=5,d_model=256。256/5=51.2,不是整数。模型训练到一半直接崩了。从那以后,我每次写代码都会加一句断言:
assert d_model % h == 0, f"d_model({d_model}) 必须能被 h({h}) 整除"
2.3 维度分配的直观理解
你想想看,多头注意力就像是一个团队在开会。
- d_model:整个团队的知识总量
- h:团队里的人数
- d_k:每个人分到的知识量
如果总知识量是512,你非要分给7个人。那每个人分到73.14份知识?这怎么分?有人多拿一点,有人少拿一点,团队协作就出问题了。
所以,整除是硬性要求。不是“最好”,是“必须”。
2.4 常见配置速查表
我整理了一份常用配置,方便你参考:
| d_model | h | d_k | 常见模型 |
|---|---|---|---|
| 512 | 8 | 64 | Transformer Base |
| 768 | 12 | 64 | BERT Base |
| 1024 | 16 | 64 | BERT Large |
| 1024 | 8 | 128 | GPT-2 Medium |
| 1280 | 10 | 128 | 某些定制模型 |
嗯,这里要注意:d_k=64不是铁律。像GPT-2 Medium就用128。关键是d_model和h要匹配好。
2.5 代码实现中的维度分配
咱们看看实际代码怎么写。我习惯用PyTorch,直接上代码:
import torch
import torch.nn as nn
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, h):
super().__init__()
# 这里必须检查整除
assert d_model % h == 0, f"d_model({d_model}) % h({h}) != 0"
self.d_model = d_model
self.h = h
self.d_k = d_model // h # 每个头的维度
# 定义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, x):
batch_size, seq_len, _ = x.shape
# 线性变换后,拆分成多头
Q = self.W_q(x).view(batch_size, seq_len, self.h, self.d_k)
K = self.W_k(x).view(batch_size, seq_len, self.h, self.d_k)
V = self.W_v(x).view(batch_size, seq_len, self.h, self.d_k)
# 转置:把头的维度放到第1维
Q = Q.transpose(1, 2) # [batch, h, seq_len, d_k]
K = K.transpose(1, 2)
V = V.transpose(1, 2)
# 计算注意力(简化版)
scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5)
attn = torch.softmax(scores, dim=-1)
out = torch.matmul(attn, V)
# 拼接多头输出
out = out.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
# 最后的线性变换
return self.W_o(out)
小技巧:我在实际项目中,喜欢把d_k设成2的幂次方(64、128、256)。这样GPU的矩阵乘法效率最高。你想想看,2的幂次方在二进制层面就是对齐的,硬件算起来特别快。
2.6 维度分配的深层思考
到这里,你可能想问:为什么非得是d_k = d_model / h?能不能让每个头维度不一样?
嗯,理论上可以。但实践中没人这么干。
原因很简单:对称性。每个头在模型中的地位是平等的。你给头A分配128维,给头B分配64维,那模型就会偏向头A。这违背了多头注意力的设计初衷——让不同头关注不同模式,而不是让某些头“权力更大”。
我记得有一次,团队里有人想试试非均匀分配。结果调了一周,效果还不如均匀分配。从那以后,我们就老老实实用d_k = d_model / h了。
2.7 总结一下
- 核心公式:d_k = d_model / h,必须整除
- 常见配置:d_k=64或128,d_model和h按需搭配
- 代码检查:加断言,避免运行时崩溃
- 硬件优化:d_k尽量选2的幂次方
下一章,咱们聊聊每个头到底在学什么。你会发现,不同头关注的东西,差别还挺大的。