1. 为什么值得把LSTM的公式亲手推一遍很多人第一次接触LSTM都是从一句它解决了RNN的梯度消失问题开始的。这句话本身没错但它太省事了省事到让人误以为理解了。我见过不少同学调包调得飞起nn.LSTM一写hidden_size一填训练也能跑通可一旦被问到遗忘门到底在遗忘什么细胞状态和隐藏状态差在哪为什么这个门用sigmoid那个门用tanh就开始含糊其辞。这不是能力问题是学习路径的问题——大家跳过了公式推演这一步。LSTMLong 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 stateC_t它像一条传送带从序列开头一直贯穿到结尾信息在上面流动时只经过少量的线性交互加法和逐元素乘法不像RNN那样每步都被矩阵乘法和激活函数折腾一遍。这条传送带就是LSTM能记住长期信息的物理基础。光有传送带还不够得决定什么信息放上传送带、什么信息拿下来、什么时候读出来。于是就有了三个门遗忘门、输入门、输出门。门gate的本质是一个sigmoid层输出0到1之间的数0代表完全关闭一点都不让过1代表完全打开全部放行。用逐元素乘法来实现这种开合。这里有个关键设计我要强调为什么门用sigmoid而候选记忆用tanhsigmoid的值域是(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, dH)的大向量然后乘一个(dH, 4H)的大矩阵一次性算出四个门遗忘、输入、候选、输出的预激活值。这样做的好处是矩阵乘法更少、GPU利用率更高。下面为了讲清楚我把它们拆开写但你要知道底层实现通常是合并的。3.2 遗忘门决定丢掉哪些旧记忆遗忘门的公式f_t σ(W_f · [h_{t-1}, x_t] b_f)[h_{t-1}, x_t]表示拼接。W_f形状(dH, 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̃_tC̃_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, seed42): 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], axis1) # (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有多少参数时答不上来。其实很好算四个门各有一个(dH, H)的权重和一个(H,)的偏置所以总参数量是4 × (d × H H × H H) 4H(d H 1)举个例子d128、H256参数量就是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随序列变化你会对记忆这个词有完全不一样的感觉。