ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LSTM公式手推指南:从门控原理到NumPy前向传播实战

LSTM公式手推指南:从门控原理到NumPy前向传播实战

1. 为什么值得把LSTM的公式亲手推一遍

很多人第一次接触LSTM,都是从一句"它解决了RNN的梯度消失问题"开始的。这句话本身没错,但它太省事了,省事到让人误以为理解了。我见过不少同学,调包调得飞起,nn.LSTM一写,hidden_size一填,训练也能跑通,可一旦被问到"遗忘门到底在遗忘什么""细胞状态和隐藏状态差在哪""为什么这个门用sigmoid那个门用tanh",就开始含糊其辞。这不是能力问题,是学习路径的问题——大家跳过了公式推演这一步。

LSTM(Long Short-Term Memory,长短期记忆网络)是序列建模里绕不开的经典结构。它被用在文本生成、语音识别、时间序列预测、传感器信号处理等一大票场景里。哪怕现在注意力机制和大模型风头正劲,LSTM依然是理解"门控"思想最好的入口,而门控思想又是后面GRU、甚至各种gating机制的祖宗。所以把LSTM的公式从头推一遍,收益不只是"会算LSTM",而是拿到了一把理解整个门控家族的钥匙。

这篇内容适合三类人:一是刚学完RNN、想搞明白LSTM内部到底怎么转的初学者;二是调包用惯了、想补一补底层原理的工程同学;三是准备面试、需要把公式讲清楚讲透的人。我会带着你一个符号一个符号地推,把每个矩阵乘法的形状标出来,把每个门的物理含义讲明白,最后再手写一个能跑通的前向传播。全程不跳步,因为跳步正是大多数人卡住的地方。

2. 从RNN的痛点说起:LSTM到底在解决什么

2.1 普通RNN的记忆为什么靠不住

先把普通RNN的公式摆出来,就一个式子:

h_t = tanh(W_xh · x_t + W_hh · h_{t-1} + b_h)

隐藏状态h_t既当"当前输出",又当"传给下一步的记忆"。问题就出在这个"又当又立"上。反向传播时,梯度要沿着时间步一路往回乘,每经过一步就要乘上一个权重矩阵和tanh的导数。tanh的导数最大是1,而且大多数时候远小于1,权重矩阵的谱范数如果也小于1,那么梯度连乘几十步之后就会指数级衰减到几乎为零。这就是梯度消失。

梯度消失的直接后果是:网络记不住长距离的依赖。比如一句话"我从小在南方长大,……,所以我特别习惯吃米饭",中间隔了十几个词,普通RNN到后面早就把"南方"这个信息冲淡了。反过来如果权重偏大,又会梯度爆炸。RNN的"记忆"本质上是一条没有闸门的直筒通道,信息只能被动地被反复冲刷。

2.2 LSTM的核心思路:给记忆装上门

LSTM的破局思路非常朴素:既然问题是"记忆通道没有控制",那就给它加控制。具体做法是引入一条独立的细胞状态(cell state)C_t,它像一条传送带,从序列开头一直贯穿到结尾,信息在上面流动时只经过少量的线性交互(加法和逐元素乘法),不像RNN那样每步都被矩阵乘法和激活函数折腾一遍。这条传送带就是LSTM能记住长期信息的物理基础。

光有传送带还不够,得决定"什么信息放上传送带、什么信息拿下来、什么时候读出来"。于是就有了三个门:遗忘门、输入门、输出门。门(gate)的本质是一个sigmoid层,输出0到1之间的数,0代表"完全关闭,一点都不让过",1代表"完全打开,全部放行"。用逐元素乘法来实现这种"开合"。

这里有个关键设计我要强调:为什么门用sigmoid,而候选记忆用tanh?sigmoid的值域是(0,1),天然适合做"比例开关",它输出的就是"通过多少百分比"。而tanh的值域是(-1,1),输出有正有负,适合表达"内容本身"——因为记忆里既要有正向激活也要有负向激活,用tanh能保留符号信息。如果候选记忆也用sigmoid,那所有内容都是正的,表达能力会大打折扣。这个"门用sigmoid、内容用tanh"的搭配,是LSTM设计里最精妙的一笔,理解了它,后面GRU的公式你一眼就能看懂。

3. 逐个拆解LSTM的六个核心公式

3.1 符号约定与维度对齐

推公式之前,先把符号和维度钉死,不然很容易在矩阵形状上绕晕。假设:

  • 批量大小为N,输入特征维度为d,隐藏单元数为H。
  • 当前输入x_t形状(N, d),上一步隐藏状态h_{t-1}形状(N, H),上一步细胞状态C_{t-1}形状(N, H)。
  • 权重矩阵W的形状都是(d + H, H)或分开写成W_x为(d, H)、W_h为(H, H)。
  • 偏置b形状(H,),广播到每个样本。

