3、注意力机制入门:Bahdanau Attention 与 Luong Attention 详解
聊到 Transformer,注意力机制是绕不开的核心。但很多人一上来就扎进 Self-Attention,反而忽视了它的两员老将——Bahdanau Attention 和 Luong Attention。
我个人习惯把注意力机制的发展史看作「从搬砖到自动化」的进化。早期的 Seq2Seq 模型其实挺笨的:编码器把所有信息压成一个固定长度的向量,解码器全靠这个向量猜下一个词。长句子一进来,效果直接崩。我 2017 年第一次跑机器翻译任务的时候,输入超过 30 个词,BLEU 分数就往下跌,当时还不清楚原因。后来读了 Bahdanau 的论文才恍然大悟——原来是「信息瓶颈」卡住了脖子。
3.1 为什么需要注意力机制?
你可以想象一个翻译任务:英语句子 "The cat sat on the mat" 翻译成中文。传统 Seq2Seq 把整个句子压缩成一个固定长度的向量,生成 "猫" 这个词的时候,其实最该关注 "cat",却被迫平均照顾所有单词。这就是瓶颈。
注意力机制的思路很直接:生成每个目标词时,动态计算源句子中哪些词更相关,然后把注意力集中在它们身上。说白了,不再是「一视同仁」,而是「重点关照」。
- 避免了长句信息丢失
- 提升了翻译质量(尤其是 30 词以上的句子)
- 提供了可解释性——你可以看到解码器在「看」源句子的哪个位置
3.2 Bahdanau Attention(加性注意力)
Bahdanau 在 2014 年提出了第一个用于 Seq2Seq 的注意力机制。它的核心是一个前馈神经网络,用来计算「解码器隐藏状态」与「编码器所有隐藏状态」之间的匹配分数。
数学上,它的分数函数是:
score(s_{t-1}, h_j) = v_a^T · tanh(W_a · [s_{t-1} ; h_j])
其中 s_{t-1} 是解码器上一时刻的隐藏状态,h_j 是编码器第 j 个位置的隐藏状态。W_a 和 v_a 是可学习的参数。[;] 表示拼接操作。
注意,这里用的是 tanh 激活函数。加性注意力名字的由来,就是因为分数是通过「加」的方式把两个向量融合后再非线性变换的。我当年第一次看这个公式的时候,怎么都想不通为什么要用 tanh 而不是 ReLU。后来跑实验才发现:tanh 的输出有正有负,能更好地捕捉相关性差异;ReLU 会把负值砍成 0,导致很多位置注意力为零,梯度流不动。
得到分数后,通过 softmax 归一化得到注意力权重 α_tj,再用权重对编码器隐藏状态做加权求和,得到上下文向量 c_t。
α_tj = exp(score(s_{t-1}, h_j)) / Σ_k exp(score(s_{t-1}, h_k))
c_t = Σ_j α_tj · h_j
这个上下文向量会和当前解码器输入拼接,一起送入 RNN 生成下一个词。
3.3 Luong Attention(乘性注意力)
2015 年,Luong 提出了另一种注意力,主要做了两件事简化:
- 全局与局部注意力:全局注意力看所有源位置,局部注意力只看一个窗口(节省计算)
- 多种分数计算方式:给出了三种分数函数,其中 dot 和 general 是乘性,concat 是加性
Luong 注意力的分数函数:
score(s_t, h_j) =
- dot: s_t^T · h_j
- general: s_t^T · W_a · h_j
- concat: v_a^T · tanh(W_a · [s_t ; h_j]) # 类似 Bahdanau
注意 Luong 用的是当前解码器时刻的隐藏状态 s_t,而不是上一时刻 s_{t-1}。这是与 Bahdanau 的一个重要区别。
重点对比:
- Bahdanau 用 s_{t-1} 计算注意力,然后生成 s_t;Luong 用 s_t 计算注意力,再生成上下文
- Luong 的 dot 方案没有可学习参数,速度极快,但表达能力弱一些
- general 方案引入了一个矩阵 W_a,灵活度更高
3.4 两者的关键区别
| 维度 | Bahdanau Attention | Luong Attention |
|---|---|---|
| 提出年份 | 2014 | 2015 |
| 计算时刻 | 用解码器上一时刻 s_{t-1} | 用解码器当前时刻 s_t |
| 分数函数类型 | 加性(MLP + tanh) | 乘性(dot/general/concat) |
| 可学习参数 | W_a, v_a(较多) | dot: 0;general: W_a;concat: W_a, v_a |
| 计算效率 | 较低(MLP 需要逐个位置算) | 较高(dot 可矩阵加速) |
| 长句效果 | 更稳定 | dot 在长句上容易衰减 |
| 常见应用 | 早期 NMT、语音识别 | NMT、摘要、图像描述 |
我个人的建议:如果你刚开始实现注意力,先试 Luong 的 dot 版本,代码不到 10 行就能跑通。调好后再换 general 或者 Bahdanau 看看效果有没有提升。没必要一上来就上最复杂的。
3.5 代码实现对比
我用 PyTorch 简单写下两种注意力的核心逻辑。注意这里只做演示,实际使用时需要结合 RNN 状态循环。
import torch
import torch.nn as nn
import torch.nn.functional as F
class BahdanauAttention(nn.Module):
def __init__(self, hidden_dim):
super().__init__()
self.W_a = nn.Linear(hidden_dim * 2, hidden_dim)
self.v_a = nn.Linear(hidden_dim, 1, bias=False)
def forward(self, s_prev, encoder_outputs):
# s_prev: (batch, hidden_dim)
# encoder_outputs: (batch, src_len, hidden_dim)
src_len = encoder_outputs.size(1)
s_prev = s_prev.unsqueeze(1).repeat(1, src_len, 1) # (batch, src_len, hidden_dim)
energy = torch.tanh(self.W_a(torch.cat((s_prev, encoder_outputs), dim=2)))
scores = self.v_a(energy).squeeze(2) # (batch, src_len)
attn_weights = F.softmax(scores, dim=1)
context = torch.bmm(attn_weights.unsqueeze(1), encoder_outputs).squeeze(1)
return context, attn_weights
class LuongAttention(nn.Module):
def __init__(self, hidden_dim, method='dot'):
super().__init__()
self.method = method
if method == 'general':
self.W_a = nn.Linear(hidden_dim, hidden_dim, bias=False)
def forward(self, s_t, encoder_outputs):
# s_t: (batch, hidden_dim)
if self.method == 'dot':
scores = torch.bmm(encoder_outputs, s_t.unsqueeze(2)).squeeze(2)
elif self.method == 'general':
scores = torch.bmm(encoder_outputs, self.W_a(s_t).unsqueeze(2)).squeeze(2)
else:
raise ValueError("只实现了 dot 和 general,concat 参考 Bahdanau")
attn_weights = F.softmax(scores, dim=1)
context = torch.bmm(attn_weights.unsqueeze(1), encoder_outputs).squeeze(1)
return context, attn_weights
调试小技巧:刚写完注意力的时候,建议打印一两个样本的注意力权重,确认它们是不是大致均匀分布(刚开始训练时应该是均匀的),有没有出现全零或全一的极端情况。我吃过一次亏:忘了给 softmax 加 dim 参数,结果 softmax 默认对全 batch 做归一化,权重全变成 0.01 左右,训练直接废掉。
3.6 知识体系结构
下面这张图概括了本章的核心逻辑:从注意力机制的动机出发,对比两种经典实现,最终导向选择指南。
3.7 实际工程选型建议
我在两个实战项目中对比过这两种注意力:
- 机器翻译(中英 20 万句对):Luong dot + 双向 GRU,训练比 Bahdanau 快 30%,但 BLEU 低 0.8 分。换成 Luong general 后,BLEU 追平 Bahdanau,训练速度只慢 5%。我最终选了 Luong general。
- 文本摘要(CNN/DailyMail):用了 Bahdanau,因为输入句子很长(平均 40 词),加性注意力的稳定性更好,没出现过注意力塌缩。
注意:不要迷信某一方。如果你的模型需要极强的对齐能力(比如语音识别中音素对齐),Bahdanau 的加性结构往往更可靠。如果算力有限且目标句子很短,Luong dot 完全够用。
好了,关于两种经典注意力就聊这么多。代码贴在上面了,你可以直接跑起来看看注意力权重到底长什么样——相信我,可视化出来会很有趣。