查看全课大纲(第 15 / 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.推理部署与成本:量化/蒸馏概览
RNN 与 BPTT
RNN 如何捕捉序列信息?—— 循环神经网络与 BPTT 算法详解
小象实战讲义 · 深度学习基础:从神经网络到 Transformer
在前几节中,我们学习了处理空间结构数据(如图像)的卷积神经网络(CNN)。然而,现实世界中大量数据具有时间或顺序依赖关系,例如视频帧、自然语言句子、股票价格序列等。CNN 难以有效捕捉这种跨时间的依赖。本节我们将学习循环神经网络(RNN),一种专为序列数据设计的网络结构,并深入剖析其核心训练算法——沿时间反向传播(BPTT)。学完本节,你将能回答:RNN 如何通过“记忆”处理序列?为什么训练 RNN 会遇到梯度消失/爆炸?BPTT 算法具体如何工作?
💡 核心导读
- RNN 的核心机制:通过引入一个“隐藏状态”,该状态在每个时间步更新,从而将过去的信息传递到未来,实现对序列的“记忆”。
- 时间展开与权重共享:RNN 可以沿时间轴展开成一个深度前馈网络,但不同时间步共享同一套参数(U, V, W),极大减少了参数量。
- BPTT 算法:RNN 训练的本质是计算损失对共享参数的梯度。BPTT 将时间展开的网络视为一个深度网络,从最后一个时间步开始,沿时间轴反向传播误差,并累加各时间步对参数的梯度。
- 梯度消失/爆炸的根源:在 BPTT 中,梯度需要连续乘以循环权重矩阵
W的转置。当W的谱范数(最大奇异值)大于 1 时,每反向一步梯度就被放大一次;小于 1 则被缩小一次,长期累积就表现为指数级的爆炸或衰减。 - RNN 的多种变体:根据输入和输出的对应关系,RNN 可以灵活应用于多种任务,如序列分类(Many-to-One)、序列生成(One-to-Many)和序列到序列转换(Many-to-Many)。
🧭 前置知识
- 前馈神经网络的前向与反向传播:了解多层感知机(MLP)如何计算输出和梯度。
- 矩阵乘法与形状:熟悉
(batch, dim)形状的张量运算。 - 链式法则:理解复合函数求导的基本原理。
- Softmax 与交叉熵损失:了解分类任务中常用的损失函数。(不展开,详见站内数学基础课)
从静态到动态:RNN 的基本结构
前馈神经网络(如 MLP、CNN)的信息流是单向的,从输入层到输出层,层内或跨层没有循环连接。这种结构适合处理独立同分布的静态数据点。
循环神经网络则引入了循环连接,使得网络单元的输出不仅取决于当前输入,还取决于上一时刻自身的状态。这相当于为网络赋予了“记忆”能力。
一个最简单的 RNN 单元(或称 Elman RNN)在一个时间步 t 的计算如下:
符号说明:
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(如交叉熵)之和:
沿时间反向传播 (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} 有两个来源:
- 来自当前时刻的输出
o_t(通过V)。 - 来自下一时刻的隐藏状态
h_{t+1}(通过W)。
因此,\delta_{h_t} 的递归计算公式为:
其中 \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 的梯度为所有时间步贡献的累加:
梯度消失/爆炸的直观解释: 观察 \delta_{h_t} 的递归公式,其中包含一项 \mathbf{W}^\top \delta_{h_{t+1}}。当我们需要计算很早时间步(如 t=1)的梯度 \delta_{h_1} 时,它需要通过 \mathbf{W}^\top 被连续乘上 T-1 次:
如果循环权重 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 有几种经典的应用模式:
- One-to-One:单输入单输出,等同于标准的前馈网络。
- One-to-Many:单输入,序列输出。例如,图像描述生成(输入一张图像,输出描述语句)。
- Many-to-One:序列输入,单输出。例如,情感分析(输入一个句子,输出积极/消极情感)。
- Many-to-Many (同步):每个时间步都有输入和输出,且输入输出长度相同。例如,视频帧分类(每帧打标签)。
- 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)。 |
本章小结
- RNN 通过循环连接和隐藏状态来捕捉序列中的时间依赖关系,隐藏状态是网络记忆的载体。
- 处理序列时,RNN 沿时间轴展开,形成深度网络,并通过权重共享(参数
U, W, V跨时间步共享)来高效处理变长序列。 - BPTT 算法是训练 RNN 的核心,它通过沿时间轴反向传播误差来计算共享参数的梯度,梯度计算涉及跨时间步的链式求导。
- 基本 RNN 在训练中存在梯度消失/爆炸的固有问题,根源在于梯度在时间轴上反向传播时需要连续乘以循环权重矩阵
W(或其转置)。 - 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 为准。
On the difficulty of training Recurrent Neural Networks(Pascanu, Mikolov & Bengio, 2013, ICML, arXiv:1211.5063)—— 系统分析 RNN 的梯度消失/爆炸与梯度裁剪 - https://arxiv.org/abs/1211.5063Long Short-Term Memory(Hochreiter & Schmidhuber, 1997, Neural Computation 9(8))—— LSTM 原始论文,解决长程依赖的奠基工作 - https://doi.org/10.1162/neco.1997.9.8.1735- PyTorch 官方文档
torch.nn.RNN(2.14.1,含batch_first与输入形状说明) - https://pytorch.org/docs/stable/generated/torch.nn.RNN.html
—— 小象教研组