一、RNN概述
循环神经网络(Recurrent Neural Network, RNN)是一类专门用于处理序列数据的神经网络架构。与传统的前馈神经网络不同,RNN通过在时间步之间传递隐藏状态来捕获序列中的时序依赖关系,这使得它能够处理任意长度的序列输入。
为什么需要RNN
传统神经网络(如全连接网络、CNN)假设输入之间相互独立,这一假设在处理序列数据时存在根本性缺陷。在自然语言处理、时间序列预测、语音识别等任务中,数据点之间存在着复杂的时序依赖关系——一个单词的含义依赖于其上下文,今天的股票价格受过去数日走势影响。RNN正是为建模这种依赖关系而设计。
RNN的核心思想
RNN的核心创新在于两个关键设计:
- 共享权重: 在所有时间步使用相同的权重矩阵,使模型能够学习序列中的通用模式,无论这些模式出现在序列的哪个位置
- 记忆状态: 隐藏状态 $h_t$ 充当网络的"记忆",在每个时间步接收当前输入 $x_t$ 和上一时间步的隐藏状态 $h_{t-1}$,输出新的隐藏状态
RNN的展开计算
RNN的计算过程可以通过"展开"(unrolling)来理解。对于一个长度为 $T$ 的输入序列 $(x_1, x_2, ..., x_T)$,RNN按时间步依次计算:
隐藏状态更新: ht = tanh(Wih · xt + Whh · ht-1 + bh)
输出计算: yt = softmax(Why · ht + by)
其中 $W_{ih}$ 是输入到隐藏层的权重,$W_{hh}$ 是隐藏层到隐藏层的循环权重,$W_{hy}$ 是隐藏层到输出的权重。关键在于 $W_{hh}$ 在所有时间步之间共享,使得信息可以在时间维度上传递。
PyTorch中的nn.RNN
在PyTorch中,使用 nn.RNN 可以快速构建一个基础的循环神经网络:
import torch
import torch.nn as nn
# 定义RNN
rnn = nn.RNN(
input_size=100, # 输入特征的维度(如词嵌入维度)
hidden_size=128, # 隐藏状态的维度
num_layers=2, # RNN层数
batch_first=True, # 输入形状为 (batch, seq_len, input_size)
nonlinearity='tanh' # 激活函数
)
# 前向传播
x = torch.randn(32, 10, 100) # (batch=32, seq_len=10, input_size=100)
output, h_n = rnn(x)
# output: (32, 10, 128) - 每个时间步的隐藏状态
# h_n: (2, 32, 128) - 最后一层的最终隐藏状态
参数说明:input_size 是每个时间步输入的特征维度,hidden_size 是隐藏状态的维度(决定了模型的容量),num_layers 是堆叠的RNN层数(深层RNN可以捕获更复杂的模式)。
二、RNN的应用场景
RNN在众多序列建模任务中都有广泛应用,以下是几个主要的应用领域:
自然语言处理(NLP)
- 文本生成: 给定前几个字符/单词,预测下一个字符/单词,可用于诗歌生成、代码补全等
- 机器翻译: 将源语言序列(如英文)编码为上下文向量,再解码为目标语言序列(如中文)
- 情感分析: 分析文本序列,判断其情感倾向(正面/负面/中性)
- 命名实体识别: 标注文本中的人名、地名、组织机构名等实体
时间序列预测
- 股票价格预测: 利用历史价格序列预测未来走势
- 天气预测: 基于过去数日的气温、湿度、气压等序列数据预测未来天气
- 电力负荷预测: 预测电网在未来时间段内的用电需求,辅助电力调度
- 传感器数据异常检测: 监测工业设备传感器的时间序列,检测异常模式
语音与音频处理
- 语音识别: 将语音信号序列转换为文本序列
- 音乐生成: 学习音符序列的规律,生成新的音乐
- 语音情感识别: 从语音的韵律特征中识别说话人的情感状态
视频分析
- 视频行为识别: 对视频帧序列进行分析,识别出正在执行的动作
- 视频描述生成: 将视频帧序列编码后生成自然语言描述
- 目标跟踪: 利用时序信息预测目标在视频帧中的运动轨迹
三、RNN的局限
长程依赖问题
标准的RNN在处理长序列时面临严重的长程依赖问题。理论上,RNN的隐藏状态可以捕获无限远的上下文信息,但实际上,随着时间步的增加,较早时间步的信息会逐渐被稀释。例如,在句子"我在法国长大……我会说流利的____"中,要预测空白处的"法语",模型需要记住出现在序列前端的"法国"信息。当序列很长时,标准RNN很难保持这种远距离的依赖关系。
问题根源:梯度消失与梯度爆炸
RNN使用随时间反向传播(Backpropagation Through Time, BPTT)算法进行训练。在BPTT中,梯度需要通过时间维度反向传播。由于循环权重的重复乘法操作,梯度会经历指数级的衰减或增长:
- 梯度消失: 当权重的谱半径小于1时,长期梯度趋近于零,模型无法学习长程依赖
- 梯度爆炸: 当权重的谱半径大于1时,梯度指数增长,导致训练不稳定甚至发散
BPTT的挑战
BPTT算法将RNN展开为一个深度前馈网络(深度等于序列长度),然后应用标准的反向传播。当序列长度较大时,BPTT面临以下挑战:
- 计算开销大: 长时间序列展开导致计算图极其庞大,内存占用和计算量随序列长度线性增长
- 截断BPTT: 实践中常采用截断BPTT(Truncated BPTT),只反向传播固定步数的梯度,但这限制了模型捕获长程依赖的能力
- 记忆容量有限: 标准RNN的隐藏状态只有有限的维度,难以存储大量历史信息
标准RNN的理论记忆容量受限于隐藏状态的维度和循环权重的条件数,实践中通常只能捕获约5-10个时间步的依赖关系,远不能满足长序列任务的需求。
四、LSTM长短期记忆网络
长短期记忆网络(Long Short-Term Memory, LSTM)由 Hochreiter 和 Schmidhuber 于1997年提出,是解决RNN长程依赖问题的最经典方案。LSTM通过引入门控机制和单元状态(cell state),让网络能够选择性地记忆或遗忘信息。
LSTM的核心思想
LSTM引入了一条独立的"传送带"——单元状态(cell state)$C_t$,它贯穿整个序列处理过程,只经过少量的线性变换,使得梯度可以无损地在时间维度上传播。三个门控单元协同工作,控制信息的流入、存储和流出。
遗忘门(Forget Gate)
遗忘门决定从单元状态中丢弃哪些信息。它读取 $h_{t-1}$ 和 $x_t$,输出一个0到1之间的值给单元状态 $C_{t-1}$ 中的每个元素(1表示"完全保留",0表示"完全丢弃"):
遗忘门: ft = σ(Wf · [ht-1, xt] + bf)
输入门(Input Gate)
输入门决定将哪些新信息存储到单元状态中。它由两部分组成:
输入门: it = σ(Wi · [ht-1, xt] + bi)
候选记忆(Candidate Memory)
候选记忆 $C̃_t$ 通过tanh层创建新的候选值向量,将被添加到单元状态中:
候选记忆: C̃t = tanh(WC · [ht-1, xt] + bC)
单元状态更新(Cell State Update)
单元状态通过遗忘门和输入门的组合更新:旧的单元状态乘以遗忘门(丢弃需要遗忘的信息),加上输入门乘以候选记忆(添加新信息):
状态更新: Ct = ft ⊙ Ct-1 + it ⊙ C̃t
输出门(Output Gate)
输出门基于单元状态决定最终输出。首先通过sigmoid层决定输出单元状态的哪些部分,然后将单元状态通过tanh(将值压缩到-1到1之间),再与输出门相乘:
输出门: ot = σ(Wo · [ht-1, xt] + bo)
隐藏状态: ht = ot ⊙ tanh(Ct)
LSTM的完整流程
在一个时间步中,LSTM的执行顺序为:
- 计算遗忘门 $f_t$,决定丢弃哪些旧信息
- 计算输入门 $i_t$ 和候选记忆 $C̃_t$,决定添加哪些新信息
- 更新单元状态 $C_t = f_t \odot C_{t-1} + i_t \odot C̃_t$
- 计算输出门 $o_t$,生成隐藏状态 $h_t$
这种设计使得梯度可以通过单元状态的加法和逐元素乘法路径轻松传播,有效缓解了梯度消失问题。
LSTM vs 标准RNN对比
| 特性 |
标准RNN |
LSTM |
| 记忆机制 |
单一隐藏状态 |
隐藏状态 + 单元状态 |
| 门控机制 |
无 |
遗忘门、输入门、输出门 |
| 长程依赖 |
困难(梯度消失) |
擅长(梯度路径通畅) |
| 参数量 |
较少 |
约4倍于RNN |
| 训练难度 |
容易过拟合/梯度问题 |
较为稳定 |
| 适用场景 |
短序列、简单任务 |
长序列、复杂任务 |
五、GRU门控循环单元
门控循环单元(Gated Recurrent Unit, GRU)由 Cho 等人于2014年提出,是LSTM的简化变体。GRU将遗忘门和输入门合并为单一的更新门(Update Gate),并将单元状态和隐藏状态合并,因此参数量更少,计算效率更高。
重置门: rt = σ(Wr · [ht-1, xt])
更新门: zt = σ(Wz · [ht-1, xt])
候选隐藏状态: h̃t = tanh(W · [rt ⊙ ht-1, xt])
最终隐藏状态: ht = (1 - zt) ⊙ ht-1 + zt ⊙ h̃t
LSTM vs GRU对比
| 对比维度 |
LSTM |
GRU |
| 门控数量 |
3个(遗忘、输入、输出) |
2个(重置、更新) |
| 状态数量 |
2个(h, C) |
1个(h) |
| 参数量 |
较多 |
较少(约减少25%) |
| 计算效率 |
较低 |
较高 |
| 表达能力 |
更强(独立控制读写) |
次之(读写耦合) |
| 数据量需求 |
更多数据才能发挥优势 |
小数据量表现更好 |
经验法则: 在数据量充足且需要精细控制记忆时优先使用LSTM;在数据量有限或需要快速迭代时,GRU通常是更好的选择。
六、双向RNN
双向循环神经网络(Bidirectional RNN, BiRNN)通过引入两个独立的RNN层——一个按时间正方向处理序列,另一个按时间反方向处理序列——来捕获每个时间步的完整上下文信息。
BiRNN/BiLSTM原理
BiRNN由两个独立的RNN组成:
- 前向RNN: 从左到右处理序列 $(\overrightarrow{h_1}, \overrightarrow{h_2}, ..., \overrightarrow{h_T})$
- 后向RNN: 从右到左处理序列 $(\overleftarrow{h_T}, \overleftarrow{h_{T-1}}, ..., \overleftarrow{h_1})$
每个时间步的最终隐藏状态是两个方向的隐藏状态拼接而成:
拼接隐藏状态: ht = [ht ; ht←]
在PyTorch中实现BiLSTM
import torch.nn as nn
bilstm = nn.LSTM(
input_size=100,
hidden_size=128,
num_layers=2,
bidirectional=True, # 启用双向
batch_first=True
)
x = torch.randn(32, 10, 100) # (batch, seq_len, input_size)
output, (h_n, c_n) = bilstm(x)
# output: (32, 10, 256) - hidden_size * 2 = 256(前向+后向拼接)
# h_n: (4, 32, 128) - num_layers * 2 = 4
应用场景
双向RNN在许多NLP任务中显著优于单向RNN,因为在这些任务中,当前位置的理解需要同时依赖左上下文和右上下文:
- 命名实体识别: 判断"苹果"是水果还是公司名,需要看前后文
- 机器翻译: 翻译一个词时需要同时考虑其左右上下文
- 文本分类: 虽然整篇文档分类看似不需要双向,但双向可以构建更丰富的特征表示
- 序列标注: 如词性标注、语义角色标注等
注意事项
- 双向RNN不适用于在线或实时预测(需要完整的未来上下文,不能逐时间步推理)
- 参数量是单向RNN的两倍,计算成本更高
- 对于生成任务(如语言模型),通常只能使用单向(未来信息不可见)
七、RNN的实战:PyTorch实现LSTM情感分类
下面通过一个完整的情感分类任务演示如何使用LSTM处理文本序列数据,涵盖变长序列处理和模型构建的完整流程。
完整代码实现
import torch
import torch.nn as nn
from torch.nn.utils.rnn import pad_sequence, pack_padded_sequence, pad_packed_sequence
class LSTMClassifier(nn.Module):
def __init__(self, vocab_size, embedding_dim=100,
hidden_size=128, num_layers=2,
num_classes=2, bidirectional=False, dropout=0.5):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0)
self.lstm = nn.LSTM(
input_size=embedding_dim,
hidden_size=hidden_size,
num_layers=num_layers,
batch_first=True,
bidirectional=bidirectional,
dropout=dropout if num_layers > 1 else 0
)
lstm_out_dim = hidden_size * (2 if bidirectional else 1)
self.classifier = nn.Sequential(
nn.Dropout(dropout),
nn.Linear(lstm_out_dim, num_classes)
)
def forward(self, x, lengths):
# x: (batch, seq_len) - token indices
embedded = self.embedding(x) # (batch, seq_len, embedding_dim)
# 处理变长序列:pack
packed = pack_padded_sequence(
embedded, lengths.cpu(),
batch_first=True, enforce_sorted=False
)
packed_output, (h_n, c_n) = self.lstm(packed)
# 取最后一层所有方向的隐藏状态
if self.lstm.bidirectional:
# h_n: (num_layers*2, batch, hidden_size)
h_fwd = h_n[-2, :, :] # 最后一层前向
h_bwd = h_n[-1, :, :] # 最后一层后向
h_out = torch.cat([h_fwd, h_bwd], dim=1) # (batch, hidden_size*2)
else:
h_out = h_n[-1, :, :] # (batch, hidden_size)
logits = self.classifier(h_out)
return logits
处理变长序列
在实际的NLP任务中,批次内的句子长度通常不同。PyTorch提供了pack_padded_sequence和pad_packed_sequence来高效处理变长序列:
# 假设一个批次中有3个句子,长度分别为 8, 5, 3
texts = [
torch.tensor([12, 34, 56, 78, 90, 11, 22, 33]), # 长度8
torch.tensor([44, 55, 66, 77, 88]), # 长度5
torch.tensor([99, 10, 20]) # 长度3
]
# 填充到相同长度(短的补0)
padded = pad_sequence(texts, batch_first=True, padding_value=0)
# 形状: (3, 8)
# 记录每个样本的真实长度
lengths = torch.tensor([8, 5, 3])
# Pack操作:去除填充位置的冗余计算
packed = pack_padded_sequence(
padded, lengths,
batch_first=True, enforce_sorted=False
)
# 送入LSTM
packed_output, (h_n, c_n) = lstm(packed)
# 如果需要每个时间步的输出,可以unpack
output, _ = pad_packed_sequence(packed_output, batch_first=True)
关键参数详解
- input_size: 每个时间步输入的特征维度,通常等于词嵌入维度
- hidden_size: 隐藏状态的维度,决定模型的记忆容量
- num_layers: 堆叠的RNN层数,深层结构可以捕获更抽象的模式
- bidirectional: 是否使用双向RNN,启用后输出维度翻倍
- batch_first: 输入张量的第一个维度是否为batch_size,建议设为True
- dropout: 多层LSTM时层间的dropout比率,用于防止过拟合
- padding_idx: Embedding层中填充位置的索引,其梯度始终为0
完整训练流程
model = LSTMClassifier(
vocab_size=50000, embedding_dim=100,
hidden_size=128, num_layers=2,
num_classes=2, bidirectional=True
)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
# 训练循环
for epoch in range(10):
for batch_texts, batch_lengths, batch_labels in dataloader:
logits = model(batch_texts, batch_lengths)
loss = criterion(logits, batch_labels)
optimizer.zero_grad()
loss.backward()
# 梯度裁剪:防止梯度爆炸
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
梯度裁剪(gradient clipping)是训练RNN/LSTM时的重要技巧。它通过将梯度的范数限制在某个阈值以内,有效防止梯度爆炸导致的训练不稳定问题。
训练完成后,模型能够对新的文本序列进行情感分类预测,判断其情感倾向为正或负。通过调整超参数(如 hidden_size、num_layers、学习率等)可以进一步优化模型性能。
核心要点总结
- RNN的本质: 通过共享权重和隐藏状态在时间维度上传递信息,专门用于处理序列数据
- RNN的局限: 梯度消失/爆炸导致标准RNN难以捕获长程依赖关系
- LSTM的突破: 引入单元状态和三个门控(遗忘门、输入门、输出门),为梯度提供通畅的传播路径
- GRU的简化: 将门控减少为两个(更新门、重置门),参数量更少,效率更高
- 双向RNN: 通过前向+后向处理捕获完整上下文,适合需要全局信息的任务
- 变长序列处理: pack_padded_sequence 和 pad_packed_sequence 是PyTorch中处理变长序列的标准方式
- 训练技巧: 梯度裁剪(防止梯度爆炸)+ 合理初始化(缓解梯度消失)是训练成功的关键
- 选择建议: 数据量大用LSTM,数据量小用GRU;在线预测用单向,离线任务用双向