1. 从“硬记”到“理解”:为什么我们需要Self-Attention?

如果你刚开始接触自然语言处理,可能会觉得这事儿挺玄乎的。我们怎么让计算机理解“我爱北京天安门”和“天安门在北京,我爱它”这两句话其实意思差不多呢?早期的模型,比如RNN(循环神经网络),处理句子就像我们背单词一样,是一个字一个字“硬记”的。它先看“我”,再看“爱”,再看“北京”……这样一路记下去。问题来了,当它看到句子末尾的“天安门”时,可能已经快忘了开头的“我”是什么了。这种“健忘症”在处理长句子时尤其严重,模型很难捕捉到距离很远的词语之间的关系。

这就引出了我们今天的主角:Self-Attention(自注意力机制)。我第一次接触这个概念时,觉得它名字起得特别贴切。它的核心思想就是让句子里的每个词都“环顾四周”,看看句子中其他所有词,然后决定:“我和哪个词关系更密切?我应该更关注谁?” 这个过程是同时发生的,而不是像RNN那样排着队来。

举个例子,在“苹果公司发布了新款手机”这句话里,“苹果”这个词单独看可能是一种水果,但当它看到后面的“公司”、“发布”、“手机”时,通过Self-Attention机制,它就能立刻明白,这里的“苹果”指的更可能是那家科技巨头,而不是水果摊上的红富士。这种动态的、上下文相关的理解能力,正是Self-Attention带来的革命性变化。它让模型不再孤立地看待每个词,而是学会了从整体上把握句子的语义。后来大名鼎鼎的Transformer模型,就是建立在Self-Attention的基础之上,彻底改变了NLP领域的游戏规则。

2. 拆解Self-Attention:三张“名片”与一场“投票”

说了这么多好处,Self-Attention到底是怎么工作的呢?别被公式吓到,咱们用一个“开会讨论”的类比来把它讲明白。

想象一下,我们现在有一个句子,比如“猫追老鼠”。我们要开一个会,来深入理解这个句子。句子里的每个词(“猫”、“追”、“老鼠”)都是与会代表。但光把人叫来不够,我们得让他们充分交流。Self-Attention就是这个会议的高效沟通规则

首先,每个词代表不能干坐着,他们需要准备三张特殊的“名片”:

  • 查询(Query, Q):这张名片上写着“我想了解什么?”。比如“猫”的Query可能是:“谁在执行动作?”。
  • 键(Key, K):这张名片上写着“我有什么信息?”。比如“追”的Key可能是:“我是一个动作”。
  • 值(Value, V):这张名片上写着“我的核心内容是什么?”。比如“老鼠”的Value就是“老鼠”这个词本身的含义。

这三张名片(Q, K, V)是怎么来的呢?很简单,就是每个词原始的向量表示(可以理解为这个词的初始“身份证”),分别通过三个不同的线性变换(可以理解为三个不同的“制卡机”)生成的。所以,虽然源于同一个词,但Q、K、V承载了不同的角色信息。

会议开始,沟通(计算)流程如下:

  1. 发起询问(计算Q和K的相似度):“猫”代表举起它的Q名片(“谁在执行动作?”),向全场广播。其他所有代表(包括“猫”自己)则出示他们的K名片。
  2. 计算关注度(计算Attention分数):“猫”会用自己的Q去和每一个代表的K进行比对,计算一个匹配分数。这个分数越高,说明两者越相关。比如,“猫”的Q和“追”的K(“我是一个动作”)匹配分数可能很高,因为“追”这个动作需要执行者;而和“老鼠”的K匹配分数可能就低一些。计算时,我们会用Q和K的点积(一种衡量相似度的方式)来表示这个分数。为了防止分数过大,通常会除以一个缩放因子(通常是Key向量维度的平方根)。
  3. 达成共识(Softmax归一化):现在“猫”手里有一堆分数,分别对应它和“猫”、“追”、“老鼠”的关注度。我们需要把这些分数归一化,变成一组权重,且所有权重之和为1。这就是Softmax函数干的事。归一化后,“猫”对“追”的权重可能最高(比如0.6),对自己权重次之(0.3),对“老鼠”最低(0.1)。这组权重就是“猫”的注意力分布——它最关注“追”这个动作。
  4. 汇总信息(加权求和V):最后一步,“猫”根据这个注意力权重,对所有代表的V名片进行加权求和。权重高的,其V信息就多拿一点进来。所以,“猫”新的表示 = 0.6 * “追”的V + 0.3 * “猫”的V + 0.1 * “老鼠”的V。这个新的表示,已经不再是孤立的“猫”了,而是包含了“猫正在执行追这个动作”的上下文信息

