有些序列任务只看附近几个词就能做得不错,有些任务却必须把很早的信息带到后面。比如“这份方案虽然前半段有争议,但在补齐证据之后,评审最终表示____”,最后一个词的判断会受到前面转折结构、论据和主语的共同影响。语音中的说话节奏、序列标注中的实体边界、流式传感器中的状态变化,也有类似的时间依赖。
普通循环神经网络把历史压进一个隐藏状态,形式很紧凑。困难在于:状态能够向前传,并不等于损失能够稳定地向后追责。当梯度跨越许多时间步时,每一步的局部导数会连乘;有效倍率持续小于 1,早期信息就收不到学习信号,持续大于 1,更新又可能突然失控。
这一讲不把 LSTM 和 GRU 当作需要背诵的方框图。我们会从雅可比连乘开始,逐项看门控如何改变梯度通路,再落实到双向网络、变长批次、梯度裁剪和模型选型。最后还有一组可复现的数值实验:它不会替任何任务宣布“谁更好”,只把梯度保留机制单独测出来。
先写出最常见的 Elman RNN。对第 个输入 ,隐藏状态为
如果损失只在序列末端计算,早期状态 收到的梯度要穿过后面的所有状态转移。记
则单步雅可比为
从 时刻回到 时刻的核心项是
这就是问题的骨架:不是一个矩阵,而是一串随输入变化的矩阵。用谱范数给出上界,可以看到
若有效倍率长期约为 ,跨 50 步后只剩 ;若长期约为 ,同样 50 步会放大到 。前者是梯度消失,后者是梯度爆炸。

这里有两个容易被一句“权重小于 1 就消失”掩盖的细节。
第一, 会随着状态变化。tanh 接近两端饱和时,导数接近 0;即使 的范数不小,激活饱和也可能切断梯度。第二,矩阵有方向。某个奇异值大于 1,并不保证每个样本、每条方向都爆炸;梯度是否持续落在被放大的方向上同样重要。因此,矩阵范数是有用的诊断工具,不是对每一步结果的精确预言。
“前向状态还带着一点旧信息”和“模型能从损失中学会该保留什么”是两件事。前者看隐藏状态是否可辨认,后者看梯度能否把责任分配回早期输入。长依赖训练失败,往往败在第二步。
下面的实验台把矩阵暂时压缩成一个有效倍率 。拖动倍率和反传步数,可以直接看见指数变化;纵轴使用对数刻度,消失与爆炸会变成方向相反的直线。
梯度爆炸和梯度消失来自同一类连乘,却会留下不同的训练痕迹。把它们混在一起,常见结果是看到损失不降就直接调学习率,最后仍不知道问题在哪里。
全局范数裁剪先把所有参数梯度看成一个长向量 。若其二范数超过阈值 ,就统一缩放:
统一缩放大体保留了梯度方向,只限制步长。它适合阻止一次异常更新把参数推离可用区域,却不能把接近零的长期梯度恢复出来。换句话说,裁剪治爆炸,不治消失。
optimizer.zero_grad(set_to_none=True)
loss.backward()
# 返回值是裁剪前的总范数,应该先记录再 optimizer.step()
total_norm = torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=1.0,
error_if_nonfinite=True,
)
optimizer.step()阈值不是固定答案。1.0、5.0 都只是常见起点,应该结合未裁剪范数的分布、学习率和任务损失来定。如果 99% 的批次都被大幅裁剪,裁剪很可能正在掩盖更深的数值或数据问题。
截断时间反向传播会每隔 步切断计算图,以换取可控的显存和计算量。它并不会阻止隐藏状态继续向前传,但损失无法越过切断点追责。若任务需要 200 步的信用分配,而 ,模型即使使用 LSTM 也不能靠一次反向传播把第 200 步的损失传回最早位置。
因此,排查长依赖时要同时问三个问题:状态有没有传过去,梯度有没有回得来,训练图有没有被人为截断。只回答其中一个,很容易误判。
普通 RNN 每一步都用同一个非线性变换重写隐藏状态。LSTM 增加细胞状态 ,让“保留旧记忆”和“写入新内容”通过加法汇合。现代常用形式有四组变换:
然后更新细胞状态与对外隐藏状态:
常见讲解会把 合称“四门”。严格说,、、 是输出在 的 sigmoid 门, 是范围在 的候选记忆。区分这件事很有用:门负责“放多少”,候选负责“写什么”。

