查看全课大纲(第 18 / 40 节)
  1. 1.从规则到学习:深度学习解决什么问题
  2. 2.表示学习与端到端训练
  3. 3.张量、计算图与自动微分
  4. 4.训练三要素:数据、模型、目标函数
  5. 5.从感知机到多层感知机
  6. 6.反向传播:手推一个两层网络
  7. 7.激活函数与初始化
  8. 8.优化器:SGD → Momentum → Adam
  9. 9.正则化:权重衰减、Dropout、BN/LN
  10. 10.卷积的直觉与参数共享
  11. 11.池化、感受野与下采样
  12. 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
  13. 13.残差连接与归一化为什么有效
  14. 14.迁移学习与特征可视化
  15. 15.RNN 与 BPTT
  16. 16.LSTM/GRU:门控如何缓解梯度问题
  17. 17.Seq2Seq 与编码器-解码器
  18. 18.注意力前史:Bahdanau 加性注意力
  19. 19.Self-Attention:QKV 与缩放点积
  20. 20.多头注意力:并行的多种关系
  21. 21.位置编码:正弦与可学习
  22. 22.残差、LayerNorm 与前馈层
  23. 23.从零实现一个最小 Transformer block
  24. 24.训练稳定性、复杂度与常见误区
  25. 25.自监督与语言建模目标
  26. 26.BERT:掩码语言模型与双向编码
  27. 27.GPT:自回归生成与缩放定律
  28. 28.指令微调与对齐:SFT → RLHF/DPO
  29. 29.参数高效微调:LoRA 与适配器
  30. 30.自编码器与 VAE
  31. 31.GAN:对抗训练与模式崩塌
  32. 32.扩散模型:前向加噪与反向去噪
  33. 33.扩散模型的应用与采样加速
  34. 34.对比学习与 CLIP
  35. 35.ViT:把图像当序列
  36. 36.多模态架构:对齐、融合与生成
  37. 37.能力边界、幻觉与评测
  38. 38.PyTorch 2.x 训练循环与混合精度
  39. 39.实验管理、评测与复现
  40. 40.推理部署与成本:量化/蒸馏概览

注意力前史:Bahdanau 加性注意力

小象实战讲义 · 深度学习基础:从神经网络到 Transformer

在上一节,我们学习了循环神经网络(RNN)如何通过隐藏状态处理序列。但 RNN 在处理长序列时,存在信息瓶颈:编码器必须将整个源序列压缩成一个固定长度的向量,这导致翻译长句子时性能下降。本节我们将看到,2014年提出的 Bahdanau 注意力 如何通过让解码器在生成每个词时“动态回顾”编码器的全部状态,从而突破这一瓶颈。理解这一机制是通往 Transformer 中自注意力的关键桥梁。

先修:上一节《Seq2Seq 与编码器-解码器》,熟悉 RNN 编码器-解码器框架与序列建模的基本概念。 后续:下一章将进入 Transformer,那里的「缩放点积注意力」是本文加性注意力的高效变体。

💡 核心导读

  1. 问题根源:传统RNN编码器-解码器将整个输入序列编码为单个固定长度向量,是处理长序列的瓶颈。
  2. 核心思想:解码器在生成每个目标词时,为编码器所有隐藏状态计算一个动态权重(注意力权重),并加权求和得到一个上下文向量,该向量聚焦于当前最相关的源信息。
  3. 关键机制:使用一个小的前馈神经网络(对齐模型)来评分,通过加性运算(tanh + 线性层)计算注意力分数。
  4. 形状变化:注意力机制将 (batch, src_len, enc_dim) 的编码器状态,转换为每个解码步独有的 (batch, enc_dim) 上下文向量。
  5. 代码实现:我们将分步实现加性注意力层,并观察其如何融入解码过程。

🧭 前置知识

  • 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编码器-解码器的核心缺陷:

“我们推测,使用固定长度向量是改进这种基础编码器-解码器架构性能的一个瓶颈……这使得神经网络难以处理长句子,尤其是那些比训练语料库中句子更长的句子。”

具体来说,在之前的模型中,编码器读取源序列 x=(x1,⋯ ,xTx)\mathbf{x} = (x_1, \cdots, x_{T_x}),最终生成一个上下文向量 cc(通常是最后一个隐藏状态 hTxh_{T_x})。解码器在整个生成过程中都使用这个同一个 cc 来预测每一个目标词 yty_t。

p(yt∣{y1,⋯ ,yt−1},c)=g(yt−1,st,c)p(y_t | \lbrace y_1, \cdots, y_{t-1} \rbrace, c) = g(y_{t-1}, s_t, c)

想象一下,让你只读一遍一篇长文章,然后在不许回看的情况下,从头到尾复述其翻译。你很可能记住开头和结尾,但中间细节会模糊。这就是固定长度上下文向量的“记忆瓶颈”。

