72·深度学习入门进阶

循环神经网络(RNN)与 LSTM

deep-learningrnn

第72课:循环神经网络(RNN)与 LSTM

学习目标

完成本课程后,你将能够:

  1. 理解循环神经网络(RNN)的核心思想及其在处理序列数据上的优势。
  2. 了解 RNN 面临的“梯度消失/爆炸”问题及其解决方案——长短期记忆网络(LSTM)。
  3. 使用 PyTorch 框架搭建一个基础的 RNN 模型和 LSTM 模型。
  4. 对比 RNN 和 LSTM 在处理长序列依赖关系时的性能差异。
  5. 了解 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 的二分类情感分析模型。

  1. 数据预处理:对文本进行分词,构建词汇表,将文本转换为索引序列。
  2. 模型构建:使用 nn.Embedding 层将索引转换为向量,然后接入 LSTM 和全连接层。
  3. 训练与评估:计算模型在测试集上的准确率。 预期输出:一个能基本区分正面/负面评价的 LSTM 模型,测试准确率应高于随机猜测(50%)。

练习3:进阶挑战

在练习2的 LSTM 模型基础上,尝试:

  1. 使用双向 LSTM (bidirectional=True)。
  2. 增加 Dropout 层以防止过拟合。
  3. 比较使用 GRU (nn.GRU) 替代 LSTM 的效果。 预期输出:通过对比不同模型在验证集上的准确率或损失,分析 GRU、单向 LSTM、双向 LSTM 的优缺点。

常见错误

  1. 序列形状混淆: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
    
  2. 忘记初始化隐藏状态:RNN/LSTM 需要初始隐藏状态 h0(LSTM 还需要细胞状态 c0)。虽然 PyTorch 可以自动初始化,但显式初始化并确保其维度正确是更好的习惯。

  3. 梯度爆炸问题:在训练 RNN/LSTM 时,梯度可能变得非常大(爆炸)。常用的解决方法是梯度裁剪

    # 在反向传播后、优化器更新前加入
    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    
  4. 忽略 packed_padded_sequence:当处理长度不一的序列时,需要进行填充(padding)并使用 pack_padded_sequencepad_packed_sequence 来提高计算效率并避免模型学习填充符号。忽略这一步会导致性能下降和训练错误。

小结

  • RNN 通过循环结构引入记忆,适合处理序列数据,但存在难以学习长距离依赖的“梯度消失”问题。
  • LSTM 通过精巧的门控机制(遗忘门、输入门、输出门)和记忆细胞,有效缓解了梯度消失问题,成为处理长序列任务的首选。
  • 在 PyTorch 中,使用 nn.RNNnn.LSTM 可以快速搭建这两种网络。关键点在于正确设置输入形状和初始化隐藏状态。
  • 选择 RNN 还是 LSTM?对于短序列或计算资源受限的场景,简单 RNN 可能足够;对于大多数需要理解上下文的序列任务(如机器翻译、情感分析、时间序列预测),LSTM 或其变体(如 GRU)通常是更安全、更强大的选择
  • 实践中,还需注意梯度裁剪、处理变长序列等技巧,以确保模型稳定训练。

练习编辑器

rust
Loading...

继续学习

完成本课后,建议继续学习下一课「迁移学习与预训练模型」 以巩固所学知识。