第16课:PyTorch|循环神经网络RNN与序列数据处理【让模型拥有“记忆”】

文章目录


📖 课前导读

为什么图像模型不能直接用于文本和时序数据?

在第11课到第15课中,我们学习了如何处理图像数据,并搭建了CNN模型取得了不错的分类效果。但如果给你一段文本"我昨天去了____,今天回到北京",要求预测空白处最可能是"上海"还是"吃饭",图像模型就无能为力了。

这是因为文本、音频、时间序列这类数据有一个共同特征:序列性 ------ 每个数据点(字、词、采样点)出现的含义强烈依赖于它在序列中的位置以及前后文内容。全连接网络和CNN假设输入是独立同分布的,无法捕捉这种"时间依赖"(或更广义的"序列依赖")。

循环神经网络(RNN)正是为了解决这个问题而设计的。其核心思想是:在每一个时间步,网络不仅接收当前时刻的输入,还接收上一个时间步输出的隐藏状态作为"记忆",从而让信息在序列中持续传递。

💡 生活类比:RNN就像你在读一本小说。你每读一个句子(当前输入),都会结合你对前面剧情的记忆(隐藏状态)来理解这个句子的含义,然后更新你的记忆并继续读下一个句子。CNN则像是只看一张独立的图片,完全不知道前后页的故事。