通过这一套流程,句子中的每个词都重新计算了一遍自己的表示,新的表示里都融入了全局的上下文信息。这个过程是并行完成的,效率极高。

3. 手把手实现:用PyTorch写一个Self-Attention层

理论懂了,不敲代码总觉得不踏实。咱们现在就动手,用PyTorch实现一个最基础的Self-Attention层。我会把每一步都掰开揉碎讲清楚。

首先,确保你安装了PyTorch。然后,我们创建一个 SelfAttention 类。

import torch
import torch.nn as nn
import torch.nn.functional as F
from math import sqrt

class SelfAttention(nn.Module):
    def __init__(self, embed_size):
        """
        初始化函数。
        Args:
            embed_size (int): 输入词向量的维度,也是我们内部Q,K,V的维度。
        """
        super(SelfAttention, self).__init__()
        self.embed_size = embed_size

        # 定义三个线性变换层,用于生成Q, K, V。
        # 注意:这里我们让Q, K, V的维度都等于embed_size,这是一种常见做法。
        # 实际上,Q和K的维度必须相同,因为要点积。V的维度可以不同。
        self.query = nn.Linear(embed_size, embed_size, bias=False)
        self.key = nn.Linear(embed_size, embed_size, bias=False)
        self.value = nn.Linear(embed_size, embed_size, bias=False)

        # 缩放因子,就是公式里除以的 sqrt(d_k)
        self.scale = sqrt(embed_size)

    def forward(self, x):
        """
        前向传播函数。
        Args:
            x (Tensor): 输入张量,形状为 [batch_size, seq_len, embed_size]
                        batch_size: 批大小
                        seq_len: 句子长度(单词个数)
                        embed_size: 词向量维度
        Returns:
            Tensor: 经过Self-Attention处理后的输出,形状为 [batch_size, seq_len, embed_size]
        """
        batch_size, seq_len, embed_size = x.shape

        # 1. 生成Q, K, V
        # 输入x经过三个不同的线性层,得到Q, K, V
        Q = self.query(x)  # 形状: [batch_size, seq_len, embed_size]
        K = self.key(x)    # 形状: [batch_size, seq_len, embed_size]
        V = self.value(x)  # 形状: [batch_size, seq_len, embed_size]

        # 2. 计算注意力分数 (Q * K^T)
        # 我们需要计算每个Q与所有K的点积。
        # torch.bmm 是批量矩阵乘法。K.transpose(1, 2) 将K的最后两维转置。
        # 结果 scores 的形状: [batch_size, seq_len, seq_len]
        # 这个矩阵的第i行第j列,表示第i个词对第j个词的注意力分数。
        scores = torch.bmm(Q, K.transpose(1, 2))

        # 3. 缩放并应用Softmax
        # 除以缩放因子,防止点积结果过大导致Softmax梯度消失。
        scores = scores / self.scale
        # 在最后一个维度(seq_len)上应用Softmax,使得每一行的和为1。
        attention_weights = F.softmax(scores, dim=-1)  # 形状: [batch_size, seq_len, seq_len]

        # 4. 加权求和,得到输出
        # 用注意力权重对V进行加权求和。
        # output 形状: [batch_size, seq_len, embed_size]
        output = torch.bmm(attention_weights, V)

        return output, attention_weights  # 通常返回输出和注意力权重,权重可用于可视化分析

