自注意力原理:Query、Key、Value的矩阵计算与特征映射
说到Transformer的核心,自注意力机制绝对是绕不开的一块。很多朋友刚开始接触的时候,被Q、K、V三个字母搞得晕头转向。说实话,我最初也觉得这东西有点玄乎——为什么非得是三个矩阵?不能直接算相似度吗?
后来在实际项目中调模型、写推理代码,才慢慢摸清楚这里的门道。今天咱们就把这块掰开揉碎了讲一遍。
从词嵌入到QKV:三个矩阵的诞生
首先你得明确一件事:自注意力的输入,是同一组序列。说白了,就是让序列里的每个位置,自己去跟其他所有位置做一次「配对打分」。
假设我们有一个输入矩阵 X,形状是 (seq_len, d_model)。每一行是一个位置的向量表示。那Q、K、V是怎么来的?
很简单,三个独立的线性变换:
Q = X @ W_Q # Query 查询
K = X @ W_K # Key 键
V = X @ W_V # Value 值
W_Q、W_K、W_V 都是可训练的权重矩阵。我经常跟团队里的新人说:Q和K决定了「谁该关注谁」,V决定了「被关注的那个该传递什么信息」。
为什么是三个矩阵?不是两个?我在项目中遇到过这样的疑惑。其实你可以把 Q 和 K 理解为一对「寻址」操作——Q 是问问题的人,K 是身上贴着标签的回答者。两者做点积,就能算出每个位置对当前问问题的位置有多重要。
至于 V,它承载的是真正要传递出去的内容。你想想看,如果只有 Q 和 K,那你只能得到一组权重,但拿不到具体的内容特征。所以 V 不能少。
缩放点积注意力:完整的计算流程
好,有了Q、K、V,接下来就是核心的计算过程。我习惯把自注意力的公式写成这样:
Attention(Q, K, V) = softmax(Q @ K^T / sqrt(d_k)) @ V
你光看公式可能觉得不难,但写成代码,每一步都有坑。我给学员讲课的时候,喜欢拆成五个步骤:
- 计算相似度矩阵:Q 乘以 K 的转置,得到 (seq_len, seq_len) 的矩阵。
- 缩放:除以 sqrt(d_k),防止点积值过大导致softmax进入饱和区。
- 可选:加Mask:Decoder里用因果掩码,防止看到未来位置。
- Softmax归一化:对每一行做softmax,让权重和为1。
- 加权求和:用softmax后的权重乘以V,得到最终的输出。
我以前在代码里踩过一个大坑——忘记做缩放。当 d_k 很大时,比如 512 或者 1024,Q 和 K 的点积值会非常大,softmax 之后几乎变成 one-hot 分布,梯度几乎消失。那次模型训练了两天,Loss 就是下不去,折腾了半天才发现是分母没加。
关键点总结:
- Q @ K^T 得到的是「注意力分数」,分数越高表示当前位置越该关注对方。
- 除以 sqrt(d_k) 是让梯度更稳定,你别省这一步。
- Softmax 按行做,保证每个位置的注意力权重之和为1。
多头注意力:扩展模型关注能力
如果你只用一组Q、K、V,模型只能从「一个视角」去看序列。但实际情况下,一个词可能需要从多个角度去理解。比如「苹果」这个词,在「苹果很好吃」和「苹果发布了新手机」里,关注的维度完全不同。
这就引出了多头注意力(Multi-Head Attention)。
具体做法很简单:把 d_model 拆成 h 个头,每个头独立做自注意力,最后再把结果拼起来。
# 伪代码示意
def multi_head_attention(X, h=8):
# 1. 线性变换得到 QKV
Q = X @ W_Q # (seq, d_model)
K = X @ W_K
V = X @ W_V
# 2. 拆成 h 个头
Q = reshape(Q, (seq, h, d_k)) # d_k = d_model / h
K = reshape(K, (seq, h, d_k))
V = reshape(V, (seq, h, d_k))
# 3. 每个头独立算注意力
out = []
for i in range(h):
attn = softmax(Q[:,i,:] @ K[:,i,:].T / sqrt(d_k))
head_out = attn @ V[:,i,:]
out.append(head_out)
# 4. 拼接 + 线性投影
out = concat(out, axis=-1) # (seq, d_model)
out = out @ W_O
return out
我以前用8个头的时候,训练出来的模型明显比单头要好。每个头关注的东西不一样,有的侧重语法结构,有的侧重语义关系。这就好比一个团队里,有人负责看全局,有人关注细节。
我的建议:
头数并不是越多越好。h=8 或 h=12 是比较常用的配置。头数太多,每个头的维度太小,表达能力反而会下降。我做过对比实验,当 d_k 小于 32 的时候,效果掉得比较明显。
特征映射:自注意力到底学到了什么?
咱们聊聊本质。自注意力中的Q、K、V,本质上是在做一种 动态的特征映射。
传统的卷积或者全连接层,参数是固定的。你输入什么,它就按照固定的权重去计算输出。但自注意力不一样——它的权重是依赖输入动态生成的。
换句话说:Q 和 K 的乘积产生了一个「依赖输入的自适应权重矩阵」。这个矩阵告诉模型:对于当前的这个词,你应该更关注前面的哪个词、后面的哪个词。然后通过 V,把被关注的那些位置的信息提取出来。
我在项目里遇到过这样一个场景:用Transformer做长文档分类。文档有几千个token,模型需要找到关键的那几句话。单靠一层自注意力不够,因为它只会做全局的「加权平均」。后来我叠加了多层,并且配合了相对位置编码,效果才有了质的提升。
避坑指南:
我曾经在复现论文的时候,发现自注意力的输出分布很集中——所有位置的注意力基本都落在同一个token上。后来排查发现,是因为初始化的W_Q和W_K太相似,导致每个位置算出来的Q和K几乎一样。换了个初始化方式就好了。所以初始化真的不能随便写。
计算复杂度分析
自注意力虽然好,但它有一个明显的短板:计算复杂度是 O(n²),其中 n 是序列长度。
| 操作 | 复杂度 | 说明 |
|---|---|---|
| Q、K、V 线性变换 | O(n·d²) | n是序列长度,d是模型维度 |
| Q @ K^T | O(n²·d) | 这个是主要瓶颈 |
| Softmax | O(n²) | 逐行计算 |
| Attention @ V | O(n²·d) | 又一个平方项 |
你想想看,当 n=1024 时,n² 就已经百万级别了。如果是 8192 的长序列,直接用标准自注意力,显存直接爆炸。这也是为什么后来出现了各种优化方法:Sparse Attention、Flash Attention、Linear Attention……
不过这都是后话了。先把基础的QKV理解透,后面讲优化手段的时候你才能知道它在改什么。
嗯,这一章咱们把QKV的矩阵计算和特征映射讲清楚了。核心就三句话:Q查、K被查、V传值;除以sqrt(d_k)保梯度;多头拆视角,单头做细节。