Bahdanau 注意力提出的解决方案是动态上下文:解码器在生成第 tt 个目标词时,不是使用同一个 cc,而是计算一个专属的上下文向量 ctc_t。

p(yt∣y1,…,yt−1,x)=g(yt−1,st,ct)p(y_t | y_1, \ldots, y_{t-1}, \mathbf{x}) = g(y_{t-1}, s_t, c_t)

ctc_t 是编码器所有隐藏状态 (h1,⋯ ,hTx)(h_1, \cdots, h_{T_x}) 的加权和,权重 αtj\alpha_{tj} 表示在生成 yty_t 时,编码器第 jj 个位置的重要性。这样,模型在生成每个词时,都能“软搜索”并聚焦于源序列中最相关的部分。

Bahdanau 加性注意力机制详解

Bahdanau 注意力,也称为“加性注意力”,其计算流程可分为三步:评分、归一化、加权求和。

1. 编码器:双向RNN生成注释序列

为了获得包含前后文信息的编码表示,论文采用了双向RNN。前向RNN从左到右读取序列,后向RNN从右到左读取序列,然后将每个时间步的前向和后向隐藏状态拼接起来,得到最终的注释(annotation)hjh_j。

hj=[h→j;h←j]h_j = [\overrightarrow{h}_j; \overleftarrow{h}_j]

这样,hjh_j 就包含了围绕第 jj 个源词的整体上下文信息。假设前向和后向RNN的隐藏层大小都是 enc_hidden_size,那么 hjh_j 的维度就是 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)方式

对于解码器的第 tt 步,我们需要计算当前解码器隐藏状态 st−1s_{t-1}(生成 yty_t 之前的状态)与每一个编码器状态 hjh_j 的相关性得分 etje_{tj}。

Bahdanau 等人提出使用一个小的前馈神经网络(称为对齐模型)来计算这个分数。具体公式为:

etj=va⊤tanh⁡(Wast−1+Uahj)e_{tj} = \mathbf{v}_a^\top \tanh(\mathbf{W}_a s_{t-1} + \mathbf{U}_a h_j)

其中:

  • st−1s_{t-1}:解码器上一个时刻的隐藏状态,形状为 (batch, dec_hidden_size)。
  • hjh_j:编码器第 jj 个位置的状态,形状为 (batch, enc_dim)。
  • Wa\mathbf{W}_a:将 st−1s_{t-1} 投影到 attn_dim 维的矩阵,形状 (attn_dim, dec_hidden_size)(与 nn.Linear(dec_hidden_size, attn_dim) 的权重排布一致)。
  • Ua\mathbf{U}_a:将 hjh_j 投影到 attn_dim 维的矩阵,形状 (attn_dim, enc_dim)。
  • tanh⁡\tanh:非线性激活函数。
  • va\mathbf{v}_a:将 attn_dim 维向量投影为标量分数的向量,形状 (attn_dim,)。

这个计算是“加性”的,因为它在 tanh 内部将两个投影后的向量相加。最终 etje_{tj} 是一个标量,表示对齐分数。

形状推导:

  1. W_a @ s_{t-1}.T:(attn_dim, dec_hidden_size) @ (dec_hidden_size, batch) -> (attn_dim, batch),转置后得 (batch, attn_dim)。
  2. U_a @ h_j.T:(attn_dim, enc_dim) @ (enc_dim, batch) -> (attn_dim, batch),转置后得 (batch, attn_dim)。
  3. 两者相加后经过 tanh,形状仍为 (batch, attn_dim)。
  4. v_a @ tanh_output.T:(attn_dim,) @ (attn_dim, batch) -> (1, batch),转置后得 (batch, 1),即每个样本、每个编码器位置一个分数。

