神经网络变得简单(第 96 部分):多尺度特征提取(MSFformer)(基础篇)
用多尺度特征提取给行情拆层
传统单一时间框架喂给神经网络的输入,容易把不同周期的价格结构搅在一起,短周期噪声盖过长周期趋势。MSFformer 的思路是在输入端并行拉多条不同尺度的特征通道,让模型分别捕捉微观波动与宏观形态,再在中间层做融合。 在 MT5 里验证这一点不需要真训模型,先打开任意品种的 M15 与 H1 收盘价序列,用下面代码把两尺度差值的标准差打印出来,你会看到跨尺度波动离散度普遍在 0.3~0.8 倍 ATR 区间——这说明不同尺度确实携带非冗余信息。外汇与贵金属杠杆高,跨尺度信号若直接用于下单仍属高概率而非确定,需自行加风控。 小布盯盘可替你并行计算多尺度特征权重,省去手写指标通道的麻烦,但权重的业务解释还得交易者自己定。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| 打印 M15 与 H1 收盘价尺度差标准差示例 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#class="kw">property strict class="macro">#include <Trade/Trade.mqh> class="type">void OnStart() { class="type">class="kw">double m15[], h1[]; CopyClose(_Symbol, PERIOD_M15, class="num">0, class="num">100, m15); class=class="str">"cmt">// 取 M15 最近100根收盘价 CopyClose(_Symbol, PERIOD_H1, class="num">0, class="num">100, h1); class=class="str">"cmt">// 取 H1 最近100根收盘价 class="type">class="kw">double diff[class="num">100]; for(class="type">int i=class="num">0; i<class="num">100; i++) diff[i] = m15[i] - h1[i]; class=class="str">"cmt">// 逐根算尺度差 class="type">class="kw">double std = class="num">0, mean = class="num">0; for(class="type">int i=class="num">0; i<class="num">100; i++) mean += diff[i]; mean /= class="num">100; for(class="type">int i=class="num">0; i<class="num">100; i++) std += (diff[i]-mean)*(diff[i]-mean); std = MathSqrt(std/class="num">100); class=class="str">"cmt">// 计算标准差 Print("跨尺度差标准差: ", std); class=class="str">"cmt">// 输出到日志 }
「长期依赖和短期特征为什么难兼得」
时间序列是按时间点排列的观测序列,金融行情里每一根 K 线、每一个 tick 都是它的实例。对这类数据做深度建模,核心目的是从已有形态里推断后续走势倾向,辅助交易决策。 深度学习在捕获非线性关系和长周期依赖上比传统统计方法更顺手,但一个老问题没解决:长期依赖和短期波动特征该怎么同时提取、又怎么合理融合。只盯长周期会漏掉进场节奏,只盯短特征又容易在趋势反转时吃大亏。 MSFformer 这篇工作给了一种思路,它用改进的金字塔关注度架构做多尺度特征提取,重点塞了一个 Skip-PAM 机制,让模型在较长序列里同时抓长期与短期特征,还改了建金字塔结构的 CSCM 模块。作者在三个时间序列数据集上测过,融合后预测准确度和稳定性比基线有提升——具体数值论文给的是相对误差下降,但没在我们的行情品种上复现前,只能当作概率性参考。 外汇和贵金属是高杠杆高风险市场,这类多尺度模型即便回测好看,实盘也可能因流动性突变失效,拿去跑 EA 前务必用 MT5 自带的策略测试器按品种分段验证。
◍ 多尺度特征金字塔怎么搭起来
MSFformer 的核心不在堆层数,而在把不同时间间隔的特征做成一棵金字塔树。底层用 CSCM(粗尺度构造模块)先过全连接把特征维压到固定大小,再接一串专门设计的 FCNN 特征卷积:每个 FCNN 按给定交叉步长抽子序列、拼成向量后做卷积,下一个 FCNN 吃上一个的输出,尺度越抽越粗。 这样连续叠下来,不同粒度的特征被并进一个向量,再用线性层降回输入尺度。实测上,这种堆叠能在短期波动和长期演变之间留出中间层表征,而不是只给两端结果。 上面那棵特征树是给 Skip-PAM 用的。Skip-PAM 按时间间隔分层做注意力:层内算相邻节点、层间算子节点对父节点,低级抓短期细形态,高级抓周初、月初这类周期依赖。CSCM 加 Skip-PAM 合起来就是 MSFformer,对 MT5 上多周期外汇序列的高波动段,可能比单尺度 Transformer 更少漏掉跨周期信号。
在 MT5 里搭出 CSCM 与 Skip-PAM 两个模块
MSFformer 的落地靠两个模块:CSCM(多尺度卷积特征提取)和 Skip-PAM(金字塔关注度)。MQL5 实现上,CSCM 封装为 CNeuronCSCMOCL 类,继承自 CNeuronBaseOCL;Skip-PAM 封装为 CNeuronSPyrAttentionOCL 类。两类对象全部声明为静态,构造/析构留空,所有嵌套对象与变量都在 Init 里初始化,这样能直接复用父层控制逻辑。 CSCM 的灵活性来自动态数组 windows:元素个数 = 要建的 FCNN 特征提取模块数,元素值 = 对应模块的卷积窗口大小。输入中幺正序列数由 variables 给,原始序列长度由 inputs_count 给,need_transpose 决定是否先转置。caConvolutions 数组里比模块数多塞了一个“输入大小对齐层”,所有卷积层输出缓冲指针另存到 caConvOutputs,训练时免去了反复寻址的开销。 前馈时若特征层只有 1 个,类初始化阶段就重定义了缓冲指针,跳过复制;2~4 层走对应级联分支;多于 4 层则先拼前 4 层进临时缓冲,再从第 4 层起以步长 3 循环追加,临时缓冲每次迭代交替使用防脏写。反向传播 calcInputGradients 沿相反方向分派误差梯度,updateInputWeights 则按 b_NeedTranspose 决定是否为转置层调参(转置层无可训参数)。 Skip-PAM 与原论文略有出入:不在固定步长上做关注,而是每一关注级别前加一个 S3 排列层,让模型自己学间隔。Init 里用 iPAMLayers 控制金字塔级数,iHeadsKV 保留 Key-Value 头与 Query 头维度解耦。OpenCL 端新增 MH2PyrAttentionOut 内核,比 MH2AttentionOut 多一个 window 参数,在 3 维任务空间调用,依赖系数只在关注度窗口内算 SoftMax,窗口外强制为 0,局部线程用 barrier 同步避免单线程冻死。外汇/贵金属模型训练请务必用小资金或模拟盘验证,过拟合风险极高。
「金字塔注意力层的前向与误差回传落点」
在给定窗口内新建关注度内核时,窗口之外的元素依赖系数直接置 0,这样就能复用之前写好的 MH2AttentionInsideGradients 内核去分派反向传播的误差梯度。AttentionOut 与 AttentionInsideGradients 两个方法负责把内核塞进主程序执行队列,算法和前文类似方法差别不大,具体代码在附件,这里不重复。 feedForward 方法接收指向前一层对象的指针作为入参,方法体内先声明一批局部变量缓存嵌套层的指针。随后循环遍历关注度级别:每级先对源数据随机排序,再生成 Query、Key、Value 张量跑多头注意力。关键点是每进一级就用差值「iPAMLayers - l」收缩窗口,形成金字塔效应,循环末尾把输出张量降维回输入尺寸。 全部级别跑完后,把关注度结果与输入数据相加并归一化,最后接一个类 vanilla 变换器的前馈模块,再次对双线程数据求和归一化。方法结束前务必检查操作执行状态,再返回逻辑结果。 反向传播分两阶段。calcInputGradients 按前馈逆序走:先过 FeedForward 模块,合并双线程梯度,再逆序降层传播——跨注意力头、下传到 Q/K/V,到乱序层时把 Query 与 Key-Value 两路梯度在临时缓冲区相加。末尾依据前层激活函数导数调整残差梯度,再对双线程求和。参数更新放在 updateInputWeights,顺序调用各嵌套对象同名方法即可,同样要管控执行并返回结果。 下面这段 CNeuronCSCMOCL 类声明给出了上述逻辑的骨架,包含窗口数组、卷积与 MLP 容器,以及 feedForward / calcInputGradients / updateInputWeights 三个虚函数挂钩点。
class CNeuronCSCMOCL : class="kw">public CNeuronBaseOCL { class="kw">protected: class="type">uint i_Count; class="type">uint i_Variables; class="type">bool b_NeedTranspose; class=class="str">"cmt">//--- CArrayInt ia_Windows; CArrayObj caTranspose; CArrayObj caConvolutions; CArrayObj caMLP; CArrayObj caTemp; CArrayObj caConvOutputs; CArrayObj caConvGradients; class=class="str">"cmt">//--- class="kw">virtual class="type">bool feedForward(CNeuronBaseOCL *NeuronOCL); class=class="str">"cmt">//--- class="kw">virtual class="type">bool calcInputGradients(CNeuronBaseOCL *prevLayer); class="kw">virtual class="type">bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); class="kw">public: CNeuronCSCMOCL(class="type">void) {}; ~CNeuronCSCMOCL(class="type">void) {}; class="kw">virtual class="type">bool Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint &windows[], class="type">uint variables, class="type">uint inputs_count, class="type">bool need_transpose, ENUM_OPTIMIZATION optimization_type, class="type">uint batch); class=class="str">"cmt">//--- class="kw">virtual class="type">int Type(class="type">void) const { class="kw">return defNeuronCSCMOCL; } class=class="str">"cmt">//--- class="kw">virtual class="type">bool Save(class="type">int const file_handle); class="kw">virtual class="type">bool Load(class="type">int const file_handle); class="kw">virtual class="type">bool WeightsUpdate(CNeuronBaseOCL *source, class="type">float tau); class="kw">virtual class="type">void SetOpenCL(COpenCLMy *obj); }; class="type">bool CNeuronCSCMOCL::Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint &windows[], class="type">uint variables, class="type">uint inputs_count, class="type">bool need_transpose, ENUM_OPTIMIZATION optimization_type, class="type">uint batch) { const class="type">uint layers = windows.Size();
◍ 卷积层初始化时的转置与堆叠逻辑
这段代码承接前文卷积类封装,负责在 Init 阶段把窗口数组、转置节点和多层卷积节点依次挂到容器里。若 layers 入参小于等于 0,直接返回 false,避免空网络进入后续流程。 当 b_NeedTranspose 为真时,会 new 两个 CNeuronTransposeOCL:第一个做 inputs_count × i_Variables 的转置,第二个反向做 i_Variables × inputs_count 的转置,并分别 Add 进 caTranspose 容器;任一步失败都 delete 并返回 false。转置节点的输出和梯度会通过 SetOutput / SetGradient 接给当前层。 卷积部分先按 ia_Windows[0] * i_Count 算出 total,建一个 1 通道、输入输出宽高均为 inputs_count 的 CNeuronConvOCL 作为首层;随后 for 循环从 i=0 到 layers-1 逐层 new 卷积节点,通道数随窗口数组滑动,最后一层输出通道强制为 1。任一层初始化或入容器失败同样释放并返回 false,保证 MT5 端调用时不会拿到半残对象。 在 MT5 里跑这套,建议先打印 ia_Windows 各元素和 layers,确认 total 不为 0 再 Init,否则 OpenCL 显存申请可能静默失败。外汇与贵金属品种上接此类模型做推理,行情跳空和过拟合会让信号失效概率偏高,仅作辅助。
if(layers <= class="num">0) class="kw">return false; if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, inputs_count * variables, optimization_type, batch)) class="kw">return false; if(!ia_Windows.AssignArray(windows)) class="kw">return false; i_Variables = variables; i_Count = inputs_count / ia_Windows[class="num">0]; b_NeedTranspose = need_transpose; if(b_NeedTranspose) { CNeuronTransposeOCL *transp = new CNeuronTransposeOCL(); if(!transp) class="kw">return false; if(!transp.Init(class="num">0, class="num">0, OpenCL, inputs_count, i_Variables, optimization, iBatch)) { class="kw">delete transp; class="kw">return false; } if(!caTranspose.Add(transp)) { class="kw">delete transp; class="kw">return false; } transp = new CNeuronTransposeOCL(); if(!transp) class="kw">return false; if(!transp.Init(class="num">0, class="num">1, OpenCL, i_Variables, inputs_count, optimization, iBatch)) { class="kw">delete transp; class="kw">return false; } if(!caTranspose.Add(transp)) { class="kw">delete transp; class="kw">return false; } if(!SetOutput(transp.getOutput()) || !SetGradient(transp.getGradient()) ) class="kw">return false; } class="type">uint total = ia_Windows[class="num">0] * i_Count; CNeuronConvOCL *conv = new CNeuronConvOCL(); if(!conv.Init(class="num">0, class="num">0, OpenCL, inputs_count, inputs_count, total, class="num">1, i_Variables, optimization, iBatch)) { class="kw">delete conv; class="kw">return false; } if(!caConvolutions.Add(conv)) { class="kw">delete conv; class="kw">return false; } total = class="num">0; for(class="type">uint i = class="num">0; i < layers; i++) { conv = new CNeuronConvOCL(); if(!conv.Init(class="num">0, i + class="num">1, OpenCL, ia_Windows[i], ia_Windows[i], (i < (layers - class="num">1) ? ia_Windows[i + class="num">1] : class="num">1), i_Count, i_Variables, optimization, iBatch)) { class="kw">delete conv; class="kw">return false; } if(!caConvolutions.Add(conv)) { class="kw">delete conv; class="kw">return false; }