简介:利用相关性分析的CNN-Attention-LSTM期货价格预测完整工程,面向计算机相关专业学生毕业设计、课程设计或项目实战,解决金融时间序列多因素预测难题。项目基于Python实现,覆盖数据预处理、模型构建、训练评估与预测API等环节,包含SQL/Excel原始数据、时间步处理脚本、CNN-Attention-LSTM核心代码及训练好的模型权重,注释详尽并附PDF使用教程和热力图,方便对照理解。压缩包共29个文件,约30.28MB,含8个py源码、6个npy数据、3个xlsx表格、2个模型权重、2个pdf文档等,目录清晰,可直接运行验证。已有81人学习浏览。通过该项目可掌握相关性分析方法筛选特征、理解注意力机制与LSTM在时序预测中的协同作用,并学习完整深度学习项目组织方式,为论文写作和答辩提供有力支撑。
1. 用相关性分析给 CNN-Attention-LSTM 预测期货价格先划好数据边界
期货价格预测的难点从来不是模型跑不出来,而是喂给模型的列到底该怎么选。常见新手做法是把原油、美元指数、持仓量、资金流等几十个因子全部丢进 CNN-Attention-LSTM,训练一轮就发现训练集拟合曲线漂亮得像教科书,换到验证集却把方向全猜反。问题大多不在注意力度量失效,而在特征里混进了太多跨期信息,或者预处理时把未来统计量写进了历史样本。更隐蔽的是,直接用价格本身做特征会带来非平稳序列的伪相关,网络学到的是绝对值趋势而不是波动结构。所以先把数据边界用相关性分析划清楚,再谈模型结构才有意义。
本文将按照做期货日频或高频预测时最常见可靠的一条路线展开:用 Spearman 相关性分析筛选与下一期收益率有单调关系的因子,生成固定时间步长的滑窗样本,再用带注释的 Python 代码搭建 CNN-Attention-LSTM,最后给出滚动回测和方向准确率的验证方式。整个过程不依赖特定自建数据集,只要把数据整理成统一 DataFrame 结构就可以跑;注释会覆盖每段代码在做什么、为什么这样做、改哪个参数会影响什么。
2. Python环境准备:整理期货数据集并用 Spearman 相关性分析筛选特征
2.1 Python安装与依赖选择
在开始写模型之前,先确认 Python 解释器和 TensorFlow 版本匹配。按目前社区成熟组合,Python 3.10 搭配 TensorFlow 2.10 或 2.15 都可以,不要直接用 3.13 去跑老版本 Keras。如果机器是空白环境,先按 python 安装教程装好 3.10,然后在 vscode 里做 python 环境配置:装好 Python 扩展,用Ctrl+Shift+P打开 "Python: Select Interpreter",选中新建的 conda 环境。
conda create -n future python=3.10 -y conda activate future pip install pandas numpy matplotlib tensorflow scikit-learn openpyxl这里需要解释一下兼容性:TensorFlow 2.10 以后在 Windows 上不再支持 GPU 的原生编译,如果读者用的是 N 卡,建议把 TensorFlow 换到 Linux 环境;本机只有 CPU 时,上面的命令也能跑通但慢一些。openpyxl是为了读 xlsx 格式的行情导出文件,scikit-learn用来做归一化和时序交叉验证。所有代码尽量控制在 pandas、numpy、TensorFlow 三件套内,便于改写。
2.2 数据集字段结构和避免未来函数
为了让本文不依赖某个具体仓库,我在这里约定一个通用期货特征表结构。假设你已经把原始行情整理成如下 CSV,列名如下表所示:
| 列名 | 含义 | 说明 |
|---|---|---|
| datetime | 时间戳 | 日频数据用日期,高频数据用时间 |
| close | 收盘价 | 主力连续合约或加权合约均可 |
| volume | 成交量 | 通常取原始手数 |
| open_interest | 持仓量 | 用于观察资金进出 |
| brent_close | 外盘原油收盘价 | 可选外部因子 |
| usd_index | 美元指数 | 可选外部因子 |
| rsi14 | RSI 指标 | 由 close 计算得到 |
原始数据里极少有现成的“目标标签”,需要我们自己计算下一期收益。这里最容易踩的一个坑是未来函数:如果直接用df['close'].pct_change(),得到的是从上一期到本期已经实现的收益,用同一根 K 线的 close 去预测已实现的收益,等于把答案写在特征里。正确做法是让标签对齐到当前时间戳的“下一期收益”。
import pandas as pd df = pd.read_csv("future_daily.csv", parse_dates=["datetime"]) df = df.sort_values("datetime").reset_index(drop=True) # 先处理收益率特征,原始价格列不直接进模型 df["close_ret"] = df["close"].pct_change() df["volume_ret"] = df["volume"].pct_change() df["oi_ret"] = df["open_interest"].pct_change() # 外盘因子如果存在,同样做收益率处理 for col in ["brent_close", "usd_index"]: df[f"{col}_ret"] = df[col].pct_change() # 目标:下一期收益率,用 shift(-1) 对齐到当前行 df["target"] = df["close"].shift(-1) / df["close"] - 1.0 # 去掉因为 pct_change 和 shift 产生的 NaN df = df.replace([float("inf"), float("-inf")], float("nan")).dropna()代码里有两个关键点:pct_change()默认分母是上一期,所以close_ret当前行表示本期的涨跌幅,可以作为特征;而target用了shift(-1),让当前行的标签成为未来一期相对当前期的收益。训练时模型确实是在用 t 时刻信息预测 t+1 结果,不存在信息穿越。但需要记得,dropna()之后最后一行必然被删掉,因为该行的 target 是 NaN;真正的样本数是全表格减掉一个滑窗长度加一。
2.3 用 Spearman 相关性矩阵挑选和 target 真正相关的因子
为什么期货数据里常用 Spearman 而不是 Pearson?因为收益率的分布往往是厚尾,且很多技术指标与收益之间的关系并不是直线。Spearman 相关性分析计算的是等级相关,不要求正态分布,可以更稳健地找出“单调关系”。这一步是在为 CNN-Attention-LSTM 筛选输入特征:如果因子和目标的关系接近随机噪声,模型只能依靠记忆训练集来拟合,泛化能力会很差。
import seaborn as sns import matplotlib.pyplot as plt feature_cols = ["close_ret", "volume_ret", "oi_ret", "rsi14", "brent_close_ret", "usd_index_ret"] corr = df[feature_cols + ["target"]].corr(method="spearman") # 取出 target 与各因子之间的相关性绝对值 corr_with_target = corr["target"].drop("target").abs().sort_values(ascending=False) print(corr_with_target) # 画热力图便于观察因子之间的共线性 plt.figure(figsize=(8, 6)) sns.heatmap(corr, annot=True, cmap="coolwarm", fmt=".2f") plt.title("Spearman Correlation Matrix") plt.show()这段代码里corr(method="spearman")直接输出 Spearman 系数。注意,不要在相关系数大于 0.8 的两个因子里同时保留两个,因为 CNN 的卷积核会放大这种共线性,注意力机制也会把重复信息当作高优先级。我的经验做法是先按与 target 的相关性绝对值排序,然后逐个查看因子间相关性,如果两个因子相关系数超过 0.8,保留与 target 相关性较高的一方。通常会保留 5 到 8 个特征就足够,不需要把所有外盘数据都放进去。
2.4 构造滑窗样本和按时间顺序划分数据集
相关性分析完成后,选出的特征列需要切成长度为time_steps的三维张量,CNN 和 LSTM 才能处理。滑窗有两种常见做法:一种是直接按时间步生成连续重叠窗口,另一种是 stride 大于 1 的稀疏采样。高频数据里我一般用全重叠窗口,因为一个品种的样本量就是几千根 K 线,过度降采样会让训练样本不够。日频数据建议time_steps=20,相当于近一个月的交易日。
import numpy as np from sklearn.preprocessing import MinMaxScaler selected_features = ["close_ret", "volume_ret", "oi_ret", "rsi14", "brent_close_ret", "usd_index_ret"] time_steps = 20 X_list, y_list = [], [] data = df[selected_features + ["target"]].to_numpy(dtype=float) for i in range(time_steps, len(data)): X_list.append(data[i - time_steps:i, :len(selected_features)]) y_list.append(data[i, len(selected_features)]) X = np.array(X_list) y = np.array(y_list) # 保持时间顺序,前 80% 训练,后 10% 验证,最后 10% 作为测试 split1 = int(len(X) * 0.8) split2 = int(len(X) * 0.9) X_train, y_train = X[:split1], y[:split1] X_val, y_val = X[split1:split2], y[split1:split2] X_test, y_test = X[split2:], y[split2:] # 在训练集上拟合归一化器,再用同一变换处理验证集和测试集 scaler = MinMaxScaler() origin_shape = X_train.shape X_train_flatten = X_train.reshape(-1, origin_shape[-1]) scaler.fit(X_train_flatten) X_train = scaler.transform(X_train_flatten).reshape(origin_shape) X_val = scaler.transform(X_val.reshape(-1, origin_shape[-1])).reshape(X_val.shape) X_test = scaler.transform(X_test.reshape(-1, origin_shape[-1])).reshape(X_test.shape)这段代码在切分窗口时没有打乱时间顺序,因为金融时序一旦 shuffle,未来信息就会混进训练集。归一化同样只在训练集上做fit,验证集和测试集只是transform,这是防止数据泄漏的基本要求。很多项目喜欢用scaler.fit_transform(X)处理全部数据,看起来没毛病,但测试集的均值和最大值已经参与了缩放,预测阶段只要出现更大波动,模型就会失真。滑窗生成的X形状是(样本数, time_steps, 特征数),这个顺序在 Keras 里正好是 Conv1D 和 LSTM 期望的输入格式。
3. 搭建 CNN-Attention-LSTM 模型:带着注释走一遍关键层
3.1 按 CNN → Attention → LSTM 串接的理由
期货行情序列里,单看某一天的技术指标反应有限,真正有预测能力的是“最近几个交易日的形态组合”,比如放量滞涨、连续缩量回落、外盘和国内盘背离。CNN 的最大作用就是通过一维卷积核提取时间轴上的局部模式;卷积核宽度设为 3 时,每个局部窗口恰好覆盖三个交易日,能够捕捉短期组合信号。Attention 的作用则是为每个时间步分配权重,让模型在处理序列时自动放大与 target 相关程度更高的时刻。最后用 LSTM 建模时间依赖,因为 Attention 权重只代表当前窗口内的静态重要性,真正跨长周期的衰减记忆还是要靠门控循环单元。
需要特别说明的是,这里采用 CNN → Attention → LSTM 的顺序,是严格遵循标题里的组合方向:卷积输出先经过注意力加权,再进入 LSTM。这样 LSTM 看到的不是原始特征,而是在 CNN 和 Attention 双重过滤后对预测最有价值的时间步。如果某些实现把 Attention 放在 LSTM 之后做序列聚合,那本质上等价于“先编码后加权”,参数可以迁移但语义顺序不同,调参时不要直接照抄。
3.2 自定义 Attention 层与完整模型代码
Keras 自带的Attention层默认会把时间维度的信息求和,得到上下文向量,而我们需要保留时间步序列以便继续输入 LSTM。所以这里实现一个简化的加性注意力层,它对每个时间步计算一个标量权重,然后用权重乘以原输入,输出仍然是三维张量。
import tensorflow as tf from tensorflow.keras import layers, Model, Input from tensorflow.keras import backend as K class TimeStepAttention(layers.Layer): def __init__(self, units=32, **kwargs): super().__init__(**kwargs) self.units = units def build(self, input_shape): # input_shape: (batch, steps, features) self.W = self.add_weight( shape=(input_shape[-1], self.units), initializer='glorot_uniform', name='attention_w' ) self.b = self.add_weight( shape=(self.units,), initializer='zeros', name='attention_b' ) self.u = self.add_weight( shape=(self.units,), initializer='glorot_uniform', name='attention_u' ) def call(self, x): # 将输入投影到 units 维空间 score = K.tanh(K.dot(x, self.W) + self.b) # (batch, steps, units) score = K.sum(score * self.u, axis=-1) # (batch, steps) alpha = K.softmax(score, axis=-1) # (batch, steps) alpha = K.expand_dims(alpha, axis=-1) # (batch, steps, 1) return x * alpha下面这段代码是完整的 CNN-Attention-LSTM 模型。为了让参数好理解,我把time_steps和特征数都从数据准备阶段传进来,避免硬编码。
def build_cnn_attention_lstm(time_steps, n_features): inputs = Input(shape=(time_steps, n_features)) # CNN 模块:局部特征提取 x = layers.Conv1D(filters=64, kernel_size=3, padding='same')(inputs) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = layers.MaxPooling1D(pool_size=2)(x) # Attention 模块:对时间步加权,不改变维度 x = TimeStepAttention(units=32)(x) # LSTM 模块:捕捉序列依赖 x = layers.LSTM(units=64, dropout=0.2, return_sequences=False)(x) # 输出层:单目标,回归到下一期收益率 x = layers.Dense(16, activation='relu')(x) x = layers.Dropout(0.2)(x) outputs = layers.Dense(1)(x) model = Model(inputs, outputs) return model model = build_cnn_attention_lstm(time_steps=20, n_features=len(selected_features)) model.summary()这里几个设计点值得展开。卷积层的padding='same'保证滑窗维度不变,否则 Attention 和后续 LSTM 需要重新计算时间步长度。BatchNormalization放在卷积和激活之间,能够稳定训练过程,尤其当 RSI 这类指标和其他因子数值范围差异较大时效果明显。MaxPooling1D 的pool_size=2会将 20 个时间步压缩到 10 个,减少 LSTM 的计算量;但代价是损失部分短期连续信息,如果数据集特别小,建议去掉池化层,让时间步保持为 20。
3.3 必须说清楚的关键参数表
期货价格预测的模型复杂性控制很关键,参数不是越大越好。下表是我在日频主力合约数据上比较常用的初始参数,适合几百到几千根 K 线的规模。
| 参数 | 初始值 | 调整方向 |
|---|---|---|
| time_steps | 20 | 观察窗口短,适合捕捉短线反转;长周期持仓可调到 40 |
| filters | 64 | 卷积通道数,特征少时降到 32,特征多时提高到 128 |
| kernel_size | 3 | 1 到 2 容易高波动,5 以上偏向周度趋势 |
| attention units | 32 | 控制注意力投影空间,过大会让注意力权重过拟合 |
| LSTM units | 64 | 两层 LSTM 时第二层减半,防止过拟合 |
| dropout | 0.2 | CNN 后不设,LSTM 和最后 Dense 前各加 0.2 |
| batch_size | 32 | 高频数据用 64,日频样本少用 16 或 32 |
| learning_rate | 0.001 | Adam 优化器通常从 1e-3 开始 |
| max_epochs | 200 | 配合早停使用,不设固定 epoch |
参数调整的核心逻辑是:如果验证集中方向准确率低于 52%,先看是不是特征选择出了问题;如果训练损失远低于验证损失,再考虑减小 LSTM units 或增大 dropout。CNN 的卷积核数量对结果的影响没有 LSTM 隐层维度敏感,所以不要一上来就盲目调大 filters。
3.4 损失函数与评估指标符号
期货收益率存在明显的极端值,比如跳空高开、跌停、夜盘突发行情。直接用均方误差会让模型把注意力全放在少数极端样本上,导致普通行情预测失真。这里建议用 Huber 损失,它在误差较小时接近 MSE,误差较大时接近 MAE,能对离群值更稳健。
model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss=tf.keras.losses.Huber(delta=0.5), metrics=[tf.keras.metrics.MeanAbsoluteError()] )代码中的delta=0.5是判断误差进入线性区的阈值。如果 label 已经是小数形式的收益率,通常 0.1 到 1.0 之间比较合适;如果直接预测价格,需要把 delta 放大到几十甚至几百。这个值可以作为超参数记录在一起,便于后期回测比较。
4. 训练与回测:早停回调、滚动预测和三个高频踩坑点
4.1 训练代码和回调配置
训练阶段必须同时配置早停和学习率衰减。期货数据样本量本身不大,模型很容易在 20 个 epoch 内开始记忆训练集;早停的前提是不要关掉 shuffle 吗?注意,Keras 的fit默认会把训练数据按 batch 随机打乱,这在时序模型里本身没有问题,因为样本来自滑窗,窗口内部的时间顺序被保留了,窗口之间的随机顺序不会破坏时间因果性。但验证集不能打乱。
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau, ModelCheckpoint early_stop = EarlyStopping( monitor='val_loss', patience=10, restore_best_weights=True ) reduce_lr = ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=5, min_lr=1e-5 ) checkpoint = ModelCheckpoint( filepath='best_model.keras', monitor='val_loss', save_best_only=True ) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=200, batch_size=32, callbacks=[early_stop, reduce_lr, checkpoint], verbose=1 )EarlyStopping 的patience=10表示连续 10 个 epoch 验证损失没下降就停止训练,同时restore_best_weights=True会把模型恢复到验证集表现最好的权重,而不是停留在最后一个 epoch。ReduceLROnPlateau 在验证损失停滞 5 个 epoch 后将学习率减半,这个组合在日频期货上比固定学习率稳定很多。
4.2 用滚动预测替换一次性预测
拿到测试集后,很多人直接把整个X_test扔进model.predict,把输出和y_test对比。这种做法验证的是模型的拟合能力,但和实盘操作方式并不一致。实盘交易中,每一根新 K 线收盘后,我们只能用最新的一段时间窗口去预测下一个 bar,等新的真实数据产生后再把新窗口滚动起来。因此验证阶段也应该按时间顺序逐个预测。
predictions = [] last_window = X_test[0].copy() # 第一个预测使用测试集第一个窗口 for i in range(len(X_test)): pred = model.predict(last_window[None, :, :], verbose=0)[0, 0] predictions.append(pred) # 将当前窗口的下一真实窗口滑进来 if i < len(X_test) - 1: last_window = X_test[i + 1]注意这里没有把预测值塞回特征空间重新计算下一步,因为 LSTM 不是自回归模型,它的输入特征依赖的是真实观测值。滚动预测代码里X_test[i + 1]直接用了下一个窗口的真实特征,这等于假设每次预测都能在真实数据收盘后立刻执行,符合实盘信号产生顺序。如果要是做多步预测,可以用模型输出作为下一时间窗口的 target 更新特征,但那样误差会累积,需要单独讨论。
4.3 三个容易做错的地方
第一个坑是归一化泄漏。之前已经提到在全量数据上fit_transform,很多教程这么写,但期货数据在不同的市场状态下波动率差异极大。一旦测试集里包含了多年后的大行情极值,训练集的 min-max 范围就会失守,导致所有特征缩放后集中在 0 到 0.1 之间。保险做法是只对训练集每个特征做 min-max,存储 scaler 并在模型保存时一起打包。
第二个坑是相关性分析中混入了同步行情。比如用日频国内期货收盘价和当日 16:00 才更新的外盘原油收盘价对齐,看起来有强相关,但其实在当前 K 线未收盘前,外盘数据还没产生;真正可用的是前一日的 brent 收盘价,而不是当日。因此在 prepare 数据阶段就要用shift(1)把外盘因子滞后一天,保证所有特征在 t 时刻都是可观测的。
第三个坑是时间序列交叉验证的使用方式。K 折随机打乱会把前年的样本和去年的样本混在一起,网络会通过学习价格水平而非波动规律来预测。正确做法是使用TimeSeriesSplit,每一折都按时间顺序切分,并且需要把归一化器重新 fit 到每一折训练集上。如果只想验证最终模型,用 80/10/10 的简单顺序划分已经足够。
5. 结果验证:方向准确率比 RMSE 更容易暴露过拟合
5.1 计算方向准确率和收益相关性
回归模型的评估指标通常看 MAE、RMSE,但期货交易更关心预测方向和真实方向是否一致。一个模型可能在数值误差上表现很好,却几乎把所有涨跌方向都预测成 0 附近的小幅度波动,这种模型对交易没有帮助。方向准确率的定义是predictions * y_test > 0的比例,收益相关性则是预测值与真实收益的 Spearman 相关性。
import numpy as np from scipy.stats import spearmanr y_pred = np.array(predictions).flatten() y_true = y_test.flatten() mae = np.mean(np.abs(y_pred - y_true)) rmse = np.sqrt(np.mean((y_pred - y_true) ** 2)) direction_acc = np.mean((np.sign(y_pred) * np.sign(y_true)) > 0) corr, p_value = spearmanr(y_pred, y_true) print(f"MAE: {mae:.6f}") print(f"RMSE: {rmse:.6f}") print(f"Direction Accuracy: {direction_acc:.2%}") print(f"Spearman Correlation: {corr:.4f}")输出结果里有一点需要警惕:如果方向准确率超过 58%,而样本量只有几百,先不要高兴,去检查测试集最后一段是否出现了极端趋势行情。因为单边上涨时模型只需要预测正方向就能获得高准确率。这种虚高通常伴随着corr提高但p_value很大,说明预测值和真实收益之间的相关性并不显著。要补充一个更严格的分位检验:把预测按大小分成五组,看每组的真实收益率均值是否有单调递增趋势,这比单一相关系数更能说明排序能力。
5.2 用可视化对比预测和真实收益
验证模型的最后一步是画图,把预测曲线和真实收益率叠在一起看。不要直接画价格曲线,价格有强自相关性,预测值和真实值都会表现出高度同步,看起来像拟合得很好,实际是因为两者都继承了共同的趋势成分。画收益率对比图才看得出模型是否把握住了方向。
import matplotlib.pyplot as plt plt.figure(figsize=(12, 5)) plt.plot(y_true, label='True Return', alpha=0.8) plt.plot(y_pred, label='Pred Return', alpha=0.8) plt.axhline(y=0, color='gray', linewidth=0.8) plt.legend() plt.title("True vs Predicted Return on Test Set") plt.show()从这张图里重点找两个现象:一是真实收益峰值处预测值是不是严重钝化,如果是,说明 Huber 的 delta 设置过大,或者 LSTM 隐层容量不足;二是预测值是否几乎为零,只在零点附近抖动,这是回归模型最常见的问题,通常需要增大 batch size 或降低 dropout 来让梯度更稳定。还有一个实用技巧:把方向准确率按周维度进行滚动计算,比如每周 5 个交易日滚动求窗口内的方向一致率,如果某些连续窗口准确率低于 30%,说明模型在这段时间的预测系统性反向,可以考虑在策略层加上预测置信度过滤,而不是无条件跟随输出。
本文还有配套的精品资源,点击获取