4、自注意力机制:QKV向量、注意力分数计算与加权求和

前面我们聊了 Transformer 的整体架构,今天终于要啃最核心的硬骨头了 —— 自注意力机制。

说白了,自注意力就是让每个词看看句子里的其他词,然后决定自己该更关注谁。我刚开始接触这个想法时觉得挺反直觉的:一个词还能“看见”自己?后来做机器翻译项目才发现,正因为有了这种全局视野,模型才能处理长距离依赖。

4.1 为什么需要 Q、K、V 三套向量?

你想想看,如果只用一套向量自己跟自己算相似度,信息太单一。Q(Query)、K(Key)、V(Value)这三套东西,其实是从三个不同角度看同一个词:

  • Query:相当于在问“我该关注谁?”
  • Key:相当于回答“我这里有啥信息,值得被关注?”
  • Value:就是真实要提取的信息内容

嗯,这很像检索系统。你去图书馆借书:Query 是你的借书单,Key 是图书分类标签,Value 是书本身。我当年把这个类比讲给学生听,他们一下就懂了。

4.2 QKV 是怎么来的?

每个输入词向量 x 会通过三个不同的线性变换得到 Q、K、V:

Q = x * W_Q
K = x * W_K
V = x * W_V

这三个权重矩阵 W_Q, W_K, W_V 都是可学习的参数。我在项目里经常遇到新人问:能不能只用两个矩阵?答案是理论上可以,但效果会打折扣。原因很简单:如果 Q 和 K 共享参数,模型很难区分“谁在问”和“谁在被问”,表达能力就弱了。

我的习惯:在初始化 W_Q、W_K 时,我会用 Xavier 均匀分布,别用全零初始化 —— 否则梯度消失会让你怀疑人生。

4.3 注意力分数计算:点积 + 缩放

有了 Q 和 K,就要算它们之间的匹配程度。最常用的是缩放点积注意力:

score = (Q · K^T) / sqrt(d_k)

为什么除以 sqrt(d_k)?因为当向量维度 d_k 很大时,点积结果会非常大,导致 Softmax 后的梯度极其微小(想想那些接近 0 或 1 的概率)。缩放一下,让梯度更稳定。我曾经在训练 512 维的模型时忘了做这一步,结果训练半天 loss 纹丝不动……后来检查了一整天才发现是这个坑。

步骤 公式 作用
点积 Q · K^T 计算每对词之间的相似度
缩放 / sqrt(d_k) 防止 Softmax 进入饱和区
掩码(可选) mask = -inf 在解码器里屏蔽未来信息

4.4 Softmax 归一化与加权求和

分数算完后,用 Softmax 转成概率分布:

attention_weights = softmax(score)
output = attention_weights · V

这里的 attention_weights 就是每个词对当前词的重要性权重,然后拿这些权重去加权融合所有词的 Value。最终输出就是考虑了全局语境的表示。

我强调一个容易被忽视的点:Softmax 的温度。标准实现里没有显式温度参数,但 sqrt(d_k) 其实就是一种温度控制。如果你自己调试时发现注意力权重太平滑(所有词几乎均匀),试着调大分母;太尖锐(只盯着一个词),就调小分母。这招我在处理长序列问题时用过多次。

完整自注意力公式(矩阵形式):

Attention(Q, K, V) = softmax( (Q * K^T) / sqrt(d_k) ) * V

其中 Q, K, V 形状都是 (batch_size, seq_len, d_k)。

我曾经踩过的坑:在实现时忘记对 score 做 mask 。比如在解码器里,如果不把未来位置的分数设为负无穷,模型就能“偷看”后面的词,导致训练 loss 正常但推理时完全崩溃。现在我已经形成了条件反射——只要写自注意力,第一件事就是检查掩码逻辑。

4.5 一张图看懂自注意力流程

下面这张 SVG 图总结了整个计算过程,从输入到输出。建议你边看边在脑子里过一遍矩阵运算。

自注意力机制计算流程 输入序列 X Q = X·W_Q K = X·W_K V = X·W_V S = Q·K^T (点积) 缩放 ÷ √dₖ Softmax 归一化 加权求和 (权重 × V) → 输出向量 输出 Z

4.6 代码实现:从零开始写自注意力

理论懂了,但不动手验证一下心里不踏实。下面是个极简实现,去掉 batch 和 mask,聚焦核心逻辑。

import numpy as np

def self_attention(x, d_k):
    """
    x: (seq_len, d_model)  假设已经嵌入好了
    d_k: 每个头的维度
    """
    d_model = x.shape[-1]
    # 随机初始化权重(实际训练时这些是参数)
    np.random.seed(42)
    W_Q = np.random.randn(d_model, d_k) * 0.1
    W_K = np.random.randn(d_model, d_k) * 0.1
    W_V = np.random.randn(d_model, d_k) * 0.1

    Q = x @ W_Q   # (seq_len, d_k)
    K = x @ W_K
    V = x @ W_V

    # 注意力分数
    score = Q @ K.T  # (seq_len, seq_len)
    score /= np.sqrt(d_k)

    # Softmax
    exp_s = np.exp(score - np.max(score, axis=-1, keepdims=True))
    attn_weights = exp_s / np.sum(exp_s, axis=-1, keepdims=True)

    # 加权求和
    output = attn_weights @ V  # (seq_len, d_k)
    return output, attn_weights

# 测试
seq_len, d_model, d_k = 4, 8, 4
x = np.random.randn(seq_len, d_model)
out, w = self_attention(x, d_k)
print("输出形状:", out.shape)    # (4, 4)
print("注意力权重:\n", np.round(w, 2))

我的调试小技巧:在真实项目中,我会先打印 score 和 attn_weights 的统计值,看看有没有出现 nan 或者全零行。如果 Softmax 结果大部分都是 0,说明分数太大或者 Mask 写错了。

4.7 一个极易忽略的细节:多头与单头的关系

你可能看到这里觉得自注意力很简单,但实际代码里通常用多头注意力。其实多头就是并行跑多个独立的单头自注意力,然后拼起来再投影。每个头关注不同的子空间,比如一个头关注语法关系,另一个头关注语义相似度。

我经常把单头理解成“一个人的视角”,多头则是“多个专家开会”。但无论头数多少,每个头内部的计算都是本章讲的这套 QKV 流程。

4.8 背下来还是理解原理?

我个人建议把公式记在脑子里:

Attention(Q,K,V) = softmax( (Q·K^T) / √d_k )·V

剩下的就是维度对齐和效率优化了。面试时问自注意力几乎必考这个公式,但真正的价值在于理解为什么这么设计。你在项目中遇到的问题,多半都能回退到这个基础公式去找原因。