LSTM从梯度消失到门控机制:结构、推导与代码实现 做序列任务的人大概都经历过这个阶段明明数据清洗干净了、标签也核对了模型在训练集上就是学不动loss 卡在某个值附近上下抖动怎么调学习率都没用。我最早碰到这种情况是在做一个传感器读数的分类小工具输入是几百步的连续采样点用最朴素的循环网络去跑短序列还行序列一长准确率就掉到接近瞎猜。后来一步一步排查问题落在了梯度上再后来才真正把**长短期记忆网络Long Short Term Memory NetworkLSTM**这套结构啃明白。这篇笔记就是那段时间的完整沉淀。不打算写成教科书式的科普而是按我当时“从困惑到能复现”的路径来组织先讲清楚普通循环网络到底卡在哪再把 LSTM 的结构分析拆成能记住的画面然后把数学推导里那几个乘法和加法逐项写清楚最后给出两套代码——一套用纯数组手写方便你把每一步的形状和数值都对上一套用主流框架搭建能直接塞进项目里跑。不管你是刚接触序列建模、被公式吓退过的新手还是已经在调参但总觉得 LSTM 是个黑盒、想搞清楚内部到底发生什么的中级使用者这篇应该都能给你一些能直接抄走的东西。我尽量不省略中间步骤公式出现的每一个符号都会交代它的形状和来历。1. 从一个训练不收敛的序列任务说起为什么非得啃 LSTM1.1 普通循环网络的“记忆漏勺”问题先把场景说具体一点。假设手头有一批变长序列每一步输入一个向量 $x_t$模型需要在读完全部输入之后输出一个判断。最直接的建模方式就是循环网络维护一个隐状态 $h_t$每来一个新输入就把它和旧状态揉在一起更新成新状态公式只有一行$$h_t \tanh(W_h h_{t-1} W_x x_t b)$$这个结构在直觉上完全说得通——当前状态既包含历史信息又吸收了新输入像一个滚动的记忆槽。问题在于这个记忆槽会漏。举个生活化的例子你在听一段长对话每一句话你都只能记住“上一句的大意加上这一句的内容”那么说过三十句之后第一句讲了什么基本就模糊了。循环网络的状态更新是逐元素的压缩变换每次都要经过一次 $\tanh$ 和一个矩阵乘法信息被反复重新编码原始细节在几十步之后会被稀释得面目全非。更麻烦的是这种稀释不是均匀的而是指数级的。模型的输出只依赖最后那个 $h_T$而 $h_T$ 是若干次变换的复合结果早期输入对最终结果的影响被压在了一个非常浅的梯度通道里。训练的时候反向传播要沿这条通道一路回传越往回走信号越弱。我在做那个传感器项目时的第一版模型就是这样序列长度 50 以内还行到了 300 步准确率直接掉到和多数类基线持平。当时还以为是数据的问题换了好几种归一化方式效果都没变。后来打印每一步的隐状态范数才发现$h_t$ 很快收敛到一个几乎恒定的向量也就是说模型实际上已经“放弃”了区分不同时间步把状态当成常数在用。这就是典型的梯度传不回去、模型学不到长距离依赖的表现。1.2 梯度在时间轴上连乘会发生什么要理解为什么会这样得把反向传播在时间维度上的展开看清楚。依然用上面那个最简单的循环网络设损失对隐状态的梯度为 $\delta_t \partial L / \partial h_t$那么从 $t$ 时刻回传到 $t-1$ 时刻的关系是$$\frac{\partial h_t}{\partial h_{t-1}} \operatorname{diag}\left(1 - \tanh^2(\cdot)\right) W_h$$注意这里的结构一个对角矩阵$\tanh$ 的导数每个元素都在 0 到 1 之间左乘一个权重矩阵。要从时刻 $T$ 一路传到时刻 $k$就要把这个雅可比矩阵连乘 $T-k$ 次$$\frac{\partial L}{\partial h_k} \frac{\partial L}{\partial h_T} \prod_{tk1}^{T} \operatorname{diag}\left(1 - \tanh^2(\cdot)\right) W_h$$连乘是最要命的地方。设权重矩阵的谱范数为 $\sigma$$\tanh$ 导数的最大值是 1在输入为 0 处取到绝大多数时候远小于 1那么整个连乘的规模大致被 $(\sigma \cdot \gamma)^{T-k}$ 控制其中 $\gamma$ 是导数的典型量级。如果 $\sigma \cdot \gamma 1$梯度会随距离指数衰减如果 $ 1$会指数爆炸。实际上在初始化合理的网络里前者是常态因为 $\tanh$ 在饱和区导数接近 0多数元素都被压得很小。这里要澄清一个常见误解梯度消失不是说梯度变成 0而是它衰减到远小于有效更新量级。当距离拉长到几十步梯度可能只有 $10^{-8}$ 量级此时参数更新几乎不动模型自然学不到跨越这么长的依赖关系。而如果序列里恰好存在一个特别强的长距离信号模型要么学不到要么被局部短距离模式主导。还有一个隐蔽的坑很多人以为把 $\tanh$ 换成 ReLU 就能解决。换成 ReLU 确实缓解了激活函数饱和的问题但权重矩阵连乘这一项还在谱范数稍微偏大一点就会梯度爆炸偏小一点依然消失。问题的根源在连乘形式本身不在激活函数的选择。1.3 LSTM 的三个改动与一个代价LSTM 的思路不是去修补那个连乘而是另开一条路。它做了三件事我在下面按重要性排序第一引入了除隐状态之外的第二个状态变量叫细胞状态cell state通常记作 $c_t$。这条状态更新的方式是加法而不是纯粹的乘性变换$c_t f_t \odot c_{t-1} i_t \odot g_t$。加法意味着梯度回传时那一项是直接透传的不需要经过矩阵乘法和激活函数的挤压。第二加上了门控机制。所谓门就是一个取值在 0 到 1 之间的向量通过逐元素乘法控制另一条信息流的通过比例。$f_t$ 控制旧记忆保留多少$i_t$ 控制新信息写入多少还有一个输出门控制当前时刻对外暴露多少。门本身也是学出来的跟着任务一起训练。第三门控是乘性交互这让网络具备了“按内容寻址”的能力。同一个模型在不同输入下可以自动切换成“记住”或者“清空”模式而不是靠固定的权重硬编码。代价也很明确参数量直接翻四倍因为有四组权重三个门加一个候选记忆计算无法在时间维度并行必须一步一步算长序列训练慢。这也是后来注意力机制流行的直接原因——它把序列依赖变成了可并行的矩阵运算。但在中小规模序列任务、数据量不算特别大的场景里LSTM 依然是性价比很高的选择训练稳定、收敛行为可预期、实现成熟很多工业场景到现在还在用。2. 结构拆解一条传送带 三道闸门2.1 两个状态h 与 c 的分工刚接触 LSTM 的时候我最困惑的一点是为什么需要两个状态$h_t$ 和 $c_t$ 到底谁是谁后来找到一个自己觉得最好用的类比把 LSTM 的一个时间步想象成一条传送带加一个工作台。细胞状态 $c_t$ 就是那条传送带货物在上面从上游往下游走沿途只有两个操作——把旧的货扔掉一部分把新的货放上去一部分。它不参与对外输出只是安静地运货。而隐状态 $h_t$ 是工作台是这一刻真正对外展示的东西它是从传送带上取一部分货物、加工一下、交出去。这个分工带来的直接好处是信息的主干通道传送带和输出通道工作台被解耦了。普通循环网络里$h_t$ 既要承担记忆职责又要承担输出职责两者的需求经常冲突——比如某个信息需要在内部保留很久但不应该在中间每一步都输出。解耦之后$c_t$ 可以长期保持一个值不变而 $h_t$ 在每一步都可以按需变化。在代码和公式里两个状态都是长度为 $H$ 的向量$H$ 是隐层维度初始时刻都置零。整个序列处理完之后通常拿最后一个时刻的 $h_T$或者 $c_T$两者都可以作为序列的表示向量往下游走。2.2 遗忘门先决定丢什么遗忘门是整个结构里我认为最巧妙的一笔。它的输入是当前输入 $x_t$ 和上一时刻隐状态 $h_{t-1}$输出是一个和 $c_{t-1}$ 同维的向量每个元素都在 0 到 1 之间$$f_t \sigma\left(W_f x_t U_f h_{t-1} b_f\right)$$其中 $\sigma$ 是 sigmoid 函数把实数压到 $(0,1)$。然后这个向量和旧记忆做逐元素乘法$f_t \odot c_{t-1}$。$f_t$ 某一位是 0.9对应位置的记忆就保留九成是 0.05就基本被清掉了。为什么用 sigmoid 而不是别的因为它的输出天然被解释为“通过比例”。而且是软门控不是硬开关梯度可以正常回传sigmoid 的导数处处非零。硬开关0/1 二值在数学上更干净但没法用梯度下降训练只能靠强化学习那一套代价太高。这里有个我踩过好几次的坑遗忘门的偏置项建议初始化为正值常见做法是 1.0也有人用 2.0。原因是网络刚初始化时如果遗忘门输出接近 0.5意味着模型一开始就在以指数速度丢弃历史信息长距离依赖还没来得及学就被截断了。把偏置调成正的初始时 $f_t$ 接近 0.73 到 0.88模型默认倾向于“记住”先学短程模式再逐步学会该忘什么。这个改动在长序列任务上的提升相当明显我在一个长度 500 左右的序列分类任务上试过验证集准确率差了三到四个百分点。不过要提醒一句PyTorch 里nn.LSTM的四个门参数是拼接存放的顺序是input, forget, cell, output也就是 $i, f, g, o$而不是很多人凭直觉以为的 $i, f, o, g$。如果按 $i,f,o,g$ 的顺序去改偏置实际上是把偏置加在了 cell 门和 output 门上训练会变得很奇怪但又不会立刻报错排查起来非常费劲。2.3 输入门与候选记忆决定写什么新信息的写入分两步走这个设计也值得说一下。第一步算候选记忆有的书里叫 cell candidate 或 new memory content它和遗忘门一样接收 $x_t$ 和 $h_{t-1}$但激活函数用 $\tanh$ 而不是 sigmoid$$g_t \tanh\left(W_g x_t U_g h_{t-1} b_g\right)$$用 $\tanh$ 是因为这一项要作为实际写入内容取值范围应该在 $(-1, 1)$既能写正值也能写负值。如果这里也用 sigmoid记忆就只能累加非负数表达能力会被砍掉一半。这是个典型的参数选择逻辑门控用 sigmoid比例内容用 tanh值。第二步算输入门控制候选内容有多少被真正写进去$$i_t \sigma\left(W_i x_t U_i h_{t-1} b_i\right)$$然后更新细胞状态$$c_t f_t \odot c_{t-1} i_t \odot g_t$$这个公式是整个 LSTM 的核心值得再看一遍旧记忆乘一个衰减系数新内容乘一个写入系数两者相加。相加这一步就是梯度高速公路的入口。反向传播时$\partial c_t / \partial c_{t-1} f_t$只是一个逐元素的向量不再是矩阵连乘。如果某个维度上 $f_t$ 接近 1梯度就可以沿着这个维度几乎无损地往前走很多步。顺带说一句早期的 LSTM 变体还引入了 peephole 连接让门能看到细胞状态本身$f_t \sigma(W_f x_t U_f h_{t-1} V_f \odot c_{t-1} b_f)$。理论上更合理实际收益很小主流框架都不支持实践里基本没人用知道有这么回事就行。2.4 输出门决定这一刻读出什么最后一步是把内部记忆转成对外输出$$o_t \sigma\left(W_o x_t U_o h_{t-1} b_o\right)$$$$h_t o_t \odot \tanh(c_t)$$注意这里的顺序先把当前细胞状态过一遍 $\tanh$ 压到 $(-1, 1)$再乘输出门。$\tanh$ 的作用是保证输出的量级稳定不然记忆累加久了数值会飘输出门的作用是控制“这一刻到底要让外界看到多少”。这个设计解释了 LSTM 的一个有趣能力它可以做到内部记住某件事但不影响当前输出。比如解析一句话主语在开头出现中间隔了一大段修饰语模型可以在内部一直保存主语信息$c$ 里保留但中间每一步的 $h_t$ 都聚焦在局部语法上直到需要的时候再把主语放出来。我在调试时常用的一个验证方法是训练好一个模型后把某个样本跑一遍手动记录每一步的 $f_t$、$i_t$、$o_t$ 的均值。正常工作的模型上你能看到明显的分工——标点符号位置附近经常出现 $i_t$ 的尖峰和 $f_t$ 的下凹清空再写入而承载关键实体的位置$f_t$ 会长时间保持在 0.9 以上。如果三个门的均值全程都贴在 0.5 附近基本可以断定模型没学到门控行为问题多半在数据或学习率上。2.5 参数形状与维度对照表把公式写完之后一定要把形状对一遍这是我自己养成的一个习惯。设批量大小 $B$、输入维度 $D$、隐层维度 $H$那么一步内部各张量的形状如下变量形状说明$x_t$$(B, D)$当前输入$h_{t-1}$$(B, H)$上一步隐状态$W_{*}$四个门各一个$(H, D)$输入到门的权重$U_{*}$$(H, H)$隐状态到门的权重$b_{*}$$(H,)$门偏置$f_t, i_t, o_t, g_t$$(B, H)$三个门加候选记忆$c_t, h_t$$(B, H)$两个状态输出工程实现里几乎没人会真的去建四个独立的线性层而是把四个门的权重在输出维度上拼起来一次矩阵乘法算出 $4H$ 维的结果再切分这样能显著减少框架调用开销。拼接后的权重形状是 $(4H, D)$ 和 $(4H, H)$偏置是 $(4H,)$。记住这个拼接顺序它就是后面改偏置时最容易出错的地方。参数总量算一下$4H(D H) 4H$。如果输入维度 128、隐层 256那单层就是 $4 \times 256 \times (128 256) 1024 \approx 394K$。同样配置下普通循环网络大概只有 98K 左右正好四倍。堆叠 $N$ 层时第一层输入维度是 $D$后面每层的输入维度都是 $H$。3. 数学推导前向公式、反向梯度与那个关键的加号3.1 前向传播的完整公式把前面分散的公式收拢一下一个时间步的前向计算按顺序是$$z_t \begin{bmatrix} W_i \ W_f \ W_g \ W_o \end{bmatrix} x_t \begin{bmatrix} U_i \ U_f \ U_g \ U_o \end{bmatrix} h_{t-1} \begin{bmatrix} b_i \ b_f \ b_g \ b_o \end{bmatrix}$$$$i_t \sigma(z_t[0{:}H]),\quad f_t \sigma(z_t[H{:}2H]),\quad g_t \tanh(z_t[2H{:}3H]),\quad o_t \sigma(z_t[3H{:}4H])$$$$c_t f_t \odot c_{t-1} i_t \odot g_t,\qquad h_t o_t \odot \tanh(c_t)$$这里我把拼接顺序写成 $i, f, g, o$和上面提过的 PyTorch 内部顺序一致。手写实现时可以自己选顺序但一旦选定就必须在前向和反向里保持一致否则梯度会静默算错——不会报错只会训练得很差。初始条件 $h_0 \mathbf{0}$、$c_0 \mathbf{0}$。整个序列跑完之后输出通常取 $h_T$或者送入下游的全连接层。3.2 反向传播逐项拆解反向传播也就是沿时间展开的 BPTT看着吓人其实只要抓住“每个中间变量都可能有多个下游消费者梯度要累加”这一条原则。假设从下一时刻传回来的隐状态梯度是 $dh_{\text{next}}$从输出层传回来的梯度是 $dh_{\text{out}}$先合并$$dh_t dh_{\text{out},t} dh_{\text{next}}$$第一步穿过输出门。$h_t o_t \odot \tanh(c_t)$对 $o_t$ 和 $c_t$ 分别求偏导$$do_t dh_t \odot \tanh(c_t) \odot o_t \odot (1 - o_t)$$$$dc_t dc_{\text{next}} dh_t \odot o_t \odot \left(1 - \tanh^2(c_t)\right)$$注意 $dc_t$ 这里有两个来源一是下一时刻沿细胞状态传回来的 $dc_{\text{next}}$二是当前时刻 $h_t$ 对 $c_t$ 的依赖。这个“两路相加”是整个 LSTM 反向传播的关键也是梯度能跨长距离传递的数学根源。第二步穿过细胞状态更新式。$c_t f_t \odot c_{t-1} i_t \odot g_t$分别对四个门求$$df_t dc_t \odot c_{t-1} \odot f_t \odot (1 - f_t)$$$$di_t dc_t \odot g_t \odot i_t \odot (1 - i_t)$$$$dg_t dc_t \odot i_t \odot \left(1 - g_t^2\right)$$同时还要把梯度继续往回传给 $c_{t-1}$ 和 $h_{t-1}$$$dc_{t-1} dc_t \odot f_t$$第三步回传到线性层。把四个门的梯度拼回 $dz_t$形状 $(B, 4H)$然后$$dW dz_t^\top x_t,\qquad dU dz_t^\top h_{t-1},\qquad db \sum_{b1}^{B} dz_t^{(b)}$$$$dx_t dz_t W,\qquad dh_{t-1} dz_t U$$这里的 $W$ 和 $U$ 都是拼接后的 $(4H, \cdot)$ 矩阵。整个反向过程从 $tT$ 倒着走到 $t1$每个时刻的 $dW$、$dU$、$db$ 都累加最后统一更新。写代码时最容易被忽略的是 $dc_{\text{next}}$ 这个回流项。很多人第一次实现时只算了 $dh_t \odot o_t \odot (1-\tanh^2(c_t))$忘了加上从后面传回来的梯度结果模型还能凑合训练——因为另一条通路还在起作用——但效果会明显偏弱而且很难通过看 loss 曲线发现问题。我建议手写实现完之后务必做一次数值梯度检验。3.3 为什么说 LSTM 缓解而不是消除了梯度消失这是我在读资料时被误导过的一个点很多材料写“LSTM 解决了梯度消失”这个说法不准确容易让人误以为可以随便堆长序列。看上面推导出的那一行$dc_{t-1} dc_t \odot f_t$。要从时刻 $T$ 传到时刻 $k$需要连乘$$\frac{\partial c_T}{\partial c_k} \prod_{tk1}^{T} f_t$$这是一个逐元素的连乘不是矩阵连乘。这带来两个好处其一没有权重矩阵参与不会因为谱范数放大或缩小引入额外的缩放其二每个维度独立模型可以学着在某些维度上让 $f_t \approx 1$长期保存某些维度上让 $f_t \approx 0$快速清空实现选择性的记忆时长。但这依然是连乘依然会衰减。如果所有维度上 $f_t$ 都是 0.9那么 100 步之后就是 $0.9^{100} \approx 2.6 \times 10^{-5}$梯度照样微弱。所以准确的说法是LSTM 把梯度衰减的速率从“由权重矩阵谱范数决定”变成了“由网络自己学习的遗忘门决定”给了模型一个可调的、内容相关的时间尺度但没有从数学上消除指数衰减的可能性。另一方面$h_t$ 那条路径上依然存在 $U$ 矩阵的连乘只不过 $h_t$ 在每一步的输出路径上都有直接的梯度注入$dh_{\text{out},t}$所以不需要靠跨很多步的回传来学习——每个时刻的 $h_t$ 都能独立地从输出端得到信号。这个“每个时刻都有监督信号”的设定在很多任务比如逐帧标注里可以大幅缓解长程依赖的压力。理解了这一点后面的实践选择就很自然了序列超过几百步时光靠 LSTM 硬扛效果有限该做分段、该加注意力、该换架构都是合理的选择。别指望调调参就能让 LSTM 记住一千步之前的信息。3.4 梯度裁剪与数值稳定既然连乘还在梯度爆炸的风险也就还在。实践中几乎必须做梯度裁剪。最常见的做法是按范数裁剪$$\text{if } |g|_2 \theta \text{ then } g \leftarrow g \cdot \frac{\theta}{|g|_2}$$$\theta$ 取 1.0 到 5.0 之间是常见范围。我一般从小值开始试序列长度越大$\theta$ 取得越小。按范数裁剪比按值裁剪clip by value更平滑因为它在整体方向上缩放不会破坏梯度的方向信息。还有一个数值稳定的细节sigmoid 在 $|x|$ 很大时会饱和直接计算 $1/(1e^{-x})$ 在 $x$ 是很大的负数时会溢出。稳妥的写法是分段处理或者至少把输入裁剪到 $[-30, 30]$ 再算。手写实现的时候我就因为没做这个处理在某个批次上直接得到了nan排查了大半天。4. 代码落地numpy 手写一遍再用框架跑起来4.1 numpy 前向实现与逐步验证我坚持认为LSTM 一定要手写一遍前向不然永远搞不清楚形状和门的对应关系。下面这份实现刻意写得直白没有做任何向量化优化为的是每一步都能对着公式看。import numpy as np def sigmoid(x): x np.clip(x, -30.0, 30.0) return 1.0 / (1.0 np.exp(-x)) class LSTMCell: def __init__(self, input_dim, hidden_dim): self.input_dim input_dim self.hidden_dim hidden_dim H hidden_dim self.W np.random.uniform(-0.1, 0.1, (4 * H, input_dim)) self.U np.random.uniform(-0.1, 0.1, (4 * H, H)) self.b np.zeros((1, 4 * H)) self.b[0, H:2 * H] 1.0 # 遗忘门偏置初始化为 1 self.cache [] def forward(self, X, h0None, c0None): # X: (T, B, D) T, B, D X.shape H self.hidden_dim h np.zeros((B, H)) if h0 is None else h0 c np.zeros((B, H)) if c0 is None else c0 self.cache [] hs np.zeros((T, B, H)) for t in range(T): x X[t] h_prev, c_prev h, c z x self.W.T h_prev self.U.T self.b # (B, 4H) i sigmoid(z[:, 0:H]) f sigmoid(z[:, H:2 * H]) g np.tanh(z[:, 2 * H:3 * H]) o sigmoid(z[:, 3 * H:4 * H]) c f * c_prev i * g tc np.tanh(c) h o * tc self.cache.append((x, h_prev, c_prev, i, f, g, o, tc)) hs[t] h return hs, h, c代码里有两个刻意的选择一是偏置初始化为 1 只加在遗忘门上切片H:2H二是拼接顺序固定为 $i, f, g, o$。跑之前建议做一次手算对照拿 $T1$、$B1$、$D2$、$H2$ 的极小例子把每个中间结果打印出来和纸上用公式算出来的数值比对一遍。这个习惯帮我省过很多次时间。4.2 反向传播实现要点反向代码接在上面核心就是把 3.2 节的公式逐行翻译。要注意dh_next和dc_next的初始值是零并且每一步都要累加。def backward(self, dhs): # dhs: (T, B, H) 每个时刻 h 的梯度 T len(self.cache) H self.hidden_dim dW np.zeros_like(self.W) dU np.zeros_like(self.U) db np.zeros_like(self.b) dxs np.zeros((T,) self.cache[0][0].shape) dh_next np.zeros_like(dhs[0]) dc_next np.zeros((dhs.shape[1], H)) for t in reversed(range(T)): x, h_prev, c_prev, i, f, g, o, tc self.cache[t] dh dhs[t] dh_next dc dc_next dh * o * (1.0 - tc ** 2) do dh * tc * o * (1.0 - o) di dc * g * i * (1.0 - i) dg dc * i * (1.0 - g ** 2) df dc * c_prev * f * (1.0 - f) dz np.concatenate([di, df, dg, do], axis1) # (B, 4H) dW dz.T x dU dz.T h_prev db dz.sum(axis0, keepdimsTrue) dxs[t] dz self.W dh_next dz self.U dc_next dc * f return dxs, dW, dU, db写完一定要做数值梯度检验随机取几个参数分量把它的值分别加减一个极小的 $\epsilon$用有限差分算出数值梯度和上面算出的解析梯度比对相对误差控制在 $10^{-6}$ 以内基本就没问题。我见过不少人跳过这一步结果模型能跑、loss 也降但就是比预期差一截最后查出来是某个门漏了一项梯度。4.3 框架版本模块定义与参数初始化实际项目里当然不会用上面的手写版本性能和可维护性都跟不上。框架版本的关键在于初始化这一步做对了后面调参会省很多力气。import torch import torch.nn as nn class SeqClassifier(nn.Module): def __init__(self, input_dim, hidden_dim, num_layers, num_classes, dropout0.3): super().__init__() self.lstm nn.LSTM( input_dim, hidden_dim, num_layersnum_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0.0, ) self.drop nn.Dropout(dropout) self.fc nn.Linear(hidden_dim, num_classes) self._init_params(1.0) def _init_params(self, forget_bias): for name, p in self.lstm.named_parameters(): if name.startswith(weight_ih): nn.init.xavier_uniform_(p) elif name.startswith(weight_hh): nn.init.orthogonal_(p) elif name.startswith(bias): n p.size(0) p.data.zero_() p.data[n // 4: n // 2].fill_(forget_bias) # 顺序是 i, f, g, o nn.init.xavier_uniform_(self.fc.weight) self.fc.bias.data.zero_() def forward(self, x, lengthsNone): if lengths is not None: packed nn.utils.rnn.pack_padded_sequence( x, lengths, batch_firstTrue, enforce_sortedFalse) out, (h, c) self.lstm(packed) out, _ nn.utils.rnn.pad_packed_sequence(out, batch_firstTrue) idx torch.arange(out.size(0), deviceout.device) last out[idx, (lengths - 1).to(out.device)] else: out, (h, c) self.lstm(x) last out[:, -1] return self.fc(self.drop(last))几个必须强调的点weight_hh用正交初始化比均匀初始化收敛更稳这个在长序列上尤其明显因为它能让循环部分的谱性质在初始阶段保持接近 1nn.LSTM自带的dropout参数只在层与层之间生效单层时完全无效所以外面必须再套一个nn.Dropout使用pack_padded_sequence时lengths必须是 CPU 上的int64张量这是个高频报错点。4.4 训练循环与变长序列处理训练循环本身没什么特别但有几个细节决定成败def train_one_epoch(model, loader, optimizer, criterion, clip5.0): model.train() total, count 0.0, 0 for xb, yb, lens in loader: optimizer.zero_grad() logits model(xb, lens) loss criterion(logits, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), clip) optimizer.step() total loss.item() * xb.size(0) count xb.size(0) return total / countoptimizer.zero_grad()放在循环里这一步看着废话但zero_grad(set_to_noneTrue)现在更推荐能省一点显存。变长序列的批处理有两种做法一是用pack_padded_sequence二是手动加 mask。前者效率高但要小心排序要求后者写起来啰嗦但不容易出错。我一般先用 mask 版本跑通确认逻辑对了再换 pack 版本提速。还有一个容易忽略的点取序列表示时不能用out[:, -1]。如果按最长序列补齐out[:, -1]拿到的是补齐位置的状态那个位置根本没有真实输入表示是无效的。必须按各自的真实长度去取也就是上面代码里的out[idx, lengths-1]。这个 bug 我在早期项目里犯过现象是模型表现特别不稳定不同批次差异巨大排查了很久才发现是取错了位置。4.5 一份可直接复现的实验配置下面这套配置我在几个中等规模的序列分类任务上反复用过作为一个可靠的起点超参数取值说明隐层维度128 ~ 256再大容易过拟合收益不明显层数23 层以上收益递减训练明显变慢dropout0.2 ~ 0.4层间 输出前双重 dropout优化器AdamWweight_decay 设 1e-4 到 1e-5学习率1e-3初调后期用余弦退火降到 1e-5批大小32 ~ 64太小梯度噪声大太大泛化差梯度裁剪5.0序列越长取得越小序列长度截断到 200 ~ 500超长序列优先考虑分段早停验证 loss 连续 5 轮不降patience 别设太大数据预处理部分有两个必须做的按特征维度做标准化用训练集的均值和标准差验证测试集用同一套统计量以及序列长度分布统计。如果长度分布是长尾的95 分位之外的截断掉通常不会有损失反而能省大量训练时间。5. 常见问题与排查实录5.1 症状速查表下面这张表是我这些年遇到过、也帮别人排查过的问题集合按症状索引症状最可能原因处理方式loss 完全不降贴着初始值学习率过大导致门全饱和忘记 zero_grad学习率除以 10 试一次检查梯度是否累积loss 剧烈震荡后变 nan梯度爆炸输入含 inf 或异常大值加梯度裁剪检查数据统计量训练集准确率高验证集很差过拟合加 dropout、减层数或隐层维度、早停长时间稳定在某个准确率不动长距离依赖没学到遗忘门偏置未初始化调遗忘门偏置缩短序列或加注意力不同批次表现差异巨大变长序列取了补齐位置的表示按真实长度索引训练速度极慢序列过长未启用高效实现缩短序列确认输入连续内存布局门控均值全程在 0.5 附近模型没学到门控行为检查学习率、数据归一化、初始化pack 报错lengths 类型或设备不对转成 CPU int64 张量5.2 几条踩出来的经验第一条先把手写版本跑通再上框架。听起来很浪费时间实际上省时间。手写版本让你必须显式面对每一个形状和每一个梯度项一旦出现“模型能跑但效果不对”的情况你对内部状态的直觉能帮你快速定位。我现在的习惯是新结构一定先写个小规模的数组版本用数值梯度检验过一遍再迁到框架里。第二条可视化门的活动。训练完之后抽几个样本把三步的门输出画出来看。如果遗忘门在所有位置都接近同一个值说明模型没有学会按内容遗忘这时候要么数据里的长程依赖本身不存在要么学习率太大把门推到了饱和区。这个检查比看 loss 曲线信息量大得多我也推荐把它作为每次训练的固定动作。第三条不要迷信双向。双向 LSTM 在分类任务上通常能涨点但在任何有因果约束的场景比如在线预测、自回归生成里都不能用因为它会看到未来。见过有人在一个流式推理的服务里用了双向离线指标很好看上线之后延迟和效果都不对返工成本很高。第四条关注隐藏状态是否“僵死”。如果训练一段时间后同一个批次里不同样本的隐状态几乎一模一样说明模型把序列信息压没了。这种情况常见于学习率过大、或者输入特征维度远大于隐层维度。我一般的做法是加大隐层维度或者先做个降维。第五条关于序列长度的直觉。我的经验是LSTM 在 200 步以内表现稳定200 到 500 步需要仔细调遗忘门偏置和梯度裁剪超过 500 步就得考虑分段、降采样或者换结构了。这个界限不是绝对的取决于任务里有效依赖的实际距离但可以作为初始判断。第六条别忘了最简单的基线。在做序列分类时先跑一个“对所有时间步做平均池化 全连接”的基线很多时候它的效果离 LSTM 只差一两个点但训练速度快十倍。只有确认 LSTM 确实带来显著提升才值得为它付出那些工程复杂度。这条经验让我避免过好几次为了用而用的过度设计。调 LSTM 这件事做到最后其实是在和数据的真实结构对话。超参数、初始化、门控行为这些都是在帮你确认一件事任务里的依赖关系到底有多长、有多稀疏、有多依赖上下文。模型只是把这个结构显式化的工具看懂了门在做什么很多原本玄学的调参决定就变得有据可依了。