交易中的神经网络:频域异常检测(终篇)
引言
在上一篇文章中,我们介绍了用于多变量时间序列异常检测的创新 CATCH 框架。该框架提出的频域分析法,不仅能检测离群值、突变等单点异常,还可识别传统方法难以捕捉的复杂隐性规律。CATCH 框架借助傅里叶变换,将时域数据转换为频谱数据,为为更细致地分析序列特征提供了新的可能性。
频域分块是 CATCH 框架的核心优势之一。模型不会对整个频谱进行整体分析,而是将其切分为对应不同频段的独立片段。这种方式既能分析整体趋势,也能挖掘高频分量对应的局部特征。无论是短期脉冲异动,还是子序列层面的复杂偏离,各类异常都能被高精度识别与归类。这种频域分块机制,也让时间序列的结构分析变得更加细致。
框架中还有一个核心自适应模块,用于分析不同数据通道间的关联关系。该模块采用掩码注意力机制,可让系统聚焦关键关联特征,同时过滤噪声与不相关信息。这一机制提升了正常行为的重构质量,显著增强模型在变化市场环境中的鲁棒性。传统方法会对各数据通道单独处理,而 CATCH 框架会充分考量通道间复杂的相互关联。这一点在金融数据分析中尤为重要 —— 一个市场的行情波动,往往会影响其他相关板块。
CATCH 框架的最后一步是重构原始时间序列。完成深度频域分析、定位潜在异常后,系统通过逆变换将数据转回常规时域。对比原始序列与重构序列的差值,即可有效判定行情偏离,进而及时应对市场动态变化。
下文为作者绘制的 CATCH 框架架构示意图。

