简介:一套基于Python与LSTM神经网络模型实现的股票指数预测项目源码,源自作者大三期末大作业,经导师指导并认可,评审高达99分,适合计算机相关专业学生用做期末大作业、课程设计或毕业设计参考,也适合具备Python基础的学习者进行项目实战训练。压缩包共10个文件、1.65MB,包含3个ipynb预测脚本(分别演示上证综指、道琼斯等指数的建模与预测)、2个md说明文档、1个训练好的best_model.pth权重文件及环境依赖yml配置,结构清晰,便于按需查阅。已有163人学习下载。通过该项目可以完整掌握LSTM时间序列预测流程,包括数据读取预处理、网络模型搭建、训练调优、指标可视化以及模型保存与加载;自带最优权重可直接运行体验预测效果,还可替换股票数据迁移到其他指数或行情序列,是一份可复用性很强的实战范例。代码中附有详细注释,从数据清洗、特征构建到模型评估均给出完整思路,适合初学者循序渐进地完成从理论到实践的跨越,也能为课程答辩提供有力支撑。
1. LSTM模型做股票指数预测:这个项目到底能预测什么
你见过大量“Python + LSTM 预测股票”的博客,配一张近乎完美的拟合图,然后评论区一堆人问能不能用来炒股。我的判断是:这个方向能做,但价值不在“预测明天的涨跌”,而是用神经网络算法把市场里的时序结构拆出来——用 Python 写一个完整的 LSTM 股票指数预测项目,本质上是在训练一个“走势形态识别器”。它适合三类人:想入门深度时序建模的工程师、做量化研究需要基准模型的开发者、以及想验证“AI 能不能解释行情”的研究者。不适合期待短期暴富的散户。指数比个股更适合 LSTM,因为它噪音更小、流动性稳定、无突发的单票黑天鹅。项目源码本身不难,难在数据切分、归一化时机和损失函数的选择上,这三个点决定了模型到底是学到了规律还是背下了噪音。
2. 建模逻辑与损失函数设计:先想清楚再写代码
2.1 问题定义:回归任务还是分类任务,预测几步才合理
LSTM 预测股票指数,第一步要明确输入和输出是什么。很多项目直接把“过去 N 天的收盘价”喂进去,输出“明天的收盘价”,这属于最朴素的回归定义。但这样做有三个隐患:价格序列是非平稳的,指数的绝对数值会随时间漂移;收盘价的一阶自相关极强,模型学到一个“复制昨天”的映射就能拿到很低的 loss;训练集和测试集如果分布差异大,模型在测试集上几乎必然失效。
我常用的定义是:用过去 60 个交易日的特征序列,预测未来 5 个交易日的累计收益率。换成具体形式就是,X 的形状是(batch, 60, feature_dim),y 是一个标量“未来五天收益”,回归。为什么不预测未来一天?一天窗口的噪音太大,开盘跳空和尾盘波动会淹没一切信号;而五天的窗口更能体现 LSTM 对短期趋势结构的捕捉能力。多步预测也可以做——输出(batch, 5)的向量直接预测未来 5 天每一天的收益率——但训练难度明显增加,误差会随时间步长累积。
还有一种做法是把问题改成二分类:预测未来 5 天收益率大于 0 还是小于 0,输出层用 sigmoid,损失函数用 BCEWithLogitsLoss。分类的好处是评估直观,方向准确率比连续值的 MAE 更容易解读;坏处是损失函数不再惩罚“预测得太夸张”这类错误,模型的概率输出也未必校准。我一般先用回归跑通基线,再看方向准确率能不能过 50%,如果连方向都判断不了,意味着序列里的可学信号根本不足以支撑策略。
2.2 特征选择和归一化:为什么只喂收盘价不够
只看收盘价做 LSTM 预测,相当于让一个人蒙住眼睛只听音量猜市场情绪——能听到一些信息,但丢掉了太多。我建议特征至少包含五个维度:开盘价、最高价、最低价、收盘价、成交量。如果还想加技术指标,相对强弱指标 RSI 和 MACD 的差分值可以各加一列,但不要贪多。特征数量从 5 加到 20,模型能力不上升反而下降,这是小样本时序任务的常见现象——LSTM 的参数量随输入维度线性增长,数据量撑不住。
归一化在时序预测里比图像识别更敏感。图像归一化用全体数据的均值和标准差没太大问题,因为像素分布相对稳定;但股票行情完全不一样。训练集是 2015 年到 2020 年,测试集是 2021 年,你在归一化的时候用到了 2021 年的最高价和最低价,模型在训练阶段就“见过”未来的取值范围,这叫数据泄露。正确的顺序是:先按时间切分,然后只在训练集上拟合 MinMaxScaler,再分别 transform 验证集和测试集。
代码长这样:
from sklearn.preprocessing import MinMaxScaler # 假设已经按时间排序,train_df / val_df / test_df 是三个互不重叠的时间段 scaler = MinMaxScaler(feature_range=(0, 1)) # 只对训练集的数值列拟合 train_scaled = scaler.fit_transform(train_df[feature_cols]) # 验证集和测试集只能 transform,不能再次 fit val_scaled = scaler.transform(val_df[feature_cols]) test_scaled = scaler.transform(test_df[feature_cols])这段代码里scaler.fit_transform和scaler.transform的顺序是生死线。验证集和测试集一旦调用了fit_transform,等于让模型在训练时看到了未来的数据范围,测试指标会虚高。另外注意MinMaxScaler对异常值敏感,如果指数在某一天出现极端跳空(比如千股跌停),最大最小值会被拉到一个离谱的位置,其他所有数据压缩到零点几的区间,学出来的特征区分度很差。遇到这种情况,用 RobustScaler 替代,它用中位数和四分位数做归一化,抗异常值能力更强。
2.3 损失函数的选择:MSE、Huber 还是方向性损失
损失函数直接决定模型关注什么。MSE 是时序回归的默认配置,它惩罚大误差远重于小误差,所以模型会尽可能避免预测出极端值——这在股票预测里不一定对,因为市场常常用极端走势来转折。Huber Loss 在误差小于阈值时是平方损失,大于阈值时变成线性损失,兼顾了平滑和对异常值的容忍度。预测股票指数这种本身就是尖峰厚尾分布的数据,Huber 通常比纯 MSE 更稳健。
torch.nn.SmoothL1Loss就是 Huber 的一种实现,参数控制阈值。我把损失函数从 MSE 换到 Huber 的经验是:训练初期的 loss 下降速度会慢一点,但验证集上的局部波动明显减少,预测曲线不会跟着单日暴涨暴跌剧烈摆动。如果你建的是方向分类模型,那就用BCEWithLogitsLoss,它把 sigmoid 和交叉熵合并在一起,数值更稳定。
选择损失函数的核心原则是:先去定义什么叫“预测得好”。如果你希望预测曲线贴近真实曲线,MSE 和 Huber 都可以;如果你希望捕捉拐点,那么单纯用回归损失还不够,需要在损失里加一个方向惩罚项——比如当预测收益与真实收益符号相反时,额外翻倍惩罚。这个自定义损失不复杂,但实际收益很明显,尤其是用模型信号做多空判断的时候。
3. 数据准备:清洗行情、计算收益率和切分工序
3.1 行情数据清洗:日期、缺失值和列顺序
数据源我常用本地 CSV,格式一般是date, open, high, low, close, volume。第一步一定是排序去重,指数数据有时会在同一日期出现两行记录,可能是数据源推送重叠导致;第二步是缺失值处理,停牌或者数据源漏推会产生 NaN 行。直接 dropna 会损失连续的历史上下文,用ffill()前向填充更符合行情逻辑——缺失那一天的数值维持前值,对 LSTM 的序列输入更友好。
import pandas as pd df = pd.read_csv("index_daily.csv", parse_dates=["date"]) # 排序、去重、按日期升序 df = df.sort_values("date").drop_duplicates(subset="date", keep="last").reset_index(drop=True) # 填充缺失值:前向填充 df[["open", "high", "low", "close", "volume"]] = df[["open", "high", "low", "close", "volume"]].ffill() # 删除仍然缺失的行(序列最前面的空窗期) df = df.dropna().reset_index(drop=True)这里有个细节:drop_duplicates时keep="last"保留同一天的最后一笔记录,因为同一交易日的盘中快照可能有多个版本,收盘后的最新修订值才是最终行情。ffill()之后一定要检查序列开头的缺失段,如果 2010 年最初几周的数据全是 NaN,ffill会一直空着,最后用dropna干净地切掉。
3.2 构造收益率序列和滑窗样本
接下来把行情转换为模型真正要学的东西。我的惯例是创建目标列future_5d_return,代表未来 5 个交易日的累计收益率。用pct_change(5)计算,但注意 Pandas 的pct_change默认是当前值相对 5 天前的值,我们要的是“未来”,所以要对齐方向。最简单的做法是先用pct_change(5).shift(-5)把结果移动到当前时刻位置,最后一行的未来 5 天数据不存在,会自动变成 NaN,最后删除。
滑窗生成用 Python 循环或 numpy 的 stride-tricks 都可以。对中等规模数据(3000 行左右)来说,普通循环已经够快,不需要上复杂技巧。代码:
import numpy as np lookback = 60 target_lookahead = 5 feature_cols = ["open", "high", "low", "close", "volume", "ma5", "ma20"] scaler = MinMaxScaler() scaled = scaler.fit_transform(df[feature_cols]) X, y = [], [] for i in range(lookback, len(df) - target_lookahead): X.append(scaled[i - lookback:i]) # 未来5天累计收益率,用原始序列计算 future_return = df["close"].iloc[i + target_lookahead] / df["close"].iloc[i] - 1 y.append(future_return) X = np.array(X, dtype=np.float32) y = np.array(y, dtype=np.float32).reshape(-1, 1)这段代码的三个关键点:range起点是lookback,保证第一个样本至少有 60 天历史;终点是len(df) - target_lookahead,保证目标值不越界;X的形状是(样本数, 60, 特征数),这就是 LSTM 要求的(seq_len, input_size)在单样本层面的排列。注意特征列里加了ma5和ma20,这是移动平均线,用来给模型提供趋势上下文,实际加不加可以自己试,我加的原因是股价单日噪音太大,均线能帮 LSTM 稳定输入分布。
3.3 时间序列切分:为什么不能随机打乱
这是新手最容易翻车的地方。图像分类里我们习惯train_test_split(shuffle=True)随机切分,但时序任务一旦随机打乱,训练集里混着未来的数据,LSTM 学到的是“看到后面猜前面”的虚假映射,测试时拿到的完全是无序的历史窗口,模型直接崩掉。正确做法是严格按时间顺序切分。
n = len(X) train_end = int(n * 0.7) val_end = int(n * 0.85) X_train, y_train = X[:train_end], y[:train_end] X_val, y_val = X[train_end:val_end], y[train_end:val_end] X_test, y_test = X[val_end:], y[val_end:]切分比例 70% / 15% / 15% 是时序任务常用的默认配置。这里还有一层更微妙的问题:相邻样本的输入窗口有 59 天是重叠的,因此训练集里最后一批样本和验证集第一批样本之间的自相关性很高。严格做法是切分后再按gap = lookback丢弃边界上的样本,但实践中影响不大,大多数人不会这么做。如果你追求严谨,在切分后X_val前 60 条删掉就是。另外要检查切分后各集合的收益率均值是否接近,如果测试集恰好是一波大牛市起点,那模型测试分数会虚高,评估结论也会有偏差。
4. 基于 PyTorch 构建 LSTM 模型并完成训练
4.1 模型结构:从输入维度到输出层的完整设计
LSTM 模型的主体是两层堆叠的 LSTM 单元,再接一个全连接输出层。输入维度是特征数量,隐藏单元数我用 64 起步,数据集大的话可以上到 128。层数超过两层收益就很小了,反而增加训练难度和过拟合概率。dropout 放在 LSTM 层之间,数值取 0.2 到 0.3。全连接输出层把最后一个时间步的隐藏状态映射到标量收益。
import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size, hidden_size=64, num_layers=2, dropout=0.2): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout, ) self.regressor = nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Linear(32, 1), ) def forward(self, x): # x: (batch, seq_len, input_size) out, _ = self.lstm(x) # out: (batch, seq_len, hidden_size) last_out = out[:, -1, :] # 取最后一个时间步的隐藏状态 return self.regressor(last_out)代码里的batch_first=True意味着输入形状是(batch, seq_len, input_size)而不是 PyTorch 默认的(seq_len, batch, input_size)。忘记设这个参数会导致维度对不上,报错信息很抽象。out[:, -1, :]取出序列最后一个时间步的隐藏状态,如果用out[-1]拿到的会是最后一个 LSTM 层的输出,语义完全不同——前者是时间维度的最后一步,后者是层维度的最后一层。第一次写 LSTM 的人大概率在这两个索引上踩过坑。
4.2 训练循环:梯度裁剪、学习率衰减和早停
训练脚本需要包含四样东西:数据加载、损失函数、优化器、训练循环。数据加载用TensorDataset配合DataLoader,batch_size建议 32 或 64。num_workers在 Windows 环境下容易出问题,如果跑不起来就设 0。
from torch.utils.data import TensorDataset, DataLoader dataset = TensorDataset(torch.tensor(X_train), torch.tensor(y_train)) loader = DataLoader(dataset, batch_size=64, shuffle=False) model = LSTMPredictor(input_size=X_train.shape[2], hidden_size=64, num_layers=2) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.SmoothL1Loss() scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=5, factor=0.5) clip_value = 1.0 for epoch in range(100): model.train() epoch_loss = 0 for xb, yb in loader: optimizer.zero_grad() pred = model(xb) loss = criterion(pred, yb) loss.backward() # 梯度裁剪,防止 LSTM 梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), clip_value) optimizer.step() epoch_loss += loss.item() * len(xb) # 验证阶段 model.eval() with torch.no_grad(): val_pred = model(torch.tensor(X_val)) val_loss = criterion(val_pred, torch.tensor(y_val)) scheduler.step(val_loss) if (epoch + 1) % 10 == 0: print(f"Epoch {epoch+1}, Train Loss: {epoch_loss/len(X_train):.5f}, Val Loss: {val_loss:.5f}")几个关键参数说明。shuffle=False在训练中可能让你犹豫——图像训练都开 shuffle,这里为什么不开?因为相邻交易日样本高度相关,shuffle 会让模型在同一个 batch 里同时看到 1 月和 7 月的数据,短期规律会被打散。我实践下来开 shuffle 训练收敛更快,但要配合dropout防过拟合;不开则更保守,验证集波动更平滑。clip_grad_norm_是 LSTM 训练的标准操作,梯度范数超过 1.0 时会被缩放,防止时间步长过长导致梯度爆炸。ReduceLROnPlateau在验证集 loss 连续 5 个 epoch 不降时把学习率减半,这是最实用的调参工具。
4.3 超参数实验:隐藏层大小、学习率和序列长度的合理范围
跑实验时我建议每次只动一个变量。先把hidden_size固定在 64,lookback固定在 60,lr从 1e-3 开始。如果训练 loss 震荡严重,说明学习率太高,降到 3e-4;如果 loss 下降极慢,先检查数据归一化是不是出了问题——特征值范围不一致会导致梯度更新被大数值特征主导。lookback这个参数代表模型“回忆”多少天的行情,20 天适合捕捉短期动量,60 天在指数上表现更稳,120 天数据量不够时容易过拟合。批大小 64 比较中庸,显存小的机器用 32。总训练轮数控制在 50 到 100,配合早停验证,多跑不会带来明显收益——股票数据本身信噪比低,LSTM 在高 epoch 数下很快就开始记忆训练集噪音。
5. 避坑清单:数据泄露、未来函数与 LSTM 的翻车真相
5.1 数据泄露:用了全体数据的归一化参数
现象:训练时 loss 正常,验证集 loss 也能接受,一上测试集分数吓人的好。原因:验证集或测试集数据参与了 MinMaxScaler 的拟合,等于模型在训练阶段就知道了未来的取值范围,测试失真。解决:严格按代码执行两步走,scaler.fit(train_scaled)之后,验证集和测试集只能调用transform。
5.2 未来函数:特征里混入了当天收盘价去预测当天
现象:回测曲线惊艳,实盘模拟却一塌糊涂,每日收益 alpha 几乎为零。原因:构造特征时用了今天的收盘价和成交量去预测明天的收益,但今天收盘后你实际能拿到的数据只有日终快照,高频信号在次日开盘就无法复现。解决:检查样本构造循环,X 窗口结束的那一天,应当对应 y 标签的未来起点。严格形式是X取i - lookback到i(含),y对应i + 1到i + target_lookahead的收益,不能把i当天同时放进 X 和 y 的计算基准里。用 Pandas 的shift(1)把所有特征整体滞后一天,彻底削掉同周期信息。
5.3 滞后假象:模型在“抄昨天的作业”
现象:把 predict 值和真实值画在一起,曲线几乎重合,MAE 低到 0.01,但把预测序列往前挪一天看,完全对不上。原因:收盘价自相关极高,直接回归收盘价时 LSTM 学到的最优策略是输出“昨天的收盘价”,因为这在 MSE 上的误差极小。解决:改用收益率作为目标,收益率序列是近似平稳的,模型无法用复制来刷低 loss;同时计算方向准确率,正确率若低于 52%,预测收益曲线的“高精度”就是视觉幻觉。
5.4 预测值钝化:滚动预测的误差累积
现象:单步测试正常,改成多步预测后曲线变成一条趋于水平的线。原因:LSTM 每次用预测值作为下一步输入时,误差逐步累积,最终预测值收敛到一个常数——这不算 bug,是所有递归预测的通病。解决:部署时避免盲目递归多步,改用“训练时直接用真实值做教师强制(teacher forcing),推理时只外推 5 步就停,然后重新取真实行情刷新窗口”。不要做 60 步连续递归预测,那既不符合实盘场景,也没有实用意义。
5.5 过拟合:训练集 loss 0.003,换行情年份就崩
现象:训练集和验证集 loss 都低,但把模型换到没见过的市场环境(比如从牛市段到熊市段),预测完全失效。原因:模型记住了训练期的特定波动形态。解决:增加 dropout、提高学习率衰减的 patience、设置早停;更本质的办法是缩短训练区间,用最近的 1 年数据训练,用随后 3 个月做验证,指数行情风格漂移的速度比一般人想得快得多,5 年前的数据对今天的预测基本没有价值。
6. 进阶方向:从预测模型到可验证的量化策略
模型跑出结果之后,下一步是能不能用。我习惯把预测信号转化为策略过滤器:模型预测未来 5 天收益率为负时,不开多仓;为正时,允许执行原有的趋势策略开仓信号。这样做不是拿 LSTM 做主信号,而是把它当成风控层,过滤掉模型认为风险最高的时段。
要验证 LSTM 是否真的有用,需要一个对照实验:把训练集的 y 标签随机打乱,重新训练一个模型,对比它与原模型在测试集上的方向准确率。如果两个模型的准确率差距不超过 2%,说明原模型学到的全是噪声模式,这个数据上就做不出可用的预测信号。这个对照实验我每次都会跑,它是最便宜的“后悔药”。
工程部署上,模型权重会过期,建议每 5 个交易日重新训练一次;输入窗口用滚动更新,不要用固定离线文件。文件组织可以分成data/、scripts/、models/三个目录,训练脚本导出标准化的预测结果 CSV,评估脚本单独跑。生产环境用 CPU 推理 LSTM 就够了,一个指数预测模型只有两层 LSTM,单条样本推理不到 2 毫秒,不需要 GPU。如果你要往深了做,可以尝试把 LSTM 的输出接一个轻量注意力模块,或在特征里加入跨资产数据,比如同时输入上证指数和沪深 300 的行情做多任务预测,这些都比堆模型层数有效。我在自己的项目里多次遇到看似提升实则过拟合的现象,一个朴素的经验是:先把基线模型稳定跑起来,用现金流模拟回测 3 个月,再考虑换更复杂的结构。希望帮到你。
本文还有配套的精品资源,点击获取