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_QW_KW_V,维度都是 d_model × d_k

为什么是 d_modeld_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_QW_KW_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_QW_KW_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_QW_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 的点积。那一步才是注意力机制的「灵魂」所在。