☰
LSTM从梯度消失到门控机制:结构、推导与代码实现
2026/10/25 4:10:14 网站建设 项目流程

做序列任务的人大概都经历过这个阶段:明明数据清洗干净了、标签也核对了,模型在训练集上就是学不动,loss 卡在某个值附近上下抖动,怎么调学习率都没用。我最早碰到这种情况是在做一个传感器读数的分类小工具,输入是几百步的连续采样点,用最朴素的循环网络去跑,短序列还行,序列一长准确率就掉到接近瞎猜。后来一步一步排查,问题落在了梯度上,再后来才真正把**长短期记忆网络(Long Short Term Memory Network,LSTM)**这套结构啃明白。

这篇笔记就是那段时间的完整沉淀。不打算写成教科书式的科普,而是按我当时“从困惑到能复现”的路径来组织:先讲清楚普通循环网络到底卡在哪,再把 LSTM 的结构分析拆成能记住的画面,然后把数学推导里那几个乘法和加法逐项写清楚,最后给出两套代码——一套用纯数组手写,方便你把每一步的形状和数值都对上;一套用主流框架搭建,能直接塞进项目里跑。不管你是刚接触序列建模、被公式吓退过的新手,还是已经在调参但总觉得 LSTM 是个黑盒、想搞清楚内部到底发生什么的中级使用者,这篇应该都能给你一些能直接抄走的东西。我尽量不省略中间步骤,公式出现的每一个符号都会交代它的形状和来历。

1. 从一个训练不收敛的序列任务说起:为什么非得啃 LSTM

1.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_{t=k+1}^{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_{b=1}^{B} dz_t^{(b)}$$

$$dx_t = dz_t W,\qquad dh_{t-1} = dz_t U$$

这里的 $W$ 和 $U$ 都是拼接后的 $(4H, \cdot)$ 矩阵。整个反向过程从 $t=T$ 倒着走到 $t=1$,每个时刻的 $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_{t=k+1}^{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/(1+e^{-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, h0=None, c0=None): # 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$。跑之前建议做一次手算对照,拿 $T=1$、$B=1$、$D=2$、$H=2$ 的极小例子,把每个中间结果打印出来,和纸上用公式算出来的数值比对一遍。这个习惯帮我省过很多次时间。

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], axis=1) # (B, 4H) dW += dz.T @ x dU += dz.T @ h_prev db += dz.sum(axis=0, keepdims=True) 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, dropout=0.3): super().__init__() self.lstm = nn.LSTM( input_dim, hidden_dim, num_layers=num_layers, batch_first=True, dropout=dropout 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, lengths=None): if lengths is not None: packed = nn.utils.rnn.pack_padded_sequence( x, lengths, batch_first=True, enforce_sorted=False) out, (h, c) = self.lstm(packed) out, _ = nn.utils.rnn.pad_packed_sequence(out, batch_first=True) idx = torch.arange(out.size(0), device=out.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用正交初始化比均匀初始化收敛更稳,这个在长序列上尤其明显,因为它能让循环部分的谱性质在初始阶段保持接近 1;nn.LSTM自带的dropout参数只在层与层之间生效,单层时完全无效,所以外面必须再套一个nn.Dropout;使用pack_padded_sequence时,lengths必须是 CPU 上的int64张量,这是个高频报错点。

4.4 训练循环与变长序列处理

训练循环本身没什么特别,但有几个细节决定成败:

def train_one_epoch(model, loader, optimizer, criterion, clip=5.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 / count

optimizer.zero_grad()放在循环里这一步看着废话,但zero_grad(set_to_none=True)现在更推荐,能省一点显存。变长序列的批处理有两种做法:一是用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 这件事,做到最后其实是在和数据的真实结构对话。超参数、初始化、门控行为,这些都是在帮你确认一件事:任务里的依赖关系到底有多长、有多稀疏、有多依赖上下文。模型只是把这个结构显式化的工具,看懂了门在做什么,很多原本玄学的调参决定就变得有据可依了。

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

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

立即咨询