PyTorch实战:RNN与LSTM原理、实现与避坑指南
2026/9/9 19:56:13 网站建设 项目流程

循环神经网络(RNN)和长短期记忆网络(LSTM)是处理文本、语音、时序预测等序列数据时最常碰到的两个模型。在 PyTorch 里实现它们并不复杂,但很多人第一次跑的时候会把大量时间耗在维度报错、loss 不降和 NaN 上面。这一篇我按自己的实战顺序把这两类模型拆开讲:先理解核心公式,再用 PyTorch 内置模块跑通最小示例,最后补上训练和调参要避开的坑。适合已经会用 PyTorch 搭建全连接网络或 CNN、但还没有系统跑过序列模型的读者。

1. 先用一句话说清 RNN 和 LSTM 解决什么问题

1.1 标准 RNN 是怎么处理序列的

RNN 的工作方式可以理解成一个人逐字阅读文本:每次只看一个词,同时把前面看过的内容压缩成一个固定长度的隐藏状态,然后带着这个状态去看下一个词。这个隐藏状态就是网络在当前时间步的“记忆”。核心公式里,当前时间步的隐藏状态 h_t 由当前输入 x_t 和上一个隐藏状态 h_prev 共同决定:

h_t = tanh(x_t * W_ih^T + b_ih + h_{t-1} * W_hh^T + b_hh)

PyTorch 的nn.RNN封装的就是这套计算。和全连接网络不同,RNN 的所有时间步共享同一组权重,所以不管输入序列多长,模型的参数量都不会随着长度增长。这是它相比普通神经网络最大的特点。

举个例子:预测下一个词时,输入“今天天气很”这五个字,网络会逐个读入“今”“天”“天”“气”“很”。当读到“很”的时候,隐藏状态里已经浓缩了前面四个字的信息,再结合“很”本身,网络就能预测下一个字大概率是“好”“热”“冷”这类形容词。这种“边读边记”的方式,是处理变长序列的基础思路。

1.2 梯度消失是 RNN 的最大短板

理论上,RNN 应该能把很早期的信息通过隐藏状态一路带到后面。但实际训练时,标准 RNN 在长序列上的表现往往不如预期,核心原因是梯度消失。

反向传播时,梯度需要沿着时间步往前传。如果每一步的导数值小于 1,经过几十个时间步后,梯度会接近 0,网络无法更新,也就学不到“很久之前”的信息。反过来,如果每一步的导数值大于 1,梯度还可能爆炸,训练直接发散。

这就是为什么后来出现了 LSTM。LSTM 用一套门控机制让信息可以选择性通过,给梯度提供更稳的传播路径,从而缓解标准 RNN 的梯度消失问题。

1.3 什么场景优先考虑 LSTM

需要捕捉长期依赖、序列长度中等或偏长、数据是文本或时间序列时,优先考虑 LSTM。情感分类、机器翻译、语音识别、电力负荷预测、股价走势预测这类任务,都属于典型场景。

但我也要泼一盆冷水:LSTM 并不总是比 RNN 好。如果序列很短,比如只有三五个时间步,标准 RNN 往往更快,精度也不差。如果序列特别长,或者数据量非常大,LSTM 的顺序计算会成为瓶颈,这时通常要转向 GRU 或 Transformer。选模型之前,先想清楚数据长度和依赖距离。

2. PyTorch 环境准备:装错版本等于白跑

2.1 安装前先确认版本搭配

RNN/LSTM 本身对 PyTorch 版本不算挑剔,但如果你要用 GPU 加速,最好在安装前确认 Python 版本、PyTorch 版本、CUDA 驱动和显卡驱动之间的对应关系。

我一般会用 Anaconda 创建独立环境,避免把系统自带的 Python 弄乱:

conda create -n rnn_lstm python=3.10 conda activate rnn_lstm

接下来安装 PyTorch。不要直接复制网上很久以前给出来的命令,因为 PyTorch 安装命令会根据 CPU 还是 GPU、CUDA 版本是多少而不同。正确做法是打开 PyTorch 官网的安装选择器,选好自己的系统和驱动版本,复制生成的那条命令。

安装完成后,用两行代码验证最基础的环境是否正常:

import torch print(torch.__version__) print(torch.cuda.is_available())

如果输出torch.cuda.is_available()False,说明 PyTorch 没有识别到 CUDA。这不一定是代码问题,先查显卡驱动、CUDA 驱动和 PyTorch 版本是否匹配。如果在服务器上安装,还要确认当前用户对 CUDA 目录是否有可执行权限。

