循环神经网络(RNN)深度学习笔记
目录
1. 动机:为什么需要RNN
1.1 问题背景
在现实世界中,我们经常遇到序列数据:
- 自然语言处理:一句话是单词的序列,前面的词会影响后面词的理解
- 时间序列预测:股票价格、天气变化、传感器读数
- 语音识别:音频信号是时间序列
- 视频分析:视频是图像帧的序列
- 音乐生成:音符的序列构成旋律
核心问题 :传统的前馈神经网络(Feedforward Neural Network)有一个致命缺陷------无法处理可变长度的序列,且无法保留历史信息。
1.2 具体场景分析
场景1:情感分析
输入: "这部电影的前半部分很无聊,但结局出人意料地精彩"
期望输出: 正面评价
如果使用传统神经网络:
- 需要固定输入长度
- "无聊"和"精彩"会被独立处理,无法理解"但"的转折关系
- 无法捕捉词序信息
场景2:机器翻译
输入: "I love deep learning"
期望输出: "我爱深度学习"
挑战:
- 不同语言的词序可能不同
- 需要理解整个句子的语境
- 输入和输出长度可能不同
1.3 RNN的核心思想
RNN通过引入"记忆"机制解决上述问题:
当前时刻的输出 = f(当前输入, 过去的记忆)
关键特性:
- 参数共享:处理每个时间步使用相同的参数
- 循环连接:隐藏状态从一个时间步传递到下一个时间步
- 可变长度:可以处理任意长度的序列
类比:RNN就像一个人在阅读句子,每读一个词,都会根据之前读到的内容(记忆)来理解当前这个词。
2. 数学基础
2.1 RNN的基本结构
2.1.1 核心公式
对于时间步 t t t,RNN的计算过程如下:
h t = tanh ( W h h h t − 1 + W x h x t + b h ) y t = W h y h t + b y \begin{aligned} h_t &= \tanh(W_{hh} h_{t-1} + W_{xh} x_t + b_h) \\ y_t &= W_{hy} h_t + b_y \end{aligned} htyt=tanh(Whhht−1+Wxhxt+bh)=Whyht+by
其中:
- x t ∈ R d x_t \in \mathbb{R}^{d} xt∈Rd:时间步 t t t 的输入向量(维度为 d d d)
- h t ∈ R h h_t \in \mathbb{R}^{h} ht∈Rh:时间步 t t t 的隐藏状态(维度为 h h h)
- h t − 1 ∈ R h h_{t-1} \in \mathbb{R}^{h} ht−1∈Rh:上一时间步的隐藏状态
- y t ∈ R o y_t \in \mathbb{R}^{o} yt∈Ro:时间步 t t t 的输出(维度为 o o o)
- W x h ∈ R h × d W_{xh} \in \mathbb{R}^{h \times d} Wxh∈Rh×d:输入到隐藏层的权重矩阵
- W h h ∈ R h × h W_{hh} \in \mathbb{R}^{h \times h} Whh∈Rh×h:隐藏层到隐藏层的权重矩阵(循环权重)
- W h y ∈ R o × h W_{hy} \in \mathbb{R}^{o \times h} Why∈Ro×h:隐藏层到输出的权重矩阵
- b h ∈ R h b_h \in \mathbb{R}^{h} bh∈Rh:隐藏层偏置
- b y ∈ R o b_y \in \mathbb{R}^{o} by∈Ro:输出层偏置
2.1.2 展开的计算图
让我们看一个具体例子,假设输入序列长度为3:
时间步: t=0 t=1 t=2
输入: x_0 → x_1 → x_2
↓ ↓ ↓
隐藏层: h_0 → h_1 → h_2
↓ ↓ ↓
输出: y_0 y_1 y_2
数学展开:
h_0 = tanh(W_hh * h_{-1} + W_xh * x_0 + b_h) # h_{-1} 通常初始化为0
h_1 = tanh(W_hh * h_0 + W_xh * x_1 + b_h)
h_2 = tanh(W_hh * h_1 + W_xh * x_2 + b_h)
y_0 = W_hy * h_0 + b_y
y_1 = W_hy * h_1 + b_y
y_2 = W_hy * h_2 + b_y
2.2 详细的维度变化分析
这是理解RNN的关键!让我们用一个具体例子追踪每一步的维度变化。
2.2.1 问题设定
假设我们有以下配置:
- 输入维度 d = 10 d = 10 d=10(例如,词嵌入维度)
- 隐藏层维度 h = 20 h = 20 h=20
- 输出维度 o = 5 o = 5 o=5(例如,5个类别的分类)
- 序列长度 T = 3 T = 3 T=3
- 批次大小 B = 2 B = 2 B=2(同时处理2个序列)
2.2.2 单个时间步的维度变化
输入数据形状:
x_t: (B, d) = (2, 10)
表示2个样本,每个样本是10维向量
权重矩阵形状:
W_xh: (h, d) = (20, 10) # 输入到隐藏层
W_hh: (h, h) = (20, 20) # 隐藏层到隐藏层(循环)
W_hy: (o, h) = (5, 20) # 隐藏层到输出
b_h: (h,) = (20,) # 隐藏层偏置
b_y: (o,) = (5,) # 输出层偏置
前一时刻隐藏状态形状:
h_{t-1}: (B, h) = (2, 20)
计算过程与维度变化:
python
# 步骤1: W_xh @ x_t^T
# (20, 10) @ (10, 2) = (20, 2)
# 转置后: (2, 20)
term1 = x_t @ W_xh.T # (2, 10) @ (10, 20) = (2, 20)
# 步骤2: W_hh @ h_{t-1}^T
# (20, 20) @ (20, 2) = (20, 2)
# 转置后: (2, 20)
term2 = h_{t-1} @ W_hh.T # (2, 20) @ (20, 20) = (2, 20)
# 步骤3: 相加并加偏置
# (2, 20) + (2, 20) + (20,) = (2, 20)
# 广播机制: (20,) 自动扩展为 (2, 20)
h_t = tanh(term1 + term2 + b_h) # (2, 20)
# 步骤4: 计算输出
# (2, 20) @ (20, 5) = (2, 5)
y_t = h_t @ W_hy.T + b_y # (2, 5)
总结单个时间步:
输入: x_t (2, 10)
h_{t-1} (2, 20)
↓
输出: h_t (2, 20)
y_t (2, 5)
2.2.3 完整序列的维度变化
对于整个序列(T=3个时间步):
python
# 输入序列
X: (B, T, d) = (2, 3, 10)
# 可以理解为: 2个样本,每个样本有3个时间步,每个时间步是10维向量
# 初始化隐藏状态
h_0: (B, h) = (2, 20) # 通常初始化为全0
# 时间步 t=0
x_0 = X[:, 0, :] # (2, 10) - 取所有样本的第0个时间步
h_0 = tanh(x_0 @ W_xh.T + h_{-1} @ W_hh.T + b_h) # (2, 20)
y_0 = h_0 @ W_hy.T + b_y # (2, 5)
# 时间步 t=1
x_1 = X[:, 1, :] # (2, 10) - 取所有样本的第1个时间步
h_1 = tanh(x_1 @ W_xh.T + h_0 @ W_hh.T + b_h) # (2, 20)
y_1 = h_1 @ W_hy.T + b_y # (2, 5)
# 时间步 t=2
x_2 = X[:, 2, :] # (2, 10) - 取所有样本的第2个时间步
h_2 = tanh(x_2 @ W_xh.T + h_1 @ W_hh.T + b_h) # (2, 20)
y_2 = h_2 @ W_hy.T + b_y # (2, 5)
# 收集所有输出
Y = stack([y_0, y_1, y_2], dim=1) # (2, 3, 5)
# 形状: (批次大小, 序列长度, 输出维度)
可视化维度流动:
时间维度展开:
t=0: X[:, 0, :] (2, 10) ──→ h_0 (2, 20) ──→ y_0 (2, 5)
↓
t=1: X[:, 1, :] (2, 10) ──→ h_1 (2, 20) ──→ y_1 (2, 5)
↓
t=2: X[:, 2, :] (2, 10) ──→ h_2 (2, 20) ──→ y_2 (2, 5)
最终输出: Y (2, 3, 5)
2.2.4 实际数据流示例
让我们用一个文本分类的具体例子:
任务:情感分类(正面/负面)
输入句子:
样本1: "I love this movie" → [I, love, this, movie]
样本2: "Great film" → [Great, film, <PAD>, <PAD>]
数据准备:
python
# 词汇表
vocab = {"I": 0, "love": 1, "this": 2, "movie": 3, "Great": 4, "film": 5, "<PAD>": 6}
# 转换为索引
sequence1 = [0, 1, 2, 3] # "I love this movie"
sequence2 = [4, 5, 6, 6] # "Great film <PAD> <PAD>" (填充到相同长度)
# 词嵌入层会将索引转换为向量
# 假设embedding_dim = 10
# sequence1 → (4, 10) 的矩阵
# sequence2 → (4, 10) 的矩阵
# 批次处理
X = stack([sequence1_embedded, sequence2_embedded])
# X: (2, 4, 10) # 2个样本,4个时间步,10维嵌入
RNN处理流程:
python
# 配置
B = 2 (批次大小)
T = 4 (序列长度)
d = 10 (嵌入维度)
h = 20 (隐藏层维度)
o = 2 (输出维度: 正面/负面)
# 初始隐藏状态
h_init = zeros(2, 20) # (B, h)
# 逐时间步处理
for t in range(4):
x_t = X[:, t, :] # (2, 10) - 当前时间步的输入
h_t = rnn_cell(x_t, h_prev) # (2, 20) - 更新隐藏状态
h_prev = h_t
# 最后一个隐藏状态用于分类
# h_4: (2, 20)
logits = h_4 @ W_hy.T + b_y # (2, 2)
probabilities = softmax(logits) # (2, 2)
# [[0.8, 0.2], # 样本1: 80%正面, 20%负面
# [0.9, 0.1]] # 样本2: 90%正面, 10%负面
2.3 损失函数
2.3.1 序列到序列任务(Sequence-to-Sequence)
对于每个时间步都有输出的任务(如语言模型):
L = 1 T ∑ t = 1 T L t ( y t , y ^ t ) \mathcal{L} = \frac{1}{T} \sum_{t=1}^{T} \mathcal{L}_t(y_t, \hat{y}_t) L=T1t=1∑TLt(yt,y^t)
交叉熵损失 (用于分类):
L t = − ∑ c = 1 C y t ( c ) log ( y ^ t ( c ) ) \mathcal{L}t = -\sum{c=1}^{C} y_t^{(c)} \log(\hat{y}_t^{(c)}) Lt=−c=1∑Cyt(c)log(y^t(c))
其中 C C C 是类别数, y t ( c ) y_t^{(c)} yt(c) 是真实标签的one-hot编码。
2.3.2 序列到单一输出任务(Sequence-to-One)
对于只需要最后输出的任务(如情感分类):
L = L ( y T , y ^ T ) \mathcal{L} = \mathcal{L}(y_T, \hat{y}_T) L=L(yT,y^T)
只计算最后一个时间步的损失。
2.3.3 具体例子:语言模型
任务:给定前面的词,预测下一个词
输入序列: "I love deep"
目标序列: "love deep learning"
时间步 t=0: 输入 "I" → 预测 "love"
时间步 t=1: 输入 "love" → 预测 "deep"
时间步 t=2: 输入 "deep" → 预测 "learning"
损失计算:
python
# 假设词汇表大小 V = 10000
# 每个时间步的输出是 (B, V) 的概率分布
# y_0 是 "love" 的one-hot编码
# y_1 是 "deep" 的one-hot编码
# y_2 是 "learning" 的one-hot编码
# 总损失
loss = (CrossEntropy(pred_0, y_0) +
CrossEntropy(pred_1, y_1) +
CrossEntropy(pred_2, y_2)) / 3
2.4 通过时间反向传播(BPTT)
2.4.1 基本思想
由于RNN在时间上展开,反向传播需要沿着时间链传递梯度。
前向传播(已知):
h_0 → h_1 → h_2 → ... → h_T → Loss
反向传播:
Loss → ∂L/∂h_T → ∂L/∂h_{T-1} → ... → ∂L/∂h_1 → ∂L/∂h_0
2.4.2 梯度推导
对于时间步 t t t,计算损失对隐藏状态的梯度:
∂ L ∂ h t = ∂ L ∂ y t ∂ y t ∂ h t + ∂ L ∂ h t + 1 ∂ h t + 1 ∂ h t \frac{\partial \mathcal{L}}{\partial h_t} = \frac{\partial \mathcal{L}}{\partial y_t} \frac{\partial y_t}{\partial h_t} + \frac{\partial \mathcal{L}}{\partial h_{t+1}} \frac{\partial h_{t+1}}{\partial h_t} ∂ht∂L=∂yt∂L∂ht∂yt+∂ht+1∂L∂ht∂ht+1
第一项:当前时间步的直接贡献
第二项:未来时间步的间接贡献(通过链式法则)
权重梯度累积:
∂ L ∂ W h h = ∑ t = 1 T ∂ L ∂ h t ∂ h t ∂ W h h \frac{\partial \mathcal{L}}{\partial W_{hh}} = \sum_{t=1}^{T} \frac{\partial \mathcal{L}}{\partial h_t} \frac{\partial h_t}{\partial W_{hh}} ∂Whh∂L=t=1∑T∂ht∂L∂Whh∂ht
注意:由于参数共享,每个时间步都对权重梯度有贡献。
2.4.3 梯度消失和梯度爆炸
问题的数学根源:
考虑梯度从时间步 T T T 传播到时间步 t t t:
∂ h T ∂ h t = ∏ k = t + 1 T ∂ h k ∂ h k − 1 = ∏ k = t + 1 T W h h T ⋅ diag ( tanh ′ ( a k − 1 ) ) \frac{\partial h_T}{\partial h_t} = \prod_{k=t+1}^{T} \frac{\partial h_k}{\partial h_{k-1}} = \prod_{k=t+1}^{T} W_{hh}^T \cdot \text{diag}(\tanh'(a_{k-1})) ∂ht∂hT=k=t+1∏T∂hk−1∂hk=k=t+1∏TWhhT⋅diag(tanh′(ak−1))
其中 a k − 1 = W h h h k − 2 + W x h x k − 1 + b h a_{k-1} = W_{hh} h_{k-2} + W_{xh} x_{k-1} + b_h ak−1=Whhhk−2+Wxhxk−1+bh
梯度消失:
- 如果 ∣ W h h ∣ < 1 |W_{hh}| < 1 ∣Whh∣<1 且 ∣ tanh ′ ∣ < 1 |\tanh'| < 1 ∣tanh′∣<1,连乘会导致梯度指数衰减
- 结果:长距离依赖无法学习
梯度爆炸:
- 如果 ∣ W h h ∣ > 1 |W_{hh}| > 1 ∣Whh∣>1,连乘会导致梯度指数增长
- 结果:训练不稳定,权重更新过大
直观理解:
假设每一步梯度都乘以 0.5(小于1):
1步后: 梯度 = 0.5
2步后: 梯度 = 0.25
10步后: 梯度 ≈ 0.001
50步后: 梯度 ≈ 0(几乎消失)
3. 优化算法
3.1 梯度下降及其变体
3.1.1 标准梯度下降(SGD)
更新规则 :
θ t + 1 = θ t − η ∇ θ L ( θ t ) \theta_{t+1} = \theta_t - \eta \nabla_\theta \mathcal{L}(\theta_t) θt+1=θt−η∇θL(θt)
其中 η \eta η 是学习率。
应用于RNN:
python
# 伪代码
for epoch in range(num_epochs):
for batch in dataloader:
# 前向传播
outputs = rnn(batch.inputs)
loss = criterion(outputs, batch.targets)
# 反向传播(BPTT)
loss.backward()
# 参数更新
for param in rnn.parameters():
param.data -= learning_rate * param.grad
# 清零梯度
rnn.zero_grad()
3.1.2 梯度裁剪(Gradient Clipping)
目的:防止梯度爆炸
方法1:按值裁剪
g clipped = max ( min ( g , clip_value ) , − clip_value ) g_{\text{clipped}} = \max(\min(g, \text{clip\_value}), -\text{clip\_value}) gclipped=max(min(g,clip_value),−clip_value)
方法2:按范数裁剪 (更常用)
g clipped = { clip_norm ∥ g ∥ g if ∥ g ∥ > clip_norm g otherwise g_{\text{clipped}} = \begin{cases} \frac{\text{clip\_norm}}{\|g\|} g & \text{if } \|g\| > \text{clip\_norm} \\ g & \text{otherwise} \end{cases} gclipped={∥g∥clip_normggif ∥g∥>clip_normotherwise
PyTorch实现:
python
import torch.nn.utils as nn_utils
# 前向和反向传播
loss.backward()
# 梯度裁剪
nn_utils.clip_grad_norm_(rnn.parameters(), max_norm=5.0)
# 参数更新
optimizer.step()
为什么有效:
原始梯度: [100, -200, 50] → 范数 ≈ 229
裁剪到 max_norm=5:
新梯度: [2.18, -4.36, 1.09] → 范数 = 5
保持了梯度方向,但限制了幅度
3.1.3 Adam优化器
核心思想:
- 动量(Momentum):利用历史梯度信息
- 自适应学习率:每个参数有独立的学习率
数学公式:
m t = β 1 m t − 1 + ( 1 − β 1 ) g t v t = β 2 v t − 1 + ( 1 − β 2 ) g t 2 m ^ t = m t 1 − β 1 t v ^ t = v t 1 − β 2 t θ t + 1 = θ t − η v ^ t + ϵ m ^ t \begin{aligned} m_t &= \beta_1 m_{t-1} + (1 - \beta_1) g_t \\ v_t &= \beta_2 v_{t-1} + (1 - \beta_2) g_t^2 \\ \hat{m}_t &= \frac{m_t}{1 - \beta_1^t} \\ \hat{v}t &= \frac{v_t}{1 - \beta_2^t} \\ \theta{t+1} &= \theta_t - \frac{\eta}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t \end{aligned} mtvtm^tv^tθt+1=β1mt−1+(1−β1)gt=β2vt−1+(1−β2)gt2=1−β1tmt=1−β2tvt=θt−v^t +ϵηm^t
其中:
- m t m_t mt:一阶矩估计(梯度的移动平均)
- v t v_t vt:二阶矩估计(梯度平方的移动平均)
- β 1 = 0.9 \beta_1 = 0.9 β1=0.9, β 2 = 0.999 \beta_2 = 0.999 β2=0.999(默认值)
- ϵ = 1 0 − 8 \epsilon = 10^{-8} ϵ=10−8(数值稳定性)
PyTorch实现:
python
optimizer = torch.optim.Adam(rnn.parameters(), lr=0.001)
for epoch in range(num_epochs):
for batch in dataloader:
optimizer.zero_grad()
outputs = rnn(batch.inputs)
loss = criterion(outputs, batch.targets)
loss.backward()
optimizer.step() # Adam自动处理参数更新
为什么Adam适合RNN:
- 自适应学习率能应对RNN中不同参数的不同梯度规模
- 动量机制有助于穿越平坦区域
- 对学习率不太敏感
3.2 截断BPTT(Truncated BPTT)
问题:对于很长的序列,BPTT计算成本太高
解决方案:将长序列截断成小块
算法:
python
seq_length = 1000 # 原始序列很长
chunk_size = 50 # 截断长度
for start in range(0, seq_length, chunk_size):
end = min(start + chunk_size, seq_length)
# 只对这一块做BPTT
chunk_input = full_sequence[start:end]
chunk_target = full_targets[start:end]
# 前向传播
h = rnn(chunk_input, h_prev.detach()) # detach切断梯度流
loss = criterion(h, chunk_target)
# 反向传播(只在这个chunk内)
loss.backward()
optimizer.step()
# 保留隐藏状态用于下一个chunk(但不保留梯度)
h_prev = h.detach()
关键点:
- 隐藏状态在chunk间传递(保持序列连续性)
- 梯度不在chunk间传递(降低计算成本)
- 平衡:chunk太小损失性能,太大增加计算
4. 工程方法
4.1 高效训练技巧
4.1.1 批处理与填充
问题:不同序列长度不同,如何批处理?
解决方案:填充(Padding)+ 掩码(Masking)
python
import torch
from torch.nn.utils.rnn import pad_sequence, pack_padded_sequence, pad_packed_sequence
# 原始序列(长度不同)
sequences = [
torch.tensor([1, 2, 3, 4, 5]), # 长度 5
torch.tensor([6, 7]), # 长度 2
torch.tensor([8, 9, 10, 11]) # 长度 4
]
# 方法1: 简单填充
padded = pad_sequence(sequences, batch_first=True, padding_value=0)
# 结果:
# [[1, 2, 3, 4, 5],
# [6, 7, 0, 0, 0],
# [8, 9, 10, 11, 0]]
# 形状: (3, 5) # 3个序列,最大长度5
# 方法2: PackedSequence(更高效)
lengths = torch.tensor([5, 2, 4]) # 记录真实长度
sorted_lengths, sorted_idx = lengths.sort(descending=True)
sorted_sequences = [sequences[i] for i in sorted_idx]
padded_sorted = pad_sequence(sorted_sequences, batch_first=True)
packed = pack_padded_sequence(padded_sorted, sorted_lengths, batch_first=True)
# RNN处理
output, hidden = rnn(packed)
# 解包
unpacked, _ = pad_packed_sequence(output, batch_first=True)
为什么PackedSequence更高效:
- 避免对填充部分做无用计算
- 内存占用更少
- 训练速度更快
4.1.2 双向RNN(Bidirectional RNN)
动机:有些任务需要同时考虑过去和未来的信息
结构:
前向RNN: h_0 → h_1 → h_2 → h_3
↓ ↓ ↓
后向RNN: h_0 ← h_1 ← h_2 ← h_3
最终输出: [h_forward; h_backward] 拼接
数学公式 :
h → t = RNN forward ( x t , h → t − 1 ) h ← t = RNN backward ( x t , h ← t + 1 ) h t = [ h → t ; h ← t ] \begin{aligned} \overrightarrow{h}t &= \text{RNN}{\text{forward}}(x_t, \overrightarrow{h}{t-1}) \\ \overleftarrow{h}t &= \text{RNN}{\text{backward}}(x_t, \overleftarrow{h}{t+1}) \\ h_t &= [\overrightarrow{h}_t; \overleftarrow{h}_t] \end{aligned} h th tht=RNNforward(xt,h t−1)=RNNbackward(xt,h t+1)=[h t;h t]
维度变化:
python
# 单向RNN
input: (B, T, d) = (2, 3, 10)
hidden: (B, h) = (2, 20)
output: (B, T, h) = (2, 3, 20)
# 双向RNN
input: (B, T, d) = (2, 3, 10)
forward_hidden: (B, h) = (2, 20)
backward_hidden: (B, h) = (2, 20)
output: (B, T, 2*h) = (2, 3, 40) # 拼接后维度翻倍
PyTorch实现:
python
rnn = nn.RNN(input_size=10, hidden_size=20,
num_layers=1, bidirectional=True, batch_first=True)
# 输入: (B, T, d)
output, hidden = rnn(input)
# output: (B, T, 2*hidden_size)
# hidden: (2, B, hidden_size) # 2表示前向和后向
4.1.3 多层RNN(Stacked RNN)
结构:
第2层: h2_0 → h2_1 → h2_2
↑ ↑ ↑
第1层: h1_0 → h1_1 → h1_2
↑ ↑ ↑
输入: x_0 x_1 x_2
维度变化(2层RNN):
python
# 配置
num_layers = 2
input_size = 10
hidden_size = 20
batch_size = 2
seq_length = 3
# 第1层
layer1_input: (2, 3, 10) # (B, T, input_size)
layer1_output: (2, 3, 20) # (B, T, hidden_size)
# 第2层(第1层的输出作为输入)
layer2_input: (2, 3, 20) # 等于layer1_output
layer2_output: (2, 3, 20) # (B, T, hidden_size)
# 最终hidden state: (num_layers, B, hidden_size) = (2, 2, 20)
PyTorch实现:
python
rnn = nn.RNN(input_size=10, hidden_size=20,
num_layers=2, batch_first=True)
input = torch.randn(2, 3, 10) # (B, T, input_size)
h0 = torch.zeros(2, 2, 20) # (num_layers, B, hidden_size)
output, hidden = rnn(input, h0)
# output: (2, 3, 20) # 只输出最后一层的结果
# hidden: (2, 2, 20) # 所有层的最终隐藏状态
4.2 数值稳定性
4.2.1 权重初始化
Xavier初始化 (适用于tanh激活):
W ∼ U ( − 6 n in + n out , 6 n in + n out ) W \sim \mathcal{U}\left(-\sqrt{\frac{6}{n_{\text{in}} + n_{\text{out}}}}, \sqrt{\frac{6}{n_{\text{in}} + n_{\text{out}}}}\right) W∼U(−nin+nout6 ,nin+nout6 )
PyTorch实现:
python
for name, param in rnn.named_parameters():
if 'weight_ih' in name: # 输入到隐藏层的权重
nn.init.xavier_uniform_(param)
elif 'weight_hh' in name: # 隐藏层到隐藏层的权重
nn.init.orthogonal_(param) # 正交初始化有助于缓解梯度消失
elif 'bias' in name:
nn.init.zeros_(param)
4.2.2 Dropout正则化
在RNN中应用Dropout:
python
class RNNWithDropout(nn.Module):
def __init__(self, input_size, hidden_size, dropout=0.5):
super().__init__()
self.rnn = nn.RNN(input_size, hidden_size, batch_first=True)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
# 方法1: 在RNN输出后应用dropout
output, hidden = self.rnn(x)
output = self.dropout(output)
return output, hidden
注意:
- 不要在隐藏状态的循环连接上使用dropout
- 通常在RNN的输出上或多层RNN的层间使用dropout
4.3 硬件加速
4.3.1 GPU优化
python
import torch
# 检查GPU可用性
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 模型和数据转移到GPU
rnn = rnn.to(device)
input_data = input_data.to(device)
# cuDNN加速(PyTorch自动启用)
torch.backends.cudnn.enabled = True
torch.backends.cudnn.benchmark = True # 自动寻找最优算法
4.3.2 混合精度训练
使用FP16加速训练:
python
from torch.cuda.amp import autocast, GradScaler
scaler = GradScaler()
for batch in dataloader:
optimizer.zero_grad()
# 前向传播使用FP16
with autocast():
output = rnn(batch.input)
loss = criterion(output, batch.target)
# 反向传播时自动缩放梯度
scaler.scale(loss).backward()
# 梯度裁剪
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(rnn.parameters(), max_norm=1.0)
# 参数更新
scaler.step(optimizer)
scaler.update()
优势:
- 训练速度提升2-3倍
- 显存占用减少约50%
- 在现代GPU上效果显著
5. 批判性思维技能
5.1 RNN的局限性
5.1.1 长期依赖问题
问题描述 :
尽管理论上RNN可以捕捉长距离依赖,但实际上由于梯度消失,很难学习距离超过10-20步的依赖关系。
实验验证:
python
# 创建一个简单的复制任务
# 任务: 记住序列开始的符号,在很久之后输出
def create_copy_task(seq_length, delay):
"""
seq_length: 序列长度
delay: 需要记忆的时间步数
"""
# 输入: [3, 7, 0, 0, 0, ..., 0, 9]
# ↑ delay步 ↑
# 记住这个 在这里输出
input_seq = [random.randint(1, 8)] # 需要记住的符号
input_seq += [0] * delay # 填充
input_seq += [9] # 触发输出的信号
target_seq = [0] * delay + [input_seq[0]] # 最后才输出记住的符号
return input_seq, target_seq
# 测试标准RNN
delays = [5, 10, 20, 50, 100]
for delay in delays:
rnn = SimpleRNN(input_size=10, hidden_size=50)
accuracy = train_and_test(rnn, delay)
print(f"Delay={delay}, Accuracy={accuracy}")
# 预期结果:
# Delay=5, Accuracy=0.95 ✓ 效果好
# Delay=10, Accuracy=0.87 ✓ 还可以
# Delay=20, Accuracy=0.45 ✗ 开始失败
# Delay=50, Accuracy=0.10 ✗ 完全失败
# Delay=100, Accuracy=0.10 ✗ 完全失败
结论:标准RNN难以处理长期依赖 → 需要LSTM/GRU
5.1.2 并行化困难
问题 :
RNN的计算是串行的,时间步 t t t 必须等待时间步 t − 1 t-1 t−1 完成。
python
# RNN: 必须串行
h_0 = f(x_0, h_init)
h_1 = f(x_1, h_0) # 必须等h_0算完
h_2 = f(x_2, h_1) # 必须等h_1算完
...
# CNN或Transformer: 可以并行
# 所有位置可以同时计算
影响:
- 训练速度慢
- 无法充分利用现代GPU的并行能力
- 对长序列尤其慢
解决方向:
- Transformer架构(完全并行)
- 并行RNN变体(如Quasi-RNN)
5.2 何时使用RNN
决策树
你的任务是什么?
│
├─ 序列建模?
│ │
│ ├─ 序列很长(>100)?
│ │ ├─ 是 → 考虑Transformer
│ │ └─ 否 → 继续
│ │
│ ├─ 需要处理实时流数据?
│ │ ├─ 是 → RNN/LSTM/GRU(保持隐藏状态)
│ │ └─ 否 → 继续
│ │
│ ├─ 计算资源有限?
│ │ ├─ 是 → GRU(参数少)
│ │ └─ 否 → LSTM(效果更好)
│ │
│ └─ 需要双向信息?
│ ├─ 是 → Bidirectional RNN
│ └─ 否 → 单向RNN
│
└─ 非序列任务 → 不要用RNN,考虑CNN/Transformer
5.2.1 RNN适用场景
✓ 适合使用RNN的情况:
- 实时序列处理:语音识别、在线手写识别
- 中等长度序列:情感分析(句子级别)
- 时间序列预测:股票价格、天气预报
- 序列生成:音乐生成、文本生成
- 资源受限环境:移动设备、嵌入式系统
✗ 不适合使用RNN的情况:
- 很长序列(>500词):文档分类 → 用Transformer
- 完全并行任务:图像分类 → 用CNN
- 不关心顺序:词袋模型任务 → 用MLP
- 需要全局关注:机器翻译 → 用Transformer
5.3 调试技巧
5.3.1 检查梯度
python
# 检查梯度是否消失/爆炸
def check_gradients(model):
total_norm = 0
for name, param in model.named_parameters():
if param.grad is not None:
param_norm = param.grad.data.norm(2)
total_norm += param_norm.item() ** 2
print(f"{name}: {param_norm.item():.6f}")
total_norm = total_norm ** 0.5
print(f"Total gradient norm: {total_norm:.6f}")
if total_norm < 1e-5:
print("⚠️ 警告: 梯度消失!")
elif total_norm > 100:
print("⚠️ 警告: 梯度爆炸!")
# 使用
loss.backward()
check_gradients(rnn)
5.3.2 可视化隐藏状态
python
import matplotlib.pyplot as plt
def visualize_hidden_states(rnn, input_seq):
"""可视化RNN的隐藏状态演化"""
hidden_states = []
h = torch.zeros(1, rnn.hidden_size)
for x_t in input_seq:
h = rnn.step(x_t, h)
hidden_states.append(h.detach().numpy())
hidden_states = np.array(hidden_states).squeeze()
# 绘制热图
plt.figure(figsize=(12, 6))
plt.imshow(hidden_states.T, aspect='auto', cmap='viridis')
plt.colorbar(label='Activation')
plt.xlabel('Time Step')
plt.ylabel('Hidden Unit')
plt.title('RNN Hidden State Evolution')
plt.show()
# 观察模式:
# - 横条纹: 某些单元持续激活(好)
# - 快速变化: 响应输入(好)
# - 全白/全黑: 饱和或死亡(坏)
5.3.3 过拟合单个batch
python
# 调试技巧: 先确保模型能过拟合单个样本
single_batch = next(iter(dataloader))
for i in range(1000):
optimizer.zero_grad()
output = rnn(single_batch.input)
loss = criterion(output, single_batch.target)
loss.backward()
optimizer.step()
if i % 100 == 0:
print(f"Step {i}, Loss: {loss.item():.6f}")
# 期望: loss应该降到接近0
# 如果不能 → 模型有bug或容量不足
5.4 理论与实践的差距
5.4.1 理论能力 vs 实际表现
理论:RNN可以表示任意序列函数(图灵完备)
实践:
- 受梯度消失限制
- 需要大量数据
- 训练困难
启示:
"理论上可能 ≠ 实际上可行"
需要LSTM/GRU等改进架构
5.4.2 超参数的影响
实验:固定架构,改变超参数
python
# 测试不同隐藏层大小
hidden_sizes = [10, 20, 50, 100, 200]
results = []
for h in hidden_sizes:
rnn = SimpleRNN(input_size=50, hidden_size=h)
test_acc = train(rnn)
results.append(test_acc)
# 观察:
# h=10: underfitting (63% acc)
# h=20: still low (71% acc)
# h=50: good (87% acc) ← sweet spot
# h=100: good (88% acc)
# h=200: overfitting (85% acc) ← 太大反而下降
经验法则:
- 隐藏层大小: 通常在输入维度的1-4倍
- 学习率: 从0.001开始尝试
- 批次大小: 32-256(取决于内存)
- 梯度裁剪: 1.0-5.0
6. 附录:完整代码示例
6.1 从零实现RNN单元
python
import numpy as np
class SimpleRNNCell:
"""从零实现的RNN单元"""
def __init__(self, input_size, hidden_size):
# Xavier初始化
self.W_xh = np.random.randn(hidden_size, input_size) * np.sqrt(2.0 / input_size)
self.W_hh = np.random.randn(hidden_size, hidden_size) * np.sqrt(2.0 / hidden_size)
self.b_h = np.zeros(hidden_size)
# 输出层(假设二分类)
self.W_hy = np.random.randn(2, hidden_size) * np.sqrt(2.0 / hidden_size)
self.b_y = np.zeros(2)
def forward(self, x, h_prev):
"""
前向传播
x: (input_size,)
h_prev: (hidden_size,)
"""
# h_t = tanh(W_xh @ x + W_hh @ h_prev + b_h)
h_next = np.tanh(
self.W_xh @ x + self.W_hh @ h_prev + self.b_h
)
# y_t = W_hy @ h_t + b_y
y = self.W_hy @ h_next + self.b_y
# 保存中间值用于反向传播
self.cache = (x, h_prev, h_next)
return h_next, y
def backward(self, dh_next, dy):
"""
反向传播
dh_next: 从下一时间步传来的梯度
dy: 当前时间步输出的梯度
"""
x, h_prev, h = self.cache
# 输出层梯度
dW_hy = dy.reshape(-1, 1) @ h.reshape(1, -1)
db_y = dy
dh = self.W_hy.T @ dy + dh_next
# tanh的导数
dtanh = (1 - h ** 2) * dh
# 参数梯度
dW_xh = dtanh.reshape(-1, 1) @ x.reshape(1, -1)
dW_hh = dtanh.reshape(-1, 1) @ h_prev.reshape(1, -1)
db_h = dtanh
# 传递给前一时间步的梯度
dh_prev = self.W_hh.T @ dtanh
return dh_prev, {
'W_xh': dW_xh, 'W_hh': dW_hh, 'b_h': db_h,
'W_hy': dW_hy, 'b_y': db_y
}
# 使用示例
input_size, hidden_size = 10, 20
rnn_cell = SimpleRNNCell(input_size, hidden_size)
# 处理序列
h = np.zeros(hidden_size) # 初始隐藏状态
sequence = [np.random.randn(input_size) for _ in range(5)]
for x_t in sequence:
h, y = rnn_cell.forward(x_t, h)
print(f"Hidden state shape: {h.shape}, Output shape: {y.shape}")
6.2 PyTorch完整训练示例
python
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
# ========== 1. 定义数据集 ==========
class TextDataset(Dataset):
"""简单的文本分类数据集"""
def __init__(self, texts, labels, vocab, max_length=50):
self.texts = texts
self.labels = labels
self.vocab = vocab
self.max_length = max_length
def __len__(self):
return len(self.texts)
def __getitem__(self, idx):
text = self.texts[idx]
label = self.labels[idx]
# 文本转索引
indices = [self.vocab.get(word, 0) for word in text.split()]
# 截断或填充
if len(indices) > self.max_length:
indices = indices[:self.max_length]
else:
indices += [0] * (self.max_length - len(indices))
return torch.tensor(indices), torch.tensor(label)
# ========== 2. 定义RNN模型 ==========
class TextRNN(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, output_dim,
num_layers=1, bidirectional=False, dropout=0.5):
super(TextRNN, self).__init__()
# 词嵌入层
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
# RNN层
self.rnn = nn.RNN(
input_size=embed_dim,
hidden_size=hidden_dim,
num_layers=num_layers,
bidirectional=bidirectional,
batch_first=True,
dropout=dropout if num_layers > 1 else 0
)
# 全连接层
fc_input_dim = hidden_dim * 2 if bidirectional else hidden_dim
self.fc = nn.Linear(fc_input_dim, output_dim)
# Dropout
self.dropout = nn.Dropout(dropout)
def forward(self, text):
# text: (batch_size, seq_length)
# 嵌入: (batch_size, seq_length, embed_dim)
embedded = self.dropout(self.embedding(text))
# RNN: output (batch_size, seq_length, hidden_dim * num_directions)
# hidden (num_layers * num_directions, batch_size, hidden_dim)
output, hidden = self.rnn(embedded)
# 取最后一个时间步的输出用于分类
# 如果是双向,需要拼接前向和后向的最后隐藏状态
if self.rnn.bidirectional:
# hidden[-2, :, :] 是前向最后一层
# hidden[-1, :, :] 是后向最后一层
hidden = torch.cat([hidden[-2, :, :], hidden[-1, :, :]], dim=1)
else:
hidden = hidden[-1, :, :]
# 全连接: (batch_size, output_dim)
output = self.fc(self.dropout(hidden))
return output
# ========== 3. 训练函数 ==========
def train_epoch(model, dataloader, criterion, optimizer, device):
model.train()
total_loss = 0
correct = 0
total = 0
for batch_idx, (texts, labels) in enumerate(dataloader):
texts, labels = texts.to(device), labels.to(device)
# 前向传播
optimizer.zero_grad()
outputs = model(texts)
loss = criterion(outputs, labels)
# 反向传播
loss.backward()
# 梯度裁剪
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# 参数更新
optimizer.step()
# 统计
total_loss += loss.item()
_, predicted = outputs.max(1)
total += labels.size(0)
correct += predicted.eq(labels).sum().item()
if (batch_idx + 1) % 10 == 0:
print(f'Batch {batch_idx+1}/{len(dataloader)}, '
f'Loss: {loss.item():.4f}, '
f'Acc: {100.*correct/total:.2f}%')
return total_loss / len(dataloader), 100. * correct / total
# ========== 4. 评估函数 ==========
def evaluate(model, dataloader, criterion, device):
model.eval()
total_loss = 0
correct = 0
total = 0
with torch.no_grad():
for texts, labels in dataloader:
texts, labels = texts.to(device), labels.to(device)
outputs = model(texts)
loss = criterion(outputs, labels)
total_loss += loss.item()
_, predicted = outputs.max(1)
total += labels.size(0)
correct += predicted.eq(labels).sum().item()
return total_loss / len(dataloader), 100. * correct / total
# ========== 5. 主训练流程 ==========
def main():
# 设置设备
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')
# 超参数
VOCAB_SIZE = 10000
EMBED_DIM = 100
HIDDEN_DIM = 256
OUTPUT_DIM = 2 # 二分类
NUM_LAYERS = 2
BIDIRECTIONAL = True
DROPOUT = 0.5
BATCH_SIZE = 64
LEARNING_RATE = 0.001
NUM_EPOCHS = 10
# 创建模型
model = TextRNN(
vocab_size=VOCAB_SIZE,
embed_dim=EMBED_DIM,
hidden_dim=HIDDEN_DIM,
output_dim=OUTPUT_DIM,
num_layers=NUM_LAYERS,
bidirectional=BIDIRECTIONAL,
dropout=DROPOUT
).to(device)
print(f'模型参数量: {sum(p.numel() for p in model.parameters()):,}')
# 损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE)
# 学习率调度器
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)
# 假设我们有训练和验证数据
# train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
# val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE)
# 训练循环
best_val_acc = 0
for epoch in range(NUM_EPOCHS):
print(f'\n{"="*50}')
print(f'Epoch {epoch+1}/{NUM_EPOCHS}')
print(f'{"="*50}')
# 训练
train_loss, train_acc = train_epoch(
model, train_loader, criterion, optimizer, device
)
# 验证
val_loss, val_acc = evaluate(
model, val_loader, criterion, device
)
# 更新学习率
scheduler.step()
print(f'\nTrain Loss: {train_loss:.4f}, Train Acc: {train_acc:.2f}%')
print(f'Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.2f}%')
# 保存最佳模型
if val_acc > best_val_acc:
best_val_acc = val_acc
torch.save(model.state_dict(), 'best_rnn_model.pth')
print('✓ Saved best model')
print(f'\n训练完成! 最佳验证准确率: {best_val_acc:.2f}%')
# if __name__ == '__main__':
# main()
6.3 维度追踪工具
python
class DimensionTracker:
"""辅助追踪RNN中的维度变化"""
@staticmethod
def print_shape(tensor, name):
"""打印张量形状"""
if isinstance(tensor, torch.Tensor):
print(f"{name:20s}: {str(tuple(tensor.shape)):20s} dtype={tensor.dtype}")
else:
print(f"{name:20s}: {tensor}")
@staticmethod
def trace_rnn_forward(rnn_module, input_tensor, hidden=None):
"""追踪RNN前向传播的所有维度"""
print("\n" + "="*60)
print("RNN Forward Pass Dimension Trace")
print("="*60)
# 输入
DimensionTracker.print_shape(input_tensor, "Input")
# 模型参数
print("\nModel Parameters:")
for name, param in rnn_module.named_parameters():
DimensionTracker.print_shape(param, name)
# 前向传播
print("\nForward Propagation:")
if hidden is not None:
DimensionTracker.print_shape(hidden, "Initial Hidden")
output, hidden = rnn_module(input_tensor, hidden)
else:
output, hidden = rnn_module(input_tensor)
# 输出
print("\nOutputs:")
DimensionTracker.print_shape(output, "Output")
DimensionTracker.print_shape(hidden, "Final Hidden")
print("="*60 + "\n")
return output, hidden
# 使用示例
rnn = nn.RNN(input_size=10, hidden_size=20, num_layers=2,
bidirectional=True, batch_first=True)
input_tensor = torch.randn(3, 5, 10) # (batch, seq_len, input_size)
output, hidden = DimensionTracker.trace_rnn_forward(rnn, input_tensor)
# 输出示例:
# ============================================================
# RNN Forward Pass Dimension Trace
# ============================================================
# Input : (3, 5, 10) dtype=torch.float32
#
# Model Parameters:
# weight_ih_l0 : (20, 10) dtype=torch.float32
# weight_hh_l0 : (20, 20) dtype=torch.float32
# bias_ih_l0 : (20,) dtype=torch.float32
# bias_hh_l0 : (20,) dtype=torch.float32
# ...
#
# Forward Propagation:
#
# Outputs:
# Output : (3, 5, 40) dtype=torch.float32
# Final Hidden : (4, 3, 20) dtype=torch.float32
# ============================================================
6.4 可视化工具
python
import matplotlib.pyplot as plt
import seaborn as sns
def visualize_attention_weights(attention_weights, input_words, output_words):
"""可视化注意力权重(适用于seq2seq with attention)"""
plt.figure(figsize=(10, 8))
sns.heatmap(attention_weights,
xticklabels=input_words,
yticklabels=output_words,
cmap='Blues',
annot=True,
fmt='.2f')
plt.xlabel('Input Words')
plt.ylabel('Output Words')
plt.title('Attention Weights Visualization')
plt.tight_layout()
plt.show()
def plot_training_curves(train_losses, val_losses, train_accs, val_accs):
"""绘制训练曲线"""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 5))
# 损失曲线
ax1.plot(train_losses, label='Train Loss', marker='o')
ax1.plot(val_losses, label='Val Loss', marker='s')
ax1.set_xlabel('Epoch')
ax1.set_ylabel('Loss')
ax1.set_title('Training and Validation Loss')
ax1.legend()
ax1.grid(True)
# 准确率曲线
ax2.plot(train_accs, label='Train Acc', marker='o')
ax2.plot(val_accs, label='Val Acc', marker='s')
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Accuracy (%)')
ax2.set_title('Training and Validation Accuracy')
ax2.legend()
ax2.grid(True)
plt.tight_layout()
plt.show()
总结
-
动机: RNN通过引入"记忆"机制处理序列数据,解决了传统神经网络无法捕捉时序依赖的问题
-
数学基础:
- 核心公式: h t = tanh ( W h h h t − 1 + W x h x t + b h ) h_t = \tanh(W_{hh} h_{t-1} + W_{xh} x_t + b_h) ht=tanh(Whhht−1+Wxhxt+bh)
- 关键是理解维度变化和数据流动
- BPTT算法用于训练
-
优化算法:
- 梯度裁剪防止梯度爆炸
- Adam优化器适合RNN
- 截断BPTT处理长序列
-
工程方法:
- PackedSequence高效处理变长序列
- 双向RNN和多层RNN提升能力
- 混合精度训练加速
-
局限性:
- 长期依赖问题 → LSTM/GRU
- 并行化困难 → Transformer