
文章目录
-
- [📖 课前导读](#📖 课前导读)
- 一、知识原理:RNN的循环机制
-
- [1.1 序列数据与独立同分布假设的冲突](#1.1 序列数据与独立同分布假设的冲突)
- [1.2 RNN的计算单元:一个简单的循环层](#1.2 RNN的计算单元:一个简单的循环层)
- [1.3 按时间展开:将循环视为深度网络](#1.3 按时间展开:将循环视为深度网络)
- [1.4 RNN的输入输出模式](#1.4 RNN的输入输出模式)
- [1.5 RNN的梯度消失与爆炸问题](#1.5 RNN的梯度消失与爆炸问题)
- 二、环境搭建与准备
- 三、代码实战:PyTorch中的RNN
-
- [3.1 基础RNN层的使用](#3.1 基础RNN层的使用)
- [3.2 理解输入输出形状的转换](#3.2 理解输入输出形状的转换)
- [3.3 多对一:序列分类模型](#3.3 多对一:序列分类模型)
- [3.4 处理变长序列:填充与打包](#3.4 处理变长序列:填充与打包)
- [3.5 实战:正弦波预测(时序预测任务)](#3.5 实战:正弦波预测(时序预测任务))
- [3.6 使用RNN进行简单的文本分类(基于字符)](#3.6 使用RNN进行简单的文本分类(基于字符))
- 四、难点解析:常见问题与陷阱
-
- [4.1 batch_first参数的选择](#4.1 batch_first参数的选择)
- [4.2 处理变长序列时忘记排序](#4.2 处理变长序列时忘记排序)
- [4.3 RNN的梯度消失表现](#4.3 RNN的梯度消失表现)
- [4.4 多层RNN的隐藏状态维度](#4.4 多层RNN的隐藏状态维度)
- [4.5 输入数据类型错误](#4.5 输入数据类型错误)
- [4.6 梯度爆炸的监测与处理](#4.6 梯度爆炸的监测与处理)
- 五、课后总结
- 六、课后作业
- 七、下一课预告
- [🔗《精讲25课|PyTorch 从入门到精通》系列课程导航](#🔗《精讲25课|PyTorch 从入门到精通》系列课程导航)
📖 课前导读
为什么图像模型不能直接用于文本和时序数据?
在第11课到第15课中,我们学习了如何处理图像数据,并搭建了CNN模型取得了不错的分类效果。但如果给你一段文本"我昨天去了____,今天回到北京",要求预测空白处最可能是"上海"还是"吃饭",图像模型就无能为力了。
这是因为文本、音频、时间序列这类数据有一个共同特征:序列性 ------ 每个数据点(字、词、采样点)出现的含义强烈依赖于它在序列中的位置以及前后文内容。全连接网络和CNN假设输入是独立同分布的,无法捕捉这种"时间依赖"(或更广义的"序列依赖")。
循环神经网络(RNN)正是为了解决这个问题而设计的。其核心思想是:在每一个时间步,网络不仅接收当前时刻的输入,还接收上一个时间步输出的隐藏状态作为"记忆",从而让信息在序列中持续传递。
💡 生活类比:RNN就像你在读一本小说。你每读一个句子(当前输入),都会结合你对前面剧情的记忆(隐藏状态)来理解这个句子的含义,然后更新你的记忆并继续读下一个句子。CNN则像是只看一张独立的图片,完全不知道前后页的故事。
学完这一课,你将能够:
- ✅ 理解RNN的循环结构和隐藏状态传递机制
- ✅ 掌握PyTorch中
nn.RNN和nn.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_ih和W_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 处理变长序列:填充与打包
在实际任务中,序列长度往往不相等(如句子长度不同)。通常做法是:
- 将所有序列填充(padding)到同一长度(在末尾添加0向量)。
- 使用
torch.nn.utils.rnn.pack_padded_sequence和pad_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 从入门到精通》系列课程导航
🌟 感谢您耐心阅读到这里!
💡 如果本文对您有所启发欢迎:
👍 点赞📌 收藏 📤 分享给更多需要的伙伴。
🗣️ 期待在评论区看到您的想法, 共同进步。
🔔 关注我,持续获取更多干货内容~
🤗 我们下篇文章见~