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 共享参数,模型很难区分“谁在问”和“谁在被问”,表达能力就弱了。
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 图总结了整个计算过程,从输入到输出。建议你边看边在脑子里过一遍矩阵运算。
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
剩下的就是维度对齐和效率优化了。面试时问自注意力几乎必考这个公式,但真正的价值在于理解为什么这么设计。你在项目中遇到的问题,多半都能回退到这个基础公式去找原因。