我来解释一下代码里的几个关键点:

  • nn.Linear:这就是我们的“制卡机”。它做一个线性变换 y = xA^T + b,这里我们设置了 bias=False,就是不加偏置项。用三个独立的线性层,才能让Q、K、V学到不同的变换。
  • torch.bmm:批量矩阵乘法。因为我们的输入 x 是三维的(批大小,序列长度,特征维度),普通的 torch.mm 处理不了。bmm 会对每一批(batch)的数据单独做矩阵乘法,非常高效。
  • transpose(1, 2):这是矩阵转置。K 的形状是 [batch, seq_len, embed_size],转置最后两维后变成 [batch, embed_size, seq_len]。这样 Q ([batch, seq_len, embed_size]) 和 K^T ([batch, embed_size, seq_len]) 才能相乘,得到 [batch, seq_len, seq_len] 的分数矩阵。
  • F.softmax(dim=-1):在最后一个维度上做Softmax。dim=-1 是PyTorch的一个便利写法,代表最后一个维度。这里就是在“所有词”这个维度上做归一化,对每个词来说,它对所有词的注意力权重加起来等于1。

你可以写个小测试来验证一下:

# 假设我们的词向量维度是64,一个批次有2个句子,每个句子有5个词
batch_size = 2
seq_len = 5
embed_size = 64
dummy_input = torch.randn(batch_size, seq_len, embed_size) # 随机生成输入数据

# 初始化Self-Attention层
attn_layer = SelfAttention(embed_size)

# 前向传播
output, attn_weights = attn_layer(dummy_input)

print(f"输入形状: {dummy_input.shape}")
print(f"输出形状: {output.shape}") # 应该也是 [2, 5, 64]
print(f"注意力权重形状: {attn_weights.shape}") # 应该是 [2, 5, 5]
print(f"注意力权重第一行(第一个词对所有人的关注度): {attn_weights[0, 0]}")
print(f"检查权重和是否为1: {attn_weights[0, 0].sum()}") # 应该非常接近1.0

运行这段代码,你就能看到一个完整的Self-Attention层是如何工作的。attn_weights 这个矩阵特别有意思,你可以把它想象成一个“关系图谱”,可视化出来就能看到模型在处理句子时,到底更关注哪些词。

4. 实战演练:用Self-Attention提升文本分类效果

光有一个层还不够,我们得把它用起来。文本分类是个非常经典的任务,比如判断一条评论是正面还是负面(情感分析)。我们用一个简单的例子,看看如何把Self-Attention塞进一个网络里,让它发挥作用。

假设我们的任务是对电影评论进行二分类(正面/负面)。一个经典的基线模型是使用Embedding层把词变成向量,然后用RNN(比如LSTM)来编码整个句子,最后接一个全连接层分类。这个模型的问题在于,LSTM可能更关注句子后面的信息(因为是单向的),或者即使用了双向LSTM,其捕捉长距离依赖的能力也不如Self-Attention直接。

我们的改进方案:在LSTM之后,加一个Self-Attention层,让模型自己学会聚焦在评论中那些决定情感的关键词上,比如“精彩”、“糟糕”、“拖沓”、“感人”等等。

下面是一个简化的模型结构代码:

class TextClassifierWithAttention(nn.Module):
    def __init__(self, vocab_size, embed_size, hidden_size, num_classes, num_layers=1, dropout=0.5):
        super(TextClassifierWithAttention, self).__init__()
        # 1. 词嵌入层
        self.embedding = nn.Embedding(vocab_size, embed_size)
        # 2. 双向LSTM层,用于获取初步的上下文编码
        self.lstm = nn.LSTM(embed_size, hidden_size, num_layers=num_layers,
                            batch_first=True, bidirectional=True, dropout=dropout if num_layers>1 else 0)
        # 3. Self-Attention层。注意LSTM是双向的,所以输出维度是 hidden_size * 2
        self.attention = SelfAttention(hidden_size * 2)
        # 4. 一个全连接层,用于将Attention后的结果映射到分类空间
        self.fc = nn.Linear(hidden_size * 2, num_classes)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        # x: [batch_size, seq_len] (单词索引)
        # 嵌入
        embedded = self.dropout(self.embedding(x)) # [batch, seq_len, embed_size]
        # LSTM编码
        lstm_out, _ = self.lstm(embedded) # [batch, seq_len, hidden_size*2]
        # Self-Attention
        attn_out, attn_weights = self.attention(lstm_out) # attn_out: [batch, seq_len, hidden_size*2]
        # 这里有一个关键操作:池化(Pooling)。
        # attn_out 包含了每个词经过上下文增强后的表示。
        # 我们需要把整个句子的信息聚合成一个向量。常用的方法是取平均,或者取最后一个状态。
        # 这里我们使用“注意力加权后的平均值”。实际上,我们可以直接对 attn_out 在序列长度维度上取平均。
        # 更高级的做法是使用 attn_weights 矩阵的列和(每个词被其他词关注的总强度)作为权重,对原始lstm_out加权平均。
        # 为了简单,我们这里直接对 attn_out 取平均。
        pooled = attn_out.mean(dim=1) # [batch, hidden_size*2]
        # 全连接层分类
        logits = self.fc(self.dropout(pooled)) # [batch, num_classes]
        return logits, attn_weights

这个模型是怎么工作的?

  1. Embedding层把句子中的单词索引(比如[23, 45, 10, ...])转换成稠密的词向量。
  2. 双向LSTM从左到右、从右到左扫描一遍句子,得到每个词初步的上下文表示。双向的结果拼接起来,所以维度是 hidden_size * 2
  3. 关键步骤SelfAttention层上场。它接收LSTM的输出,让句子中的每个词都去关注所有其他词。比如,“这部电影一点也不好看”这句话,经过LSTM后,“不”和“好看”都有了自己的表示。在Self-Attention中,“好看”这个词会去计算它与“不”、“电影”、“一点”等词的关联度。理想情况下,它会发现“不”对自己的影响最大,从而生成一个更准确的、带有否定含义的“好看”表示。attn_out就是经过这样全局关联后的新序列表示。
  4. 我们需要一个句子级别的向量来做分类。这里采用了最简单的全局平均池化(mean(dim=1)),将attn_out在序列长度维度上压平,得到一个代表整个句子的向量。
  5. 最后通过一个全连接层fc,将这个句子向量映射到分类标签(如正面/负面)上。

训练和观察: 在实际训练中,你可以对比“只用LSTM”和“LSTM+Self-Attention”两个模型。通常,加入Self-Attention后,模型在验证集上的准确率会有可观的提升,尤其是在处理那些情感关键词被否定词或程度词修饰的复杂句子时。更重要的是,你可以把 attn_weights 可视化出来。对于一条差评“特效很棒,但剧情太无聊了”,你可能会发现模型在判断为“负面”时,对“无聊”这个词赋予了极高的注意力权重,而对“很棒”关注较少。这种可解释性是Self-Attention一个非常大的优点,让我们能窥见模型做出决策的依据。

5. 进阶与避坑:Multi-Head Attention与实战经验

我们上面实现的,在Transformer里被称为 Single-Head Attention(单头注意力)。但真正在Transformer和大模型中发挥威力的,是它的升级版——Multi-Head Attention(多头注意力)。

你可以把单头注意力理解成从一个角度(比如“语法角色”)去分析句子中词的关系。但一个词的含义是丰富的,比如“苹果”,它同时具有“公司实体”、“水果”、“品牌”等多重属性。多头注意力的思想就是:我们为什么不并行地做多次Self-Attention,每次关注不同的“方面”呢?

