ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

LSTM多步预测避坑指南:从数据组织到模型验证的工程实践

LSTM多步预测避坑指南:从数据组织到模型验证的工程实践

简介:这份资源面向具备一定深度学习基础、希望用PyTorch实践金融时间序列预测的学习者,聚焦LSTM多变量多步股票预测这一典型任务,帮助读者打通从数据预处理、特征构建到序列到序列建模的完整链路。压缩包共11个文件,约222KB,以xml工程配置、iml模块文件为主,辅以一个Python主程序、一份xlsx股票数据样例和一张结果示意图,结构轻量,便于直接运行与二次修改。资源已有9250人学习下载,说明其在入门实战场景中具备一定参考价值。代码围绕编码器—解码器LSTM展开,涉及标准化与缺失值处理、教师强制策略、MSE/MAE损失与Adam优化、训练验证划分及早停等关键环节,并给出预测结果与实际值对比的可视化思路。读者可据此理解多变量输入的组织方式、多步预测的解码流程以及模型评估指标的使用,进而迁移到自己的数据集上做实验与调参。

1. 多步预测的真相:LSTM 在股票序列里到底学到了什么

很多人第一次跑通 LSTM 股票预测,都会经历同一个落差:训练 loss 一路下降,画出来的曲线几乎贴着真实价格,可一旦把预测窗口往后推几步,结果立刻变成一条平滑的直线或者剧烈震荡的噪声。这不是代码写错了,而是多变量多步预测这件事本身藏着一个容易被忽略的结构问题——模型学到的往往不是「未来会怎么走」,而是「上一刻长什么样」。

搞懂这个机制,比调参重要得多。一个 LSTM 多步预测项目,真正决定成败的是三件事:输入张量的形状怎么组织、多步输出怎么解码、以及归一化与损失函数如何配合。这三件事决定了模型是在学序列依赖,还是仅仅在记忆价格水平。下面从数据组织一路拆到训练循环,把每一步为什么这么设计讲清楚。

2. 多变量输入的组织:为什么张量形状决定了模型能不能学到东西

2.1 从一张表到三维张量:滑动窗口到底在切什么

原始数据通常是一张按时间排列的表,每行一个交易日,列是开盘、最高、最低、收盘、成交量这类字段。LSTM 不接受二维表,它要的是(batch, time_steps, features)三维张量。把表变成张量的过程就是滑动窗口:固定一个回看长度seq_len,从第 i 行取到第 i+seq_len-1 行作为一个样本的输入,第 i+seq_len 行开始的若干行作为标签。

这里第一个容易翻车的地方是「多变量」的含义。多变量不是把多个特征简单堆进 features 维度就完事,而是要求这些特征在同一个时间步上是对齐的。如果收盘价做了归一化而成交量没做,成交量动辄上百万的量级会直接主导梯度,收盘价那点变化在损失里几乎看不见。常见的实现思路是对每个特征列独立做 MinMax 或标准化,并且把 scaler 的拟合范围严格限制在训练集上,验证集和测试集只做 transform。

import numpy as np def make_windows(data, seq_len, pred_len): # data: (T, F) 已归一化的二维数组 xs, ys = [], [] for i in range(len(data) - seq_len - pred_len + 1): xs.append(data[i : i + seq_len]) # 回看窗口 ys.append(data[i + seq_len : i + seq_len + pred_len, target_idx]) # 未来 pred_len 步的目标列 return np.array(xs), np.array(ys)

这段代码里seq_len是回看步数,pred_len是要预测的未来步数,target_idx指定用哪一列当预测目标(通常是收盘价)。注意标签只取目标列,而输入取全部特征列——这是多变量预测的标准做法:用多个相关变量辅助预测单一目标。窗口切完后xs形状是(N, seq_len, F),ys形状是(N, pred_len),后者直接决定了输出层要吐多少个值。

2.2 归一化与目标列的选择:一个被低估的误差来源

归一化这件事值得单独说,因为它是多步预测里最隐蔽的误差放大器。假设用 MinMax 把收盘价压到 [0,1],模型预测出 0.52,反归一化回真实价格时乘上(max-min)再加min,训练集里那个 max 如果是某次异常高点,反归一化的斜率就被拉大了,预测误差会被同比放大。血泪经验是:归一化参数一定要在训练集上算,并且保存下来供推理时复用,绝不能在全体数据上 fit 再切分,否则验证指标会好得离谱,上线就崩。