为了高效计算所有 jj 位置的分数,我们可以利用广播机制。将 st−1s_{t-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. 计算注意力权重与上下文向量

得到原始分数 etje_{tj}(形状 (batch, src_len))后,我们沿着源序列维度(dim=1)应用 softmax 进行归一化,得到注意力权重 αtj\alpha_{tj}。

αtj=exp⁡(etj)∑k=1Txexp⁡(etk)\alpha_{tj} = \frac{\exp(e_{tj})}{\sum_{k=1}^{T_x} \exp(e_{tk})}

αtj\alpha_{tj} 表示在生成第 tt 个目标词时,模型分配给第 jj 个源词的“注意力”比例,所有权重之和为1。

最后,上下文向量 ctc_t 是编码器状态的加权和:

ct=∑j=1Txαtjhjc_t = \sum_{j=1}^{T_x} \alpha_{tj} h_j

这可以通过 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. 融入解码器

得到上下文向量 ctc_t 后,它被用作解码器RNN在时间步 tt 的额外输入。在Bahdanau的原始设计中,解码器RNN的更新公式变为:

st=f(st−1,yt−1,ct)s_t = f(s_{t-1}, y_{t-1}, c_t)

其中 ff 是RNN单元(如GRU或LSTM)。ctc_t 通常与目标词嵌入 yt−1y_{t-1} 拼接后,再输入到RNN中。然后,RNN的输出 sts_t 再与 ctc_t 拼接,通过一个线性层和 softmax 来预测当前词 yty_t 的概率分布。

# 概念性代码:展示注意力如何融入解码步
# 假设我们有:
# 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)

# 后续计算损失...

为什么是“加性”?设计考量与局限

设计考量:

  1. 非线性融合:tanh 提供了非线性,允许模型学习解码器状态与编码器状态之间复杂的交互模式,而不只是简单的点积相似度。
  2. 可处理不同维度:st−1s_{t-1} 和 hjh_j 的维度可以不同,通过 Wa\mathbf{W}_a 和 Ua\mathbf{U}_a 投影到统一的 attn_dim 空间,灵活性高。
  3. 直观对齐:加性注意力被证明在机器翻译任务中能学习到类似“词对齐”的软权重,符合直觉。

局限与后续发展:

  1. 计算复杂度:加性注意力需要对每个 (解码器状态, 编码器状态) 对进行一次前向传播,计算成本较高。后续的点积注意力(Luong et al., 2015)通过简单的矩阵乘法大幅提升了计算效率。
  2. 额外参数:引入了 Wa\mathbf{W}_a, Ua\mathbf{U}_a, va\mathbf{v}_a 三组可学习参数。
  3. 缩放问题:点积注意力后来被发现当维度较高时,softmax 梯度会很小,因此引入了缩放因子 dk\sqrt{d_k},这直接导致了Transformer中缩放点积注意力的诞生。

早期课程中,注意力机制可能被简化为一个黑盒。现在我们根据原始论文和现代框架(如PyTorch)的常见实现,清晰地拆解了其计算图。

📝 动手练一练

问题:在加性注意力中,如果将 tanh 激活函数移除,即评分函数变为 etj=va⊤(Wast−1+Uahj)e_{tj} = \mathbf{v}_a^\top (\mathbf{W}_a s_{t-1} + \mathbf{U}_a h_j),那么注意力机制会退化成什么形式?请从模型表达能力的角度分析可能的影响。

👉 点击查看参考答案

思路:移除 tanh 后,评分函数变成 etj=va⊤Wast−1+va⊤Uahje_{tj} = \mathbf{v}_a^\top \mathbf{W}_a s_{t-1} + \mathbf{v}_a^\top \mathbf{U}_a h_j——它是关于 st−1s_{t-1} 与 hjh_j 的线性(加性)形式,并没有出现 s⊤Mhs^\top M h 这样的交叉项,所以严格说不上「双线性」。更关键的是:第一项 va⊤Wast−1\mathbf{v}_a^\top \mathbf{W}_a s_{t-1} 与 jj 无关,在 softmax 归一化时会被整体约去,于是权重只剩下 va⊤Uahj\mathbf{v}_a^\top \mathbf{U}_a h_j 的贡献——完全不随解码步变化。也就是说,注意力退化成静态对齐:无论生成第几个词,关注的源位置分布都一样。

可能的影响:

  1. 表达能力下降:非线性 tanh 能够帮助模型捕捉更复杂的、非单调的匹配关系(例如,某些组合特征才重要)。移除后,模型可能难以学习到更精细的对齐模式。
  2. 可能退化为简单线性模型:在某些情况下,如果数据中的对齐关系本身近似线性,影响可能不大。但对于复杂的序列到序列任务(如翻译),非线性通常是必要的。
  3. 实现上:只需把 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(即 st−1s_{t-1})来计算当前步的注意力权重和上下文向量 ctc_t。

本章小结

  1. 动机:Bahdanau 注意力旨在解决RNN编码器-解码器中固定长度上下文向量的信息瓶颈问题,使解码能动态聚焦于源序列的不同部分。
  2. 核心计算:包含三步:a) 用加性网络(tanh(Ws + Uh))计算对齐分数;b) 沿源序列维度做 softmax 得到注意力权重;c) 对编码器状态加权求和得到上下文向量。
  3. 形状流:编码器状态 (B, L, D_enc) + 解码器状态 (B, D_dec) → 注意力分数 (B, L) → 权重 (B, L) → 上下文向量 (B, D_enc)。
  4. 融入解码:上下文向量与上一目标词嵌入拼接,作为解码器RNN的输入,其输出再与上下文向量拼接用于最终预测。
  5. 历史地位:加性注意力是首个成功将注意力机制应用于神经机器翻译的工作,为后续更高效的点积及缩放点积注意力奠定了基础。

📋 行动清单

  • 运行本节所有代码块,确保理解每一步张量形状的变化。
  • 尝试修改 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 为准。

  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
  2. 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

—— 小象教研组