编程靠的是直觉而不是背公式。我在学 LSTM 很长一段时间里,每次看到那张三行门控公式的图,第一反应都是“又要记四个 sigmoid 和 tanh 的分工”,然后当场劝退。直到后来在序列建模项目里,用 LSTM 解决了普通 RNN 一直收敛不了的问题,才真正意识到,这个网络的核心并不在门多不多,而在于它把“记忆”本身变成了一个可以持续更新的状态通道。这篇是深度学习动手系列的第四十篇,我想用一种更适合理解的方式,把长短期记忆网络(LSTM)从原理、公式、手写实现到训练细节完整地过一遍,目标是让看过的人不仅能背下公式,还能知道每一步计算在做什么、哪些超参数值得调、哪些坑值得避开。
如果你正在学循环神经网络(RNN),或者已经了解过 RNN 和它的简化版门控循环单元(GRU),这篇文章会非常合适。我会先讲清楚 LSTM 到底解决了什么问题,再把遗忘门、输入门、输出门一一拆开,最后给出一个可以直接跑通的最小代码实现和一组工程调参经验。即使你只写过几行 Python,也能跟着一步一步看懂。
1. 梯度消失是怎么把普通RNN的记忆杀死的:从长距离依赖说起
1.1 一个看似简单却很难学好的任务
想象你正在做一个对话模型,输入是“小明在法国的巴黎度过了五年,期间他学会了流利的___”,正确输出大概率是“法语”。这句话的关键信息“巴黎”“法国”在很前面,而需要预测的词出现在句子结尾。对模型来说,它需要跨越比较长的距离,把前面的位置信息保留到后面使用,这就是所谓的“长距离依赖”问题。
普通 RNN 处理这种情况的效果往往不太好。原因不复杂,但需要从反向传播的角度看。RNN 在时间维度上是共享权重的,也就是同一个矩阵 W 被重复使用很多次。训练时要通过时间的反向传播(Backpropagation Through Time,BPTT)计算梯度,每往回走一个时间步,就需要乘一次这个 W 的转置。
如果 W 的特征值模长小于 1,这个连乘会让梯度指数级地衰减;如果大于 1,梯度又会指数级地爆炸。梯度爆炸还好一点,至少能看出来训练不稳定、loss 变成 NaN,可以用梯度裁剪兜底。最麻烦的是梯度消失,因为此时梯度看起来数值正常,loss 也在往下走,但模型实际上已经学不到遥远位置的依赖了,只会利用局部上下文做一个看似合理、实则短期主义的预测。
1.2 加一条“高速公路”的直观动机
理解 LSTM 的设计,最好的切入点是把普通 RNN 想成一条普通公路,信息在上面每过一个时间步都要被“压缩”一次,经过多次非线性变换后,越远的信息变形越严重。LSTM 的思路,是额外修一条贯穿所有时间步的垂直通道,叫细胞状态(cell state),符号通常记作 C。这条通道上的信息更新主要是线性操作——遗忘掉一部分旧信息,再叠加一部分新信息。
这样一来,梯度从输出端往回传的时候,可以沿着这条直通的细胞状态路径走,不需要每次都经过非线性压缩层。路径上的导数近似为 1,梯度消失的问题就被明显缓解了。这条“高速公路”正是 LSTM 与普通 RNN、GRU 在结构上最重要的区别,也是它名字里“长短期”的含义:短期信息由隐状态 h 携带,每一时刻都在剧烈变化;长期信息由细胞状态 C 携带,相对稳定地跨时间传递。
所以,与其把 LSTM 的三个门当成需要硬背的公式,不如把它理解成一辆在高速公路上行驶的车:要进入新的货物,得先看看闸口开多大(输入门);要丢掉旧包袱,又得看看水龙头关多少(遗忘门);最后决定对外展示多少信息,再由输出门把关。
2. 细胞状态上的三个闸口:遗忘门、输入门、输出门的工作逻辑
2.1 遗忘门:决定丢掉多少旧记忆
遗忘门接收的输入是当前时刻的输入 x_t 和上一时刻的隐状态 h_{t-1},经过线性变换后,再过 sigmoid 激活函数,输出一个 0 到 1 之间的值:
f_t = σ(W_f · [h_{t-1}, x_t] + b_f)
sigmoid 输出接近 1 表示“尽量保留”,接近 0 表示“尽量忘记”。它是一个软开关,而不是二值开关,所以整个过程是处处可微的,能用梯度下降训练。
一个很容易被误解的点是:遗忘门并不是真的在“遗忘”,而是在对细胞状态做缩放。假设某一步模型学会了把 f_t 输出为 0.9,那么上一步的 90% 信息会被保留下来。经过连续 10 步,大约能保留 0.9^10 ≈ 0.35。这比普通 RNN 里每一步被非线性压缩到只剩很小一部分要强得多,但仍然会有衰减。这也就是为什么在特别长的序列上,LSTM 依然存在理论上限,只是比 RNN 高了很多。
2.2 输入门和候选记忆:决定写入什么新内容
遗忘门决定了要丢掉多少旧记忆,接下来就是决定写入多少新记忆。输入门 i_t 负责控制输入 x_t 的影响大小:
i_t = σ(W_i · [h_{t-1}, x_t] + b_i)
真正要写入的候选内容则用 tanh 生成,符号记作 g_t 或者 ~C_t:
g_t = tanh(W_g · [h_{t-1}, x_t] + b_g)
之后,细胞状态的更新公式就是把“旧记忆按遗忘门缩放”和“候选记忆按输入门缩放”相加:
C_t = f_t * C_{t-1} + i_t * g_t
这里有一个值得注意的细节:sigmoid 输出范围是 (0,1),适合做“闸门”;tanh 输出范围是 (-1,1),适合做“候选内容”,因为内容本身需要带正负号,并且可以被梯度稳定地更新。把两类激活函数混在一起,是 LSTM 最容易被初学者记混的地方,也恰恰是它的设计精妙之处。
2.3 输出门:决定向外暴露多少状态
隐状态 h_t 才是真正对外输出给下一层或下一个时间步的信息。输出门 o_t 控制从细胞状态 C_t 中取出多少内容:
o_t = σ(W_o · [h_{t-1}, x_t] + b_o)
h_t = o_t * tanh(C_t)
先对更新后的细胞状态做 tanh 压缩到 (-1,1),再用输出门的值做缩放。这样做的意义在于,细胞状态 C_t 的内部数值范围可能很大,直接作为隐状态传给后续层不够稳定,先规范化再缩放输出,模型就能灵活控制每一时刻“说”多少、保留多少。
2.4 四个组件的角色总结
为了方便记忆和对照,我把四个组件的职责整理成一个表:
| 组件 | 符号 | 激活函数 | 在做什么 | 类比 |
|---|---|---|---|---|
| 遗忘门 | f_t | sigmoid | 旧细胞状态的衰减系数 | 家里清理旧物件的“断舍离”开关 |
| 输入门 | i_t | sigmoid | 新候选内容的写入比例 | 往书包里塞新书的决策 |
| 候选记忆 | g_t | tanh | 当前输入带来的候选信息 | 新书本身的内容 |
| 输出门 | o_t | sigmoid | 从细胞状态中取多少给隐状态 | 对外展示多少藏书的决定 |
其实 LSTM 并不是什么神秘的黑盒。从计算图上看,它的底部就是常规的线性变换和激活函数,多出来的东西只是“一条直连通道 + 四个可学习的控制旋钮”。一旦接受了这个视角,后面所有推导都能顺下来。
3. 手写一个最小可运行的LSTM单元:从公式到张量操作
3.1 准备工作:理解输入的维度
在实现前,先把维度这件事弄清楚。假设一个 batch 的输入形状是 (batch_size, seq_len, input_size),其中 input_size 是每个时间步特征向量的维度。LSTM 的隐状态 h 和细胞状态 c 的维度都是 hidden_size。
传统写法里经常把输入 x_t 和上一时刻隐状态 h_{t-1} 拼接起来,再乘一个大矩阵。更现代的实现方式是把输入变换和隐状态变换分开写成两个矩阵,然后把结果相加,效果完全一样,计算上更清晰:
gate = x_t @ W_ih + h_{t-1} @ W_hh + b
这里 W_ih 的形状是 (input_size, 4 * hidden_size),W_hh 的形状是 (hidden_size, 4 * hidden_size)。之所以是 4 倍,是因为一次线性变换同时算出了输入门、遗忘门、候选记忆、输出门四份结果,然后用 chunk 拆开。
3.2 完整的最小实现
我用常见的深度学习框架来写一个可直接运行的 LSTMCell,然后外层用循环处理时间步。为了教学目的,刻意不用框架的高层封装。
import torch import torch.nn as nn class LSTMCell(nn.Module): def __init__(self, input_size, hidden_size): super().__init__() self.input_size = input_size self.hidden_size = hidden_size # 输入到四份门的权重:(input_size, 4 * hidden_size) self.W_ih = nn.Parameter(torch.randn(input_size, 4 * hidden_size) * 0.01) # 隐状态到四份门的权重:(hidden_size, 4 * hidden_size) self.W_hh = nn.Parameter(torch.randn(hidden_size, 4 * hidden_size) * 0.01) # 偏置项:这里简单起见不拆开 self.bias = nn.Parameter(torch.zeros(4 * hidden_size)) def forward(self, x, state): h_prev, c_prev = state gates = x @ self.W_ih + h_prev @ self.W_hh + self.bias # 按最后一维切成四份,顺序分别是输入门、遗忘门、候选记忆、输出门 i, f, g, o = gates.chunk(4, dim=-1) i = torch.sigmoid(i) f = torch.sigmoid(f) g = torch.tanh(g) o = torch.sigmoid(o) c = f * c_prev + i * g h = o * torch.tanh(c) return h, c这个类做的事情和前面公式完全对应。接下来是外层按时间步循环:
def lstm_forward(cell, inputs): """ inputs.shape = (batch_size, seq_len, input_size) 返回 outputs: (batch_size, seq_len, hidden_size) 最后的状态 (h, c) """ batch_size, seq_len, input_size = inputs.shape hidden_size = cell.hidden_size h = torch.zeros(batch_size, hidden_size) c = torch.zeros(batch_size, hidden_size) outputs = [] for t in range(seq_len): x_t = inputs[:, t, :] # 取出当前时间步 h, c = cell(x_t, (h, c)) outputs.append(h) outputs = torch.stack(outputs, dim=1) return outputs, (h, c)如果只是验证维度,用随机数跑一遍就能看到输出形状是 (batch_size, seq_len, hidden_size),最后拿到的是处理完整个序列后的最终隐状态和细胞状态。
3.3 与内置接口的差异和注意点
真实项目中一般不会自己写 LSTMCell,都是调用框架内置的 LSTM 接口。但手写一遍有两个不可替代的价值:一是能建立张量维度和公式的映射关系,二是出 bug 时知道去检查哪个矩阵。
这里提几个我容易踩的坑。第一,框架内置 LSTM 的返回值通常是 output 和 (h, c) 的结构,如果只想拿最后一个时间步,不能直接把 output[:, -1, :] 当成最终隐状态,当序列变长且未使用 pack 时二者数值一样,但语义上 output[:, -1, :] 更直观。第二,如果用了双向 LSTM,维度会翻倍,hidden_size 必须除以 2,否则后面接全连接层时维度对不上。第三,内置 LSTM 默认会返回每一时间步的输出,这在计算 loss 时很方便,但如果只需要最后输出,仍然建议显式传入位置参数,避免隐式依赖默认行为。
提示:刚写完手写版时,最好用纯随机数据跑一次内置 LSTM,对比二者输出数值是否在同一个量级。我们自己的初始化方式是 randn * 0.01,而内置库的初始化方法更复杂,所以数值不会完全一致,但最终 loss 下降趋势应可对照。
3.4 为什么手写的初始化要乘 0.01
这个细节值得展开说。LSTM 的权重是多个矩阵连乘,如果初始值太大,前向传播时门控值会普遍远离 0.5,在 sigmoid 的饱和区停留,梯度一开始就很小,模型几乎学不动。乘 0.01 只是一种保守做法,实际训练里更常用统一的 Xavier 或 He 初始化。但对教学实现来说,小随机数已经足够让梯度正常流动并复现一个能下降的 loss。
偏置项我直接置零,但有一个非常实用的工程技巧:把遗忘门的偏置初始化调大,比如设成 1.0。原因在后面训练细节里专门讲,这里先埋个伏笔。
4. 训练LSTM时绕不开的工程细节:初始化、梯度裁剪、变长样本和正则化
4.1 遗忘门偏置的初始化:影响比你想象的大
很多教科书只把 LSTM 当作一个黑盒模块,而不会提醒你怎么初始化偏置。实际经验是,遗忘门偏置初始化为较大的正值(比如 1.0),相当于初始状态下的遗忘门输出接近 sigmoid(1) ≈ 0.73,再叠加前面的变换后会趋近 1,模型一开始倾向于“尽量记住一切”,然后再慢慢学习去遗忘不重要信息。这比一开始就“丢一半”的默认行为要稳得多。
具体做法也很简单:先创建一个 LSTM 单元,然后把遗忘门那一段偏置单独赋值。如果像我上面的代码一样把四份偏置拼成一个向量,遗忘门对应的是第 2 块(下标 hidden_size 到 2 * hidden_size 之间):
cell.bias.data[hidden_size:2 * hidden_size] = 1.0如果你的模型比较小、序列比较短,不做这一步也能收敛,只是速度可能慢一点,loss 前期也可能更震荡。我实际测过一个简单的文本情感分类任务,设置遗忘门偏置为 1.0 之后,前 3 个 epoch 的验证集准确率比默认初始化高了快 2 个百分点,之后差距才慢慢缩小。
4.2 梯度裁剪:应对梯度爆炸的最后防线
LSTM 虽然缓解了梯度消失,但梯度爆炸依然会出现,尤其在使用深层 LSTM、或者序列特别长的时候。训练到一半 loss 突然变成 NaN,十有八九就是爆炸了。
工程上最常用的处理是梯度裁剪:
import torch.nn.utils as utils utils.clip_grad_norm_(model.parameters(), max_norm=1.0)max_norm 一般取 0.25 到 1.0 之间。值太小,梯度被限得太死,收敛很慢;值太大,又起不到稳定作用。如果训练不稳定性明显,可以先试 1.0,不行再往 0.5、0.25 调。
需要注意,clip_grad_norm_ 是“裁剪所有参数梯度”的简单方案。实践中我更喜欢先加上它排除复杂度因素,等模型稳定了再尝试去掉。如果你的任务本身序列不长,网络也不深,不裁剪也完全没问题。
4.3 变长序列的处理:不要傻傻地 pad 到一样长
文本任务里句子长度参差不齐,最常见的错误是直接把所有句子 pad 到固定长度,然后送进 LSTM。这样做有两个问题:一是大量 pad 位置会浪费算力;二是 pad 位置的输入通常是 0,而 LSTM 在每一步都会更新细胞状态,如果 pad 占了很大比例,前向信息会被无意义的位置干扰。
正规做法是使用 pack_padded_sequence 机制。大致思路是把 batch 内的样本按真实长度降序排列,告诉 LSTM 每个样本实际有多少步是有效数据,在计算时跳过 pad 部分,最后拿到一个打平的输出,再用 pad_packed_sequence 恢复成原始的形状。框架内置接口已经把这些封装好了,核心就是调用 pack 和 unpack 配对,别搞混顺序。
排序长度这一步很容易被忽略。如果 batch 里的样本没有按有效长度降序,接口会直接报错或者计算错误。我记得第一次用的时候就是没排序,报错信息又比较晦涩,浪费了不少时间。
4.4 Dropout 的正确位置:层间而不是时间步间
在深层 LSTM 中,Dropout 一般加在层与层之间,也就是把第 1 层 LSTM 的输出作为第 2 层输入时,对这个输出做 Dropout。而不是对同一层内的时间步之间做,因为隐状态 h_t 本身就是需要跨时间传递的短期记忆,对它做随机丢弃会破坏信息的连续性。
框架内置的多层 LSTM 接口里有个 dropout 参数,它控制的就是层间 Dropout 的比例,不是时间步之间的。这一点如果没理解,可能会出现“我明明设置了 dropout 但几乎没效果”的困惑。如果你自己在手工实现多层叠加,那么只需要在把上一层输出喂到下一层之前加个 Dropout 层即可。
4.5 隐层维度、层数和学习率的粗调经验
隐层维度 hidden_size 的选择没有绝对标准。小任务上,比如情感分类或短文本标注,64 到 128 通常够用;稍大一点的序列生成任务,256 是个常见起步值。我见过不少项目一上来就设 512,结果训练很慢,也没有明显收益,因为 LSTM 的容量瓶颈往往不在隐层大小,而在建模方式本身。
层数方面,2 层 LSTM 在大多数任务是性价比最高的。3 层以上收益明显递减,而且训练时间线性上涨。如果你想追求更强的序列表达能力,不如把 LSTM 换成双向结构或再接注意力机制,而不是一味堆层。
学习率方面,LSTM 对学习率比普通前馈网络更敏感。过大的学习率很容易让门控值在初期集中到饱和区,导致梯度长期很小。AdamW 配合 1e-3 起步,是很多任务里比较稳妥的组合。如果遇到 loss 迟迟不下去,先把学习率降 10 倍再观察,往往比调整网络结构更快见效。
5. LSTM不是万金油:选型场景、GRU替代与注意力机制时代的边界
5.1 什么场景值得继续用 LSTM
近几年的序列建模领域,注意力机制和 Transformer 已经成为大模型的标配,不少初学者会怀疑现在还有没有必要学 LSTM。实际上,LSTM 仍然有不可替代的使用场景:需要在线或流式处理的任务,比如实时语音识别、股票逐 tick 预测、对话系统的增量解码,这些场景都要求模型只能看到当前及过去的信息,LSTM 天然就是因果的。对这类任务,直接用 LSTM 比在 Transformer 上强行加 mask 更自然、更轻量。
另外在中小规模数据上,LSTM 往往能比 Transformer 更快收敛,因为它的参数结构对序列位置信息有很强归纳偏置。比如一些百条到千条样本的时序预测任务,用 LSTM 可以稳定拟合,而 Transformer 很容易过拟合或训不动。
5.2 GRU 是不是更好的“穷人版”选择
GRU 可以看作 LSTM 的简化版:只保留两个门——更新门和重置门,没有独立的细胞状态。参数更少,计算更快,在很多任务上表现接近 LSTM,甚至在部分小规模任务上反超。
在选择时,可以参考这样几条原则:如果训练数据很充足、任务比较复杂,LSTM 上限更高;如果数据和算力有限,或者需要快速尝试多种模型,GRU 通常是更好的基线;如果本就用内置接口,两边代码成本差不多,则可以直接用 LSTM 省去反复对比的时间。
我自己常用的做法是:先跑通 GRU 版本的 baseline,确保整个数据流程和 loss 计算正确,然后把 GRU 替换成 LSTM 再跑一轮,观察验证集差异。这样可以快速定位问题,也能避免两个模型同时在选型上加倍试错。
5.3 为什么 Transformer 能解决 LSTM 的短板
LSTM 最核心的问题有两个。第一是串行计算:处理第 t 个时间步必须等待第 t-1 步的隐状态,无法并行化,训练速度天然受限。Transformer 则完全抛弃循环结构,用自注意力直接计算所有位置之间的依赖,GPU 的并行度大幅提高。第二是长距离捕获仍然有限:LSTM 的遗忘门是衰减乘子,即使设计再好,距离极远的信息也难免被稀释;而自注意力在一步之内就可以让任意两个位置直接交互,理论上没有距离衰减问题。
这也是为什么主流预训练语言模型几乎全在 Transformer 路线上。但如果你是做音视频对齐、时序异常检测、强化学习的环境状态建模这类任务,LSTM 仍然是最好的开局选择之一。
5.4 混合方案:在 Encoder-Decoder 里把两者结合
很多人默认 LSTM 和注意力机制是非此即彼的关系,实际上它们经常是一起用的。经典的序列到序列结构里,Encoder 和 Decoder 可以用 LSTM 建模时序,同时在 Decoder 端加入注意力层,让每一步生成时都能直接“回头”看 Encoder 的各个位置。这种组合很实用:LSTM 负责压缩序列顺序带来的上下文动态,注意力负责对齐源与目标之间的关联。
在实现上,通常的做法是把 LSTM 每一时间步的隐状态保存下来,作为注意力的 key 和 value;Decoder 当前隐状态作为 query。这样既保留了循环模型对顺序的敏感度,又引入了全局交互能力。如果你的任务里输入很长、但关键信息比较分散,强烈建议尝试这个混合方案,而不是单纯增加 LSTM 层数。
最后说一点我自己的体会。LSTM 的公式让人害怕,很大程度上是因为它一次性引入了太多符号。真正上手跑过几轮之后,我发现最有用的学习方法就是亲手实现一个最简单的 LSTMCell,然后盯着维度表和公式一块儿看。等到某一天你不再把遗忘门、输入门当成需要死记的对象,而是把它们看作一组可学习的、用来管理记忆转轮的旋钮时,LSTM 的大门才算真正打开了。之后再去接触 GRU、注意力机制等变体,都会顺畅很多,因为这些模型本质上都在回答同一个问题:如何让信息在时间或位置之间高效流动。