1. 注意力机制基础:从Seq2Seq到Attention的演进

为什么需要注意力机制?

先说说我最早接触Seq2Seq时的感受吧。那时候做机器翻译,输入一个长句子,模型就靠一个「固定长度的向量」来记住全部信息。你想想看,这合理吗?

举个例子,翻译「我今天下午三点在会议室和客户讨论项目方案」这句话。编码器要把这十几个词的信息全部压缩到一个向量里。长句子的开头信息,传到解码器时早就衰减得差不多了。这就是经典的「长距离依赖问题」。

我在项目中遇到过这样一个场景:用Seq2Seq做文本摘要,输入文章超过50个词时,生成的摘要就开始胡言乱语。后来一分析,问题就出在这个「信息瓶颈」上——编码器的最后一个隐藏状态根本装不下那么多信息。

核心痛点:固定长度的上下文向量是信息瓶颈,长序列场景下性能急剧下降。

Seq2Seq的局限性

传统的Seq2Seq架构,说白了就是两个RNN的接力赛。编码器RNN读完整个输入序列,生成一个上下文向量c,然后解码器RNN拿着这个c开始生成输出。

这个过程有几个硬伤:

  • 信息压缩损失:无论输入多长,c的维度是固定的。就像用一个小杯子装一桶水,肯定会洒出来不少。
  • 位置信息模糊:c是最后一个时间步的隐藏状态,它更偏向记住序列末尾的信息。开头说了什么?早就忘了。
  • 梯度消失/爆炸:RNN本身就有这个问题,长序列下更严重。我调试过一个50层的LSTM,梯度直接炸到NaN,调了三天学习率才稳住。

我的经验:如果你用BiLSTM做编码器,记得把前向和后向的隐藏状态拼接起来。这样至少能同时看到「过去」和「未来」的信息,比单向LSTM强不少。

注意力机制的核心思想

注意力机制的想法其实很直观:不要把所有信息都塞进一个向量里,而是在每个解码步骤动态地「回头看」输入序列

具体来说,解码器在生成第t个词时,会计算当前状态与编码器每个时间步隐藏状态的「相关性分数」。然后根据这个分数加权求和,得到一个动态的上下文向量c_t。

这个过程可以拆成三步:

  1. 计算注意力分数:用某种打分函数衡量当前解码状态与每个编码器隐藏状态的匹配程度。
  2. 归一化:用softmax把分数转成概率分布,也就是注意力权重。
  3. 加权求和:用注意力权重对编码器隐藏状态做加权平均,得到上下文向量。

说白了,就是让模型学会「该看哪里」。翻译「苹果」这个词时,注意力权重应该集中在输入中的「apple」上,而不是远处的「tree」。

核心公式(加性注意力):

score(h_t, h_s) = v^T * tanh(W * [h_t; h_s])
alpha_t = softmax(score)
c_t = sum(alpha_t * h_s)

其中h_t是当前解码器状态,h_s是编码器各时间步的隐藏状态。

注意力机制的几种常见形式

我这些年用过不少注意力变体,挑几个最常用的说说:

类型 打分函数 特点
加性注意力(Bahdanau) v^T * tanh(W * [h_t; h_s]) 可学习参数多,适合高维特征
乘性注意力(Luong) h_t^T * W * h_s 计算更快,适合低维特征
点积注意力 h_t^T * h_s 最简单,但需要维度匹配
缩放点积注意力 (h_t^T * h_s) / sqrt(d_k) Transformer用的,防止梯度消失

避坑指南:我曾经在点积注意力上吃过亏。当特征维度d_k很大时,点积的结果会变得非常大,softmax的梯度会趋近于0。后来改用缩放点积注意力,问题就解决了。所以如果你用Transformer,记得加上那个sqrt(d_k)的缩放因子。

注意力机制带来的改变

注意力机制引入后,Seq2Seq的性能有了质的飞跃。我总结了几点关键变化:

  • 长序列不再是噩梦:模型可以灵活地关注输入的不同位置,50个词的句子和5个词的句子都能处理。
  • 可解释性增强:注意力权重可以可视化,你能看到模型在生成每个词时「看了哪里」。这在调试时特别有用。
  • 梯度传播更顺畅:注意力机制建立了解码器和编码器各时间步的直接连接,缓解了梯度消失问题。

嗯,这里要注意一点:注意力机制不是银弹。它解决了信息瓶颈问题,但计算复杂度从O(n)变成了O(n^2)。输入序列太长时,显存会爆炸。我处理过一篇3000词的论文摘要,注意力矩阵直接撑爆了24G显存。

一个简单的PyTorch实现

最后,我贴一个加性注意力的实现。这是我在项目中常用的版本,加了dropout防止过拟合:

import torch
import torch.nn as nn
import torch.nn.functional as F

class BahdanauAttention(nn.Module):
    def __init__(self, hidden_dim, dropout=0.1):
        super().__init__()
        self.W = nn.Linear(hidden_dim * 2, hidden_dim)
        self.v = nn.Linear(hidden_dim, 1)
        self.dropout = nn.Dropout(dropout)
        
    def forward(self, decoder_hidden, encoder_outputs):
        # decoder_hidden: [batch, hidden_dim]
        # encoder_outputs: [batch, seq_len, hidden_dim]
        
        seq_len = encoder_outputs.size(1)
        decoder_hidden = decoder_hidden.unsqueeze(1).repeat(1, seq_len, 1)
        
        # 拼接并计算分数
        combined = torch.cat((decoder_hidden, encoder_outputs), dim=2)
        energy = self.v(torch.tanh(self.W(combined))).squeeze(2)
        
        # 归一化得到注意力权重
        attention_weights = F.softmax(energy, dim=1)
        attention_weights = self.dropout(attention_weights)
        
        # 加权求和
        context = torch.bmm(attention_weights.unsqueeze(1), encoder_outputs).squeeze(1)
        
        return context, attention_weights

使用建议:dropout加在注意力权重上,而不是加在上下文向量上。我试过两种方式,加在权重上效果更好,能防止模型过度依赖某些位置。

好了,这一章就到这里。注意力机制的核心思想其实不复杂——就是让模型学会「聚焦」。下一章我们会深入讨论Dropout在注意力机制中的具体应用,包括在哪里加dropout、加多少、以及一些我踩过的坑。