简介:一套基于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(drop=True) # 对数收益: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(drop=True)这段代码有三个参数值得说清楚: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 天,标签是第 t+1 到 t+horizon 天的累计对数收益,这样标签里只包含未来信息,不会与特征重叠。
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 到 i+seq_len-1 行,共 seq_len 天 X.append(df.loc[i : i + seq_len - 1, features].values) # 标签:从 i+seq_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()把多日对数收益累加,当horizon=1时就是单日收益。新手可以先固定seq_len=20, horizon=1跑通全流程,再改horizon=5做多日预测。
2.3 数据划分:时序样本禁止随机打散
很多人习惯train_test_split(random_state=42)随机切分,这在时序任务里是禁忌。相邻交易日的样本高度相关,随机切会让训练集和验证集互相“剧透”,验证指标虚高。正确做法是按时间顺序切:前 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_sequences=True,否则第二层拿不到完整的序列输出。
调参是有几分玄学,但先跑基线再动结构能把这部分玄学压到最低。下表是我常用的参数起点和调整范围,一次只改一个变量,改完立刻看验证集变化。
| 参数 | 默认值 | 调整范围 | 说明 |
|---|---|---|---|
seq_len | 20 | 10 ~ 60 | 窗口越短越偏向高频噪声 |
units | 64 | 32 ~ 128 | 单元数翻倍前先确认数据量够 |
dropout | 0.2 | 0.1 ~ 0.5 | 过拟合加大,欠拟合减小 |
batch_size | 64 | 32 ~ 128 | 收益序列噪声大,小 batch 更震荡 |
learning_rate | 0.001 | 0.0005 ~ 0.01 | 配合衰减回调使用 |
horizon | 1 | 1 ~ 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_sequences=False, input_shape=(seq_len, n_feat)), tf.keras.layers.Dropout(0.2), # 回归输出:线性激活,不加 sigmoid/tanh tf.keras.layers.Dense(1) ]) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss=tf.keras.losses.Huber() # 对极端行情不敏感 ) return modelreturn_sequences=False表示只输出最后一个时间步的隐藏状态,接一个 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, epochs=epochs, batch_size=64, verbose=0) pred = model.predict(X_te, verbose=0).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 章的排查里还会重点提到。epochs=10是保守值,如果你加了早停回调,可以适当放大到 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( monitor="val_loss", patience=15, restore_best_weights=True), tf.keras.callbacks.ReduceLROnPlateau( monitor="val_loss", factor=0.5, patience=5, min_lr=1e-5), ] model.fit( X_tr, y_tr, validation_data=(X_val, y_val), epochs=100, batch_size=64, callbacks=callbacks, verbose=1, )restore_best_weights=True确保训练结束后拿回的是验证 loss 最低时的权重,而不是最后几个 epoch 的过拟合权重。梯度裁剪写进优化器里:tf.keras.optimizers.Adam(learning_rate=0.001, clipnorm=1.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,务必传shuffle=False。
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 天收益,有两种选择。一种是用horizon=1的模型滚动预测 5 次,把上次输出喂回输入窗口,代价是误差逐级累积,到第三步基本变成噪声。另一种是直接改horizon=5重新训练,标签改成未来 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, verbose=0).ravel()保存的 scaler 必须是训练阶段最后 fit 的那个。如果训练时用了 walk-forward,上线前用最新一段数据的统计量重新 fit 一份同口径 scaler,比直接用最后一次训练 fold 里的更稳妥。
6.3 上线前的最后一道体检:分层收益单调性
把验证集上的预测收益按从小到大分成五组,用 pandas 的qcut切分,再分别计算每组未来真实收益的均值。如果第一组到第五组单调递增,说明模型有稳定的分层能力;如果中间断层甚至倒挂,说明模型只是在一两段行情里碰巧有效。这道体检不需要额外装库,一张表就能说明问题。
我现在的习惯是,任何时序收益预测系统上线前,必须先跑 naive 基准、IC 和分层检验,三项都过了才轮到 LSTM 网络本身去调结构。该踩的坑我基本踩过一遍,这套流程帮我拦掉了绝大多数验证集神、实盘死的模型。希望帮到你。
本文还有配套的精品资源,点击获取