在上一篇文章的实操部分,基于 MQL5 按照我们自己的思路实现这些方法。我们编写了用于处理复数值数据的卷积层。还完成了 OpenCL 环境下,复数值掩码注意力模块前向传播与反向传播的代码实现。本文将继续推进后续开发工作。
复数值掩码注意力模块
上一篇我们讲解了MaskAttentionComplex与MaskAttentionGradientsComplex核心内核,二者分别实现了复数域下掩码注意力机制的前向传播与反向传播算法。本文将继续推进后续开发工作。我们将在主程序层对掩码注意力模块进行封装整合。为此,新建CNeuronComplexMVMHMaskAttention对象,并定义其内部结构如下:
class CNeuronComplexMVMHMaskAttention : public CNeuronBaseOCL { protected: uint iWindow; uint iWindowKey; uint iHeads; uint iUnits; uint iVariables; //--- CNeuronComplexConvOCL cQKV; CNeuronBaseOCL cQ; CNeuronBaseOCL cKV; CNeuronConvOCL cMask; CNeuronBaseOCL cMHAttentionOut; CNeuronComplexConvOCL cPooling; CNeuronBaseOCL cResidual; CNeuronComplexConvOCL cFeedForward[2]; //--- virtual bool AttentionOut(void); virtual bool AttentionInsideGradients(void); virtual bool SumAndNormilize(CBufferFloat *tensor1, CBufferFloat *tensor2, CBufferFloat *out, int dimension, bool normilize = true, int shift_in1 = 0, int shift_in2 = 0, int shift_out = 0, float multiplyer = 0.5f) override; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer) override; public: CNeuronComplexMVMHMaskAttention(void) {}; ~CNeuronComplexMVMHMaskAttention(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint units_count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) const { return defNeuronComplexMVMHMaskAttention; } //--- virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; virtual void SetOpenCL(COpenCLMy *obj) override; };
可以看到,该新对象的结构与常规注意力模块基本一致,我们的代码库中已实现过多种同类变体。但由于处理复数值数据,它存在一处关键区别。该对象直接接收已完成复数转换的输入数据,因此省去了数据转换环节。我们借助此前开发的复数卷积层来生成查询向量(Query)、键向量(Key)与值向量(Value)。下面分步讲解具体实现。
所有内部对象均声明为类成员,因此类的构造函数与析构函数无需编写逻辑、保持空实现即可。已声明成员及继承对象的初始化工作,统一在 Init 方法中完成。该方法传入一组配置参数,用以唯一确定当前对象的架构。参数结构也是此类组件的通用设计。
bool CNeuronComplexMVMHMaskAttention::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint units_count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch) { //--- if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, 2 * window * units_count * variables, optimization_type, batch)) return false;
该方法首先调用父类的对应方法,父类已完成继承对象与接口的初始化逻辑。随后将外部传入的模型架构参数存入内部变量:
iWindow = window; iWindowKey = MathMax(window_key, 1); iUnits = units_count; iHeads = MathMax(heads, 1); iVariables = variables;
接下来开始初始化新定义的各类组件。首先初始化掩码生成模块。
需要说明的是,原框架作者设计的掩码生成模块,基于对输入数据做可学习投影实现。同时本模块采用多头注意力机制,每一个注意力头都会使用独立的掩码。
另一项关键设计:注意力计算并非作用于整个频谱。CATCH 框架仅在同频段、不同单变量序列对应的各个频域分块内部执行注意力运算。
相应地,掩码生成模块的输出,必须以实数系数的形式,表征各个数据通道的影响权重(概率形式)。
为满足以上要求,我们采用标准卷积层,并将卷积核尺寸扩大一倍(适配复数窗口运算);卷积核数量与单个序列元素对应的掩码向量维度保持一致。
uint index = 0; if(!cMask.Init(0, index, OpenCL, 2 * iWindow, 2 * iWindow, iVariables * iHeads, iUnits, iVariables, optimization, iBatch)) return false; cMask.SetActivationFunction(SIGMOID); CBufferFloat *temp = cMask.GetWeightsConv(); if(!temp || !temp.Fill(0)) return false;
为保证输出数值落在规定区间内,此处选用Sigmoid 激活函数。
注意:初始化阶段会将可学习参数矩阵全部置零。该设置会让所有元素初始的掩码影响系数均为 0.5。模型训练过程中权重会不断迭代更新,逐步调整各数据通道之间的关联掩码。
之后初始化Query,Key和Value生成模块。由于该模块的输入、输出均为复数值,因此选用复数卷积层实现。
index++; if(!cQKV.Init(0, index, OpenCL, iWindow, iWindow, 3 * iWindowKey * iHeads, iUnits, iVariables, optimization, iBatch)) return false; cQKV.SetActivationFunction(None);
随后将整合了 Q、K、V 三组特征的张量拆分,把Query单独提取为独立矩阵。为此还需额外初始化两个功能对象。最后该方法执行完毕,返回布尔值,标识全部初始化操作是否成功。
index++; if(!cQ.Init(0, index, OpenCL, 2 * iWindowKey * iHeads * iVariables * iUnits, optimization, iBatch)) return false; cQ.SetActivationFunction(None); index++; if(!cKV.Init(0, index, OpenCL, 2 * cQ.Neurons(), optimization, iBatch)) return false; cKV.SetActivationFunction(None);
在同一阶段,初始化用于存储多头注意力计算结果的对象。
index++; if(!cMHAttentionOut.Init(0, index, OpenCL, cQ.Neurons(), optimization, iBatch)) return false; cMHAttentionOut.SetActivationFunction(None);
接着增设复数卷积层,用于对注意力输出做降维处理。
index++; if(!cPooling.Init(0, index, OpenCL, iWindowKey * iHeads, iWindowKey * iHeads, iWindow, iUnits, iVariables, optimization, iBatch)) return false; cPooling.SetActivationFunction(None);
参照经典 Transformer 架构,我们添加一层网络以实现残差连接。
index++; if(!cResidual.Init(0, index, OpenCL, cPooling.Neurons(), optimization, iBatch)) return false; cResidual.SetActivationFunction(None);
随后搭建前馈网络模块的两层复数卷积层。
index++; if(!cFeedForward[0].Init(0, index, OpenCL, iWindow, iWindow, 4 * iWindow, iUnits, iVariables, optimization, iBatch)) return false; cFeedForward[0].SetActivationFunction(LReLU); index++; if(!cFeedForward[1].Init(0, index, OpenCL, 4 * iWindow, 4 * iWindow, iWindow, iUnits, iVariables, optimization, iBatch)) return false; cFeedForward[1].SetActivationFunction(None); SetActivationFunction(None); //--- return true; }
至此,该对象的初始化流程全部完成,并向调用方返回布尔值,标识初始化是否成功。
下一步,在feedForward方法中实现前向传播逻辑。
bool CNeuronComplexMVMHMaskAttention::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false;
该方法接收输入数据对象的指针,代码首先校验指针是否有效。
为提升模型训练稳定性,先对输入数据做归一化处理。
if(!NeuronOCL.SwapOutputs()) return false; if(!SumAndNormilize(NeuronOCL.getPrevOutput(), NeuronOCL.getPrevOutput(), NeuronOCL.getOutput(), iWindow, true, 0, 0, 0, 1)) return false;
需要注意,本文处理的输入均为复数值数据。因此我们对求和与归一化方法进行了改写,使其支持复数运算。完整源码见附件。
数据归一化完成后,基于输入数据生成掩码张量以及查询(Query)、键(Key)和值(Value) 特征。
if(!cMask.FeedForward(NeuronOCL)) return false; if(!cQKV.FeedForward(NeuronOCL)) return false;
随后将拼接而成的特征张量拆分为两个独立张量。
if(!NeuronOCL.SwapOutputs()) return false; if(!DeConcat(cQ.getOutput(), cKV.getOutput(), cQKV.getOutput(), 2 * iWindowKey * iHeads, 4 * iWindowKey * iHeads, iUnits * iVariables)) return false;
所有数据准备完毕后,送入掩码注意力的前向计算核心函数。该逻辑封装在AttentionOut方法中执行。
if(!AttentionOut()) return false;
接下来对多头注意力的输出结果降维,并将结果与原始输入相加,构建残差连接。
if(!cPooling.FeedForward(cMHAttentionOut.AsObject())) return false; if(!SumAndNormilize(NeuronOCL.getOutput(), cPooling.getOutput(), cResidual.getOutput(), iWindow, true, 0, 0, 0, 1)) return false;
数据再依次经过前馈模块的两层卷积层。
if(!cFeedForward[0].FeedForward(cResidual.AsObject())) return false; if(!cFeedForward[1].FeedForward(cFeedForward[0].AsObject())) return false;
再次添加残差连接,并将最终输出结果存入继承而来的数据缓冲区,用于和模型其他网络层交互。
if(!SumAndNormilize(cResidual.getOutput(), cFeedForward[1].getOutput(), getOutput(), iWindow, true, 0, 0, 0, 1)) return false; //--- return true; }
该方法执行结束后,向调用方返回执行结果。
下面说明AttentionOut方法,其作用是将多头掩码注意力核心算子MaskAttentionComplex提交至执行队列。前文很少介绍 OpenCL 核心算子的入队执行流程。因为该流程属于通用标准写法。无需反复赘述。但针对 CATCH 框架,此处有一处关键实现细节。
前文提到,CATCH 框架要求:掩码注意力运算仅在不同单变量序列的同频段单元之间执行。简单来说,就是提取不同单变量序列中频段一致的频域分块,分析彼此的关联关系。这种设计可以在不同频段内独立挖掘各单变量序列的内在联系,分别捕捉长期趋势与短期波动特征。我们在设计通用掩码注意力算子时,并未专门适配这一规则。
但可以通过控制算子的任务执行空间,实现该逻辑。首先分析当前对象的输入数据维度。我们处理的是多变量序列分段数据,可表示为三维张量:{变量, 分段, 维度}。生成多头注意力特征后,输出数据为四维张量:{变量, 分段, 注意力头, 维度}。
本实现中,各个注意力头相互独立运算。因此我们可以将分段维度与注意力头维度合并,把每一个分段视作独立的注意力头,以此实现 “不同单变量序列、同分段之间的关联性分析”。这也正是 CATCH 框架要求的运行逻辑。
AttentionOut方法无入参。函数内部仅校验 OpenCL 上下文管理指针是否有效。
bool CNeuronComplexMVMHMaskAttention::AttentionOut(void) { if(!OpenCL) return false;
接下来定义算子的执行空间参数。如上文所述,序列维度对应数据集内单变量序列的数量。头数维度由配置的注意力头数量与单序列分段数相乘得到。
uint global_work_offset[3] = {0}; uint global_work_size[3] = {iVariables/*Q units*/, iVariables/*K units*/, iHeads * iUnits/*Heads*/}; uint local_work_size[3] = {1, iVariables, 1};
随后在线程任务空间的第二维上,将执行线程划分为线程组。
最后,把所需数据缓冲区的指针作为参数传入核心算子。
ResetLastError(); int kernel = def_k_MaskAttentionComplex; if(!OpenCL.SetArgumentBuffer(kernel, def_k_maskattcom_q, cQ.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(kernel, def_k_maskattcom_kv, cKV.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(kernel, def_k_maskattcom_scores, cMask.getPrevOutIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(kernel, def_k_maskattcom_out, cMHAttentionOut.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(kernel, def_k_maskattcom_masks, cMask.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
此时,我们利用注意力系数矩阵与通道掩码矩阵维度相同这一特性。这样就无需额外分配缓冲区来临时存放注意力系数。转而复用掩码生成模块的空闲缓冲区来实现该功能。
接下来,我们将其余所需参数传入核心算子。
if(!OpenCL.SetArgument(kernel, def_k_maskattcom_dimension, (int)iWindowKey)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(kernel, def_k_maskattcom_heads_kv, (int)(iHeads * iUnits))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
另外需要注意,对于键(Key)与值(Value)特征,我们依旧将注意力头数设置为配置的头数与序列长度的乘积。
最后,将核心算子加入执行队列等待运行。
if(!OpenCL.Execute(kernel, 3, global_work_offset, global_work_size, local_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
该方法执行完毕,向上层调用方返回执行状态。
完成前向传播逻辑后,我们开始编写反向传播流程。首先实现 calcInputGradients 方法,该方法会依据各内部组件对模型最终输出的贡献,将误差梯度逐层分发。
bool CNeuronComplexMVMHMaskAttention::calcInputGradients(CNeuronBaseOCL *prevLayer) { if(!prevLayer) return false;
该方法沿用前向传播阶段的输入数据对象。但本次调用的目的是完成误差梯度的反向传递。代码首先校验指针是否合法有效。
此时,当前对象的外部接口缓冲区中,存放着从下一层网络传回的误差梯度。这些梯度值尚未经过激活函数求导修正。这是刻意设计的结果:组件初始化时未配置激活函数。该设计可让内部组件灵活选用不同的激活函数。接下来对前馈网络最后一层卷积层做激活函数求导,并传递修正后的梯度。
if(!DeActivation(cFeedForward[1].getOutput(), cFeedForward[1].getGradient(), Gradient, cFeedForward[1].Activation())) return false;
随后将梯度沿网络层反向传递,直至注意力模块后的残差连接层。
if(!cFeedForward[0].calcHiddenGradients(cFeedForward[1].AsObject())) return false; if(!cResidual.calcHiddenGradients(cFeedForward[0].AsObject())) return false;
残差连接组件同样未设置激活函数。因此我们再次对注意力输出缩放层做激活函数求导,完成梯度修正。
if(!DeActivation(cPooling.getOutput(), cPooling.getGradient(), cResidual.getGradient(), cPooling.Activation()) || !DeActivation(cPooling.getOutput(), cPooling.getPrevOutput(), Gradient, cPooling.Activation()) || !SumAndNormilize(cPooling.getGradient(), cPooling.getPrevOutput(), cPooling.getGradient(), iWindow, false, 0, 0, 0, 1)) return false;
需要重点说明:当前残差连接组件中,仅包含沿前馈网络通路传递的梯度。我们还需要纳入沿注意力残差通路传递的梯度。故而再次结合注意力输出映射层的激活函数导数,修正外部接口缓冲区中的梯度。
请注意,复用外部接口缓冲区并不会造成数据重复转换。第一轮运算的修正结果存入前馈网络末层缓冲区,接口缓冲区始终保留原始梯度。当前步骤的计算结果写入映射层缓冲区,原始数据不受影响;后续沿残差连接向输入层回传梯度时,会继续使用这份原始数据。
完成全部梯度修正后,对两条通路的梯度贡献进行求和。再将梯度分发至各个注意力头。
if(!cMHAttentionOut.calcHiddenGradients(cPooling.AsObject())) return false; if(!AttentionInsideGradients()) return false;
接着调用 AttentionInsideGradients 方法,完成注意力机制内部的梯度传递。本文不再赘述该方法的具体细节。相关内容可自行研读。完整实现代码见附件。但必须注意:任务空间配置与核心算子调度参数,需要和前向传播阶段保持一致。
下一步,将各个注意力组件的梯度结果合并为一个张量。
if(!Concat(cQ.getGradient(), cKV.getGradient(), cQKV.getGradient(), 2 * iWindowKey * iHeads, 4 * iWindowKey * iHeads, iUnits * iVariables)) return false;
随后结合对应激活函数的导数,将误差梯度传递至输入层。
if(!DeActivation(cQKV.getOutput(), cQKV.getPrevOutput(), cQKV.getGradient(), cQKV.Activation()) || !prevLayer.calcHiddenGradients(cQKV.AsObject())) return false;
但这仅为其中一条数据流向。我们还需要结合输入数据处理层的激活函数导数,纳入残差连接通路产生的梯度分量。
该步骤会同时取用前馈模块与外部接口缓冲区的数据。
if(!DeActivation(prevLayer.getOutput(),cResidual.getPrevOutput(),cResidual.getGradient(),prevLayer.Activation()) || !SumAndNormilize(prevLayer.getGradient(), cResidual.getPrevOutput(), cResidual.getPrevOutput(), iWindow, false, 0, 0, 0, 1)) return false; if(!DeActivation(prevLayer.getOutput(), cResidual.getGradient(), Gradient, prevLayer.Activation()) || !SumAndNormilize(cResidual.getGradient(), cResidual.getPrevOutput(), cResidual.getPrevOutput(), iWindow, false, 0, 0, 0, 1)) return false;
需要注意,输入数据同时也用于生成掩码矩阵。因此我们还要叠加这条计算通路对应的梯度分量。
if(!DeActivation(cMask.getOutput(), cMask.getGradient(), cMask.getGradient(), cMask.Activation()) || !prevLayer.calcHiddenGradients(cMask.AsObject()) || !SumAndNormilize(prevLayer.getGradient(), cResidual.getPrevOutput(), prevLayer.getGradient(), iWindow, false, 0, 0, 0, 1)) return false; //--- return true; }
至此,输入层已汇总所有数据流的完整梯度信号,本方法执行结束,并向调用方返回布尔状态值。
参数优化方法请大家自行研读。该部分逻辑较为简单,主要是调用内部子模块的对应方法。该模块、所有方法的完整代码与实现均见附件。接下来进入下一阶段的开发工作。
搭建 CATCH 框架
至此,各项前期准备工作已基本完成,我们也实现了各个独立功能组件。现在开始将这些组件整合为完整的 CATCH 框架。框架的运行逻辑由 CNeuronCATCH 组件实现,其结构如下所示。
class CNeuronCATCH : public CNeuronTransposeOCL { protected: CNeuronTransposeOCL cTranspose; CNeuronBaseOCL caFreqIn[2]; CNeuronBaseOCL cFreqConcat; CNeuronComplexConvOCL caProjection[2]; CNeuronComplexMVMHMaskAttention caChannelFusion[2]; CNeuronComplexConvOCL caLinearHead[2]; CNeuronBaseOCL caFreqOut[2]; //--- virtual bool FFT(CBufferFloat *inp_re, CBufferFloat *inp_im, CBufferFloat *out_re, CBufferFloat *out_im, uint variables, bool reverse = false); //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer) override; public: CNeuronCATCH(void) {}; ~CNeuronCATCH(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint time_step, uint variables, uint window, uint step, uint window_key, uint heads, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronCATCH; } //--- virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; virtual void SetOpenCL(COpenCLMy *obj) override; };
从给出的结构可以看到,内部包含大量功能组件,每个组件分别负责框架复杂运算流程中的某一部分。在实现类方法的过程中,我们会逐步讲解各个组件的作用。目前只需了解:所有组件均为静态声明,因此构造函数和析构函数都无需编写逻辑。组件的初始化工作统一在 Init 方法中完成。
bool CNeuronCATCH::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint time_step, uint variables, uint window, uint step, uint window_key, uint heads, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronTransposeOCL::Init(numOutputs, myIndex, open_cl, variables, time_step, optimization_type, batch)) return false;
该方法接收一组配置参数,用于唯一确定当前初始化对象的整体架构。其中一部分参数用于设定输入数据的维度。另一部分参数则用于定义内部数据流的结构。举例来说,time_step 代表时序步长,variables 代表多变量时间序列中单变量序列的数量。window 和 step参数用于设定频谱的分片规则。而window_key 和 heads 则是注意力机制的核心配置参数。
方法内部首先调用父类的对应初始化方法。这里的父类是数据转置组件,该设计是特意为之。输入数据为以矩阵形式存储的多变量时间序列,矩阵每一行对应单个时间步下的状态向量。但 CATCH 框架需要基于各个单变量序列的频域形式进行运算。为方便计算,我们先对输入张量做转置,运算结束后再恢复为原始格式。恢复格式的操作由父类实现,这一点也体现在传入父类的参数当中。
我们采用快速傅里叶变换,将时间序列转换至频域。该算法要求序列长度必须为 2 的整数次幂。为满足该限制,可对序列补零做长度扩充,补零操作不会影响变换结果。下一步需要计算出距离当前长度最近的合法序列长度。
//--- Calculate FFT size int power = int(MathLog(time_step) / M_LN2); if(power <= 0) return false; if(MathPow(2, power) != time_step) power++; uint FreqUinits = uint(MathPow(2, power));
随后我们根据设定的分片参数计算分片总数。
if(window <= 0 || step <= 0) return false; int Segments = int((FreqUinits - int(window) + step - 1) / step); if(Segments <= 0) return false;
至此准备工作完成,接下来开始初始化内部功能组件。首先初始化输入数据转置层。该组件的初始化参数简单直观,无需额外说明。
uint index = 0; if(!cTranspose.Init(0, index, OpenCL, time_step, variables, optimization, iBatch)) return false;
接着创建两个数据缓冲区,分别存储傅里叶分解后频谱的实部与虚部。
for(uint i = 0; i < caFreqIn.Size(); i++) { index++; if(!caFreqIn[i].Init(0, index, OpenCL, FreqUinits * variables, optimization, iBatch)) return false; caFreqIn[i].SetActivationFunction(None); }
之后增设频域数据拼接模块。
index++; if(!cFreqConcat.Init(0, index, OpenCL, 2 * FreqUinits * variables, optimization, iBatch)) return false; cFreqConcat.SetActivationFunction(None);
我们采用配置好对应参数的复数卷积层来完成频域分块。
index++; if(!caProjection[0].Init(0, index, OpenCL, window, step, 2 * window, Segments, variables, optimization, iBatch)) return false; caProjection[0].SetActivationFunction(LReLU);
第二层卷积层用于完成频谱分块的特征嵌入。
index++; if(!caProjection[1].Init(0, index, OpenCL,2*window,2*window,window_key, Segments, variables, optimization, iBatch)) return false; caProjection[1].SetActivationFunction(TANH);
我们通过两级串联的注意力模块,分析各个单变量序列频谱之间的关联关系。第一级注意力模块直接基于已生成的分块嵌入特征进行运算。
index++; if(!caChannelFusion[0].Init(0, index, OpenCL,window_key,window_key,heads,Segments, variables, optimization, iBatch)) return false;
第二级通道间掩码注意力层的结构由分片数量决定。如果分片数量可被 2 整除,则对分片进行两两合并,以此挖掘更高阶的关联特征。
index++; if(Segments % 2 == 0) { if(!caChannelFusion[1].Init(0, index, OpenCL, 2 * window_key, window_key, heads, Segments / 2, variables, optimization, iBatch)) return false; } else if(!caChannelFusion[1].Init(0, index, OpenCL, window_key, window_key, heads, Segments, variables, optimization, iBatch)) return false;
若无法被 2 整除,则该层结构与上一级注意力层保持一致。
随后通过两级连续的映射卷积层,将注意力模块输出的张量维度还原为原始频谱长度。
index++; if(!caLinearHead[0].Init(0, index, OpenCL, window_key, window_key, window, Segments, variables, optimization, iBatch)) return false; caLinearHead[0].SetActivationFunction(LReLU); index++; if(!caLinearHead[1].Init(0, index, OpenCL, window * Segments, window * Segments, FreqUinits, variables, 1, optimization, iBatch)) return false; caLinearHead[ 1 ].SetActivationFunction(None);
第一层负责调整分片维度,第二层则还原单变量序列的原始长度。
我们还新增两个组件,用于在执行逆傅里叶变换前,拆分频谱的实部与虚部。
for(uint i = 0; i < caFreqOut.Size(); i++) { index++; if(!caFreqOut[i].Init(0, index, OpenCL, FreqUinits * variables, optimization, iBatch)) return false; caFreqOut[i].SetActivationFunction(None); } //--- return true; }
至此所有内部组件初始化完毕,该方法返回布尔值,标识执行结果。
接下来在feedForward方法中实现前向传播逻辑。前文提到,该方法接收输入数据对象指针,其中存放多变量时间序列张量。
bool CNeuronCATCH::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cTranspose.FeedForward(NeuronOCL)) return false;
首先对输入数据做转置,方便后续处理单变量序列。再通过快速傅里叶变换将数据转换至频域。
if(!FFT(cTranspose.getOutput(), NULL, caFreqIn[0].getOutput(), caFreqIn[1].getOutput(), iCount, false)) return false;
将转换后的频域数据拼接为一个完整张量。
if(!Concat(caFreqIn[0].getOutput(), caFreqIn[1].getOutput(), cFreqConcat.getOutput(), 1, 1, caFreqIn[0].Neurons())) return false;
随后借助两层映射卷积层,完成频谱分片与特征嵌入。
CNeuronBaseOCL *neuron = cFreqConcat.AsObject(); for(uint i = 0; i < caProjection.Size(); i++) { if(!caProjection[i].FeedForward(neuron)) return false; neuron = caProjection[i].AsObject(); }
接着利用通道间掩码注意力模块,分析数据间的关联关系。
for(uint i = 0; i < caChannelFusion.Size(); i++) { if(!caChannelFusion[i].FeedForward(neuron)) return false; neuron = caChannelFusion[i].AsObject(); }
之后将输出特征映射回原始频谱的维度。
for(uint i = 0; i < caLinearHead.Size(); i++) { if(!caLinearHead[i].FeedForward(neuron)) return false; neuron = caLinearHead[i].AsObject(); }
再将频域数据拆分为实部与虚部。
if(!DeConcat(caFreqOut[0].getOutput(), caFreqOut[1].getOutput(), neuron.getOutput(), 1, 1, caFreqOut[0].Neurons())) return false; if(!FFT(caFreqOut[0].getOutput(), caFreqOut[1].getOutput(), caFreqOut[0].getPrevOutput(), caFreqOut[1].getPrevOutput(), iCount, true)) return false;
随后执行逆傅里叶变换,将数据从频域还原为时域序列。需要注意,快速逆傅里叶变换输出的序列长度必为 2 的整数次幂。该长度可能与原始序列长度不一致,因此通过张量拆分舍弃多余数据。
if(!DeConcat(caFreqOut[0].getOutput(), caFreqOut[1].getOutput(), caFreqOut[0].getPrevOutput(), iWindow, caFreqOut[0].Neurons() / iCount - iWindow, iCount)) return false;
将转置后的原始输入数据与运算结果做残差连接,并进行归一化处理。最后将数据恢复为原始的时间序列格式。
if(!SumAndNormilize(caFreqOut[0].getOutput(),cTranspose.getOutput(),caFreqOut[0].getOutput(),iWindow,true,0,0,0,1)) return false; //--- return CNeuronTransposeOCL::feedForward(caFreqOut[0].AsObject()); }
前向传播计算到此结束,该方法向调用方返回执行状态。
下一阶段将实现该组件的反向传播逻辑。重点需要实现calcInputGradients方法,该方法会依据各组件对最终输出的贡献,逐层分发误差梯度。
bool CNeuronCATCH::calcInputGradients(CNeuronBaseOCL *prevLayer) { if(!prevLayer) return false;
该方法接收输入数据对象指针,代码会首先校验指针有效性。此前已经多次说明这类校验操作的必要性。
接下来将从下一层传回的梯度做转置,转换为单变量序列格式。
if(!CNeuronTransposeOCL::calcInputGradients(caFreqOut[1].AsObject())) return false; if(!SumAndNormilize(caFreqOut[1].getGradient(),caFreqOut[1].getGradient(),cTranspose.getPrevOutput(), iWindow,false,0,0,0,0.5f)) return false;
将运算结果拷贝至输入转置组件的空闲缓冲区,这部分数据对应残差通路的梯度。
需要注意,逆傅里叶变换的输出序列长度可能超出预期长度。在前向传播阶段,我们已经舍弃了超出部分的数据。这里补充说明:前向传播时,我们曾对原始时间序列补零,使其满足运算所需长度。因此逆傅里叶变换结果中被舍弃的部分,对应数值理论上也为零。为保证模型训练正常进行,需将该舍弃区域对应的梯度取反赋值。
if((caFreqOut[0].Neurons() - iWindow) > 0) if(!SumAndNormilize(caFreqOut[1].getOutput(), caFreqOut[1].getOutput(), caFreqOut[1].getOutput(), 1, false, 0, 0, 0, -0.5f)) return false;
随后将两个模块的梯度拼接为一个完整张量。
if(!Concat(caFreqOut[1].getGradient(), caFreqOut[1].getOutput(), caFreqOut[0].getGradient(), iWindow, caFreqOut[0].Neurons() - iWindow, iCount)) return false;
至此我们得到重构信号实部对应的梯度。但虚部的梯度也需要一并处理。逆傅里叶变换会根据频域数据还原出实数形式的时间序列。该时间序列全部由实数构成,对应虚部数值恒为零。因此,虚部误差梯度的计算方式与前文被舍弃的实数部分保持一致,也就是对已有计算结果取反。
if(!SumAndNormilize(caFreqOut[1].getPrevOutput(), caFreqOut[1].getPrevOutput(), caFreqOut[1].getGradient(), 1, false, 0, 0, 0, -0.5f)) return false;
接下来通过快速傅里叶变换,将梯度转换至频域。
if(!FFT(caFreqOut[0].getGradient(), caFreqOut[1].getGradient(), caFreqOut[0].getOutput(), caFreqOut[1].getOutput(), iCount, false)) return false;
将变换后的数据拼接为一个整体张量,整合复数梯度的实部与虚部。
if(!Concat(caFreqOut[0].getOutput(), caFreqOut[1].getOutput(), caLinearHead[1].getGradient(), 1, 1, caFreqOut[0].Neurons())) return false;
随后梯度依次反向传递至各个内部组件。首先经过注意力模块的末层映射网络。
if(!caLinearHead[0].calcHiddenGradients(caLinearHead[1].AsObject())) return false;
接着逐层穿过注意力模块,一直传递到特征嵌入卷积层。
CObject *neuron = caLinearHead[0].AsObject(); for(int i = int(caChannelFusion.Size()) - 1; i >= 0; i--) { if(!caChannelFusion[i].calcHiddenGradients(neuron)) return false; neuron = caChannelFusion[i].AsObject(); }
该传递过程持续进行,直至抵达拼接完成的输入频域数据层。
for(int i = int(caProjection.Size()) - 1; i >= 0; i--) { if(!caProjection[i].calcHiddenGradients(neuron)) return false; neuron = caProjection[i].AsObject(); } //--- if(!cFreqConcat.calcHiddenGradients(neuron)) return false;
此时将梯度数据拆分为实部与虚部。
if(!DeConcat(caFreqIn[0].getGradient(), caFreqIn[1].getGradient(), cFreqConcat.getGradient(), 1, 1, caFreqIn[0].Neurons())) return false;
再执行逆傅里叶变换,把梯度转回时域格式。
if(!FFT(caFreqIn[0].getGradient(), caFreqIn[1].getGradient(), caFreqIn[0].getPrevOutput(), caFreqIn[1].getPrevOutput(), iCount, false)) return false;
我们仅保留有效输入数据对应的梯度部分。
if(!DeConcat(cTranspose.getGradient(), caFreqIn[0].getGradient(), caFreqIn[0].getPrevOutput(), iWindow, caFreqIn[0].Neurons() / iCount - iWindow, iCount)) return false; //--- if(!SumAndNormilize(cTranspose.getGradient(),cTranspose.getPrevOutput(),cTranspose.getGradient(),iWindow, false,0,0,0,1.0f)) return false;
将当前梯度与此前保存的残差连接梯度进行合并。
最后把梯度转置为原始输入格式,若有需要,再结合输入层激活函数的导数完成梯度修正。
if(!prevLayer.calcHiddenGradients(cTranspose.AsObject())) return false; if(prevLayer.Activation() != None) { if(!DeActivation(prevLayer.getOutput(), prevLayer.getGradient(), prevLayer.getGradient(), prevLayer.Activation())) return false; } //--- return true; }
向调用方返回执行状态,本方法运行结束。
至此,关于 CATCH 框架整套方案的代码实现讲解全部完成。文中所有组件与方法的完整源码均收录在附件中。
模型架构
讲解完具体实现算法后,我们简要介绍可训练模型的整体架构。和此前的研究一致,本次共训练三个模型:环境状态编码器、动作器,以及用于预测下一行情涨跌概率的模型。CATCH 框架被集成到环境状态编码器中,用于提取观测状态特征。由于整套框架被封装为独立组件,最终呈现的模型架构简洁直观。
bool CreateDescriptions(CArrayObj *&encoder, CArrayObj *&actor, CArrayObj *&probability) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; } if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!probability) { probability = new CArrayObj(); if(!probability) return false; } //--- Encoder encoder.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
和往常一样,第一层为全连接层,用于对原始输入数据做特征编码,其后衔接批量归一化层。该环节会对多变量时间序列进行初步预处理,将各组单变量序列缩放到相近的数值范围。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
接下来接入我们的 CATCH 模块。这里将频谱划分为 8 个单元,步长设为 1。这种设置能够更细致地分析输入数据整个频谱范围内的各类关联关系。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronCATCH; prev_count=descr.count = HistoryBars; { int temp[]={BarDescr,8,32,4}; // Variables, Frequency window, Key Size, Heads if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.step=1; int prev_out=descr.windows[0]; descr.batch = 1e4; descr.optimization=ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
随后对输出特征做缩放处理,将数值限定在较窄的归一化区间内。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = HistoryBars; descr.window = BarDescr; descr.step = BarDescr; descr.window_out = BarDescr; descr.layers = 1; descr.activation = TANH; if(!encoder.Add(descr)) { delete descr; return false; }
最后通过反向归一化层,还原数据原本的分布特征。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronRevInDenormOCL; descr.count = HistoryBars * BarDescr; descr.layers = 1; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
动作器与行情涨跌概率预测模型的架构,和此前版本基本保持一致。相关内容请读者自行查阅。完整源码详见附件。环境交互脚本与模型训练脚本也沿用原有版本,未做任何修改。
测试
我们已完成在 MQL5 中实现 CATCH 框架,并将其集成至可训练模型的全部开发工作。如今进入关键阶段:在真实历史行情数据上,检验该方案的实际效果。通过这一环节,可以客观评估这套实现方案的优缺点,并判断其后续优化空间。
训练所用数据集,由 MetaTrader 5 策略测试器生成的随机交易片段组合而成。数据集基于 2024 年全年的欧元兑美元一分钟(EURUSD M1)历史行情构建。
模型评估则采用 2025 年 1 月至 3 月的历史行情数据。所有实验参数均保持不变,以此保证结果客观,公正评判策略表现。该实验设置可确保模型并非单纯记忆训练集数据,而是真正具备适应全新市场行情的能力。
下面将展示本次测试结果。

在测试周期内,模型共计完成 68 笔交易,其中盈利平仓 32 笔,胜率略高于 47%。与此同时,每笔盈利交易的平均收益,接近亏损交易平均亏损幅度的两倍。最终模型在整个测试区间实现整体盈利,利润因子达到 1.72。
结论
本文分两部分讲解了 CATCH 框架的理论原理。该创新方案结合傅里叶变换与频域分块机制,用于多变量时间序列的异常检测。它的核心优势在于,能够挖掘出仅在时域分析时无法发现的复杂规律。
将数据转换至频域进行分析,可以更深入地解读市场运行特征。频域分块机制提升了分析的灵活性,让模型能够适配不断变化的市场环境。与传统方法不同,CATCH 不再局限于识别剧烈价格波动或异常离群值,还可以捕捉数据内部的潜在关联关系。
在实践部分,我们基于 MQL5 完成了该方案的代码实现、模型训练,并使用真实历史数据开展测试。测试结果表明该方案具备良好的应用潜力,但仍存在若干有待进一步优化的问题。
参考
本文用到的程序
| # | 名称 | 类型 | 说明 |
|---|---|---|---|
| 1 | Research.mq5 | EA | 采集样本数据的EA |
| 2 | ResearchRealORL.mq5 | EA | 采用 Real-ORL 方法采集样本的 EA |
| 3 | Study.mq5 | EA | 模型训练EA |
| 4 | Test.mq5 | EA | 模型测试EA |
| 5 | Trajectory.mqh | 类库 | 系统状态与模型架构描述结构 |
| 6 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
| 7 | NeuroNet.cl | 代码库 | OpenCL 程序代码 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/17675
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
您应该了解的MQL5向导技巧(第六十九部分):使用SAR与RVI的形态
开发多币种 EA(第 26 部分):交易品种信息工具
市场模拟(第 18 部分):SQL 入门(一)