查看全课大纲(第 15 / 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.推理部署与成本:量化/蒸馏概览

RNN 与 BPTT

RNN 如何捕捉序列信息?—— 循环神经网络与 BPTT 算法详解

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

在前几节中,我们学习了处理空间结构数据(如图像)的卷积神经网络(CNN)。然而,现实世界中大量数据具有时间或顺序依赖关系,例如视频帧、自然语言句子、股票价格序列等。CNN 难以有效捕捉这种跨时间的依赖。本节我们将学习循环神经网络(RNN),一种专为序列数据设计的网络结构,并深入剖析其核心训练算法——沿时间反向传播(BPTT)。学完本节,你将能回答:RNN 如何通过“记忆”处理序列?为什么训练 RNN 会遇到梯度消失/爆炸?BPTT 算法具体如何工作?

💡 核心导读

  1. RNN 的核心机制:通过引入一个“隐藏状态”,该状态在每个时间步更新,从而将过去的信息传递到未来,实现对序列的“记忆”。
  2. 时间展开与权重共享:RNN 可以沿时间轴展开成一个深度前馈网络,但不同时间步共享同一套参数(U, V, W),极大减少了参数量。
  3. BPTT 算法:RNN 训练的本质是计算损失对共享参数的梯度。BPTT 将时间展开的网络视为一个深度网络,从最后一个时间步开始,沿时间轴反向传播误差,并累加各时间步对参数的梯度。
  4. 梯度消失/爆炸的根源:在 BPTT 中,梯度需要连续乘以循环权重矩阵 W 的转置。当 W 的谱范数(最大奇异值)大于 1 时,每反向一步梯度就被放大一次;小于 1 则被缩小一次,长期累积就表现为指数级的爆炸或衰减。
  5. RNN 的多种变体:根据输入和输出的对应关系,RNN 可以灵活应用于多种任务,如序列分类(Many-to-One)、序列生成(One-to-Many)和序列到序列转换(Many-to-Many)。

🧭 前置知识

  1. 前馈神经网络的前向与反向传播:了解多层感知机(MLP)如何计算输出和梯度。
  2. 矩阵乘法与形状:熟悉 (batch, dim) 形状的张量运算。
  3. 链式法则:理解复合函数求导的基本原理。
  4. Softmax 与交叉熵损失:了解分类任务中常用的损失函数。(不展开,详见站内数学基础课)

从静态到动态:RNN 的基本结构

前馈神经网络(如 MLP、CNN)的信息流是单向的,从输入层到输出层,层内或跨层没有循环连接。这种结构适合处理独立同分布的静态数据点。

循环神经网络则引入了循环连接,使得网络单元的输出不仅取决于当前输入,还取决于上一时刻自身的状态。这相当于为网络赋予了“记忆”能力。

一个最简单的 RNN 单元(或称 Elman RNN)在一个时间步 t 的计算如下:

at=b+Wht−1+Uxtht=tanh⁡(at)ot=c+Vhty^t=softmax(ot)\begin{aligned} \mathbf{a}_t &= \mathbf{b} + \mathbf{W} \mathbf{h}_{t-1} + \mathbf{U} \mathbf{x}_t \\ \mathbf{h}_t &= \tanh(\mathbf{a}_t) \\ \mathbf{o}_t &= \mathbf{c} + \mathbf{V} \mathbf{h}_t \\ \hat{\mathbf{y}}_t &= \text{softmax}(\mathbf{o}_t) \end{aligned}

符号说明:

  • x_t: 时间步 t 的输入向量,形状为 (input_dim,)。
  • h_t: 时间步 t 的隐藏状态向量,形状为 (hidden_dim,),是网络的“记忆”。
  • o_t: 时间步 t 的输出 logits(未归一化分数),形状为 (output_dim,)。
  • U: 输入到隐藏层的权重矩阵,形状 (hidden_dim, input_dim)。
  • W: 隐藏层到隐藏层的循环权重矩阵,形状 (hidden_dim, hidden_dim)。
  • V: 隐藏层到输出层的权重矩阵,形状 (output_dim, hidden_dim)。
  • b, c: 偏置向量。
  • tanh: 双曲正切激活函数,将值压缩到 (-1, 1) 区间。

关键洞察:h_t 是核心。它由上一时刻状态 h_{t-1} 和当前输入 x_t 共同决定,像一个不断更新的“上下文摘要”。

让我们用代码实现一个时间步的前向计算,并观察形状变化。

import torch
torch.manual_seed(0)

# 定义模型维度
input_dim = 3
hidden_dim = 4
output_dim = 2

# 初始化参数(模拟训练中的可学习参数)
U = torch.randn(hidden_dim, input_dim) * 0.1
W = torch.randn(hidden_dim, hidden_dim) * 0.1
V = torch.randn(output_dim, hidden_dim) * 0.1
b = torch.zeros(hidden_dim)
c = torch.zeros(output_dim)

# 模拟一个时间步的数据
x_t = torch.randn(input_dim)  # 当前输入
h_prev = torch.zeros(hidden_dim) # 初始隐藏状态(通常为0)

# 前向计算
a_t = b + W @ h_prev + U @ x_t
h_t = torch.tanh(a_t)
o_t = c + V @ h_t
y_hat_t = torch.softmax(o_t, dim=0)

print("输入 x_t 形状:", x_t.shape)
print("上一状态 h_prev 形状:", h_prev.shape)
print("当前状态 h_t 形状:", h_t.shape)
print("输出 logits o_t 形状:", o_t.shape)
print("预测概率 y_hat_t 形状:", y_hat_t.shape)
print("y_hat_t 值 (和为1):", [round(v.item(), 4) for v in y_hat_t])
输入 x_t 形状: torch.Size([3])
上一状态 h_prev 形状: torch.Size([4])
当前状态 h_t 形状: torch.Size([4])
输出 logits o_t 形状: torch.Size([2])
预测概率 y_hat_t 形状: torch.Size([2])
y_hat_t 值 (和为1): [0.4962, 0.5038]

时间展开、权重共享与 BPTT

沿时间轴展开

处理一个长度为 T 的序列时,我们可以将 RNN 按时间步展开,形成一个 深度为 T 的前馈网络。每个时间步都使用相同的参数 U, W, V, b, c。这就是权重共享,它使 RNN 能够处理任意长度的序列,同时极大减少了参数量。

时间展开示意图 (T=3):
         h0 (初始状态,如全0)
          |
时间步 t=1: x1 -> [RNN Cell] -> h1 -> o1 -> y_hat1
                      | (W)
时间步 t=2: x2 -> [RNN Cell] -> h2 -> o2 -> y_hat2
                      | (W)
时间步 t=3: x3 -> [RNN Cell] -> h3 -> o3 -> y_hat3

所有 [RNN Cell] 共享参数 U, W, V, b, c。

损失函数

对于序列分类或预测任务,每个时间步可能都有一个目标 y_t(如语言模型预测下一个词)。总损失 L 通常是各时间步损失 L_t(如交叉熵)之和:

L=∑t=1TLt=∑t=1TLoss(y^t,yt)L = \sum_{t=1}^{T} L_t = \sum_{t=1}^{T} \text{Loss}(\hat{\mathbf{y}}_t, \mathbf{y}_t)

沿时间反向传播 (BPTT)

训练 RNN 就是最小化总损失 L。我们需要计算损失 L 对共享参数 U, W, V, b, c 的梯度。BPTT 算法的核心思想是:将展开的 RNN 视为一个深度前馈网络,然后应用反向传播算法,但梯度需要沿着时间轴反向流动并累加。

具体来说,参数 W 出现在每个时间步。损失 L 对 W 的梯度,等于每个时间步的损失 L_t 对 W 的梯度之和。而计算 L_t 对 W 的梯度时,由于 W 通过链式法则影响了从当前步 t 回溯到第一步的所有隐藏状态,因此梯度计算涉及跨时间步的连乘。

梯度计算的关键公式(以对 W 的梯度为例,简化示意):

假设我们使用 tanh 激活,\delta_{h_t} = \frac{\partial L}{\partial \mathbf{h}_t} 表示损失对 t 时刻隐藏状态的梯度。根据链式法则,\delta_{h_t} 有两个来源:

  1. 来自当前时刻的输出 o_t(通过 V)。
  2. 来自下一时刻的隐藏状态 h_{t+1}(通过 W)。

因此,\delta_{h_t} 的递归计算公式为:

δht=V⊤δot+W⊤[δht+1⊙(1−ht+1⊙ht+1)]\delta_{h_t} = \mathbf{V}^\top \delta_{o_t} + \mathbf{W}^\top \left[ \delta_{h_{t+1}} \odot (1 - \mathbf{h}_{t+1} \odot \mathbf{h}_{t+1}) \right]

其中 \odot 表示逐元素乘法,(1 - \mathbf{h}_{t+1} \odot \mathbf{h}_{t+1}) 是 t+1 时刻 tanh 激活的导数项(注意它作用在 t+1,因为梯度要先穿过下一时刻的激活再经 W 回到 h_t)。\delta_{o_t} = \frac{\partial L}{\partial \mathbf{o}_t} 通常可以直接从损失函数得到(例如,对于交叉熵损失,\delta_{o_t} = \hat{\mathbf{y}}_t - \mathbf{y}_t)。

最终,损失 L 对 W 的梯度为所有时间步贡献的累加:

∂L∂W=∑t=1T[δht⊙(1−ht⊙ht)]ht−1⊤\frac{\partial L}{\partial \mathbf{W}} = \sum_{t=1}^{T} \left[ \delta_{h_t} \odot (1 - \mathbf{h}_t \odot \mathbf{h}_t) \right] \mathbf{h}_{t-1}^\top

梯度消失/爆炸的直观解释: 观察 \delta_{h_t} 的递归公式,其中包含一项 \mathbf{W}^\top \delta_{h_{t+1}}。当我们需要计算很早时间步(如 t=1)的梯度 \delta_{h_1} 时,它需要通过 \mathbf{W}^\top 被连续乘上 T-1 次:

δh1∝(W⊤)T−1δhT\delta_{h_1} \propto (\mathbf{W}^\top)^{T-1} \delta_{h_T}

如果循环权重 W 的谱范数(最大奇异值)大于 1,每反向一步梯度就被放大一次,连续相乘导致梯度指数级增长(梯度爆炸);如果小于 1,梯度会指数级衰减到接近 0(梯度消失)。梯度消失使得网络难以学习长距离的依赖关系。

严格地说,长期指数增长率由 W 的谱半径(最大特征值模)决定;对可对角化的矩阵,它与谱范数同量级。下面的代码用「每步乘 W^T 后的范数」做直观演示,所以打印的是谱范数(最大奇异值)。

下面的代码模拟了梯度在时间步之间反向传播时,由于 W 的谱范数导致的变化。

import torch
torch.manual_seed(0)

hidden_dim = 5
steps = 10  # 反向传播步数(模拟序列长度)

# 模拟一个谱半径略小于1的权重矩阵 W
W = torch.randn(hidden_dim, hidden_dim) * 0.2
# 计算其最大奇异值(近似谱范数)
U, S, Vh = torch.linalg.svd(W)
print(f"权重矩阵 W 的谱范数(最大奇异值): {S[0]:.4f}")

# 模拟初始梯度(来自损失对最终状态 h_T 的梯度)
grad_h = torch.ones(hidden_dim)
grad_history = [grad_h.norm().item()]

# 模拟沿时间反向传播 steps 步
for i in range(steps):
    # 每一步梯度都左乘 W^T (这里简化,忽略激活函数导数)
    grad_h = W.T @ grad_h
    grad_history.append(grad_h.norm().item())

print(f"\n梯度范数随反向传播步数的变化:")
for i, norm in enumerate(grad_history):
    print(f"  步 {i}: {norm:.6f}")
权重矩阵 W 的谱范数(最大奇异值): 0.7250

梯度范数随反向传播步数的变化:
  步 0: 2.236068
  步 1: 0.660079
  步 2: 0.325208
  步 3: 0.138340
  步 4: 0.048631
  步 5: 0.017096
  步 6: 0.007358
  步 7: 0.003196
  步 8: 0.001218
  步 9: 0.000549
  步 10: 0.000253

可以看到,由于 W 的谱范数约为 0.7 < 1,梯度范数随着反向传播步数增加而指数衰减,这就是梯度消失的直观体现。如果 W 的谱范数大于1,我们将看到梯度爆炸。

RNN 的变体与应用模式

根据输入序列和输出序列的对应关系,RNN 有几种经典的应用模式:

  1. One-to-One:单输入单输出,等同于标准的前馈网络。
  2. One-to-Many:单输入,序列输出。例如,图像描述生成(输入一张图像,输出描述语句)。
  3. Many-to-One:序列输入,单输出。例如,情感分析(输入一个句子,输出积极/消极情感)。
  4. Many-to-Many (同步):每个时间步都有输入和输出,且输入输出长度相同。例如,视频帧分类(每帧打标签)。
  5. Many-to-Many (异步):编码器-解码器结构。编码器将整个输入序列编码成一个上下文向量,解码器再基于该向量生成输出序列。例如,机器翻译。

以下代码演示了 Many-to-One 和 One-to-Many 模式的思想。

import torch
torch.manual_seed(0)

hidden_dim = 3
input_dim = 2

# Many-to-One: 仅使用最后时刻的隐藏状态作为整个序列的表示
print("=== Many-to-One 模式示例 ===")
U = torch.randn(hidden_dim, input_dim) * 0.1
W = torch.randn(hidden_dim, hidden_dim) * 0.1
x_seq = torch.randn(1, 5, input_dim)  # 一个长度为5的序列
h0 = torch.zeros(1, hidden_dim)

# 手动循环处理序列
h = h0
for t in range(x_seq.shape[1]):
    x_t = x_seq[:, t, :]  # 取出第t个时间步的输入
    h = torch.tanh(h @ W.T + x_t @ U.T)
print(f"输入序列形状: {tuple(x_seq.shape)}")
print(f"最终隐藏状态形状 (序列表示): {tuple(h.shape)}")
print(f"最终隐藏状态值:\n {[[round(v,4) for v in row] for row in h.tolist()]}")

# One-to-Many: 从单个输入生成序列
print("\n=== One-to-Many 模式示例 (思想) ===")
# 假设初始输入 x0 是一个特征向量(例如,图像特征)
x0 = torch.randn(1, input_dim)
# 第一个输出基于 x0
h_start = torch.tanh(x0 @ U.T)
# 后续步骤可以将前一步的输出(或一个特殊标记)作为输入,并依赖循环连接 W 来生成序列。
# 这里省略具体生成循环,仅说明状态传递。
print(f"初始输入形状: {tuple(x0.shape)}")
print(f"生成的第一个隐藏状态形状: {tuple(h_start.shape)}")
=== Many-to-One 模式示例 ===
输入序列形状: (1, 5, 2)
最终隐藏状态形状 (序列表示): (1, 3)
最终隐藏状态值:
 [[0.1162, -0.1534, -0.1319]]

=== One-to-Many 模式示例 (思想) ===
初始输入形状: (1, 2)
生成的第一个隐藏状态形状: (1, 3)

📝 动手练一练

问题:假设我们有一个用于情感分析的 Many-to-One RNN,输入序列长度为 4,input_dim=3, hidden_dim=4。请手动模拟前向传播过程(两个时间步),计算 h1 和 h2,并验证其形状。使用 tanh 激活。

👉 点击查看参考答案

思路:按照公式 h_t = tanh(W @ h_{t-1} + U @ x_t + b) 逐步计算。注意初始状态 h0 通常设为全零向量。

import torch
torch.manual_seed(0)

input_dim = 3
hidden_dim = 4
seq_len = 2  # 我们先计算前两步

# 初始化参数(小随机数)
U = torch.randn(hidden_dim, input_dim) * 0.1
W = torch.randn(hidden_dim, hidden_dim) * 0.1
b = torch.zeros(hidden_dim)

# 构造输入序列 (batch_size=1, seq_len, input_dim)
x_seq = torch.randn(1, seq_len, input_dim)
x1 = x_seq[0, 0]  # 第一步输入
x2 = x_seq[0, 1]  # 第二步输入

# 初始状态
h0 = torch.zeros(hidden_dim)

# 时间步 1
a1 = b + W @ h0 + U @ x1
h1 = torch.tanh(a1)
print(f"h1 形状: {tuple(h1.shape)}")
print(f"h1 值: {[round(v.item(), 4) for v in h1]}")

# 时间步 2
a2 = b + W @ h1 + U @ x2
h2 = torch.tanh(a2)
print(f"\nh2 形状: {tuple(h2.shape)}")
print(f"h2 值: {[round(v.item(), 4) for v in h2]}")
h1 形状: (4,)
h1 值: [-0.1882, -0.2621, 0.0693, -0.0845]

h2 形状: (4,)
h2 值: [-0.1572, -0.1777, -0.0805, -0.0068]

可以看到,隐藏状态 h_t 的形状始终是 (hidden_dim,),它携带了截至当前时间步的序列信息。

常见错误

错误理解或写法现象或报错原因正确做法
在 BPTT 中,对每个时间步的 W 梯度独立更新。模型无法学习,或学习不稳定。忽略了 W 是共享参数,其梯度应是所有时间步贡献的和。在反向传播时,累积所有时间步对 W 的梯度,再进行一次参数更新。
使用 RNN 处理超长序列(如长度>100)而不做任何处理。梯度消失,模型无法建立长距离依赖;或梯度爆炸,训练出现 NaN。基本 RNN 受限于梯度消失/爆炸问题。1. 使用梯度裁剪(torch.nn.utils.clip_grad_norm_)应对梯度爆炸。
2. 对于长序列依赖,使用更高级的结构如 LSTM 或 GRU(下节内容)。
忘记对隐藏状态 h 进行初始化,或错误地复用批次间的隐藏状态。训练结果不一致或效果差。未初始化的 h 包含随机值,破坏了序列开始的确定性。批次间的 h 不应混用。在每个序列开始时,将 h0 初始化为零向量(torch.zeros(batch, hidden_dim))。对于批次训练,确保每个样本有独立的 h 序列。
把 (batch, seq_len, feature) 的数据直接送进默认配置的 RNN 层。形状不匹配报错,或模型把 batch 当成了时间步、学到错误的时间关系。PyTorch 的 nn.RNN / nn.LSTM / nn.GRU 默认 batch_first=False,期望输入形状是 (seq_len, batch, feature)。构造层时传 batch_first=True,或用 tensor.transpose(0, 1) 把维度顺序换成 (seq_len, batch, feature)。

本章小结

  1. RNN 通过循环连接和隐藏状态来捕捉序列中的时间依赖关系,隐藏状态是网络记忆的载体。
  2. 处理序列时,RNN 沿时间轴展开,形成深度网络,并通过权重共享(参数 U, W, V 跨时间步共享)来高效处理变长序列。
  3. BPTT 算法是训练 RNN 的核心,它通过沿时间轴反向传播误差来计算共享参数的梯度,梯度计算涉及跨时间步的链式求导。
  4. 基本 RNN 在训练中存在梯度消失/爆炸的固有问题,根源在于梯度在时间轴上反向传播时需要连续乘以循环权重矩阵 W(或其转置)。
  5. RNN 可根据输入输出关系分为多种应用模式,如 Many-to-One(序列分类)、One-to-Many(序列生成)和 Many-to-Many(序列转换)。

📋 行动清单

  • 运行并理解本节第一个代码块,明确 RNN 单步计算中每个张量的形状 (input_dim,), (hidden_dim,), (output_dim,)。
  • 修改“梯度消失/爆炸的直观解释”代码块中的 W 初始化(例如将 * 0.2 改为 * 1.5),重新运行,观察梯度范数是否呈现指数增长(爆炸趋势)。
  • 思考一个实际任务(如根据前三个单词预测第四个单词),并画出对应的 RNN 时间展开图(T=3),标出 x_t, h_t, o_t, y_t。

延伸阅读

本段 PyTorch 官方文档/教程条目的框架出处:PyTorch: An Imperative Style, High-Performance Deep Learning Library(Paszke et al., 2019,arXiv:1912.01703);页面内容以 PyTorch 2.14.1 为准。

  1. On the difficulty of training Recurrent Neural Networks(Pascanu, Mikolov & Bengio, 2013, ICML, arXiv:1211.5063)—— 系统分析 RNN 的梯度消失/爆炸与梯度裁剪 - https://arxiv.org/abs/1211.5063
  2. Long Short-Term Memory(Hochreiter & Schmidhuber, 1997, Neural Computation 9(8))—— LSTM 原始论文,解决长程依赖的奠基工作 - https://doi.org/10.1162/neco.1997.9.8.1735
  3. PyTorch 官方文档 torch.nn.RNN(2.14.1,含 batch_first 与输入形状说明) - https://pytorch.org/docs/stable/generated/torch.nn.RNN.html

—— 小象教研组