LSTM股票预测实战:从特征工程到回测避坑与基线验证

发布时间:2026/10/2 22:45:46
LSTM股票预测实战:从特征工程到回测避坑与基线验证 简介这是一份关于LSTM神经网络股票预测算法的学术PDF论文面向关注量化投资、金融数据建模和深度学习的读者用于理解如何借助循环神经网络预测股票最高价。文中从LSTM的选择记忆机制与三门结构入手介绍了PyTorch框架下的模型搭建、数据预处理、z-score标准化、梯度爆炸应对策略并给出股票指数预测实验及拟合曲线可直接作为时序预测项目的方法参考。压缩包内共1个PDF文件大小约897KB内容为完整期刊论文包含算法实现流程、实验原理和结果分析。该资源已有294人浏览学习适合作为深度学习建模与金融预测方向的入门研读资料。1. 股票预测不是让LSTM背K线这篇研究在解决什么你可能在论文库或技术社区见过“基于LSTM神经网络的股票预测算法研究”这个标题。它是很多量化入门项目最常选的方向但真正照着标题去复现的人第一周就会碰到训练 loss 不降、预测曲线比真实行情慢半拍、回测很漂亮实盘却连续亏损。问题不在 LSTM 本身而在输入特征怎么选、标签怎么定义、训练集怎么切、评估怎么设计。LSTM 神经网络有记忆序列的能力可股票行情噪声极大算法如果不围绕“可落地的预测目标”设计模型就是个黑匣子。下面按我跑通这类研究的顺序拆解特征与窗口、训练参数、回测评估、避坑、最后用随机基线审计模型。适合复现论文的在校生、量化学徒以及所有想用时间序列模型做预测的开发者。2. 把LSTM搬进股票预测输入特征与序列窗口怎么定LSTM 的输入不是一整张行情表而是一段“按时间排序的特征切片”。很多新手只把收盘价丢进去模型自然学不到稳定规律。这个阶段要解决三件事构造哪些特征、用多长的历史窗口、预测哪个标签。它们决定了模型能力的上限后面的调参只是在逼近这个上限。2.1 特征表OHLCV之外我给模型加了哪些衍生量股票原始行情只有开高低收和成交量直接喂给 LSTM 也能跑但效果通常一般。原因有两个一是绝对价格具有非平稳性不同股票的价位区间完全不同模型很难跨样本学习二是单维价格序列携带的信息太少LSTM 找不到足够的模式。常见做法是构造一组衍生特征把绝对价格转换成相对变化量。下面是我在类似算法研究中默认使用的特征集特征计算方式设计意图日对数收益率log(close / close.shift(1))平稳化价格序列减少长期趋势干扰当日振幅(high - low) / close刻画日内波动状态日内位置(close - low) / (high - low)表示收盘价在当日区间的强弱成交量变化率volume.pct_change()捕捉放量缩量行为5日与20日均线差MA5 - MA20给模型一个短期趋势信号5日波动率近5日对数收益率标准差观察风险状态变化需要特别说明不是特征越多越好。股票数据信噪比低几十个强相关特征会把 LSTM 训练推向过拟合。我一般先构建上表约 6 到 8 个特征再观察相关性矩阵把与收盘价几乎完全线性相关的原始 close 列删掉只保留衍生量。这样既保留了 LSTM 需要的序列结构又避免了输入维度膨胀。特征确定后要在切分数据集之后再归一化。我习惯用 sklearn 的 StandardScaler对每个特征列分别做 z-score 标准化。这里的注意点是只用训练集统计量去 fit验证集和测试集只做 transform否则未来信息会泄漏进训练过程。泄漏带来的后果我会在第 5 章专门展开。2.2 窗口、标签与训练/验证划分LSTM 依赖“窗口长度”来读取历史。窗口太短模型看不到一个完整的短期形态窗口太长噪声累积反而掩盖真实规律。我跑这类股票预测研究时主要用两个档位预测未来一天的回归任务窗口取 30 个交易日预测未来五天甚至更远时窗口取 60 个交易日。30 天大约一个半月的交易数据刚好覆盖一波短期趋势也不会引入太多噪声。标签定义是第二个关键决策。论文里最常见的是回归任务用前 30 个交易日的特征预测第 31 天的收盘价。但我后来更倾向于把标签改成“未来第 1 天的收盘价相对当日收盘价的涨跌幅”或者直接做分类未来一天涨还是跌。原因后面在回测章节会讲。回归任务适合做算法研究方向判断更适合做投资决策。切分数据集时绝不能随机打乱。正确的做法是按时间顺序切成三段前 70% 训练中间 20% 验证最后 10% 作为测试集。验证集用来选早停和超参数测试集只在最终评估时碰一次。这是时间序列任务的基本纪律任何像train_test_split(X, y, shuffleTrue)的默认行为都会让股票预测变成记忆游戏。2.3 用PyTorch搭一个能跑的LSTM预测模型这里给出最精简的 LSTM 预测模型。我用 PyTorch 而不是 Keras是因为训练循环更透明调试梯度问题时能直接看到中间结果。import numpy as np import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size, hidden_size64, num_layers2, dropout0.2): super().__init__() # input_size: 每个时间步的特征数量 # hidden_size: LSTM单元数量, 太大容易过拟合 # num_layers: 叠层数, 股票场景尽量不超过2层 self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0, ) self.fc nn.Linear(hidden_size, 1) def forward(self, x): # x: (batch, seq_len, input_size) out, _ self.lstm(x) # 取最后一个时间步的输出作为序列表示 last_hidden out[:, -1, :] # 线性层输出预测值 return self.fc(last_hidden)这段代码的关键点有两个。第一batch_firstTrue让输入形状变成(batch, seq_len, features)更符合直觉也方便从 pandas 构造数据。第二LSTM 每一时间步都会输出一个隐藏状态预测用的是最后一个时间步的隐藏状态因为它理论上汇总了整段 30 日历史的信息。如果想让模型输出涨跌方向把fc的输出维度改成 2 并接一个 CrossEntropyLoss 就行。接下来是数据准备。假设已经构造好一个二维数组df_feat每一行是一个交易日的特征列最后一列是标签。用滑窗切片生成 LSTM 样本def make_sequences(df, feature_cols, label_colreturn_t1, seq_len30): data df[feature_cols [label_col]].values xs, ys [], [] for i in range(seq_len, len(data)): # 取前seq_len天的特征, 预测第i天的标签 xs.append(data[i-seq_len:i, :-1].astype(float32)) ys.append(data[i, -1].astype(float32)) return np.array(xs), np.array(ys)这里要强调一个容易写错的小细节data[i-seq_len:i]是不包括第i天的即用第i-30到第i-1天预测第i天。这样才能保证训练数据里没有“用明天预测今天”的未来函数。生成完样本后再按 7:2:1 时间顺序切分即可不需要额外处理。3. 训练LSTM股票模型的核心参数学习率、批次、隐藏单元和早停模型结构只是骨架训练参数才是真正让 LSTM 收敛的手段。推荐配置里学习率、批次大小、隐藏单元数、层数和早停都必须调过。这里不是套一个 Adam 就完事股票数据的梯度噪声非常大参数配不好训练曲线会在几个 epoch 后突然发散。3.1 学习率与优化器Adam不是万能我也会试SGDAdam 是我在这个场景下的默认优化器学习率从 1e-3 起步。但这个学习率并不总是合适特征维度多时1e-3 容易让 loss 震荡特征只用了 6 到 8 个基础量时1e-3 收敛又偏慢。我一般先用 Adam 跑 20 个 epoch观察验证集 loss 的走势如果 loss 在早期就出现尖刺就把初始学习率降到 3e-4。训练循环里还有两个股票场景下必须加的步骤梯度裁剪和验证集调度。梯度裁剪解决的是长序列上梯度爆炸的问题调度器则能在验证集 loss 进入平台期时自动降低学习率。参考代码criterion nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience5 ) for epoch in range(200): model.train() total_loss 0.0 for start in range(0, len(X_train), 64): X_batch torch.tensor(X_train[start:start64], dtypetorch.float32) y_batch torch.tensor(y_train[start:start64], dtypetorch.float32) optimizer.zero_grad() pred model(X_batch).squeeze() loss criterion(pred, y_batch) loss.backward() # 防止梯度爆炸, 原因为行情数据偶尔出现异常跳变 nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() model.eval() # 假设有一个compute_val_loss函数 val_loss compute_val_loss(model, X_val, y_val) scheduler.step(val_loss)参数说明max_norm5.0表示把所有参数的梯度模长限制在 5 以内超过则等比缩放。太大起不到保护作用太小又会让训练缓慢在 LSTM 股票预测里 5 是一个比较稳的起点。ReduceLROnPlateau的 patience 设 5表示验证集 loss 连续 5 个 epoch 不创新低就把学习率减半。这样不需要人工盯着 loss 曲线改学习率。3.2 隐藏单元数与层数通用神经网络里的“容量陷阱”LSTM 不是层数越多越聪明。股票数据的有效信号非常弱模型容量一旦超出必要范围就会把噪声当规律。我在历史项目里见过最典型的例子把num_layers从 1 提到 3训练集 loss 降得很漂亮但验证集方向准确率反而从 54% 掉到 49%这就是过拟合。我的默认配置是hidden_size64, num_layers2, dropout0.2。如果训练样本只有几千条hidden_size要降到 32如果用了大量股票做联合训练可以提升到 128。层数方面绝大多数股票预测研究用一层或两层就足够。多层的收益在金融噪声面前非常有限但参数量和训练时间会成倍增长。训练时建议固定随机种子。LSTM 权重初始化对收敛影响明显固定种子至少保证同一份代码两次运行结果一致调参时才能判断改进来自参数还是来自运气。import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42) torch.cuda.manual_seed_all(42)这段代码放在数据切分之后、模型创建之前即可。注意它不能消除所有随机性在 GPU 上某些原子操作仍会有微小差异但对复现实验已经足够。3.3 早停与模型保存用验证集而不是测试集挑模型训练 LSTM 股票模型最容易犯把测试集当“调参助手”的错。正确流程是用训练集更新权重每轮在验证集上计算 loss当验证集 loss 连续多个 epoch 不下降就停止训练并回退到历史最优参数。测试集只能最后碰一次。早停代码可以这样写best_val_loss float(inf) best_epoch 0 patience_counter 0 patience 15 for epoch in range(200): # 上面的训练循环 train_loss total_loss / len(X_train) val_loss compute_val_loss(model, X_val, y_val) if val_loss best_val_loss: best_val_loss val_loss best_epoch epoch patience_counter 0 torch.save(model.state_dict(), best_lstm.pt) else: patience_counter 1 if patience_counter patience: print(fearly stop at epoch {epoch}, best epoch {best_epoch}) breakpatience15是一个经验值太小会导致早停在训练初期太大则失去早停意义。保存时只保存state_dict不要保存整个 model 对象方便后续加载和做 walk-forward 验证。加载方式就是model.load_state_dict(torch.load(best_lstm.pt))。如果嫌手写循环麻烦建议用一个超参速查表作为默认起点参数常见范围默认值学习率1e-4 ~ 1e-21e-3batch_size32 ~ 12864hidden_size32 ~ 12864num_layers1 ~ 22dropout0.1 ~ 0.50.2seq_len20 ~ 6030patience10 ~ 2515这个表不是万能参数而是排错起点。任何一组参数被验证集证明有效之后都应该把测试集上的结果单独记录避免陷入“调参调得好看但实盘失效”的循环。4. 回测与评估别拿RMSE当交易信号训练完模型很多研究文章习惯只在测试集上报告 RMSE 或 R²。但对股票预测来说RMSE 低不等于能落地。更常见的现象是模型把昨天的价格预测成今天的价格RMSE 很小时实际策略却无法操作。所以回测与评估必须围绕“方向”和“收益”来设计。4.1 回测框架预测方向比预测价格更接近落地股票买入卖出靠的是方向判断不是精确到下一位的价格预测。一个模型预测次日收盘价为 10.01 元今日收盘 10.00 元它告诉你要涨另一个模型预测准确到 10.005 元但方向是跌那第一次操作就会亏损。因此把回归输出转为方向信号是常规做法# pred: 模型预测的下一个交易日收盘价 # close: 测试集当日真实收盘价 signal np.where(pred close, 1, 0)signal1表示次日持有或买入signal0表示空仓。这里的关键是必须使用“下一个交易日”的预测结果和“当日”价格比较不能拿当日预测当日。如果预测目标本身就是未来一天涨跌幅那 signal 逻辑变成pred 0更直接。方向信号只是第一步回测还需要一个可执行的时间线。第 T 日收盘产生信号第 T1 日开盘执行收益从第 T1 日到第 T2 日实现。这样处理是为了避免“收盘看到信号、当日按收盘价成交”这种实盘根本不存在的未来函数。4.2 评估指标用收益率、最大回撤和换手率一起看只有累计收益率的策略报告没有说服力。我通常至少记录五个指标指标定义关注原因累计收益率策略净值最后值减 1衡量总体收益年化波动率日收益标准差乘 sqrt(252)衡量风险最大回撤净值从峰值到谷底的最大跌幅决定能否拿住夏普比率策略超额收益除以波动率衡量风险调整后收益换手率调仓次数除以持仓天数估算交易成本在股票预测算法研究里夏普比率和最大回撤比 RMSE 更接近任务目标。一个累计收益很高但最大回撤超过 30% 的策略实盘很容易被震下车换手率过高则意味着手续费会吃掉大部分利润。回测时要加入单边手续费和滑点按 A 股万 2.5 佣金加千 1 印花税的简化模型单次换仓成本约 0.1% 到 0.15%。4.3 一个简单的策略回测脚本基于前面的信号向量可以写一个最简回测。假设close是测试集日收盘价数组signal是已经产生的仓位信号# 日收益: 当日close相对前日close的涨跌幅 daily_ret np.diff(close) / close[:-1] # signal[i]表示第i日持仓状态, 收益从第i日到第i1日实现 # 所以对齐时去掉signal最后一个元素 strategy_ret daily_ret * signal[:-1] # 加入换手成本: 仓位变化时才收费 turnover np.abs(np.diff(signal)) strategy_ret strategy_ret - 0.001 * turnover[:len(strategy_ret)] # 累计净值曲线 equity np.cumprod(1 strategy_ret)这段代码的逻辑说明signal[:-1]与daily_ret对齐含义是“第 i 天的持仓状态拿到第 i1 天的收益”。turnover计算仓位从 0 到 1 或 1 到 0 的次数每次扣 0.1% 交易成本。然后累计乘积就是策略净值。买入持有基准可以直接用np.cumprod(1 daily_ret)得到策略净值只有持续跑赢这个基准才有意义。回测结果如果出现累计收益率很高但最大回撤集中在几个极值日那要先检查是否有复权问题或涨跌停样本。金融数据的极端值会让 LSTM 学到“暴力模式”这种模式在实盘中往往不可交易具体排查放下一章。5. LSTM股票预测避坑指南从数据泄漏到归一化翻车这一章来自实际操作中翻过车的点。每一条都按照“现象 → 原因 → 解决”来写希望读者少走弯路。5.1 数据泄漏归一化用了全局统计量预测结果虚高现象回测净值曲线漂亮得惊人测试集方向命中率超过 80%但换一段新行情就完全失效。原因在切分训练集和测试集之前直接对整个数据集做了归一化。StandardScaler在全部数据上 fit 完后测试集的均值和方差混进了训练过程等价于让模型偷偷看到了未来数据的分布特征。这种情况在股票预测研究里非常常见。解决先按时间顺序切分再只用训练集 fit 缩放器验证集和测试集只做 transform。代码示例from sklearn.preprocessing import StandardScaler X_train_raw, X_val_raw, X_test_raw split_by_time(data) scaler StandardScaler() X_train scaler.fit_transform(X_train_raw) X_val scaler.transform(X_val_raw) X_test scaler.transform(X_test_raw)这里必须将scaler保存下来线上预测时加载同一个对象再用与训练集相同的方式处理新样本。否则未来新数据无法套用任何全局统计量。5.2 时间序列乱划分随机打乱训练集等于作弊现象测试集准确率极高训练曲线几乎无波动。仔细检查发现数据加载时用了带shuffleTrue的划分函数。原因股票相邻时间的样本高度相关。第 t 天的特征与第 t1 天的标签几乎包含了相同的信息随机打乱后训练集和验证集可能同时出现这两条样本模型实际上把邻近样本背了下来而不是学到规律。解决使用顺序切分并在切分处留出一个间隙。预测目标是未来 1 天时训练集末端与验证集起点之间至少空出 5 个交易日避免因滑窗重叠导致的标签间泄漏。代码gap 5 train_end 700 val_start train_end gap val_end 900 test_start val_end gapgap的大小要大于预测步长。如果预测未来 5 天gap至少设为 5甚至 10 更稳妥。5.3 预测值滞后LSTM把昨天的价格搬出来现象预测曲线几乎贴着真实收盘价但比真实曲线整体右移一天。RMSE 很低方向命中率却只有 50% 左右。原因干净的价格序列接近随机游走。如果模型输入包含前一日收盘价输出被训练成“尽可能接近当前价”那么最优预测就是“上一日收盘价”。LSTM 也能学会这一点所以预测曲线就像把真实价格平移了一天。解决不要直接回归绝对收盘价改为预测收益率或涨跌方向。同时必须加入一个朴素基线假设预测值等于今日收盘价。如果模型的方向命中率不能显著超过这个基线说明学到的大部分是滞后信息。以下代码可以打印基线与模型的方向命中率baseline_pred close[:-1] model_pred pred[:-1] baseline_acc np.mean((np.diff(close) 0) (np.diff(baseline_pred) 0)) model_acc np.mean((np.diff(close) 0) (np.diff(model_pred) 0))注意模型预测序列是下一日的价格所以在比较方向时也要把轴对齐。模型至少要比基线高 3 到 5 个百分点才有继续优化的价值。5.4 归一化反向转换不当序列被压缩成一条直线现象把模型输出的归一化数值还原成价格后预测序列是一条近似水平线或者范围极窄。原因训练时把特征和目标放一起归一化预测时又拿特征缩放器去还原目标。特征的均值和方差与价格标签完全不同还原后的结果自然不对。另一个常见问题是忘记保存目标缩放器直接在预测时手动减均值除方差。解决特征和目标各用独立的缩放器。目标通常是单列数组可以这样处理feature_scaler StandardScaler() target_scaler StandardScaler() X_train feature_scaler.fit_transform(X_train_raw) y_train target_scaler.fit_transform(y_train.reshape(-1, 1)).ravel() # 预测后 pred_real target_scaler.inverse_transform(pred_scaled.reshape(-1, 1)).ravel()target_scaler要和模型权重一起保存。预测阶段加载模型权重后也必须加载这个目标缩放器否则所有预测都停留在归一化空间无法投入实际使用。5.5 涨跌停与停牌未复权数据带来的假信号现象策略回测在某几天收益突然大幅拉升但复盘发现那几天对应的是涨停一字板实盘根本买不进去。原因数据没有排除涨跌停和非交易停牌样本也没有做复权处理。涨停板个股的可用流通筹码极少回测按收盘价成交是不现实的。此外未复权价格在除权除息日会出现人为下跌LSTM 会把这个“价格跳空”当成正常行情。解决统一使用前复权行情剔除停牌日。对连续一字涨停或开盘即涨停的样本把对应信号强制设为 0。简化写法# 涨停判定: 当日涨幅超过9.8%, 近似判断 df[limit_up] (df[close] df[close].shift(1) * 1.098) # 出现涨停后的交易信号不允许“当日收盘买入” tradable ~df[limit_up]还需要过滤 ST 股和上市不足半年的次新股。这类标的的涨跌幅规则与普通股不同噪音也更大会让 LSTM 的注意力被异常样本带偏。6. 落地验证的最后一公里用随机标签基线检验LSTM是否在学规律当模型在回测里跑出不错的结果别急着总结。传统论文验证方式只做训练集和测试集切分但股票模型很容易出现过拟合而不自知。一个可靠的验证方法是引入随机标签基线也就是把训练标签打乱后重训同一个模型反复多次得到一组“瞎猜”的成绩再判断真实模型是否显著优于这组成绩。6.1 随机标签基线最简单的过拟合检测随机标签不影响特征和窗口只是把标签到样本的对应关系破坏掉。如果原模型有效它会把数据中的真实规律保存下来如果模型只是记住了噪声那么在打乱标签后它仍然能从特征里“看”出一些训练样本的记忆验证集成绩也不会差太多。实现思路很直接# 对回归模型, 记录验证集方向命中率 random_scores [] for seed in range(10): np.random.seed(seed) y_train_shuffled np.random.permutation(y_train) # 用相同的超参数重新训练模型 model train_lstm(X_train, y_train_shuffled, X_val, y_val) acc direction_accuracy(model, X_val, y_val_close) random_scores.append(acc) random_mean np.mean(random_scores) real_acc direction_accuracy(best_model, X_val, y_val_close)如果real_acc比random_mean高 2 个百分点以内说明当前特征和模型并没有学到超越噪声的有效信息。我习惯把随机基线结果直接写进实验记录真实方向命中率 56%随机基线 50% ± 1%说明模型有一点信号如果真实只有 52%随机基线 50%那大概率是过拟合或数据泄漏。这个测试跑起来通常只要几十分钟比继续调参省时间。6.2 滚动前推验证更接近线上环境随机标签基线只验证了静态切分下的稳定性还需要做滚动前推验证。做法是不断把训练数据向后移动让模型在不同行情段上重复训练和测试。例如第一次用 2018 到 2020 训练2021 验证2022 测试第二次用 2018 到 2021 训练2022 验证2023 测试如此类推。每个滚动窗口都记录方向命中率和随机基线得分。如果某个窗口内真实模型不能稳定超过随机基线说明模型对市场风格变化敏感研究结论需要谨慎。最后说一句大实话我每次报告实验结果都会附上随机标签基线否则再漂亮的收益曲线都经不起追问。多写这十几行验证逻辑能替你省下无数次自我感动式的调参。希望帮到你。本文还有配套的精品资源点击获取