简介:面向金融时序预测与深度学习初学者的PyTorch LSTM多变量多步股票预测资源包,基于真实股票数据(600519.xlsx)演示从数据预处理、特征构建、序列到序列建模到多步预测与评估的完整流程,适合希望系统掌握LSTM在金融领域应用的读者。压缩包内共11个文件,包含PyTorch主脚本、数据文件、结果可视化图及工程配置说明文档(涵盖py、xlsx、png、xml等),整体仅222KB,轻量易上手,便于快速复现和二次开发。已有9240人浏览学习,可见该案例对同类任务有较高参考价值。通过项目主脚本中的LSTM训练与预测代码,可掌握多步预测中教师强制、MSE损失函数、Adam优化器及训练验证策略等关键细节;资源还体现数据标准化、缺失值填补、特征组合等完整技术路线,并附带对比图辅助评估预测效果,兼顾原理理解与实战落地。此外,工程配置与说明文档便于在IDE中直接打开项目复现。1. 多变量多步预测没有你想的那么简单:先看清这个 PyTorch + LSTM 项目用 PyTorch 的 LSTM 做股票多变量多步预测,最容易翻车的地方大概率不在模型结构,而在数据窗口怎么切。这个项目把开盘、最高、最低、收盘、成交量、成交额六列日线数据组织成输入序列,让 LSTM 一次性输出未来 5 个交易日的收盘价曲线。它适合两类人:一类是刚接触深度学习的开发者,想打通“数据预处理 → 模型 → 训练 → 评估”的完整链路;另一类是手头已有行情数据、想给策略加一个预测模块的量化爱好者。整份源码的核心就三块:滑动窗口切数据、StockLSTM 模型、训练评估脚本,下面按章节拆开讲。2. 项目定位与技术选型:一场「多进多出」的序列预测2.1 输入输出先对齐:模型吃进什么,吐出什么先把这个项目的输入输出说得再直白一点。原始行情表是日线维度,字段至少包含 date、open、high、low、close、volume、amount 这 7 列。模型一次只看过去 20 个交易日,也就是一个 (20, 6) 的二维矩阵,6 代表除 date 外的全部数值特征;模型要回答的问题是未来 5 个交易日的收盘价会落在什么位置,即 (5,) 的一维向量。所以整条 pipeline 的输出维度是 (batch, 20, 6) → (batch, 5),这也是后面所有代码的默认形状。字段含义是否参与模型输入date交易日日期否,只用于排序open开盘价是high最高价是low最低价是close收盘价是,同时作为标签volume成交量是amount成交额是在做数据对齐时,我一般只保留真实存在于交易日历上的行,而不是用自然日去补。如果某个交易日因为停牌缺少一行,宁可删掉也不要 fillna 塞一个假行情进去,否则模型会学到一个并不存在的价格水平。这一步排干净之后,才能进入滑动窗口切分。2.2 项目目录结构:几个文件撑起一个完整训练链路stock-lstm-predict/ ├── data/ │ └── stock_data.csv # 日线行情,7 个字段 ├── src/ │ ├── data_preprocess.py # 清洗 + 滑动窗口切分 + 归一化 │ ├── model.py # StockLSTM 定义 │ ├── train.py # 训练、验证、保存 checkpoint │ └── predict.py # 加载模型、画预测曲线 └── requirements.txt # pytorch、pandas、numpy、sklearn、matplotlib结构与大多数单机训练 demo 一样,没有用工程化的 mlflow、config 管理,都是为了让人更快看明白。data_preprocess.py 只负责把 CSV 变成 TensorDataset,model.py 只做模型定义,train.py 承担训练、评估、保存三件事,predict.py 在训练结束后加载权重做可视化。后期想换数据,只需要修改 data_preprocess.py 里的字段列表;想换模型,只需要改 model.py 的 forward。2.3 LSTM 为什么能处理股票序列:门控逻辑与时间步展开在深度学习里处理序列数据,LSTM 是最被熟悉的一种结构,但先说清一个预期:股票数据噪声非常大,LSTM 不会变成印钞机。这个项目里我把它当作一个序列特征抽取器来用,而不是预测神器。LSTM 的核心是三组门——遗忘门、输入门、输出门,它们在每个时间步决定“上一刻的记忆保留多少、新进来的行情更新多少、当前步输出什么”。与普通 RNN 相比,它在长序列梯度反传时的衰减被门控结构抑制住了,所以拿 20 天窗口训练一般不会像老式 RNN 那样梯度消失到完全学不动。在 PyTorch 里你不需要自己写门控矩阵,nn.LSTM 封装了全部实现,但理解它按时间步展开的机制对调参有帮助:batch_first=True 意味着输入张量的第一维是 batch,第二维是时间步,第三维是特征,否则默认是 (seq_len, batch, input_size)。这次代码统一用 batch_first=True,因为从 torch 的 Dataset 出来的张量更习惯把 batch 放最前面。2.4 多步预测的三种做法:直接多步、递归多步和 seq2seq做法训练方式推理方式误差累积实现成本递归多步每步输出 1 个点上次输出作为下次输入明显,越远越偏低直接多步一次输出 M 个点一次输出 M 个点各步独立,无累积中Seq2Seq编码器 + 解码器解码器逐位生成相对可控,但训练复杂高这个项目选了第二种直接多步:模型全连接头的输出维度直接设成 output_step=5,训练和推理完全一致,不需要 teacher forcing,也不需要在推理时把预测值循环喂回去。递归多步虽然模型简单,但每一步都带着上一步的误差往前走,预测第 5 天时误差已经被放大很多轮;seq2seq 在数据量少的股票场景容易过拟合,对入门项目来说性价比反而不高。M 的值也不是越大越好,5 天和 20 天的难度完全不同,预测步长越长,模型能依赖的中期趋势信息越少,损失会显著上升。如果你第一次跑发现第 4 天、第 5 天误差大,不要先加复杂模型,先看输出步长是不是设得太长。提示:这个项目只做技术验证,输出的预测结果不构成任何投资建议。3. 数据准备与滑动窗口:从 CSV 到 TensorDataset 的完整链路3.1 原始行情加载:排序、去空、处理停牌import pandas as pd def load_stock_data(path): df = pd.read_csv(path) df['date'] = pd.to_datetime(df['date']) df = df.sort_values('date').reset_index(drop=True) # 数值列里如果出现空值,先看是停牌还是数据缺失 required = ['open', 'high', 'low', 'close', 'volume', 'amount'] df = df.dropna(subset=required).reset_index(drop=True) return dfdate 的格式在不同数据源里可能不一样,先统一成 datetime 再排序,否则后续按时间轴分隔数据时顺序会错乱。dropna 丢弃的是数据源里实打实缺的行,而不是用上一日值填充;如果一定要处理停牌,我一般会检查缺失日期是否连续,连续超过 5 个交易日就直接删掉这一段,避免把“不存在”的价格学进模型。3.2 滑动窗口切分:让模型理解时间上下文def make_sequences(df, feature_cols, input_len=20, output_len=5): feature_data = df[feature_cols].to_numpy(dtype=np.float32) close_col = feature_cols.index('close') x, y = [], [] for i in range(len(df) - input_len - output_len + 1): x.append(feature_data[i: i + input_len]) y.append(feature_data[i + input_len: i + input_len + output_len, close_col]) return np.array(x), np.array(y) # x: (N, 20, 6), y: (N, 5)这里窗口参数是 input_len=20、output_len=5。第 i 个样本用的是第 i 到 i+19 天做输入,第 i+20 到 i+24 天收盘价做标签,特征窗口和标签窗口严格不重叠。如果你只想预测未来 1 天,把 output_len 改成 1 即可;窗口大小不要拍脑袋,可以先看数据总量,1000 个交易日大概能切成 976 个样本,样本太少就把 output_len 降到 3。3.3 归一化与反变换:scaler 只允许 fit 训练段from sklearn.preprocessing import MinMaxScaler def fit_train_scaler(x_all, y_all, train_ratio=0.8): n_train = int(len(x_all) * train_ratio) output_len = y_all.shape[1] feature_scaler = MinMaxScaler(feature_range=(0, 1)) feature_scaler.fit(x_all[:n_train].reshape(-1, x_all.shape[-1])) def scale_samples(samples, scaler): n, seq, f = samples.shape flat = samples.reshape(-1, f) scaled = scaler.transform(flat) return scaled.reshape(n, seq, f) x_train = scale_samples(x_all[:n_train], feature_scaler) x_val = scale_samples(x_all[n_train:], feature_scaler) y_scaler = MinMaxScaler(feature_range=(0, 1)) y_scaler.fit(y_all[:n_train].reshape(-1, 1)) y_train = y_scaler.transform(y_all[:n_train].reshape(-1, 1)).reshape(-1, output_len) y_val = y_scaler.transform(y_all[n_train:].reshape(-1, 1)).reshape(-1, output_len) return (x_train, y_train), (x_val, y_val), feature_scaler, y_scalerfit 只发生在训练段,验证段对训练段归一化后才 transform,缩放参数不接触验证数据。这样也符合实盘场景:预测未来时,你没有未来数据来算全量 min 和 max。为什么用 MinMax 而不是 StandardScaler?股价的数值区间相对有限,MinMax 可以保留每个时刻的相对位置,而且反变换回真实价格时逻辑最简单。反变换是 predict.py 里必然用到的一步:模型输出的是 0~1 区间值,必须用 y_scaler.inverse_transform 转回真实价格才能画图和计算指标。3.4 DataLoader 组织:shuffle 该开就开from torch.utils.data import TensorDataset, DataLoader train_dataset = TensorDataset( torch.tensor(x_train, dtype=torch.float32), torch.tensor(y_train, dtype=torch.float32) ) val_dataset = TensorDataset( torch.tensor(x_val, dtype=torch.float32), torch.tensor(y_val, dtype=torch.float32) ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=128, shuffle=False)训练集 shuffle=True 是为了让每个 batch 的样本在时间上打乱,帮助优化器跳出局部震荡;验证集 shuffle=False 则保证评估时样本顺序固定,便于复现结果。滑动窗口生成的相邻样本会共享大量时间上下文,所以评估时我不会打乱顺序,并且会在验证集上计算整体指标而不是按每个 batch 分别算。4. 核心模块拆解:LSTM forward 到 loss 反传的完整链路4.1 StockLSTM 定义:多层 LSTM 加一个全连接头import torch.nn as nn class StockLSTM(nn.Module): def __init__(self, input_size=6, hidden_size=64, num_layers=2, output_step=5): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True ) self.fc = nn.Sequential( nn.Linear(hidden_size, hidden_size // 2), nn.ReLU(), nn.Linear(hidden_size // 2, output_step), ) def forward(self, x): # x: (batch, input_len=20, input_size=6) _, (h_n, _) = self.lstm(x) last_hidden = h_n[-1] # (batch, hidden_size) out = self.fc(last_hidden) # (batch, output_step) return outinput_size 直接对应 feature_cols 的特征数量 6,output_step 对应未来 5 天收盘价。模型用 h_n[-1] 而不是 LSTM 每个时间步的 outputs,是因为我们要一次性预测后面 5 天,信息量集中在最后一个时间步的隐藏状态里。hidden_size 和 num_layers 是超参,股票这种千级别样本量,64 和 2 是比较稳妥的起点;数据量更小可以降到 32 和 1,防止过拟合。相比 Transformer,LSTM 在这种几千条日线数据的体量下不容易过拟合,训练也快。4.2 训练循环:梯度裁剪与学习率衰减是组合拳def evaluate(model, loader, criterion): model.eval() total_loss, n = 0.0, 0 with torch.no_grad(): for bx, by in loader: pred = model(bx) total_loss += criterion(pred, by).item() * bx.size(0) n += bx.size(0) model.train() return total_loss / n optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5 ) criterion = nn.MSELoss() for epoch in range(50): model.train() epoch_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_norm=1.0) optimizer.step() epoch_loss += loss.item() * batch_x.size(0) avg_train_loss = epoch_loss / len(train_loader.dataset) val_loss = evaluate(model, val_loader, criterion) scheduler.step(val_loss) print(f"epoch {epoch:03d} | train {avg_train_loss:.6f} | val {val_loss:.6f}")clip_grad_norm 在 backward 之后、step 之前执行,作用是把整个参数梯度向量按模长压缩到 1.0 以内。LSTM 梯度跨 20 个时间步累积,不裁剪的话遇到异常行情 batch,loss 很容易跳到 NaN。scheduler 用 val_loss 而不是 train_loss,是为了让学习率在验证集不再下降时才减半,避免模型已经在训练集上过拟合了,还继续用大的学习率往前冲。epoch 一般先跑 50 轮,重点看 val_loss 是否在 20 轮后进入平台期,如果还在持续下降可以往上加到 80。4.3 评估指标:MAE、MAPE 和方向准确率import numpy as np def evaluate_metrics(y_true, y_pred): y_true = np.asarray(y_true) y_pred = np.asarray(y_pred) mae = np.mean(np.abs(y_true - y_pred)) mape = np.mean(np.abs((y_true - y_pred) / (np.abs(y_true) + 1e-6))) * 100 # 方向准确率:比较相邻两个预测日的涨跌方向 sign_true = np.sign(np.diff(y_true, axis=1)) sign_pred = np.sign(np.diff(y_pred, axis=1)) direction_acc = np.mean(sign_true == sign_pred) return {"mae": mae, "mape": mape, "direction_acc": direction_acc}为什么关注方向准确率而不是只盯着 mae。股票预测里价格误差小不一定方向对,比如真实收盘 10 元涨到 11 元,模型预测 10.1 元,绝对误差不大但方向完全反了。direction_acc 比较的是相邻两个预测日的变化方向,能直接反映模型对涨跌方向的判断能力。分母加 1e-6 是为了防止 y_true 恰好为 0 时出现除零。评估多步预测时,只算总体的 mae 会掩盖第 1 天准、第 5 天飘的问题,我习惯再加一步:按列分别计算第 1 天到第 5 天的 mae 并打印成 5 个数字,一眼就能看出误差随预测距离放大的趋势。4.4 保存与加载:checkpoint 里带上配置和 scalerimport torch torch.save({ "model_state": model.state_dict(), "optimizer_state": optimizer.state_dict(), "feature_scaler": feature_scaler, "y_scaler": y_scaler, "config": { "input_size": 6, "hidden_size": 64, "num_layers": 2, "input_len": 20, "output_step": 5, } }, "checkpoint.pt") ckpt = torch.load("checkpoint.pt") model = StockLSTM( input_size=ckpt["config"]["input_size"], hidden_size=ckpt["config"]["hidden_size"], num_layers=ckpt["config"]["num_layers"], output_step=ckpt["config"]["output_step"], ) model.load_state_dict(ckpt["model_state"]) model.eval()很多只存 model_state 的做法有一个问题:模型结构变了就加载失败,也不知道当初用的什么窗口长度。把 config 和两个 scaler 一起存进 checkpoint,是让项目能够一键恢复到训练时状态的最稳做法。load 之后记得调用 model.eval(),因为训练时的 dropout 与 batch norm 行为在推理时应该关闭。5. 避坑记录:数据泄漏、梯度爆炸与均值回归预测的 5 个真实案例5.1 数据泄漏:验证集 loss 好看,换一段行情就翻车现象:训练跑 30 个 epoch,验证集 mae 漂亮得不像话,预测曲线和真实曲线几乎贴在一起;但把模型拿到后一段新行情上,预测线却严重滞后。原因:归一化时把训练、验证、测试全部扔进一个 scaler 去 fit,测试集的统计量提前进入了模型,这是时序项目最常见的数据泄漏。解决:把 fit 严格限定在训练段,验证段只用 transform,3.3 的代码已经按这个顺序写好了。检查方法也很简单,打印 scaler.data_min_ 和 data_max_,看它是不是等于训练段的最小最大值而不是全量数据。5.2 标签泄漏:预测曲线整体右移一天,训练 loss 低到离谱现象:模型看起来学到了什么,但仔细看预测线整体滞后真实线约一天,loss 比正常水平低好几倍。原因:构造样本时把输出窗口也并进了输入窗口,典型的错误写法是 feature_data[i: i+input_len+output_len] 作为 x,再取同区间的收盘价当标签,模型其实直接把标签里的信息抄了一遍。解决:回看 3.2 的区间公式,输入区间结束位置严格等于 i+input_len,标签从 i+input_len 开始,二者没有重叠。改完代码可以打印两个样本,人工确认第 20 天的特征和第 21 天的标签来自不同日期。5.3 梯度爆炸:训练第 10 个 epoch 后 loss 变成 NaN现象:训练前几轮正常,第 10 轮左右 loss 突然变成 nan,后续回不来,检查参数发现 LSTM 权重里出现了 inf。原因:特征没归一化或归一化边界不对,股价从几元到几千元不等,量纲差异大,LSTM 内部矩阵乘法的梯度跨时间步累积后溢出;另一个常见原因是学习率设到 1e-2。解决:先确认 y_scaler 对标签做了归一化,再把 lr 降到 1e-3,最后在 optimizer.step() 之前加 clip_grad_norm_。这组组合要同时调整,不要只加裁剪。5.4 递归多步误差累积:多步推理越推越偏现象:模型单独预测明天很准,用上一步预测值循环预测未来 5 天时,第 3 天开始明显偏离,第 5 天可能飘到不能看。原因:训练时 LSTM 每一步都吃真实值,推理时吃的是自己上一轮的输出,预测误差被一次次迭代放大。解决:项目默认用直接多步输出,模型一步给出 5 天结果,训练和推理一致,从根上避免累积。想验证这个现象,把 output_step 改成 1,在 predict.py 里写个 for 循环,对比一下直接多步和递归多步在第 5 天的误差。5.5 均值回归:预测线牢牢贴着近期均值,涨跌方向几乎给不出现象:预测曲线像一条温水线,在最近几天收盘价均值附近轻微波动,连明显的单边趋势都拟合不出来。原因:MSE 损失在噪声大的数据上,模型发现输出区域中心的代价最小,于是学会了“贴均值”,这在统计上是最小均方误差的无偏估计,但对交易没有意义。解决:两个方向,一是把原始价格换算成收益率再训练,让模型预测涨跌幅度而不是绝对价格;二是在损失函数里加方向约束,下一章给出具体代码。另外可以把输入窗口从 20 天拉到 60 天,给模型更多趋势上下文。6. 二次开发实战:换数据、加方向约束、做滚动回测6.1 替换自己的行情数据拿到一份新数据,先别急着改模型。打开 data_preprocess.py,把 load_stock_data 的路径换成新 CSV,逐一核对 feature_cols 里的列名,缺一列就补一列,字段值单位如果不同,先统一再进归一化。同步检查输入窗口与预测步长:数据量少于 500 条的,把 input_len 降到 15、output_step 降到 3,否则样本数量不足以支撑 LSTM 参数学习。model.py 里的 input_size 要和 feature_cols 长度一致,这是最容易起步就报错的地方。6.2 损失函数加方向约束def directional_loss(pred, target, mse_weight=1.0, direction_weight=0.2): mse = nn.functional.mse_loss(pred, target) sign_pred = torch.sign(pred[:, 1:] - pred[:, :-1]) sign_true = torch.sign(target[:, 1:] - target[:, :-1]) direction_penalty = torch.mean((sign_pred != sign_true).float()) return mse_weight * mse + direction_weight * direction_penaltydirection_weight 从 0.1 开始试,太大模型会为了方向正确牺牲价格精度;预测点只有 5 个,diff 之后出现 4 段方向比较,计算开销可以忽略。6.3 滚动回测验证for start in range(0, total_len - train_len - test_len, step): # 每次前移 step 天,重训一次模型 x_train, y_train = cut_window(start, start + train_len) x_test, y_test = cut_window(start + train_len, start + train_len + test_len) model = retrain_on(x_train, y_train) metrics = evaluate_metrics(y_test, model.predict(x_test)) print(metrics)每次只取最近 train_len 条训练,在紧随其后的 test_len 上验证,然后整个窗口往前挪 step 天再重来。滚动回测比一次性切分更接近真实交易中的顺序,指标也更可信。这个验证循环是二次开发中性价比最高的一步。这五件事里,最让我长记性的是 5.1 那条数据泄漏。从那以后我每次换数据集,都会先打印一遍 scaler 的 fit 范围、检查预测曲线有没有滞后一天,再跑正式训练;花十分钟自检,能省掉后面几十个 epoch 的返工。希望帮到你。本文还有配套的精品资源,点击获取
简介:面向金融时序预测与深度学习初学者的PyTorch LSTM多变量多步股票预测资源包,基于真实股票数据(600519.xlsx)演示从数据预处理、特征构建、序列到序列建模到多步预测与评估的完整流程,适合希望系统掌握LSTM在金融领域应用的读者。压缩包内共11个文件,包含PyTorch主脚本、数据文件、结果可视化图及工程配置说明文档(涵盖py、xlsx、png、xml等),整体仅222KB,轻量易上手,便于快速复现和二次开发。已有9240人浏览学习,可见该案例对同类任务有较高参考价值。通过项目主脚本中的LSTM训练与预测代码,可掌握多步预测中教师强制、MSE损失函数、Adam优化器及训练验证策略等关键细节;资源还体现数据标准化、缺失值填补、特征组合等完整技术路线,并附带对比图辅助评估预测效果,兼顾原理理解与实战落地。此外,工程配置与说明文档便于在IDE中直接打开项目复现。1. 多变量多步预测没有你想的那么简单:先看清这个 PyTorch + LSTM 项目用 PyTorch 的 LSTM 做股票多变量多步预测,最容易翻车的地方大概率不在模型结构,而在数据窗口怎么切。这个项目把开盘、最高、最低、收盘、成交量、成交额六列日线数据组织成输入序列,让 LSTM 一次性输出未来 5 个交易日的收盘价曲线。它适合两类人:一类是刚接触深度学习的开发者,想打通“数据预处理 → 模型 → 训练 → 评估”的完整链路;另一类是手头已有行情数据、想给策略加一个预测模块的量化爱好者。整份源码的核心就三块:滑动窗口切数据、StockLSTM 模型、训练评估脚本,下面按章节拆开讲。2. 项目定位与技术选型:一场「多进多出」的序列预测2.1 输入输出先对齐:模型吃进什么,吐出什么先把这个项目的输入输出说得再直白一点。原始行情表是日线维度,字段至少包含 date、open、high、low、close、volume、amount 这 7 列。模型一次只看过去 20 个交易日,也就是一个 (20, 6) 的二维矩阵,6 代表除 date 外的全部数值特征;模型要回答的问题是未来 5 个交易日的收盘价会落在什么位置,即 (5,) 的一维向量。所以整条 pipeline 的输出维度是 (batch, 20, 6) → (batch, 5),这也是后面所有代码的默认形状。字段含义是否参与模型输入date交易日日期否,只用于排序open开盘价是high最高价是low最低价是close收盘价是,同时作为标签volume成交量是amount成交额是在做数据对齐时,我一般只保留真实存在于交易日历上的行,而不是用自然日去补。如果某个交易日因为停牌缺少一行,宁可删掉也不要 fillna 塞一个假行情进去,否则模型会学到一个并不存在的价格水平。这一步排干净之后,才能进入滑动窗口切分。2.2 项目目录结构:几个文件撑起一个完整训练链路stock-lstm-predict/ ├── data/ │ └── stock_data.csv # 日线行情,7 个字段 ├── src/ │ ├── data_preprocess.py # 清洗 + 滑动窗口切分 + 归一化 │ ├── model.py # StockLSTM 定义 │ ├── train.py # 训练、验证、保存 checkpoint │ └── predict.py # 加载模型、画预测曲线 └── requirements.txt # pytorch、pandas、numpy、sklearn、matplotlib结构与大多数单机训练 demo 一样,没有用工程化的 mlflow、config 管理,都是为了让人更快看明白。data_preprocess.py 只负责把 CSV 变成 TensorDataset,model.py 只做模型定义,train.py 承担训练、评估、保存三件事,predict.py 在训练结束后加载权重做可视化。后期想换数据,只需要修改 data_preprocess.py 里的字段列表;想换模型,只需要改 model.py 的 forward。2.3 LSTM 为什么能处理股票序列:门控逻辑与时间步展开在深度学习里处理序列数据,LSTM 是最被熟悉的一种结构,但先说清一个预期:股票数据噪声非常大,LSTM 不会变成印钞机。这个项目里我把它当作一个序列特征抽取器来用,而不是预测神器。LSTM 的核心是三组门——遗忘门、输入门、输出门,它们在每个时间步决定“上一刻的记忆保留多少、新进来的行情更新多少、当前步输出什么”。与普通 RNN 相比,它在长序列梯度反传时的衰减被门控结构抑制住了,所以拿 20 天窗口训练一般不会像老式 RNN 那样梯度消失到完全学不动。在 PyTorch 里你不需要自己写门控矩阵,nn.LSTM 封装了全部实现,但理解它按时间步展开的机制对调参有帮助:batch_first=True 意味着输入张量的第一维是 batch,第二维是时间步,第三维是特征,否则默认是 (seq_len, batch, input_size)。这次代码统一用 batch_first=True,因为从 torch 的 Dataset 出来的张量更习惯把 batch 放最前面。2.4 多步预测的三种做法:直接多步、递归多步和 seq2seq做法训练方式推理方式误差累积实现成本递归多步每步输出 1 个点上次输出作为下次输入明显,越远越偏低直接多步一次输出 M 个点一次输出 M 个点各步独立,无累积中Seq2Seq编码器 + 解码器解码器逐位生成相对可控,但训练复杂高这个项目选了第二种直接多步:模型全连接头的输出维度直接设成 output_step=5,训练和推理完全一致,不需要 teacher forcing,也不需要在推理时把预测值循环喂回去。递归多步虽然模型简单,但每一步都带着上一步的误差往前走,预测第 5 天时误差已经被放大很多轮;seq2seq 在数据量少的股票场景容易过拟合,对入门项目来说性价比反而不高。M 的值也不是越大越好,5 天和 20 天的难度完全不同,预测步长越长,模型能依赖的中期趋势信息越少,损失会显著上升。如果你第一次跑发现第 4 天、第 5 天误差大,不要先加复杂模型,先看输出步长是不是设得太长。提示:这个项目只做技术验证,输出的预测结果不构成任何投资建议。3. 数据准备与滑动窗口:从 CSV 到 TensorDataset 的完整链路3.1 原始行情加载:排序、去空、处理停牌import pandas as pd def load_stock_data(path): df = pd.read_csv(path) df['date'] = pd.to_datetime(df['date']) df = df.sort_values('date').reset_index(drop=True) # 数值列里如果出现空值,先看是停牌还是数据缺失 required = ['open', 'high', 'low', 'close', 'volume', 'amount'] df = df.dropna(subset=required).reset_index(drop=True) return dfdate 的格式在不同数据源里可能不一样,先统一成 datetime 再排序,否则后续按时间轴分隔数据时顺序会错乱。dropna 丢弃的是数据源里实打实缺的行,而不是用上一日值填充;如果一定要处理停牌,我一般会检查缺失日期是否连续,连续超过 5 个交易日就直接删掉这一段,避免把“不存在”的价格学进模型。3.2 滑动窗口切分:让模型理解时间上下文def make_sequences(df, feature_cols, input_len=20, output_len=5): feature_data = df[feature_cols].to_numpy(dtype=np.float32) close_col = feature_cols.index('close') x, y = [], [] for i in range(len(df) - input_len - output_len + 1): x.append(feature_data[i: i + input_len]) y.append(feature_data[i + input_len: i + input_len + output_len, close_col]) return np.array(x), np.array(y) # x: (N, 20, 6), y: (N, 5)这里窗口参数是 input_len=20、output_len=5。第 i 个样本用的是第 i 到 i+19 天做输入,第 i+20 到 i+24 天收盘价做标签,特征窗口和标签窗口严格不重叠。如果你只想预测未来 1 天,把 output_len 改成 1 即可;窗口大小不要拍脑袋,可以先看数据总量,1000 个交易日大概能切成 976 个样本,样本太少就把 output_len 降到 3。3.3 归一化与反变换:scaler 只允许 fit 训练段from sklearn.preprocessing import MinMaxScaler def fit_train_scaler(x_all, y_all, train_ratio=0.8): n_train = int(len(x_all) * train_ratio) output_len = y_all.shape[1] feature_scaler = MinMaxScaler(feature_range=(0, 1)) feature_scaler.fit(x_all[:n_train].reshape(-1, x_all.shape[-1])) def scale_samples(samples, scaler): n, seq, f = samples.shape flat = samples.reshape(-1, f) scaled = scaler.transform(flat) return scaled.reshape(n, seq, f) x_train = scale_samples(x_all[:n_train], feature_scaler) x_val = scale_samples(x_all[n_train:], feature_scaler) y_scaler = MinMaxScaler(feature_range=(0, 1)) y_scaler.fit(y_all[:n_train].reshape(-1, 1)) y_train = y_scaler.transform(y_all[:n_train].reshape(-1, 1)).reshape(-1, output_len) y_val = y_scaler.transform(y_all[n_train:].reshape(-1, 1)).reshape(-1, output_len) return (x_train, y_train), (x_val, y_val), feature_scaler, y_scalerfit 只发生在训练段,验证段对训练段归一化后才 transform,缩放参数不接触验证数据。这样也符合实盘场景:预测未来时,你没有未来数据来算全量 min 和 max。为什么用 MinMax 而不是 StandardScaler?股价的数值区间相对有限,MinMax 可以保留每个时刻的相对位置,而且反变换回真实价格时逻辑最简单。反变换是 predict.py 里必然用到的一步:模型输出的是 0~1 区间值,必须用 y_scaler.inverse_transform 转回真实价格才能画图和计算指标。3.4 DataLoader 组织:shuffle 该开就开from torch.utils.data import TensorDataset, DataLoader train_dataset = TensorDataset( torch.tensor(x_train, dtype=torch.float32), torch.tensor(y_train, dtype=torch.float32) ) val_dataset = TensorDataset( torch.tensor(x_val, dtype=torch.float32), torch.tensor(y_val, dtype=torch.float32) ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=128, shuffle=False)训练集 shuffle=True 是为了让每个 batch 的样本在时间上打乱,帮助优化器跳出局部震荡;验证集 shuffle=False 则保证评估时样本顺序固定,便于复现结果。滑动窗口生成的相邻样本会共享大量时间上下文,所以评估时我不会打乱顺序,并且会在验证集上计算整体指标而不是按每个 batch 分别算。4. 核心模块拆解:LSTM forward 到 loss 反传的完整链路4.1 StockLSTM 定义:多层 LSTM 加一个全连接头import torch.nn as nn class StockLSTM(nn.Module): def __init__(self, input_size=6, hidden_size=64, num_layers=2, output_step=5): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True ) self.fc = nn.Sequential( nn.Linear(hidden_size, hidden_size // 2), nn.ReLU(), nn.Linear(hidden_size // 2, output_step), ) def forward(self, x): # x: (batch, input_len=20, input_size=6) _, (h_n, _) = self.lstm(x) last_hidden = h_n[-1] # (batch, hidden_size) out = self.fc(last_hidden) # (batch, output_step) return outinput_size 直接对应 feature_cols 的特征数量 6,output_step 对应未来 5 天收盘价。模型用 h_n[-1] 而不是 LSTM 每个时间步的 outputs,是因为我们要一次性预测后面 5 天,信息量集中在最后一个时间步的隐藏状态里。hidden_size 和 num_layers 是超参,股票这种千级别样本量,64 和 2 是比较稳妥的起点;数据量更小可以降到 32 和 1,防止过拟合。相比 Transformer,LSTM 在这种几千条日线数据的体量下不容易过拟合,训练也快。4.2 训练循环:梯度裁剪与学习率衰减是组合拳def evaluate(model, loader, criterion): model.eval() total_loss, n = 0.0, 0 with torch.no_grad(): for bx, by in loader: pred = model(bx) total_loss += criterion(pred, by).item() * bx.size(0) n += bx.size(0) model.train() return total_loss / n optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5 ) criterion = nn.MSELoss() for epoch in range(50): model.train() epoch_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_norm=1.0) optimizer.step() epoch_loss += loss.item() * batch_x.size(0) avg_train_loss = epoch_loss / len(train_loader.dataset) val_loss = evaluate(model, val_loader, criterion) scheduler.step(val_loss) print(f"epoch {epoch:03d} | train {avg_train_loss:.6f} | val {val_loss:.6f}")clip_grad_norm 在 backward 之后、step 之前执行,作用是把整个参数梯度向量按模长压缩到 1.0 以内。LSTM 梯度跨 20 个时间步累积,不裁剪的话遇到异常行情 batch,loss 很容易跳到 NaN。scheduler 用 val_loss 而不是 train_loss,是为了让学习率在验证集不再下降时才减半,避免模型已经在训练集上过拟合了,还继续用大的学习率往前冲。epoch 一般先跑 50 轮,重点看 val_loss 是否在 20 轮后进入平台期,如果还在持续下降可以往上加到 80。4.3 评估指标:MAE、MAPE 和方向准确率import numpy as np def evaluate_metrics(y_true, y_pred): y_true = np.asarray(y_true) y_pred = np.asarray(y_pred) mae = np.mean(np.abs(y_true - y_pred)) mape = np.mean(np.abs((y_true - y_pred) / (np.abs(y_true) + 1e-6))) * 100 # 方向准确率:比较相邻两个预测日的涨跌方向 sign_true = np.sign(np.diff(y_true, axis=1)) sign_pred = np.sign(np.diff(y_pred, axis=1)) direction_acc = np.mean(sign_true == sign_pred) return {"mae": mae, "mape": mape, "direction_acc": direction_acc}为什么关注方向准确率而不是只盯着 mae。股票预测里价格误差小不一定方向对,比如真实收盘 10 元涨到 11 元,模型预测 10.1 元,绝对误差不大但方向完全反了。direction_acc 比较的是相邻两个预测日的变化方向,能直接反映模型对涨跌方向的判断能力。分母加 1e-6 是为了防止 y_true 恰好为 0 时出现除零。评估多步预测时,只算总体的 mae 会掩盖第 1 天准、第 5 天飘的问题,我习惯再加一步:按列分别计算第 1 天到第 5 天的 mae 并打印成 5 个数字,一眼就能看出误差随预测距离放大的趋势。4.4 保存与加载:checkpoint 里带上配置和 scalerimport torch torch.save({ "model_state": model.state_dict(), "optimizer_state": optimizer.state_dict(), "feature_scaler": feature_scaler, "y_scaler": y_scaler, "config": { "input_size": 6, "hidden_size": 64, "num_layers": 2, "input_len": 20, "output_step": 5, } }, "checkpoint.pt") ckpt = torch.load("checkpoint.pt") model = StockLSTM( input_size=ckpt["config"]["input_size"], hidden_size=ckpt["config"]["hidden_size"], num_layers=ckpt["config"]["num_layers"], output_step=ckpt["config"]["output_step"], ) model.load_state_dict(ckpt["model_state"]) model.eval()很多只存 model_state 的做法有一个问题:模型结构变了就加载失败,也不知道当初用的什么窗口长度。把 config 和两个 scaler 一起存进 checkpoint,是让项目能够一键恢复到训练时状态的最稳做法。load 之后记得调用 model.eval(),因为训练时的 dropout 与 batch norm 行为在推理时应该关闭。5. 避坑记录:数据泄漏、梯度爆炸与均值回归预测的 5 个真实案例5.1 数据泄漏:验证集 loss 好看,换一段行情就翻车现象:训练跑 30 个 epoch,验证集 mae 漂亮得不像话,预测曲线和真实曲线几乎贴在一起;但把模型拿到后一段新行情上,预测线却严重滞后。原因:归一化时把训练、验证、测试全部扔进一个 scaler 去 fit,测试集的统计量提前进入了模型,这是时序项目最常见的数据泄漏。解决:把 fit 严格限定在训练段,验证段只用 transform,3.3 的代码已经按这个顺序写好了。检查方法也很简单,打印 scaler.data_min_ 和 data_max_,看它是不是等于训练段的最小最大值而不是全量数据。5.2 标签泄漏:预测曲线整体右移一天,训练 loss 低到离谱现象:模型看起来学到了什么,但仔细看预测线整体滞后真实线约一天,loss 比正常水平低好几倍。原因:构造样本时把输出窗口也并进了输入窗口,典型的错误写法是 feature_data[i: i+input_len+output_len] 作为 x,再取同区间的收盘价当标签,模型其实直接把标签里的信息抄了一遍。解决:回看 3.2 的区间公式,输入区间结束位置严格等于 i+input_len,标签从 i+input_len 开始,二者没有重叠。改完代码可以打印两个样本,人工确认第 20 天的特征和第 21 天的标签来自不同日期。5.3 梯度爆炸:训练第 10 个 epoch 后 loss 变成 NaN现象:训练前几轮正常,第 10 轮左右 loss 突然变成 nan,后续回不来,检查参数发现 LSTM 权重里出现了 inf。原因:特征没归一化或归一化边界不对,股价从几元到几千元不等,量纲差异大,LSTM 内部矩阵乘法的梯度跨时间步累积后溢出;另一个常见原因是学习率设到 1e-2。解决:先确认 y_scaler 对标签做了归一化,再把 lr 降到 1e-3,最后在 optimizer.step() 之前加 clip_grad_norm_。这组组合要同时调整,不要只加裁剪。5.4 递归多步误差累积:多步推理越推越偏现象:模型单独预测明天很准,用上一步预测值循环预测未来 5 天时,第 3 天开始明显偏离,第 5 天可能飘到不能看。原因:训练时 LSTM 每一步都吃真实值,推理时吃的是自己上一轮的输出,预测误差被一次次迭代放大。解决:项目默认用直接多步输出,模型一步给出 5 天结果,训练和推理一致,从根上避免累积。想验证这个现象,把 output_step 改成 1,在 predict.py 里写个 for 循环,对比一下直接多步和递归多步在第 5 天的误差。5.5 均值回归:预测线牢牢贴着近期均值,涨跌方向几乎给不出现象:预测曲线像一条温水线,在最近几天收盘价均值附近轻微波动,连明显的单边趋势都拟合不出来。原因:MSE 损失在噪声大的数据上,模型发现输出区域中心的代价最小,于是学会了“贴均值”,这在统计上是最小均方误差的无偏估计,但对交易没有意义。解决:两个方向,一是把原始价格换算成收益率再训练,让模型预测涨跌幅度而不是绝对价格;二是在损失函数里加方向约束,下一章给出具体代码。另外可以把输入窗口从 20 天拉到 60 天,给模型更多趋势上下文。6. 二次开发实战:换数据、加方向约束、做滚动回测6.1 替换自己的行情数据拿到一份新数据,先别急着改模型。打开 data_preprocess.py,把 load_stock_data 的路径换成新 CSV,逐一核对 feature_cols 里的列名,缺一列就补一列,字段值单位如果不同,先统一再进归一化。同步检查输入窗口与预测步长:数据量少于 500 条的,把 input_len 降到 15、output_step 降到 3,否则样本数量不足以支撑 LSTM 参数学习。model.py 里的 input_size 要和 feature_cols 长度一致,这是最容易起步就报错的地方。6.2 损失函数加方向约束def directional_loss(pred, target, mse_weight=1.0, direction_weight=0.2): mse = nn.functional.mse_loss(pred, target) sign_pred = torch.sign(pred[:, 1:] - pred[:, :-1]) sign_true = torch.sign(target[:, 1:] - target[:, :-1]) direction_penalty = torch.mean((sign_pred != sign_true).float()) return mse_weight * mse + direction_weight * direction_penaltydirection_weight 从 0.1 开始试,太大模型会为了方向正确牺牲价格精度;预测点只有 5 个,diff 之后出现 4 段方向比较,计算开销可以忽略。6.3 滚动回测验证for start in range(0, total_len - train_len - test_len, step): # 每次前移 step 天,重训一次模型 x_train, y_train = cut_window(start, start + train_len) x_test, y_test = cut_window(start + train_len, start + train_len + test_len) model = retrain_on(x_train, y_train) metrics = evaluate_metrics(y_test, model.predict(x_test)) print(metrics)每次只取最近 train_len 条训练,在紧随其后的 test_len 上验证,然后整个窗口往前挪 step 天再重来。滚动回测比一次性切分更接近真实交易中的顺序,指标也更可信。这个验证循环是二次开发中性价比最高的一步。这五件事里,最让我长记性的是 5.1 那条数据泄漏。从那以后我每次换数据集,都会先打印一遍 scaler 的 fit 范围、检查预测曲线有没有滞后一天,再跑正式训练;花十分钟自检,能省掉后面几十个 epoch 的返工。希望帮到你。本文还有配套的精品资源,点击获取