第72课:循环神经网络(RNN)与 LSTM
学习目标
完成本课程后,你将能够:
- 理解循环神经网络(RNN)的核心思想及其在处理序列数据上的优势。
- 了解 RNN 面临的“梯度消失/爆炸”问题及其解决方案——长短期记忆网络(LSTM)。
- 使用 PyTorch 框架搭建一个基础的 RNN 模型和 LSTM 模型。
- 对比 RNN 和 LSTM 在处理长序列依赖关系时的性能差异。
- 了解 RNN/LSTM 在自然语言处理、时间序列分析等领域的典型应用。
核心概念
1. 循环神经网络(RNN)
想象你在读一句话:“今天天气很好,我去____。” 预测空缺的词时,你需要结合前文。传统的神经网络(如全连接网络、CNN)处理输入时,每个样本是独立的,无法利用历史信息。
RNN 的核心思想是引入“循环”结构。它不仅接收当前时刻的输入,还会接收上一时刻的隐藏状态。这个隐藏状态就像网络的“记忆”,包含了过去输入的信息。
通俗比喻:RNN 像一个有短期记忆的人。他边听句子边记笔记,笔记(隐藏状态)会随着每个新单词更新。最终,他基于最新的笔记做出判断。
2. 长短期记忆网络(LSTM)
标准的 RNN 虽然能记住一些信息,但在处理长序列时,早期的信息往往会被后来的信息“冲刷”掉,导致无法学习长距离依赖(例如,文章开头和结尾的关联)。这被称为“梯度消失”问题。
LSTM 是一种特殊的 RNN 结构,它通过引入记忆细胞和三个“门”(输入门、遗忘门、输出门)来解决这个问题。
- 记忆细胞:一条贯穿整个序列的信息传送带,可以长期保存信息。
- 遗忘门:决定从记忆细胞中丢弃多少旧信息。
- 输入门:决定当前输入有多少新信息被存入记忆细胞。
- 输出门:决定记忆细胞中哪些信息用于当前时刻的输出。
通俗比喻:LSTM 像一个认真做笔记的人。他不仅更新笔记(RNN),还会:
- 划掉笔记中不再重要的旧内容(遗忘门)。
- 在重要的新内容下划线(输入门)。
- 根据当前问题,从笔记中摘抄相关部分来回答(输出门)。 这使得笔记(记忆细胞)能够保留真正重要的长期信息。
代码示例
我们将使用 PyTorch 构建一个简单的字符级序列预测模型,对比 RNN 和 LSTM 的表现。
import torch
import torch.nn as nn
import numpy as np
# 1. 准备数据:一个简单的序列预测任务(预测下一个字符)
text = "hello world" * 10 # 创建重复的文本数据
chars = sorted(list(set(text)))
char_to_int = {ch: i for i, ch in enumerate(chars)}
int_to_char = {i: ch for i, ch in enumerate(chars)}
vocab_size = len(chars)
seq_length = 4 # 输入序列长度
# 创建训练数据 (X: 输入序列, y: 目标字符)
dataX, dataY = [], []
for i in range(0, len(text) - seq_length):
seq_in = text[i:i + seq_length]
seq_out = text[i + seq_length]
dataX.append([char_to_int[char] for char in seq_in])
dataY.append(char_to_int[seq_out])
X = torch.tensor(dataX, dtype=torch.float32).reshape(-1, seq_length, 1) # (样本数, 序列长度, 特征数)
y = torch.tensor(dataY, dtype=torch.long)
# 数据归一化到 [0, 1]
X = X / float(vocab_size)
# 2. 定义模型
class SimpleRNN(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super(SimpleRNN, self).__init__()
self.hidden_size = hidden_size
self.rnn = nn.RNN(input_size, hidden_size, batch_first=True)
self.fc = nn.Linear(hidden_size, output_size)
def forward(self, x):
# 初始化隐藏状态 (num_layers, batch_size, hidden_size)
h0 = torch.zeros(1, x.size(0), self.hidden_size)
# 前向传播RNN
out, hn = self.rnn(x, h0)
# 取最后一个时间步的输出用于分类
out = self.fc(out[:, -1, :])
return out
class SimpleLSTM(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super(SimpleLSTM, self).__init__()
self.hidden_size = hidden_size
self.lstm = nn.LSTM(input_size, hidden_size, batch_first=True)
self.fc = nn.Linear(hidden_size, output_size)
def forward(self, x):
# 初始化隐藏状态和细胞状态
h0 = torch.zeros(1, x.size(0), self.hidden_size)
c0 = torch.zeros(1, x.size(0), self.hidden_size)
# 前向传播LSTM
out, (hn, cn) = self.lstm(x, (h0, c0))
out = self.fc(out[:, -1, :])
return out
# 3. 训练函数
def train_model(model, epochs=100):
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
for epoch in range(epochs):
outputs = model(X)
loss = criterion(outputs, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if (epoch+1) % 25 == 0:
print(f'Epoch [{epoch+1}/{epochs}], Loss: {loss.item():.4f}')
return model
# 4. 训练并评估
print("训练 SimpleRNN:")
rnn_model = SimpleRNN(input_size=1, hidden_size=16, output_size=vocab_size)
rnn_model = train_model(rnn_model, epochs=100)
print("\n训练 SimpleLSTM:")
lstm_model = SimpleLSTM(input_size=1, hidden_size=16, output_size=vocab_size)
lstm_model = train_model(lstm_model, epochs=100)
# 5. 测试预测
def predict_next_char(model, start_str):
model.eval()
with torch.no_grad():
input_seq = [char_to_int[ch] for ch in start_str[-seq_length:]]
input_tensor = torch.tensor(input_seq, dtype=torch.float32).reshape(1, seq_length, 1) / float(vocab_size)
output = model(input_tensor)
predicted_idx = torch.argmax(output, dim=1).item()
return int_to_char[predicted_idx]
test_str = "hell"
print(f"\n给定 \"{test_str}\",预测下一个字符:")
print(f"RNN 预测: {test_str}{predict_next_char(rnn_model, test_str)}")
print(f"LSTM 预测: {test_str}{predict_next_char(lstm_model, test_str)}")
实践练习
练习1:基础实现
修改上述代码,将序列长度 seq_length 增加到 10,观察 RNN 和 LSTM 模型的最终损失值。哪个模型损失更低?为什么?
预期输出:应观察到 LSTM 的损失值通常低于 RNN,尤其是在序列较长时,因为 LSTM 更擅长处理长程依赖。
练习2:情感分析
使用 torchtext 或加载一个简单的影评数据集(如 IMDB 的简化版),构建一个基于 LSTM 的二分类情感分析模型。
- 数据预处理:对文本进行分词,构建词汇表,将文本转换为索引序列。
- 模型构建:使用
nn.Embedding层将索引转换为向量,然后接入 LSTM 和全连接层。 - 训练与评估:计算模型在测试集上的准确率。 预期输出:一个能基本区分正面/负面评价的 LSTM 模型,测试准确率应高于随机猜测(50%)。
练习3:进阶挑战
在练习2的 LSTM 模型基础上,尝试:
- 使用双向 LSTM (
bidirectional=True)。 - 增加 Dropout 层以防止过拟合。
- 比较使用 GRU (
nn.GRU) 替代 LSTM 的效果。 预期输出:通过对比不同模型在验证集上的准确率或损失,分析 GRU、单向 LSTM、双向 LSTM 的优缺点。
常见错误
-
序列形状混淆:PyTorch 的 RNN/LSTM 期望输入张量的形状为
(序列长度, 批量大小, 特征维度)或(批量大小, 序列长度, 特征维度)(当batch_first=True时)。初学者容易搞错维度顺序。# 错误示例:假设 batch_size=32, seq_len=10, features=1 x = torch.randn(10, 32, 1) # (seq_len, batch, features) 正确 x = torch.randn(32, 10, 1) # (batch, seq_len, features) 需要设置 batch_first=True -
忘记初始化隐藏状态:RNN/LSTM 需要初始隐藏状态
h0(LSTM 还需要细胞状态c0)。虽然 PyTorch 可以自动初始化,但显式初始化并确保其维度正确是更好的习惯。 -
梯度爆炸问题:在训练 RNN/LSTM 时,梯度可能变得非常大(爆炸)。常用的解决方法是梯度裁剪。
# 在反向传播后、优化器更新前加入 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) -
忽略
packed_padded_sequence:当处理长度不一的序列时,需要进行填充(padding)并使用pack_padded_sequence和pad_packed_sequence来提高计算效率并避免模型学习填充符号。忽略这一步会导致性能下降和训练错误。
小结
- RNN 通过循环结构引入记忆,适合处理序列数据,但存在难以学习长距离依赖的“梯度消失”问题。
- LSTM 通过精巧的门控机制(遗忘门、输入门、输出门)和记忆细胞,有效缓解了梯度消失问题,成为处理长序列任务的首选。
- 在 PyTorch 中,使用
nn.RNN和nn.LSTM可以快速搭建这两种网络。关键点在于正确设置输入形状和初始化隐藏状态。 - 选择 RNN 还是 LSTM?对于短序列或计算资源受限的场景,简单 RNN 可能足够;对于大多数需要理解上下文的序列任务(如机器翻译、情感分析、时间序列预测),LSTM 或其变体(如 GRU)通常是更安全、更强大的选择。
- 实践中,还需注意梯度裁剪、处理变长序列等技巧,以确保模型稳定训练。