☰
PyTorch循环神经网络实战:从RNN到LSTM/GRU的序列预测
2026/10/19 8:24:32 网站建设 项目流程

一听到“循环神经网络”这个名字,不少人第一反应是:循环在哪?网络怎么还能循环?我最早被 RNN 这种结构吸引,是因为它处理的是真正“带顺序”的数据——文字、语音、传感器采样、股价,甚至你打字的按键序列。PyTorch 里实现 RNN 循环神经网络并不复杂,真正让新手卡住的是三件事:循环结构背后的直觉、张量维度到底怎么排、以及训练序列模型时那些隐蔽的坑。这篇文章就从这三个痛点出发,从原理讲到 PyTorch 实战,带你把 RNN、LSTM、GRU 这几个模型真正用起来。

这篇内容适合两类人:一类是学完 CNN、但还没碰过序列模型的人,另一类是已经跑过 RNN Demo、但总是 loss 不降或者维度报错的人。我会以两个 LSTM 层的时序预测为完整主线案例,从造数据、定义模型、训练循环,一直讲到常见的报错和排查思路。看完之后,你能跑通模型,也能自己动手把同样的套路迁移到文本、音频等其他序列任务上。

1. 为什么序列数据绕不开RNN:从“记不住”到“带记忆”

1.1 全连接网络处理序列的尴尬

先看看常规思路的问题。假设我要预测下一个时刻的电力负荷,前面 24 个小时的数据作为输入。用全连接网络也能做:把 24 个值拼成一个 24 维向量,丢进一个多层感知机,输出第 25 小时的数值。听起来可行,但有个致命的问题:网络根本不知道这 24 个维度谁先谁后。你把这 24 个数按任意顺序打乱,全连接网络的输出几乎不变。可时间序列里,顺序本身就是信息。“凌晨 2 点”和“下午 2 点”的耗电模式完全不一样,光靠位置编码和参数学习,很难真正建模这种依赖关系。

更麻烦的是跨序列长度的问题。今天你要用 24 小时预测第 25 小时,明天想用 48 小时预测,全连接网络的输入维度就得改,整个网络架构也得跟着重建。这类模型对“变长序列”几乎没有泛化能力。

语言也是同样的道理。“我打你”和“你打我”的词袋完全相同,语义却相反。全连接网络处理这类输入时,本质上把句子当成一个无序的特征集,丢失了语法结构。循环神经网络做的事情很简单:引入一个随时间步逐步更新的“状态变量”,让信息沿着时间一步一步传递下去。每一步处理新的输入时,都能参考之前积累的状态。

1.2 循环结构的本质:同一个网络被反复调用

RNN 的核心公式并不复杂。给定输入序列 (x_1, x_2, ..., x_t),在第 t 步,网络会维护一个隐藏状态 (h_t),更新规则是:

[ h_t = \tanh(W_{ih} x_t + b_{ih} + W_{hh} h_{t-1} + b_{hh}) ]

其中 (W_{ih}) 是当前输入到隐层的权重,(W_{hh}) 是上一步隐藏状态到当前隐藏状态的权重。注意一个关键点:这些权重在所有时间步上是共享的。也就是说,RNN 学到的是一个“如何根据当前输入和历史状态更新记忆”的规则,而不是为每个时刻单独训练一套参数。

如果把 RNN 的循环展开,它就变成了一条链:(h_1) 依赖 (x_1) 和初始的 (h_0),(h_2) 依赖 (x_2) 和 (h_1),以此类推。每个时刻的输出 (y_t) 通常只由 (h_t) 经过一个输出层得到。整个过程叫 BPTT(Backpropagation Through Time,随时间反向传播),误差信号会沿着这条展开的链路一层一层往回传,路径越长,梯度影响越不稳定。

我在实践中的体会是,刚开始写 RNN 时不需要把 BPTT 的数学推导背下来,但要有直觉:梯度要穿过“时间维度”,所以很多在普通前馈网络上不明显的问题,在 RNN 上会被放大。这也是为什么后面大家不直接推荐用最原始的 RNN,而更倾向于 LSTM 和 GRU。

