第三节: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都会轻松很多。下一节我们聊聊多头注意力——为什么一个注意力头不够,非要搞八个?