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做的事情可以拆成三步:
- 计算注意力分数:对于解码器的当前隐藏状态 \( s_{t-1} \) 和编码器的每个隐藏状态 \( h_j \),计算一个分数 \( e_{tj} \)
- 归一化:用softmax把这些分数变成概率分布 \( \alpha_{tj} \)
- 加权求和:用这些概率对编码器的隐藏状态做加权平均,得到上下文向量 \( 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行,我用了unsqueeze和expand来对齐维度。这是PyTorch中处理广播的常用技巧。另外,torch.bmm是批量矩阵乘法,比手动循环快得多。
1.6 小结:Attention到底带来了什么?
回顾一下,Bahdanau Attention的核心贡献是:
- 打破了信息瓶颈:不再需要把整个输入压缩成一个向量
- 建立了动态连接:解码器可以“聚焦”到输入序列的任意位置
- 提供了可解释性:注意力权重让我们知道模型在“看”哪里
我个人觉得,Attention机制最妙的地方在于它的通用性。它不局限于Seq2Seq,后来被广泛应用到图像、语音、推荐系统等各个领域。甚至可以说,没有Attention,就没有后来的Transformer,也就没有今天的GPT和BERT。
下一章,我们会深入探讨Attention的数学本质,看看它到底是怎么计算的。到时候你会发现,Attention其实没那么神秘——说白了,就是一套查询-键-值的匹配机制。