1.3 为什么后来大家默认用LSTM/GRU

原始 RNN 在处理长序列时会遇到一个著名问题:梯度消失或梯度爆炸。原理不复杂:反向传播时,梯度要从最后一层一路乘回最开始的时间步。如果把每一步的乘子看作一个系数,那这个系数在一段距离上的连乘是指数级的。指数小于 1,梯度迅速缩到零,模型学不到“很久以前”的信息;指数大于 1,梯度爆掉,训练直接发散。

可以拿传话游戏来类比这句话的处境:十几个人站成一排,第一个人说一句话,然后逐个往传。每传一次,信息都会丢失或变形一点,传到队尾时,最早那句话只剩下模糊的影子。普通 RNN 就是这么传话的。LSTM 加了三个门控——遗忘门、输入门、输出门,每条信息流经过时可以决定“哪些旧记忆要丢弃、哪些新信息要写入、哪些状态要输出”,相当于让传话的人手里拿一个记事本,看到重要内容就写下来,再往下传的时候照着本子念。

GRU 则是 LSTM 的轻量版,把三个门简化成两个:重置门和更新门,内部参数更少,训练更快,在很多任务上效果不输 LSTM。我在实际项目里,如果资源紧张或者序列规模很大,会优先试 GRU;如果更看重对长期依赖的建模能力,还是 LSTM 更稳。文档里常说的“RNN”有时泛指这整个家族,有时单指最朴素的nn.RNN,读代码时要分辨一下。

2. PyTorch里RNN家族的API拆解:先搞懂维度再写代码

2.1 核心三个类怎么选

PyTorch 在torch.nn里提供了三个现成的模块:nn.RNN、nn.LSTM、nn.GRU。它们的接口长得非常像,学会一个,另两个也就懂了。构造函数最常用的参数都是这些:

  • input_size:每个时间步输入的特征维度。数值序列里通常为 1,文本里一般是 embedding 的维度。
  • hidden_size:隐藏状态的维度,相当于这个记忆单元的“容量”。
  • num_layers:纵向堆叠的层数,默认是 1。
  • batch_first:输入 batch 维度是否放在最前面。一般设成True,否则容易把形状搞混。
  • bidirectional:是否用双向结构,默认False。

三个类在小数据集上的表现差异,我整理成了对比表:

模型门控数量参数规模长依赖建模能力典型场景
nn.RNN无最小弱入门演示,短序列
nn.GRU2(重置门、更新门)中等较强资源受限、快速迭代
nn.LSTM3(遗忘门、输入门、输出门)较大强大多数序列建模任务

新手不用纠结每个门内部的计算细节,先记住选型逻辑:默认用 LSTM 当主力,简单任务和快速实验用 GRU。我曾经在字符级文本生成任务上对比过,GRU 训练时间比 LSTM 少大概 20%,生成质量差距很小。

2.2 batch_first和两个形状规则

PyTorch 的 RNN 家族默认输入形状是(seq_len, batch, input_size),即“序列长度”在最前面。对习惯图像任务的人来说,这个顺序非常别扭:你每次送进来的数据不都是batch打头吗?所以 PyTorch 提供了batch_first=True参数,让输入变成(batch, seq_len, input_size)。

下面用代码把两种写法都展示一下:

import torch import torch.nn as nn # 假设一个 batch 有 8 个样本,每个样本 10 个时间步,每个时间步 4 维特征 seq_len, batch, input_size = 10, 8, 4 # batch_first=False(默认) rnn = nn.LSTM(input_size, hidden_size=16, batch_first=False) x = torch.randn(seq_len, batch, input_size) out, state = rnn(x) # out: (10, 8, 16) # batch_first=True rnn_bf = nn.LSTM(input_size, hidden_size=16, batch_first=True) x_bf = torch.randn(batch, seq_len, input_size) out_bf, state_bf = rnn_bf(x_bf) # out_bf: (8, 10, 16)

这里的out和out_bf内容上是对应一致的,只是前两个维度的顺序换了。换句话说,batch_first=True只是把张量的形状重排,不会改变模型的计算逻辑。