多头注意力的工作原理

  1. 把生成Q、K、V的线性变换维度降低。假设原来嵌入维度是512,我们想做8个头(head)。那么就把Q、K、V的维度从512降到 512/8=64。这64维就是每个头所关注的“子空间”。
  2. 并行进行8次独立的Self-Attention计算,每次都在自己64维的子空间里计算词与词之间的关系。这8次计算互不干扰。
  3. 得到8个 [batch, seq_len, 64] 的输出矩阵。
  4. 把这8个矩阵在最后一个维度上拼接(Concat)起来,变回 [batch, seq_len, 512]
  5. 再通过一个最终的线性投影层,整合来自8个头的不同视角的信息。

代码示意(关键部分)

class MultiHeadAttention(nn.Module):
    def __init__(self, embed_size, num_heads):
        super().__init__()
        assert embed_size % num_heads == 0, “Embedding size must be divisible by number of heads”
        self.embed_size = embed_size
        self.num_heads = num_heads
        self.head_dim = embed_size // num_heads

        # 生成Q,K,V的大线性层,输出维度仍是 embed_size
        self.q_linear = nn.Linear(embed_size, embed_size)
        self.k_linear = nn.Linear(embed_size, embed_size)
        self.v_linear = nn.Linear(embed_size, embed_size)
        # 最终输出的投影层
        self.out_linear = nn.Linear(embed_size, embed_size)

    def forward(self, x):
        batch_size, seq_len, _ = x.shape
        # 1. 线性投影得到Q,K,V
        Q = self.q_linear(x)
        K = self.k_linear(x)
        V = self.v_linear(x)
        # 2. 改变形状,分割成多个头 [batch, seq_len, num_heads, head_dim]
        # 然后转置为 [batch, num_heads, seq_len, head_dim] 以便并行计算
        Q = Q.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
        K = K.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
        V = V.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
        # 3. 对每个头,单独计算Scaled Dot-Product Attention
        # 这里可以调用一个我们写好的单头注意力函数,但需要支持4维输入
        scores = torch.matmul(Q, K.transpose(-2, -1)) / sqrt(self.head_dim)
        attn_weights = F.softmax(scores, dim=-1)
        attn_output = torch.matmul(attn_weights, V) # [batch, num_heads, seq_len, head_dim]
        # 4. 合并多头
        attn_output = attn_output.transpose(1, 2).contiguous().view(batch_size, seq_len, self.embed_size)
        # 5. 最终投影
        output = self.out_linear(attn_output)
        return output, attn_weights

实战中的经验与“坑”

  1. 维度要对齐:实现多头注意力时,最常遇到的错误就是张量形状没对齐。一定要清楚每一步操作后张量的四个维度 [batch, num_heads, seq_len, head_dim] 是如何变化的。.transpose().view() 操作要格外小心。
  2. 注意力掩码(Attention Mask):在真实任务中,比如训练Transformer做翻译,句子长度不一样,我们需要用Padding(填充)来对齐。在计算注意力时,必须让模型忽略这些Padding的位置。这就需要用到注意力掩码。通常是一个布尔矩阵,Padding位置为True(或1),在Softmax之前,将这些位置的分数加一个极大的负数(如-1e9),这样Softmax后权重就几乎为0了。
  3. 计算效率:Self-Attention的计算量和序列长度的平方成正比(因为要计算所有词对之间的分数)。这对于处理超长文本(如一篇论文)是个挑战。工业界和学术界提出了很多变体,如LongformerBigBird的稀疏注意力,Reformer的局部敏感哈希注意力等,都是为了解决这个效率问题。
  4. 初始化与学习率:包含Self-Attention的模型(如Transformer)通常对参数初始化和学习率比较敏感。使用像AdamW这样的优化器,并配合学习率预热(Warmup)策略,往往是稳定训练的关键。

从我自己的项目经验来看,初次尝试把Self-Attention加入现有网络时,不要期待有魔法般的提升。它更像是一个强大的特征增强器。你需要确保模型的其他部分(如词嵌入、基础编码器)是work的,然后再引入Self-Attention,让它去学习那些复杂的、长距离的依赖关系。多观察注意力权重的可视化结果,它能给你带来很多关于模型行为和数据集特性的洞见。

更多推荐