ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LSTM股票涨跌预测:从多值量化分类到PyTorch滚动回测

LSTM股票涨跌预测:从多值量化分类到PyTorch滚动回测 简介基于长短期记忆网络(LSTM)的股票涨跌预测项目面向量化交易入门者与金融数据分析学习者聚焦上证50ETF510050历史行情采用多值量化分类将连续涨跌幅离散化把预测转化为多维函数拟合与分类任务。模型以历史交易信息为特征输入经LSTM层训练后对涨跌区间预测实验显示在单纯涨跌判断上效果较好。压缩包共31个文件含7个Python源码、6个pyc缓存、5个XML配置、5张PNG截图、3个CSV数据、1个pkl模型及README说明并附有数据挖掘竞赛相关截图整包仅551KB结构清晰。已有1217人学习/下载。压缩包内含完整stockPredict工程训练与预测脚本可直接运行搭配数据集与模型可复现特征构建、模型训练、涨跌分类全流程竞赛截图与说明文档有助于理解LSTM在金融时序预测中的调优细节适合课程设计、毕业设计或量化竞赛参考。1. 股票涨跌预测为什么要用 LSTM 做分类而不是回归把上证 510050 的历史行情交给 LSTM最顺手的做法是让模型输出下一日涨跌幅的数值但真正能复现的项目通常不会这样做。压缩包里的 stockpredict 代码把涨跌幅先切成五档再让神经网络去拟合每一档的概率分布预测目标从连续值变成离散类别。这个转变直接影响结果股票价格序列里包含大量噪声回归模型的损失函数会被极端行情的数据点牵制最后输出总是向均值回归改成多值量化分类后模型只需要区分涨跌幅度区间对微小波动不再过度敏感。从另一个角度看这也是把一个序列预测问题重新表述成多维函数拟合问题。下面会从数据预处理、单向 LSTM 实现、训练评估一直拆到滚动回测适合正在做 LSTM 时间序列预测 Python 练手或准备数据挖掘比赛的人。2. 上证 510050 历史数据的多值量化与特征窗口构造拿到压缩包后建议先看 README 和代码目录stockpredict 目录里一般只有模型训练脚本数据需要另外准备。510050 是上证 50 对应的 ETF 代码日线数据可以从行情服务商导出也可以使用常用数据接口拉取。原始字段至少要有 date、open、high、low、close、volume、amount再按时间升序排列否则后面滑窗构造的序列顺序是乱的。2.1 特征字段与归一化处理原始行情字段可以直接作为模型输入但只有这些字段时LSTM 需要从零开始学习短期趋势。把移动平均线显式加进特征里能降低训练难度尤其在数据量不大的竞赛场景下更明显。下面是一组可用的基础特征。特征名计算方式在模型中的含义open当日开盘价隔夜信息与集合竞价的结果high当日最高价日内多方力量边界low当日最低价日内空方力量边界close当日收盘价当日最终均衡价volume当日成交量市场活跃程度amount当日成交额剔除价格后的真实交易规模ma5close 的 5 日均值短周期趋势方向ma10close 的 10 日均值中短周期趋势方向ma5 和 ma10 只由当前时间之前的数据计算不引入未来信息。把特征准备好之后接着做归一化。LSTM 内部使用 sigmoid 和 tanh 激活函数输入特征如果数值差异过大梯度更新会不稳定。常见做法是用 StandardScaler 对每一列做 z-score 标准化但有一个关键约束归一化参数只能用训练段拟合不能把验证段和测试段的统计信息混进去。import pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler df pd.read_csv(510050_daily.csv, parse_dates[date]) df df.sort_values(date).reset_index(dropTrue) df[ma5] df[close].rolling(5).mean() df[ma10] df[close].rolling(10).mean() df df.dropna().reset_index(dropTrue) feature_cols [open, high, low, close, volume, amount, ma5, ma10] split int(len(df) * 0.7) scaler StandardScaler() scaled_train scaler.fit_transform(df.loc[:split, feature_cols].values) scaled_val scaler.transform(df.loc[split:, feature_cols].values) scaled np.concatenate([scaled_train, scaled_val], axis0)这里rolling(5).mean()生成前 5 天收盘均线dropna()去掉最初没有完整均线的交易日。split按 70% 比例切分训练段fit_transform在训练段上计算每列的均值和方差transform再用同一套参数处理验证段。feature_cols 的顺序要固定后面模型输入维度就是len(feature_cols)。如果直接把全量数据拿去 fit再切训练验证验证段的均值方差会泄漏进训练过程导致回测结果虚高。2.2 涨跌幅的多值量化分类预测目标来自摘要里说的“多值量化分类”。把 pct_change 映射成五个类别大跌、小跌、震荡、小涨、大涨。510050 是宽基 ETF日涨跌很少超过 2%所以阈值要按指数型标的的实际分布设置不能照搬个股常用的涨停跌停逻辑。def quantize_return(r, low2.0, mid0.5): if r -low: return 0 # 大跌 if r -mid: return 1 # 小跌 if r mid: return 2 # 震荡 if r low: return 3 # 小涨 return 4 # 大涨 df[pct_change] df[close].pct_change() * 100 df[label] df[pct_change].apply(quantize_return) print(df[label].value_counts(normalizeTrue))low 取 2.0 表示 2%mid 取 0.5 表示 0.5%。代码先判断r -low再判断r -mid这样幅度更大的类别不会被后面的区间截获。运行value_counts(normalizeTrue)后要观察类别占比如果某一类占比不到 5%说明阈值选得不够合理需要把 low 调低或者把相邻类别合并。类别划分完成后模型输出就从单个涨跌数值变成五个类别的概率分布本质上是在拟合一个从特征窗口到多维概率向量的函数。2.3 滑动窗口构造 LSTM 输入LSTM 不能直接吃一行的独立样本它需要看到连续多天的特征变化。每个训练样本由 seq_len 天的数据快照组成标签取窗口结束后下一个交易日的量化类别。这里 seq_len 取 20对应大约一个月的交易日数量。def make_windows(scaled, labels, seq_len20): X, y [], [] for i in range(len(scaled) - seq_len): X.append(scaled[i:i seq_len]) y.append(labels[i seq_len]) return np.array(X, dtypenp.float32), np.array(y, dtypenp.int64) X, y make_windows(scaled, df[label].to_numpy(), seq_len20) print(X.shape, y.shape) # (样本数, 20, 特征数), (样本数,)scaled[i:i seq_len]截取连续 20 天特征labels[i seq_len]取下一天的标签。窗口之间是重叠的样本量约等于总天数减 20对几千条日线数据来说足够。窗口内部的行顺序不能乱第一行必须是最早那天最后一行是当前可用信息的最新一天。验证集和测试集不能整体随机采样要按时间顺序切分否则模型会提前看到未来行情比赛结果也失去意义。3. 单向 LSTM 遗忘门输入与 PyTorch 模型搭建LSTM 模型搭起来不难难的是搞清楚门控单元在做什么以及为什么这个项目必须用单向结构。网上搜 LSTM 时间序列预测 Python多数教程只给 model 定义和 loss 计算很少解释遗忘门的输入从哪来。这一章把这两件事一起说清楚。3.1 为什么用单向 LSTM遗忘门的输入到底是什么回到 LSTM 原文的定义遗忘门的作用是决定上一个时刻的记忆状态要保留多少。很多初学者以为遗忘门只接收上一时刻的隐藏状态实际上它的输入由两部分拼接而成上一时刻的隐藏状态h_{t-1}和当前时刻的输入x_t。在 PyTorch 的nn.LSTM中这一步由框架内部完成不需要手工拼向量。遗忘门的输出是一个 0 到 1 之间的向量通过 sigmoid 激活函数得到。数值接近 1 表示保留旧记忆接近 0 表示清空旧记忆。对应到股票场景里如果前面连续缩量横盘h_{t-1}携带的趋势信息和x_t的弱势行情共同作用遗忘门会把一周多前积累的上涨记忆削弱让网络更快切换到当前状态。如果换成双向 LSTM网络会在训练时把当前时刻之后的数据也编码进隐藏状态训练集指标很好看但真正部署时未来数据不存在效果会明显缩水。所以这个项目里把bidirectionalFalse固定下来默认使用单项的单向 LSTM。3.2 定义 PyTorch LSTM 模型模型结构采用 LSTM 加全连接层的方式。输入是 20 天乘 8 个特征的窗口LSTM 依次读取每个交易日的特征最后取最后一层最后一个时刻的隐藏状态通过全连接层输出 5 个类别的 logits。import torch import torch.nn as nn class StockLSTM(nn.Module): def __init__(self, input_size, hidden_size, num_layers, num_classes, dropout0.2): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalFalse, dropoutdropout if num_layers 1 else 0.0 ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_size, num_classes) def forward(self, x): out, (h_n, c_n) self.lstm(x) last_hidden h_n[-1] return self.fc(self.dropout(last_hidden))input_size 就是前文 feature_cols 的长度这里等于 8。hidden_size 取 64 时模型容量适中取太大容易在几千条日线数据上过拟合。batch_firstTrue 让输入张量形状变为 (batch_size, seq_len, input_size)。out 保存所有时间步的输出h_n 保存每一层最后时刻的隐藏状态h_n[-1]取的是最后一层最后时刻的向量它已经包含了整个序列的压缩信息。dropout 加在全连接层之前用来缓解隐藏状态过拟合。3.3 损失函数、优化器与类别不均衡处理分类任务首选交叉熵损失。但由于五档类别的样本量天然不均衡震荡类占比往往最高直接训练会让模型把所有样本都预测成震荡方向上却没有判断力。常见做法是给每个类别设置权重让少数类的错误产生更高损失。class_weights torch.tensor([1.2, 1.0, 0.7, 1.0, 1.3]) criterion nn.CrossEntropyLoss(weightclass_weights) model StockLSTM(input_size8, hidden_size64, num_layers2, num_classes5) optimizer torch.optim.Adam(model.parameters(), lr0.001) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, patience5, factor0.5 )class_weights 的序号对应大跌、小跌、震荡、小涨、大涨。震荡类权重设为 0.7极端涨跌类权重提升到 1.2 和 1.3网络会把更多注意力放在较少出现的类别上。CrossEntropyLoss 接受整数标签内部自动完成 softmax 和损失计算模型输出层不需要额外加 softmax 激活。优化器用 Adam学习率 1e-3 对日线数据的 LSTM 是一个安全起点验证 loss 震荡明显时可以把 lr 降到 3e-4。scheduler 会在验证 loss 连续 5 轮不下降时把学习率折半减少后期训练抖动。有一点要注意默认情况下 LSTM 每个 batch 的初始状态 h0 和 c0 都是全零batch 之间不保留状态。所以时间顺序必须通过滑动窗口写进样本内部这也是上一章构造窗口输入的直接原因。4. 训练、验证与涨跌方向预测评估训练流程决定了最终模型是学到的行情规律还是记住了训练集噪声。复现这个项目时我会把 batch_size、早停 patience 和阈值处理都单独抽出来调。4.1 训练循环与早停策略先把 X 和 y 包装成 TensorDataset再按时间顺序切成训练、验证、测试三段。训练 loader 可以 shuffle因为每个样本内部的时间顺序已经由滑窗固定样本与样本之间的顺序不会传入 LSTM 状态验证 loader 必须保持原始时间顺序。超参数取值batch_size64hidden_size64num_layers2dropout0.2learning_rate0.001训练循环里要区分 train 和 eval 两种模式因为 dropout 和 batch normalization 在两者的行为不同。早期停止的逻辑是记录验证 loss 的历史最小值连续多轮没有更新就提前结束训练并保存验证 loss 最低时的权重。from torch.utils.data import TensorDataset, DataLoader X_t torch.from_numpy(X) y_t torch.from_numpy(y) train_ds TensorDataset(X_t[:7000], y_t[:7000]) val_ds TensorDataset(X_t[7000:8500], y_t[7000:8500]) test_ds TensorDataset(X_t[8500:], y_t[8500:]) train_loader DataLoader(train_ds, batch_size64, shuffleTrue) val_loader DataLoader(val_ds, batch_size256, shuffleFalse) def run_epoch(loader, trainTrue): if train: model.train() else: model.eval() total_loss, total_cnt 0.0, 0 for xb, yb in loader: optimizer.zero_grad() out model(xb) loss criterion(out, yb) if train: loss.backward() optimizer.step() total_loss loss.item() * yb.size(0) total_cnt yb.size(0) return total_loss / total_cnt best_loss float(inf) patience, trigger 10, 0 for epoch in range(50): train_loss run_epoch(train_loader, trainTrue) val_loss run_epoch(val_loader, trainFalse) scheduler.step(val_loss) if val_loss best_loss: best_loss val_loss torch.save(model.state_dict(), best_stock_lstm.pt) trigger 0 else: trigger 1 if trigger patience: break这里 train_loader 的 shuffleTrue 只打乱样本顺序不打乱样本内部 20 天的顺序。DataLoader 每次返回的 xb 形状是 (batch_size, 20, 8)正好对应模型输入。run_epoch 里通过 train 参数切换 model.train() 和 model.eval()防止 dropout 在验证阶段继续随机失活。patience10 表示验证 loss 连续 10 轮不下降就停止股票序列噪声大patience 太小容易在验证 loss 暂时反弹时提前终止导致最好的权重没被保存。4.2 方向准确率、混淆矩阵与 F1评估阶段不能只看 5 分类准确率。510050 的日常波动集中在震荡区间模型如果一直输出震荡类也能拿到比较高的准确率但对真实交易没有价值。比赛截图里说的“单纯预测涨跌有比较好的效果”应该用方向准确率来验证。from sklearn.metrics import accuracy_score, confusion_matrix, classification_report model.load_state_dict(torch.load(best_stock_lstm.pt)) model.eval() pred_probs, true_labels [], [] with torch.no_grad(): for xb, yb in test_loader: out model(xb) prob torch.softmax(out, dim-1) pred_probs.extend(prob.detach().numpy()) true_labels.extend(yb.numpy()) pred_probs np.array(pred_probs) pred_classes np.argmax(pred_probs, axis1) true_labels np.array(true_labels) print(5分类准确率:, accuracy_score(true_labels, pred_classes)) print(confusion_matrix(true_labels, pred_classes))方向准确率要把震荡类别剔除后再算。把大跌和小跌当成下跌方向小涨和大涨当成上涨方向模型预测方向与真实方向一致才算对。ignore true_labels 2 dir_true (true_labels 2).astype(int) - (true_labels 2).astype(int) dir_pred (pred_classes 2).astype(int) - (pred_classes 2).astype(int) valid ~ignore dir_acc (dir_true[valid] dir_pred[valid]).mean() print(剔除震荡后的方向准确率:, round(dir_acc, 4))true_labels 2在大涨和小涨上得到 1true_labels 2在大跌和小跌上得到 1两者相减得到 -1、0、1 三值。把真实震荡样本剔除后剩下的样本才是真正考验模型涨跌判断能力的数据。这样算出来的方向准确率才和摘要里“单纯预测涨跌的情况下有比较好的预测效果”形成对应。4.3 从概率输出到置信度过滤模型输出的是类别概率而不是确定的涨跌结论。真实预测时经常出现两个类别概率接近 0.5 比 0.5 的情况直接 argmax 会引入大量不稳定判断。更实用的做法是增加一个置信度阈值当最大概率低于阈值时把预测归入震荡类。max_prob pred_probs.max(axis1) final_pred pred_classes.copy() final_pred[max_prob 0.45] 2 acc_high_conf accuracy_score( true_labels[final_pred ! 2], final_pred[final_pred ! 2] )0.45 这个阈值表示模型对涨跌方向没有明确把握时宁可输出震荡也不乱报方向。阈值调高保留的样本数量减少但每个保留样本的置信度更高。这个方法不改变模型参数只改变决策规则是数据挖掘竞赛里常见的后处理技巧。5. LSTM 时间序列预测的边界问题与一个可复现的回测技巧LSTM 不是越深越好窗口也不是越长越好。做这类时间序列预测最大的坑不是模型实现而是验证方式。5.1 序列长度与窗口重叠的取舍seq_len20 是经验值覆盖大概一个月的交易日。如果把窗口拉长到 60LSTM 需要沿着 60 个时间步传播梯度训练更慢遗忘门也要花更多精力判断两个月前的信息是否还有用。如果缩短到 5模型只能看到短期波动无法形成趋势判断。另一个容易忽略的问题是滑动窗口的重叠效应1000 天数据能生成接近 980 个 20 天窗口但这些窗口高度相关并不是 980 个独立样本。所以验证集不要随机抽样要按连续时间段切分否则评估结果会被重叠窗口放大。5.2 walk-forward 验证与滚动回测骨架单次切分只能验证一个市场环境模型可能恰好躲过某段暴涨暴跌。更接近真实操作的验证方式是 walk-forward训练窗口逐步前移每移动一段就重新训练一次模型并在下一段做预测。def walk_forward(X, y, n_train2000, n_test30, step30): acc_list [] start 0 while start n_train n_test len(X): X_train, y_train X[start:start n_train], y[start:start n_train] X_test, y_test X[start n_train:start n_train n_test], \ y[start n_train:start n_train n_test] model StockLSTM( input_sizeX.shape[-1], hidden_size64, num_layers2, num_classes5 ) train_model(model, X_train, y_train) acc evaluate_direction(model, X_test, y_test) acc_list.append(acc) start step return np.mean(acc_list)这里的 train_model 和 evaluate_direction 要封装前两章的训练和方向准确率计算逻辑。n_train2000 表示用约 8 年的日线训练n_test30 表示每次验证一个月step30 让验证段互不重叠。每次循环都必须重新创建模型不能沿用上一轮训练好的权重否则看不到模型在陌生时间段上的表现。把 walk-forward 得到的准确率均值作为最终评估指标比单次切分出来的单一数字可靠得多。最后看一眼各段准确率的波动范围如果某些时间段准确率骤降说明模型对特定行情状态仍有明显的边界问题适合再回到标签阈值和特征窗口上调整。本文还有配套的精品资源点击获取
返回列表