资讯动态

LSTM前向与反向传播公式推导:从梯度崩溃到手写NumPy实现

发布时间:2026/10/23 3:59:10 来源:尧图企业网站定制
1. 从一次手推LSTM梯度的崩溃说起如果你正在做时序预测、文本生成或者任何跟序列建模相关的项目大概率绕不开LSTM。很多人第一次用LSTM都是直接调库nn.LSTM(input_size, hidden_size, num_layers)三行代码跑通训练也能收敛看起来一切都很美好。但一旦遇到梯度爆炸、长期依赖学不到、门控行为不符合预期这些问题光靠调参和试错就很难定位根因了。这时候唯一的出路就是把LSTM的公式从头到尾推一遍搞清楚每个门、每个激活函数、每条梯度路径到底在干什么。这篇内容就是我在反复推导LSTM公式过程中积累下来的完整笔记。核心目标很明确把LSTM的前向传播和反向传播公式逐项拆开解释每个矩阵乘法的维度变化、每个门控的物理含义、每条梯度回传路径的数学逻辑。适合已经用过LSTM但对其内部机制一知半解的人也适合想从零手写一个LSTM单元来加深理解的开发者。读完之后你应该能做到拿一张纸一支笔不查任何资料把LSTM的前向和反向公式完整写出来并且能解释每一步为什么这么设计。LSTM的公式推导之所以值得花时间是因为它涉及的计算图比普通RNN复杂得多。普通RNN只有一个tanh和一个矩阵乘法梯度回传路径清晰。LSTM有四个线性变换、三个门控、一条细胞状态通道反向传播时梯度要经过多条路径汇合。不理解这些路径就没法解释为什么LSTM能缓解梯度消失也没法在自定义实现时保证梯度正确。2. LSTM整体计算图与公式框架拆解2.1 为什么需要门控机制从普通RNN的缺陷说起普通RNN的隐藏状态更新公式是 $h_t \tanh(W_{hh}h_{t-1} W_{xh}x_t b_h)$。这个结构的问题在于每一步的隐藏状态都被完全覆盖历史信息只能通过 $h_{t-1}$ 间接传递。反向传播时梯度需要连乘每一时刻的雅可比矩阵 $\frac{\partial h_t}{\partial h_{t-1}}$。当序列较长时这些雅可比矩阵的连乘要么趋近于零梯度消失要么发散梯度爆炸。用一个生活化的类比普通RNN就像每次记笔记都把上一页撕掉重写信息只能靠脑子记住。LSTM则是在笔记本上专门留了一条“细胞状态”通道相当于一条传送带信息可以在上面长时间保留而门控机制决定了什么时候往传送带上放新东西、什么时候把旧东西拿下来。LSTM的核心设计思想就是引入一条细胞状态$C_t$ 通道以及三个门控——遗忘门、输入门、输出门——来控制信息流动。细胞状态的更新是加法操作而不是乘法覆盖这使得梯度可以沿着细胞状态通道以接近恒等映射的方式回传从而缓解梯度消失。2.2 前向传播公式全貌与符号约定先把符号约定统一不然后面推导容易乱。假设batch size为1多样本只是多一维不影响公式结构输入维度为 $d$隐藏状态维度为 $h$。$x_t \in \mathbb{R}^{d}$$t$ 时刻的输入向量$h_{t-1} \in \mathbb{R}^{h}$$t-1$ 时刻的隐藏状态$C_{t-1} \in \mathbb{R}^{h}$$t-1$ 时刻的细胞状态$W_f, W_i, W_o, W_c \in \mathbb{R}^{h \times (hd)}$四个线性变换的权重矩阵$b_f, b_i, b_o, b_c \in \mathbb{R}^{h}$对应的偏置项$\sigma$sigmoid函数$\sigma(z) \frac{1}{1e^{-z}}$$\tanh$双曲正切函数LSTM的前向传播公式如下遗忘门 $$f_t \sigma(W_f \cdot [h_{t-1}, x_t] b_f)$$输入门 $$i_t \sigma(W_i \cdot [h_{t-1}, x_t] b_i)$$候选细胞状态 $$\tilde{C}t \tanh(W_c \cdot [h{t-1}, x_t] b_c)$$细胞状态更新 $$C_t f_t \odot C_{t-1} i_t \odot \tilde{C}_t$$输出门 $$o_t \sigma(W_o \cdot [h_{t-1}, x_t] b_o)$$隐藏状态输出 $$h_t o_t \odot \tanh(C_t)$$其中 $\odot$ 表示逐元素乘法Hadamard积$[h_{t-1}, x_t]$ 表示向量拼接。这里有一个常见的实现细节很多教程把四个权重矩阵分开写但实际工程中通常把 $W_f, W_i, W_c, W_o$ 拼成一个大矩阵 $W \in \mathbb{R}^{4h \times (hd)}$一次矩阵乘法算出四个门的线性部分再切片。这样做的好处是利用GPU的并行计算能力减少四次小矩阵乘法的开销。我在实际测试中对比过拼接后一次算完比分开算四次大约快15%到25%序列越长差距越明显。2.3 计算图视角数据流与梯度流的分叉点把前向传播画成计算图可以看到几个关键的梯度分叉点第一个分叉点在 $[h_{t-1}, x_t]$ 拼接处。$h_{t-1}$ 同时参与了四个门的计算所以反向传播时$h_{t-1}$ 的梯度来自四个方向遗忘门、输入门、候选细胞状态、输出门。这四个梯度需要相加。第二个分叉点在细胞状态 $C_t$。$C_t$ 的梯度有两条来源一条是当前时刻 $h_t$ 通过 $\tanh(C_t)$ 和输出门回传的梯度另一条是下一时刻 $C_{t1}$ 通过遗忘门回传的梯度。这两条路径在 $C_t$ 处相加。第三个分叉点在 $h_t$。$h_t$ 的梯度也有两条来源一条是当前时刻输出 $y_t$ 的梯度如果有监督信号另一条是下一时刻 $h_{t1}$ 回传的梯度。理解这三个分叉点是理解LSTM反向传播的关键。很多人推梯度时漏掉某条路径导致结果对不上数值梯度检验根源就在这里。3. 逐项推导反向传播每条梯度路径的数学细节3.1 定义损失与梯度符号假设损失函数 $L$ 是所有时刻输出的损失之和$L \sum_{t1}^{T} L_t$其中 $L_t$ 是 $t$ 时刻的损失。定义$$\delta h_t \frac{\partial L}{\partial h_t}, \quad \delta C_t \frac{\partial L}{\partial C_t}$$这两个是反向传播的核心中间变量。注意 $\delta h_t$ 包含了来自 $t$ 时刻输出损失的梯度以及来自 $t1$ 时刻回传的梯度。3.2 从输出门和隐藏状态开始回传先看 $h_t o_t \odot \tanh(C_t)$。对 $o_t$ 和 $C_t$ 分别求偏导$$\frac{\partial h_t}{\partial o_t} \tanh(C_t), \quad \frac{\partial h_t}{\partial C_t} o_t \odot (1 - \tanh^2(C_t))$$所以输出门和细胞状态从 $h_t$ 接收到的梯度是$$\delta o_t \delta h_t \odot \tanh(C_t)$$ $$\delta C_t^{(from\ h)} \delta h_t \odot o_t \odot (1 - \tanh^2(C_t))$$这里 $\delta C_t^{(from\ h)}$ 表示从 $h_t$ 路径回传到 $C_t$ 的梯度。后面还要加上从 $C_{t1}$ 路径回传的梯度。3.3 细胞状态的双路径梯度汇合细胞状态更新公式是 $C_t f_t \odot C_{t-1} i_t \odot \tilde{C}_t$。对 $f_t$、$i_t$、$\tilde{C}t$、$C{t-1}$ 分别求偏导$$\frac{\partial C_t}{\partial f_t} C_{t-1}, \quad \frac{\partial C_t}{\partial i_t} \tilde{C}_t, \quad \frac{\partial C_t}{\partial \tilde{C}t} i_t, \quad \frac{\partial C_t}{\partial C{t-1}} f_t$$所以$$\delta f_t \delta C_t \odot C_{t-1}$$ $$\delta i_t \delta C_t \odot \tilde{C}_t$$ $$\delta \tilde{C}t \delta C_t \odot i_t$$ $$\delta C{t-1} \delta C_t \odot f_t$$注意 $\delta C_t$ 本身是两部分之和$\delta C_t \delta C_t^{(from\ h)} \delta C_{t1}^{(from\ next)}$其中 $\delta C_{t1}^{(from\ next)}$ 是下一时刻通过 $C_{t1} f_{t1} \odot C_t \ldots$ 回传的梯度即 $\delta C_{t1} \odot f_{t1}$。这里就是LSTM缓解梯度消失的核心所在$\delta C_{t-1} \delta C_t \odot f_t$梯度沿着细胞状态通道回传时只乘以遗忘门 $f_t$而不是乘以一个复杂的雅可比矩阵。当 $f_t$ 接近1时梯度几乎无损地传回去。这就是为什么LSTM能学到长期依赖。3.4 门控线性部分的梯度与权重更新现在处理四个门的线性变换。以遗忘门为例$f_t \sigma(z_f)$其中 $z_f W_f \cdot [h_{t-1}, x_t] b_f$。sigmoid的导数是 $\sigma(z) \sigma(z)(1-\sigma(z))$所以$$\delta z_f \delta f_t \odot f_t \odot (1 - f_t)$$同理$$\delta z_i \delta i_t \odot i_t \odot (1 - i_t)$$ $$\delta z_o \delta o_t \odot o_t \odot (1 - o_t)$$ $$\delta z_c \delta \tilde{C}_t \odot (1 - \tilde{C}_t^2)$$然后对权重和偏置求导$$\frac{\partial L}{\partial W_f} \delta z_f \cdot [h_{t-1}, x_t]^T, \quad \frac{\partial L}{\partial b_f} \delta z_f$$其他三个门同理。最后回传到 $h_{t-1}$ 和 $x_t$ 的梯度是四个门梯度的总和$$\delta [h_{t-1}, x_t] W_f^T \delta z_f W_i^T \delta z_i W_c^T \delta z_c W_o^T \delta z_o$$从 $\delta [h_{t-1}, x_t]$ 中切分出 $\delta h_{t-1}$ 和 $\delta x_t$。$\delta h_{t-1}$ 会继续往前一时刻回传$\delta x_t$ 用于更新输入相关的参数如果输入有可学习嵌入的话。3.5 完整反向传播流程总结把上面的推导串起来一个时刻的反向传播流程是接收 $\delta h_t$ 和 $\delta C_{t1}^{(from\ next)}$计算 $\delta o_t \delta h_t \odot \tanh(C_t)$计算 $\delta C_t \delta h_t \odot o_t \odot (1-\tanh^2(C_t)) \delta C_{t1}^{(from\ next)}$计算 $\delta f_t \delta C_t \odot C_{t-1}$$\delta i_t \delta C_t \odot \tilde{C}_t$$\delta \tilde{C}_t \delta C_t \odot i_t$计算 $\delta C_{t-1} \delta C_t \odot f_t$计算四个门的线性梯度 $\delta z_f, \delta z_i, \delta z_o, \delta z_c$累加权重梯度计算 $\delta h_{t-1}$ 并回传这个流程每一步都有明确的物理意义不是纯粹的数学符号游戏。比如第5步 $\delta C_{t-1} \delta C_t \odot f_t$遗忘门在这里扮演了“梯度阀门”的角色它决定了多少梯度能继续往前传。4. 手写一个LSTM单元从公式到可运行代码4.1 前向传播的NumPy实现光看公式容易眼高手低我建议至少用NumPy手写一遍前向和反向。下面是一个单层LSTM单元的前向实现维度参数用变量表示方便对照公式import numpy as np def sigmoid(z): return 1.0 / (1.0 np.exp(-np.clip(z, -500, 500))) class LSTM Cell: def __init__(self, input_dim, hidden_dim): self.input_dim input_dim self.hidden_dim hidden_dim concat_dim input_dim hidden_dim # 四个门的权重拼在一起方便一次矩阵乘法 self.W np.random.randn(4 * hidden_dim, concat_dim) * 0.01 self.b np.zeros(4 * hidden_dim) def forward(self, x_t, h_prev, C_prev): # 拼接输入和上一时刻隐藏状态 concat np.concatenate([h_prev, x_t], axis0) # shape: (hd,) z self.W concat self.b # shape: (4h,) h self.hidden_dim z_f z[0:h] z_i z[h:2*h] z_c z[2*h:3*h] z_o z[3*h:4*h] f_t sigmoid(z_f) i_t sigmoid(z_i) C_tilde np.tanh(z_c) o_t sigmoid(z_o) C_t f_t * C_prev i_t * C_tilde h_t o_t * np.tanh(C_t) # 缓存中间结果用于反向传播 cache (x_t, h_prev, C_prev, concat, z_f, z_i, z_c, z_o, f_t, i_t, C_tilde, o_t, C_t) return h_t, C_t, cache这段代码里有一个细节值得注意np.clip(z, -500, 500)是为了防止sigmoid溢出。当 $z$ 很大或很小时np.exp(-z)可能溢出成inf导致结果为nan。虽然理论上sigmoid不会溢出但浮点数运算有精度限制加个clip是工程上的保险做法。4.2 反向传播的NumPy实现与梯度检验反向传播的实现直接对应第3节的公式def backward(self, dh_t, dC_next, cache): (x_t, h_prev, C_prev, concat, z_f, z_i, z_c, z_o, f_t, i_t, C_tilde, o_t, C_t) cache h self.hidden_dim # 输出门梯度 tanh_C_t np.tanh(C_t) do_t dh_t * tanh_C_t # 细胞状态梯度来自h_t的路径 来自下一时刻的路径 dC_t dh_t * o_t * (1 - tanh_C_t**2) dC_next # 遗忘门、输入门、候选细胞状态梯度 df_t dC_t * C_prev di_t dC_t * C_tilde dC_tilde dC_t * i_t dC_prev dC_t * f_t # 线性部分梯度 dz_f df_t * f_t * (1 - f_t) dz_i di_t * i_t * (1 - i_t) dz_c dC_tilde * (1 - C_tilde**2) dz_o do_t * o_t * (1 - o_t) dz np.concatenate([dz_f, dz_i, dz_c, dz_o]) # 权重和偏置梯度 dW np.outer(dz, concat) db dz # 回传到拼接向量再切分出dh_prev和dx_t d_concat self.W.T dz dh_prev d_concat[:h] dx_t d_concat[h:] return dh_prev, dC_prev, dW, db, dx_t写完反向传播后必须做梯度检验。方法是用数值梯度近似对每个参数 $W_{ij}$计算 $\frac{L(W_{ij}\epsilon) - L(W_{ij}-\epsilon)}{2\epsilon}$和反向传播算出的梯度对比。相对误差应该小于 $10^{-6}$。我踩过的坑是忘记在 $\delta C_t$ 里加上来自下一时刻的梯度导致梯度检验在序列长度大于1时失败但长度为1时通过。这个bug很隐蔽因为单步看起来完全正确。4.3 梯度检验中常见的三个错误第一个错误是符号错误。$\delta C_t \delta h_t \odot o_t \odot (1-\tanh^2(C_t)) \delta C_{t1}^{(from\ next)}$这里两项都是加号。有人会写成减号因为觉得“遗忘”应该是减法。实际上遗忘门是乘法不是减法。第二个错误是维度不匹配。$W_f$ 的形状是 $(h, hd)$$\delta z_f$ 的形状是 $(h,)$$\frac{\partial L}{\partial W_f} \delta z_f \cdot [h_{t-1}, x_t]^T$ 得到 $(h, hd)$维度是对的。但如果把 $\delta z_f$ 当成列向量外积的顺序搞反就会得到 $(hd, h)$形状对不上。第三个错误是忘记累加。$h_{t-1}$ 的梯度来自四个门必须把四个 $W^T \delta z$ 加起来。只取其中一个门的梯度梯度检验会显示部分参数梯度正确、部分错误这种“部分正确”的现象很容易让人误以为是数值精度问题。5. 公式背后的设计哲学与常见误区5.1 为什么用sigmoid做门控、tanh做候选状态门控用sigmoid是因为它的输出范围是 $(0,1)$可以解释为“保留比例”。0表示完全遗忘1表示完全保留。这个解释很直观也使得门控的梯度有界不会爆炸。候选细胞状态用tanh是因为它的输出范围是 $(-1,1)$均值为0。这比sigmoid的 $(0,1)$ 更好因为均值为0的激活值能让下一层的输入分布更稳定收敛更快。如果候选状态也用sigmoid所有值都是正的会导致梯度更新方向出现“锯齿”现象。输出门用sigmoid也是同样的道理它控制细胞状态的多少部分被输出到隐藏状态。隐藏状态 $h_t$ 的范围是 $(-1,1)$因为 $\tanh(C_t) \in (-1,1)$ 且 $o_t \in (0,1)$。5.2 遗忘门偏置初始化为1的工程意义这是一个非常实用但很多教程不讲的技巧把遗忘门的偏置 $b_f$ 初始化为1而不是0。原因是在训练初期模型还不知道该遗忘什么如果 $b_f0$sigmoid(0)0.5意味着一半的细胞状态被遗忘。初始化 $b_f1$ 使得 $f_t \approx 0.73$更倾向于保留历史信息。这让模型在训练初期就能记住较长的依赖梯度也能更顺畅地回传。我在一个序列长度为200的实验中对比过$b_f0$ 时模型需要大约30个epoch才能学到长距离依赖$b_f1$ 时大约10个epoch就学到了。当然这个数字因任务而异但趋势是一致的。5.3 常见误区LSTM真的完全解决了梯度消失吗不是。LSTM只是缓解了梯度消失没有完全解决。梯度沿着细胞状态回传时是 $\delta C_{t-1} \delta C_t \odot f_t$如果 $f_t$ 持续小于1比如0.5那么经过10步后梯度就衰减到 $0.5^{10} \approx 0.001$。LSTM的优势在于 $f_t$ 是可学习的模型可以学会让 $f_t$ 接近1来保留梯度。但如果任务本身要求频繁遗忘$f_t$ 就会较小梯度消失仍然会发生。另一个误区是认为LSTM的参数量比普通RNN大很多所以更强。参数量大确实增加了表达能力但也增加了过拟合风险。在数据量不足时LSTM可能不如普通RNN。我的一般建议是序列长度小于20且数据量小于1万条时先试普通RNN或GRU序列长度超过50或数据量充足时再上LSTM。6. 实操排查梯度异常与门控行为诊断6.1 梯度爆炸的定位与梯度裁剪即使有LSTM的门控机制梯度爆炸仍然可能发生尤其是序列很长或学习率较大时。定位方法是打印每个时刻的梯度范数def clip_gradients(grads, max_norm5.0): total_norm np.sqrt(sum(np.sum(g**2) for g in grads)) if total_norm max_norm: scale max_norm / (total_norm 1e-8) grads [g * scale for g in grads] return grads, total_norm梯度裁剪的阈值一般设在1到10之间。太小会减慢收敛太大起不到防止爆炸的作用。我通常从5开始试如果训练不稳定就降到1。6.2 门控值诊断表训练过程中打印门控值的统计量可以快速判断LSTM是否在正常工作诊断项正常范围异常表现可能原因遗忘门均值0.4~0.8持续小于0.2遗忘门偏置初始化不当或学习率过大输入门均值0.2~0.6持续接近0输入门饱和模型学不到新信息输出门均值0.3~0.7持续接近1输出门饱和隐藏状态无选择性候选状态均值-0.3~0.3绝对值持续大于0.8tanh饱和梯度消失细胞状态范数缓慢增长或稳定指数增长梯度爆炸前兆这张表是我在实际调试中总结的不是理论推导的结果但多次帮我快速定位了问题。比如有一次遗忘门均值一直在0.1左右模型完全学不到长依赖后来发现是学习率设成了0.1降到0.001后就正常了。6.3 常见问题速查问题一梯度检验通过但训练不收敛。检查数据归一化和学习率。LSTM对输入尺度敏感输入值范围建议归一化到 $[-1,1]$ 或 $[0,1]$。学习率从0.001开始试。问题二损失震荡严重。可能是batch size太小或序列长度差异太大。尝试增大batch size或对序列做padding后按长度分桶。问题三验证集损失早早就上升。过拟合。减小隐藏层维度或加dropout。注意LSTM的dropout应该加在层与层之间不要加在循环连接上否则会破坏时间依赖。问题四预测结果恒定不变。检查输出门是否饱和。如果输出门一直接近1隐藏状态就是 $\tanh(C_t)$如果 $C_t$ 也饱和输出就恒定。降低学习率或加梯度裁剪。7. 从公式到直觉LSTM到底在学什么把公式推完、代码写完、梯度检验通过之后我对LSTM的理解才真正从“调包”变成了“知道自己在干什么”。遗忘门学的是“什么时候该忘”输入门学的是“什么时候该记”输出门学的是“什么时候该说”。这三个门的组合本质上是在学习一个动态的记忆管理策略。细胞状态通道的设计尤其巧妙。它是一条加法通道梯度回传时只经过逐元素乘法不经过矩阵乘法。矩阵乘法是梯度消失的元凶因为矩阵的奇异值可能远小于1。逐元素乘法如果乘数接近1梯度就能无损通过。LSTM把“记忆”和“输出”解耦细胞状态负责长期记忆隐藏状态负责当前输出这是它比普通RNN强大的根本原因。最后分享一个我在推导过程中用的小技巧每推完一个公式立刻用NumPy写三行代码验证。比如推完 $\delta C_{t-1} \delta C_t \odot f_t$就构造随机数验证一下数值是否相等。公式推导容易出错但代码不会骗人。这个习惯帮我省了大量回头检查的时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