沿着细胞状态的直接通路,把门值暂时视作已给定,可以得到
跨越多步的直接梯度因而包含
这里必须保留“直接通路”四个字。门值还依赖 ,总导数会有其他间接项;LSTM 不是把全部雅可比神奇地变成 。它真正提供的是一条可以学到接近恒等映射的加法路径。
如果某一维的遗忘门长期约为常数 ,它的记忆半衰期近似为
时半衰期约 13.5 步, 时约 69 步。门控能缓解长依赖,不代表记忆自动无限长;门长期小于 1,乘积仍会衰减。
下面的实验台把 LSTM 与 GRU 都压缩到一个维度。可以先点“长期保留”,再点“快速改写”,观察保留门、候选和输出如何分工。
GRU 不再分别维护 和 ,而是只维护隐藏状态 。以 PyTorch 当前实现对应的形式为例:

更新门 是新旧状态之间的插值系数。 接近 1 时,旧状态沿直通分量保留;接近 0 时,候选状态 主导更新。重置门 不直接清空最终状态,它控制计算候选时参考多少旧状态。于是两类问题被分开了:
若暂时把候选路径对旧状态的间接影响拿开,GRU 的直接保留项给出
所以 GRU 和 LSTM 都能学出接近恒等的时间通路,只是状态组织不同:LSTM 用独立细胞状态承载记忆,再通过输出门读出;GRU 让一个隐藏状态同时承担存储与输出。
原始常见写法会先计算 ,再乘隐藏权重;PyTorch 为提高计算效率,把重置门乘在隐藏线性变换之后。这两个形式参数化不同,不能把一个实现的权重逐项复制到另一个实现后期待完全相同的输出。阅读框架文档时,方程比“它是 GRU”这个名字更可靠。
“GRU 更快”“LSTM 更准”都不能当成通用结论。两者的差异常常小于随机种子、隐藏维度和训练预算带来的差异。稳妥做法是先算清资源,再在相同数据划分和预算下比较。
设输入维度为 、隐藏维度为 ,暂不计输出层。PyTorch 每个循环层为输入和隐藏变换各保留一组偏置,因此单向单层的参数量是:
不同框架若把两组偏置合并,偏置项会减半;主导项的比例仍大致是 。双向结构会为反向再建一套参数。多层网络中,第二层以后的 等于前一层输出维度;双向时通常是 ,所以不能只拿第一层公式乘层数。
先看因果约束。需要逐步输出、不能看未来时,只能使用单向单元;离线序列标注可以比较双向 GRU 与双向 LSTM。
再看状态和延迟预算。若设备需要同时维护大量会话,GRU 少一个细胞状态,通常更省状态存储;若任务希望把长期记忆与当前读出分开控制,LSTM 的独立细胞状态更直观。
固定隐藏维度、参数预算和训练步数各比较一次,再做“同参数量”比较。只固定隐藏维度会天然让 LSTM 拥有更多参数,不是完全公平的架构比较。
不只看总体分数。按序列长度、噪声比例和领域切片,记录精度、峰值内存、吞吐、首个输出延迟与长序列退化曲线。
门控单元也不是免调参方案。若数据真正只依赖很短窗口,简单卷积或普通 RNN 可能已经足够;若任务需要频繁随机访问全局上下文,注意力架构通常更自然。选 LSTM 或 GRU 之前,先确认问题确实需要递归状态。
门控解决的是单条递归通路,真实任务还要处理方向、深度和批次长度。很多“长句效果差”并非单元本身失效,而是未来信息泄漏、填充污染或状态索引用错。

