首页 / 资讯中心 / 文章详情

LSTM收益预测系统实战:从数据构造到walk-forward验证

LSTM收益预测系统实战:从数据构造到walk-forward验证 ★ FEATURED ARTICLE
简介一套基于LSTM的时序收益预测实战代码包面向希望将深度学习用于金融数据建模的Python开发者也适合作为课程设计或毕业设计的参考资料。资源共55个文件压缩包大小2.27MB包含Python脚本、已训练模型与索引文件、npy与xlsx格式的数据集、jpg训练曲线、docx操作文档等目录按data、models、logs等模块划分便于按流程查找。其中数据预处理、模型训练、预测评估三个环节均由对应脚本实现配套操作文档与实验截图可辅助理解每一步实现。已有2911人学习浏览。通过该资源可掌握LSTM处理时间序列的完整方法包括门控机制理解、数据标准化、模型调参、MAE/RMSE评估与预测曲线可视化并借助化工、有色、贵金属等大宗商品数据在真实场景中动手实践非常适合希望快速打通时序预测与深度学习全流程的学习者。1. 收益预测不是价格预测为什么第一个 LSTM 版本总是翻车这篇代码分享要讲的是怎么把手头的行情数据变成一套能跑的基于 LSTM 的时序收益预测系统。先说结论直接把收盘价喂给 LSTM 预测明天的价格训练集上拟合得再好也说明不了问题因为价格是非平稳序列模型最后只是在复读今天的价格换到验证集上立刻现出原形。收益预测的正确对象是收益率也就是价格的变化率而不是价格本身。把目标换成收益率之后模型要学的才是一个真正的时序预测问题过去一段时间的价格形态和成交变化如何影响未来一段时间的涨跌。下面从数据构造、LSTM 模型搭建到 walk-forward 验证、常见问题排查给出一条用 Python 一步步落地的实现路径让有 pandas 基础、读过一点深度学习中英文资料的读者不靠黑匣子就能复现、调参并且能判断模型到底是真的有效还是运气好。项目仅用于技术学习与研究不构成投资建议。2. 数据构造从 K 线到监督学习样本的三个关键步骤这个系统里数据部分决定上限模型只是在拟合数据。做 LSTM 时间序列预测的第一步不是搭网络而是把 K 线整理成(样本数, 时间步, 特征数)的三维张量。这一步最容易出错的地方有两个标签方向搞反、数据集乱切。下面按三步走每一步都给出能直接跑的代码。2.1 用收益率而不是原始价格先解决非平稳问题价格序列的均值和方差随时间变化直接回归价格会让模型倾向于输出“昨天的价格”。收益率序列的均值和方差在长周期上相对稳定更适合作为监督学习的目标而且收益率是可加的多日累计收益可以直接通过对数收益求和得到。代码里一般用对数收益率log_ret log(close_t / close_{t-1})它近似连续复利收益率。shift(1)负责滞后一期的除法rolling(5).mean()构造短周期动量特征成交量比值vol_ratio用来刻画放量缩量这是收益序列之外成本最低的增量信号。import numpy as np import pandas as pd def load_kline(path: str) - pd.DataFrame: # 行情文件至少要有 date, close, volume 三列 df pd.read_csv(path, parse_dates[date]) df df.sort_values(date).reset_index(dropTrue) # 对数收益:close 除以前一日 close 再取 log df[log_ret] np.log(df[close] / df[close].shift(1)) # 5 日收益均线,衡量短周期动量 df[ret_ma5] df[log_ret].rolling(5).mean() # 当日成交量与 5 日均量的比值 df[vol_ratio] df[volume] / df[volume].rolling(5).mean() return df.dropna().reset_index(dropTrue)这段代码有三个参数值得说清楚shift(1)保证log_ret只使用当天及之前的信息rolling(5)的窗口越小对噪声越敏感越大越滞后vol_ratio分母用了 5 日均量如果数据里有停牌导致的零成交量要先做缺失值处理再跑。2.2 构造监督样本lookback 与标签方向LSTM 的输入要组织成(n_samples, seq_len, n_features)。seq_len是回看窗口长度我用 20 个交易日作为默认值大约是一个自然月horizon是预测周期默认 1 表示预测下一个交易日的收益。窗口和标签必须严格错位窗口的最后一行是第 t 天标签是第 t1 到 thorizon 天的累计对数收益这样标签里只包含未来信息不会与特征重叠。def make_windows(df: pd.DataFrame, seq_len: int 20, horizon: int 1): features [log_ret, ret_ma5, vol_ratio] X, y [], [] # 注意:reset_index 之后才能用整数位置切片 for i in range(len(df) - seq_len - horizon 1): # 输入:第 i 到 iseq_len-1 行,共 seq_len 天 X.append(df.loc[i : i seq_len - 1, features].values) # 标签:从 iseq_len 开始,累加 horizon 天的对数收益 future df[log_ret].iloc[i seq_len : i seq_len horizon] y.append(future.sum()) return np.array(X), np.array(y)关键在索引错位特征截止到i seq_len - 1标签从i seq_len开始中间没有重叠。future.sum()把多日对数收益累加当horizon1时就是单日收益。新手可以先固定seq_len20, horizon1跑通全流程再改horizon5做多日预测。2.3 数据划分时序样本禁止随机打散很多人习惯train_test_split(random_state42)随机切分这在时序任务里是禁忌。相邻交易日的样本高度相关随机切会让训练集和验证集互相“剧透”验证指标虚高。正确做法是按时间顺序切前 70% 训练、后 30% 验证更严格的做法是 walk-forward第 4 章展开讲。from sklearn.preprocessing import StandardScaler # 按时间顺序切分,不用随机打散 split int(len(X) * 0.7) X_tr, X_val X[:split], X[split:] y_tr, y_val y[:split], y[split:] # 关键:scaler 只能在训练集上 fit,验证集只 transform seq_len, n_feat X_tr.shape[1], X_tr.shape[2] scaler StandardScaler() X_tr scaler.fit_transform(X_tr.reshape(-1, n_feat)).reshape(-1, seq_len, n_feat) X_val scaler.transform(X_val.reshape(-1, n_feat)).reshape(-1, seq_len, n_feat)reshape(-1, n_feat)先把三维张量摊平成二维做标准化再还原回三维。y 不需要做任何标准化因为推理时没有真实标签可以还原后面 5.3 会专门讲这个坑。3. 搭建 LSTM 网络层数、单元数与损失函数的选型网络结构本身不是这个系统的护城河但它决定了模型能学到什么尺度的时间依赖。这一章讲清楚为什么用 LSTM、用几层、损失函数怎么选最后给出可复现的 LSTM 模型代码。3.1 LSTM 神经网络为什么适合收益序列建模LSTM 是 Hochreiter 和 Schmidhuber 在 1997 年提出的循环神经网络变体核心是三个门输入门、遗忘门、输出门。门控机制让信息可以有选择地跨时间步保留比普通 RNN 更擅长捕捉几十个交易日内的形态依赖。收益序列里一次放量上涨对后续几天的影响不是均匀衰减的门控恰好能学出这种“记住或遗忘”的节奏。但有一点要泼冷水收益序列的信噪比很低LSTM 网络不是魔法。它的作用是从特征窗口里提炼有统计意义的结构而不是记住历史价格。如果特征本身没有预测力把 LSTM 堆到三层也只是把噪声拟合得更漂亮。3.2 单层还是堆叠一上来就上三层是大忌收益预测的样本量通常只有几千到几万堆叠多层 LSTM 网络极易过拟合。我一般的做法是先用单层 LSTM、64 个单元、dropout 0.2 跑一个 baseline当验证集 IC 明显超过 0.05再尝试堆叠第二层来捕捉更抽象的特征。堆叠时第一层必须设return_sequencesTrue否则第二层拿不到完整的序列输出。调参是有几分玄学但先跑基线再动结构能把这部分玄学压到最低。下表是我常用的参数起点和调整范围一次只改一个变量改完立刻看验证集变化。参数默认值调整范围说明seq_len2010 ~ 60窗口越短越偏向高频噪声units6432 ~ 128单元数翻倍前先确认数据量够dropout0.20.1 ~ 0.5过拟合加大欠拟合减小batch_size6432 ~ 128收益序列噪声大小 batch 更震荡learning_rate0.0010.0005 ~ 0.01配合衰减回调使用horizon11 ~ 5越大标签越平滑样本噪声越低3.3 可复现的建模代码固定随机种子与 Huber 损失下面的 LSTM 实现用tf.keras写成单层网络加 dropout输出层是线性回归单元。固定随机种子这一步非常关键收益数据本身噪声大不固定种子的话同样的代码跑两次结果都不一样你根本分不清改动是有效还是随机波动。import tensorflow as tf def build_model(seq_len: int, n_feat: int) - tf.keras.Model: tf.random.set_seed(42) # 固定 TensorFlow 侧随机性 np.random.seed(42) # 配合固定 numpy 侧 model tf.keras.Sequential([ tf.keras.layers.LSTM(64, return_sequencesFalse, input_shape(seq_len, n_feat)), tf.keras.layers.Dropout(0.2), # 回归输出:线性激活,不加 sigmoid/tanh tf.keras.layers.Dense(1) ]) model.compile( optimizertf.keras.optimizers.Adam(learning_rate0.001), losstf.keras.losses.Huber() # 对极端行情不敏感 ) return modelreturn_sequencesFalse表示只输出最后一个时间步的隐藏状态接一个 Dense 就能回归如果这里改成True输出会变成(batch, seq_len, 64)Dense 层没法直接接。损失函数用 Huber 而不是 MSE因为收益序列偶尔出现极端涨跌MSE 会被这些离群点拉着走Huber 在误差大时从二次退化为线性训练过程更稳。注意tf.random.set_seed只能降低随机性不能完全消除。不同硬件、不同 TensorFlow 版本之间结果仍有细微差异这是深度学习的常态。4. 训练与验证用 walk-forward 判断模型是否真的可用单次时间切分的验证结果会被某一段特定行情左右一次定生死。walk-forward 模拟的是真实上线节奏用过去的数据训练在接下来的新数据上验证再把新数据并入训练集往前走。这一章的代码是整个系统里最接近实盘评估的部分。4.1 walk-forward模拟真实滚动重训的切分方式walk-forward 的做法是把数据切成时间上连续的若干段在第 0 段上训练在第 1 段上验证然后把第 1 段并入训练集在第 2 段上验证依次类推。第一次跑不用做太多折叠折数太多训练成本成倍上涨收益却有限。def walk_forward(X, y, first_train: int, fold_size: int, epochs: int 10): seq_len, n_feat X.shape[1], X.shape[2] preds, trues [], [] start first_train while start fold_size len(X): X_tr, y_tr X[:start], y[:start] X_te, y_te X[start:start fold_size], y[start:start fold_size] # 每个 fold 重新 fit scaler,只用当前训练段 scaler StandardScaler() X_tr scaler.fit_transform(X_tr.reshape(-1, n_feat)).reshape(-1, seq_len, n_feat) X_te scaler.transform(X_te.reshape(-1, n_feat)).reshape(-1, seq_len, n_feat) # 每次用固定结构重新训练,不继承上一轮权重 model build_model(seq_len, n_feat) model.fit(X_tr, y_tr, epochsepochs, batch_size64, verbose0) pred model.predict(X_te, verbose0).ravel() preds.append(pred) trues.append(y_te) start fold_size return np.concatenate(preds), np.concatenate(trues)first_train是第一段训练集长度一般占总样本的 60% 到 70%fold_size是每段验证长度取总样本的 10% 到 15%。每个 fold 内部重新 fit scaler这个问题在第 5 章的排查里还会重点提到。epochs10是保守值如果你加了早停回调可以适当放大到 30。4.2 三个指标IC、命中率、和 naive 基准对比收益预测的 RMSE 绝对值没有直观含义因为收益率的尺度本身随标的波动。我习惯看三个指标IC即预测值与真实值的 Pearson 相关系数命中率即预测方向与真实方向一致的比例以及和 naive 基准的对比。naive 基准用“上一期真实收益作为本期预测”它代表最简单的动量假设。如果 LSTM 连这个基准都跑不赢问题大概率在数据和特征不在网络结构。def evaluate_with_baseline(pred: np.ndarray, true: np.ndarray) - dict: ic np.corrcoef(pred, true)[0, 1] hit np.mean(np.sign(pred) np.sign(true)) # 朴素动量基准:用 true 的上一期值预测本期 base_pred np.roll(true, 1) base_ic np.corrcoef(base_pred[1:], true[1:])[0, 1] base_hit np.mean(np.sign(base_pred[1:]) np.sign(true[1:])) return { IC: ic, hit_rate: hit, baseline_IC: base_ic, baseline_hit: base_hit, }在日频收益预测里IC 超过 0.05、命中率超过 0.52 已经算不错的信号IC 超过 0.1 就要先怀疑数据泄漏而不是急着庆祝。np.roll会把数组末尾的值滚到开头所以计算基准指标时从下标 1 开始把那个伪造的边界点丢掉。4.3 训练监控早停、学习率衰减与梯度裁剪LSTM 在收益数据上收敛很快也容易过拟合。训练时我固定用三个回调EarlyStopping 在验证 loss 连续多个 epoch 不下降时恢复最优权重ReduceLROnPlateau 在验证 loss 进入平台期时把学习率减半另外在 Adam 优化器上开梯度裁剪防止个别极端样本把权重拉飞。callbacks [ tf.keras.callbacks.EarlyStopping( monitorval_loss, patience15, restore_best_weightsTrue), tf.keras.callbacks.ReduceLROnPlateau( monitorval_loss, factor0.5, patience5, min_lr1e-5), ] model.fit( X_tr, y_tr, validation_data(X_val, y_val), epochs100, batch_size64, callbackscallbacks, verbose1, )restore_best_weightsTrue确保训练结束后拿回的是验证 loss 最低时的权重而不是最后几个 epoch 的过拟合权重。梯度裁剪写进优化器里tf.keras.optimizers.Adam(learning_rate0.001, clipnorm1.0)。收益数据的尾部偶尔会出现极端值不裁剪的话一个样本就能让权重跳一大步。5. 基于 LSTM 的收益预测系统排查五个最容易翻车的细节下面五条是我从血泪经验里筛出来的高频问题每条按现象、原因、解决的顺序写。你做时序收益预测时如果指标突然变得异常好先按这个清单过一遍。5.1 验证集指标高得离谱实盘却失灵现象walk-forward 的 IC 到 0.2 以上方向命中率接近 60%看起来胜券在握一放到新行情上立刻衰竭。原因绝大多数情况是归一化泄漏。scaler 在包含验证集的全量数据上 fit验证样本的均值和标准差提前“告诉”了模型相当于开卷考试。解决所有fit_transform只发生在训练段验证段和推理段只用transform。把第 2 章的标准化代码抽成独立函数在每个 fold 内部重新 fit不要图省事用全局 scaler。5.2 随机切分数据导致验证集失真现象验证 loss 波动极大同一份数据换一个random_state结果天上地下。原因随机切分把时间上相邻的样本分到了训练集和验证集两边训练集里已经包含了验证样本的“邻居”信息通过自相关泄漏过去。收益序列的自相关虽然不强但窗口重叠导致样本间相关性很高。解决按时间顺序切片或者直接用第 4 章的walk_forward。如果必须用 scikit-learn 的train_test_split务必传shuffleFalse。5.3 标签做了标准化后无法还原现象预测值的分布和真实收益不在一个量纲上方向大体对但幅度完全对不上比如预测值集中在正负 0.05 附近真实收益动辄正负 0.03。原因对 y 也做了 z-score。训练时有真实标签可以计算均值和标准差但推理时模型只输出裸预测值没有对应的 y 分布可供还原。解决y 保持原始对数收益不做任何标准化。如果某个版本必须缩放标签就把训练集的y_mean和y_std和模型一起保存推理时手工乘回去。这个坑一旦踩进去排查成本很高最好从源头就不缩放。5.4 把价格当预测目标出现“滞后一期”的假规律现象把预测结果和真实价格画在同一张图里预测曲线像是真实价格往后平移了一天IC 还特别高。原因价格是强自相关序列模型学到的其实是p_t ≈ p_{t-1}只要复读昨天的价格就能把损失压到很低。解决把目标换成对数收益模型无法通过复读来压低损失IC 会回落到真实水平。这就是第 1 章强调的先定义任务、再调模型。判断方法很简单如果预测序列和真实价格曲线的相关性极高但预测序列一阶差分后和真实收益相关性很低基本就是复读机。5.5 行错位特征和标签用了同一根 K 线现象训练 loss 低到不合理验证集却完全失效。回查代码发现窗口最后一行和标签首日用了同一天的收盘价。原因构造窗口时索引偏了一位标签从i seq_len - 1开始而不是i seq_len。这意味着模型用当天收盘后的信息去预测当天的收益制造了未来函数。解决把每个样本的日期范围打印出来核对标准动作是检查特征最后一行日期与标签首日日期是否严格错开。# 排查标签错位的标准动作:打印前 3 个样本的日期范围 for i in range(3): feat_end df[date].iloc[i seq_len - 1] label_day df[date].iloc[i seq_len] print(f样本{i}: 特征截至 {feat_end.date()}, 标签首日 {label_day.date()}) assert label_day feat_end, 标签日期必须晚于特征截止日期只要标签首日小于等于特征截止日就是错位直接改make_windows里的索引偏移。6. 上线前最后一步多步预测、保存模型与基准对比6.1 多步预测迭代法还是直接法如果业务方要的是未来 5 天收益有两种选择。一种是用horizon1的模型滚动预测 5 次把上次输出喂回输入窗口代价是误差逐级累积到第三步基本变成噪声。另一种是直接改horizon5重新训练标签改成未来 5 天累计对数收益。我一般选直接法虽然标签更平滑、单日信号的锐度变低但误差不会在预测步之间滚动放大线上维护也简单。6.2 把模型和 scaler 绑定保存上线推理最容易出低级错误的点是只带走了模型文件忘了归一化参数。推理端输入是原始量纲和训练分布对不上预测值会整体偏移。我用SavedModel格式保存模型同时用joblib保存 scaler推理时先transform再predict。model.save(lstm_return_model.keras) joblib.dump(scaler, lstm_return_scaler.joblib) # 推理端加载 import joblib sc joblib.load(lstm_return_scaler.joblib) m tf.keras.models.load_model(lstm_return_model.keras) X_new sc.transform(X_new.reshape(-1, n_feat)).reshape(-1, seq_len, n_feat) pred m.predict(X_new, verbose0).ravel()保存的 scaler 必须是训练阶段最后 fit 的那个。如果训练时用了 walk-forward上线前用最新一段数据的统计量重新 fit 一份同口径 scaler比直接用最后一次训练 fold 里的更稳妥。6.3 上线前的最后一道体检分层收益单调性把验证集上的预测收益按从小到大分成五组用 pandas 的qcut切分再分别计算每组未来真实收益的均值。如果第一组到第五组单调递增说明模型有稳定的分层能力如果中间断层甚至倒挂说明模型只是在一两段行情里碰巧有效。这道体检不需要额外装库一张表就能说明问题。我现在的习惯是任何时序收益预测系统上线前必须先跑 naive 基准、IC 和分层检验三项都过了才轮到 LSTM 网络本身去调结构。该踩的坑我基本踩过一遍这套流程帮我拦掉了绝大多数验证集神、实盘死的模型。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站