实际工程里最常见的做法是把x_t和h_{t-1}拼接成一个(N, d+H)的大向量,然后乘一个(d+H, 4H)的大矩阵,一次性算出四个门(遗忘、输入、候选、输出)的预激活值。这样做的好处是矩阵乘法更少、GPU利用率更高。下面为了讲清楚,我把它们拆开写,但你要知道底层实现通常是合并的。

3.2 遗忘门:决定丢掉哪些旧记忆

遗忘门的公式:

f_t = σ(W_f · [h_{t-1}, x_t] + b_f)

[h_{t-1}, x_t]表示拼接。W_f形状(d+H, H),b_f形状(H,)。σ是sigmoid。输出f_t形状(N, H),每个元素在0到1之间。

它的物理含义是:对细胞状态C_{t-1}的每一个维度,决定保留多少。f_t里某个位置接近0,就意味着那个维度的旧记忆基本被清空;接近1则几乎原样保留。举个直观例子,处理一段文本时,如果模型判断"当前进入了一个新句子",遗忘门可能会把上一句主语相关的记忆维度压到接近0,腾出空间给新主语。

注意:遗忘门是作用在C_{t-1}上的,不是作用在h_{t-1}上。初学者最容易搞混这一点,记住"遗忘门管的是细胞状态这条传送带"。

3.3 输入门与候选记忆:决定写入哪些新信息

输入门和候选记忆是成对出现的:

i_t = σ(W_i · [h_{t-1}, x_t] + b_i) C̃_t = tanh(W_C · [h_{t-1}, x_t] + b_C)

i_t是输入门,形状(N, H),值域(0,1),决定"新信息写入多少"。C̃_t是候选记忆(也叫候选细胞状态),形状(N, H),值域(-1,1),是"准备写入的内容本身"。

为什么要分成两个?因为"要不要写"和"写什么"是两件事。C̃_t负责生成候选内容,i_t负责给这些内容打一个通过率。两者逐元素相乘i_t ⊙ C̃_t,才是真正写入传送带的新信息。这种"内容与开关分离"的设计,让网络可以灵活地"生成一堆候选,但只挑一部分写进去"。

3.4 细胞状态更新:传送带的加法

有了遗忘门和输入门,细胞状态的更新就水到渠成:

C_t = f_t ⊙ C_{t-1} + i_t ⊙ C̃_t

这个式子是整个LSTM的心脏。注意它是逐元素乘法加逐元素加法,没有任何矩阵乘法,也没有经过激活函数。这正是LSTM能缓解梯度消失的根本原因:梯度沿着C_t往回传时,主要经过的是f_t这个逐元素乘子,而不是一个稠密矩阵连乘。只要遗忘门的值接近1,梯度就能几乎无损地传很远。相比之下,RNN的梯度每步都要乘权重矩阵,衰减快得多。

你可以把C_t想象成一个笔记本:f_t ⊙ C_{t-1}是"擦掉一些旧笔记",i_t ⊙ C̃_t是"写上一些新笔记"。擦和写都是逐维度的,互不干扰。

3.5 输出门:决定这一刻暴露什么

最后是输出门和隐藏状态:

o_t = σ(W_o · [h_{t-1}, x_t] + b_o) h_t = o_t ⊙ tanh(C_t)

o_t是输出门,形状(N, H),决定"细胞状态里的哪些部分要暴露给外部(也就是作为这一步的隐藏状态输出)"。tanh(C_t)把细胞状态压到(-1,1),然后o_t逐元素筛选。

这里要特别澄清一个高频误区:h_t不等于C_t。C_t是内部长期记忆,h_t是这一刻对外输出的短期信息。h_t会传给下一步参与门控计算,也会作为这一时间步的输出送往上层的网络。细胞状态C_t只在LSTM单元内部流转,不直接对外。理解这个区分,你就理解了LSTM"内外分离"的设计哲学。

3.6 六个公式汇总与形状速查

把上面六个式子整理成一张表,方便你对照记忆:

公式名称输出形状激活函数作用对象
f_t = σ(W_f·[h_{t-1},x_t]+b_f)遗忘门(N,H)sigmoidC_{t-1}
i_t = σ(W_i·[h_{t-1},x_t]+b_i)输入门(N,H)sigmoidC̃_t
C̃_t = tanh(W_C·[h_{t-1},x_t]+b_C)候选记忆(N,H)tanh写入内容
C_t = f_t⊙C_{t-1} + i_t⊙C̃_t细胞状态更新(N,H)无传送带
o_t = σ(W_o·[h_{t-1},x_t]+b_o)输出门(N,H)sigmoidtanh(C_t)
h_t = o_t⊙tanh(C_t)隐藏状态(N,H)tanh对外输出