学完这一课,你将能够:

  • ✅ 理解RNN的循环结构和隐藏状态传递机制
  • ✅ 掌握PyTorch中nn.RNNnn.RNNCell的用法
  • ✅ 正确配置RNN的输入形状([seq_len, batch, input_size]
  • ✅ 处理变长序列(填充、pack_padded_sequence等)
  • ✅ 实现一个基础的时序预测任务(正弦波预测)
  • ✅ 认识RNN的梯度消失问题及其表现

一、知识原理:RNN的循环机制

1.1 序列数据与独立同分布假设的冲突

机器学习通常假设数据是独立同分布的(i.i.d.)。但在序列数据中,这个假设不成立:今天股票价格和昨天价格高度相关;句子中下一个单词的概率依赖于前面的单词。RNN通过引入隐藏状态 来打破i.i.d.假设,让模型学习条件分布P(y_t | x_t, h_{t-1})

1.2 RNN的计算单元:一个简单的循环层

一个基础RNN层在时间步t的计算包括两个公式:

复制代码
h_t = tanh(W_ih @ x_t + b_ih + W_hh @ h_{t-1} + b_hh)
y_t = W_ho @ h_t + b_ho(可选,取决于任务)
  • x_t:当前时间步的输入,形状 (input_size,)
  • h_{t-1}:上一时间步的隐藏状态,形状 (hidden_size,)
  • W_ih:输入到隐藏的权重矩阵,形状 (hidden_size, input_size)
  • W_hh:隐藏到隐藏的权重矩阵,形状 (hidden_size, hidden_size)
  • tanh:激活函数,将输出压缩到(-1,1)区间

RNN的"循环"体现在W_hh上:同一组权重在每个时间步被重复使用,这就是权值共享的体现,也是RNN能处理变长序列的根本原因。

1.3 按时间展开:将循环视为深度网络

如果把RNN在时间上"展开",它等价于一个深度前馈网络,其中每一层的权重是共享的。但传统前馈网络不同层有不同的参数,而RNN所有时间步共享W_ihW_hh。这种设计大幅减少了参数量,也使得模型可以泛化到训练时未见过的序列长度。

1.4 RNN的输入输出模式

根据任务不同,RNN有多种输入输出结构:

模式 输入形状 输出形状 典型任务
多对多(每个时间步输出) [seq_len, batch, input_size] [seq_len, batch, output_size] 序列标注(词性标注)
多对一(仅最后输出) [seq_len, batch, input_size] [batch, output_size] 文本分类、情感分析
多对多(编码器-解码器) 编码器同上,解码器逐步生成 变长输出 机器翻译、文本生成
一对多 [1, batch, input_size] [seq_len, batch, output_size] 图像描述生成

本课重点学习前两种模式。

1.5 RNN的梯度消失与爆炸问题

理论分析表明,在反向传播时,误差项沿时间轴传播需要乘以矩阵(W_hh)^T的多次幂。如果W_hh的特征值大于1,梯度会指数级爆炸;如果特征值小于1,梯度会指数级消失。这导致基础RNN很难学习超过10个时间步的长距离依赖。第17课将讲解LSTM和GRU如何通过门控机制缓解这个问题。


二、环境搭建与准备

python 复制代码
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, Dataset
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler

print(f"PyTorch版本: {torch.__version__}")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备: {device}")

# 设置随机种子
torch.manual_seed(42)
np.random.seed(42)

三、代码实战:PyTorch中的RNN

3.1 基础RNN层的使用

PyTorch提供了两种构建RNN的方式:nn.RNN(封装了整个循环层)和nn.RNNCell(只计算一个时间步,需要手动循环)。

使用nn.RNN(推荐)
python 复制代码
# 参数:input_size, hidden_size, num_layers, batch_first
rnn = nn.RNN(input_size=10, hidden_size=20, num_layers=1, batch_first=True)
print(rnn)

# 创建输入:batch_size=3, seq_len=5, input_size=10
x = torch.randn(3, 5, 10)

# 初始化隐藏状态:num_layers=1, batch_size=3, hidden_size=20
h0 = torch.zeros(1, 3, 20)

# 前向传播
output, hn = rnn(x, h0)

print(f"输入形状: {x.shape}")        # [3, 5, 10]
print(f"输出形状: {output.shape}")   # [3, 5, 20] (每个时间步的隐藏状态)
print(f"最后隐藏状态形状: {hn.shape}") # [1, 3, 20]

参数解读

  • batch_first=True:输入形状为[batch, seq_len, input_size](更符合直觉)。默认False时为[seq_len, batch, input_size]
  • num_layers:RNN堆叠的层数(深度),每层都接收上一层的输出作为输入。
  • hn:最后一个时间步的隐藏状态,常用于序列分类任务的输入。
使用nn.RNNCell(手动循环,更灵活)
python 复制代码
rnn_cell = nn.RNNCell(input_size=10, hidden_size=20)

x = torch.randn(3, 5, 10)  # [batch, seq_len, input_size]
h = torch.zeros(3, 20)     # 初始隐藏状态

outputs = []
for t in range(x.shape[1]):
    h = rnn_cell(x[:, t, :], h)   # 输入当前时间步的特征 (3,10)
    outputs.append(h)

output_seq = torch.stack(outputs, dim=1)  # [3, 5, 20]
print(f"RNNCell输出形状: {output_seq.shape}")

3.2 理解输入输出形状的转换

python 复制代码
# 以文本分类为例:3个句子,每个句子5个单词,词向量维度为10
batch_size, seq_len, input_size = 3, 5, 10
x = torch.randn(batch_size, seq_len, input_size)

# 单层RNN
rnn_single = nn.RNN(input_size, 20, batch_first=True)
out, h = rnn_single(x)
print(f"单层输出形状: {out.shape}")  # [3,5,20]

# 多层RNN (num_layers=2)
rnn_multi = nn.RNN(input_size, 20, num_layers=2, batch_first=True)
out_multi, h_multi = rnn_multi(x)
print(f"多层输出形状: {out_multi.shape}")     # [3,5,20]
print(f"多层隐藏状态形状: {h_multi.shape}")   # [2,3,20]

3.3 多对一:序列分类模型

python 复制代码
class RNNClassifier(nn.Module):
    """
    用于序列分类的RNN模型
    输入:[batch, seq_len, input_size]
    输出:[batch, num_classes](分类logits)
    """
    def __init__(self, input_size, hidden_size, num_classes, num_layers=1):
        super().__init__()
        self.rnn = nn.RNN(input_size, hidden_size, num_layers, 
                          batch_first=True, nonlinearity='tanh')
        self.fc = nn.Linear(hidden_size, num_classes)
    
    def forward(self, x):
        # out: [batch, seq_len, hidden_size]
        # h: [num_layers, batch, hidden_size]
        out, h = self.rnn(x)
        # 取最后一个时间步的输出(或取h[-1])
        last_output = out[:, -1, :]   # [batch, hidden_size]
        logits = self.fc(last_output)
        return logits

# 测试
model = RNNClassifier(input_size=10, hidden_size=32, num_classes=2)
x = torch.randn(4, 8, 10)  # batch=4, seq_len=8, input_size=10
logits = model(x)
print(f"分类logits形状: {logits.shape}")  # [4, 2]

3.4 处理变长序列:填充与打包

在实际任务中,序列长度往往不相等(如句子长度不同)。通常做法是:

  1. 将所有序列填充(padding)到同一长度(在末尾添加0向量)。
  2. 使用torch.nn.utils.rnn.pack_padded_sequencepad_packed_sequence让RNN跳过填充部分,提高效率并避免填充值影响隐藏状态。
python 复制代码
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence

# 示例:3个序列,原始长度分别为5、3、4
seq_lengths = torch.tensor([5, 3, 4])
# 填充后的输入(假设input_size=2)
x_padded = torch.randn(3, 5, 2)  # batch_first=True
# 将第2个序列的第4、5时间步填充为0,第3个序列的第5时间步填充为0

# 按长度降序排序(pack_padded_sequence要求)
seq_lengths_sorted, indices = seq_lengths.sort(descending=True)
x_sorted = x_padded[indices]

# pack
packed = pack_padded_sequence(x_sorted, seq_lengths_sorted.cpu(), 
                               batch_first=True, enforce_sorted=True)

# 通过RNN
rnn = nn.RNN(input_size=2, hidden_size=4, batch_first=True)
packed_out, h = rnn(packed)

# unpack
x_unpacked, lengths = pad_packed_sequence(packed_out, batch_first=True)
print(f"Unpacked形状: {x_unpacked.shape}")  # [3,5,4]
print(f"实际长度: {lengths}")  # [5,3,4]

注意pack_padded_sequence要求输入已按序列长度降序排列,且seq_lengths必须在CPU上。

3.5 实战:正弦波预测(时序预测任务)

我们将使用RNN预测正弦波的未来值。输入过去几个时间点的值,预测下一个值。

python 复制代码
# 生成正弦波数据
def generate_sine_wave(seq_length=1000, freq=0.1):
    t = np.linspace(0, 4 * np.pi, seq_length)
    y = np.sin(freq * t) + 0.05 * np.random.randn(seq_length)  # 加噪声
    return y

data = generate_sine_wave(seq_length=2000, freq=0.2)
plt.plot(data[:200])
plt.title("Sine Wave Data")
plt.show()

# 创建序列样本(滑动窗口)
def create_sequences(data, seq_len=10):
    xs, ys = [], []
    for i in range(len(data) - seq_len):
        xs.append(data[i:i+seq_len])
        ys.append(data[i+seq_len])
    return np.array(xs), np.array(ys)

seq_len = 10
X, y = create_sequences(data, seq_len)
print(f"X形状: {X.shape}, y形状: {y.shape}")  # (1990, 10), (1990,)

# 划分训练/测试
train_size = int(0.8 * len(X))
X_train, X_test = X[:train_size], X[train_size:]
y_train, y_test = y[:train_size], y[train_size:]

# 转换为PyTorch张量
X_train = torch.tensor(X_train, dtype=torch.float32).unsqueeze(-1)  # [batch, seq_len, 1]
y_train = torch.tensor(y_train, dtype=torch.float32).unsqueeze(-1)
X_test = torch.tensor(X_test, dtype=torch.float32).unsqueeze(-1)
y_test = torch.tensor(y_test, dtype=torch.float32).unsqueeze(-1)

print(f"训练集形状: {X_train.shape}, {y_train.shape}")

# 定义RNN预测模型
class SinePredictor(nn.Module):
    def __init__(self, input_size=1, hidden_size=32, num_layers=2):
        super().__init__()
        self.rnn = nn.RNN(input_size, hidden_size, num_layers, 
                          batch_first=True)
        self.fc = nn.Linear(hidden_size, 1)
    
    def forward(self, x):
        # x: [batch, seq_len, 1]
        out, _ = self.rnn(x)          # out: [batch, seq_len, hidden]
        # 取最后一个时间步的输出
        out_last = out[:, -1, :]      # [batch, hidden]
        pred = self.fc(out_last)      # [batch, 1]
        return pred

model = SinePredictor(input_size=1, hidden_size=32, num_layers=2).to(device)
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

# 训练
epochs = 30
batch_size = 32
train_dataset = torch.utils.data.TensorDataset(X_train, y_train)
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)

