第三节:Self-Attention机制:QKV矩阵计算,注意力分数与Softmax
好,咱们今天来啃一块硬骨头——Self-Attention的核心计算。说实话,我刚接触Transformer那会儿,看到Q、K、V这三个字母就头大。什么Query、Key、Value,听着像数据库查询,又像信息检索。但等你真正搞懂了,会发现这东西其实特别直观。
我习惯把Self-Attention比作一个「智能阅读器」。你读一句话的时候,眼睛会不自觉地来回扫视,把当前词和前后文联系起来。Self-Attention干的就是这个活——让每个词都能「看到」序列里的所有其他词,然后决定该关注谁。
3.1 从词向量到Q、K、V矩阵
假设我们有一个输入序列,经过Embedding层后,每个词变成了一个向量。比如句子"I love NLP",三个词对应三个向量。但问题来了:这些向量本身是孤立的,它们不知道彼此之间的关系。
Self-Attention的第一步,就是给每个词准备三份「分身」:Query、Key、Value。怎么准备?用三个不同的权重矩阵去乘输入向量。
公式很简单:
Q = X · W_Q
K = X · W_K
V = X · W_V
其中X是输入矩阵(形状为[序列长度, 词向量维度]),W_Q、W_K、W_V是可学习的参数矩阵。这三个矩阵的维度可以不同,但实践中通常保持一致。
关键点:Q、K、V是从同一个输入通过不同线性变换得到的。它们「同源但不同质」。
我在项目中遇到过一个问题:有人问为什么不用同一个矩阵?非要搞三个?嗯,你想想看,如果Q=K=V,那每个词跟自己算注意力分数永远是最大的,模型就学不会关注其他位置了。三个不同的投影,相当于让模型从三个角度去理解同一个词——Query负责「我想找什么」,Key负责「我有什么」,Value负责「我提供什么信息」。
3.2 注意力分数的计算
有了Q和K,接下来就是算「谁该关注谁」。具体做法是:用每个词的Query去跟所有词的Key做点积。
公式:
Attention Scores = Q · K^T
这里K^T是K矩阵的转置。结果是一个[序列长度, 序列长度]的矩阵,每个位置(i, j)表示第i个词对第j个词的关注程度。
举个例子,假设序列长度是3,词向量维度是4:
- Q的形状:[3, 4]
- K的形状:[3, 4]
- K^T的形状:[4, 3]
- Q · K^T的形状:[3, 3]
这个[3, 3]的矩阵就是注意力分数矩阵。第0行第1列的值,就是第0个词对第1个词的注意力分数。
注意:点积的结果会随着向量维度增大而变大。如果词向量维度是512,点积结果可能达到几百甚至上千。这会导致Softmax后的梯度变得非常小,模型难以训练。
怎么解决?加一个缩放因子。这就是为什么叫Scaled Dot-Product Attention:
Attention Scores = (Q · K^T) / √d_k
其中d_k是Key向量的维度。除以√d_k,相当于把方差拉回到1附近。我刚开始做实验时没加这个缩放,结果模型loss死活降不下去,折腾了两天才发现是这个问题。嗯,这个坑我替你们踩过了。
3.3 Softmax归一化
注意力分数算出来了,但还不是最终权重。因为这些分数可以是任意实数,我们需要把它们变成概率分布——所有位置加起来等于1,每个值在0到1之间。
Softmax登场:
Attention Weights = Softmax( (Q · K^T) / √d_k )
Softmax做的事情很简单:对每一行(也就是每个Query对应的所有Key分数)做归一化。公式是:
Softmax(x_i) = exp(x_i) / Σ exp(x_j)
经过Softmax后,每一行的值都变成了正数,且和为1。这样我们就得到了一个「注意力权重矩阵」,每个元素表示一个词对另一个词的关注比例。
小技巧:实际实现时,为了防止数值溢出,通常会在Softmax之前先减去该行的最大值。这叫「Log-Sum-Exp Trick」,PyTorch和TensorFlow的Softmax实现默认都做了这个处理。
3.4 加权求和得到输出
最后一步,用注意力权重去加权求和Value矩阵:
Output = Attention Weights · V
V的形状是[序列长度, 词向量维度],注意力权重的形状是[序列长度, 序列长度]。相乘后,输出形状还是[序列长度, 词向量维度]。
每个位置的输出,就是所有位置Value向量的加权平均。权重由注意力分数决定。说白了,每个词最终的表征,是「它认为重要的其他词的信息的融合」。
3.5 完整计算流程
把上面所有步骤串起来,就是完整的Self-Attention计算公式:
Attention(Q, K, V) = Softmax( (Q · K^T) / √d_k ) · V
我习惯用一张表来总结每个步骤的作用:
| 步骤 | 输入 | 输出 | 作用 |
|---|---|---|---|
| 线性投影 | X | Q, K, V | 从不同角度提取特征 |
| 点积计算 | Q, K | 注意力分数 | 衡量词与词之间的相关性 |
| 缩放 | 注意力分数 | 缩放后的分数 | 防止梯度消失 |
| Softmax | 缩放后的分数 | 注意力权重 | 归一化为概率分布 |
| 加权求和 | 注意力权重, V | 输出 | 融合上下文信息 |
3.6 代码实现
光说不练假把式。咱们用PyTorch实现一个最简单的Self-Attention:
import torch
import torch.nn as nn
import torch.nn.functional as F
class SimpleSelfAttention(nn.Module):
def __init__(self, embed_dim):
super().__init__()
self.embed_dim = embed_dim
# 定义三个线性投影层
self.W_Q = nn.Linear(embed_dim, embed_dim)
self.W_K = nn.Linear(embed_dim, embed_dim)
self.W_V = nn.Linear(embed_dim, embed_dim)
def forward(self, x):
# x: [batch_size, seq_len, embed_dim]
Q = self.W_Q(x) # [batch, seq, dim]
K = self.W_K(x) # [batch, seq, dim]
V = self.W_V(x) # [batch, seq, dim]
# 计算注意力分数并缩放
scores = torch.matmul(Q, K.transpose(-2, -1)) # [batch, seq, seq]
scores = scores / (self.embed_dim ** 0.5)
# Softmax归一化
attn_weights = F.softmax(scores, dim=-1) # [batch, seq, seq]
# 加权求和
output = torch.matmul(attn_weights, V) # [batch, seq, dim]
return output, attn_weights
这段代码虽然简单,但已经包含了Self-Attention的全部核心逻辑。实际项目中,我们还会加入多头机制、Dropout、残差连接等,但底层的QKV计算逻辑是不变的。
核心要点回顾:
- Q、K、V来自同一个输入的不同线性变换
- 注意力分数 = Q与K的点积,再除以√d_k做缩放
- Softmax将分数转为概率权重
- 最终输出 = 权重与V的加权求和
我曾经在面试候选人的时候,发现很多人能背出公式,但问一句「为什么Q和K的维度要一致」就答不上来了。其实答案很简单:维度不一致,点积就没法算。但更深层的理解是——Query和Key必须在同一个语义空间里做匹配,维度一致是保证它们「说同一种语言」的前提。
好了,这一节的内容就到这里。Self-Attention是整个Transformer的基石,搞懂了它,后面的多头注意力、位置编码、LayerNorm都会轻松很多。下一节我们聊聊多头注意力——为什么一个注意力头不够,非要搞八个?