2.2 GPU 不够也完全能跑,但要把预期调低

如果你只有 CPU,或者一张 4GB 显存的旧显卡,不用一上来就放弃。RNN/LSTM 在小数据集上,CPU 也能跑,只是每个 epoch 会明显更慢。

我的建议是第一轮先把hidden_size设为 32 或 64,batch_size设为 16,序列长度截断在 50 以内。先用一个小配置验证代码逻辑,再决定要不要加大。不要一上来就把hidden_size拉到 512,在 CPU 上跑一个 5000 样本的文本任务,那会等到失去耐心。

如果使用 GPU 训练,经常还要看显存占用。序列任务里显存大头通常是中间缓存,不是模型参数。你可以在训练时用命令查看显存使用,如果接近显存上限,优先减小batch_size或序列长度,不要直接换更大的模型。

2.3 固定随机种子是复现前提

序列模型训练本身有随机性,如果不固定随机种子,同样的代码每次跑出来的 loss 曲线可能差很多。这不是模型代码写错了,而是初始化权重、数据打乱顺序不一样。

我通常在训练脚本的最前面统一固定三个随机源:

import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)

这就是为什么很多时候你看网上复现帖子,代码一模一样,但跑出来的结果对不上。不是帖子造假,是随机种子和数据顺序不同。调参之前,先固定种子。

3. 标准 RNN 实战:从核心公式到 nn.RNN

3.1 先理解时间步、隐藏状态和序列维度

假设输入序列是 x_1, x_2, ..., x_T,每个 x_t 是一个 input_size 维的向量。RNN 从初始隐藏状态 h_0 开始,在每个时间步 t 计算新的隐藏状态 h_t,同时根据需要输出一个预测结果。

在 PyTorch 的nn.RNN里,默认激活函数是tanh,也可以改成relu,需要在构造时指定nonlinearity='relu'tanh通常更稳,因为输出被限制在 [-1,1] 之间,梯度不容易爆炸。

理解维度是这一步最关键的事。PyTorch RNN 的输入通常有两种排列方式:

  • batch_first=False时,输入形状是[seq_len, batch, input_size]
  • batch_first=True时,输入形状是[batch, seq_len, input_size]

新手建议直接使用batch_first=True,这样在打印张量形状时更直观,后续接Linear层也不容易搞混维度顺序。

3.2 nn.RNN 最小示例

下面是一个最简单的 RNN Demo。输入是 4 个样本,每个样本 10 个时间步,每个时间步 8 维特征:

import torch import torch.nn as nn rnn = nn.RNN( input_size=8, hidden_size=16, num_layers=1, batch_first=True ) x = torch.randn(4, 10, 8) # [batch, seq_len, input_size] out, h_n = rnn(x) print(out.shape) # 期望 [4, 10, 16] print(h_n.shape) # 期望 [1, 4, 16]

这里有两个返回结果:

  • out是最后一个隐藏层在每个时间步的输出,形状是[batch, seq_len, hidden_size]
  • h_n是最后一个时间步的隐藏状态,形状是[num_layers, batch, hidden_size]

也就是说,out[:, -1, :]h_n[-1]的内容是一致的,都代表每个样本在最后一步的隐藏状态。

3.3 手写一个 RNN 单元,验证公式理解

只看封装 API 很难真正理解 RNN。我建议你像我一样,用 PyTorch 内部权重手动算一个时间步,再把结果和nn.RNN自动算的对比。

先创建一个带随机权重的 RNN:

rnn = nn.RNN(input_size=8, hidden_size=16, batch_first=True) x = torch.randn(4, 10, 8) out, h_n = rnn(x)

接着手动实现一步:

def custom_rnn_step(x_t, h_prev, rnn_cell): weight_ih = rnn_cell.weight_ih_l0 weight_hh = rnn_cell.weight_hh_l0 bias_ih = rnn_cell.bias_ih_l0 bias_hh = rnn_cell.bias_hh_l0 h_t = torch.tanh( x_t @ weight_ih.t() + bias_ih + h_prev @ weight_hh.t() + bias_hh ) return h_t h0 = torch.zeros(4, 16) h1 = custom_rnn_step(x[:, 0, :], h0, rnn) print(torch.allclose(h1, out[:, 0, :], atol=1e-6))

如果输出True,说明你手动算的第一步和 PyTorch 内置 RNN 完全一致。这一步做完,你再看 LSTM 的公式会更轻松。

4. LSTM 实战:门控机制和 nn.LSTM 用法

4.1 LSTM 比标准 RNN 多了什么