另一个细节是目标列和特征列的关系。如果目标列本身也在输入特征里(收盘价既当输入又当输出),模型很容易退化成「复制上一刻收盘价」的恒等映射,训练 loss 很低但毫无预测能力。常见的缓解办法是让输入窗口的最后一刻与预测起点之间留一个间隔,或者干脆在输入里去掉目标列的当期值,逼模型从其他变量里推断。

提示:切分训练/验证/测试集要按时间顺序切,不能随机打乱。随机切分会让未来信息泄漏进训练集,指标虚高,这是时序任务里最常见的自欺欺人。

3. 多步预测的三种解码方式:直接多输出、递归、以及它们的取舍

3.1 直接多输出:一次吐出 pred_len 个值

最直观的做法是让 LSTM 最后一层的隐藏状态接一个全连接层,输出维度设成pred_len,一次前向就得到未来所有步。这种结构在 PyTorch 里就是nn.Linear(hidden_size, pred_len)。它的好处是误差不会逐步累积,每一步的预测都直接由同一段历史决定;坏处是各步之间缺乏显式的时序约束,模型可能输出一条不连贯的曲线。

import torch import torch.nn as nn class DirectMultiStepLSTM(nn.Module): def __init__(self, n_features, hidden_size, pred_len, num_layers=2): super().__init__() self.lstm = nn.LSTM(n_features, hidden_size, num_layers, batch_first=True) self.head = nn.Linear(hidden_size, pred_len) # 一次输出 pred_len 步 def forward(self, x): # x: (batch, seq_len, n_features) out, _ = self.lstm(x) last = out[:, -1, :] # 只取最后一个时间步的隐藏状态 return self.head(last) # (batch, pred_len)

batch_first=True让输入的第一维是 batch,符合直觉;out[:, -1, :]取的是序列末尾的隐藏状态,它被认为压缩了整段回看窗口的信息。这里有个设计取舍:只取最后一步,等于假设 LSTM 已经把历史信息都编码进了最终状态;如果序列很长,早期信息可能被稀释,这时可以考虑对out做注意力加权或取均值池化,而不是简单取末位。

3.2 递归多步:把预测值喂回输入

递归方式的思路是模型每次只预测一步,然后把预测值拼回输入窗口,滚动着往后推。它的优点是结构简单、单步精度通常不错;缺点是误差会逐步累积,推得越远越飘,而且推理时要循环pred_len次,速度慢。更麻烦的是训练和推理的不一致:训练时喂的是真实值,推理时喂的是自己预测的值,这种 exposure bias 会让模型在长步预测上表现骤降。

常见的折中方案是 scheduled sampling,训练时按一定概率把真实值替换成模型自己的预测,让模型逐渐适应带噪声的输入。这个技巧在多步预测项目里出现频率很高,但实现时要小心概率调度,前期多用真实值保证收敛,后期逐步提高自预测比例。

3.3 两种方式的对比与选择依据

维度直接多输出递归多步
误差累积无逐步累积随步数累积明显
推理速度一次前向循环 pred_len 次
步间连贯性依赖损失约束天然连贯
训练难度较低需处理 exposure bias

选择依据其实很实际:预测步数在 5 步以内、追求稳定,直接多输出更省心;步数很长、又需要每一步都合理,递归加 scheduled sampling 更合适。我一般会先跑直接多输出拿到基线,再决定要不要上递归。

4. 损失函数与训练循环:MSE 之外你还需要知道的事

4.1 为什么单纯 MSE 会让曲线变平

MSE 对大的偏差惩罚重,对小的偏差几乎不敏感。股票价格在局部往往小幅波动,模型为了降低整体 MSE,倾向于输出接近均值的平滑预测——这就是那条「贴着走但没脾气」的曲线的来源。如果目标是捕捉方向而不是精确点位,可以在 MSE 之外加一项方向损失,或者改用对收益率建模而不是对价格建模。

对收益率建模是个很实用的思路:先把价格序列转成对数收益率,预测收益率再累乘回价格。收益率近似平稳,量级小且围绕零波动,模型更容易学到结构,反变换也天然避免了价格水平漂移带来的外推困难。

# 用对数收益率作为建模目标 log_ret = torch.log(price[1:] / price[:-1]) # 预测后累乘还原 pred_price = price[0] * torch.exp(torch.cumsum(pred_ret, dim=-1))

cumsum沿时间维累加收益率,exp还原成价格比例,再乘起始价格得到绝对价格。注意累乘对早期误差同样敏感,所以收益率预测的稳定性比点位预测更关键。

4.2 训练循环里的三个关键控制点

