ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

CNN-Attention-LSTM期货价格预测:从相关性分析到注意力可视化

CNN-Attention-LSTM期货价格预测:从相关性分析到注意力可视化 简介面向期货价格预测的CNN-Attention-LSTM深度学习项目整合相关性分析、时间步处理与注意力机制适合深度学习、人工智能方向的毕业设计或课程设计。压缩包共29个文件数据与代码分层清晰8个Python脚本覆盖模型构建、训练、预测及API服务6个npy文件保存处理后的训练/测试特征与预测结果3个xlsx表包含原始数据、相关性分析用表及处理后数据2个ckpt权重文件对应不同版本模型另有SQL备份、PDF教程和README说明整体约30.29MB便于快速复现。当前已有808人学习下载。资源内模型代码注释详细附玉米期货周报数据、热力图和Web前端配置教程可帮助理解特征相关性筛选与注意力机制在价格预测中的实际用法独立算法文件、预测脚本和API接口还可迁移到其他期货品种或行情数据中从数据清洗、特征构造到模型训练与接口封装均形成完整链路适合作为时序预测项目的系统性参考。1. 相关性分析先行CNN-Attention-LSTM期货价格预测的完整链路期货价格预测最容易犯的错误不是模型选得不对而是把几十个指标一股脑喂进LSTM指望深度学习自己找出规律。结果往往是训练集上拟合得很漂亮验证集上方向准确率还不如昨天的收盘价。真正决定模型上限的是“数据集里有没有携带预测信息”和“序列结构有没有被正确建模”这两件事。这个标题把“相关性分析”放在CNN-Attention-LSTM前面恰好点出了正确顺序先用统计方法筛选出与未来价格变动相关的因子再用CNN提取局部形态、Attention定位关键时间窗口、LSTM捕捉先后依赖。它适合做量化因子研究、时序预测模型落地以及想把自己训练的模型跑通完整流程的Python开发者。下文从数据预处理讲到训练调试给出可复现的命令和参数。2. 数据准备把原始行情整理成CNN-Attention-LSTM能吃的训练样本在搭模型之前先把原始数据整理成样本。这部分是整条链路里最容易复制错误的环节比如未来数据泄漏、因子单位不统一、验证集被随机切分。常见的数据集格式是一张包含时间戳、OHLCV和若干技术因子的CSV表源码里的数据加载部分一般要求至少包含date、open、high、low、close、volume六个字段。我习惯先做一次相关性筛选把特征数量从几十压到十以内再滑窗成样本数序列长度特征数的张量。2.1 Spearman相关性分析特征筛选用秩相关而不是皮尔逊期货收益率和成交量、持仓量等因子的关系通常不是线性的而且存在厚尾和极端值。Pearson对异常值非常敏感一个极端行情就能让相关系数失真。Spearman相关性分析统计的是秩次相关性对单调关系更稳健这也是量化研究里常把Spearman作为因子筛选第一道关口的原因。计算时用scipy.stats.spearmanrpandas的corr方法指定methodspearman也可以但前者能同时返回p值方便过滤不显著的因子。import pandas as pd from scipy.stats import spearmanr df pd.read_csv(data/futures_5min.csv, parse_dates[datetime]) df[ret] df[close].pct_change() features [open, high, low, volume, oi, return_5, bid_ask_gap] target df[ret].shift(-1) # 下一期收益率作为预测目标 selected [] for col in features: corr, p spearmanr(df[col].iloc[:-1], target.dropna()) if abs(corr) 0.03 and p 0.01: selected.append(col) print(selected)这里先把目标列shift(-1)表示用当前时刻的特征预测下一根K线的收益率这是时序预测里最基础也最关键的一步。corr和p的阈值需要根据品种和K线周期调整5分钟级别0.03的相关系数已经算有价值日线级别可以放宽到0.02。p0.01用来排除随机波动造成的假相关。这种筛选方式的问题是只考虑了单因子与目标的边际关系没有处理因子之间的共线性所以在进入模型之前还可以再跑一遍VIF或者直接用模型的注意力权重做第二轮筛选。2.2 滑窗构造序列长度、步长和特征张量化相关性筛选留下特征后就要把它们组织成定长的时序窗口。窗口长度直接影响模型能看到的“记忆范围”常见做法是对训练集做自相关分析观察偏自相关函数衰减到0的阶数或者直接按业务经验选日线用60个交易日5分钟线用120根。步长设置成1表示每根K线都产生一个训练样本样本量足够但相邻样本高度重叠容易让模型把“记住上一根K线”当成“预测成功”步长设成窗口长度的一半可以缓解这个问题。import numpy as np def create_sequences(data, features, window120, step5): X, y [], [] for i in range(0, len(data) - window, step): x data[features].iloc[i:iwindow].values label data[target].iloc[iwindow] X.append(x) y.append(label) return np.array(X, dtypenp.float32), np.array(y, dtypenp.float32) X, y create_sequences(df.dropna(), selected [target]) print(X.shape, y.shape)函数返回的X是样本数window特征数的浮点数组这个形状决定了后面模型的输入层怎么定义。step5意味着每隔5根K线采样一个窗口样本之间的重叠度大约为96%相比step1能够减少计算量且不会明显损失精度。y取的是窗口结束时刻的下一期收益率也就是滑窗要预测的对象。如果你的任务目标是涨跌分类可以在这一步把y改写成np.where(y 0, 1, 0)再交给模型。2.3 数据集划分的时序陷阱与归一化时序数据不能像图像一样随机打乱后划分。假设用第1000根K线之前的样本做训练但验证集随机抽到了第500根模型在训练阶段就已经“见过”验证集附近的行情形态这种信息泄漏会让验证指标虚高。正确顺序是按时间切分前70%训练、中间15%验证、最后15%测试并且验证集和测试集从窗口边界处断开。train_end int(len(X) * 0.7) val_end int(len(X) * 0.85) X_train, y_train X[:train_end], y[:train_end] X_val, y_val X[train_end:val_end], y[train_end:val_end] X_test, y_test X[val_end:], y[val_end:] mu np.mean(X_train, axis(0, 1), keepdimsTrue) sigma np.std(X_train, axis(0, 1), keepdimsTrue) X_train (X_train - mu) / (sigma 1e-6) X_val (X_val - mu) / (sigma 1e-6) X_test (X_test - mu) / (sigma 1e-6)归一化统计量必须只用训练集计算验证集和测试集直接用同一组mu和sigma做变换不能重新统计否则验证集的信息会通过均值渗透进训练过程。sigma加1e-6是为了防止常量特征除零。这里使用的是z-score归一化对成交量这类有量纲的因子比min-max缩放更合适因为后续模型里各种因子要通过同一个卷积核做加权量纲不一致会让卷积核的前几个梯度全部被大数值特征主导。3. CNN特征提取用一维卷积在相关性筛选后的特征序列上找局部形态把数据张量准备好之后先进入CNN部分。很多人会问时序预测为什么要先用CNN直接上LSTM不行吗LSTM擅长捕捉长距离时序依赖但期货数据里的短期形态比如连续放量突破、连续三根阴线后的反弹本质上是局部模式LSTM需要训练到足够深的门控权重才能记住这种模式而且记住的代价是参数变多、训练变慢。一维CNN用一个小卷积核就能覆盖连续5根K线的模式网络直接在局部感受野里提取特征计算效率比LSTM高一个量级。3.1 Conv1d的输入形状与感受野设计PyTorch里nn.Conv1d要求输入形状是batch, channel, length和我们的训练样本形状batch, length, feature不一样所以要先做permute换轴。这里的channel可以理解成经过相关性分析筛选后的特征数量length对应时间步窗口长度。卷积核大小一般取5到9对应5到9根K线。之所以不用更大的核是因为第一层卷积感受野太大时会把行情趋势直接当成噪声网络学到的模式变得宏观且难以迁移换一个品种就要重新训练。import torch.nn as nn class CNNBlock(nn.Module): def __init__(self, n_features, n_filters64, kernel_size7): super().__init__() self.conv1 nn.Conv1d(n_features, n_filters, kernel_size, paddingkernel_size // 2) self.bn1 nn.BatchNorm1d(n_filters) self.conv2 nn.Conv1d(n_filters, n_filters, kernel_size, paddingkernel_size // 2) self.bn2 nn.BatchNorm1d(n_filters) self.pool nn.MaxPool1d(2) def forward(self, x): # x: (batch, feature, length) x torch.relu(self.bn1(self.conv1(x))) x torch.relu(self.bn2(self.conv2(x))) x self.pool(x) return xpaddingkernel_size // 2可以让卷积输出长度和输入保持一致这样多层卷积串联时序列信息不会逐层收缩。但注意MaxPool1d(2)会把序列长度减半如果原始窗口是120根K线经过一层池化变成60两层就变成30。在设计网络时要把压缩后的序列长度记下来因为它就是后面LSTM实际看到的时序长度。如果窗口太短比如只有32根K线就不建议加两层池化否则LSTM拿到的序列过短Attention没有足够的“关键位置”可以选择。3.2 卷积核数量与残差连接的实践经验卷积核数量从32到128是一个比较安全的区间。第一层用64第二层用64或者128。更宽的卷积核意味着更多参数期货数据集的样本量通常只有几万到几十万条过宽会让验证集上的表现迅速恶化。我一般会在每层卷积后面接BatchNorm和ReLU并把CNNBlock设计成可以重复调用的结构。如果网络加深到三层以上残差连接的作用会比较明显原因是BatchNorm在小batch size下不稳定梯度经过多层非线性后容易出现退化。class CNNFeatureExtractor(nn.Module): def __init__(self, n_features, n_filters64, kernel_size7): super().__init__() self.block CNNBlock(n_features, n_filters, kernel_size) def forward(self, x): # 输入 x: (batch, length, feature) x x.permute(0, 2, 1) # 转为 (batch, feature, length) x self.block(x) x x.permute(0, 2, 1) # 转回 (batch, length, feature) return x这个封装的核心价值是把轴序转换收拢在一个模块里模型中其他地方只需要记住“CNN的输出形状是batch, length_after_pool, n_filters”这一条约定。length_after_pool等于原始窗口长度除以2的池化次数。后续接Attention或LSTM时可以直接把CNN输出当成新的序列特征每个时间步对应原始几根K线的压缩表示。3.3 CNN输出与Attention衔接时的维度变化这里容易踩的坑是不做permute就直接丢进nn.MultiheadAttention。PyTorch的MultiheadAttention官方实现默认输入是L, N, E也就是序列长度在前和CNN输出的N, L, E顺序恰好相反。虽然新版PyTorch里能通过batch_first参数调整但如果不显式指定模型内部会偷偷转置等你要把注意力权重提取出来可视化时就会发现形状对不上。# CNN 输出形状: (batch, length_after_pool, n_filters) cnn_out cnn_block(x) # (B, 60, 64) attn_input cnn_out.permute(1, 0, 2) # (60, B, 64) 适配 MultiheadAttention attn_out, attn_weights nn.MultiheadAttention( embed_dim64, num_heads4, batch_firstFalse )(attn_input, attn_input, attn_input)这里attn_out的形状和attn_input一致依然是60, B, 64在进入LSTM之前要再permute回B, 60, 64。attn_weights的形状是B, num_heads, 60, 60第2个维度是注意力头编号后面做可视化时看的是第0个头或者对4个头取平均。如果不是为了提取权重做分析直接使用batch_firstTrue可以让两个permute都省掉但可视化时还是要先把权重转置回来。4. Attention与LSTM融合给CNN特征序列加上时间权重与先后依赖Attention在这里的作用是回答“哪段K线历史对下一根K线的收益率更重要”。LSTM顺着时间方向逐个读取序列最后几步的状态往往覆盖了早期信息但也可能把关键信号冲淡。Attention机制则显式地为每个时间步计算一个权重把序列压缩成加权和。在期货行情里这个设计比较直观的含义是模型可能学习到开盘后第30根K线的某个形态最重要而不是机械地把最近一根K线当作决定性输入。4.1 注意力权重的计算方式与缩放点积常见的注意力打分函数有加性注意力和点积注意力两种。点积注意力实现简单、在Transformer生态里被验证得最多计算方式是query和key做内积后除以根号d_k再做softmax。在CNN-Attention-LSTM的语境里query和key都来自同一段序列所以是自注意力。d_k是每个注意力头的维度除以根号d_k是为了防止内积结果过大导致softmax进入饱和区梯度接近于零。import torch import torch.nn.functional as F def scaled_dot_product_attention(query, key, value, maskNone): d_k query.size(-1) scores torch.matmul(query, key.transpose(-2, -1)) / torch.sqrt(torch.tensor(d_k, dtypetorch.float32)) if mask is not None: scores scores.masked_fill(mask 0, -1e9) weights F.softmax(scores, dim-1) return torch.matmul(weights, value), weightsmask参数在训练时通常用来遮住未来时间步防止模型“偷看”后面的K线。如果整个序列长度相同不使用mask也能训练出正常结果因为时间步之间的依赖是允许双向的。但如果你做的是逐点预测即每个时间点都要预测下一个时间点那就必须用因果mask否则Attention会把未来的价格信息混进当前时刻的表征这种泄漏会比普通特征泄漏更难察觉因为训练损失依然在下降。4.2 两种LSTM融合方式的取舍融合顺序有两种常见设计。第一种是“先Attention后LSTM”对CNN输出的每个时间步加权后再喂给LSTM这样LSTM只处理经过加权强调的序列减少了它需要建模的信息量第二种是“先LSTM后Attention”让LSTM先跑完整个序列再用注意力权重把LSTM所有时间步的隐状态加权成一个上下文向量。两种在公开实现里都能看到区别在于前者更容易可视化每个时间步的贡献后者和机器翻译里的Bahdanau attention更接近上下文向量的维度等于LSTM隐层维度后续接全连接时更顺。class AttentionLSTM(nn.Module): def __init__(self, input_dim, hidden_dim128, num_layers2, dropout0.2): super().__init__() self.lstm nn.LSTM(input_dim, hidden_dim, num_layers, batch_firstTrue, dropoutdropout) self.attn nn.Linear(hidden_dim, 1) def forward(self, x): lstm_out, _ self.lstm(x) # (B, T, hidden_dim) scores self.attn(lstm_out).squeeze(-1) # (B, T) weights torch.softmax(scores, dim1) context torch.bmm(weights.unsqueeze(1), lstm_out).squeeze(1) return context, weights这里用的是最轻量的加性注意力变体把隐状态送进一个线性层得到标量分数然后对时间维做softmax最后用bmm做加权求和。上下文向量shape是(B, hidden_dim)可以直接接全连接层输出预测值。weights是(B, T)每个样本对应一组归一化的时间权重后续可视化时直接取weights[0]就是第一条样本各时间步的重要性。这种实现比MultiheadAttention参数少很多在几万条样本的期货数据集上不容易过拟合。4.3 LSTM隐层维度、层数与正则化的参数选择隐层维度不是越大越好。日线级别预测64就够分钟级别128或256更能容纳高频模式但训练时间会显著增加。LSTM层数建议不超过3层超过之后梯度在时间维度上反复相乘容易出现梯度消失或爆炸。这里的一个隐含问题是LSTM需要处理的时间步已经被CNN池化过长度可能只有30到60两层LSTM足以捕捉这些压缩步骤之间的依赖。model AttentionLSTM(input_dim64, hidden_dim128, num_layers2, dropout0.2) criterion nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(30): model.train() optimizer.zero_grad() pred, weights model(cnn_out) loss criterion(pred.squeeze(1), y_batch) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step()训练时特别要关注nn.utils.clip_grad_norm_这一行。LSTM反向传播经过的时间步多梯度范数经常大得离谱不裁剪时Adam可能在某个batch之后突然失控表现为loss跳到nan。max_norm从1.0试到5.0都可以太小会让收敛变慢太大起不到保护作用。lr1e-3是Adam在时序预测里的常见起点如果验证loss震荡明显说明学习率偏高降到5e-4再重跑。5. 用Python源码跑通模型从目录结构到训练参数调优前面的章节把模型结构拆开了下面把它们组装成一套能直接训练的源码。拿到这类带数据集的工程时仓库里通常会有data目录放CSVmodels目录放网络定义train.py负责训练evaluate.py负责回测。训练前应该先检查数据和模型是否匹配相关性分析的字段是否都存在于CSV表头序列窗口的长度是奇数还是偶数因为maxpool会把长度除以2。5.1 最小目录结构与训练命令以下是一种标准组织方式好处是训练、评估和可视化各自独立不需要为了画一个图而重新训练一次。project/ ├── data/ │ └── rb_5min.csv # 期货5分钟数据集 ├── config.py # 所有超参数集中管理 ├── dataset.py # 读取CSV、相关性分析、滑窗 ├── model.py # CNN-Attention-LSTM定义 ├── train.py # 模型训练入口 └── evaluate.py # 在测试集上评估并画注意力热力图 python train.py --data data/rb_5min.csv --window 120 --epochs 50 --lr 1e-3config.py里一般存哪些内容一定要包括窗口长度window、训练轮数epochs、学习率lr、batch_size、LSTM隐层维度hidden_dim、注意力头数n_heads、特征列表features。把超参集中在config而不是散落在训练脚本里是因为调参时经常要同时改窗口长度和池化层数这两个参数组合错了会导致维度对不上。# config.py 的核心配置 WINDOW 120 BATCH_SIZE 256 EPOCHS 50 LR 1e-3 FEATURES [open, high, low, close, volume, oi] TARGET forward_ret HIDDEN_DIM 128 NUM_LAYERS 2 DROP_PROB 0.2参数名里的TARGET表示预测目标可以改成“next_ret”或者“direction”只要dataset.py里用它去构造y就行。BATCH_SIZE在期货分钟数据上通常可以设到256或512显存不是瓶颈过大的batch_size会让Adam的梯度过于平滑导致模型倾向于预测均值而忽略极端行情所以我一般不会超过512。以下是我在两种常见周期下的参数起点可以直接套用再按验证集表现微调参数日线窗口605分钟线窗口120n_filters64128kernel_size75hidden_dim64128num_layers22batch_size128256lr5e-41e-3kernel_size在日线用7意味着看一周半的交易形态在5分钟线用5是因为高频形态更短太大的核会把日内噪声包进来。n_filters和hidden_dim在分钟级别可以放大到128因为样本量通常足以支撑更多参数。5.2 训练过程与验证集方向准确率回归任务和分类任务的评估方式不同。预测收益率时用MSE或Huber作为主损失但这不能直接反映交易效果。更实用的评估是指示方向准确率direction accuracy也就是预测收益率的符号与真实收益率符号一致的比例。能够达到52%到55%已经说明模型学到了有效信号低于50%说明模型还在用昨天的方法拟合噪声。import numpy as np def direction_accuracy(y_true, y_pred): true_sign np.sign(y_true) pred_sign np.sign(y_pred) correct (true_sign pred_sign).mean() return correct y_true y_test.numpy() y_pred model(X_test).detach().numpy() print(RMSE:, np.sqrt(np.mean((y_true - y_pred) ** 2))) print(Direction Acc:, direction_accuracy(y_true, y_pred))注意sign函数会把0视为0而收益率恰好等于0的样本极少因此不会对统计结果产生明显影响。如果预测结果大量接近于0说明模型退化成了“平均预测器”这时可以改用HuberLoss它对离群点更宽容也能让模型敢于输出较大数值。另一方面RMSE数值本身依赖数据的量纲如果训练前用了z-score归一化RMSE就要在原始尺度上解释不要在归一化空间直接评估否则会低估模型的真实误差。5.3 三个高频故障滞后预测、注意力均匀化、特征泄漏故障一预测结果比真实值滞后半根K线。这个问题在分钟级数据上几乎必现原因是模型学会了“用上一根K线的收益率近似下一根”本质上是把pct_change的动量当成了预测信号。检查方法是对比y_true和y_pred的互相关函数如果在滞后1的位置出现明显峰值就需要把特征里的收益率列去掉或者改用收益率的符号作为新的目标做分类。故障二注意力权重在全部时间步上接近均匀分布。这说明Attention没有学会挑选关键历史窗口被LSTM的隐状态完全主导加权和退化成简单平均。常见原因有两层训练轮数太少注意力模块还没有收敛或者CNN简化了太多信息每个时间步的区分度不足。处理手段是把注意力层移到CNN之后、LSTM之前让注意力直接作用于CNN特征而不是LSTM隐状态权重分布往往更有区分度。故障三验证集效果远好于实盘。这通常是特征泄漏造成的例如使用了当天的日线收盘价去预测同一天的下一分钟收益率。检查泄漏最简单的方法是把训练集随机洗乱再训练一遍如果洗乱后的验证集指标仍然很高说明标签信息在特征里可以直接读到需要逐列检查特征与目标的时序关系。相关性分析阶段就应该做这个检查我当时是把所有特征的shift(-1)版本也纳入spearmanr计算若shift(-1)后的相关系数与原始值接近就有泄漏嫌疑。6. 进阶验证用注意力热力图检验模型是否学到相关性分析选中的特征模型训练完不是终点还要回答一个问题模型学到的东西和相关性分析发现的规律是否一致这一步在量化场景里叫“可解释性验证”作用是防止上层领导或合规只看到一堆张量迭代却不知道模型为什么给出多头信号。这里介绍两个做法都只需要在evaluate阶段增加少量代码。第一个做法是把注意力权重画成热力图。取一条测试样本横轴是时间步纵轴是特征通道颜色深浅代表该位置对最终预测的贡献程度。代码逻辑是把CNN输出的特征和Attention权重相乘得到一个(batch, time, feature)的贡献张量然后对time维求和得到每个特征的总贡献。这个数值和最初相关性分析里的spearmanr系数放在同一张表里对比你会发现排在前面的特征往往正是那些通过显著性检验的因子。import matplotlib.pyplot as plt # cnn_out: (B, T, F), weights: (B, T) contribution cnn_out * weights.unsqueeze(-1) # (B, T, F) feat_importance contribution.mean(dim1) # (B, F) plt.figure(figsize(10, 3)) plt.bar(range(feat_importance.shape[1]), feat_importance[0].detach().numpy()) plt.xticks(range(len(selected)), selected, rotation45) plt.title(Feature Contribution Based on Attention) plt.tight_layout() plt.savefig(attn_feature_importance.png)第二个做法是特征扰动实验。把测试集里某个特征列整体置为均值重新跑一遍模型观察预测误差上升的幅度。上升幅度越大说明模型对该特征越敏感这个特征在预测链路里的真实贡献就越高。这个做法比直接看注意力权重更可靠因为Attention权重只能反映模型内部的权重分配不能反映梯度等信息而扰动实验反映的是实际预测行为。baseline_rmse compute_rmse(X_test, y_test) for i, feat in enumerate(selected): X_noisy X_test.copy() X_noisy[:, :, i] X_noisy[:, :, i].mean() noisy_rmse compute_rmse(X_noisy, y_test) print(f{feat}: rmse delta {noisy_rmse - baseline_rmse:.5f})输出结果里如果volume、oi这类特征的rmse增量明显高于其他特征说明模型确实抓住了成交与持仓变化带来的价格信息这和相关性分析阶段spearmanr给出的结论一致如果某个特征扰动后误差不升反降说明该特征在模型内部产生了干扰可以考虑从features列表里删掉再重新训练。这轮验证跑完模型才算真正从“能跑通”变成“敢实盘”。本文还有配套的精品资源点击获取
返回列表