
做时序预测的人早晚会遇到一个坎普通RNN用久了会发现它回头看得不够远。去年做水文径流预报时我把18个月的降雨流量数据塞进一个普通的tanh循环单元训练出来发现模型只能记住最近三天的趋势稍微早一点的降雨过程对预测几乎没有贡献。后来把网络切成长短时记忆单元LSTM cell这个问题才明显好转。LSTM cell并不神秘它就是在循环网络里加了一条可以跨时间步传递的“细胞状态”传送带再用三个门决定传送带上哪些信息留下、哪些丢弃。这篇文章从一个经常手推公式的角度把LSTM cell的结构和计算流程拆开揉碎三个门分别干什么六个公式为什么是这样一个执行顺序以及维度、初始化这类实战里容易翻车的细节。LSTM的应用范围远不止水文预报。语音、文本、传感器告警、股价序列……凡是输入是一串有先后依赖关系的数据你都可以用LSTM去建模而且它的底层计算逻辑不会因为任务变化而改变。所以这篇文章适合两类人一类是刚开始学循环神经网络、被各种符号绕晕的学生另一类是已经在用nn.LSTM但总是“调包一时爽细节全不知”的工程朋友。无论你属于哪一类我建议你拿着纸笔把第3节的手算例子完整过一遍再去看代码和调参数体会会完全不一样。1. 普通RNN记不住“久远”的症结在哪里LSTM cell的设计起点1.1 RNN的梯度衰减链标准的循环神经网络在每一个时间步做这样一件事h_t tanh(W_ih · x_t W_hh · h_{t-1} b)它把一个定长向量h_t当作“到目前为止的记忆”一边接收当前输入x_t一边读取上一时刻的隐藏状态h_{t-1}再输出一个新的隐藏状态。从结构上看这个单元确实有记忆能力但它有一个很麻烦的问题当你用时间反向传播算法去更新参数时误差信号要从最后的时刻一路传回最初的时刻。每传一步就要乘一次tanh的导数。tanh的导数最大值是1但绝大多数情况下都远小于1。你可以把它想象成信号经过一个又一个衰减器每走一步都乘以一个小于1的系数传不了几步梯度就趋近于0了。这就是“梯度消失”。梯度一旦消失前面时刻的参数几乎收不到更新信号网络自然学不会“很久很久以前发生了什么”。我见过不少新手把模型从RNN换到LSTM之后loss降不下来就问“为什么”其实就是没有意识到RNN的梯度传播链路本质上是一个连乘结构。1.2 传送带与门的整体图景LSTM对这个问题的破解思路非常直观它单独维护一条细胞状态C_t也就是常说的“传送带”。这条传送带上信息的更新方式是C_t f_t ⊙ C_{t-1} i_t ⊙ g_t这个公式是整篇文章的基石。它和普通RNN最大的区别在于C_{t-1}到C_t的路径上不经过tanh非线性压缩只有两个门控系数f_t和i_t在控制信息的保留和写入。如果f_t接近1且i_t接近0那么细胞状态基本上就是原样沿时间轴往下传梯度也就能顺着这条“高速公路”传回很远。很多资料把这一结构概括成“三个门”遗忘门f_t、输入门i_t、输出门o_t。这三个门都必须与一个候选状态g_t配合。需要特别注意的是这里的“门”不是数字电路里非0即1的开关而是由sigmoid函数生成的、取值在0到1之间的实数。它控制的是信息保留的比例所以整个过程是可微的可以用梯度下降训练。你只要把这张整体图景放进脑子里后面再拆细节就不会迷路传送带是主干道三个门是路口的闸机候选状态是准备汇入主干道的新内容。2. 三个门加一条传送带LSTM cell内部结构逐模块拆解2.1 遗忘门决定记忆保留比例遗忘门在LSTM cell里通常用f_t表示它在某个时间步的计算是f_t σ(W_f · [h_{t-1}, x_t] b_f)σ是sigmoid函数输出范围是(0, 1)。f_t的每一维都对应细胞状态C_{t-1}的一个元素表示“这个维度的旧记忆保留多少”。如果f_t某个分量接近1就等于告诉细胞状态这条信息先别丢我后面还用得着如果接近0就说明这部分记忆没什么价值了直接淘汰。举一个水文预报里的例子。假如过去30天的降雨量被压缩进了细胞状态的不同维度其中一个维度可能记录了“流域土壤是否已经很湿润”这样的中期状态。当连续晴天出现时遗忘门会把这个维度的保留比例调低因为土壤在变干之前积累的湿润程度不再重要。这里的关键是遗忘门不是人工设计特征它是在训练中自动学会该忘什么的。初学阶段很多人会忽略遗忘门的作用以为LSTM能记住长序列全靠输入门。这个认识是片面的。如果遗忘门从不丢弃旧信息细胞状态会被无关历史越堆越满真正有用的长期依赖反而会被淹没。一个好的LSTM模型遗忘门往往会在不同时间步呈现出明显差异这也可以作为你在训练后检查模型是否学到规律的手段。2.2 输入门与候选状态决定写入什么输入门i_t负责控制“新信息以多大比例写入细胞状态”。它和候选状态g_t经常被放在一起理解i_t σ(W_i · [h_{t-1}, x_t] b_i) g_t tanh(W_g · [h_{t-1}, x_t] b_g)候选状态g_t是模型根据当前输入x_t和上一隐藏状态h_{t-1}临时生成的一段“草稿信息”它可能是有用的也可能是噪声。输入门i_t决定这个草稿最终有多少被正式写进传送带。很多教材会把i_t和g_t合称“输入门”严格来说不太严谨。i_t是一个0到1之间的门控系数g_t才是实际内容。你可以把它们类比成“写操作”里的两个条件先把要写的内容准备好候选状态再检查当前这个位置允不允许写入输入门。两者相乘i_t ⊙ g_t就是真正汇入细胞状态的增量。为什么要用sigmoid取门控系数、用tanh取候选状态我在第4节会详细讲。这里你只需要先记住分工遗忘门管旧的丢多少输入门管新的进多少。2.3 输出门决定对外呈现什么输出门o_t不直接参与细胞状态的更新它控制的是C_t中有多少信息可以对外输出为当前步的隐藏状态h_to_t σ(W_o · [h_{t-1}, x_t] b_o) h_t o_t ⊙ tanh(C_t)先对更新后的细胞状态做一次tanh把它压到(-1, 1)区间再用输出门做元素级乘法得到一个筛选后的版本。这个h_t才是传给下一层LSTM、或传给最终输出层做预测的特征。为什么要多这一步因为LSTM的“记忆”一般包含大量内部信息而当前时刻的外部预测并不需要看到所有记忆。比如你在做词性标注时隐藏状态需要包含足够多的上下文信息但真正输出某个词的词性时只需要暴露其中一小部分。输出门相当于给外部世界提供一份“脱敏”后的记忆摘要。结构正确理解之后你会意识到C_t是LSTM cell的内部记忆h_t是外部可见的状态很多框架里拿到的输出是h_t而不是C_t这一点在实用中非常重要。3. 一组完整数字示例手动推演LSTM cell的前向计算3.1 微型例子的变量与权重约定理论讲再多不亲手算一遍都容易飘。下面我构造一个极简的LSTM cell隐藏单元数为1输入维度也是1所以所有门控系数都是标量。虽然规模小但计算的逻辑和真实场景完全一致。初始条件设为上一时刻隐藏状态h_{t-1} 0.1上一时刻细胞状态C_{t-1} 0.5当前输入x_t 0.2为了让手算过程清晰我直接给每组门指定权重和偏置不用训练门控输入权重 W_x隐藏权重 W_h偏置 b遗忘门 f0.30.50.1输入门 i0.40.2-0.1候选状态 g0.10.70.0输出门 o0.6-0.30.2在真实框架中每个门控还有一组分别作用于x_t和h_{t-1}的权重矩阵这里简化为标量后含义不变。你看到公式里出现W_f · [h_{t-1}, x_t]其实就是W_h · h_{t-1} W_x · x_t b两种写法是同一件事。3.2 一步步算出各个门控值先算遗忘门f_t。它接收的中间值z_f是z_f W_fx · x_t W_fh · h_{t-1} b_f 0.3 × 0.2 0.5 × 0.1 0.1 0.21 f_t sigmoid(0.21) ≈ 0.5523sigmoid(0.21)的意思是1 / (1 e^(-0.21))算出来约0.5523。这个数字意味着上一时刻的细胞状态C_{t-1}大约保留55.23%。接着算输入门i_t和候选状态g_tz_i 0.4 × 0.2 0.2 × 0.1 - 0.1 0 i_t sigmoid(0) 0.5 z_g 0.1 × 0.2 0.7 × 0.1 0 0.09 g_t tanh(0.09) ≈ 0.0898输入门是0.5等于说候选状态被写入一半。候选状态约0.0898数值很小因为中间值z_g本身只有0.09tanh在0附近近似于线性所以映射后变化不大。再算输出门o_tz_o 0.6 × 0.2 (-0.3) × 0.1 0.2 0.41 o_t sigmoid(0.41) ≈ 0.6011到这里三个门都已经算完接下来才进入真正的细胞状态更新。3.3 状态更新与输出解读更新细胞状态时把遗忘门的输出和上一状态相乘再把输入门的输出和候选状态相乘两者相加C_t f_t × C_{t-1} i_t × g_t 0.5523 × 0.5 0.5 × 0.0898 0.27615 0.0449 0.32105可以看到旧的0.5经过遗忘门后只留下约0.27615新的候选值只贡献了约0.0449最终细胞状态略微下降。这说明模型在这个时间步更倾向于“遗忘一部分旧记忆同时少量吸收新信息”。如果f_t是0.9而i_t是0.1那么旧记忆会保留得更多新信息只做微调这就是LSTM维持长期记忆的机制。最后算当前隐藏状态h_th_t o_t × tanh(C_t) 0.6011 × tanh(0.32105) 0.6011 × 0.3106 ≈ 0.1867这个h_t 0.1867就是本时间步的对外输出同时它也会作为下一时间步的h_{t-1}继续参与计算。注意C_t并没有直接变成输出而是先经过tanh压缩再被输出门筛选。如果你和普通RNN对比就会发现普通RNN的h_t既当记忆又当输出权重耦合得很紧LSTM把记忆和输出拆开模型表达能力自然更强。4. 维度、拼接、激活函数和初始化计算时最容易被绊倒的细节4.1 权重拼接与维度变化很多人在读LSTM公式时第一反应是W_f的形状到底是什么这取决于你看到的公式写法。如果写成f_t σ(W_f · [h_{t-1}, x_t] b_f)说明它把h_{t-1}和x_t拼成一个长向量假设隐藏状态维度为d_h、输入维度为d_x拼接后的长度是d_h d_x那么W_f的形状就是(d_h, d_h d_x)。如果写成PyTorch风格的公式f_t σ(W_fh · h_{t-1} W_fx · x_t b_f)那W_fh的形状是(d_h, d_h)W_fx的形状是(d_h, d_x)两者各算各的再相加。这两种写法在数学上完全等价因为拼接后做一个大矩阵乘法等价于拆成两个小矩阵乘法再求和。区别只影响代码实现和权重管理。实际使用PyTorch时你会在nn.LSTMCell里看到两个参数weight_ih和weight_hh。weight_ih负责输入x_tweight_hh负责隐藏状态h_{t-1}它们的第一维都是4 * hidden_size因为四个门控输入门、遗忘门、候选状态、输出门的权重被拼在了一个张量里。行顺序是固定的但框架之间有差异这一点第5节的代码里会再做验证。理解了这一层你手写任何深度学习框架下的LSTM都能对得上门。4.2 为什么激活函数一个用sigmoid一个用tanhLSTM里有三种激活函数各自分工明确。sigmoid的输出范围是(0, 1)天然适合做门控。遗忘门想表达的语义是“旧记忆保留百分之多少”这个百分比只能在0到1之间输入门和输出门同理。如果把门控激活函数换成tanh输出可能是负的会出现“负保留率”这种没有实际意义的中间状态模型训练也会变得不稳定。tanh用在两个地方候选状态g_t以及输出阶段对C_t的压缩。原因是候选状态g_t表示“新信息相对于当前记忆的增量”增量可以是正的也可以是负的所以它的输出范围应该是(-1, 1)。tanh的零均值特性也让梯度传播比sigmoid更友好。对C_t做压缩也是同理细胞状态在长时间累积后可能变得很大直接作为输出会让数值不稳定先用tanh压到(-1, 1)再让输出门筛选数值范围更可控。一个常见的误用是有人把候选状态的tanh换成了ReLU。ReLU输出非负细胞状态只能“增加或持平”无法直接通过候选状态表达负向修正。虽然遗忘门可以间接降低记忆但表达空间被压缩了所以经典LSTM里几乎不会这么改。4.3 遗忘门偏置初始化的经验LSTM的初始化对训练效果影响很大其中一个常被忽略的细节是遗忘门偏置b_f的取值。如果偏置初始化为0遗忘门在训练初期输出接近0.5相当于一开始就让模型忘掉一半历史。在序列较长或依赖较强的任务里这会让模型很难起步。我在做水文径流预报时序列长度经常拉到60多个时间步如果遗忘门偏置不调前期loss下降会非常慢。后来把所有遗忘门偏置初始化为1或接近1模型“开局默认记住”过去的信息经过训练再逐步学会遗忘收敛速度明显改善。TensorFlow早年的LSTM实现里有一个unit_forget_bias参数就是干这件事的PyTorch默认的遗忘门偏置没有特殊处理所以做长序列任务时建议自己改初始化。这只是初始化策略不影响LSTM cell本身的公式推导但对训练结果的影响相当实在。另外权重初始化一般使用均匀分布或正交初始化。对nn.LSTM来说PyTorch默认权重是在[-1/sqrt(hidden_size), 1/sqrt(hidden_size)]之间均匀采样这个范围对多数任务是可用的但如果你有明确的长依赖需求可以按照“偏向记忆”的方向去调整遗忘门偏置整体会更稳。5. 用PyTorch把公式和框架对齐手写LSTMCell并验证5.1 基于公式的手写实现光看公式总有种“纸上谈兵”的感觉。下面我用PyTorch从零写一个LSTMCell不调用任何现成循环层直接按前向公式算。这段代码的价值在于它能让你把符号公式翻译成张量操作一旦跑通你对LSTM cell的理解会变得非常具体。import torch import torch.nn as nn def sigmoid(z): return 1 / (1 torch.exp(-z)) def manual_lstm_cell(x_t, h_prev, c_prev, W_ih, W_hh, b_ih, b_hh): # W_ih: (4 * hidden_size, input_size) # W_hh: (4 * hidden_size, hidden_size) gates x_t W_ih.T h_prev W_hh.T b_ih b_hh # 按 PyTorch 行顺序切分i, f, g, o d_h h_prev.shape[-1] i_gate torch.sigmoid(gates[:, 0:d_h]) f_gate torch.sigmoid(gates[:, d_h:2*d_h]) g_candidate torch.tanh(gates[:, 2*d_h:3*d_h]) o_gate torch.sigmoid(gates[:, 3*d_h:4*d_h]) c_next f_gate * c_prev i_gate * g_candidate h_next o_gate * torch.tanh(c_next) return h_next, c_next这里我用了一个小技巧把输入门、遗忘门、候选状态、输出门四个线性变换的结果一次性算出来再切成4块。如果你严格按照上一节的公式手写也可以写成四个独立的矩阵乘法两种方式数值上完全一致。验证时我生成一个batch_size2、input_size3、hidden_size4的例子用随机初始化的权重跑一遍torch.manual_seed(0) batch_size, input_size, hidden_size 2, 3, 4 x_t torch.randn(batch_size, input_size) h_prev torch.randn(batch_size, hidden_size) c_prev torch.randn(batch_size, hidden_size) W_ih torch.randn(4 * hidden_size, input_size) W_hh torch.randn(4 * hidden_size, hidden_size) b_ih torch.randn(4 * hidden_size) b_hh torch.randn(4 * hidden_size) h_manual, c_manual manual_lstm_cell(x_t, h_prev, c_prev, W_ih, W_hh, b_ih, b_hh) print(manual h:, h_manual)这只是一个物理公式的实现还没有和框架对拍。5.2 与内置nn.LSTMCell的权重布局对齐要验证手写版本是不是和PyTorch一致不能直接把随机张量塞给nn.LSTMCell因为两者权重不同。正确做法是把内置nn.LSTMCell的权重取出来喂给手写函数再比较输出。torch.manual_seed(1) lstm_cell nn.LSTMCell(input_size3, hidden_size4) W_ih_p lstm_cell.weight_ih.detach() W_hh_p lstm_cell.weight_hh.detach() b_ih_p lstm_cell.bias_ih.detach() b_hh_p lstm_cell.bias_hh.detach() h_manual2, c_manual2 manual_lstm_cell( x_t, h_prev, c_prev, W_ih_p, W_hh_p, b_ih_p, b_hh_p ) h_framework, c_framework lstm_cell(x_t, (h_prev, c_prev)) print(manual:, h_manual2) print(nn.LSTMCell:, h_framework) print(max abs diff:, (h_manual2 - h_framework).abs().max().item())只要输出差异在1e-6量级以内就说明手写公式和框架的权重排列、计算顺序完全对上了。实际运行时会发现nn.LSTMCell返回的隐藏状态就是h_manual2细胞状态也对得上。通过这个对拍你顺手也验证了PyTorch的权重行顺序第一段是输入门第二段是遗忘门第三段是候选状态第四段是输出门。不同框架可能把顺序调成i, f, o, g所以换框架时一定要看文档否则拿torch的权重喂给别的手写实现结果会错得莫名其妙。5.3 在时间步循环中验证多层与双向结构单个时间步的cell验证通过之后多时间步的序列循环就容易了。所谓LSTM层其实就是一个LSTMCell在一个时间循环里反复执行每一时间步共享同一组权重。你可以自己写一个循环def manual_lstm_layer(xs, h_prev, c_prev, lstm_cell_params): h, c h_prev, c_prev hs [] for t in range(xs.shape[1]): x_t xs[:, t, :] h, c manual_lstm_cell(x_t, h, c, *lstm_cell_params) hs.append(h) return torch.stack(hs, dim1), c把这个输出和nn.LSTM(..., batch_firstTrue)对拍只要初始状态一致、权重一致结果也会一致。这个练习对理解“参数共享”特别有帮助你在这里循环了几十次但实际训练的模型参数只有四组权重和偏置。多层LSTM和双向LSTM也是在这个基础上叠加的。多层LSTM是把上一层的h_t序列当作下一层的x_t双向LSTM则多了一个从序列尾部往头部扫描的反向cell然后把两个方向的隐藏状态拼接或相加后输出。很多人一开始学到这里容易懵但只要理解了单个LSTM cell的计算堆叠只是工程组织问题不再产生新的数学难点。6. 从单个cell到实际任务LSTM建模与调参的经验取舍6.1 取h_t还是C_t作为输出特征实际做预测时一个绕不开的问题是最终接全连接层时到底用哪个状态经典LSTM的对外输出是h_t所以大多数框架的output属性返回的是隐藏状态序列而不是细胞状态序列。比如PyTorch的nn.LSTM返回的第一个元素是(seq_len, batch, num_directions * hidden_size)每一个时间步的向量就是该时刻的h_t。如果你做的是多步预测一般把最后一个时间步的h_T接全连接层输出未来值如果你做的是序列标注那就把每个时间步的h_t都接一个输出头。那C_t有用吗有。在某些任务里最后一步的细胞状态包含更完整的长期记忆当你的输入序列特别长而最后一步的隐藏状态被输出门筛选掉了一部分信息时把C_t也接进输出层有时能提升效果。不过这不是标准做法需要自己实验。我的习惯是先用h_t做基线如果模型在长序列上“近期特征权重过大、远期记忆利用不足”再尝试把C_t或多个时间步的h_t拼接起来作为输出特征。这个做法等于给模型额外提供一条“记忆旁路”有时能带来稳定提升。6.2 水文径流预报等时序场景的输入输出组织以水文径流预报为例输入特征通常是过去若干天的降雨量、蒸发量、气温、历史流量等输出是未来几天或未来几个小时的预测径流。这里有两个关键选择。第一个是时间步的切分长度。窗口太短模型看不到一次降雨过程的完整生命周期窗口太长训练数据变少计算成本上升。通常我会先用自相关分析看看目标序列能记住多少天的自身影响再结合降雨事件的典型持续时间来定。比如一个流域的大规模径流过程往往滞后降雨3到7天那时间步至少要覆盖10天以上留出余量。第二个是特征归一化。LSTM对输入数据的尺度很敏感不同特征的量纲差异如果太大门控计算容易被大数值特征主导。水文数据里流量可能成千上万立方米每秒而降雨量只有几十毫米如果不归一化模型基本学不好。常见的做法是每个特征单独做MinMax归一化或Z-score标准化。这个步骤看起来平平无奇但我见过太多水文预测项目loss曲线一直震荡最后发现就是没做归一化。6.3 更容易踩到的调参问题和应对LSTM训练中最常见的现象是loss曲线出现突然的尖峰。因为LSTM在长序列上是展开计算的内部状态会不断累积一旦某个时刻的输出特别大反向传播的梯度也容易爆炸。面对这种情况除了调小学习率梯度裁剪几乎是必备手段。给梯度设置一个最大范数比如5.0或1.0可以有效防止训练发散又不影响正常收敛。隐藏层维度hidden_size也不是越大越好。LSTMCell的参数量大约是4 * ((input_size hidden_size) * hidden_size hidden_size)隐藏维度翻倍参数量会近似翻两倍。对中小规模时间序列任务hidden_size取32到128之间通常已经足够盲目增大只会让模型过拟合且训练更慢。我做过一组对比同一个水文数据集上hidden_size从64增到256验证集误差几乎没有改善训练时间却长了近三倍。还有一个容易被忽略的点时间步之间是否设置dropout。PyTorch的nn.LSTM里dropout参数只对多层LSTM的非最后层生效如果你只有一个LSTM层设置dropout0.5是无效的。所以要么把dropout放在LSTM后面的全连接层要么堆两层LSTM再在中间加dropout。这种框架层面的小细节不看文档真的容易被坑。最后想说的是LSTM cell虽然经典但并不是所有时间序列问题的唯一解。数据长度较短时简单的线性自回归或树模型可能更稳数据量非常大时Transformer类模型可能更有优势。LSTM真正的舒适区是序列长度中等、样本量不算大、需要解释性较强的任务。你如果能动手把门控计算推一遍再在框架里对拍一次后面碰到任何变体——GRU、双向LSTM、注意力增强LSTM——都可以用同一套方法去拆解这才是这篇文章最希望你带走的东西。