第一个是梯度裁剪。LSTM 在长序列上容易出现梯度爆炸,torch.nn.utils.clip_grad_norm_几乎是标配,阈值一般设在 1 到 5 之间。第二个是学习率调度,时序任务前期收敛快、后期容易在局部震荡,用 ReduceLROnPlateau 按验证损失降学习率比固定学习率稳。第三个是早停,验证损失连续若干轮不降就停,避免在训练集上过拟合出一段漂亮的假曲线。

optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=5, factor=0.5) for epoch in range(max_epochs): model.train() for xb, yb in train_loader: optimizer.zero_grad() pred = model(xb) loss = criterion(pred, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() # 验证阶段 model.eval() with torch.no_grad(): val_loss = sum(criterion(model(xb), yb).item() for xb, yb in val_loader) / len(val_loader) scheduler.step(val_loss)

clip_grad_norm_在step之前调用,把梯度范数压到阈值以内;scheduler.step(val_loss)把验证损失喂给调度器,触发降学习率。这两个顺序错了都不会报错,但效果会差很多,属于典型的静默 bug。

5. 避坑与排查:多步预测里那些不报错但结果不对的坑

5.1 现象:训练 loss 很低,验证曲线却像直线

原因通常是归一化在全体数据上 fit,或者输入里包含了目标列的当期值,模型学会了恒等复制。解决方式是重做切分,scaler 只在训练集 fit,并检查输入特征是否泄漏了未来信息。

5.2 现象:预测值整体偏移一个常数

多半是反归一化时用了错误的 min/max,或者训练和推理用了两套 scaler。排查方法是把模型输出反变换后与真实值画在同一张图上,看偏移是否恒定;恒定偏移基本可以锁定在反变换环节。

5.3 现象:多步预测越往后越离谱

递归解码的误差累积,或者直接多输出但损失只约束了整体 MSE 而没约束步间关系。解决方式是缩短预测步数、改用直接多输出,或在损失里加入相邻步的差分约束。

5.4 现象:验证损失震荡不收敛

学习率过大或 batch 太小,也可能是序列长度设得过长导致梯度不稳定。先开梯度裁剪,再把学习率降一个量级,观察 loss 曲线是否变平滑。

注意:以上四类问题的共同点是都不抛异常,只能靠对比预测曲线和真实曲线来发现。养成每轮训练后画一张对比图的习惯,比盯着 loss 数字有用得多。

6. 从能跑到可信:多步预测的验证方法与设计取舍

一个 LSTM 多步预测项目「能跑」和「可信」之间隔着一段验证距离。最容易被忽略的是评估口径:用 MSE 衡量点位误差,和用方向准确率衡量趋势判断,结论可能完全相反。我的习惯是至少同时看三个指标——点位误差(MAE/MSE)、方向命中率、以及多步预测的误差随步数增长的曲线。第三条曲线最能暴露模型到底有没有学到时序结构:如果误差随步数线性上升,说明模型基本在靠最近值外推;如果上升很快后趋平,说明模型在长步上已经退化成均值预测。

设计取舍上,有几个点值得反复权衡。第一是回看窗口长度,太短学不到周期,太长引入噪声且训练慢,通常从 20 到 60 个时间步开始试。第二是隐藏层维度和层数,股票序列的信噪比低,过大的模型容量只会更快过拟合,两层、隐藏维度几十到一百多往往够用。第三是是否引入外部变量,多变量预测的价值在于辅助变量能提供目标列之外的信息,但如果辅助变量本身也是噪声,反而拖累模型。

取舍点偏小/偏简偏大/偏复杂建议起点
回看窗口学不到周期噪声多、训练慢20~60 步
隐藏维度欠拟合过拟合快64~128
LSTM 层数表达不足梯度难传2 层
预测步数信息少误差累积先 1~5 步

验证方法上,除了按时间切分的留出集,滚动验证(walk-forward)更贴近真实使用场景:每次用一段历史训练,预测紧接着的一段,然后窗口整体后移。它比单次留出更能反映模型在不同市场阶段的稳定性,代价是训练次数多。如果资源有限,至少保证测试集完全在训练集时间之后,且不参与任何调参。

最后说一句实在的教训。我早期做这类项目时,总想着把网络堆深、把特征塞满,结果模型在训练集上完美,换个时间段就翻车。后来养成一个习惯:任何一次结构调整,都先跑滚动验证看误差随步数的曲线,曲线形状不对就回头查数据组织,而不是继续调超参。多步预测的难点从来不在 LSTM 本身,而在你喂给它的那段历史是否干净、是否对齐、是否没有偷看未来。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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