1. Attention的起源:从Seq2Seq到Bahdanau Attention,为什么需要注意力机制?

1.1 故事要从一个“信息瓶颈”说起

我刚开始接触NLP的时候,Seq2Seq模型还是个新鲜玩意儿。说白了,它的核心思想很简单:用一个RNN把输入句子“编码”成一个固定长度的向量,再用另一个RNN从这个向量里“解码”出目标句子。

听起来挺合理的,对吧?但我在实际项目中踩过坑——当句子稍微长一点,比如超过20个词,模型的表现就开始断崖式下跌。为什么会这样?

嗯,这里有个关键问题:固定长度的向量是个信息瓶颈。你想想看,一个300维的向量,要承载“我今天下午三点在人民广场地铁站A出口等你”这么丰富的信息,怎么可能装得下?

核心痛点:编码器必须把整个输入序列压缩成一个固定长度的向量,这个向量成了整个系统的“咽喉”。长句子的信息损失几乎是不可避免的。

1.2 我当年踩过的坑:长句子翻译的噩梦

记得有一次,我在做一个中英翻译项目。短句子翻译得还不错,但一遇到长句子就翻车。比如:

  • 输入:“昨天我在图书馆看到一本关于深度学习的书,作者是Yoshua Bengio,这本书写得非常好”
  • 输出:“Yesterday I saw a book about deep learning in the library, it was very good”

看到了吗?“作者是Yoshua Bengio”这个关键信息直接丢了。这就是固定长度向量的典型问题——模型记不住长距离的依赖关系

我当时试过各种方法:加大LSTM的隐藏层维度、堆叠更多层、甚至用双向RNN。效果有提升,但本质问题没解决。说白了,你用一个瓶子装水,瓶子再大也有装满的时候。

1.3 Bahdanau Attention:让模型学会“回头看”

2015年,Bahdanau等人提出了一个天才的想法:为什么不让模型在生成每个词的时候,自己决定要看输入序列的哪些部分?

这就是Attention机制的雏形。我个人的理解是:它给模型装了一个“聚光灯”,每次只照亮输入序列中最重要的几个位置。

数学上,Bahdanau Attention做的事情可以拆成三步:

  1. 计算注意力分数:对于解码器的当前隐藏状态 \( s_{t-1} \) 和编码器的每个隐藏状态 \( h_j \),计算一个分数 \( e_{tj} \)
  2. 归一化:用softmax把这些分数变成概率分布 \( \alpha_{tj} \)
  3. 加权求和:用这些概率对编码器的隐藏状态做加权平均,得到上下文向量 \( c_t \)

公式长这样:

e_{tj} = v_a^T tanh(W_a * s_{t-1} + U_a * h_j)    # 计算注意力分数
α_{tj} = exp(e_{tj}) / Σ_k exp(e_{tk})              # softmax归一化
c_t = Σ_j α_{tj} * h_j                              # 加权求和得到上下文向量

个人经验:我在实现Bahdanau Attention时,发现一个容易忽略的细节——注意力分数的计算方式有很多变种。Bahdanau用的是加性注意力(additive attention),而后来流行的Luong Attention用的是点积注意力(dot-product attention)。前者计算量稍大,但效果在某些任务上更稳定。

1.4 为什么Bahdanau Attention能解决问题?

说白了,Attention机制解决了两个核心问题:

问题 传统Seq2Seq 带Attention的Seq2Seq
信息瓶颈 所有信息挤在一个固定向量里 每个时间步都可以访问完整的编码器状态
长距离依赖 RNN难以记住20步之前的信息 注意力权重直接连接解码器和编码器的每个位置
可解释性 黑盒,不知道模型关注哪里 注意力权重可以可视化,知道模型在看什么

我举个例子你就明白了。翻译“I love you”到中文时,传统模型可能把整个句子编码成一个向量,然后生成“我爱你”。但带Attention的模型在生成“爱”这个词时,会重点关注输入中的“love”,生成“你”时重点关注“you”。

避坑指南:我曾经在实现时犯过一个低级错误——忘记对注意力权重做softmax归一化。结果模型训练时loss死活降不下去。检查了半天才发现,注意力权重没有归一化,导致上下文向量的尺度一直在变。所以,一定要确保注意力权重是概率分布,即所有权重之和为1。

1.5 从Bahdanau到现代Attention:一个简单的代码实现

为了让你更直观地理解,我写了一个简化版的Bahdanau Attention实现。这个代码去掉了一些工程细节,保留了核心逻辑:

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

class BahdanauAttention(nn.Module):
    def __init__(self, hidden_dim):
        super().__init__()
        # 注意:这里W_a和U_a是两个不同的线性变换
        self.W_a = nn.Linear(hidden_dim, hidden_dim, bias=False)
        self.U_a = nn.Linear(hidden_dim, hidden_dim, bias=False)
        self.v_a = nn.Linear(hidden_dim, 1, bias=False)
    
    def forward(self, decoder_hidden, encoder_outputs):
        """
        decoder_hidden: (batch_size, hidden_dim)
        encoder_outputs: (batch_size, seq_len, hidden_dim)
        """
        # 步骤1:计算注意力分数
        # decoder_hidden扩展维度: (batch, 1, hidden) -> (batch, seq_len, hidden)
        decoder_expanded = decoder_hidden.unsqueeze(1).expand(-1, encoder_outputs.size(1), -1)
        
        # 计算e_tj = v_a^T * tanh(W_a * s_{t-1} + U_a * h_j)
        score = self.v_a(torch.tanh(self.W_a(decoder_expanded) + self.U_a(encoder_outputs)))
        # score shape: (batch, seq_len, 1)
        
        # 步骤2:softmax归一化
        attention_weights = F.softmax(score.squeeze(-1), dim=1)
        # attention_weights shape: (batch, seq_len)
        
        # 步骤3:加权求和得到上下文向量
        context = torch.bmm(attention_weights.unsqueeze(1), encoder_outputs).squeeze(1)
        # context shape: (batch, hidden_dim)
        
        return context, attention_weights

代码解读:注意看第20行,我用了unsqueezeexpand来对齐维度。这是PyTorch中处理广播的常用技巧。另外,torch.bmm是批量矩阵乘法,比手动循环快得多。

1.6 小结:Attention到底带来了什么?

回顾一下,Bahdanau Attention的核心贡献是:

  • 打破了信息瓶颈:不再需要把整个输入压缩成一个向量
  • 建立了动态连接:解码器可以“聚焦”到输入序列的任意位置
  • 提供了可解释性:注意力权重让我们知道模型在“看”哪里

我个人觉得,Attention机制最妙的地方在于它的通用性。它不局限于Seq2Seq,后来被广泛应用到图像、语音、推荐系统等各个领域。甚至可以说,没有Attention,就没有后来的Transformer,也就没有今天的GPT和BERT。

下一章,我们会深入探讨Attention的数学本质,看看它到底是怎么计算的。到时候你会发现,Attention其实没那么神秘——说白了,就是一套查询-键-值的匹配机制。