model.train()
for epoch in range(epochs):
    total_loss = 0
    for batch_x, batch_y in train_loader:
        batch_x, batch_y = batch_x.to(device), batch_y.to(device)
        optimizer.zero_grad()
        pred = model(batch_x)
        loss = criterion(pred, batch_y)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    if (epoch+1) % 10 == 0:
        print(f"Epoch {epoch+1}/{epochs}, Loss: {total_loss/len(train_loader):.6f}")

# 测试
model.eval()
with torch.no_grad():
    X_test_gpu = X_test.to(device)
    pred_test = model(X_test_gpu).cpu().numpy()

# 可视化预测结果
plt.figure(figsize=(12, 5))
plt.plot(y_test[:200].numpy(), label='True')
plt.plot(pred_test[:200], label='Predicted')
plt.legend()
plt.title("Sine Wave Prediction (RNN)")
plt.show()

3.6 使用RNN进行简单的文本分类(基于字符)

作为序列数据的另一个典型应用,我们实现一个基于字符级别的RNN文本分类器。

python 复制代码
# 简单的英文姓名国籍分类数据集(虚构)
names = ['Adam', 'Alice', 'Bob', 'Charlie', 'David', 'Emma', 'Frank', 'Grace', 
         'Hannah', 'Isaac', 'Julia', 'Kevin', 'Lisa', 'Michael', 'Nina']
