
简介面向金融数据分析与量化投资初学者这份资源提供基于LSTM长短期记忆网络的股票基金预测完整示例。项目从历史行情数据出发系统演示了数据归一化、滑动窗口特征构造、多层LSTM网络搭建、训练验证与预测评估的全过程适合希望将机器学习落地到金融时序场景的读者参考也可作为课程设计或毕业设计的起点。压缩包约154KB共10个文件包含5个Excel数据文件分别对应训练集、测试集及预测结果对比3个Python脚本覆盖模型定义、视图处理和基金净值数据获取另有1个pyc缓存文件与1张预测效果图整体结构精简、便于快速复现。已有361人学习下载。读者可借助源码与配套数据快速理解LSTM处理股票基金序列的典型流程并在此基础上扩展自己的特征工程与参数调优实验还可参考数据获取脚本自行更新行情数据。1. 拿到一份LSTM股票基金预测模型代码包先别急着跑做过时间序列预测的人大概都遇到过这种场景同事发来一个“基于机器学习LSTM(长短期记忆的股票基金预测模型.zip”解压后里面有train.py、data.csv和一个写满参数的config.py。跑一遍loss确实在降预测曲线也贴着真实价格走但总感觉哪里不对劲——回测做得越漂亮越怀疑是数据泄漏。这个标题指向的方向本质上是把LSTM神经网络用于股票、基金这类金融时序的回归预测覆盖数据预处理、滑窗建样本、模型训练、评测和回测一整套流程。它适合两类人一是做毕设或课程项目需要把机器学习流程完整走一遍二是刚转时序预测的工程师想用一份可复现的代码包理解LSTM在金融数据上的边界在哪。2. 把行情数据变成LSTM能吃的样本数据源、归一化与滑窗切片LSTM不会直接读K线图它吃的是三维张量形状是(样本数, 时间步数, 特征数)。所以拿到原始行情数据之后第一件事不是建模型而是先把DataFrame变成模型能消化的样本集。这一步决定了后面所有结果是否可信比调参重要得多。2.1 选数据源本地CSV、akshare还是tushare常见做法是先确定数据来源。做本地复现的时候用CSV是最省事的数据格式自己控制需要拉取历史行情时akshare不需要token接口写起来最顺手tushare数据更规整但要积分和token适合对数据质量要求更高的场景。数据源成本适用场景注意点本地CSV无复现、调试、课程作业数据时间范围自己把关akshare免费无需token快速拉日线、指数、基金净值接口偶尔变更需要try/except兜底tushare需要token全市场快照、财务数据积分限制频率高并发要等下面这个函数用akshare拉日线拉失败时回退到本地CSVimport akshare as ak import pandas as pd def load_kline(code600519, start20200101, end20231231): try: df ak.stock_zh_a_hist(symbolcode, perioddaily, start_datestart, end_dateend, adjustqfq) df df[[日期, 开盘, 收盘, 最高, 最低, 成交量]] df.columns [date, open, close, high, low, volume] except Exception: # akshare 接口变动时回退到本地文件 df pd.read_csv(local_kline.csv, parse_dates[date]) return df.sort_values(date).reset_index(dropTrue)这段代码里有两个关键点。第一sort_values(date)必须做akshare返回的数据不保证严格按日期递增滑窗切分如果拿到乱序数据样本里就会混进“未来”。第二adjustqfq是前复权它在除权除息后会把历史价格统一调整到当前口径避免价格跳空影响序列连续性。血泪经验是先排序再切片顺序错了后面一切都是白算。基金净值数据也走同一套流程把接口换成净值历史接口即可。2.2 归一化顺序不能乱只在训练集上fitLSTM对输入尺度敏感尤其是后续要用MSE做损失函数价格从几块钱到上千块钱量级差异会让loss被高价样本主导。所以归一化是必须的但归一化最大的坑在于误把测试集的统计量也用进了训练期。正确做法是先切分再在训练集上fit然后用同一个缩放器去transform训练集和测试集。from sklearn.preprocessing import MinMaxScaler close df[close].values.reshape(-1, 1) train_size int(len(close) * 0.8) scaler MinMaxScaler(feature_range(0, 1)) # 只在这80%的数据上做fit测试集的统计量不参与 train_close close[:train_size] scaler.fit(train_close) train_scaled scaler.transform(train_close) test_scaled scaler.transform(close[train_size:])注意scaler.fit只喂了train_close。这样做的原因是测试集代表的是“未来”做预测时你不应该知道未来价格的最大值和最小值。很多人嫌麻烦直接对全量数据fit训练loss会异常好看但真实场景一用就翻车因为测试集的价格范围已经被“偷看”过了。后面避坑章节会专门展开这个问题。2.3 滑窗构造序列look_back和horizon的取值归一化之后把一维价格序列切成LSTM需要的三维样本。常见的做法是固定一个窗口长度比如用过去60个交易日的收盘价预测下一个交易日这个60就是look_back预测第几天后的价格叫horizon。我一般会先把horizon设成1模型简单先跑通再做多步预测。import numpy as np def build_sequences(data, look_back60, horizon1): X, y [], [] total len(data) - look_back - horizon 1 for i in range(total): X.append(data[i:i look_back]) y.append(data[i look_back horizon - 1]) return np.array(X), np.array(y) X_train, y_train build_sequences(train_scaled, look_back60, horizon1) X_test, y_test build_sequences(test_scaled, look_back60, horizon1) print(X_train.shape, y_train.shape) # (样本数, 60, 1) (样本数, 1)build_sequences里X[i]取的是第i到ilook_back-1天y[i]取的是ilook_backhorizon-1这个位置。这个偏移写法容易把自己绕晕建议你自己推导一遍当horizon1时y落在窗口结束后的第一个位置也就是用过去60天预测“明天”当horizon5时y落在窗口结束后第5个位置预测的是“未来第5天”。参数上还有个细节如果输入的data是二维数组比如多特征(close, volume)X的形状会自动变成(样本数, 60, 特征数)LSTM对应位置的输入维度要随之调整。3. 用PyTorch写出可复现的LSTM预测模型网络结构、loss与训练循环数据准备好了接下来是模型部分。这里说的LSTM实现关键不在背一个网络结构而是理解输入张量的形状、怎么取输出、loss怎么选以及训练循环里哪些参数不能乱调。3.1 模型定义LSTM加一个全连接输出层PyTorch的nn.LSTM默认batch_firstFalse输入形状是(seq_len, batch, feature)大多数人不习惯设成batch_firstTrue之后输入变成(batch, seq_len, feature)代码写起来顺手很多。我一般会显式把这个参数写出来避免阅读代码的人还要去查默认值。import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size1, hidden_size64, num_layers2, output_size1, dropout0.2): 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): out, _ self.lstm(x) # 输出形状: (batch, seq_len, hidden_size) out out[:, -1, :] # 取最后一个时间步的隐状态 return self.fc(out)核心逻辑在forward里。out保存了每个时间步的隐状态做单步价格预测时只用最后一个时间步的输出因为前面的状态已经“折叠”进最后一步了。全连接层把64维的隐状态压到1维输出就是预测价格。num_layers2是经验值一层往往拟合不足三层以上在小数据集上容易过拟合。dropout只在层数大于1时生效它防止模型把训练集的噪声也背下来。补充一个从PyTorch源码层面看的细节nn.LSTM的dropout参数会插入到相邻层之间输入输出层不设。所以如果你只设num_layers1这个参数是无效的别在单层模型上调它。3.2 loss选择回归任务为什么默认用MSELoss价格预测是个回归任务最常见的损失函数是MSE它对大误差的惩罚是平方级的。金融数据里偶尔会出现极端行情MSE会让模型花大量容量去拟合这些尖峰这是它的缺点但反过来MSE对“预测价格与实际价格偏离”特别敏感用来衡量模型有没有学到趋势比较好。损失函数对异常值的惩罚适用场景MSE平方级惩罚很重默认选择梯度平稳收敛快MAE线性惩罚较轻数据异常点多但训练收敛慢Huber阈值内MSE阈值外MAE折中方案delta需要调criterion nn.MSELoss() # 如果数据里尖峰多可以换 Huber # criterion nn.SmoothL1Loss(beta1.0)Huber的beta参数控制“线性区”和“平方区”的切换点beta越大越接近MSE越小越接近MAE。实践里我一般直接先用MSE因为它的梯度在零点附近是连续的训练更平稳后面如果发现预测结果被极端值带偏再换成Huber比较效果。3.3 训练循环优化器、学习率调度与梯度裁剪训练循环里我有三个必写项Adam优化器、学习率自动衰减、梯度裁剪。Adam对学习率不那么敏感但金融时序数据噪声大固定学习率跑50个epoch很容易在后期震荡ReduceLROnPlateau会在loss进入平台期时自动把学习率减半相当于给模型二次机会。梯度裁剪则是给LSTM上保险——长序列上梯度累积很容易爆炸。import torch import torch.optim as optim model LSTMPredictor(input_size1, hidden_size64, num_layers2) optimizer optim.Adam(model.parameters(), lr1e-3) scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience3 ) criterion nn.MSELoss() X_train_t torch.tensor(X_train, dtypetorch.float32) y_train_t torch.tensor(y_train, dtypetorch.float32) X_test_t torch.tensor(X_test, dtypetorch.float32) y_test_t torch.tensor(y_test, dtypetorch.float32) for epoch in range(50): model.train() optimizer.zero_grad() pred model(X_train_t) loss criterion(pred, y_train_t) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() model.eval() with torch.no_grad(): test_pred model(X_test_t) test_loss criterion(test_pred, y_test_t) scheduler.step(test_loss) print(fepoch {epoch:02d}, train_loss {loss.item():.6f}, test_loss {test_loss.item():.6f})这里直接全量数据喂给模型省了DataLoader的batch逻辑数据量几千条时完全够用。clip_grad_norm_的max_norm1.0意思是将所有参数梯度的范数裁剪到不超过1.0防止梯度值过大导致loss跳成nan。scheduler.step(test_loss)用的是验证集loss不是训练loss这样学习率的衰减只跟模型的泛化表现挂钩。4. 跑通完整流程数据切分、训练主循环与三个评测指标模型定义好了训练循环也有了接下来要把“数据准备—训练—预测—评估”这几段串成一条完整的流水线。这一章的目的是给你一张可以直接照着抄的路线图。4.1 按时间顺序切分数据不要在时序上shuffle分类模型里随机打乱数据没问题但时间序列不行。用过去预测未来就必须严格按时间顺序切分——训练集在前测试集在后。这个原则如果不遵守相当于让模型“偷看”了未来行情再回去预测历史评测结果没有意义。import torch from torch.utils.data import TensorDataset, DataLoader def make_loader(X, y, batch_size64, shuffleFalse): dataset TensorDataset( torch.tensor(X, dtypetorch.float32), torch.tensor(y, dtypetorch.float32) ) return DataLoader(dataset, batch_sizebatch_size, shuffleshuffle) # 训练样本用shuffleTrue测试样本shuffleFalse train_loader make_loader(X_train, y_train, shuffleTrue) test_loader make_loader(X_test, y_test, shuffleFalse) def train_model(model, train_loader, test_loader, epochs30, lr1e-3): optimizer optim.Adam(model.parameters(), lrlr) scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience3 ) criterion nn.MSELoss() for epoch in range(epochs): model.train() total_loss 0.0 for X_batch, y_batch in train_loader: optimizer.zero_grad() pred model(X_batch) loss criterion(pred, y_batch) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() model.eval() test_loss 0.0 with torch.no_grad(): for X_batch, y_batch in test_loader: pred model(X_batch) test_loss criterion(pred, y_batch).item() scheduler.step(test_loss / len(test_loader)) model LSTMPredictor(input_size1, hidden_size64, num_layers2) train_model(model, train_loader, test_loader, epochs30, lr1e-3)训练集loader设shuffleTrue的好处是每个batch里的样本顺序被打乱打破了相邻样本强相关带来的梯度波动。这个阶段不涉及时序顺序因为滑窗已经保证每个样本内部是时间有序的。测试集必须shuffleFalse否则后面预测出来的序列没办法按时间画图对比。训练循环里scheduler.step传的是测试集平均loss验证集指标只用来调整学习率不参与梯度更新。4.2 评测指标不能只看lossMSE、MAE与方向准确率训练结束后很多初学者只看loss降了多少但回归loss在金融预测里不够直观。MSE0.0001看起来不错可如果输入做了归一化这个数对应到真实价格可能是几十块钱的误差。更好的做法是反归一化后再算指标同时增加一个方向准确率——模型预测的涨跌方向和实际走势是否一致。def evaluate(pred, actual, scaler): pred scaler.inverse_transform(pred.numpy().reshape(-1, 1)) actual scaler.inverse_transform(actual.numpy().reshape(-1, 1)) mse np.mean((pred - actual) ** 2) mae np.mean(np.abs(pred - actual)) pred_diff np.diff(pred.reshape(-1)) actual_diff np.diff(actual.reshape(-1)) direction np.mean((pred_diff 0) (actual_diff 0)) return {mse: mse, mae: mae, direction_acc: direction} with torch.no_grad(): pred model(X_test_t) metrics evaluate(pred, y_test_t, scaler) print(metrics)direction_acc的计算逻辑是先对预测值和真实值各做一阶差分判断每一天的涨跌符号然后比较符号是否一致。它衡量的是模型对“方向”的判断能力符合交易直觉。一个只预测明天涨、完全不管幅度的模型即便MSE很大方向准确率也可能不低反过来MSE很低但方向准确率只有50%说明模型只是贴近价格曲线并没有抓住涨跌逻辑。金融时序预测的实战项目里方向准确率比MSE更能说明问题。5. LSTM股票预测的常见问题与避坑指南从归一化泄漏到滞后平移这一章是实战里最容易翻车的地方。LSTM本身不复杂复杂的是金融数据里到处都是“看似合理、实则作弊”的写法。以下五个坑我分别在前后端项目里踩过每一个都会让结果要么虚高要么完全没法用。5.1 归一化泄漏全量fit导致预测“虚高”现象训练loss和测试loss都低得惊人回测曲线完美贴合真实价格但把模型接到新数据上立即失效。原因是有人在切分数据之前就对全量数据做了scaler.fit_transform测试集的均值和最值已经参与训练。解决办法是回退到第2.2节的做法先按时间切分再只对训练集fit。# 错误的做法全量fit测试集信息泄漏进scaler scaler MinMaxScaler() close_scaled scaler.fit_transform(close) # 正确的做法先切片再fit scaler.fit(train_close) train_scaled scaler.transform(train_close) test_scaled scaler.transform(close[train_size:])这类泄漏在回测里不仅让MSE失真还会让方向准确率也虚高因为测试集价格的相对高低位置已经被缩放器“预演”了一遍。5.2 未来函数前复权因子悄悄用了后续行情现象某些股票的预测在除权日附近突然出现大误差而回测整体指标却没问题。原因是adjustqfq用的复权因子是截至最新交易日的这意味着历史某一天的复权价格可能被未来的除权事件重新计算过。严格来讲这属于用未来数据修正历史价格。回避方案是改用不复权数据或自行计算“截至当日”的复权因子。做课程项目或入门复现直接用不复权数据更干净避开除权跳空对序列的影响。5.3 数据切分时随机打乱训练集混进了未来样本现象训练集和测试集都有90%以上的方向准确率但模型在实盘毫无作用。原因很可能是切分时用了train_test_split默认的shuffleTrue数据被随机洗牌测试集里的某段行情被当作历史样本送进了训练集。时间序列必须按顺序切分测试集永远是数据末尾的那一段。用train_test_split时记得显式传shuffleFalse或者干脆手动切片。5.4 预测曲线滞后一天模型学会了复制昨天的价格现象把预测值和真实值画在一张图上预测曲线比真实曲线晚一天像把真实价格往右平移了一格。原因是收盘价序列的自相关性极强今天收盘价和昨天收盘价高度相关。LSTM在MSE的驱动下发现直接预测“等于上一个值”在训练集上损失很小于是它学会了恒等映射而不是真正的预测。检验方法很简单计算np.corrcoef(pred[1:], actual[:-1])如果相关性极高就说明模型在复制滞后值。解决办法是把目标从收盘价改成收益率或者用差分后的序列训练再用积分还原价格。df[ret] df[close].pct_change() df[target] df[ret].shift(-1) # 预测明天的收益率而不是明天的收盘价把预测目标从价格换成收益率后模型不再能靠“复制昨天”混过去因为收益率序列的相关性远低于价格序列。5.5 loss震荡不收敛学习率过大或梯度爆炸现象训练loss在前几个epoch下降正常到中段突然跳高甚至出现nan。原因有两类一是学习率偏大在最优值附近来回震荡二是长序列上LSTM隐状态梯度累积梯度范数过大导致参数更新越界。解决办法是配置一个学习率调度器并给梯度设上限。optimizer optim.Adam(model.parameters(), lr5e-4) # 从1e-3降下来多试几档 scheduler optim.lr_scheduler.ReduceLROnPlateau(optimizer, factor0.5, patience3) nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)如果max_norm1.0之后还在震荡优先检查输入是否做了归一化然后检查数据里有没有缺失值被填充成了异常大数。金融数据里的缺失值处理也是一门玄学最简单可靠的办法是前向填充别用0填充——0在价格序列里会变成一个极其突兀的“悬崖”。6. 从预测收盘价改到预测收益率一个让结果更可信的进阶改动如果你已经跑通了上面的代码接下来我想说一个我个人坚持的进阶改动预测目标建议从收盘价改成收益率。直接预测收盘价时MSE最小的树杈上的解就是“几乎等于昨天的收盘价”这一点我在做第一个LSTM预测模型时体会极深——回测曲线光滑得像骗自己后来才发现模型实际在复制前一天的数值。改成预测未来1日或5日收益率模型必须去捕捉序列里的波动信息而不是背下昨天的价格评测起来也更像是一个真正的预测问题。# 构造5日收益率作为目标 df[ret_5] df[close].pct_change(5).shift(-5) df df.dropna(subset[ret_5]) # 训练特征仍用归一化后的收盘价或收益率序列 df[ret] df[close].pct_change() feature df[ret].values.reshape(-1, 1)一个配套的验证方法是滚动回测每训练一次只在历史窗口上拟合然后预测接下来一小段行情再滑动窗口。这种做法的现实价值是模型永远只用已知数据做预测没有未来信息介入评测结果更贴近真实使用边界。我自己养成的一个习惯是每个预测模型都先跑一个naive基准用昨天的价格当作今天的预测然后看LSTM到底比这个简单基准好多少——如果只是打了个平手那LSTM在这个数据集上并没有学到额外信息。希望帮到你。本文还有配套的精品资源点击获取