双向 RNN 同时运行两个互不共享状态的递归网络:
每个位置的表示通常拼接为
命名实体识别、词性标注等离线任务可以使用整句,双向上下文很合适。自回归生成或严格流式预测在时刻 看不到 之后的输入,若直接使用反向状态,就把未来答案泄漏给模型。评估分数可能变高,部署时却无法复现。
堆叠 RNN 时,第 层在每个时间步接收第 层的输出。PyTorch 的 nn.LSTM(..., num_layers=3, dropout=0.2) 会在相邻循环层之间施加 dropout,最后一层之后不会由这个参数自动施加;num_layers=1 时该参数没有层间位置可用。
不要把每个时间步独立采样的普通 dropout 随手放进递归状态通路。不同时间步不断更换掩码,会给记忆路径注入额外噪声。需要循环 dropout 时,应明确选择时间一致的掩码或经过验证的专门方案。
pack_padded_sequence 让循环层跳过一部分填充计算;掩码则告诉损失、池化和指标哪些位置是真实数据。打包不能自动修好后续的损失函数,掩码也不会自动减少循环层在填充位置上的计算。
import torch
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence
# x: [batch, max_len, input_size]
# 文档要求:作为 Tensor 传入 pack 时,lengths 位于 CPU
lengths = torch.tensor([8, 5, 3], dtype=torch.long)
packed_x = pack_padded_sequence(
x,
lengths,
batch_first=True,
enforce_sorted=False,
)
packed_y, (h_n, c_n) = lstm(packed_x)
batch_first=True 只改变输入输出为 [batch, time, feature],不会改变 h_n 和 c_n 的布局;它们仍以 [direction × layer, batch, hidden] 组织。双向网络中,最后一个时间位置的 output 也不等于两个方向各自的最终状态,读取序列级表示时应重塑并索引 h_n,不要想当然地取 output[:, -1]。
下面的实验台会把三条不同长度的序列补齐。关闭掩码、改变填充值,就能看到一个看似普通的平均池化如何被填充位置拖走。
完整训练一个模型会同时混入数据难度、参数量、优化器和随机初始化。为了只看梯度通道,我们做一个更克制的实验:把状态放在零附近,使 tanh 导数约为 1;普通 RNN 的循环系数固定为 ,LSTM 的遗忘门和 GRU 的更新门固定为 。在第一个状态已经写入之后,测“末状态对首状态的导数”。
import math
def finite_difference(step_fn, transitions, epsilon=1e-7):
positive, negative = epsilon, -epsilon
for _ in range(transitions):
positive = step_fn(positive)
negative = step_fn(negative)
return (positive - negative) / (2 * epsilon)
def rnn_step(h):
return math.tanh(0.60
真实运行得到:
中心有限差分与解析值 、 的相对误差分别为 1.679e-15 和 1.506e-16,说明脚本测到的确实是预期通道。把普通 RNN 的循环系数改为 ,60 步导数变成 5.63455628e+04,同一套连乘立即从消失转成爆炸。
我们还用梯度向量 [12, -5, 3] 检查全局范数裁剪。裁剪前二范数为 13.341664,阈值设为 5 后统一乘 0.374766,裁剪后范数为 5.000000。这说明裁剪保留整体方向并压缩长度,而不是把每个分量分别截成 。
这组数值不是 LSTM、GRU 的精度或速度排名。我们固定门值、关闭候选的间接路径,只隔离“状态已经写入后能保留多少梯度”。真实门值由样本和训练决定;若遗忘门或更新门长期偏小,门控网络同样会遗忘。
可以再构造“延迟复制”数据:序列开头给一个随机符号,中间填入与标签无关的噪声,只在末尾要求复原开头符号。训练时逐步把间隔从 10 提到 100,并同时记录三件事:准确率、首位置梯度范数、门值分布。这样才能区分模型是保留了信号、学会了任务,还是碰巧利用了数据中的捷径。
为了公平,普通 RNN、LSTM、GRU 至少要做两套对照:相同隐藏维度和相近参数量;训练步数、优化器、学习率搜索范围、随机种子与早停规则保持一致。只跑一个种子,然后把最好的一次当成架构结论,没有足够证据。
门控结构给了优化器一条更好的路,但训练是否走上这条路,还取决于初始化、数据和监控方式。下面是一套比“换成 LSTM 就行”更可靠的检查顺序。
detach,避免计算图无限增长。dropout 只作用于循环层之间。想正则化输入、输出或递归权重,应分别实现并说明掩码策略。Transformer 用自注意力让任意两个位置在一层内直接交互,并能并行处理整段序列;这对大规模训练、全局上下文和预训练迁移很有吸引力。普通全局注意力的时间和显存又会随序列长度平方增长,而循环网络每来一步只更新固定大小的状态。
因此,下面这些约束下,LSTM 或 GRU 仍可能是合理答案:
离线文档理解、大规模语言建模、需要频繁访问远距离任意位置时,Transformer 或其他注意力架构通常更自然。窗口注意力、线性注意力和循环—注意力混合模型还会继续改变边界,所以选型应回到序列长度、吞吐、延迟、状态内存和质量切片,而不是按模型年代站队。
| 三组变换,单状态 |
| LSTM | 四组变换,记忆与读出分离 |