首页 / 资讯中心 / 文章详情

多变量多步LSTM股票预测实战:数据预处理与训练避坑指南

多变量多步LSTM股票预测实战:数据预处理与训练避坑指南 ★ FEATURED ARTICLE
简介一套基于PyTorch的LSTM股票多变量多步预测实现面向深度学习入门者、时间序列分析与量化研究爱好者。资源以可运行的Python脚本为核心配套贵州茅台股价示例数据xlsx、项目配置与依赖说明以及预测结果对比图适合用于理解LSTM门控机制、多步预测解码策略和模型训练评估流程覆盖数据标准化、缺失值填充、特征构造、编码器与解码器搭建等关键步骤。压缩包共11个文件整体约222KB主要包括Python源码、Excel数据、IDE配置和图片文件结构紧凑便于快速上手。已有9240人学习浏览适合希望复现多变量时间序列预测任务、参考完整代码思路并进一步扩展的读者。通过该资源可掌握从数据预处理、序列到序列建模、损失函数选择到训练验证的完整流程为后续引入注意力机制或集成学习打下基础。1. 多变量多步 LSTM 股票预测先把“未来 N 天”这个目标定住刚接触深度学习时序项目的开发者很容易把“用 LSTM 预测股票”想成一个黑匣子把股价丢进去等模型吐出一个明天的数字。实际把 PyTorch 这套流程完整跑一遍之后你会明白真正决定预测质量的往往不在模型结构而在数据怎么组织、目标变量怎么定义。多变量意味着输入不再是单一的收盘价而是把开盘、最高、最低、成交量一起喂进去多步意味着输出不是明天一个点而是未来几个交易日的连续序列。这篇文章顺着数据处理、滑窗构造、模型实现到训练避坑的顺序把整个链路拆开讲透。适合已经在用 PyTorch 写过基础模型但对“多变量多步”落地细节还不太有把握的人。2. 数据预处理与滑窗构造把行情序列转成监督学习样本2.1 多变量字段的选择与基础清洗先明确一点这里说的多变量是指特征维度上不止收盘价一个字段。我一般先用 pandas 读入日线数据字段至少包含 date、open、high、low、close、volume有的数据源还带 amount 成交额。做多步预测时我把前五个字段拼成特征矩阵目标取 close。import pandas as pd df pd.read_csv(stock_daily.csv, parse_dates[date]) df df.sort_values(date).reset_index(dropTrue) # 剔除收盘价为空的行 df df.dropna(subset[close]) # 成交量小于等于 0 的交易日多为停牌或数据异常 invalid df[df[volume] 0].index if len(invalid) 0: print(f剔除 {len(invalid)} 个异常交易日) df df.drop(invalid)先按日期排序再清洗是最容易忽略的一步。很多行情源导出的 CSV 并不保证时间顺序正确如果直接把乱序数据拿去构造序列滑窗里的时间语义就是错的。剔除停牌日或成交量异常日是为了避免模型把“无量涨跌”当成正常信号。如果中间有缺失值我倾向于用前向填充df df.ffill()直接用均值填充在带时间顺序的序列里不合适因为均值来自前后两个不同市场状态等于引入未来信息。前向填充至少保证了缺失位置使用的是上一个合理交易日的状态。2.2 归一化Scaler 只能在训练集上 fit在做归一化之前先按时间切分训练集和验证集再在训练集上 fit MinMaxScaler。很多人习惯先把整个 DataFrame 缩放完再切分这个顺序是错的——验证集的 min/max 已经参与了训练特征的缩放属于信息泄漏。from sklearn.preprocessing import MinMaxScaler feature_cols [open, high, low, close, volume] train_size int(len(df) * 0.8) train_df df.iloc[:train_size] val_df df.iloc[train_size:] scaler MinMaxScaler() train_scaled scaler.fit_transform(train_df[feature_cols]) val_scaled scaler.transform(val_df[feature_cols])这段代码的关键在transform而不是fit_transform。训练集负责确定每个特征的最小值和最大值验证集直接沿用这套映射。这样做的好处是验证阶段模型面对的是和真实预测时一致的数据分布——真实场景中你不可能提前知道未来数据的最大值。2.3 滑窗与多步标签的生成LSTM 不能直接吃一整段股价历史需要把原始序列切成等长的窗口样本。窗口内是过去 60 个交易日的多变量特征标签是窗口之后 5 天的收盘价。这就是“多步”的由来。import numpy as np import torch def create_sequences(data, seq_len60, horizon5, step1): X, y [], [] for i in range(0, len(data) - seq_len - horizon 1, step): X.append(data[i : i seq_len]) # close 在 feature_cols 中是第 4 列索引为 3 y.append(data[i seq_len : i seq_len horizon, 3]) return np.array(X), np.array(y) X_train, y_train create_sequences(train_scaled, seq_len60, horizon5) X_val, y_val create_sequences(val_scaled, seq_len60, horizon5)seq_len是回看窗口长度horizon是向前预测的交易天数step是滑窗位移。create_sequences里每次取data[i : iseq_len]作为输入窗口取紧随其后的horizon天收盘价作为标签。step参数用来控制相邻样本的重叠程度后面避坑章节会专门讲它。注意如果step1相邻两个样本只错开一个交易日训练集样本间重叠度很高模型容易过拟合到局部形态。2.4 组装 DataLoader样本构造好之后转成 PyTorch 的 TensorDataset 和 DataLoader。这里有一个容易被忽略的点训练集可以 shuffle验证集绝对不能 shuffle。from torch.utils.data import TensorDataset, DataLoader train_dataset TensorDataset( torch.tensor(X_train, dtypetorch.float32), torch.tensor(y_train, dtypetorch.float32), ) val_dataset TensorDataset( torch.tensor(X_val, dtypetorch.float32), torch.tensor(y_val, dtypetorch.float32), ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse)shuffle 的目的是打乱时间顺序让每个 batch 里出现不同时间段的样本避免模型被某个连续区间的趋势带偏。验证集保持顺序不 shuffle是因为你评估的是模型对“未来连续多天”的预测能力乱序会掩盖这种前后依赖关系。3. LSTM 模型实现多变量输入与多步输出的形状对齐3.1 先理解 LSTM 的输入输出形状在 PyTorch 里一个标准 LSTM 层接收三维输入(batch, seq_len, input_size)。设定batch_firstTrue后第一维是 batch第二维是时间步第三维是特征数。在这个项目里input_size5对应 open、high、low、close、volume 五个字段。LSTM 层返回两个东西output和(h_n, c_n)。output是每个时间步的隐藏状态形状是(batch, seq_len, hidden_size)h_n是最后一层每个时间步最后输出的隐藏状态形状是(num_layers, batch, hidden_size)。做多步回归时我通常取h_n最后一层的输出而不是output的最后一个时间步——这两者在单层 LSTM 下等价但多层 LSTM 下h_n[-1]才是语义最完整的编码结果。3.2 定义 MultistepLSTM 模型直接输出未来 5 天的预测值模型结构不复杂一个 LSTM 编码器把 60 个交易日的多变量序列压缩成固定向量再接一个全连接层映射到 horizon 个输出节点。import torch.nn as nn class MultistepLSTM(nn.Module): def __init__(self, input_size5, hidden_size64, num_layers2, horizon5, dropout0.2): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout, ) self.fc nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Linear(32, horizon), ) def forward(self, x): # x: (batch, seq_len, input_size) _, (h_n, _) self.lstm(x) # h_n: (num_layers, batch, hidden_size)取最后一层 last_h h_n[-1] # (batch, hidden_size) out self.fc(last_h) return out # (batch, horizon)dropout只在num_layers2时生效它作用于两层 LSTM 之间防止隐藏状态过拟合。fc把 LSTM 编码出的一维向量先映射到 32 维再通过 ReLU 激活后输出 5 个值。中间加一层 32 维的全连接是为了让模型在输出多步之前有一个非线性变换的缓冲直接Linear(hidden_size, horizon)也不是不行但拟合能力会弱一些。提示h_n[-1]取的是最后一层 LSTM 的隐藏状态不是序列最后一个时间步。严格说应该是h_n[-1]很多人写h_n[0]会取到第一层的输出这两个结果差异明显。3.3 直接多步与递归多步的取舍多步预测有两条技术路线。第一种叫直接多步预测模型一次性输出未来 horizon 天的全部预测值上面这段代码就是这种方案。第二种叫递归多步预测每次只预测下一天然后把预测值当作输入继续预测下一天一步步滚动下去。直接多步的优点是结构简单训练时每个样本对应一组完整的多步标签预测时一步到位不存在误差累积。缺点是 horizon 变大时模型要同时拟合多个时间尺度的目标后几步往往被“平均化”。递归多步的优点是更接近自回归过程理论上能捕捉更长的依赖但训练和推理不一致——训练时可以用真实值作为下一步输入推理时只能用预测值误差会逐级放大。我在刚做这个项目时没有立刻上递归方案而是先跑了直接多步作为基线。原因很实际先确认多变量特征和滑窗组织没有方向性错误再考虑更复杂的自回归结构。如果你的数据量不大直接多步的稳定性明显更好。4. 训练与验证切分时序场景下的数据划分与收敛控制4.1 按时间切分训练集与验证集股票数据是典型的时间序列不能用随机 K 折或随机抽 20% 当验证集。随机切分会让验证集里混入时间上位于训练集之前的样本模型在训练时见过“未来”的数据形态验证集评估就失去了意义。正确的做法是严格按时间顺序切。前 80% 作为训练集后 20% 作为验证集中间不留交叉。实际预测场景中你永远是用截止到今天的窗口去预测明天验证集模拟的正是这个场景。train_size int(len(df) * 0.8) train_df df.iloc[:train_size] val_df df.iloc[train_size:]这里有一个细节train_df和val_df的边界处训练集的最后 60 天窗口还在训练集内验证集第一个样本恰好从边界处开始。生成样本时两个集合都必须用各自独立的create_sequences不能让验证集的窗口滑进训练集的数据范围。4.2 训练循环与损失记录训练循环本身没什么特殊之处但有几个顺序问题需要注意。optimizer.zero_grad()必须在每次迭代开始前执行否则梯度会累加loss.backward()之后必须调用optimizer.step()参数才会更新。model MultistepLSTM() optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.MSELoss() epochs 80 for epoch in range(epochs): model.train() total_train_loss 0.0 for bx, by in train_loader: optimizer.zero_grad() pred model(bx) loss criterion(pred, by) loss.backward() optimizer.step() total_train_loss loss.item() * bx.size(0) avg_train_loss total_train_loss / len(train_loader.dataset) model.eval() total_val_loss 0.0 with torch.no_grad(): for bx, by in val_loader: pred model(bx) loss criterion(pred, by) total_val_loss loss.item() * bx.size(0) avg_val_loss total_val_loss / len(val_loader.dataset) if (epoch 1) % 10 0: print(fEpoch {epoch1:3d} | train_loss {avg_train_loss:.5f} | val_loss {avg_val_loss:.5f})在验证阶段用torch.no_grad()包裹循环是为了不计算梯度、不改变任何参数。PyTorch 默认在model.eval()后仍然会保留 dropout 和 batch norm 的运行时行为但如果不用no_grad()验证过程依然会构建计算图占内存且容易触发意料之外的梯度累积。4.3 损失曲线不下降时的调参策略如果训练 20 个 epoch 后验证损失横盘不动别急着加层数。先试学习率从 1e-3 降到 3e-4这是最有效的干预手段。Adam 在时序回归任务上的默认学习率有时偏高损失曲线会出现早期下降后突然停滞的现象。如果训练损失和验证损失都高说明模型欠拟合这时才考虑增大hidden_size到 128 或增加一层 LSTM。如果训练损失低、验证损失高优先怀疑滑窗重叠和归一化泄漏而不是模型容量。# 在 optimizer 创建后追加学习率调度器 from torch.optim.lr_scheduler import ReduceLROnPlateau scheduler ReduceLROnPlateau(optimizer, modemin, factor0.5, patience10)每个 epoch 结束后调用scheduler.step(avg_val_loss)当验证损失连续 10 个 epoch 不下降时学习率自动减半。这是最省心的做法比手写学习率衰减更贴近训练曲线的真实变化。4.4 模型保存与加载保存模型时只保存state_dict不保存整个模型对象。原因是state_dict是纯参数映射跨机器和 PyTorch 版本兼容性更好。best_model_path best_lstm.pt torch.save(model.state_dict(), best_model_path) # 推理时 loaded_model MultistepLSTM() loaded_model.load_state_dict(torch.load(best_model_path)) loaded_model.eval()注意加载后必须调用model.eval()因为新实例默认处于训练模式dropout 和 batch norm 会继续生效推理结果会有随机性。5. 多变量多步预测的常见问题与避坑指南5.1 验证集损失一直横盘八成是数据泄漏现象训练集损失平稳下降验证集损失在前几个 epoch 降一点之后就一直横盘有时甚至反弹训练集损失降到 2e-4 级别验证集损失停在 1e-2 级别下不去。原因最常见的是 MinMaxScaler 在切分之前对整个数据集调用了fit_transform验证集的 min/max 已经进入训练特征第二个常见原因是用shuffleTrue加载验证集导致验证样本顺序被打乱即使没有数据泄漏也会让损失曲线失真。解决严格按时间先切分再在训练集上fit_transform验证集只transform验证集的DataLoader保持shuffleFalse。5.2 预测曲线滞后一天目标变量选错了现象把预测值和真实收盘价画在同一张图上预测曲线整体向右平移了一天像是把前一天的收盘价搬到了今天计算误差时第一天的 MAE 比后面几天明显偏大。原因直接用原始价格作为目标变量模型找到的最优解是“预测值约等于当前输入窗口最后一个交易日的价格”。对于随机游走特征明显的股价序列这个解简单且损失最小。解决把目标从绝对价格改成未来几天的收益率比如y (close_{th} - close_t) / close_t模型去拟合变动率而不是价格绝对值推理时再把收益率乘回当前价格还原预测值。这个改动在时间序列预测里是常规做法对缓解滞后非常有效。5.3 多步预测后几天全部变成同一个数信号本身太弱现象预测未来 5 天第 1 天结果还有明显波动第 4、5 天几乎输出同一个常数看起来像模型“放弃了”。原因直接多步输出时模型对 horizon 内的每一步共享同一个编码向量后几步的价格变化与输入窗口之间的统计关系更弱网络在最小化均方误差时倾向于用一个中间值覆盖后面几步。解决不用急着加模型复杂度先试对损失函数按步数加权给后几步更大的惩罚系数比如[1.0, 1.0, 1.2, 1.4, 1.6]或者直接把目标换成收益率序列后期平均化的问题通常会跟着缓解。5.4 Loss 突然变成 NaN先查数据再调学习率现象训练过程前几十个 epoch 正常某一步 loss 变成 NaN之后所有 epoch 都是 NaN无法恢复。原因最常见的是输入数据里有inf或超大值LSTM 内部的矩阵运算在序列传播中把这些异常值放大也可能是学习率 1e-3 对这个任务偏高梯度在多层 LSTM 回传时爆炸。解决先执行np.isnan(X_train).sum()和np.isfinite(X_train).all()确认数据没问题后再把学习率降到 1e-4。如果数据本身没异常给 LSTM 参数加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)这段代码放在loss.backward()之后、optimizer.step()之前把梯度的 L2 范数限制在 5.0 以内。5.5 训练集表现很好、验证集表现很差滑窗重叠太多现象训练集损失降到非常低的水平验证集损失始终偏高验证集上预测曲线与真实值完全偏离不是滞后问题而是整体失真。原因step1生成滑窗时相邻样本只差一个交易日训练集里大量样本高度重叠。模型学到的更多是“记住”这一段时间内的趋势形态一旦验证集进入没见过的时间段泛化能力立刻崩掉。解决把create_sequences的step参数从 1 调到 3 或 5降低相邻样本重叠度如果数据量足够同时对训练集做多个互不重叠的时段切片让模型在各个区间上都接触到形态差异。6. 多步预测的落地验证滚动回测与分步误差报告模型训练完成后最后一步不是看训练损失多漂亮而是把预测结果拉回原始度量空间里做验证。我用验证集的最后一段数据做滚动回测每 60 天预测未来 5 天然后窗口整体滑动 5 天重复预测。这样能还原真实使用场景——你总是用过去 60 天预测未来 5 天而不是一次性预测整个未来序列。import numpy as np model.eval() pred_all, true_all [], [] with torch.no_grad(): for bx, by in val_loader: pred model(bx) pred_all.append(pred.numpy()) true_all.append(by.numpy()) pred_all np.concatenate(pred_all, axis0) true_all np.concatenate(true_all, axis0) # 按未来第 1 至第 5 天分别计算 MAE for step_idx in range(5): mae np.mean(np.abs(pred_all[:, step_idx] - true_all[:, step_idx])) print(f第 {step_idx1} 天 MAE: {mae:.5f})这段代码的核心是“分步误差报告”。多步预测里不同步长的预测难度差异很大只看平均 MAE 会掩盖退化速度。第一天 MAE 低、第五天 MAE 飙升说明模型只在近期内有预测能力长时依赖没学到。如果从第一步开始误差就很大问题多半出在特征组织或数据预处理上而不是模型结构。另一个值得做的验证动作是趋势符号检验。对于股票预测业务预测准确价格几乎不可能但预测未来 5 天的涨跌方向可以有参考价值last_close val_scaled[-seq_len:, 3] # 最后一个输入窗口的真实 close trend_true np.sign(true_all[:, -1] - last_close) trend_pred np.sign(pred_all[:, -1] - last_close) accuracy np.mean(trend_true trend_pred) print(f第 5 天方向准确率: {accuracy:.2%})方向准确率比 MAE 更贴近实际使用场景。如果你做的是量化策略趋势方向对仓位决策的影响远大于价格误差本身。多步预测这个项目踩过的坑每个都让我不得不回头改代码。尤其是滞后问题和数据泄漏这两类错误用曲线一画就暴露了但定位起来要花不少时间。从那以后我每次做多步预测都会强制走一遍这三件事先画预测与真实值对比图再按步数拆解误差最后检查第一步是不是有滞后平移。希望这套流程能帮你少走几个弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站