这张表建议你抄一遍。抄的过程就是记忆的过程,比看十遍都管用。

4. 手写一个LSTM前向传播:从公式到能跑的代码

4.1 参数初始化与形状设计

光看公式不动手,永远隔一层。下面我用NumPy从零实现一个单层LSTM的前向传播,把每个矩阵的形状都标出来。先定义参数:

import numpy as np def init_lstm_params(input_dim, hidden_dim, seed=42): rng = np.random.default_rng(seed) scale = 1.0 / np.sqrt(hidden_dim) # 合并四个门的权重,形状 (input_dim + hidden_dim, 4 * hidden_dim) W = rng.uniform(-scale, scale, (input_dim + hidden_dim, 4 * hidden_dim)) b = np.zeros(4 * hidden_dim) return W, b

这里我把四个门的权重合并成一个大矩阵,列方向按[f, i, C, o]的顺序排列,每块hidden_dim列。偏置初始化为0,这是常见做法,因为门控的偏置如果乱初始化,训练初期容易让某个门长期饱和。权重用均匀分布缩放到1/sqrt(hidden_dim),是为了让预激活值的方差保持稳定,避免一上来就落进sigmoid的饱和区。

实操心得:遗忘门的偏置在实践中经常被初始化为1而不是0。原因是训练刚开始时,我们希望网络默认"多记住一些",而不是一上来就大量遗忘。这个小技巧在很多序列任务上能明显加快收敛。你可以把b中对应遗忘门的那一段设成1试试。

4.2 单步前向传播的完整实现

接下来是核心的单步前向:

def lstm_step(x_t, h_prev, C_prev, W, b, hidden_dim): # x_t: (N, input_dim), h_prev/C_prev: (N, hidden_dim) concat = np.concatenate([x_t, h_prev], axis=1) # (N, input_dim + hidden_dim) z = concat @ W + b # (N, 4 * hidden_dim) # 切分成四个门 f = sigmoid(z[:, 0*hidden_dim : 1*hidden_dim]) i = sigmoid(z[:, 1*hidden_dim : 2*hidden_dim]) C_tilde = np.tanh(z[:, 2*hidden_dim : 3*hidden_dim]) o = sigmoid(z[:, 3*hidden_dim : 4*hidden_dim]) C_t = f * C_prev + i * C_tilde h_t = o * np.tanh(C_t) return h_t, C_t def sigmoid(x): return 1.0 / (1.0 + np.exp(-np.clip(x, -500, 500)))

几个细节值得说。第一,np.clip是为了防止exp溢出,虽然理论上sigmoid不会溢出,但数值计算里输入可能很大,clip一下更稳。第二,切分顺序必须和初始化时的列排列一致,否则门就串了,这是新手最容易犯的错,而且错了不报错,只是效果差,非常隐蔽。第三,C_t的更新是纯逐元素运算,没有任何矩阵乘法,这正是前面强调的"传送带"特性在代码里的体现。

4.3 跑一个序列验证数值

写个循环把整个序列跑一遍:

def lstm_forward(X, W, b, hidden_dim): # X: (T, N, input_dim) T, N, _ = X.shape h = np.zeros((N, hidden_dim)) C = np.zeros((N, hidden_dim)) outputs = [] for t in range(T): h, C = lstm_step(X[t], h, C, W, b, hidden_dim) outputs.append(h) return np.stack(outputs), (h, C) # 测试 X = np.random.randn(10, 2, 5) # 10步,批量2,特征5 W, b = init_lstm_params(5, 8) out, (h_final, C_final) = lstm_forward(X, W, b, 8) print(out.shape) # (10, 2, 8) print(h_final.shape) # (2, 8)

跑通之后你会看到输出形状是(T, N, H),最后一步的h和C形状都是(N, H)。建议你手动改几个输入值,观察C_t的变化:如果某一步的遗忘门接近1、输入门接近0,你会发现C_t几乎等于C_{t-1},这就是"记忆被保留"的数值证据。亲手看到这个现象,比看任何解释都直观。

5. 推演中容易踩的坑与排查清单

5.1 形状对不上:最常见的翻车现场

矩阵形状是LSTM实现里最高频的错误来源。我整理了一张速查表,出问题时对着查:

症状可能原因排查方法
矩阵乘法报维度错误W的列数不等于4*hidden_dim打印W.shape,确认第二维
输出形状是(N, 4H)而非(N, H)忘记切分四个门检查切片索引
门的值全是0或1预激活值过大,sigmoid饱和检查权重初始化尺度
训练loss不下降门切分顺序与权重排列不一致核对[f,i,C,o]顺序
梯度爆炸权重初始化过大缩小初始化范围