标准 RNN 只有一条隐藏状态 h 在向后传,LSTM 多了一条细胞状态 c。可以把 c 想象成一条传送带,它保存长期信息;h 则是当前时间步的输出状态。

LSTM 用三个门控来控制信息流动:

  • 遗忘门:决定从上一时刻的细胞状态里丢掉多少信息;
  • 输入门:决定当前时间步的新信息写入多少;
  • 输出门:决定最终从细胞状态中提取多少信息给隐藏状态。

因为这套机制,LSTM 在反向传播时给梯度提供了更稳定的路径,训练长序列时不容易出现标准 RNN 的梯度消失问题。代价是参数更多、计算更慢。

4.2 nn.LSTM 最小示例

PyTorch 里nn.LSTM的用法和nn.RNN几乎一样,只是返回值多一个细胞状态c_n

lstm = nn.LSTM( input_size=8, hidden_size=16, num_layers=1, batch_first=True ) x = torch.randn(4, 10, 8) out, (h_n, c_n) = lstm(x) print(out.shape) # [4, 10, 16] print(h_n.shape) # [1, 4, 16] print(c_n.shape) # [1, 4, 16]

out是最后一个隐藏层在每个时间步的输出,h_n是最后一个时间步的隐藏状态,c_n是最后一个时间步的细胞状态。如果你只需要最后的输出,通常取out[:, -1, :],或者直接取h_n[-1]

4.3 跑一个正弦波预测小实验:RNN vs LSTM

为了直观看到 LSTM 的长处,我建议你做一个最小对比:用sin函数生成 10000 个点,用前 20 步预测第 21 步。输入形状是[batch, seq_len, 1],标签沿时间往后移一步。

核心训练代码长这样:

model = nn.LSTM(input_size=1, hidden_size=32, num_layers=1, batch_first=True) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) loss_fn = nn.MSELoss() for epoch in range(50): model.train() optimizer.zero_grad() out, _ = model(x) # out: [batch, seq_len, hidden_size] pred = out[:, -1, :] # 取最后一个时间步的隐藏状态 loss = loss_fn(pred, y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()

在我本机跑下来的结果是:LSTM 收敛更稳,损失曲线没有明显突跳;标准 RNN 在同样学习率下,偶尔会跳到 NaN。短序列上两者差距不大,序列越长,LSTM 的稳定性优势越明显。你跑出来的具体数值可能不完全一样,但大概方向应该类似。

要注意这里predy的形状必须对齐。如果你使用out整个序列去计算损失,标签也要是一个完整的序列形状,常见报错就是形状不匹配。

5. 训练序列模型的四个高频坑和排查方法

5.1 输入维度搞错

这是序列模型最常见的坑。全连接层输入是二维[batch, feature],RNN/LSTM 输入是三维。如果使用batch_first=True,输入必须是[batch, seq_len, input_size]

很多人拿到一维数据,直接x.unsqueeze(-1)变成[batch, seq_len, 1],这很容易漏掉 batch 维度。报错信息里如果出现RuntimeError: Expected 3D tensor, got 2D,先打印x.shape,确认最后一维是不是input_size

如果是文本数据,字符串要先转成索引,再通过 embedding 层变成稠密向量。很多人以为 input_size 是词典大小,直接把样本长度传进去,这也会错。

5.2 隐状态初始化和传递

nn.RNNnn.LSTM不传隐状态时,PyTorch 会默认初始化为 0。如果你按批次训练,每个 batch 重新从 0 开始,通常没有问题。

但如果你想把一个很长的序列切成多个段,让模型记住前一段的信息,就要手动把上一个 batch 的h_n传给下一个 batch。这里最容易出问题的是梯度链太长。正确做法是取出来之后detach()再传入,避免梯度跨 batch 累加导致显存和梯度都爆炸。

h_n = h_n.detach()

如果 loss 突然变成 NaN,可以先检查有没有在跨 batch 传隐状态时忘记 detach。

5.3 损失函数维度和标签对齐

分类任务里,nn.CrossEntropyLoss要求输入形状是[batch, num_classes],标签形状是[batch]。如果是序列标注,每个时间步都有输出,需要把输出 reshape 成[batch * seq_len, num_classes],标签也对应 reshape。

回归任务相对简单,但也要注意predy的最后维度一致。如果报错信息是Expected input batch_size X to match target batch_size Y,不要急着改模型,先打印两个张量的 shape,对比 batch、seq_len、feature 分别差在哪。

5.4 梯度爆炸和 NaN

