机器学习基础(十三):循环神经网络RNN与LSTM
一、引言
前面我们学习的 CNN 擅长处理空间数据(图像),但现实世界还有大量序列数据:
- 一句话里的词是有顺序的;
- 股票价格随时间变化;
- 音乐是一段连续的音符序列;
- 传感器数据是时间序列。
这些数据有一个共同特点:当前状态与之前的状态有关。CNN 无法建模这种时序依赖,我们需要一种新的网络结构——循环神经网络(RNN)。
这篇文章从 RNN 的基本结构出发,逐步深入到长短期记忆网络(LSTM),最后用一个文本情感分类的实战案例收尾。
二、为什么需要 RNN?
2.1 序列数据的挑战
假设我们要预测句子"我今天很开心"的情感。如果用普通神经网络:
- 把每个词编码成 one-hot 向量;
- 拼接成一个大向量输入全连接层;
- 输出情感类别。
问题很明显:
- 丢失了顺序信息:"不 是 很 好" 和 "很 好 不 是" 被当成一样的输入;
- 输入长度固定:不同长度的句子需要填充或截断;
- 没有记忆:不知道前面的词是什么。
2.2 RNN 的核心思想
RNN 的关键设计:引入隐藏状态(Hidden State),让网络有"记忆"。
在每个时间步,RNN 接收两个输入:
- 当前输入 \(x_t\)(当前词/当前时刻的数据)
- 上一时刻的隐藏状态 \(h_{t-1}\)(之前的记忆)
然后输出:
- 当前时刻的隐藏状态 \(h_t\)(更新后的记忆)
- 当前时刻的预测 \(y_t\)(可选)
三、RNN 的结构与公式
3.1 基本结构
RNN 的核心计算只有两步:
其中:
- \(x_t\):时刻 \(t\) 的输入(维度 \(d_{in}\))
- \(h_t\):时刻 \(t\) 的隐藏状态(维度 \(d_{hidden}\))
- \(W_{xh}\):输入到隐藏的权重(\(d_{hidden} \times d_{in}\))
- \(W_{hh}\):隐藏到隐藏的权重(\(d_{hidden} \times d_{hidden}\))
- \(W_{hy}\):隐藏到输出的权重(\(d_{out} \times d_{hidden}\))
- \(\tanh\):激活函数,输出范围 \((-1, 1)\)
3.2 展开视角
如果把 RNN 按时间展开,它就是一个很深的网络:
x1 → [RNN Cell] → h1 → [RNN Cell] → h2 → [RNN Cell] → h3
↑ ↑ ↑
h0 h1 h2
(初始) (记忆1) (记忆2)
关键洞察:所有时间步共享同一套参数 \((W_{hh}, W_{xh}, b_h)\),这大大减少了参数量。
3.3 PyTorch 实现
import torch
import torch.nn as nn
# 定义 RNN
rnn = nn.RNN(
input_size=10, # 输入特征维度(如词向量维度)
hidden_size=20, # 隐藏状态维度
num_layers=1, # RNN 层数
batch_first=True # 输入格式为 (batch, seq, feature)
)
# 输入: (batch_size, seq_len, input_size)
x = torch.randn(2, 5, 10) # batch=2, 序列长度=5, 特征维度=10
# 前向传播
output, hidden = rnn(x)
# output: (2, 5, 20) - 每个时间步的输出
# hidden: (1, 2, 20) - 最后时刻的隐藏状态
四、RNN 的变体
根据输入输出的不同,RNN 有几种常见结构:
| 结构 | 输入 | 输出 | 典型应用 |
|---|---|---|---|
| 一对一 | 单输入 | 单输出 | 普通神经网络 |
| 一对多 | 单输入 | 序列输出 | 图像描述生成 |
| 多对一 | 序列输入 | 单输出 | 文本情感分类 |
| 多对多 | 序列输入 | 序列输出 | 机器翻译、语音识别 |
| 多对多(同步) | 序列输入 | 每个输入对应输出 | 视频逐帧分类、NER |
4.1 多对一:情感分类
class SentimentRNN(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, output_dim):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim)
self.rnn = nn.RNN(embed_dim, hidden_dim, batch_first=True)
self.fc = nn.Linear(hidden_dim, output_dim)
def forward(self, x):
# x: (batch, seq_len)
embedded = self.embedding(x) # (batch, seq_len, embed_dim)
_, hidden = self.rnn(embedded) # hidden: (1, batch, hidden_dim)
return self.fc(hidden.squeeze(0)) # (batch, output_dim)
4.2 多对多:序列生成
# 每个时间步都输出一个预测
class Seq2SeqRNN(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super().__init__()
self.rnn = nn.RNN(input_dim, hidden_dim, batch_first=True)
self.fc = nn.Linear(hidden_dim, output_dim)
def forward(self, x):
output, _ = self.rnn(x) # output: (batch, seq_len, hidden_dim)
return self.fc(output) # (batch, seq_len, output_dim)
五、RNN 的致命缺陷:梯度消失与梯度爆炸
5.1 问题现象
RNN 展开后是一个很深的网络(深度 = 序列长度)。当序列很长时:
- 梯度消失:梯度在反向传播时不断乘以小于1的数,前面层的梯度趋近于0,模型"记不住"久远的信息;
- 梯度爆炸:梯度不断乘以大于1的数,前面层的梯度指数级增长,参数更新失控。
5.2 数学解释
RNN 的反向传播涉及对 \(W_{hh}\) 的多次连乘:
如果 \(W_{hh}\) 的特征值大于1,梯度爆炸;小于1,梯度消失。
5.3 缓解方法
| 方法 | 原理 | 效果 |
|---|---|---|
| 梯度裁剪(Gradient Clipping) | 设定梯度范数上限 | 防止梯度爆炸 |
| 使用 ReLU 替代 tanh | 导数为0或1,缓解梯度消失 | 效果有限 |
| 更好的初始化 | 正交初始化 \(W_{hh}\) | 部分缓解 |
| LSTM/GRU | 引入门控机制 | 根本解决 |
梯度裁剪代码:
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5) # 裁剪到范数5
optimizer.step()
六、LSTM:长短期记忆网络
6.1 核心思想
LSTM(Long Short-Term Memory)通过门控机制控制信息的流动:
- 遗忘门:决定丢弃哪些旧信息;
- 输入门:决定存储哪些新信息;
- 输出门:决定输出哪些信息。
关键是引入细胞状态(Cell State) \(C_t\),它像一条"传送带",信息可以不变地流过,解决了梯度消失问题。
6.2 LSTM 的完整公式
遗忘门(forget gate):
输入门(input gate):
更新细胞状态:
输出门(output gate):
其中 \(\odot\) 表示逐元素乘法(Hadamard积),\(\sigma\) 是 sigmoid 函数(输出0~1,作为门的开关)。
6.3 LSTM 的直观理解
细胞状态 C_t-1 ───────────────────────────────→ C_t
↑ ↑
遗忘门 f_t 输入门 i_t
↓ ↓
丢弃旧信息 添加新信息
隐藏状态 h_t-1 ──→ [LSTM Cell] ──→ h_t
x_t ──→ ──→ (输出)
- 遗忘门 = 0:完全丢弃旧记忆;
- 遗忘门 = 1:完全保留旧记忆;
- 输入门 = 0:不添加新信息;
- 输入门 = 1:完全接受新候选。
6.4 PyTorch 实现 LSTM
lstm = nn.LSTM(
input_size=10,
hidden_size=20,
num_layers=2, # 堆叠2层LSTM
batch_first=True,
dropout=0.3 # 层间dropout(仅num_layers>1时有效)
)
x = torch.randn(2, 5, 10) # (batch, seq_len, input_size)
# LSTM 返回 output 和 (hidden, cell)
output, (hidden, cell) = lstm(x)
# output: (2, 5, 20) - 每个时间步的输出
# hidden: (2, 2, 20) - 每层最后时刻的隐藏状态
# cell: (2, 2, 20) - 每层最后时刻的细胞状态
6.5 GRU:LSTM 的简化版
GRU(Gated Recurrent Unit)将遗忘门和输入门合并为更新门,结构更简单:
GRU vs LSTM:
- GRU 参数量更少,训练更快;
- 效果上两者相当,GRU 在数据量小时可能更好;
- LSTM 表达能力更强,在复杂任务上略优。
# PyTorch GRU
gru = nn.GRU(input_size=10, hidden_size=20, num_layers=1, batch_first=True)
output, hidden = gru(x)
七、双向 RNN 与多层堆叠
7.1 双向 RNN(Bi-RNN)
有些任务需要同时考虑过去和未来的信息,比如命名实体识别(NER):
"我在北京工作" —— 知道"工作"在后面,才能确定"北京"是地点而非人名。
双向 RNN 同时运行两个 RNN:
- 一个正向处理序列(从左到右);
- 一个反向处理序列(从右到左);
- 最后拼接两个方向的隐藏状态。
# 双向 LSTM
bi_lstm = nn.LSTM(
input_size=10,
hidden_size=20,
num_layers=1,
batch_first=True,
bidirectional=True # 启用双向
)
output, _ = bi_lstm(x)
# output: (batch, seq_len, 40) - 20*2,两个方向拼接
7.2 多层堆叠
像 CNN 一样,RNN 也可以堆叠多层,提取更高层次的特征:
# 3层双向 LSTM
stacked_lstm = nn.LSTM(
input_size=10,
hidden_size=20,
num_layers=3,
batch_first=True,
bidirectional=True,
dropout=0.3 # 层间dropout
)
八、综合实战:IMDB 情感分类
下面用 LSTM 实现一个完整的电影评论情感分类模型:
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torchvision import datasets
import torchvision.transforms as transforms
# 加载 IMDB 数据集(使用 torchtext 或手动处理)
# 这里简化展示核心模型结构
class LSTMSentiment(nn.Module):
def __init__(self, vocab_size, embed_dim=128, hidden_dim=256,
num_layers=2, output_dim=1, dropout=0.5):
super().__init__()
# 词嵌入层
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
# 双向 LSTM
self.lstm = nn.LSTM(
embed_dim,
hidden_dim,
num_layers=num_layers,
bidirectional=True,
dropout=dropout if num_layers > 1 else 0,
batch_first=True
)
# 分类层
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(hidden_dim * 2, output_dim) # *2 for bidirectional
def forward(self, text, lengths):
# text: (batch, seq_len)
embedded = self.dropout(self.embedding(text)) # (batch, seq_len, embed_dim)
# Pack sequence for efficient processing
packed = nn.utils.rnn.pack_padded_sequence(
embedded, lengths.cpu(), batch_first=True, enforce_sorted=False
)
packed_output, (hidden, cell) = self.lstm(packed)
# hidden: (num_layers * 2, batch, hidden_dim)
# 取最后一层的正向和反向隐藏状态
hidden = torch.cat((hidden[-2], hidden[-1]), dim=1) # (batch, hidden_dim * 2)
hidden = self.dropout(hidden)
return self.fc(hidden) # (batch, 1)
# 训练循环
def train_epoch(model, iterator, optimizer, criterion):
model.train()
epoch_loss = 0
for batch in iterator:
text, text_lengths = batch.text
labels = batch.label.float()
optimizer.zero_grad()
predictions = model(text, text_lengths).squeeze(1)
loss = criterion(predictions, labels)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1) # 梯度裁剪
optimizer.step()
epoch_loss += loss.item()
return epoch_loss / len(iterator)
# 使用示例
VOCAB_SIZE = 10000
model = LSTMSentiment(VOCAB_SIZE)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.BCEWithLogitsLoss()
# 假设有数据迭代器 train_iterator
# for epoch in range(10):
# loss = train_epoch(model, train_iterator, optimizer, criterion)
# print(f"Epoch {epoch+1}, Loss: {loss:.4f}")
九、RNN 的局限与 Transformer 的崛起
9.1 RNN/LSTM 的问题
尽管 LSTM 缓解了梯度消失,但仍有一些根本局限:
| 问题 | 说明 |
|---|---|
| 串行计算 | 必须按顺序处理,无法并行,训练慢; |
| 长程依赖仍有限 | 超过100步的依赖仍难以捕捉; |
| 难以捕捉全局关系 | "它"指代句子开头的哪个词?需要全局注意力。 |
9.2 Transformer 的解决方案
2017年,Transformer 架构提出自注意力机制(Self-Attention):
- 完全并行:一次性看到整个序列;
- 任意距离依赖:任意两个位置的关联直接计算;
- 成为现代 NLP/CV 的基石:BERT、GPT、ViT 都基于 Transformer。
RNN 并未完全淘汰:
- 资源受限场景(移动端);
- 实时流式处理(必须按顺序);
- 小数据集(RNN 参数少,不易过拟合)。
十、总结
| 知识点 | 要点 |
|---|---|
| RNN 核心 | 隐藏状态传递记忆,参数共享; |
| 梯度问题 | 长序列导致梯度消失/爆炸; |
| LSTM 解决 | 门控机制 + 细胞状态,保留长期记忆; |
| GRU | LSTM 的简化版,效果相当; |
| 双向/多层 | 捕捉双向信息,提取层次特征; |
| 实战技巧 | 梯度裁剪、pack_padded_sequence、取最后时刻隐藏状态; |
| 演进路线 | RNN → LSTM/GRU → Attention → Transformer。 |
关键洞察:RNN 教会了我们如何建模序列,LSTM 教会了我们如何用门控保护梯度。这些思想在 Transformer 中依然延续——只是换了一种实现方式。
附录:RNN/LSTM 速查表
# RNN
nn.RNN(input_size, hidden_size, num_layers, batch_first=True)
# LSTM
nn.LSTM(input_size, hidden_size, num_layers,
batch_first=True, bidirectional=True, dropout=0.5)
# GRU
nn.GRU(input_size, hidden_size, num_layers, batch_first=True)
# 梯度裁剪
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# 序列填充(处理变长序列)
from torch.nn.utils.rnn import pad_sequence, pack_padded_sequence
padded = pad_sequence(sequences, batch_first=True, padding_value=0)
packed = pack_padded_sequence(padded, lengths, batch_first=True, enforce_sorted=False)

浙公网安备 33010602011771号