3、线性变换与投影:W_Q、W_K、W_V权重矩阵的维度设计
好,咱们接着聊。上一节我们把输入向量从 d_model 映射到了 h * d_k 这个中间态。那这一步具体是怎么做的?说白了,就是靠三个权重矩阵:W_Q、W_K、W_V。
这三个矩阵,是注意力机制的核心参数。我刚开始学的时候,总觉得它们很神秘。后来自己动手写代码,才发现——嗯,其实就是三个全连接层,没有偏置的那种。
3.1 权重矩阵的维度:为什么是 d_model × d_k?
先看形状。每个头的 W_Q、W_K、W_V,维度都是 d_model × d_k。
为什么是 d_model 乘 d_k?
- 输入维度:每个 token 的向量长度是
d_model。比如 BERT-base 是 768。 - 输出维度:每个头要得到一个
d_k维的向量。比如 8 个头,每个头分到 64 维。
所以矩阵乘法就是:
[batch, seq_len, d_model] × [d_model, d_k] → [batch, seq_len, d_k]
我习惯把这个过程叫做「投影」。就是把一个高维空间里的点,投影到多个低维子空间里。每个头看到的,都是原始信息的一个侧面。
关键点:所有头共享同一个 d_model 输入,但各自有独立的 W_Q、W_K、W_V。这意味着每个头可以学到不同的投影方向。
3.2 从 d_model 到 h * d_k:整体视角
实际实现时,我们不会真的为每个头单独做矩阵乘法。那样太慢了。更常见的做法是——把所有头的权重拼在一起。
比如 8 个头,每个头 d_k = 64。那总的 W_Q 维度就是 d_model × (8 * 64) = d_model × d_model。
一次矩阵乘法,得到 [batch, seq_len, d_model] 的输出。然后通过 reshape 操作,拆成 8 个头:
# 伪代码示意
Q = x @ W_Q # [batch, seq_len, d_model]
Q = Q.view(batch, seq_len, h, d_k) # 拆成多头
Q = Q.transpose(1, 2) # [batch, h, seq_len, d_k]
这一步我踩过坑。有一次我忘了做 transpose,结果注意力分数算出来全是乱的。排查了半天才发现——嗯,维度顺序搞反了。
我的习惯:写代码时,我会在 reshape 之后立刻打印一下 shape。确认是 [batch, h, seq_len, d_k] 而不是 [batch, seq_len, h, d_k]。这个小动作帮我省了不少 debug 时间。
3.3 为什么需要线性变换?直接取子向量不行吗?
你可能会问:为什么不直接从 d_model 里切一段出来,比如前 64 维给头 1,后 64 维给头 2?
答案是:可以,但效果不好。
直接切分,相当于每个头只能看到输入向量的固定区域。而线性变换允许每个头学习到不同的组合方式。比如头 1 可能关注语义信息,头 2 关注位置信息——这些信息在原始向量里是混合在一起的,只有通过可学习的投影才能分离出来。
我记得有一篇论文专门分析过这个。他们发现,即使初始化时是随机投影,训练后每个头的投影方向也会自动分化,覆盖不同的语义空间。
3.4 权重矩阵的初始化
说到权重,就不得不提初始化。我见过不少新手直接全零初始化,结果训练根本跑不动。
对于 W_Q、W_K、W_V,常用的初始化方法是 Xavier 初始化 或 Kaiming 初始化。具体用哪个,取决于后面的激活函数。
| 初始化方法 | 适用场景 | 方差公式 |
|---|---|---|
| Xavier (Glorot) | tanh / sigmoid / 无激活 | Var = 2 / (fan_in + fan_out) |
| Kaiming (He) | ReLU / GELU | Var = 2 / fan_in |
我个人习惯用 Kaiming 初始化,因为 Transformer 里大量使用 GELU 或 ReLU。不过要注意,W_Q 和 W_K 的初始化方差如果太大,会导致注意力分数过大,softmax 后梯度消失。
我曾经踩过的坑:有一次我把 W_Q 的初始化方差设成了 1.0,结果训练初期所有注意力分数都集中在 0 和 1 附近,梯度几乎为零。后来加了个 1 / sqrt(d_k) 的缩放因子才解决。嗯,这个缩放就是 Transformer 论文里提到的 scaled dot-product attention 中的那个 scale。
3.5 代码示例:完整的线性变换与多头拆分
下面是一个 PyTorch 风格的实现片段。我尽量写得简洁,方便你理解核心逻辑。
import torch
import torch.nn as nn
class MultiHeadProjection(nn.Module):
def __init__(self, d_model, h, d_k):
super().__init__()
self.h = h
self.d_k = d_k
# 一次性定义所有头的 W_Q, W_K, W_V
self.W_Q = nn.Linear(d_model, h * d_k, bias=False)
self.W_K = nn.Linear(d_model, h * d_k, bias=False)
self.W_V = nn.Linear(d_model, h * d_k, bias=False)
# 初始化
nn.init.kaiming_uniform_(self.W_Q.weight, a=0.1)
nn.init.kaiming_uniform_(self.W_K.weight, a=0.1)
nn.init.kaiming_uniform_(self.W_V.weight, a=0.1)
def forward(self, x):
# x: [batch, seq_len, d_model]
batch, seq_len, _ = x.shape
# 线性变换
Q = self.W_Q(x) # [batch, seq_len, h * d_k]
K = self.W_K(x)
V = self.W_V(x)
# 拆分为多头
Q = Q.view(batch, seq_len, self.h, self.d_k).transpose(1, 2)
K = K.view(batch, seq_len, self.h, self.d_k).transpose(1, 2)
V = V.view(batch, seq_len, self.h, self.d_k).transpose(1, 2)
# 返回: [batch, h, seq_len, d_k]
return Q, K, V
你看,核心代码就这么几行。但每一行都有它的道理。比如 transpose(1, 2) 这一步,就是把头和序列长度交换位置,方便后续计算注意力分数。
3.6 小结一下
线性变换这一步,说白了就是:用可学习的矩阵,把输入投影到多个低维子空间。每个子空间对应一个注意力头,它们并行计算,互不干扰。
维度设计上,记住一个公式就够了:
d_model = h * d_k
这是 Transformer 的默认设置。当然,你也可以打破这个约束,比如让 d_model > h * d_k,再加一个压缩投影。不过那是后话了,咱们后面章节再聊。
下一节,我们会进入注意力分数的计算——也就是 Q 和 K 的点积。那一步才是注意力机制的「灵魂」所在。