新手最常见的报错是Expected 3D input, got 2D,多半就是少了 batch 维度。图像任务里我们已经习惯(B, C, H, W)四维,而序列任务里要不就是三维要么是带 embedding 的二维索引,把握住“每个 RNN 模块都必须看到三个维度”这个原则,排查起来会很快。

2.3 output和h_n到底谁是谁

用nn.LSTM的时候,前向返回两个值:output和(h_n, c_n)。很多人第一次看到这对返回值是懵的。output形状是(batch, seq_len, hidden_size)(前提是batch_first=True),它表示每一个时间步的最后一层输出。h_n形状是(num_layers, batch, hidden_size),它只保存最后一个时间步的隐藏状态。两者在“最后一层、最后一个时间步”这一点上是重合的:output[:, -1, :]就等于h_n[-1]。

这样说可能还不够直观,我直接上代码验证:

lstm = nn.LSTM(input_size=4, hidden_size=16, num_layers=2, batch_first=True) x = torch.randn(8, 10, 4) # (batch=8, seq_len=10, input=4) output, (h_n, c_n) = lstm(x) print(output.shape) # (8, 10, 16) print(h_n.shape) # (2, 8, 16) print(torch.allclose(output[:, -1, :], h_n[-1])) # True

做序列分类时,通常取output[:, -1, :]作为最后的语义表示,再丢给一个全连接层;做序列生成时,则每一步都可能要用到output中对应时间步的值。c_n是 LSTM 的细胞状态,一般只在跨时间步传递状态时用到,平时当返回值拆开就行。理解这一点能避免很多人把h_n当成整个序列的输出。

2.4 Embedding来了:文本任务的标准组合

文本是 RNN 最经典的应用领域。汉字、单词不能直接喂给网络,得先映射成整数索引,再映射成向量。nn.Embedding做的就是这件事:输入是形状(batch, seq_len)的整数张量,输出是(batch, seq_len, embedding_dim)。这个形状和batch_first=True的 RNN 刚好无缝衔接,这是为什么我强烈建议所有文本任务都统一开启batch_first=True。

vocab_size, embed_dim = 1000, 64 hidden_size = 32 emb = nn.Embedding(vocab_size, embed_dim) lstm = nn.LSTM(embed_dim, hidden_size, batch_first=True) fc = nn.Linear(hidden_size, vocab_size) x = torch.randint(0, vocab_size, (8, 20)) # (batch, seq_len) emb_out = emb(x) # (8, 20, 64) lstm_out, _ = lstm(emb_out) # (8, 20, 32) logits = fc(lstm_out) # (8, 20, 1000)

这样得到的logits每个时间步都对应一个概率分布,可以直接接nn.CrossEntropyLoss做文本生成或者词性标注。这里有一个容易忽略的细节:给nn.Embedding的索引必须是torch.long类型,而且不能超过vocab_size,否则会直接越界报错。另外,如果序列里有填充符,最好给nn.Embedding指定padding_idx,让填充位置在梯度更新时固定为零向量,避免无效信息干扰真实内容的学习。

3. 完整实战:用LSTM预测一段带噪声的正弦波

3.1 造一个干净可复现的时间序列数据集

很多教程一上来就用真实数据集,又要下载又要预处理,其实不太适合理解 RNN 的核心机制。我自己带新手入门时,更推荐先用合成数据把整个流程跑通。这里选带噪声的正弦波,因为它形状简单、有周期性规律,效果可预期,做出来很有成就感。

数据生成和滑动窗口切片的代码如下:

