
做时间序列预测的时间长了你迟早会发现单步预测做得再漂亮真正拿到业务现场十个需求里有八个是多步预测。库存要补未来三周的销售电力负荷要给出未来一周的曲线资金流要预估接下来若干个结算周期……这些场景本质上都是同一个问题输入一段历史输出一串未来。落到模型上这就是 Multi-step LSTM 要干的事。Multi-step LSTM简单说就是让 LSTM 在给定过去一段观测值的前提下一次性或滚动地给出未来 N 个时刻的预测结果。难点并不在于“LSTM 怎么搭”而在于多步预测的思路选型、误差累积逻辑、训练与推理的差异以及最后怎么让结果不被评估指标骗过去。这篇我会从原理讲到可直接复现的代码把我在反复试错中踩过的坑一并整理出来希望对正在做时间序列预测的你有点实际帮助。1. 多步预测与单步预测差的不是“一步”这么简单1.1 单步预测为什么相对容易很多人刚开始学时间序列时接触的都是单步预测拿过去 n 个观测值预测下一个时间点的值。这个框架非常接近标准的监督学习输入是历史窗口输出是单个标签训练时模型只需要拟合一个目标损失函数清晰评估也直观。单步预测之所以相对容易核心原因在于训练目标和推理目标完全一致。模型在训练时看到的是真实的历史序列预测时只需要往前走一步即使这一步有误差它也不会再被带回模型作为下一次的输入。误差被限制在一层不会被反复放大。这也是为什么很多新手做单步 LSTM 时感觉效果还行一旦改成多步立刻翻车模型输出变成一条直线或者快速发散。1.2 多步预测的三个硬骨头多步预测真正难在三个地方这三个问题不是模型复杂度的问题而是问题定义本身带来的。第一个是误差累积。如果你用滚动方式预测第一步预测出来的值会被当作下一步的输入。这一步通常带有误差于是模型实际上是在一个与训练分布不一致的输入上进行预测。误差会随着预测步数的增加不断积累预测曲线往往越到后面越平缓甚至完全失效。这不是 LSTM 特有的问题而是所有递归式预测的通病。第二个是训练策略与推理策略的不一致。如果模型在训练时只学单步推理时却要求它做多步模型天然没见过“用自己的预测结果作为输入”这种场景。这种分布偏移在统计机器学习里叫 covariate shift在多步预测里非常常见而且危害很大。第三个是多步预测的损失函数不好定义。你希望模型未来第 1 步准一点还是未来第 10 步准一点如果只用平均损失模型很可能会把所有注意力都放在更容易拟合的近端步骤上远端步骤被严重忽略。这个细节会在评估时被隐藏导致你以为模型整体不错实际上远端预测已经不能用了。所以多步预测不是一个“把输出维度从 1 改成 N”的简单修改而是需要在策略层面先想清楚用什么方式把“未来 N 步”这个目标交给模型。2. 三种主流多步预测策略选型比调参更重要做 Multi-step LSTM 第一个该拍板的事情不是神经元数量不是学习率而是多步预测的策略。目前主流方案无非三种递归式Iterated / Recursive、直接式Direct / Multi-output、以及 Seq2Seq 结构的编码器-解码器。我把它们放在一起对比你在动手搭模型之前先花十分钟想清楚这个问题后面能省很多返工时间。2.1 递归式多步预测实现最简单但误差会滚雪球递归式多步预测的思路很直白训练一个只做单步预测的模型推理时把上一步的预测结果作为输入的一部分滚动预测下一步。我举个例子。假设你有 168 条历史数据想要预测未来 24 个点。递归式的做法是用最后 24 条数据作为输入预测出第 169 个点然后把第 169 个点的预测值拼到输入窗口末尾扔掉最前面一条再用这个新的窗口预测第 170 个点依次类推直到得到 24 个预测值。这种方式的优点非常明显模型训练简单只学一个单步目标预测步数可以随意调整想预测 10 步、50 步都可以不需要重新训练。很多基准测试和对比实验都喜欢用它当基线因为它通用性强几乎无脑可用。缺点也同样明显误差累积。每预测一步模型都在往自己的错误上叠一层。尤其是当原始序列噪声较大、或者预测步数超过训练窗口长度时误差会以接近线性的速度放大。实测下来递归式在前几步效果还行一旦预测步数超过窗口长度的一半曲线往往严重衰减、波动消失看起来就像一条僵硬的直线。另一个隐性缺点是递归式在推理时的输入分布和训练时的输入分布不一致。训练时输入全部来自真实历史推理时窗口里混入了模型自己的预测值这种分布偏移会让模型的误差被进一步放大。结论是递归式适合预测步数较短的情况比如 1 到 7 步以内或者当你需要快速建立一个可运行的基线时它可以作为第一个对照。如果你要预测 24 步以上这个策略基本不建议单独使用。2.2 直接式多步预测一次输出一整段未来直接式多步预测的做法是在模型输出层直接输出 N 个值对应未来 N 个时刻的预测结果。它不滚动不迭代模型一次推理就把整条预测曲线给你。这种策略的训练方式也相应改变输入仍然是过去的历史窗口但输出的标签不再是单个值而是一整段未来序列。模型要同时拟合 N 个目标输出层数量等于预测步数 N。直接式的最大好处是训练目标与推理目标一致不存在误差累积的问题。模型在训练时就是在学“根据这段历史直接给出未来 N 步”推理时也不需要把预测结果重新喂给模型一步到位。它的缺点主要体现在三个方面第一预测步数在训练时就固定了如果业务上要求改预测长度需要重新训练模型第二输出 N 个目标意味着模型需要同时拟合 N 个输出维度如果 N 太大模型容量和训练难度都会上升第三由于每个输出是独立的神经元模型可能给出不连贯的曲线——比如第 1 步预测 100第 2 步突然跳到 120第 3 步又回到 105序列抖动明显。但这并不影响它成为目前工业界最常用的策略之一。原因很简单在预测步数适中比如 24 步以内的情况下直接式的整体准确率通常优于递归式因为它的误差不会被反复放大。实际业务里预测未来一天或未来一周的曲线我一向优先尝试直接式。2.3 Seq2Seq编码-解码结构为长序列多步预测而生Seq2SeqSequence to Sequence最早是为了解决机器翻译问题提出的但在时间序列多步预测上同样非常好用。它的结构由两个 LSTM 组成编码器 LSTM 负责把输入序列压缩成一个上下文向量解码器 LSTM 负责根据这个上下文向量逐步生成未来的预测序列。编码器会读完整个输入窗口把最后时刻的隐藏状态当作输入序列的“摘要”。解码器拿到这个摘要后开始一步步生成预测值每一步的输出既可以作为该步的预测结果也可以作为下一步的输入。这样生成出来的序列天然带有时间上的连续性比直接式更有“逐步推演”的感觉。Seq2Seq 的优势在于它能处理变长的输入和变长的输出对长预测步数更友好由于解码器是逐步生成的预测序列的形态通常更平滑、更接近真实序列另外你可以在解码阶段引入 Teacher Forcing 技巧来稳定训练这是前两种策略做不到的。代价也很明确结构更复杂训练更慢调参维度更多。编码器、解码器的层数隐层维度是否使用 Teacher Forcing训练时 Teacher Forcing 的概率怎么衰减——每一步都会影响最终效果。如果只是为了预测 5 步以内上 Seq2Seq 有点杀鸡用牛刀。三种策略的适用场景我用一个表整理出来方便你做选型参考。策略训练复杂度推理方式误差累积预测步长适应性典型应用场景递归式低滚动迭代高灵活但短步长更稳基线模型、短步预测直接式中一次输出 N 步无固定步长适中24 小时内负荷/流量预测Seq2Seq高逐步生成可控长短步长均可擅长长步长期曲线预测、多变量预测以我个人的经验如果只是做一个通用 PoC先用直接式打底如果想上线长期预测服务直接上 Seq2Seq递归式作为基准对照。3. 动手实现两个能跑的 Multi-step LSTM 基线策略选型聊完了下面进入实操。我构造一个带季节性和趋势的模拟序列目标是用历史数据预测未来 12 个时间点。这里我会分别实现直接式和递归式两种 LSTM并把每一步的细节讲清楚。代码使用 Keras 配合 TensorFlow 后端实现你只需要提前安装好环境即可。提示下面的数据是模拟生成的真实项目里建议直接替换为自己的业务序列处理流程完全相同。3.1 数据预处理把一列数值变成监督学习矩阵时间序列模型的输入不能是一维数组LSTM 要求输入形状为(样本数, 时间步数, 特征数)。因此第一步要把原始序列切分成“历史窗口 未来窗口”的样本对。这里我生成了一段 1000 步的模拟数据趋势项 24 小时周期项 噪声。import numpy as np np.random.seed(42) t np.arange(1000) trend t * 0.002 season np.sin(2 * np.pi * t / 24) noise np.random.normal(0, 0.1, size1000) data trend season noise接下来定义一个滑窗函数把序列切分成输入和标签。def create_sequences(data, input_len, pred_len): X, y [], [] for i in range(len(data) - input_len - pred_len 1): X.append(data[i:i input_len]) y.append(data[i input_len:i input_len pred_len]) return np.array(X), np.array(y) input_len 48 pred_len 12 X, y create_sequences(data, input_len, pred_len) print(X.shape, y.shape) # 输出: (941, 48) (941, 12)这里input_len48表示每次用过去 48 个时间点作为输入pred_len12表示预测未来 12 个时间点。941 是样本数量1000 - 48 - 12 1。注意切分训练集测试集时千万不要随机打乱。时间序列讲究顺序一旦打乱模型会提前看到未来信息测试评估完全失真。正确做法是按时间顺序切分。train_size int(len(X) * 0.8) X_train, y_train X[:train_size], y[:train_size] X_test, y_test X[train_size:], y[train_size:]归一化这一步需要格外小心。标准做法是只用训练数据计算均值方差再应用到训练集和测试集。如果对全部数据做归一化测试集的信息会偷偷进入训练过程这在时间序列里属于典型的数据泄露。from sklearn.preprocessing import StandardScaler scaler_X StandardScaler() scaler_y StandardScaler() # 注意fit 只用训练数据transform 再用到测试 X_train_scaled scaler_X.fit_transform(X_train.reshape(-1, input_len)).reshape(-1, input_len, 1) X_test_scaled scaler_X.transform(X_test.reshape(-1, input_len)).reshape(-1, input_len, 1) y_train_scaled scaler_y.fit_transform(y_train) y_test_scaled scaler_y.transform(y_test)标准化之后LSTM 输入的每个特征都处于均值 0 方差 1 附近训练稳定性会明显好于原始数值直接进模型。3.2 直接式 Multi-step LSTM 实现直接式模型结构很清晰LSTM 层提取序列特征输出层用Dense(pred_len)一次输出未来 12 个时刻的预测值。from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import EarlyStopping model_direct Sequential([ LSTM(64, return_sequencesTrue, input_shape(input_len, 1)), Dropout(0.2), LSTM(32, return_sequencesFalse), Dropout(0.2), Dense(pred_len) ]) model_direct.compile(optimizerAdam(learning_rate0.001), lossmse) model_direct.summary()为什么第一层 LSTM 要加return_sequencesTrue因为你想让第一层把完整的序列信息传递给第二层 LSTM而不是只输出最后一个时间步的状态。如果只有一个 LSTM 层return_sequences就没必要了。两层 LSTM 堆叠在这里的作用是让模型有更强的序列特征提取能力但也不是层数越多越好经验上两层已经足够。训练时我习惯加早停防止过拟合。验证集从训练数据末尾切出一小段即可。val_size int(train_size * 0.1) X_val, y_val X_train_scaled[-val_size:], y_train_scaled[-val_size:] X_train_final, y_train_final X_train_scaled[:-val_size], y_train_scaled[:-val_size] early_stop EarlyStopping(monitorval_loss, patience10, restore_best_weightsTrue) model_direct.fit( X_train_final, y_train_final, validation_data(X_val, y_val), epochs100, batch_size32, callbacks[early_stop], verbose1 )推理非常直接y_pred_scaled model_direct.predict(X_test_scaled) y_pred scaler_y.inverse_transform(y_pred_scaled)y_pred的形状是(样本数, 12)每一行就是对应测试样本的未来 12 步预测曲线。直接式的好处在这里就体现出来了没有循环没有迭代模型一次性给出一整段预测曲线。3.3 递归式 Multi-step LSTM 实现递归式模型的结构和单步预测几乎一样输出层只有一个神经元。model_recursive Sequential([ LSTM(64, return_sequencesTrue, input_shape(input_len, 1)), Dropout(0.2), LSTM(32, return_sequencesFalse), Dropout(0.2), Dense(1) ]) model_recursive.compile(optimizerAdam(learning_rate0.001), lossmse)注意训练标签的形状直接式的标签是(样本数, 12)递归式的标签是(样本数, 1)。如果直接用上面的y_train来训练会报错。需要重新构造单步标签。def create_single_step_sequences(data, input_len): X, y [], [] for i in range(len(data) - input_len): X.append(data[i:i input_len]) y.append(data[i input_len]) return np.array(X), np.array(y) X_s, y_s create_single_step_sequences(data, input_len) # 切分、归一化步骤与前面相同此处省略推理时要写一个滚动预测函数。def recursive_forecast(model, last_window, steps): current_window last_window.copy() predictions [] for _ in range(steps): # current_window shape: (1, input_len, 1) next_pred model.predict(current_window, verbose0)[0, 0] predictions.append(next_pred) # 把预测值拼到窗口末尾同时丢掉窗口最前面的一个点 current_window np.roll(current_window, -1) current_window[0, -1, 0] next_pred return np.array(predictions)np.roll的作用是把整个窗口向前滚动一步相当于丢弃最旧的数据点然后我们把新预测值放到最后一个位置。就这样循环 12 次得到未来 12 步的预测曲线。同样地预测结果需要反归一化但因为递归式在循环中不断把预测值放进窗口这里涉及一个关键细节你应该在标准化空间里做滚动推理得到完整序列后再反归一化而不是每一步都反归一化、再重新缩放。为什么因为反归一化会将数据映射回原始尺度此时序列的分布与训练时的标准化空间不一致再把它塞回窗口会破坏模型输入的分布一致性。完整的反归一化在推理结束后统一做一次即可。last_window X_test_scaled[-1:, :, :] # 取测试集最后一个窗口 y_pred_recursive_scaled recursive_forecast(model_recursive, last_window, pred_len) y_pred_recursive scaler_y.inverse_transform(y_pred_recursive_scaled.reshape(1, -1))如果你把递归式预测结果画出来会发现一个典型现象前几步预测和真实值贴合得还不错越往后曲线越平逐渐趋向于某个均值。这是误差累积的直接体现。3.4 两个模型的效果对比思路很多文章会直接把模型效果甩个 RMSE 出来但没有多少人告诉你多步预测的评估要分步看。正确的做法是分别计算每个预测步长的 RMSE而不是把 12 步预测结果的误差混在一起求一个总 RMSE。def rmse_per_horizon(y_true, y_pred): return np.sqrt(np.mean((y_true - y_pred) ** 2, axis0)) direct_rmse_by_step rmse_per_horizon(y_test, y_pred) recursive_rmse_by_step rmse_per_horizon(y_test, y_pred_recursive)direct_rmse_by_step是一个长度为 12 的数组第一个元素表示第 1 步预测的 RMSE第 12 个元素表示第 12 步预测的 RMSE。通常情况下你会看到直接式模型的 RMSE 曲线整体平稳前几步略好于递归式递归式模型前 3 到 5 步可能和直接式持平甚至略好但后面迅速恶化。这就是误差累积的实证。如果你看到两种预测都在某个步长附近出现“平头”现象即未来多步的预测值几乎相同不要急着骂模型。先检查是不是归一化有问题是不是序列本身周期性太强是不是预测步长已经超出了模型的有效记忆范围4. 把坑填平我在真实项目里踩过的典型翻车现场4.1 数据泄露归一化时差点把未来搬进训练集我刚开始做多步预测时图省事直接用整个数据集的均值和方差做标准化。结果模型在测试集上的表现特别好好到反常调参也没法让它变差。后来才意识到问题出在数据泄露上如果先用全量数据计算均值和方差测试集的信息就已经参与了训练预处理模型等于在开卷考试。正确的做法是先切分数据集再计算训练集上的均值方差然后用这套参数去转换测试集。而且这个原则也不仅适用于标准化。凡是涉及任何基于数据分布计算的预处理步骤都要严格限制在训练集上完成。否则你得到的不是模型预测能力而是模型“背答案”能力。4.2 递归式误差滚雪球要怎么缓解递归式误差累积只能缓解不能根除。我实测过几种缓解思路第一种是降低输入窗口的长度让模型在推理时对“自己的预测值”依赖更少但这会减弱模型对长期依赖的学习能力第二种是用一个直接式模型先预测前几步再用递归式预测后续步骤混合策略能在一定程度上分摊风险第三种是在训练时加入噪声增强模型对输入扰动的鲁棒性。其实最有效的方式还是换策略。如果业务允许固定预测步长直接用直接式。不硬扛递归式才是聪明做法。4.3 训练不稳定Loss 变成 NaN 怎么排查多步 LSTM 训练中出现 NaN 是家常便饭尤其在深层 LSTM 结构里。原因通常是梯度爆炸经过多层时间展开后梯度相乘越乘越大直接超出数值范围。排查步骤建议按顺序执行先看学习率是不是太大0.01 起步太高了时间序列 LSTM 我通常从 0.001 开始再看有没有对输入做过标准化没有标准化很容易爆炸最后看 LSTM 层数三层以上堆叠会显著提高梯度不稳定性。如果确认是梯度问题可以在模型编译时加梯度裁剪。from tensorflow.keras.optimizers import Adam optimizer Adam(learning_rate0.001, clipnorm1.0) model.compile(optimizeroptimizer, lossmse)clipnorm1.0表示把梯度的 L2 范数裁剪到 1.0 以内。这个参数通常能让你的训练从“动不动就 NaN”变成“稳定收敛”。这个坑非常常见尤其是在层数较多时。4.4 模型把“上一个值”当成了最好的预测这是新手最容易忽略的现象你看到模型预测曲线很平滑RMSE 也不差但实际上它做的工作只是把最后一个观测值重复输出几乎什么也没学到。这在高自相关的序列上格外常见比如温度、流量这种短时间变化小的数据。怎么识别把预测结果和输入窗口的最后一个值对齐看看如果预测曲线基本就是最后一条观测值的水平线那模型就是在偷懒。缓解方式一是降低序列的自相关性比如做差分后再建模二是在训练时引入更长的预测跨度增加模型“真正学习未来变化”的压力三是加入趋势特征作为额外输入提醒模型未来不是一个常数。4.5 评估指标别只看总 RMSE分步看才能发现问题多步预测模型经常出现“平均表现不错但远端一步完全不能用”的情况。如果你只输出一个总的 RMSE会被近端预测的高准确率掩盖掉远端预测的崩坏。经验是一定要把每个预测步长的 RMSE 分别列出来画成一条曲线或者至少打印成一张表。这样你能明确知道模型在第几步开始失效也便于决定业务上到底敢把预测结果用到第几步。这件事我几乎是逢人就安利因为踩过的坑太多了。5. 调参与扩展这些经验是文档里查不到的5.1 输入窗口长度怎么选输入窗口长度的选择往往比模型结构更影响最终效果。我的建议是至少覆盖序列中最长的主要周期。如果是日粒度业务数据窗口至少要包含 7 天让模型有机会看到完整的周周期性如果是小时粒度至少包含 24 小时。在此基础上你可以试着用 48、72、168 作对比实验。窗口越大模型能记忆的历史越长但训练时间也会变长而且窗口过长容易引入过多无关噪声。窗口太小又学不到长期依赖。实际工程项目里我通常以 168一周小时数据作为一个性能与效果的平衡参考点。5.2 Teacher Forcing 的五五开做法Seq2Seq 模型里Teacher Forcing 是常用的训练技巧解码器在训练时每一步用真实的历史值作为输入而不是上一步的预测值。这样可以加速收敛但也导致训练与推理不一致。我在实践中发现折中方案效果更好以一定概率在训练时把模型自己的预测值作为下一步输入刚开始概率高0.8 左右多用真实值随着训练推进概率逐步降低最终到 0.5 左右稳定。这样模型既学到了真实序列的分布也适应了推理时错误累积的情况。这个思路在很多开源实现里叫 Scheduled Sampling实现起来不复杂但有这个细节和没有这个细节最终长周期预测效果差距非常大。5.3 多步 LSTM 的稳定结构配置给你一个我验证多次的稳定结构作为起点两层 LSTM第一层 64 个单元return_sequencesTrue第二层 32 个单元每层后面加 Dropout(0.2)输出层用Dense(pred_len)。激活函数保持默认的 tanh不要轻易换成 ReLU——LSTM 的循环传播本身就有梯度消失风险ReLU 会加剧这个问题。学习率从 0.001 开始batch size 用 32 或 64优化器选 Adam。在这个基准上先观察验证集趋势再决定是否调整层数或单元数量。不要在第一天就堆一个大模型先跑通基线再逐步扩大容量是时间序列项目最稳妥的推进方式。我个人做了几轮实验之后的体会是如果预测步长不超过 10直接式的 Multi-step LSTM 是性价比最高的选择结构简单、训练可控、效果稳定如果要做 24 步以上并且对曲线连续性有要求Seq2Seq 值得认真投人研究递归式我基本只拿来做基线和对照很少让它直接上生产。做多步时间序列预测策略选型决定上限数据质量决定下限模型结构其实只是中间那一层。希望你从这里开始少踩我第一次做 Multi-step LSTM 时踩过的那些坑。