nationalities = [0, 0, 1, 1, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0]  # 0: English, 1: Other

# 字符到索引映射
chars = set(''.join(names))
char_to_idx = {ch: i+1 for i, ch in enumerate(sorted(chars))}  # 0留作padding
idx_to_char = {i: ch for ch, i in char_to_idx.items()}
vocab_size = len(char_to_idx) + 1  # 加padding

def name_to_tensor(name, max_len=10):
    indices = [char_to_idx.get(ch, 0) for ch in name]
    # 填充或截断
    if len(indices) < max_len:
        indices += [0] * (max_len - len(indices))
    else:
        indices = indices[:max_len]
    return torch.tensor(indices, dtype=torch.long)

# 准备数据集
max_len = 10
X_text = torch.stack([name_to_tensor(name, max_len) for name in names])
y_text = torch.tensor(nationalities)
print(X_text.shape)  # [15, 10]

# 定义字符级RNN分类器
class CharRNNClassifier(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_size, num_classes):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.rnn = nn.RNN(embed_dim, hidden_size, batch_first=True)
        self.fc = nn.Linear(hidden_size, num_classes)
    
    def forward(self, x):
        # x: [batch, seq_len] (indices)
        embedded = self.embedding(x)   # [batch, seq_len, embed_dim]
        out, h = self.rnn(embedded)    # out: [batch, seq_len, hidden]
        last_out = out[:, -1, :]       # 取最后时间步
        logits = self.fc(last_out)
        return logits

char_model = CharRNNClassifier(vocab_size=vocab_size, embed_dim=8, hidden_size=16, num_classes=2)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(char_model.parameters(), lr=0.01)

# 训练(小数据集,手动迭代)
char_model.train()
for epoch in range(100):
    optimizer.zero_grad()
    outputs = char_model(X_text)
    loss = criterion(outputs, y_text)
    loss.backward()
    optimizer.step()
    if (epoch+1) % 20 == 0:
        _, pred = torch.max(outputs, 1)
        acc = (pred == y_text).float().mean()
        print(f"Epoch {epoch+1}, Loss: {loss.item():.4f}, Acc: {acc:.4f}")

四、难点解析:常见问题与陷阱

4.1 batch_first参数的选择

  • 默认batch_first=False(输入形状[seq_len, batch, input_size]),这是PyTorch早期设计,兼容性考虑。
  • 推荐设置batch_first=True,更符合直觉,且与CNN、Linear等模块保持一致。

4.2 处理变长序列时忘记排序

pack_padded_sequence要求seq_lengths按降序排列,且输入按相同顺序。如果不排序会得到错误结果或报错。

4.3 RNN的梯度消失表现

在长序列上训练RNN,如果损失下降缓慢或根本不下降,且网络深层梯度趋近于0,说明发生了梯度消失。解决方案:使用LSTM/GRU(第17课)、梯度裁剪、调整激活函数等。

4.4 多层RNN的隐藏状态维度

h_n的形状是(num_layers, batch, hidden_size),取最后一层隐藏状态应使用h_n[-1]而不是直接取h_n

4.5 输入数据类型错误

RNN的输入必须是浮点型(float32),而文本词索引是整型。需要在embedding层之后才能送入RNN。