import numpy as np import torch from torch.utils.data import TensorDataset, DataLoader seq_len = 24 step = 4 total_points = 4000 t = np.linspace(0, 40 * np.pi, total_points) y = np.sin(t) + 0.05 * np.random.randn(len(t)) # 用前 seq_len 个点预测接下来的 seq_len 个点 X, Y = [], [] for i in range(0, total_points - seq_len * 2, step): X.append(y[i : i + seq_len]) Y.append(y[i + seq_len : i + seq_len * 2]) X = np.array(X).reshape(-1, seq_len, 1) Y = np.array(Y).reshape(-1, seq_len, 1) # 标准化:让均值接近0,方差接近1 mean_x, std_x = X.mean(), X.std() X = (X - mean_x) / std_x Y = (Y - mean_x) / std_x # 用同一组统计量 split = int(len(X) * 0.8) X_train, Y_train = X[:split], Y[:split] X_val, Y_val = X[split:], Y[split:] train_dataset = TensorDataset(torch.tensor(X_train, dtype=torch.float32), torch.tensor(Y_train, dtype=torch.float32)) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)

这里两个关键设计的理由说一下。滑动窗口的step=4意味着两个相邻样本间有大量重叠,数据量可以由此扩大;seq_len=24结合采样密度,刚好能覆盖超过一个周期的信息,太短模型学不到长期依赖,太长又增加训练开销。标准化这一步特别重要,正弦波的取值范围在[-1, 1]附近,看起来危害不大,但加上噪声、加上网络后续层数加深,未标准化的输入会让 LSTM 不同时间步的梯度尺度差异很大,具体表现就是 loss 起伏不定。我在这个项目里为了对比,试过不标准化直接训练,结果前几个 epoch 的 loss 曲线噪声明显变大,收敛也更慢。

3.2 定义一个两层LSTM序列回归模型

模型结构非常直接:输入是一段 24 步的一维序列,经过 LSTM 后每个时间步输出一个预测值。第一层 LSTM 负责从原始输入里提炼隐藏表示,第二层 LSTM 在更高层抽象上建模步与步之间的依赖,最后每个时间步的隐藏状态丢进一个线性层映射到预测值。

import torch.nn as nn class SequenceLSTM(nn.Module): def __init__(self, input_size=1, hidden_size=64, num_layers=2): super().__init__() self.lstm = nn.LSTM(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.lstm(x) out = self.fc(out) # 对每个时间步做一个预测 return out model = SequenceLSTM(input_size=1, hidden_size=64, num_layers=2) print(model)

为什么这里不用单层?我们的任务只有 24 步,单层理论上也能拟合参数。但实际跑下来,两层带来的收益不只是精度,还有训练稳定性。比如第一层负责对原始波形做短期局部特征的提取,第二层在这些特征上继续建模周期性依赖,相当于每个隐藏单元看到的抽象层次更高。我试过用单层 64 维跑同样的数据,loss 能降,但最终验证误差要高一些;堆到 3 层之后收益又非常小,训练时间却长了近一半。所以这个任务上,hidden_size=64, num_layers=2是性价比相当高的配置。

3.3 训练循环与损失曲线观察

训练代码和普通的回归模型几乎一样。唯一需要注意的是,RNN 对学习率比较敏感,Adam配上1e-3起步通常比较稳。如果 loss 一直不降,先检查数据预处理,再考虑调低学习率。

import torch.optim as optim device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) criterion = nn.MSELoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) for epoch in range(30): model.train() total_loss = 0.0 for xb, yb in train_loader: xb = xb.to(device) yb = yb.to(device) pred = model(xb) loss = criterion(pred, yb) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() avg_loss = total_loss / len(train_loader) if (epoch + 1) % 5 == 0: print(f"Epoch {epoch + 1}, Loss: {avg_loss:.6f}")

我训练完一轮后的 loss 输出大概是这样的轨迹:前 5 个 epoch 从 0.9 左右快速掉到 0.2 附近,10 个 epoch 之后慢慢降到 0.05 以下,最后稳定在 0.03 左右。这虽然是 MSE 值,绝对值看起来不大,但要注意数据是标准化过的,所以这个数值对应的误差大约只有峰值的百分之几。如果你的 loss 出现断崖式上升,多半是梯度爆炸;如果收敛到很大的值就卡住,优先怀疑学习率太高或数据没有归一化。

这里提一个细节:我在训练循环里没有跨时间步手动detach隐藏状态。因为每次训练 batch 里都是完整的独立序列,PyTorch 会在反向传播时自动把这批序列的梯度回传到起点,不存在跨 batch 累积状态的问题。等后面做很长的序列,才需要讨论截断 BPTT 和状态分离。

