一、RNN基础:循环结构与序列建模原理
循环神经网络(Recurrent Neural Network, RNN)是一类专门用于处理序列数据的神经网络架构。与标准前馈网络(Feedforward Neural Network)不同,RNN通过引入循环连接和隐藏状态(Hidden State),使网络具备对历史信息的记忆能力,从而能够建模序列中的时间依赖关系。
核心思想: RNN通过在时间步之间共享参数,利用隐藏状态在网络内部传递信息,使得网络可以处理任意长度的序列输入。这种参数共享机制大大减少了模型参数量,同时赋予了模型处理变长序列的能力。
1.1 循环结构与隐藏状态
RNN的核心组件是隐藏状态(Hidden State, 通常记为 ht),它充当了网络的"记忆"单元。在每个时间步 t,隐藏状态 ht 同时依赖于当前输入 xt 和上一个时间步的隐藏状态 ht-1:
ht = tanh(Wih · xt + Whh · ht-1 + bh)
其中 Wih 是输入到隐藏层的权重矩阵,Whh 是隐藏层到隐藏层的循环权重矩阵,bh 是偏置项。tanh 作为激活函数将输出压缩到 [-1, 1] 区间。正是 Whh · ht-1 这一项让 RNN 具备了"记忆"能力——前一时刻的信息得以传递到当前时刻。
隐藏状态 ht 可以被视为对过去所有输入序列的信息摘要(Summary)。在理论上,如果序列长度无限长,ht 包含了过去所有时间步的信息,但在实践中由于梯度问题,RNN通常只能记住较短距离(约 5-10 步)的依赖关系。
1.2 时间步展开(Unrolling)
理解RNN的另一种重要视角是时间步展开(Unrolling)。将一个RNN按时间步展开后,它等价于一个深层的前馈网络,其中每一层对应一个时间步,且所有层共享相同的权重参数 Wih、Whh 和 bh。
# 时间步展开示意图(伪代码)
# 一个展开3步的RNN等价于一个3层共享权重的前馈网络
h0 = 0 # 初始隐藏状态(通常为零向量)
h1 = tanh(Wih · x1 + Whh · h0 + bh) # 时间步1
h2 = tanh(Wih · x2 + Whh · h1 + bh) # 时间步2
h3 = tanh(Wih · x3 + Whh · h2 + bh) # 时间步3
y3 = softmax(Why · h3 + by) # 输出
展开后的计算图揭示了RNN的两个关键特性:深度(深度等于序列长度)和参数共享(所有时间步使用同一套权重)。这两个特性既是RNN处理序列数据的优势所在,也是其训练困难的根源。
1.3 参数共享机制
参数共享(Parameter Sharing)是RNN区别于前馈网络最显著的特征之一。在前馈网络中,输入中的每个位置使用不同的权重参数(全连接层的每个输入神经元都有自己的权重)。而在RNN中,权重矩阵 Wih 和 Whh 在所有时间步上是完全共享的。
参数共享的三大优势
- 参数量大幅减少: 无论序列多长,RNN的参数量保持恒定(只取决于隐藏层维度),不会随序列长度线性增长。
- 泛化能力增强: 模型可以处理训练时未见过的序列长度,因为权重与位置无关。
- 时间不变性: 时间步
t 和 t+1 的变换规则完全相同,使得模型能够捕捉时间平移不变的模式。
1.4 序列建模的基本原理
序列建模的核心挑战在于捕捉序列元素之间的条件依赖关系。对于序列 [x1, x2, ..., xT],序列建模的目标是学习联合概率分布:
P(x1, x2, ..., xT) = P(x1) · P(x2 | x1) · P(x3 | x1, x2) · ... · P(xT | x1, ..., xT-1)
RNN利用隐藏状态 ht 来近似表示条件信息 x1, ..., xt-1 的摘要,从而将条件概率简化为:
P(xt | x1, ..., xt-1) ≈ P(xt | ht)
这种压缩表示使得RNN能够在固定大小的状态向量中编码任意长度的历史信息,是序列建模的核心技术之一。
二、RNN前向传播详解
RNN的前向传播过程可以看作是一个逐时间步的信息传递过程。在每个时间步 t,网络执行三个关键计算步骤:输入门控、隐藏状态更新和输出计算。
2.1 输入变换
首先,当前时间步的输入 xt 通过输入权重矩阵 Wih 进行线性变换,得到输入投影:
input_projt = Wih · xt + bih
其中 xt ∈ ℝdin 是输入向量,Wih ∈ ℝdh×din 是输入权重矩阵,bih ∈ ℝdh 是输入偏置。din 是输入维度,dh 是隐藏层维度。
2.2 隐藏状态更新
这是RNN中最关键的步骤。隐藏状态的更新综合了当前输入信息和上一时间步的隐藏状态:
ht = tanh(Wih · xt + bih + Whh · ht-1 + bhh)
这一公式可以分解为两个并行路径:
- 输入路径:
Wih · xt —— 当前输入对隐藏状态的贡献
- 循环路径:
Whh · ht-1 —— 历史信息对当前状态的贡献
两条路径相加后经过 tanh 激活函数。选择 tanh 而非 ReLU 的原因在于:tanh 的输出范围是 (-1, 1),可以有效地抑制循环路径中信息的过度增长,在某种程度上缓解梯度爆炸问题。但 tanh 在饱和区(接近 -1 或 +1)的梯度接近零,这又会加剧梯度消失问题——形成了一对矛盾。
2.3 输出计算
根据任务需求,每个时间步可以产生输出 yt,也可以仅在最后一个时间步产生输出。输出层的计算方式为:
yt = softmax(Why · ht + by)
其中 Why ∈ ℝdout×dh 是隐藏层到输出层的权重矩阵,by ∈ ℝdout 是输出偏置。softmax 将输出转换为概率分布。对于回归任务(如时间序列预测),输出层通常使用线性激活或无激活函数。
2.4 完整前向传播实现
下面给出一个简单的 NumPy 风格 RNN 前向传播实现:
import numpy as np
def rnn_forward(x, W_ih, W_hh, b_h, W_hy, b_y, h0=None):
"""
RNN前向传播
Args:
x: 输入序列, shape (T, d_in)
W_ih: 输入权重, shape (d_h, d_in)
W_hh: 循环权重, shape (d_h, d_h)
b_h: 隐藏层偏置, shape (d_h,)
W_hy: 输出权重, shape (d_out, d_h)
b_y: 输出偏置, shape (d_out,)
h0: 初始隐藏状态, shape (d_h,)
Returns:
h_seq: 所有时间步的隐藏状态, shape (T, d_h)
y_seq: 所有时间步的输出, shape (T, d_out)
"""
T = x.shape[0]
d_h = W_ih.shape[0]
# 初始化隐藏状态
h = h0 if h0 is not None else np.zeros(d_h)
h_seq = []
y_seq = []
for t in range(T):
# 隐藏状态更新
h = np.tanh(W_ih @ x[t] + b_h + W_hh @ h)
# 输出计算
y = W_hy @ h + b_y
h_seq.append(h)
y_seq.append(y)
return np.array(h_seq), np.array(y_seq)
上述代码清晰地展示了RNN前向传播的三个核心步骤:在每个时间步,先接收当前输入 x[t] 和上一隐藏状态 h,更新隐藏状态,再基于新的隐藏状态计算输出。这种"循环"结构使得信息可以在时间维度上持续流动。
三、RNN的梯度问题:消失与爆炸
RNN的训练通过随时间反向传播(Backpropagation Through Time, BPTT)算法完成。BPTT将RNN按时间步展开后,应用标准的反向传播算法计算梯度。然而,当序列较长时,BPTT面临着严重的梯度消失(Vanishing Gradient)和梯度爆炸(Exploding Gradient)问题。
3.1 梯度消失的根本原因
在BPTT中,损失函数 L 对循环权重 Whh 的梯度可写为所有时间步的梯度之和:
∂L / ∂Whh = Σt=1T Σk=1t (∂Lt / ∂ht) · (∂ht / ∂hk) · (∂hk / ∂Whh)
其中关键的 Jacobian 矩阵乘积项 ∂ht / ∂hk 可以展开为:
∂ht / ∂hk = Πi=k+1t diag(tanh'(hi-1)) · Whh
由于 tanh' 的值域为 (0, 1](最大值在 h=0 处为 1,在饱和区接近 0),且 Whh 的谱范数通常小于 1,这个连乘积会随着时间步距离 t - k 的增加而指数级衰减到零。这意味着远时间步的梯度几乎完全消失,网络无法学习长距离依赖关系。
梯度消失的具体后果
- 模型只能捕捉短距离(通常 5-10 步)的序列依赖关系
- 较早时间步的权重几乎收不到有效的梯度更新信号
- 语言模型中难以建立远距离词语之间的语法/语义关联
- 表现上等价于一个"短视"的 n-gram 模型
3.2 梯度爆炸的原因
与梯度消失相反,当 Whh 的谱范数大于 1 时,Jacobian 连乘积会指数级增长,导致梯度爆炸。梯度爆炸产生的原因通常包括:
- 权重初始化不当: 循环权重矩阵的初始值过大
- 激活函数选择: 如果使用 ReLU(而非 tanh),其导数恒为 1,缺乏抑制机制
- 序列过长: 长序列的梯度连乘积更容易发散
- 数据中存在尖锐变化: 输入序列中出现极端值
梯度爆炸的后果十分严重:梯度过大会导致权重更新步伐过大,使模型参数跳出收敛区域,甚至产生 NaN(数值溢出),完全破坏训练过程。
3.3 tanh 饱和区分析
tanh 函数及其导数具有以下特性:
tanh(x) = (ex - e-x) / (ex + e-x)
tanh'(x) = 1 - tanh2(x)
# 饱和区行为
x → +∞: tanh(x) → 1, tanh'(x) → 0
x → -∞: tanh(x) → -1, tanh'(x) → 0
x = 0: tanh(x) = 0, tanh'(x) = 1 # 非饱和区,梯度最大
当隐藏状态的值接近 -1 或 +1 时,tanh 进入饱和区,梯度接近零。在RNN的训练过程中,如果隐藏状态频繁进入饱和区,那么 BPTT 中的所有 Jacobian 项都将接近零,导致梯度消失加剧。这是为什么 tanh 虽然能帮助缓解梯度爆炸,却又会加剧梯度消失的原因。
关键理解: 梯度消失和梯度爆炸本质上是一枚硬币的两面——它们都源于循环权重矩阵 Whh 的重复乘法(即幂运算)。当 Whh 的特征值小于 1 时,信息指数级衰减(消失);当特征值大于 1 时,信息指数级增长(爆炸)。理想状态下,Whh 的特征值应恰好为 1,但这在实际中几乎不可实现。这一根本性困境催生了后续的 LSTM 和 GRU 等门控机制。
3.4 梯度裁剪技术
梯度裁剪(Gradient Clipping)是应对梯度爆炸的最直接有效的方法。其思想非常简单:如果梯度的范数超过预设阈值,则将其缩放到该阈值。梯度裁剪有两种主要形式:
# 形式一:按值裁剪(Value Clipping)
g ← clip(g, -threshold, threshold)
# 形式二:按范数裁剪(Norm Clipping)—— 更常用
if ||g|| > threshold:
g ← (threshold / ||g||) · g
PyTorch 中直接提供了梯度裁剪的工具函数:
import torch.nn as nn
# 训练循环中使用梯度裁剪
loss.backward()
# 按范数裁剪,阈值 5.0
nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()
optimizer.zero_grad()
梯度裁剪虽然能防止梯度爆炸导致的数值溢出,但对梯度消失问题无能为力——它不能恢复已经消失到零的梯度信号。解决梯度消失需要从架构层面入手,即引入门控机制(LSTM/GRU)或残差连接。
四、RNN主流变体架构
为应对标准RNN的梯度问题和表达能力的限制,研究者提出了多种RNN变体架构。这些变体通过改进隐藏状态更新方式、扩展信息流动方向或改变输入输出模式,显著扩展了RNN的应用范围。
4.1 深层RNN(Deep RNN)
深层RNN通过在 每个时间步内堆叠多个RNN层 来增加网络的深度和表达能力。上层的隐藏状态接收下层的隐藏状态作为输入:
# 深层RNN(2层)的前向传播
for t in range(T):
h(1)t = tanh(Wih(1) · xt + Whh(1) · h(1)t-1 + b(1))
h(2)t = tanh(Wih(2) · h(1)t + Whh(2) · h(2)t-1 + b(2))
yt = softmax(Why · h(2)t + by)
深层RNN的优点在于每一层可以在不同的时间尺度上建模信息——底层捕捉快速变化的局部模式,高层捕捉缓慢变化的全局语义。然而,层数增加会加剧梯度问题,通常深层RNN的层数不超过 3-4 层。在实践中,深层RNN常与残差连接(Residual Connection)结合使用。
4.2 双向RNN(BiRNN)
标准RNN只能利用过去的信息来预测当前,但在许多任务中(如命名实体识别、机器翻译),当前位置的预测也需要利用未来的上下文信息。双向RNN(Bidirectional RNN, BiRNN)通过引入两个独立的RNN层来解决这个问题:
- 正向RNN: 从左到右读取序列,产生前向隐藏状态
ht→
- 反向RNN: 从右到左读取序列,产生反向隐藏状态
ht←
每个时间步的最终隐藏状态是前向和反向状态的拼接:
ht = [ht→ ; ht←]
其中 ";" 表示拼接操作。BiRNN 的输出维度是单向RNN的两倍。PyTorch 中通过 bidirectional=True 参数即可实现:
import torch.nn as nn
# 双向RNN:隐藏层维度64,输出维度128(64*2)
birnn = nn.RNN(input_size=100, hidden_size=64,
num_layers=1, bidirectional=True)
x = torch.randn(seq_len, batch_size, 100) # (T, B, d_in)
output, h_n = birnn(x)
# output shape: (T, B, 128) —— 每个时间步的输出是双向拼接
# h_n shape: (2, B, 64) —— 最后一层的前向和反向隐藏状态
BiRNN 的特点
- 每个位置的表示都包含了完整的上下文信息(过去 + 未来)
- 参数量是单向RNN的2倍(但不计算独立参数,因为正向和反向权重完全独立)
- 不适用于在线/流式预测任务(需要完整的输入序列)
- 在NLP任务(NER、序列标注、文本分类)中效果显著优于单向RNN
4.3 编码器-解码器架构
编码器-解码器(Encoder-Decoder)架构,也称为序列到序列(Seq2Seq)模型,是RNN在机器翻译、文本摘要、语音识别等任务中的核心架构。它由两个RNN组成:
- 编码器(Encoder): 读取源序列
[x1, ..., xT],将整个序列的信息编码为上下文向量(Context Vector)c,通常为编码器最后一个时间步的隐藏状态。
- 解码器(Decoder): 以上下文向量
c 为初始隐藏状态,自回归地(Autoregressively)生成目标序列 [y1, ..., yT']。
# 编码器-解码器架构示意
# 编码器:读取源序列,生成上下文向量
for t in range(T):
h_enct = tanh(W_enc · xt + U_enc · h_enct-1)
c = h_encT # 上下文向量 = 编码器最后隐藏状态
# 解码器:以上下文向量为初始状态,逐步生成
h_dec0 = c # 解码器初始状态来自编码器
for t in range(T'):
yt = softmax(W_dec · h_dect-1)
h_dect = tanh(W_emb · yt + U_dec · h_dect-1)
编码器-解码器架构的核心贡献在于它能够处理不等长的输入和输出序列。这一架构后来发展为 Transformer 的基础框架(编码器-解码器注意力机制)。
4.4 多对多 / 多对一 / 一对多架构
根据输入和输出序列的长度关系,RNN架构可以分为三种基本模式:
| 架构类型 |
输入长度 |
输出长度 |
典型应用 |
| 多对一 |
多 |
单 |
情感分类(分析整个句子的情感极性) |
| 一对多 |
单 |
多 |
图像描述(从单张图片生成描述文本) |
| 多对多(同长) |
多 |
多(等长) |
命名实体识别、词性标注(每个输入对应一个输出) |
| 多对多(不等长) |
多 |
多(不等长) |
机器翻译(编码器-解码器架构) |
PyTorch 中对这三种模式的支持非常直接:
import torch.nn as nn
rnn = nn.RNN(input_size=100, hidden_size=64, batch_first=False)
# 多对多:每个时间步都有输出
x = torch.randn(10, 32, 100) # (T=10, B=32, d_in=100)
output, h_n = rnn(x)
# output.shape = (10, 32, 64) ← 每个时间步都有输出
# 多对一:只取最后一个时间步的输出
last_output = output[-1, :, :] # (32, 64)
# 一对多:重复输入同一个向量,或使用教师强制(Teacher Forcing)
single_input = x[0:1, :, :] # 取第一个时间步作为恒定输入
repeated_input = single_input.repeat(10, 1, 1) # 重复 T 次
五、序列预测任务实战
RNN在多种序列预测任务中都有广泛的应用。下面从四个经典任务出发,展示RNN的具体应用方式。
5.1 字符级语言模型
字符级语言模型(Character-Level Language Model)是理解RNN序列生成能力的经典入门任务。模型逐字符地读取文本,并预测下一个字符的概率分布。训练完成后,模型可以自回归地生成新的文本。
import torch
import torch.nn as nn
import torch.optim as optim
class CharRNN(nn.Module):
def __init__(self, vocab_size, embed_size, hidden_size):
super().__init__()
self.hidden_size = hidden_size
self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.RNN(embed_size, hidden_size, batch_first=True)
self.fc = nn.Linear(hidden_size, vocab_size)
def forward(self, x, h0=None):
emb = self.embedding(x) # (B, T) → (B, T, emb_dim)
out, h_n = self.rnn(emb, h0) # (B, T, hidden)
logits = self.fc(out) # (B, T, vocab_size)
return logits, h_n
def generate(self, start_idx, length, temperature=1.0):
self.eval()
h = torch.zeros(1, 1, self.hidden_size)
x = torch.tensor([[start_idx]])
output = [start_idx]
for _ in range(length):
logits, h = self.forward(x, h)
logits = logits[0, -1, :] / temperature
probs = torch.softmax(logits, dim=-1)
next_idx = torch.multinomial(probs, 1).item()
output.append(next_idx)
x = torch.tensor([[next_idx]])
return output
# 训练设置
model = CharRNN(vocab_size=65, embed_size=64, hidden_size=128)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
在生成阶段,temperature 参数控制生成的创造性:高温度(如 1.5)使概率分布更均匀,生成的文本更随机多样;低温度(如 0.5)使概率分布更尖锐,生成的文本更保守稳定。
5.2 时间序列预测
RNN在金融预测、气象预报、传感器数据分析等时间序列任务中也有广泛应用。与语言模型不同,时间序列预测通常是回归任务(连续值预测),输出层使用线性激活而非 softmax。
class TimeSeriesRNN(nn.Module):
def __init__(self, input_dim=1, hidden_size=64, output_dim=1,
num_layers=2):
super().__init__()
self.rnn = nn.RNN(input_dim, hidden_size,
num_layers, batch_first=True)
self.fc = nn.Linear(hidden_size, output_dim)
def forward(self, x):
# x: (B, seq_len, input_dim)
out, _ = self.rnn(x)
# 取最后一个时间步的输出做预测
last_out = out[:, -1, :] # (B, hidden_size)
pred = self.fc(last_out) # (B, output_dim)
return pred
# 训练循环示例(滑动窗口方式)
seq_len = 20
batch_size = 32
model = TimeSeriesRNN(input_dim=1, hidden_size=64, output_dim=1)
criterion = nn.MSELoss() # 回归任务使用MSE损失
optimizer = optim.Adam(model.parameters(), lr=0.001)
# 假设 data 是形状为 (total_len,) 的一维时间序列
for epoch in range(num_epochs):
for i in range(0, len(data) - seq_len - 1, batch_size):
batch_x = ... # 构建滑动窗口输入 (B, seq_len, 1)
batch_y = ... # 下一个时间步的真实值 (B, 1)
pred = model(batch_x)
loss = criterion(pred, batch_y)
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 5.0)
optimizer.step()
时间序列预测中使用RNN时,需要特别注意数据预处理(归一化/差分平稳化)和序列长度选择(过长的序列会加剧梯度问题)。通常使用 nn.utils.clip_grad_norm_ 配合梯度裁剪来稳定训练。
5.3 情感分析
情感分析(Sentiment Analysis)是典型的多对一任务:输入为完整的句子/文档序列,输出为单一的情感类别(正面/负面/中性)。模型需要对整个序列进行理解,并提取出情感倾向的语义信息。
class SentimentRNN(nn.Module):
def __init__(self, vocab_size, embed_size, hidden_size,
num_classes=2, num_layers=2):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0)
self.rnn = nn.RNN(embed_size, hidden_size,
num_layers, batch_first=True,
dropout=0.3 if num_layers > 1 else 0)
self.fc = nn.Linear(hidden_size, num_classes)
self.dropout = nn.Dropout(0.3)
def forward(self, x):
# x: (B, T) tokenized 文本
emb = self.embedding(x) # (B, T, embed_size)
out, _ = self.rnn(emb) # (B, T, hidden_size)
# 多对一:使用最后一个时间步的输出
last_out = out[:, -1, :] # (B, hidden_size)
last_out = self.dropout(last_out)
logits = self.fc(last_out) # (B, num_classes)
return logits
在情感分析中,逐时间步取最后一个输出作为全序列的表示是关键步骤。对于更长的文本,使用 BiRNN 可以同时获得前向和反向的上下文信息,显著提升分类效果。
5.4 命名实体识别
命名实体识别(Named Entity Recognition, NER)是多对多(同长)的序列标注任务:输入序列中的每个词都需要被标注为相应的实体类别(人名/地名/组织名/非实体)。BiRNN + CRF 是经典的 NER 架构。
class NERBiRNN(nn.Module):
def __init__(self, vocab_size, embed_size, hidden_size, num_tags):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_size)
self.birnn = nn.RNN(embed_size, hidden_size,
bidirectional=True, batch_first=True)
# 双向RNN的输出维度为 hidden_size * 2
self.fc = nn.Linear(hidden_size * 2, num_tags)
def forward(self, x):
emb = self.embedding(x) # (B, T, embed_size)
out, _ = self.birnn(emb) # (B, T, hidden*2)
logits = self.fc(out) # (B, T, num_tags)
return logits
在完整实践中,通常在 BiRNN 之上叠加条件随机场(Conditional Random Field, CRF)层,以显式建模标签之间的转移约束(如"B-PER 后面不能接 I-LOC")。PyTorch 的 torchcrf 库提供了便捷的 CRF 层实现。
六、PyTorch nn.RNN 实战详解
PyTorch 的 nn.RNN 模块封装了RNN的前向传播逻辑,提供了灵活的参数配置接口。掌握 nn.RNN 的使用是进行RNN实战的基础。
6.1 nn.RNN 核心参数
| 参数 |
类型 |
说明 |
默认值 |
input_size |
int |
输入特征维度 |
— |
hidden_size |
int |
隐藏层特征维度 |
— |
num_layers |
int |
RNN层数(堆叠深度) |
1 |
nonlinearity |
str |
隐藏层激活函数:'tanh' 或 'relu' |
'tanh' |
bias |
bool |
是否使用偏置项 |
True |
batch_first |
bool |
输入张量的 batch 维度是否在第一维 |
False |
dropout |
float |
层间 dropout 概率(num_layers > 1 时生效) |
0 |
bidirectional |
bool |
是否为双向RNN |
False |
6.2 输入输出格式详解
nn.RNN 要求输入为三维张量。以 batch_first=False(默认)为例:
# RNN 层定义
rnn = nn.RNN(input_size=100, hidden_size=64, num_layers=2)
# 输入: (seq_len, batch_size, input_size)
x = torch.randn(10, 32, 100) # (T=10, B=32, d_in=100)
# 前向传播
output, h_n = rnn(x)
# output: (T, B, D*hidden_size)
# - T: 序列长度
# - B: batch大小
# - D: 方向数(1 或 2)
print(output.shape) # (10, 32, 64)
# h_n: (D*num_layers, B, hidden_size)
# - 每一层最后一个时间步的隐藏状态
print(h_n.shape) # (2, 32, 64) — num_layers=2, 每层一个方向
# 获取最后一层最后一个时间步的隐藏状态
last_layer_h = h_n[-1] # (32, 64)
# 使用 batch_first=True 时,输入为 (B, T, d_in)
rnn_bf = nn.RNN(100, 64, batch_first=True)
x_bf = torch.randn(32, 10, 100) # (B=32, T=10, d_in=100)
output_bf, h_n_bf = rnn_bf(x_bf)
print(output_bf.shape) # (32, 10, 64)
6.3 处理变长序列
在NLP任务中,批次内的序列长度往往不同。nn.RNN 支持通过 nn.utils.rnn.pack_padded_sequence 和 pad_packed_sequence 高效处理变长序列:
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence
# 批次中3个序列,长度分别为 (5, 3, 2)
lengths = torch.tensor([5, 3, 2])
# 填充后的输入: (max_len=5, B=3, d_in=100)
padded_x = torch.randn(5, 3, 100)
# 打包:去除填充,只保留有效时间步
packed_x = pack_padded_sequence(padded_x, lengths,
enforce_sorted=False)
# RNN 处理打包后的序列
packed_out, h_n = rnn(packed_x)
# 解包:恢复填充后的格式
output, _ = pad_packed_sequence(packed_out)
print(output.shape) # (5, 3, 64) — 填充部分为0
变长序列处理的核心优势在于计算效率:有效时间步被压缩打包,RNN 不会在填充的 pad 位置上执行无意义的计算。
6.4 完整训练循环示例
下面给出一个完整的情感分析训练脚本框架:
def train_epoch(model, dataloader, criterion, optimizer, device):
model.train()
total_loss = 0
correct = 0
total = 0
for texts, labels in dataloader:
texts, labels = texts.to(device), labels.to(device)
logits = model(texts)
loss = criterion(logits, labels)
optimizer.zero_grad()
loss.backward()
# 梯度裁剪(RNN训练的关键步骤)
nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()
total_loss += loss.item()
preds = logits.argmax(dim=-1)
correct += (preds == labels).sum().item()
total += labels.size(0)
return total_loss / len(dataloader), correct / total
# 模型初始化
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SentimentRNN(vocab_size=5000, embed_size=128,
hidden_size=256).to(device)
optimizer = optim.Adam(model.parameters(), lr=0.001)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode='min', factor=0.5, patience=2
)
for epoch in range(20):
train_loss, train_acc = train_epoch(
model, train_loader, criterion, optimizer, device
)
val_loss, val_acc = evaluate(model, val_loader, criterion, device)
scheduler.step(val_loss)
print(f"Epoch {epoch+1}: "
f"Train Loss={train_loss:.4f} Acc={train_acc:.4f} | "
f"Val Loss={val_loss:.4f} Acc={val_acc:.4f}")
七、RNN vs 前馈网络:全面对比
RNN 和前馈网络(Feedforward Neural Network, FFN)代表了两种完全不同的数据处理范式。理解二者的本质差异对于正确选型至关重要。
| 对比维度 |
RNN(循环神经网络) |
前馈网络(FFN) |
| 数据处理方式 |
逐时间步处理序列,维护隐藏状态传递历史信息 |
一次性处理所有输入,无时间维度概念 |
| 输入形式 |
变长序列 (T, B, d_in),时间步维度可变 |
固定长度向量 (B, d_in),输入维度固定 |
| 参数共享 |
所有时间步共享权重矩阵,参数量与序列长度无关 |
每个输入位置有独立权重,参数随输入维度线性增长 |
| 记忆能力 |
通过隐藏状态维护隐式记忆,可建模时间依赖关系 |
无记忆能力,各输入之间独立处理 |
| 梯度传播 |
随时间反向传播(BPTT),存在梯度消失/爆炸问题 |
标准反向传播,梯度路径短,相对稳定 |
| 适用场景 |
时间序列、文本、语音、视频等带时间/顺序结构的数据 |
图像分类、表格数据、特征向量等独立同分布数据 |
| 并行计算 |
时间步之间存在顺序依赖,难以完全并行化 |
所有计算可完全并行,GPU利用率高 |
| 表达能力 |
可建模复杂时序关系和长期依赖(受距离限制) |
通过深度堆叠可表达任意复杂函数,但无法处理序列关系 |
| 训练稳定性 |
对学习率和初始化敏感,需要梯度裁剪等技巧 |
相对稳定,可通过 BatchNorm/LayerNorm 等标准化手段 |
7.1 何时选择RNN
在以下场景中,RNN(或其变体 LSTM/GRU)是更合适的选择:
- 数据具有明确的顺序结构: 文本、语音、音乐、视频帧、传感器读数等
- 预测依赖于历史上下文: 如语言模型中的下一个词预测、股票价格预测
- 输入输出长度不等: 机器翻译、文本摘要等 Seq2Seq 任务
- 需要逐时间步的预测或标注: 词性标注、命名实体识别等
7.2 何时选择前馈网络
在以下场景中,前馈网络是更简单高效的选择:
- 输入维度固定且无时间依赖: 图像分类、回归任务
- 数据量较小: RNN的BPTT训练对数据量要求更高
- 需要快速推理: FFN的推理速度通常更快(可并行)
- 特征工程充分: 如果手工特征已经捕捉了时序信息,FFN可能更简单
实用建议: 在现代深度学习中,纯RNN在NLP领域已被 Transformer 架构取代。但在以下场景中,RNN/LSTM/GRU 仍然保持着竞争力:(1) 小规模数据场景(Transformer 需要大量数据);(2) 低延迟在线预测(RNN可逐时间步处理,无需看完整序列);(3) 时间序列预测(许多研究证明LSTM在经典时间序列任务中优于 Transformer);(4) 计算资源受限的边缘设备部署。
八、核心要点总结
RNN与序列建模的关键要点
- 循环结构: RNN 通过隐藏状态
ht = tanh(Wih · xt + Whh · ht-1) 在时间步之间传递信息,实现序列数据的循环处理
- 参数共享: 所有时间步共享相同的权重矩阵,使模型可处理任意长度的序列且参数量恒定
- 时间步展开: RNN 按时间步展开后等价于一个深度共享权重的前馈网络,BPTT 在此展开图上执行反向传播
- 梯度消失与爆炸: 根源在于
Whh 的重复乘法——特征值 < 1 时梯度消失,特征值 > 1 时梯度爆炸;梯度裁剪是应对爆炸的有效手段,但消失问题需要从架构层面解决(LSTM/GRU 的门控机制)
- 架构变体: 深层RNN增加表达能力(但不超过3-4层),BiRNN同时利用过去和未来上下文(双向),编码器-解码器处理不等长序列(Seq2Seq)
- 序列任务类型: 多对一(情感分析)、一对多(图像描述)、多对多等长(NER)、多对多不等长(机器翻译)
- PyTorch 实现:
nn.RNN 提供完整的RNN层封装,支持多层堆叠、双向、变长序列(pack_padded_sequence)、dropout 等功能
- RNN vs FFN: RNN 适用于带顺序/时间结构的数据,FFN 适用于独立同分布的固定维度数据;RNN 优势在于序列建模,劣势在于难以并行化和训练不稳定
- 现代定位: Transformer 在大规模NLP任务中占据主导,但 RNN/LSTM 在时间序列预测、小规模数据、低延迟场景中仍具有实际应用价值