
简介面向深度学习中阶学习者与股市数据分析爱好者这份基于LSTM的股票数据分析预测项目完整演示了从Tushare获取数据到LSTM模型训练与预测的全流程。作者在熟悉MNIST、CNN、RNN等常规demo后特意选择股票行情这一真实场景尝试脱离现成数据集独立完成数据下载、清洗、合并、特征选择与预测建模解决“只会调包跑demo、不会落地真实问题”的典型痛点。压缩包内共6个文件含5个Python脚本和1个Markdown说明文档脚本分别负责数据下载、合并、选股特征处理、LSTM主模型及入口运行结构轻量但环节齐全适合对照源码逐行理解项目组织方式。资源包仅10KB非常便于下载与阅读。目前已有572人学习浏览具有一定的参考价值。通过学习这份代码读者可掌握Tushare财经数据接口的调用方法、LSTM时间序列预测的基本流程以及如何在真实股票数据上完成特征工程和模型效果验证为后续开展量化分析或金融AI项目打下基础。1. 从MNIST到K线为什么这个LSTM股票预测项目值得从头跑一遍做了近一年深度学习的demo从MNIST手写数字到目标检测跑通的网络结构一只手数不过来但始终有个被掩盖的问题数据是别人处理好的标签是干净的我真正动手的只有搭网络和调loss。这个项目不一样它把从哪拿数据、怎么清洗、怎么构造时序样本、怎么训练、怎么评估整条链路串起来了用的还是Tushare真实日K数据。LSTM在这类任务里的价值不是准确预测明天的收盘价而是验证你的特征工程和训练流程是否可靠。适合两类人一类是熟悉CNN但没碰过时间序列的工程师另一类是已经会用LSTM跑公开数据集、想看看真实金融数据里会出什么幺蛾子的研究者。2. 数据管线先想清楚Tushare取数、多股合并与滑窗样本构造2.1 Tushare接口取数与字段选择这个项目第一步是download.py它的任务是按股票代码拉取历史日线数据。Tushare的使用方式在不同版本里差异比较大项目里用的是Tushare Pro的daily接口按ts_code日期区间一次拉完。常见的做法是在官网注册后拿到token然后初始化pro对象import tushare as ts ts.set_token(your_token_here) # 在 Tushare 官网个人主页获取 pro ts.pro_api() # 初始化 pro 接口 df pro.daily( ts_code000001.SZ, # 平安银行日线数据 start_date20180101, end_date20221231 ) print(df.head())daily接口返回的字段如表所示字段含义用途ts_code股票代码多股票合并时的主键trade_date交易日期排序与时间轴open / high / low / close开高低收核心特征与预测目标vol / amount成交量 / 成交额可作附加特征pct_chg涨跌幅评估方向命中率用拿到DataFrame后别急着用有个细节Tushare返回的数据是按日期倒序的即最新日期在第一行。时间序列模型输入必须递增所以要先按trade_date升序排序再把date列转成datetime索引。这个顺序错误会导致窗口错位训练集和测试集互相混入未来数据。2.2 数据合并与标准化merge.py处理的是多只股票的场景。单只股票的代码可以直接跑通但实操中一般会拉取多只股票做对比实验。常见的做法是按trade_date做外连接把所有股票的收盘价并排放在同一张表里。这里有个取舍停牌日会造成某列NaN直接丢掉会损失样本多股票场景下更稳妥的方案是只保留所有股票都有数据的日期或者用前值填充。填充逻辑写在merge.py里核心代码类似import pandas as pd def merge_close_prices(data_dict): data_dict: {ts_code: DataFrame}合并多只股票收盘价 frames [] for code, df in data_dict.items(): one df[[trade_date, close]].copy() one.columns [trade_date, fclose_{code}] frames.append(one) merged frames[0] for item in frames[1:]: merged pd.merge(merged, item, ontrade_date, howinner) merged merged.sort_values(trade_date).reset_index(dropTrue) return merged接缝处要处理两个坑一是必须从原始数据里把target列单独拆出来否则后续滑窗会把target和特征一起归一化造成训练时看到未来二是归一化只能在训练集上fit然后用同一组min/max去transform验证集和测试集不能拿着全量数据的统计量再切分。标准化这里用MinMaxScaler而不是Z-score。原因是股票价格没有明显的正态分布假设而且min-max把数据映射到0-1区间后LSTM的tanh输出范围能对齐模型收敛更容易。注意收盘价和成交量量纲差太多必须分别做归一化。2.3 滑窗构造时序样本LSTM输入要求是三维张量(样本数, 时间步长, 特征数)。这个项目里look_back取20个交易日也就是用最近一个月约20个交易日的开高低收特征来预测下一个交易日的收盘价。构造样本的函数长这样import numpy as np def create_sequences(data, look_back20): X, y [], [] for i in range(len(data) - look_back - 1): X.append(data[i:i look_back, :]) # 20 天窗口 y.append(data[i look_back, -1]) # 第 21 天的收盘价 return np.array(X), np.array(y)每条样本是(20, 特征数)的二维矩阵2000个交易日能产生约1980条样本。这里有个潜在问题相邻样本有19天是重叠的训练集和测试集如果直接按比例随机切分窗口之间会互相泄漏信息。正确做法是先切出连续的时间段再在各自段内构造滑窗即验证集必须完全在训练集时间范围之后。这个细节决定了评估结果的真实性。3. LSTM模型实现拆解从张量形状到训练主循环的每一行3.1 输入张量的shape到底在表达什么把滑窗数据扔进LSTM之前先明确每个维度的含义。假设特征列为open、high、low、close、vol五列一条样本shape是(20, 5)20是时间步5是特征维度。模型内部的处理顺序是第t时刻的5维向量输入LSTM单元更新隐藏状态h_t和记忆状态c_t然后接着读t1时刻……直到20步读完最后一个时间步输出一个隐藏向量。这个向量进入全连接层映射成1个标量作为预测的次日收盘价。一个容易踩坑的点是batch_first。PyTorch的LSTM默认输入shape是(seq_len, batch, feature)而DataLoader产出的数据通常是(batch, seq_len, feature)。这两个维度顺序不一致会导致运行时直接报错或者更隐蔽的shape错位。项目里在构造LSTM层时设了batch_firstTrue这样从DataLoader拿到的数据可以直接喂进去不用手动permute。3.2 LSTM.py里的模型定义LSTM.py的核心是一个两层LSTM加dropout再接全连接的结构。为什么用两层而不是单层单层LSTM表达能力有限难以捕捉多尺度的价格波动三层以上在数据量只有几千条时很容易过拟合。两层是折中方案第一层捕捉短期波动第二层在更高抽象级别上建模趋势。import torch import torch.nn as nn class StockLSTM(nn.Module): def __init__(self, input_size5, hidden_size64, num_layers2, output_size1, dropout0.2): super(StockLSTM, self).__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, dropoutdropout, batch_firstTrue ) self.regressor nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, output_size) ) def forward(self, x): out, (hn, cn) self.lstm(x) # out shape: (batch, seq_len, hidden_size) last out[:, -1, :] # 取最后一个时间步 pred self.regressor(last) return pred.squeeze(-1)参数说明hidden_size64是隐藏单元数越大模型容量越高但越容易过拟合这个规模的数据64够用num_layers2对应两层LSTM堆叠层间由PyTorch自动加dropoutregressor里先降到32维再输出1维比直接从64维映射到1维多一层非线性实测收敛更稳。forward里取out[:, -1, :]的含义是只保留序列最后一个时间步的输出。这里没有用双向LSTM因为金融时序预测严格依赖过去到现在的单向因果性双向会引入未来信息训练loss会很好看实盘完全不能用。3.3 训练主循环main.py里发生了什么main.py把数据加载、模型初始化、训练循环串起来。关键在训练循环的内部节奏每个epoch分成多个batch每个batch前向传播算loss反向传播更新梯度然后定期打印日志。criterion nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.5) for epoch in range(epochs): 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() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() epoch_loss loss.item() * batch_X.size(0) scheduler.step() val_loss evaluate(model, val_loader) print(fEpoch {epoch1:02d} | train {epoch_loss/len(train_set):.5f} | val {val_loss:.5f})这段代码里有三个关键决策。loss用MSELoss是因为预测目标是连续价格回归任务的标配梯度裁剪max_norm1.0防止LSTM训练中常见的梯度爆炸——LSTM按时间步展开时梯度是连乘关系长序列下极容易暴涨StepLR每10个epoch把学习率减半让模型前期大步搜索、后期小步精调。评估时有个容易忽略的动作验证集loss计算完要把模型切回train模式再跑下一个epoch否则BatchNorm和Dropout的状态不对训练loss会偏离预期。4. 训练参数、loss曲线与模型保存把训练过程调到可信4.1 参数怎么配一份可复用的候选表训练脚本里的参数值并不神秘但每个参数都对应一个为什么。以下参数组合在这个数据规模下实测收敛稳定参数推荐值设置理由look_back20约一个月交易日LSTM能记住的依赖范围够用batch_size64兼顾梯度稳定性和内存占用epochs50更多轮次会开始拟合噪声learning_rate1e-3Adam默认值的有效区间hidden_size64提升到128效果不明显训练时长翻倍dropout0.2小于0.1挡不住过拟合大于0.3欠拟合batch_size的选择要注意样本量只有约2000条时batch_size太大会让每个batch的统计波动过大导致loss震荡下不去太小又会让梯度估计噪声太大。64是这两个方向的平衡点。4.2 loss曲线怎么读哪些现象说明白训了单看训练结束后打印的loss没有意义要看曲线的形状。训练集loss降到0.0005以下、验证集loss在0.002-0.005之间波动说明模型学到了趋势但没记住噪声。验证集loss持续高于训练集3倍以上是过拟合信号优先调大dropout其次降低hidden_size。另一种情况是两者的loss都卡在下不去多半是数据标准化出问题检查收盘价最小值是否为0——如果出现过0说明除权除息后价格没有复权数据本身就不连续。验证集loss每轮打印出来后会有一个周期性抖动在step_size那个epoch附近学习率减半会导致验证loss突然跳一下又恢复这是scheduler生效的正常现象不是bug。4.3 模型保存与加载不只是存权重训练结束后保存的不只是模型state_dict还有归一化用的scaler和look_back值。部署预测时没有scaler就没办法把新输入转成模型认识的范围很多复现项目跑不通都是栽在这里。torch.save({ model_state: model.state_dict(), scaler: scaler, look_back: 20, hidden_size: 64, num_layers: 2, }, lstm_model.pth) # 加载预测 checkpoint torch.load(lstm_model.pth, map_locationcpu) model StockLSTM(input_size5, hidden_sizecheckpoint[hidden_size], num_layerscheckpoint[num_layers]) model.load_state_dict(checkpoint[model_state]) model.eval()加载后要做两件事把模型切成eval模式关掉Dropout再把最新的20天数据用同一个scaler.transform后构造窗口。每日预测时窗口整体向后滑动一天特征更新、target不变这才是真实的用昨天及以前预测明天。5. 边界比成绩更重要评估指标、踩坑清单与可落地的改进方向5.1 回归指标和方向命中率一起看模型输出的是一个具体价格用准确率评估从语义上就不成立。常用的是RMSE和MAPE但在股票场景里方向命中率比价格误差更有业务意义预测涨但实际跌哪怕误差只有几分钱你的策略也是亏的。方向命中率的计算很简单def directional_accuracy(y_true, y_pred): 输入是numpy数组比较相邻两天的涨跌方向 diff_true np.diff(y_true) diff_pred np.diff(y_pred) return np.mean(np.sign(diff_true) np.sign(diff_pred))注意这里比较的是涨跌幅方向而不是预测值与真实值的大小关系。先把归一化的预测值还原成真实价格再计算否则sign结果没有业务含义。方向命中率超过0.55在日线级别上已经算有效信号0.5说明模型只学到了均值回归不具备方向预测能力。5.2 高频踩坑清单复现这个项目最常翻车的四个点第一个坑是数据泄露滑块窗口滑过了train/test切分边界验证集里混入了训练集的信息loss显示0.001换到新数据直接崩溃。第二个坑是价格未复权配股或分红第二天价格会跳空LSTM会把这种序列当成剧烈波动去学习。第三个坑是乱设随机种子PyTorch的模型初始化、DataLoader的shuffle、numpy的滑窗采样各有各的随机源想复现实验结果必须固定三处seed。第四个坑是只看训练loss不看验证loss训练loss降到接近0说明已经开始背数据了。5.3 还能怎么改从复现到能写进简历这个项目的结构决定了它的扩展方式LSTM.py的输入是5维特征可以增加换手率、成交额、涨跌幅、外部指数数据input_size从5变成8或10改动只在数据管线和模型初始化参数两处。更进阶的做法是把Main.py的单只股票流程改造成批量实验脚本跑完所有沪深300成分股后统计方向命中率的分布而不是只盯着某一只股票。这样能得到一个结论模型在流动性好的股票上更可靠在小盘股上方向命中率会显著下滑。这是值得落笔写在项目文档里的边界结论。本文还有配套的精品资源点击获取