提示:调试LSTM时,先别急着训练,先做一次前向传播,把f_t、i_t、o_t的均值和最大最小值打印出来。正常情况下它们应该在0.3到0.7之间分布,如果大量接近0或1,说明初始化或输入尺度有问题。

5.2 门控饱和:为什么你的LSTM学不动

门控饱和是LSTM训练里一个隐蔽的坑。sigmoid在输入绝对值大于4左右时导数就接近0了,如果预激活值长期很大,门就会"卡死"在0或1,梯度传不回去,网络学不动。造成饱和的常见原因有三个:输入特征没有归一化、权重初始化尺度太大、学习率过高。

解决办法也很直接:输入做标准化(减均值除标准差),权重用1/sqrt(H)这类缩放初始化,学习率从1e-3往下试。我自己踩过的坑是,有一次输入特征量纲差了三个数量级,结果遗忘门全部饱和到1,模型完全学不会遗忘,表现还不如普通RNN。归一化之后立刻正常了。

5.3 遗忘门偏置初始化的取舍

前面提到遗忘门偏置初始化为1能帮助记忆,但这不是无脑适用的。如果你的任务本身依赖快速遗忘(比如高频变化的时间序列),偏置设1反而会让模型反应迟钝,需要更长时间才能学会遗忘。我的经验是:长序列、长依赖任务(如语言建模)用偏置1;短序列、快速变化任务(如高频信号)用偏置0甚至负值。这个参数值得作为超参调一调,别默认。

6. 从公式推演延伸到实战的几个关键认知

6.1 参数量到底怎么算

很多人被问到"你的LSTM有多少参数"时答不上来。其实很好算:四个门各有一个(d+H, H)的权重和一个(H,)的偏置,所以总参数量是:

4 × (d × H + H × H + H) = 4H(d + H + 1)

举个例子,d=128、H=256,参数量就是4 × 256 × (128 + 256 + 1) = 394,240。如果再加一层,第二层的输入维度就是上一层的H。这个公式在估算模型大小、判断显存占用时非常有用。我见过有人以为LSTM参数很少,结果堆了五层才发现显存爆了,就是没提前算这笔账。

6.2 双向与多层:公式怎么扩展

单层单向是最基础的形态。实际任务里常用双向LSTM:正向跑一遍得到h_t^→,反向跑一遍得到h_t^←,然后拼接[h_t^→, h_t^←]作为输出。这样每个时间步都能同时看到过去和未来的上下文,对分类类任务提升明显。注意双向只适合能拿到完整序列的场景,实时预测这种"未来还没发生"的任务不能用。

多层LSTM则是把第一层的输出序列h_t作为第二层的输入x_t,逐层堆叠。层数增加能提升表达能力,但参数量和训练难度也线性上升。我的经验是序列任务两到三层通常够用,再往上收益递减,而且容易过拟合,得配合dropout。

6.3 和GRU的对比:少一个门换来什么

GRU是LSTM的简化版,把遗忘门和输入门合并成一个"更新门",还多了一个"重置门",细胞状态和隐藏状态也合并了。公式从六个减到四个,参数量少约25%。在大多数任务上,GRU和LSTM效果接近,GRU训练更快、更省资源;但在需要精细长期记忆的任务上,LSTM的独立细胞状态往往更有优势。选哪个没有标准答案,我的做法是:数据量小、追求速度用GRU;数据量大、依赖长用LSTM,两个都试一遍看验证集。

理解了LSTM的六个公式,你再看GRU的公式会有种"原来就是把两个门捏一起"的顿悟感。这就是亲手推公式的价值——它让你看到结构之间的血缘关系,而不是把每个模型当成孤立的黑盒。

6.4 公式推演对调参的实际指导

最后说个很多人没意识到的点:推过公式,调参会有方向感。比如你知道遗忘门控制记忆保留,那么当模型"记不住长依赖"时,你会想到去检查遗忘门偏置、输入归一化;当模型"该忘的忘不掉"时,你会去看遗忘门是不是饱和了。这些判断不是玄学,是从公式里推出来的因果链。反过来,只会调包的人遇到问题只能盲目试学习率、试层数,效率差很多。

我自己在做时间序列预测时,就是靠观察遗忘门的激活值分布,发现模型在序列突变点附近遗忘门没有及时打开,于是调整了输入特征的差分处理,效果立竿见影。这种"从公式到现象再到调整"的闭环,才是把LSTM真正用明白的标志。

如果你现在还没亲手推过这六个公式,我建议你合上这篇文章,拿张纸,把f_t、i_t、C̃_t、C_t、o_t、h_t一个一个默写出来,标上形状,再对照第3节的表检查。写错的地方就是你理解的漏洞。推完再跑一遍第4节的代码,看着C_t随序列变化,你会对"记忆"这个词有完全不一样的感觉。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进