LSTM股票预测工程实践:从数据预处理到回测评估的完整指南
简介这套基于Python与LSTM的股市预测项目面向金融量化初学者和深度学习入门者聚焦如何利用历史行情数据训练循环神经网络模型并对未来价格走势进行预测与可视化。LSTM通过输入门、遗忘门和输出门控制信息流动能较好捕捉序列中的长期依赖关系适合股票这类时间序列数据。压缩包共19个文件包含9个Python脚本覆盖数据清洗与归一化、序列化转换、LSTM建模、超参数搜索、模型评估和预测绘图等完整环节同时提供2个CSV行情数据集、2个已训练好的H5权重文件、1个参数Excel表以及README说明文档整体大小仅3.92MB便于快速下载复现。目前已有517人学习下载适合结合代码注释和数据集系统性理解LSTM在金融时间序列预测中的应用。借助压缩包内代码读者可以获得从pandas读取数据、Keras搭建LSTM、设置批大小与训练轮数到输出MSE/MAE指标、绘制实际值与预测值对比图的全流程参考两个模型权重文件也能帮助直接检验效果并根据自身股票数据调整参数继续训练是兼顾学习与实战的金融深度学习案例。1. 这份股票预测工程包到底能不能拿去用手上这份以 Python 写的基于 LSTM 模型预测股市的源代码工程连同训练好的模型权重和数据集一起拿到时大多数人第一反应是赶紧跑通、看预测曲线、幻想马上能指导交易。但我建议你先按住这个冲动。这个包的真实价值不在于那条预测线画得多漂亮而在于它把「从原始行情数据到可训练样本、再到 LSTM 网络结构与评估」的完整链路都摆在了你面前。你缺的不是一个能出图的脚本而是一套能看懂、能调参、能判断结果靠不靠谱的复现能力。这个包适合三类人刚入门深度学习、想用非图像数据练手的 Python 开发者把时序预测当副业方向、想搞懂 LSTM 在金融数据上到底怎么落地的人以及手里有行情数据、想验证「神经网络能不能从历史价格里学到点东西」的量化研究者。它解决的核心问题不是「预测准确率有多高」而是「一个最小的可运行闭环长什么样」。模型的预测准确率大概率不会让你惊艳但整个工程从数据清洗、归一化、滑窗取样本、训练到回测的流程是任何时序预测任务都能复用的骨架。这篇文章就把这套骨架拆开讲清楚。2. 从 CSV 到 LSTM 能吃的张量数据集的预处理链路2.1 先摸清数据集的家底解压这个包以后dataset 目录下通常是 CSV 格式的日线行情数据。常见的列包括 date、open、high、low、close、volume不同来源的数据可能还有 adj close前复权收盘价和 turnover换手率。第一步不是急着读入而是先确认两件事时间范围和数据质量。第一件事时间范围决定了你能回溯几年、样本量是否足够。LSTM 虽然擅长捕捉长期依赖但股票日线数据一年只有大约 242 个交易日三年也才 700 多条样本对深度学习来说非常紧张。常见的做法是把日线转成周线或直接用 15 分钟、30 分钟级别数据扩样本但这份工程若默认按日线训你要有样本偏少的心理预期。第二件事数据质量直接决定训练会不会中途崩盘。我用 pandas 读进来后通常会先跑一遍最基础的检查import pandas as pd df pd.read_csv(dataset/stock_daily.csv, parse_dates[date]) print(df.shape) print(df.head(3)) print(df.isnull().sum()) # 缺值统计 print(df[close].dtype) # 确认是数值类型 print(df[date].min(), df[date].max())逻辑说明parse_dates把日期列转成 pandas 的 datetime 类型后面做时序切分依赖这个。isnull().sum()能一眼看出哪列有缺失尤其是 close 列如果有空值后续构造滑窗样本时会连锁出错。dtype检查是为了防止某些 CSV 里数字带千分位逗号被读成 object 类型。参数说明df.shape的元组输出里行数少于 500 就要慎重LSTM 在这点数据上学不到什么稳定的模式。日期范围也要注意——如果这个数据集截止到好几年前那这份模型训练出来主要是练手用别真拿去盘当前行情。数据源这块常见做法是用 akshare、tushare 或 yfinance 拉数据补全。我自己一般先用 akshare 拉最近三到五年的日线再和包里自带的数据拼接做一个鲁棒性测试看看模型在不换代码、只换数据的情况下还能不能稳定收敛。这一步虽然简单但能帮你判断模型是否对特定数据段过拟合。2.2 滑窗切片把一维价格变成监督学习样本LSTM 的输入要求是三维张量形状为(样本数, 时间步长, 特征维度)。原始 CSV 是一张二维表没法直接喂给模型。这里的关键操作用滑窗rolling window把连续若干天的收盘价切成一条样本用前 N 天预测第 N1 天。N 就是look_back也常叫 window_size是这份工程里最重要的超参数之一。假设look_back10那么第 0 到第 9 天的收盘价组成第一个样本标签是第 10 天的收盘价第 1 到第 10 天组成第二个样本标签是第 11 天……依次类推。我一般会封装一个函数import numpy as np def create_sequences(data, look_back10): X, y [], [] for i in range(len(data) - look_back): X.append(data[i : i look_back]) y.append(data[i look_back]) return np.array(X), np.array(y) # data 为经过归一化的收盘价序列一维数组 close_norm df[close].values.reshape(-1, 1) # 预处理后再传入 X, y create_sequences(close_norm, look_back10) print(X.shape, y.shape) # (样本数, 10, 1) (样本数, 1)逻辑说明循环里range(len(data) - look_back)保证切片不越界每一条样本data[i : i look_back]正好是连续的 wait_back 天标签是窗口之后的那一天。reshape(-1, 1)把一维数组变成列向量是为了匹配后续归一化器的输入要求。参数说明look_back不是越大越好。它本质上是「用多少天历史来预测下一天」。选 5 适合短周期波动选 20 相当于用一个月交易日捕捉趋势选 60 接近一个季度。这个包如果默认给的是 10你先按默认跑通后面再调。特征维度是 1意味着只用收盘价单变量。如果你想加入成交量、开盘价做多变量预测就要把多个列堆叠成(样本数, look_back, 特征数)的形状2.3 节细说。2.3 归一化最小最大值缩放和测试集数据泄漏陷阱把数据直接喂给 LSTM 之前必须做归一化因为股票价格动辄几十到几千而 LSTM 内部激活函数对输入尺度敏感不归一化会导致梯度爆炸或收敛极慢。最常见的做法是MinMaxScaler把数据压到 0 到 1 之间。这里有个容易翻车的细节很多人对全量数据fit_transform看着训练曲线很漂亮但这是典型的数据泄漏。测试集的信息早就被缩放器学去了模型的评估结果虚高。正确做法是只用训练集拟合 scaler然后用同一个 scaler 去 transform 测试集from sklearn.preprocessing import MinMaxScaler close df[close].values.reshape(-1, 1) train_size int(len(close) * 0.8) train_raw close[:train_size] test_raw close[train_size:] scaler MinMaxScaler(feature_range(0, 1)) train_scaled scaler.fit_transform(train_raw) # 只 fit 训练段 test_scaled scaler.transform(test_raw) # 测试段直接用同一参数 X_train, y_train create_sequences(train_scaled.flatten(), look_back10) X_test, y_test create_sequences(test_scaled.flatten(), look_back10)逻辑说明fit_transform在训练段上计算最小值和最大值并完成缩放transform在测试段上只用这两个统计量做映射测试段的信息没有参与计算。注意create_sequences返回的 X 形状是(样本数, look_back)LSTM 要求三维所以后面要加X_train X_train.reshape(X_train.shape[0], X_train.shape[1], 1)。参数说明train_size取 0.8 是常见做法。如果你是短序列可以考虑 0.9让训练段更充分如果数据量大到几千条0.7 也行。测试段比例越小评估的样本越少结论越不可靠比例太大训练样本又不足。另外有些变体用 StandardScaler 做标准化这在数据有较多异常大阳线或大阴线时更稳健因为它受极端值影响比 MinMaxScaler 小。但需要注意LSTM 输出层若用 Sigmoid输出范围是 0 到 1此时 MinMaxScaler 更匹配。3. 源码结构的核心模型定义、训练循环与预测流程3.1 文件家族全景model / train / predict 怎么协同这类工程整体会按「模型定义 — 训练脚本 — 预测脚本 — 可视化」来组织。常见做法是把网络结构写在model.py训练逻辑写在train.py预测和绘图写在predict.py数据预处理可能单独抽到data_loader.py也可能直接内联在 train 脚本里。再加上requirements.txt列出依赖以及保存模型权重的目录如checkpoints/或saved_models/。下面用一张表描述我通常的做法模块职责关键输出data_loader.py读 CSV、清洗、归一化、滑窗造样本X_train / X_test / y_train / y_testmodel.py定义 LSTM 网络结构PyTorch 的 nn.Module 子类train.py加载数据、实例化模型、训练循环、保存权重每个 epoch 的 loss 日志、.pt 权重文件predict.py加载权重、对测试集滚动预测、反归一化、绘图预测曲线图、误差指标requirements.txt里通常包含numpy、pandas、scikit-learn、matplotlib和深度学习框架。你的框架选择看训练轮数决定工程若基于 Keras模型后缀常是.h5基于 PyTorch 则是.pt或.pth。这套工程用哪种不魔改你就沿用哪种。新手建议找 PyTorch 版本的工程跑因为模型定义和训练循环是显式代码每一步报错都容易定位Keras 封装度高出问题时黑匣子感更强。3.2 网络结构怎么设计输入维度、隐藏层和 Dropout如果你拿到的是 PyTorch 版本model.py 里的 LSTM 类大致长这样这是最常见的设计细节可能有变import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size1, hidden_size64, num_layers2, output_size1, dropout0.3): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout, ) self.fc nn.Linear(hidden_size, output_size) def forward(self, x): # x 形状: (batch_size, look_back, input_size) out, _ self.lstm(x) # 输出所有时间步的隐状态 out out[:, -1, :] # 只取最后一个时间步的隐状态 out self.fc(out) return out逻辑说明nn.LSTM是核心input_size是每个时间步的特征数只用收盘价就是 1加了成交量就是 2hidden_size是隐状态维度相当于 LSTM 的记忆容量num_layers2表示堆叠两层 LSTMbatch_firstTrue让输入形状统一为(batch, seq_len, input_size)。out[:, -1, :]只取序列最后一天的隐状态——因为我们要预测的是第 N1 天更早时间步的输出对最终全连接层没有直接意义。参数说明hidden_size64和num_layers2是这套小规模时序任务里很稳的组合。hidden_size 太大比如 256容易过拟合且训练明显变慢太小比如 8模型容量不够预测曲线会很钝。Dropout 加在 LSTM 层之间但注意 PyTorch 官方规定num_layers1时设置 dropout 会告警只有num_layers1时 dropout 才生效。这个坑很隐蔽你如果只堆一层还设了 dropout等于白设。3.3 训练循环与 checkpoint 保存loss 怎么选、模型怎么存训练的核心过程是每个 epoch 遍历训练样本算预测值、算损失、反向传播、更新权重。这套工程通常不会用完整的批量训练而是用 DataLoader 分批。loss 函数的选择上有讲究金融时间序列常用MSELoss因为它对大的预测偏差惩罚更狠让模型尽量贴合走势。但如果你更关心「方向对不对」而不是「价格差多少」可以换HuberLoss或自己写方向损失。这里给一个完整的训练循环骨架import torch import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model LSTMPredictor(input_size1, hidden_size64, num_layers2).to(device) criterion nn.MSELoss() optimizer optim.Adam(model.parameters(), lr0.001) X_train_t torch.tensor(X_train, dtypetorch.float32).to(device) y_train_t torch.tensor(y_train, dtypetorch.float32).to(device) dataset_size X_train_t.shape[0] batch_size 64 epochs 100 for epoch in range(epochs): model.train() total_loss 0.0 for i in range(0, dataset_size, batch_size): X_batch X_train_t[i : i batch_size] y_batch y_train_t[i : i batch_size] pred model(X_batch) loss criterion(pred, y_batch) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * X_batch.size(0) avg_loss total_loss / dataset_size if (epoch 1) % 10 0: print(fEpoch {epoch1}/{epochs}, Loss: {avg_loss:.6f}) if (epoch 1) % 50 0: torch.save({ model_state_dict: model.state_dict(), look_back: 10, hidden_size: 64, num_layers: 2, input_size: 1, }, fcheckpoints/lstm_epoch_{epoch1}.pt)逻辑说明训练在取出 batch 后用model(X_batch)前向传播loss.backward()反向传播算梯度optimizer.step()更新权重。optimizer.zero_grad()必须在每个 batch 开始前清空上个 batch 的梯度否则梯度会累加loss 曲线会变得很怪。每 50 个 epoch 存一次 checkpoint而不是只在最后存是为了防止训练后期过拟合导致最终权重反而变差中途的权重能做后悔药。参数说明lr0.001是 Adam 的常见默认值但这个包的数据量如果只有几百条0.001 可能偏大训练后期 loss 震荡。我一般会加一个学习率衰减比如每隔 30 个 epoch 把 lr 乘以 0.5让训练后期步长变小、逼近最优解。batch_size在数据量小时可以降到 32数据量只有 500 条时甚至可以直接全批量训练。PyTorch 的 DataLoader 在这里被手动 for 循环替代好处是逻辑直白缺点是没法用 shuffle时间序列本来也不该 shuffle——一旦打乱顺序时间依赖就被破坏了。3.4 预测与反归一化拿到真实价格刻度训练完以后predict.py 要做三件事加载权重、对测试集做前向预测、把 0 到 1 之间的预测值还原成真实价格。反归一化这步漏了是新手经常犯的错画出来的图纵轴是 0.8 这种小数根本没法看。反归一化的数学原理就是MinMaxScaler.inverse_transform把预测值从 0-1 空间映射回原始价格空间import matplotlib.pyplot as plt model.load_state_dict(torch.load(checkpoints/lstm_epoch_100.pt)[model_state_dict]) model.eval() with torch.no_grad(): X_test_t torch.tensor(X_test, dtypetorch.float32).to(device) pred_scaled model(X_test_t).cpu().numpy() pred_price scaler.inverse_transform(pred_scaled) y_test_price scaler.inverse_transform(y_test.reshape(-1, 1)) plt.plot(y_test_price, labelreal) plt.plot(pred_price, labelpred) plt.legend() plt.savefig(pred_curve.png, dpi120)逻辑说明model.eval()切换为评估模式关闭 dropout 的随机性让每次前向输出确定。torch.no_grad()告诉 PyTorch 不需要记录梯度推理时省内存也防止误算。scaler.inverse_transform用的还是训练阶段拟合好的那个 scaler 对象这就是 2.3 节强调测试集不能用独立fit_transform的原因——一旦测试集单独 fit这个逆变换就得不到正确的价格刻度。参数说明dpi120是导出图片清晰度写报告用 150 更清晰屏幕看 100 就够了。保存路径建议用fig.savefig而不是plt.show()因为服务器上通常没有图形界面show 会直接报错。预测图的横轴是测试集索引而不是日期如果你想把日期标签打上去需要提前把df[date][train_size look_back :]传进来用plt.xticks映射。4. 避坑排查训练集曲线漂亮但预测废掉的 6 个隐蔽原因4.1 归一化泄漏测试集被 scaler 偷看了现象训练集和测试集上的 loss 都很低预测曲线和真实曲线几乎贴在一起但让你拿最近 30 天数据预测未来 10 天时结果离谱到像乱画的。原因全量数据先fit_transform再切分或者测试集自己fit_transform。测试集的分布信息在训练阶段就被缩放器学到了评估结果虚高。更隐蔽的是有些人把scaler.fit()放在train_test_split之后但用了fit_transform去处理测试集这个错误不太容易被发现因为代码不会报错。解决严格按「训练集 fit → 训练集 transform → 同一个 scaler 去 transform 测试集」的顺序执行。另外预测未来时新来的真实数据点要加入训练集后重新拟合 scaler不能让旧 scaler 一直用下去因为价格区间会漂移。4.2 时间序列切分时用了 shuffle现象训练 loss 降得很慢或者 loss 在某个值附近震荡不收敛。原因train_test_split里默认shuffleTrue把时间顺序打乱后喂给 LSTM。虽然每个样本内部的时序没乱但样本之间的顺序乱了。LSTM 的训练依赖时间依赖关系打乱后梯度更新路径混乱。解决手动按索引切片或者train_test_split(..., shuffleFalse)。更稳妥的是像我 2.3 节那样直接用数组切片不要用 sklearn 的切分函数。4.3 PyTorch dropout 静默失效现象num_layers1且设置dropout0.5训练日志正常但训练集和测试集的表现差距没有因为 dropout 而缩小。原因PyTorch 官方规定 dropout 只在num_layers 1时生效单层 LSTM 传入 dropout 参数直接忽略而且只给 warning 不报错很容易漏看。解决要么把num_layers设为 2同时在每层之间插nn.Dropout要么改用单层 全连接层后加 Dropout 的方式。显式写出来最可控。4.4 反归一化时用了不同 scaler 实例现象预测值画出来后整体偏移真实值一大截但曲线形状是对的两条线像是在不同高度上平行走。原因predict 脚本里重新创建了一个MinMaxScaler()然后fit了全量或测试集数据。即使只做transform如果 scaler 不是训练时那个保存下来的对象它的min_和scale_属性就是错的。解决在 train.py 里把 scaler 保存下来用 joblib 或 pickle 存储import joblib joblib.dump(scaler, checkpoints/scaler.pkl)预测脚本加载同一份 scaler 文件不要现 fit。4.5 模型预测的是「平滑后的均线」不是「价格」现象预测曲线看着还行但总是滞后真实价格突然拐头时预测还在原地磨蹭。原因LSTM 用 MSE 作为损失函数本质是在学条件均值。当数据噪声大时最优预测就是钝化、平滑的趋势线。这个不是 bug是统计特性。所以你得到的预测本质上更接近移动平均线而不是能精确捕捉拐点的信号。解决换目标。与其直接预测价格不如预测涨跌方向或次日收益率。把标签改成1涨或0跌损失函数换成BCELoss模型预测的语义就变了。或者保持预测价格但只把「预测方向 vs 真实方向」的一致率当作核心指标来看。4.6 归一化后的数据范围与激活函数不匹配现象训练早期 loss 正常后期 loss 降不下去预测值集中在某个范围附近。原因如果输出层用了nn.Sigmoid()输出被限制在 0 到 1 之间而你的标签用 MinMaxScaler 映射到了 -1 到 1 或者 0 到 100两者范围对不上模型再怎么调也逼近不了目标。解决输出层不加激活函数回归任务并保证标签范围和 scaler 范围一致。如果你用的是 StandardScaler 归一化到均值 0 方差 1输出层就加nn.Tanh()输出范围是 -1 到 1能和标准化后的标签匹配。5. 把模型从「能跑」推到「可信」滑窗回测、调参网格与方向胜率前面几条避坑解决了「工程上跑得顺」的问题但真正让这套工程开始有点用处的是你额外做的两个东西滑窗回测和方向胜率检验。我通常在 predict.py 之外单独写一个backtest.py用滚动的方式模拟真实交易场景每次只用截止到当天的数据训练预测下一天然后窗口后移再训练再预测。这种做法的原型是def rolling_backtest(df, look_back10, train_years2): predictions, reals [], [] for end in range(train_years * 242, len(df)): train_seg df[close].values[end - train_years * 242 : end] scaler MinMaxScaler() train_scaled scaler.fit_transform(train_seg.reshape(-1, 1)) last_close_scaled train_scaled[-look_back:] model LSTMPredictor() # 训练模型这里省略训练代码逻辑与 3.3 节一致 model.eval() with torch.no_grad(): pred_scaled model(torch.tensor(last_close_scaled.reshape(1, look_back, 1), dtypetorch.float32)) pred_price scaler.inverse_transform(pred_scaled.numpy()).flatten()[0] predictions.append(pred_price) reals.append(df[close].values[end]) return predictions, reals滚动回测的价值在于它最大程度避免了未来函数。每一天的预测只用了当天之前的数据严格模拟了实盘时的信息可得性。注意scaler.fit_transform每次都只用这个窗口内的数据窗口外新数据不会偷看。有了预测序列下一个要算的指标是方向胜率。价格预测的绝对误差里有大量噪声真正有参考意义的是「模型预测涨实际涨没涨」。方向胜率的计算方式很简单比较预测序列的差分符号和真实序列的差分符号一致的天数占比就是胜率。大多数 LSTM 股票预测的方向胜率落在 48% 到 55% 之间。如果低于 50%说明模型在这个数据集上没有学到有效信息超过 55% 已经算不错但还要考虑手续费和滑点。最后给你两个可以直接动手调的方向。一是调参网格把look_back、hidden_size、num_layers、lr各取几个值分别跑一遍滚动回测用方向胜率作为筛选指标。下面是我常用的初始网格参数尝试值look_back5, 10, 20, 30hidden_size32, 64, 128num_layers1, 2, 3lr0.0005, 0.001, 0.005二是给模型加一个简单的过滤规则当预测的涨跌幅绝对值小于某个阈值比如 0.5%时不下判断标记为「观望」。在实践中这种过滤往往能把决策的部分胜率提升几个点因为模型对小幅波动本身就没有区分能力。我自己跑这类工程的血泪经验是永远别只看训练 loss 就下结论先跑一遍滚动回测算出方向胜率再决定要不要继续调参。这套工程能否从「玩具」变成「工具」关键不在于 LSTM 结构多复杂而在于你有没有一套能让你相信模型的评估流程。希望这些拆解能帮你把它真正用起来做成你自己的一个能持续迭代的预测小闭环。本文还有配套的精品资源点击获取