查看全课大纲(第 18 / 40 节)
- 1.从规则到学习:深度学习解决什么问题
- 2.表示学习与端到端训练
- 3.张量、计算图与自动微分
- 4.训练三要素:数据、模型、目标函数
- 5.从感知机到多层感知机
- 6.反向传播:手推一个两层网络
- 7.激活函数与初始化
- 8.优化器:SGD → Momentum → Adam
- 9.正则化:权重衰减、Dropout、BN/LN
- 10.卷积的直觉与参数共享
- 11.池化、感受野与下采样
- 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
- 13.残差连接与归一化为什么有效
- 14.迁移学习与特征可视化
- 15.RNN 与 BPTT
- 16.LSTM/GRU:门控如何缓解梯度问题
- 17.Seq2Seq 与编码器-解码器
- 18.注意力前史:Bahdanau 加性注意力
- 19.Self-Attention:QKV 与缩放点积
- 20.多头注意力:并行的多种关系
- 21.位置编码:正弦与可学习
- 22.残差、LayerNorm 与前馈层
- 23.从零实现一个最小 Transformer block
- 24.训练稳定性、复杂度与常见误区
- 25.自监督与语言建模目标
- 26.BERT:掩码语言模型与双向编码
- 27.GPT:自回归生成与缩放定律
- 28.指令微调与对齐:SFT → RLHF/DPO
- 29.参数高效微调:LoRA 与适配器
- 30.自编码器与 VAE
- 31.GAN:对抗训练与模式崩塌
- 32.扩散模型:前向加噪与反向去噪
- 33.扩散模型的应用与采样加速
- 34.对比学习与 CLIP
- 35.ViT:把图像当序列
- 36.多模态架构:对齐、融合与生成
- 37.能力边界、幻觉与评测
- 38.PyTorch 2.x 训练循环与混合精度
- 39.实验管理、评测与复现
- 40.推理部署与成本:量化/蒸馏概览
注意力前史:Bahdanau 加性注意力
小象实战讲义 · 深度学习基础:从神经网络到 Transformer
在上一节,我们学习了循环神经网络(RNN)如何通过隐藏状态处理序列。但 RNN 在处理长序列时,存在信息瓶颈:编码器必须将整个源序列压缩成一个固定长度的向量,这导致翻译长句子时性能下降。本节我们将看到,2014年提出的 Bahdanau 注意力 如何通过让解码器在生成每个词时“动态回顾”编码器的全部状态,从而突破这一瓶颈。理解这一机制是通往 Transformer 中自注意力的关键桥梁。
先修:上一节《Seq2Seq 与编码器-解码器》,熟悉 RNN 编码器-解码器框架与序列建模的基本概念。 后续:下一章将进入 Transformer,那里的「缩放点积注意力」是本文加性注意力的高效变体。
💡 核心导读
- 问题根源:传统RNN编码器-解码器将整个输入序列编码为单个固定长度向量,是处理长序列的瓶颈。
- 核心思想:解码器在生成每个目标词时,为编码器所有隐藏状态计算一个动态权重(注意力权重),并加权求和得到一个上下文向量,该向量聚焦于当前最相关的源信息。
- 关键机制:使用一个小的前馈神经网络(对齐模型)来评分,通过加性运算(
tanh+ 线性层)计算注意力分数。 - 形状变化:注意力机制将
(batch, src_len, enc_dim)的编码器状态,转换为每个解码步独有的(batch, enc_dim)上下文向量。 - 代码实现:我们将分步实现加性注意力层,并观察其如何融入解码过程。
🧭 前置知识
- RNN编码器-解码器框架:编码器输出一系列隐藏状态,解码器基于上下文向量生成序列。
- 矩阵乘法与形状广播规则:
(batch, dim1) @ (dim1, dim2) -> (batch, dim2)。 tanh激活函数与softmax函数(沿特定维度归一化)。- 基本的前馈神经网络结构(线性层、激活函数)。
从瓶颈到动态聚焦:注意力机制的动机
在 Neural Machine Translation by Jointly Learning to Align and Translate(Bahdanau et al., 2014, arXiv:1409.0473) 论文中,作者明确指出传统RNN编码器-解码器的核心缺陷:
“我们推测,使用固定长度向量是改进这种基础编码器-解码器架构性能的一个瓶颈……这使得神经网络难以处理长句子,尤其是那些比训练语料库中句子更长的句子。”
具体来说,在之前的模型中,编码器读取源序列 ,最终生成一个上下文向量 (通常是最后一个隐藏状态 )。解码器在整个生成过程中都使用这个同一个 来预测每一个目标词 。
想象一下,让你只读一遍一篇长文章,然后在不许回看的情况下,从头到尾复述其翻译。你很可能记住开头和结尾,但中间细节会模糊。这就是固定长度上下文向量的“记忆瓶颈”。
Bahdanau 注意力提出的解决方案是动态上下文:解码器在生成第 个目标词时,不是使用同一个 ,而是计算一个专属的上下文向量 。
是编码器所有隐藏状态 的加权和,权重 表示在生成 时,编码器第 个位置的重要性。这样,模型在生成每个词时,都能“软搜索”并聚焦于源序列中最相关的部分。
Bahdanau 加性注意力机制详解
Bahdanau 注意力,也称为“加性注意力”,其计算流程可分为三步:评分、归一化、加权求和。
1. 编码器:双向RNN生成注释序列
为了获得包含前后文信息的编码表示,论文采用了双向RNN。前向RNN从左到右读取序列,后向RNN从右到左读取序列,然后将每个时间步的前向和后向隐藏状态拼接起来,得到最终的注释(annotation)。
这样, 就包含了围绕第 个源词的整体上下文信息。假设前向和后向RNN的隐藏层大小都是 enc_hidden_size,那么 的维度就是 enc_dim = 2 * enc_hidden_size。
编码器的输出是一个状态序列:(batch, src_len, enc_dim)。
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)
# 模拟编码器输出:双向RNN的隐藏状态序列
batch_size = 2
src_len = 10 # 源序列长度
enc_hidden_size = 8
enc_dim = 2 * enc_hidden_size # 双向拼接后的维度
# 编码器隐藏状态序列 (batch, src_len, enc_dim)
encoder_states = torch.randn(batch_size, src_len, enc_dim)
print("encoder_states.shape =", encoder_states.shape)encoder_states.shape = torch.Size([2, 10, 16])2. 注意力评分:加性(Additive)方式
对于解码器的第 步,我们需要计算当前解码器隐藏状态 (生成 之前的状态)与每一个编码器状态 的相关性得分 。
Bahdanau 等人提出使用一个小的前馈神经网络(称为对齐模型)来计算这个分数。具体公式为:
其中:
- :解码器上一个时刻的隐藏状态,形状为
(batch, dec_hidden_size)。 - :编码器第 个位置的状态,形状为
(batch, enc_dim)。 - :将 投影到
attn_dim维的矩阵,形状(attn_dim, dec_hidden_size)(与nn.Linear(dec_hidden_size, attn_dim)的权重排布一致)。 - :将 投影到
attn_dim维的矩阵,形状(attn_dim, enc_dim)。 - :非线性激活函数。
- :将
attn_dim维向量投影为标量分数的向量,形状(attn_dim,)。
这个计算是“加性”的,因为它在 tanh 内部将两个投影后的向量相加。最终 是一个标量,表示对齐分数。
形状推导:
W_a @ s_{t-1}.T:(attn_dim, dec_hidden_size) @ (dec_hidden_size, batch) -> (attn_dim, batch),转置后得(batch, attn_dim)。U_a @ h_j.T:(attn_dim, enc_dim) @ (enc_dim, batch) -> (attn_dim, batch),转置后得(batch, attn_dim)。- 两者相加后经过
tanh,形状仍为(batch, attn_dim)。 v_a @ tanh_output.T:(attn_dim,) @ (attn_dim, batch) -> (1, batch),转置后得(batch, 1),即每个样本、每个编码器位置一个分数。
为了高效计算所有 位置的分数,我们可以利用广播机制。将 扩展为 (batch, 1, dec_hidden_size),将 encoder_states 保持为 (batch, src_len, enc_dim),然后进行批量矩阵乘法。
import torch
import torch.nn as nn
torch.manual_seed(0)
# 参数定义
batch_size = 2
src_len = 10
enc_dim = 16
dec_hidden_size = 20
attn_dim = 32 # 注意力内部维度,论文中称为"对齐模型"的维度
# 模拟数据
encoder_states = torch.randn(batch_size, src_len, enc_dim) # (B, L, enc_dim)
decoder_hidden = torch.randn(batch_size, dec_hidden_size) # (B, dec_hidden)
# 定义加性注意力层(对齐模型)
class BahdanauAttention(nn.Module):
def __init__(self, enc_dim, dec_hidden_size, attn_dim):
super().__init__()
self.W = nn.Linear(dec_hidden_size, attn_dim, bias=False) # W_a
self.U = nn.Linear(enc_dim, attn_dim, bias=False) # U_a
self.v = nn.Linear(attn_dim, 1, bias=False) # v_a
def forward(self, decoder_hidden, encoder_states):
# decoder_hidden: (batch, dec_hidden)
# encoder_states: (batch, src_len, enc_dim)
# 1. 投影 decoder_hidden -> (batch, 1, attn_dim)
hidden_proj = self.W(decoder_hidden).unsqueeze(1) # (B, 1, attn_dim)
# 2. 投影 encoder_states -> (batch, src_len, attn_dim)
states_proj = self.U(encoder_states) # (B, L, attn_dim)
# 3. 相加并应用 tanh
# hidden_proj 广播到 (B, L, attn_dim)
scores_input = torch.tanh(hidden_proj + states_proj) # (B, L, attn_dim)
# 4. 投影到标量分数
scores = self.v(scores_input).squeeze(-1) # (B, L)
return scores
# 实例化并计算注意力分数
attn_layer = BahdanauAttention(enc_dim, dec_hidden_size, attn_dim)
attention_scores = attn_layer(decoder_hidden, encoder_states)
print("attention_scores.shape =", attention_scores.shape)
print("attention_scores[0] (前4个值) =", [round(x.item(), 4) for x in attention_scores[0, :4]])attention_scores.shape = torch.Size([2, 10])
attention_scores[0] (前4个值) = [0.4903, 0.2351, -0.1706, 0.5322]3. 计算注意力权重与上下文向量
得到原始分数 (形状 (batch, src_len))后,我们沿着源序列维度(dim=1)应用 softmax 进行归一化,得到注意力权重 。
表示在生成第 个目标词时,模型分配给第 个源词的“注意力”比例,所有权重之和为1。
最后,上下文向量 是编码器状态的加权和:
这可以通过 torch.bmm(批量矩阵乘法)实现:将权重 (batch, 1, src_len) 与编码器状态 (batch, src_len, enc_dim) 相乘,得到 (batch, 1, enc_dim),再压缩掉中间的维度。
import torch
import torch.nn.functional as F
torch.manual_seed(0)
batch_size, src_len, enc_dim = 2, 10, 16
# 编码器状态与「上一块算出的注意力分数」
encoder_states = torch.randn(batch_size, src_len, enc_dim) # (B, L, enc_dim)
attention_scores = torch.randn(batch_size, src_len) # 真实分数由上一块的 BahdanauAttention 给出,这里用随机分数演示后两步
# 1. 沿源序列维度做 softmax,得到注意力权重
attention_weights = F.softmax(attention_scores, dim=-1) # (B, L)
print("attention_weights.shape =", attention_weights.shape)
print("权重和(检查归一化):", [round(x.item(), 4) for x in attention_weights.sum(dim=-1)])
# 2. 加权求和:权重 (B, 1, L) × 状态 (B, L, enc_dim) -> (B, 1, enc_dim)
context_vector = torch.bmm(attention_weights.unsqueeze(1), encoder_states).squeeze(1)
print("context_vector.shape =", context_vector.shape)
print("context_vector[0] (前4维) =", [round(x.item(), 4) for x in context_vector[0, :4]])attention_weights.shape = torch.Size([2, 10])
权重和(检查归一化): [1.0, 1.0]
context_vector.shape = torch.Size([2, 16])
context_vector[0] (前4维) = [-0.5123, -0.5981, 0.2538, 0.298]4. 融入解码器
得到上下文向量 后,它被用作解码器RNN在时间步 的额外输入。在Bahdanau的原始设计中,解码器RNN的更新公式变为:
其中 是RNN单元(如GRU或LSTM)。 通常与目标词嵌入 拼接后,再输入到RNN中。然后,RNN的输出 再与 拼接,通过一个线性层和 softmax 来预测当前词 的概率分布。
# 概念性代码:展示注意力如何融入解码步
# 假设我们有:
# prev_y_embed: 上一个目标词的嵌入 (batch, tgt_embed_dim)
# context_vector: 刚计算的上下文向量 (batch, enc_dim)
# decoder_rnn: 一个RNN单元(如GRU)
# fc_out: 用于生成词表logits的线性层
# 将上一词嵌入与上下文向量拼接
# 形状: (batch, tgt_embed_dim + enc_dim)
rnn_input = torch.cat([prev_y_embed, context_vector], dim=1)
# RNN前向传播
decoder_hidden, decoder_output = decoder_rnn(rnn_input, decoder_hidden)
# 将RNN输出与上下文向量再次拼接,然后投影到词表大小
output = torch.cat([decoder_output, context_vector], dim=1)
logits = fc_out(output) # (batch, tgt_vocab_size)
# 后续计算损失...为什么是“加性”?设计考量与局限
设计考量:
- 非线性融合:
tanh提供了非线性,允许模型学习解码器状态与编码器状态之间复杂的交互模式,而不只是简单的点积相似度。 - 可处理不同维度: 和 的维度可以不同,通过 和 投影到统一的
attn_dim空间,灵活性高。 - 直观对齐:加性注意力被证明在机器翻译任务中能学习到类似“词对齐”的软权重,符合直觉。
局限与后续发展:
- 计算复杂度:加性注意力需要对每个
(解码器状态, 编码器状态)对进行一次前向传播,计算成本较高。后续的点积注意力(Luong et al., 2015)通过简单的矩阵乘法大幅提升了计算效率。 - 额外参数:引入了 , , 三组可学习参数。
- 缩放问题:点积注意力后来被发现当维度较高时,
softmax梯度会很小,因此引入了缩放因子 ,这直接导致了Transformer中缩放点积注意力的诞生。
早期课程中,注意力机制可能被简化为一个黑盒。现在我们根据原始论文和现代框架(如PyTorch)的常见实现,清晰地拆解了其计算图。
📝 动手练一练
问题:在加性注意力中,如果将 tanh 激活函数移除,即评分函数变为 ,那么注意力机制会退化成什么形式?请从模型表达能力的角度分析可能的影响。
👉 点击查看参考答案
思路:移除 tanh 后,评分函数变成 ——它是关于 与 的线性(加性)形式,并没有出现 这样的交叉项,所以严格说不上「双线性」。更关键的是:第一项 与 无关,在 softmax 归一化时会被整体约去,于是权重只剩下 的贡献——完全不随解码步变化。也就是说,注意力退化成静态对齐:无论生成第几个词,关注的源位置分布都一样。
可能的影响:
- 表达能力下降:非线性
tanh能够帮助模型捕捉更复杂的、非单调的匹配关系(例如,某些组合特征才重要)。移除后,模型可能难以学习到更精细的对齐模式。 - 可能退化为简单线性模型:在某些情况下,如果数据中的对齐关系本身近似线性,影响可能不大。但对于复杂的序列到序列任务(如翻译),非线性通常是必要的。
- 实现上:只需把
torch.tanh(hidden_proj + states_proj)改成(hidden_proj + states_proj)。下面的代码用同一组随机参数分别跑「带 tanh」和「不带 tanh」,对比注意力权重的最大值与熵(熵越大分布越均匀):
import torch
import torch.nn as nn
import torch.nn.functional as F
batch_size, src_len = 2, 10
enc_dim, dec_hidden_size, attn_dim = 16, 20, 32
torch.manual_seed(0)
encoder_states = torch.randn(batch_size, src_len, enc_dim)
decoder_hidden = torch.randn(batch_size, dec_hidden_size)
class AdditiveAttention(nn.Module):
def __init__(self, use_tanh=True):
super().__init__()
self.W = nn.Linear(dec_hidden_size, attn_dim, bias=False)
self.U = nn.Linear(enc_dim, attn_dim, bias=False)
self.v = nn.Linear(attn_dim, 1, bias=False)
self.use_tanh = use_tanh
def forward(self, decoder_hidden, encoder_states):
z = self.W(decoder_hidden).unsqueeze(1) + self.U(encoder_states)
if self.use_tanh:
z = torch.tanh(z)
return self.v(z).squeeze(-1) # (B, L)
for use_tanh in (True, False):
torch.manual_seed(0) # 两种设置从同一组参数出发,公平对比
attn = AdditiveAttention(use_tanh=use_tanh)
w = F.softmax(attn(decoder_hidden, encoder_states), dim=-1)
max_w = w.max(dim=-1).values
entropy = -(w * torch.log(w + 1e-12)).sum(dim=-1)
print(f"use_tanh={use_tanh}")
print(" 最大权重:", [round(v.item(), 4) for v in max_w])
print(" 权重熵 :", [round(v.item(), 4) for v in entropy])use_tanh=True
最大权重: [0.1246, 0.1325]
权重熵 : [2.2885, 2.2873]
use_tanh=False
最大权重: [0.1666, 0.1522]
权重熵 : [2.2503, 2.2732]对比可以看出:去掉 tanh 后权重更「尖」(最大权重变大、熵变小),说明线性评分更容易把注意力压到少数位置。这不是「更好」或「更差」的绝对值判断——在小规模随机参数下,它只说明非线性改变了权重的分布形态;是否有害,要在真实任务上用翻译质量来验证。
常见错误
| 错误理解或写法 | 现象或报错 | 原因 | 正确做法 |
|---|---|---|---|
在计算注意力权重时,softmax 应用错了维度。 | 权重之和不为1,或者上下文向量计算错误。 | softmax 默认对最后一个维度操作,如果输入形状是 (batch, src_len),应对 dim=1 操作。 | 明确指定维度:F.softmax(scores, dim=1)。 |
计算上下文向量时,直接对 encoder_states 和 attention_weights 做元素乘法而非加权和。 | 得到的 context_vector 形状是 (batch, src_len, enc_dim),而非 (batch, enc_dim)。 | 混淆了元素乘和矩阵乘。注意力权重应对序列维度进行约简。 | 使用 torch.bmm(attention_weights.unsqueeze(1), encoder_states).squeeze(1)。 |
忘记在注意力评分后对 scores 进行 softmax 归一化。 | 注意力权重可能为任意实数,且数值可能非常大或非常小,导致加权求和时某些状态被过度放大或忽略。 | 注意力权重的核心在于“比例”,必须归一化才能表示概率分布。 | 务必在计算加权和之前应用 softmax。 |
将解码器当前步的隐藏状态 s_t(而非上一步 s_{t-1})用于计算当前步的注意力。 | 在循环解码中会造成数据泄露,因为 s_t 已经包含了当前步上下文向量的信息。 | 注意力机制用于生成当前步的上下文,因此应基于上一步的状态来寻找相关源信息。 | 确保使用 decoder_hidden_prev(即 )来计算当前步的注意力权重和上下文向量 。 |
本章小结
- 动机:Bahdanau 注意力旨在解决RNN编码器-解码器中固定长度上下文向量的信息瓶颈问题,使解码能动态聚焦于源序列的不同部分。
- 核心计算:包含三步:a) 用加性网络(
tanh(Ws + Uh))计算对齐分数;b) 沿源序列维度做softmax得到注意力权重;c) 对编码器状态加权求和得到上下文向量。 - 形状流:编码器状态
(B, L, D_enc)+ 解码器状态(B, D_dec)→ 注意力分数(B, L)→ 权重(B, L)→ 上下文向量(B, D_enc)。 - 融入解码:上下文向量与上一目标词嵌入拼接,作为解码器RNN的输入,其输出再与上下文向量拼接用于最终预测。
- 历史地位:加性注意力是首个成功将注意力机制应用于神经机器翻译的工作,为后续更高效的点积及缩放点积注意力奠定了基础。
📋 行动清单
- 运行本节所有代码块,确保理解每一步张量形状的变化。
- 尝试修改
attn_dim的大小(例如改为 8 或 64),重新运行代码,观察注意力分数和最终上下文向量的变化。 - 根据“动手练一练”的提示,在代码中移除
tanh激活函数,对比前后注意力权重分布的差异(例如计算权重的熵或最大值)。
延伸阅读
本段 PyTorch 官方文档/教程条目的框架出处:PyTorch: An Imperative Style, High-Performance Deep Learning Library(Paszke et al., 2019,arXiv:1912.01703);页面内容以 PyTorch 2.14.1 为准。
Neural Machine Translation by Jointly Learning to Align and Translate(Bahdanau, Cho & Bengio, 2014, ICLR, arXiv:1409.0473)—— 本节核心论文,首次提出基于注意力的 NMT - https://arxiv.org/abs/1409.0473- PyTorch 官方教程
NLP FROM SCRATCH: TRANSLATION WITH A SEQUENCE TO SEQUENCE NETWORK AND ATTENTION(Sean Robertson, 2017;本站 2026-10 访问)—— 含注意力实现的完整示例,与原始论文细节略有不同、原理一致 - https://pytorch.org/tutorials/intermediate/seq2seq_translation_tutorial.html
—— 小象教研组