多变量多步LSTM股票预测实战:PyTorch实现与避坑指南 简介这是一份面向深度学习入门与金融时序预测实践者的PyTorch项目资源演示如何用LSTM对股票数据做多变量输入与多步预测。资源围绕数据预处理、特征构建、序列到序列建模、多步预测、损失优化、训练验证及评估等完整流程展开适合有Python基础、希望将LSTM落地到真实行情数据的开发者参考。压缩包共11个文件大小约222KB包含1个Python主脚本、1个Excel股票数据文件、1张预测结果对比图以及若干项目配置文件xml/iml/gitignore等结构精简可直接对照代码与数据运行学习。目前已有9240人学习下载。通过源码与配套数据可快速掌握多变量时间序列的构造方法、LSTM编码器-解码器设计以及多步预测的训练技巧针对特征组合、教师强制、早停与相关性评估等关键点项目提供了可复现的参考实现便于在此基础上扩展注意力或集成模型。注意股市受多重因素影响预测结果仅作参考。1. 多变量多步 LSTM 股票预测先说结论再动手实盘里最难受的不是模型不收敛而是你预测明天的价格很准但交易决策需要的是未来三到五天的走势区间。单步滚动预测在回测里曲线漂亮真金白银进去却发现方向反了问题出在预测目标和评估方式错位。这篇笔记用 PyTorch 完整拆一个多变量多步 LSTM 预测方案不只是把 LSTM 跑通而是把数据切分、输入输出结构、训练闭环、反归一化评估和一堆坑一次讲清楚。适合已经会 PyTorch 基本训练循环、但想在股票这类金融时序上做多步预测的开发者也适合被“预测漂移、结果滞后”折磨过、想排查原因的从业者。我默认你用的是日线或分钟级行情的多特征数据目标是输出未来 5 个交易日的预测序列而不是只输出一个点。2. 数据与特征工程构造 torch 能吃的多变量序列2.1 从行情接口到 DataFrame先拿到一份干净数据多变量预测的前提是特征维度够用。常见做法是取 open、high、low、close、volume 五个基础量再叠上技术指标。比如 5 日、10 日均线RSIMACD 这类派生特征。原因很简单LSTM 擅长捕捉序列依赖但光给 OHLCV 五个原始量模型很难自己学会“放量突破”这类组合特征。让特征工程先把这些结构带出来模型学起来快得多。我一般会这样组织原始数据import pandas as pd import numpy as np # data_path 指向你本地的日线行情 CSV df pd.read_csv(data_path, parse_dates[date]) df df.sort_values(date).reset_index(dropTrue) # 基础特征 派生特征 df[ma5] df[close].rolling(5).mean() df[ma10] df[close].rolling(10).mean() df[ma20] df[close].rolling(20).mean() df[rsi] 100 - (100 / (1 df[close].pct_change().rolling(14).mean())) df[return_1d] df[close].pct_change(1) feature_cols [open, high, low, close, volume, ma5, ma10, ma20, rsi, return_1d] df df.dropna().reset_index(dropTrue) df.to_pickle(./stock_features.pkl)逻辑说明滚动窗口计算指标会引入 NaN所以末尾统一 dropna再重置索引避免后续切窗口时索引错位。pct_change 生成的收益率列是序列自相关的能帮 LSTM 感知波动幅度。参数说明ma5、ma10、ma20 的窗口按交易日配置周线数据就要换成 5、10、20 对应的周数rsi 的 14 是常见周期敏感度不够时可以降到 9 或 12但不要低于 6否则噪声太大。2.2 滑动窗口切分lookback 和 horizon 怎么选多步预测的第一步是把 DataFrame 切成 (样本, 时间步, 特征数) 的三维张量。这里两个核心参数lookback 是看过去多少天horizon 是预测未来多少天。我的经验值是日线数据 lookback 取 20约一个月交易日horizon 取 5一周。理由很简单20 天窗口足够覆盖一次短周期波动5 天预测长度和实际交易持仓周期匹配。def create_sequences(data, lookback20, horizon5): X, y [], [] for i in range(len(data) - lookback - horizon 1): X.append(data[i : i lookback]) y.append(data[i lookback : i lookback horizon]) return np.array(X), np.array(y) # data_multi 是已经归一化后的 ndarray lookback, horizon 20, 5 X, y create_sequences(data_multi, lookback, horizon) # 按时间顺序做 7:1.5:1.5 切分 train_size int(len(X) * 0.7) val_size int(len(X) * 0.15) X_train, y_train X[:train_size], y[:train_size] X_val, y_val X[train_size : train_size val_size], y[train_size : train_size val_size] X_test, y_test X[train_size val_size :], y[train_size val_size :] print(X_train.shape, y_train.shape) # 输出示例(1749, 20, 10) (1749, 5, 10)逻辑说明以收盘价序列为例位置 i 的样本取 i 到 ilookback 共 20 行作为输入目标是从 ilookback 到 ilookbackhorizon 共 5 天、每天的 10 个特征。y 里带有全部 10 个特征训练时损失只算 close 这一个维度但保留全特征是为了后续做特征维度的误差分析。参数说明train/val/test 按 7:1.5:1.5 切不要用 scikit-learn 的 train_test_split因为它默认会打乱顺序时间序列一打乱就全废了。如果想做时间序列交叉验证要用 TimeSeriesSplit时序敏感的评估标准不能按普通 K 折走。2.3 归一化的坑为什么不能用全量数据 fit这一步踩坑的人最多。很多人在拿到 DataFrame 后直接写scaler.fit(data_multi)把全量数据都拿去算均值和方差再切训练集测试集。这样做在学术实验里看着没事但放到实盘回测里就是典型的数据泄漏——测试集的信息在训练前就被模型知道了回测结果虚高。from sklearn.preprocessing import MinMaxScaler # 先切再 fit只 fit 训练集 train_data_part df_scaled_source[:train_size lookback horizon - 1] scaler MinMaxScaler(feature_range(0, 1)) scaler.fit(train_data_part) # 全量 transform df_scaled scaler.transform(df_scaled_source)逻辑说明训练集的 fit 只发生在训练区间内验证集和测试集完全没见过scaler 的参数。这样预测结果反归一化时才公平。参数说明MinMaxScaler 对异常值敏感如果数据里有极端暴涨暴跌用 StandardScaler 更稳。量化交易场景里我一般先看一眼 close 的分布有长尾就换 StandardScaler否则继续用 MinMax。注意归一化是按特征维度分别做的反归一化时只需要取 close 那一列的 scaler 参数。3. LSTM 输入输出结构从 hidden state 到多变量多步解码3.1 输入张量的 shape 到底是啥PyTorch 的 LSTM 输入格式是 (batch_size, seq_len, input_size)这里 seq_len 就是 lookback20input_size 是特征数 10。常见错误是有人把特征数和时间步搞反写成 (batch, 10, 20)LSTM 会把 10 当作时间长度特征维度变成 20训练出来的模型完全没意义。import torch import torch.nn as nn class MultiStepLSTM(nn.Module): def __init__(self, input_size10, hidden_size64, num_layers2, horizon5, output_size1): super().__init__() self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue, dropout0.2) self.regressor nn.Linear(hidden_size, horizon * output_size) self.horizon horizon def forward(self, x): # x: (batch, seq_len, input_size) out, _ self.lstm(x) # out: (batch, seq_len, hidden_size) last_hidden out[:, -1, :] # 取最后一个时间步的隐状态 pred self.regressor(last_hidden) # (batch, horizon) return pred.view(-1, self.horizon, 1) # (batch, horizon, 1) model MultiStepLSTM() dummy torch.randn(64, 20, 10) out model(dummy) print(out.shape) # torch.Size([64, 5, 1])逻辑说明LSTM 返回的 out 包含每个时间步的隐状态取最后一个时间步的向量本质上是让模型把 20 天的信息压缩成一个固定长度的隐状态再接全连接层一次性输出 5 天的值。这种方式叫直接多步预测direct multi-step好处是实现简单、训练稳定缺点是模型要自己学会从隐状态里拆出 5 个不同的时间步对隐状态的信息容量要求高。参数说明hidden_size64 是日线级别数据的安全值数据量大或者特征更多时可以调到 128但不要一上来就 256小数据上容易过拟合。num_layers2 是经验值一层欠拟合风险高三层以上训练难度陡增梯度消失问题需要用残差或更小的学习率。3.2 直接多步 vs 递归多步两个方案怎么选除了直接多步还有一种常见做法是递归多步模型只预测下一步然后把预测值当作输入预测下一步循环 5 次。很多刚接触时间序列预测的开发者会直觉选递归方式因为它符合“滚动预测”的思路训练时只需要构造单步样本数据集还更大。但递归方式有个致命问题——误差累积。第一步预测偏离一点点第二步输入就带上了这个偏差后面误差按指数放大。尤其在股票这种信噪比很低的数据上两步之后预测曲线基本就是一条斜线失去参考价值。# 递归多步预测示例推理阶段 def recursive_predict(model, last_sequence, steps5): model.eval() preds [] current last_sequence.clone() # (1, lookback, input_size) with torch.no_grad(): for _ in range(steps): next_step model(current) # 单步模型 preds.append(next_step.item()) # 把新预测值滚动进窗口 new_row current[:, -1, :].clone() new_row[:, -1] next_step current torch.cat([current[:, 1:, :], new_row.unsqueeze(1)], dim1) return preds逻辑说明递归方式在验证集表现不错但到测试集特别是遇到波动放大的区段就崩。我用真实日线数据做过对比5 步递归预测的 MAPE 通常比直接多步高 1.5 到 2 倍而且随 horizon 步数增加预测方差快速拉升。实测感受如果你要的是“单日方向”或“波动区间”递归方式勉强够用如果你要的是 5 天完整预测曲线首选直接多步。两者可以结合先直接多步出一个 5 天基础预测再用递归方式每天修正最后一个预测值这个操作在后续章节讲。3.3 关键超参数hidden_size、num_layers、dropout 的设置逻辑超参数乱试是新人最常掉进去的坑。我的习惯是按“数据量 → 模型容量 → 正则强度”的顺序定。股票日线数据一般只有几千行LSTM 这种大参数模型本来就容易过拟合所以 hidden_size 控制在 32 到 64 之间num_layers 最多 2 层dropout 设 0.2 到 0.3。层数加到 3 层以上除非你有上万分钟的 tick 级数据否则验证集 loss 会开始震荡。def build_model(input_size10, hidden_size64, num_layers2, horizon5): return MultiStepLSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, horizonhorizon )参数说明batch_firstTrue 这个参数特别容易被忽略,它决定输入张量第一个维度是 batch 还是 seq_lenPyTorch 默认是 False新手经常在这里拿到奇怪的 shape 报错。dropout0.2 是 LSTM 层间的概率不是全连接层的 dropouttorch.nn.LSTM的 dropout 只在多层的层与层之间生效单层时设置 dropout 会被忽略这是个容易误判的点。4. 训练闭环把 PyTorch 训练循环写稳4.1 完整训练代码骨架训练阶段最容易犯的错误是 loss 看着往下降但验证集一测全是滞后效应。滞后效应的特征是预测曲线像翻了一天的行情——模型学到的是“今天的收盘价接近昨天”而不是真正的规律。要规避这个问题除了特征工程上做差分训练代码也要严格控制数据流的干净程度。import torch.optim as optim from torch.utils.data import TensorDataset, DataLoader X_train_t torch.from_numpy(X_train).float() y_train_t torch.from_numpy(y_train[:, :, 4]).float() # 只看 close y_train_t y_train_t.unsqueeze(-1) X_val_t torch.from_numpy(X_val).float() y_val_t torch.from_numpy(y_val[:, :, 4]).float().unsqueeze(-1) train_ds TensorDataset(X_train_t, y_train_t) val_ds TensorDataset(X_val_t, y_val_t) train_loader DataLoader(train_ds, batch_size128, shuffleFalse, drop_lastTrue) model MultiStepLSTM(input_sizeX_train.shape[2], hidden_size64, num_layers2, horizon5, output_size1) criterion nn.MSELoss() optimizer optim.Adam(model.parameters(), lr1e-3) scheduler optim.lr_scheduler.ReduceLROnPlateau(optimizer, modemin, factor0.5, patience5) epochs 100 best_val_loss float(inf) patience_counter 0 early_stop_patience 15 for epoch in range(epochs): model.train() train_loss 0.0 for batch_X, batch_y in train_loader: optimizer.zero_grad() pred model(batch_X) loss criterion(pred, batch_y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() train_loss loss.item() * batch_X.size(0) model.eval() val_loss 0.0 with torch.no_grad(): for batch_X, batch_y in val_loader: pred model(batch_X) loss criterion(pred, batch_y) val_loss loss.item() * batch_X.size(0) train_loss / len(train_ds) val_loss / len(val_ds) scheduler.step(val_loss) if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), ./best_lstm.pth) patience_counter 0 else: patience_counter 1 if patience_counter early_stop_patience: print(fEarly stop at epoch {epoch}) break if epoch % 10 0: print(fEpoch {epoch}, train_loss: {train_loss:.6f}, val_loss: {val_loss:.6f})逻辑说明这是标准的 PyTorch 训练闭环但有几个细节是股票预测场景专用的。shuffleFalse强制保留时间顺序避免同一序列的相邻时间步被拆进不同 batch 造成的数据泄漏。drop_lastTrue防止最后一个 batch 因样本数不足导致 BatchNorm 层报错或统计量偏移。clip_grad_norm_对 LSTM 很关键股票数据里偶发的大幅度波动会让梯度突然爆掉裁剪到 1.0 能在不改变训练方向的前提下防止 loss 跳成 NaN。参数说明ReduceLROnPlateau的 patience5 表示验证集 loss 连续 5 个 epoch 不下降就减半学习率early_stop_patience15表示减半学习率后最多再等 15 个 epoch还不降就停。学习率从 1e-3 起步Adam 的默认 betas 一般不用动但要留意 weight_decay 的设定LSTM 加 L2 正则容易把序列记忆压得太死我通常保持 0。4.2 学习率调度和早停的配合有些人只用固定的ReduceLROnPlateau有些人只做 early stop其实两个是不同阶段的手段。我的习惯是让调度器先跑它代表“模型已经学到平台期需要更小的步长精细调节”早停则是“步长已经降到很低但验证集依旧不降再训练只是浪费时间”。两者的 patience 参数不能设成同一个值。调度器的 patience 要小于早停的 patience否则调度器还没触发早停就断了训练。你需要在训练代码里把两个计数器分开维护# 一个更稳健的训练循环结构 if val_loss best_val_loss: best_val_loss val_loss patience_counter 0 else: patience_counter 1 scheduler.step(val_loss) # 只有验证集变差时才触发调度器 if patience_counter early_stop_patience: break这样调度器每次在验证集变差时按 factor0.5 降学习率而早停只记录连续不改善的轮数。两者互为正交约束能把模型压到最合适的容量。5. LSTM 股票预测避坑实录六个翻车点与排查5.1 预测曲线像是“平移”的昨天数据现象拿测试集画预测图发现预测值和真实值曲线形态几乎一致但总是慢半拍像把昨天的 close 搬过来了。R² 看着 0.9 以上但到方向转折点完全失效。原因时间序列的自相关性太强模型学到的是“明天的价格约等于今天的价格”这个最强的统计规律。尤其在收益率为随机游走时均方误差损失函数下最优预测就是当前值。解决在特征里加大收益率项和差分项让模型更容易学到变化量而不是绝对价格。return_1d列要保留同时训练目标可以改成预测 close 的差分评估时再还原。另一个办法是对 LSTM 的预测目标做去趋势处理。5.2 数据泄漏让验证集虚高现象验证集 loss 低得离谱测试集却一塌糊涂差异大到可以确定不是过拟合。原因归一化时对整个数据集 fit 了 MinMaxScaler或者切分窗口时把相邻时间步的样本同时放进训练集和验证集。窗口滑动切分天然会导致训练集尾部样本和验证集头部样本的输入序列有重叠。解决第一scaler 只 fit 训练区间的数据第二切分时要在序列之间留出 lookback 的间隔。如果训练集最后 20 天和验证集最开始 20 天完全一样这 20 天就是泄漏窗口。我一般直接按索引切分不让数据重叠gap lookback X_train, X_val X[:-val_size - gap], X[-val_size - gap : -gap]5.3 多步预测后面几步全是直线现象直接多步输出的 5 天预测前 1 到 2 步有起伏后面 3 步几乎走平没有趋势变化。原因均方误差损失函数在优化时倾向于最小化预测的平均偏差。未来第 4、5 天的真实值不确定性大模型在训练中发现“预测为均值”能获得较低 loss所以输出收敛到训练集均值。解决损失函数换成分位数损失或者加一个趋势惩罚项预测序列的一阶差分应该跟真实序列的一阶差分接近。也可以在训练目标上把后几步的权重调高强制模型多关注远期。weights torch.tensor([0.15, 0.2, 0.25, 0.3, 0.35]) loss torch.mean(weights.to(device) * (pred - batch_y) ** 2)5.4 多头 LSTM 的 batch_first 报错现象运行时出现RuntimeError: input must have 3 dimensions, got 2。原因LSTM 层把 (batch, 20, 10) 当成了 (batch, 10, 20)通常是忘了设 batch_firstTrue或者把 num_layers 和 hidden_size 的位置搞反。解决检查模型定义里的 batch_first 参数确认输入进来的张量首维是 batch。调试时可以打印x.shape对照不要猜。5.5 验证集 loss 来回震荡不收敛现象训练 loss 稳步下降验证集 loss 在某个区间反复跳甚至越跳越高。原因最常见的是学习率设太大ReducelROnPlateau 还没来得及响应loss 已经飞到高点了。另一个是 dropout 太高0.5 以上的 dropout 在验证阶段是关闭的训练和验证行为不一致。解决先调学习率降到 3e-4 再试dropout 限制在 0.2 到 0.3。如果验证还是不稳就检查是不是数据泄漏导致验证集太“简单”。5.6 预测结果永远偏低现象预测值的波动范围明显小于真实值牛市段预测值总是低于真实收盘。原因MinMaxScaler 在大量正常数据下少数极端高值被压到接近 1模型预测到 1 附近时会受均方误差惩罚。模型为了整体 loss 低倾向于把预测值往中间压缩这使得模型天然有“均值回归”偏向。解决反归一化后用残差修正。常见做法是计算测试集预测残差的均值把这个偏差加回去或者在归一化时对尾部极端值直接做截断处理。6. 进阶验证多步预测的误差分解与实用技巧最后一个章节不写大而全的思路写两个我每次回测都会强制做的小环节多步误差分解和预测方向一致性检查。很多模型在整体 MAPE 上表现不错但你需要知道误差到底出在哪些步骤上。做法是不管预测了多少步把第 k 步的所有样本预测值和真实值单独拿出来算误差观察误差随步长变化的曲线。误差从第 1 步到第 5 步阶梯式上升是正常的但如果第 2 步就跳得比第 5 步还高说明模型结构有问题。好模型的误差曲线应该是平滑单调上升而不是中间有尖峰。方向一致性检查更直接计算每个预测步的符号命中率。具体操作是对比真实涨跌方向与预测涨跌方向是否一致K 步方向命中率高于 55% 时模型才有交易参考价值。我一直坚持用小笔记本把每个交易日模型的 5 步预测方向、真实方向和次日实际走势手写记下来一个月后就能统计出模型的方向偏好。最后一个技巧是预测区间而不是预测点。LSTM 本身是点预测模型但你可以通过多次预测两次训练之间的随机种子得到一组不同参数的预测结果取分位数当作置信区间。虽然这个区间不是严格的统计置信区间但实盘决策中比单点预测多了一个“看不清就少动手”的参考依据。从那以后我每次跑完预测都强制走一遍误差分解和方向检查。希望帮到你。本文还有配套的精品资源点击获取