☰
LSTM股票预测实战:从特征工程到回测避坑与基线验证
2026/10/2 22:45:41 网站建设 项目流程

简介:这是一份关于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, shuffle=True)的默认行为都会让股票预测变成记忆游戏。

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_size=64, num_layers=2, dropout=0.2): super().__init__() # input_size: 每个时间步的特征数量 # hidden_size: LSTM单元数量, 太大容易过拟合 # num_layers: 叠层数, 股票场景尽量不超过2层 self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout 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_first=True让输入形状变成(batch, seq_len, features),更符合直觉,也方便从 pandas 构造数据。第二,LSTM 每一时间步都会输出一个隐藏状态,预测用的是最后一个时间步的隐藏状态,因为它理论上汇总了整段 30 日历史的信息。如果想让模型输出涨跌方向,把fc的输出维度改成 2 并接一个 CrossEntropyLoss 就行。

接下来是数据准备。假设已经构造好一个二维数组df_feat,每一行是一个交易日的特征列,最后一列是标签。用滑窗切片生成 LSTM 样本:

def make_sequences(df, feature_cols, label_col='return_t1', seq_len=30): 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不是万能,我也会试SGD

Adam 是我在这个场景下的默认优化器,学习率从 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(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5 ) 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:start+64], dtype=torch.float32) y_batch = torch.tensor(y_train[start:start+64], dtype=torch.float32) optimizer.zero_grad() pred = model(X_batch).squeeze() loss = criterion(pred, y_batch) loss.backward() # 防止梯度爆炸, 原因为行情数据偶尔出现异常跳变 nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.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_norm=5.0表示把所有参数的梯度模长限制在 5 以内,超过则等比缩放。太大起不到保护作用,太小又会让训练缓慢,在 LSTM 股票预测里 5 是一个比较稳的起点。ReduceLROnPlateau的 patience 设 5,表示验证集 loss 连续 5 个 epoch 不创新低,就把学习率减半。这样不需要人工盯着 loss 曲线改学习率。

3.2 隐藏单元数与层数:通用神经网络里的“容量陷阱”

LSTM 不是层数越多越聪明。股票数据的有效信号非常弱,模型容量一旦超出必要范围,就会把噪声当规律。我在历史项目里见过最典型的例子:把num_layers从 1 提到 3,训练集 loss 降得很漂亮,但验证集方向准确率反而从 54% 掉到 49%,这就是过拟合。

我的默认配置是:hidden_size=64, num_layers=2, dropout=0.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(f'early stop at epoch {epoch}, best epoch {best_epoch}') break

patience=15是一个经验值,太小会导致早停在训练初期,太大则失去早停意义。保存时只保存state_dict,不要保存整个 model 对象,方便后续加载和做 walk-forward 验证。加载方式就是model.load_state_dict(torch.load('best_lstm.pt'))。

如果嫌手写循环麻烦,建议用一个超参速查表作为默认起点:

参数常见范围默认值
学习率1e-4 ~ 1e-21e-3
batch_size32 ~ 12864
hidden_size32 ~ 12864
num_layers1 ~ 22
dropout0.1 ~ 0.50.2
seq_len20 ~ 6030
patience10 ~ 2515

这个表不是万能参数,而是排错起点。任何一组参数被验证集证明有效之后,都应该把测试集上的结果单独记录,避免陷入“调参调得好看但实盘失效”的循环。

4. 回测与评估:别拿RMSE当交易信号

训练完模型,很多研究文章习惯只在测试集上报告 RMSE 或 R²。但对股票预测来说,RMSE 低不等于能落地。更常见的现象是:模型把昨天的价格预测成今天的价格,RMSE 很小时,实际策略却无法操作。所以回测与评估必须围绕“方向”和“收益”来设计。

4.1 回测框架:预测方向比预测价格更接近落地

股票买入卖出靠的是方向判断,不是精确到下一位的价格预测。一个模型预测次日收盘价为 10.01 元,今日收盘 10.00 元,它告诉你要涨;另一个模型预测准确到 10.005 元,但方向是跌,那第一次操作就会亏损。因此,把回归输出转为方向信号是常规做法:

# pred: 模型预测的下一个交易日收盘价 # close: 测试集当日真实收盘价 signal = np.where(pred > close, 1, 0)

signal=1表示次日持有或买入,signal=0表示空仓。这里的关键是必须使用“下一个交易日”的预测结果和“当日”价格比较,不能拿当日预测当日。如果预测目标本身就是未来一天涨跌幅,那 signal 逻辑变成pred > 0,更直接。

方向信号只是第一步,回测还需要一个可执行的时间线。第 T 日收盘产生信号,第 T+1 日开盘执行,收益从第 T+1 日到第 T+2 日实现。这样处理是为了避免“收盘看到信号、当日按收盘价成交”这种实盘根本不存在的未来函数。

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日到第i+1日实现 # 所以对齐时去掉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 天的持仓状态拿到第 i+1 天的收益”。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 时间序列乱划分:随机打乱训练集等于作弊

现象:测试集准确率极高,训练曲线几乎无波动。仔细检查发现数据加载时用了带shuffle=True的划分函数。

原因:股票相邻时间的样本高度相关。第 t 天的特征与第 t+1 天的标签几乎包含了相同的信息,随机打乱后,训练集和验证集可能同时出现这两条样本,模型实际上把邻近样本背了下来,而不是学到规律。

解决:使用顺序切分,并在切分处留出一个间隙。预测目标是未来 1 天时,训练集末端与验证集起点之间至少空出 5 个交易日,避免因滑窗重叠导致的标签间泄漏。代码:

gap = 5 train_end = 700 val_start = train_end + gap val_end = 900 test_start = val_end + gap

gap的大小要大于预测步长。如果预测未来 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 测试,如此类推。

每个滚动窗口都记录方向命中率和随机基线得分。如果某个窗口内真实模型不能稳定超过随机基线,说明模型对市场风格变化敏感,研究结论需要谨慎。最后说一句大实话:我每次报告实验结果都会附上随机标签基线,否则再漂亮的收益曲线都经不起追问。多写这十几行验证逻辑,能替你省下无数次自我感动式的调参。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询