RNN 和 LSTM 训练时出现 NaN,通常是梯度爆炸或学习率过大。排查顺序是:

  1. 先看输入数据是否包含 NaN 或 inf;
  2. 再看学习率是否太大;
  3. backward()之后打印梯度范数;
  4. 使用梯度裁剪限制梯度最大值。

我通常会在优化器更新前加一行:

nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

如果梯度范数大于 10,大概率是学习率太高,先把学习率调到 1e-4 试试。不要一上来就换损失函数或改模型结构。

5.5 变长序列和 padding

文本任务里,同一个 batch 的句子长度往往不同,常规做法是补 0 到相同长度。但如果你直接拿 padding 后的序列输入 LSTM,模型会把 padding 部分也当成真实内容训练,结果会有偏差。

更稳妥的做法是使用nn.utils.rnn.pack_padded_sequencepad_packed_sequence,告诉 RNN 哪些位置是 padding。这里最容易踩的坑是数据必须按序列长度降序排列,否则 pack 之后的结果是错的。

不过在入门阶段,我建议先避开变长序列。可以把数据切成长度一致的片段,比如统一截断到 50 或 100,先把固定长度的流程跑通,再处理 padding 优化效率。

5.6 一条通用排查链路

遇到问题不要先改代码结构和网络层,按下面顺序查:

  1. 打印输入和标签的形状;
  2. 检查数据里是否有 NaN 或 inf;
  3. 看 loss 在最初几个 step 是否下降;
  4. 打印梯度范数,确认是否爆炸;
  5. 检查隐状态形状和 batch 维度;
  6. 缩小数据量,先跑 100 条样本,确认逻辑没问题,再全量训练。

这条顺序帮我排掉了大部分问题,尤其是前两步,经常能省下一个小时。

6. 参数调优和升级方向:从 LSTM 到 GRU 再到 Transformer

6.1 一组稳妥的默认参数

序列模型调参不是越复杂越好。我常用的起点配置是:

参数推荐值建议
hidden_size128数据量小时用 32 或 64
num_layers12 层起步要配 dropout
batch_size32显存不足降到 16
learning_rate1e-3用 Adam 优化器
dropout0单层 LSTM 不需要;多层建议 0.3
clip_grad_norm1.0训练开始阶段建议加上
epochs20小数据 50 到 100 个 epoch 也行

注意:不要盲目加num_layers。层数加多以后,模型参数量增大,训练变慢,小数据集上一不小心就过拟合。我见过很多新手把num_layers设为 4,结果 loss 反而不降,最后回到 1 层反而好了。

6.2 数据量小时可以试试 GRU

GRU 可以理解为 LSTM 的低配版,只有两个门:重置门和更新门,没有独立的细胞状态。它的参数比 LSTM 更少,训练更快,在小数据集和 CPU 环境下经常不输 LSTM。

PyTorch 里用nn.GRUnn.LSTM几乎一样:

gru = nn.GRU(input_size=8, hidden_size=16, num_layers=1, batch_first=True) out, h_n = gru(x)

注意nn.GRU返回的是out, h_n,没有细胞状态。

如果数据量只有几千条,我建议先跑 GRU,效果差不了太多,但调参速度会快很多。等你确定模型结构能跑通,再换成 LSTM 做对比。

6.3 长序列和大规模场景考虑 Transformer

LSTM 的问题在于必须按时间步顺序计算,序列越长越慢。Transformer 通过自注意力机制直接计算任意两个位置之间的关系,可以并行处理整个序列,在长文本、大规模语料上更有优势。

但 Transformer 并不是银弹。它的显存占用更高,需要更多数据,训练也不一定稳。如果序列只有几十个时间步,LSTM 完全够用;如果序列到达几百或上千,且你有足够 GPU 资源,可以试试nn.TransformerEncodernn.MultiheadAttention

对小项目来说,强行上 Transformer 只会增加代码复杂度和调试成本。

6.4 我的最终建议

如果你刚入门序列模型,先跑通一个固定长度的短序列任务。LSTM 是一个比标准 RNN 更稳妥的选择,但不代表它所有方面都更好。先把输入形状、输出形状、隐状态这三个核心点吃透,再考虑变长序列、mask、打包填充、注意力机制这些进阶内容。

踩过几次坑之后我发现,很多问题不是模型能力不够,而是前置环境和输入材料没有处理干净。序列模型尤其明显:数据形状错一个维度,loss 就不降;padding 没有 mask,训练再久也没有意义。先从小样例开始,跑稳了再扩大规模,这是我最想留给读者的一句话。

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

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

立即咨询