4.6 梯度爆炸的监测与处理

如果loss突然变为NaN或inf,且参数值变得极大,说明梯度爆炸。解决方案:使用梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)


五、课后总结

核心知识点回顾

概念 解释
隐藏状态 在每个时间步传递的记忆向量
时间步展开 将循环视为共享权重的深度网络
nn.RNN 封装好的循环层,支持多层和批量处理
nn.RNNCell 单步RNN,需要手动循环
变长序列处理 pack_padded_sequence + pad_packed_sequence
多对一/多对多 RNN的两种主要输出模式
梯度消失/爆炸 沿时间轴反向传播时的指数效应

检查清单

  • 理解RNN隐藏状态的更新公式
  • 能正确设置nn.RNN的参数并理解输入输出形状
  • 知道如何处理变长序列(填充、打包)
  • 能够实现一个简单的时序预测模型
  • 认识梯度消失问题的表现和原因
  • 会使用梯度裁剪防止梯度爆炸

六、课后作业

作业1:多对一RNN情感分析

使用IMDB数据集(可用torchtext或手动加载小样本),实现一个RNN分类器,输入为文本序列(单词索引),输出为情感类别(正面/负面)。训练并评估模型。

作业2:多对多序列标注

构建一个简单的POS标注任务(每个单词预测词性)。使用nn.RNN输出每个时间步的标签,计算序列标注的准确率。

作业3:梯度消失实验

生成长度为100的随机序列数据,训练一个RNN去预测序列的最后一个值。分别使用tanh和ReLU作为激活函数,观察不同激活函数下训练收敛速度的差异。可视化隐藏状态梯度的范数随时间的衰减。

作业4:变长序列处理

创建一批长度不同的序列(如5,3,8,4),填充后使用pack_padded_sequence和RNN,再解包。验证解包后填充位置的值是否为0,并确保RNN没有在这些时间步上更新隐藏状态(通过检查输出的对应位置)。

作业5:正弦波预测超参数调优

在正弦波预测任务中,调整seq_len(5,10,20)、hidden_size(16,64,128)、num_layers(1,2,3),记录测试集的MSE。找出最优配置并分析原因。

作业6:RNNCell实现自己的RNN

使用RNNCell手动实现一个MyRNN模块,使其行为与nn.RNN相同(支持多层、batch_first)。并与官方版本对比输出,验证正确性。


七、下一课预告

第17课我们将学习LSTM与GRU门控循环网络精讲实战,内容包括:

  • LSTM的遗忘门、输入门、输出门原理
  • GRU的简化门控结构
  • 双向循环网络(BiRNN)
  • 多层LSTM堆叠
  • 时序分类与时序回归实战

LSTM和GRU是目前处理长序列的标准工具,学完第17课,你将能解决更复杂的序列任务。


🔗《精讲25课|PyTorch 从入门到精通》系列课程导航

去订阅

🌟 感谢您耐心阅读到这里!

💡 如果本文对您有所启发欢迎:

👍 点赞📌 收藏 📤 分享给更多需要的伙伴。

🗣️ 期待在评论区看到您的想法, 共同进步。

🔔 关注我,持续获取更多干货内容~

🤗 我们下篇文章见~

相关推荐
Luhui Dev1 小时前
大模型 Token 与成本优化工程指南
人工智能·ai·agent·luhuidev
木子算法1 小时前
测出来的值会抖:约束和目标带噪声时,「可行」和「更好」该怎么判
人工智能·算法·目标跟踪
IT·陈寒1 小时前
JavaScript实战技巧总结
人工智能·大模型·api·创业·变现·简历优化
精益数智工坊2 小时前
元数据管理怎么落地?元数据管理实施路径有哪些?
大数据·人工智能·数据挖掘·数据可视化
IvanLiu2 小时前
Cloudflare Worker实现余额预留与Token结算
人工智能
回眸&啤酒鸭2 小时前
【回眸】学习力重建与卡牌游戏融合应用指南
人工智能
万物智能信息科技2 小时前
PWM散热风扇设置—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
人工智能·华为·开源·harmonyos·鸿蒙
catcatuncle2 小时前
读了一个开源 AI Agent 的源码后,我重新思考了"文件验收"这件事
人工智能
二川bro2 小时前
Agent安全执行命令:命令分级+Hook+读写锁
人工智能