3.4 从单步到自回归:评估模型到底会不会“续写”

训练完不能只看 loss,还要实际生成序列看效果。最简单的方式是拿一段验证数据,取第一个时间步的输入,用模型连续预测多个未来值。

import matplotlib.pyplot as plt model.eval() with torch.no_grad(): x_val = torch.tensor(X_val[:1], dtype=torch.float32, device=device) pred = model(x_val).cpu().squeeze().numpy() true = Y_val[0] # 还原标准化 pred = pred * std_x + mean_x true = true * std_x + mean_x plt.plot(range(len(true)), true, label="true") plt.plot(range(len(pred)), pred, label="pred") plt.legend() plt.show()

单步预测的效果大概率非常好,因为每一步都给了真实的前一步输入。真正有挑战的是自回归:把预测出来的点作为下一步输入,连续滚动预测几十步。这个过程很像文本生成里“用上一句生成的词喂给下一步”,也是评估模型是否真的学到了序列规律。实现思路是:把窗口输入模型,只取最后一个时刻的预测点,然后把这个新预测点接到窗口末尾,再丢掉最前面的旧点,重新生成下一个点。

自回归滚动预测代码:

def rolling_predict(model, seed, steps=60): model.eval() window = seed.clone().unsqueeze(0) # (1, seq_len, 1) preds = [] with torch.no_grad(): for _ in range(steps): out = model(window) # (1, seq_len, 1) next_val = out[0, -1, 0].item() # 取最后一个预测 preds.append(next_val) new_window = torch.cat( [window[0, 1:], torch.tensor([[next_val]], device=window.device)], dim=0 ) window = new_window.unsqueeze(0) return np.array(preds)

经验上,前 15 步滚动预测和真实值重合度很高,30 步之后开始出现相位偏移,60 步之后误差明显放大。这是序列模型自回归模式的普遍现象:误差会随着生成逐步累积。如果这个现象太严重,可以考虑训练时引入类似“计划采样”的策略,让模型逐步适应把预测值当作输入;但在入门阶段,能做到 30 步内的准确预测已经说明模型学到了正弦波的基本规律。

4. 常见错误、坑位排查与模型选型建议

4.1 形状错误与显存失误的高频场景

RNN 新手报错率最高的三个地方,我总结成一张速查表:

报错信息片段常见原因解决办法
Expected 3D input (unbatched), got 2D输入没有 batch 维tensor.unsqueeze(0)或者在数据预处理时 reshape 成(batch, seq_len, input_size)
size mismatch for fc.weight全连接层的输入维度和 LSTM 隐藏维度不一致核对hidden_size和fc的第一维参数
IndexError: index out of rangeEmbedding输入索引越过了vocab_size检查词表构建,确认字典索引从 0 开始且没有漏词

我自己的排查习惯是:遇到报错先把张量形状全部打印出来,用print(x.shape)确认数据进入模型那一刻的实际形状,再倒推是哪一步 reshape 出了问题。PyTorch 的解包提示很多时候会直接告诉你某个维度是-1或0,这时候不用瞎猜,加一行打印比反复看文档高效得多。

显存问题在 RNN 上也很有特点。序列越长、batch 越大,显存占用就越高,因为 BPTT 需要把每个时间步的中间激活保留下来。如果 OOM,先减batch_size,然后把seq_len截断,最后才考虑换更小的hidden_size。我在处理长文本时就吃过亏:seq_len=512, batch=64直接爆显存,减到batch=16后顺利训练。

4.2 loss不降和梯度爆炸怎么治

loss 不降是很让人烦躁的。碰到这种情况,把排查顺序固定下来会省很多时间:先确认数据标准化、再确认标签没有错位、然后调低学习率到1e-4或1e-5、最后检查是否梯度爆炸。

梯度爆炸在 RNN 里特别容易发生,尤其是序列比较长、任务比较复杂的时候。症状非常典型:loss 在某个 epoch 突然从正常的池子跳到nan或inf。解决办法最直接的是梯度裁剪:

optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()

max_norm设置 1.0 到 5.0 之间通常能挡住爆炸。我曾经好奇试过不裁剪,结果一轮训练半天,loss 直接飞掉,之前的 epoch 全白费,后来所有 RNN 相关项目都会默认加这一行。函数内部对整体梯度的范数做了缩放,相当于给梯度设了一个安全阀。

另一个抗梯度爆炸的长效手段是把序列切短。有人会担心:切短了不就不能建模长期依赖了吗?这个问题可以用 LSTM/GRU 的门控机制来兜底。因为门控结构让信息可以选择性通过,在 50 步以内通常不需要靠截断来解决,真正需要的是超过几百步时,梯度传播路径太长导致内部状态互相干扰,那时候再考虑截断 BPTT、只反向传播最近的一段窗口。

4.3 状态传递、定长切分与效率取舍

h_n和c_n不只是返回值,在状态传递场景里也非常有用。假如你有两段连续的视频帧,第一段处理完之后的隐藏状态,可以作为第二段初始状态继续传,这样可以跨段保留长期记忆。做法是把前一阶段返回的(h, c)作为下一阶段的h0和c0:

h = torch.zeros(num_layers, batch, hidden_size) c = torch.zeros(num_layers, batch, hidden_size) for segment in segments: _, (h, c) = lstm(segment, (h, c))

这一招对在线预测和流式数据特别有用。但小心一点:如果跨段的梯度也不切断,loss 会穿过整段历史反向传播,稍不留神就会梯度爆炸或训练极其缓慢。我的习惯是:传状态做推断没问题,训练时如果序列很长,一般只保留最近一段窗口的梯度,即从上一段状态里detach出来再作为下一段的初始状态。

还有一种效率优化叫做“动态打包”。现实文本往往是变长的,给所有句子强行 padding 到同一个长度,浪费了大量计算在填充符上。PyTorch 里的pack_padded_sequence是专门干这个的,它能让 RNN 在计算时自动跳过填充位置。不过这套 API 的形状规则比较复杂,我建议新手先跑通定长版本,等序列长度差异非常大的时候,再学动态打包这个进阶技巧。

4.4 双向会不会更好:用信息方向理解模型

写代码时你还会遇到一个参数bidirectional=True。双向 RNN 的思路很简单:正向从前往后扫一遍,反向从后往前扫一遍,然后把两个方向的输出拼起来。做情感分类时,一句话读到结尾再回头看“转折词”,往往能更准确判断语义,所以双向结构在 NLP 分类任务里几乎是标配。

但时间序列预测要小心。如果用今天之前的数据预测今天之后的值,模型在真实系统里上线时根本不可能看到“未来的输入”。训练时玩双向预测,看起来 loss 低,上线后就会原形毕露。这是任务本身的信息结构决定的:在线预测、实时控制这类场景,只能单向;离线分类、语音识别这类能看到整个序列的任务,才适合双向。

说到选型,我的建议是:如果你是第一次在自己的数据上做 RNN 实验,先用 GRU 或 LSTM 的单层结构跑通基线,记录 loss 和训练时间,再逐步增加层数和隐藏维度。相比一上来就堆双层双向,这种小步快跑的方式更容易定位是模型问题还是数据问题。双向也不是任何时候都好,它会把参数量翻倍,还会让训练慢一倍左右,判断标准永远是“任务是否允许看到完整序列”。

最后再分享一个经验。我在实际项目中最大的体会,是 RNN 的代码模板性很强,真正让模型效果拉开差距的,往往不是网络结构本身,而是数据怎么切窗、目标怎么定义、状态怎么传递这些好像不起眼的细节。比如正弦波预测这个项目,切窗长度从 12 改成 48,模型捕捉长期趋势的能力就有肉眼可见的变化;loss 前几个 epoch 波动特别大时,加一个标准化就能让曲线平稳下来。希望你也能先动手跑通这个小项目,再把同样的流程迁移到自己的序列数据上,遇到问题慢慢调,总能玩明白。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询