English Русский Español Português
preview
交易中的神经网络:解耦结构化分量(结论)

交易中的神经网络:解耦结构化分量(结论)

MetaTrader 5 — 交易系统 |
12 0
Dmitriy Gizlyk
Dmitriy Gizlyk

引言

在本文中,我们基于SCNN框架作者所提出的方法、按照我们自身的理解进行MQL5实现的最后阶段。SCNN(季节性卷积神经网络)是专为分析具有明确结构特征的时间序列而设计的网络架构。它的核心目标是将原始数据拆解为几个核心分量:长期趋势、季节分量与短期分量,同时兼顾不同变量之间的空间关联关系。这种分解方式不仅可以提升预测效果,还能保证模型具备可解释性 —— 这在算法交易场景中是十分难得但又极具价值的特性。

SCNN以经典分析原理为基础,包含周期归一化与季节性变换处理,同时融合现代信息处理手段:注意力机制、参数化特征投影以及卷积结果聚合。SCNN框架原作者的架构示意图展示如下。

在前文中,我们已经系统研究了模型全部内部组件的结构与作用,借助专用类完成组件开发,逐一搭建这套架构的各个组成单元。这项开发工作颇具挑战,但成果值得付出努力:我们现已拥有一套功能完备的模块集合,每个模块在系统中承担明确、固定的职责。

接下来顺理成章的步骤,就是将这些模块整合为一套统一、连贯的整体结构。SCNN的架构框架正是在这一阶段成型,它规定了模型内部信息流的完整组织方式 —— 从输入数据一直到最终预测输出。我们从准备阶段进入运行阶段:各个组件开始协同工作、相互交互,最终形成一套完整的分析机制。

技术组装工作已经完成,接下来进入大家最期待的环节 —— 在历史数据上测试模型。这一步不仅可以验证代码实现是否正确,还能评估该方法在不同市场状态下的实际稳健性。SCNN并非又一款普通的神经网络架构。它致力于兼顾计算精度,同时保证决策过程的透明性。



SCNN编码器

在前一篇文章中,我们完成了对SCNN 编码器架构的分析,该编码器由CNeuronSCNNEncoder对象实现,同时详细讲解了其全部内部组件的初始化流程。每一个模块 —— 无论是归一化、转置还是空间适配模块 —— 均已准备就绪,可接收并处理输入数据。该对象的结构如下所示。

class CNeuronSCNNEncoder   :  public CNeuronTransposeOCL
  {
protected:
   CNeuronPeriodNorm       cLongNorm;
   CNeuronTransposeVRCOCL  cSeasonTransp;
   CNeuronPeriodNorm       cSeasonNorm;
   CNeuronTransposeVRCOCL  cUnSeasonTransp;
   CNeuronPeriodNorm       cShortNorm;
   CNeuronAdaptSpatialNorm cAdaptSpatNorm;
   CNeuronBaseOCL          cConcatenated;
   CNeuronSwiGLUOCL        cProjection;
   CNeuronTransposeOCL     cTranspose;
   CNeuronConvOCL          caFusion[2];
   CNeuronBaseOCL          cFusionOut;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)  override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL)  override;

public:
                     CNeuronSCNNEncoder(void) {};
                    ~CNeuronSCNNEncoder(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint units_count, uint variables, uint forecast,
                          uint season_period, uint short_period,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual bool      Save(const int file_handle) override;
   virtual bool      Load(const int file_handle) override;
   //---
   virtual int       Type(void) override const  {  return defNeuronSCNNEncoder; }
   virtual void      TrainMode(bool flag) override;
   virtual void      SetOpenCL(COpenCLMy *obj) override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      SetActivationFunction(ENUM_ACTIVATION value) override { }
  };

现在,我们继续之前的开发工作,开始搭建核心组件 —— 前向传播算法。该算法决定数据如何依次流经模型的所有网络层,完成变换与特征聚合,最终生成输出表征。

feedForward方法中实现的前向传播逻辑,展现了整个系统的协同运行机制。

bool CNeuronSCNNEncoder::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cLongNorm.FeedForward(NeuronOCL))
      return false;

输入数据首先经过长期归一化模块,消除长期累积带来的偏移与量纲差异。这一步为后续分析构建对齐好的基础数据。

处理后的数据沿着链路传递到季节分量提取模块。正如前一篇文章所述,该模块会按照与季节周期对应的指定步长执行转置操作。处理后,时间序列元素会按照周期相位分组形成序列,从而更高效地识别稳定的季节性形态。对于得到的数据结构执行周期归一化,强化季节波动特征的表达能力。随后执行逆转置,为后续处理步骤准备数据。

   if(!cSeasonTransp.FeedForward(cLongNorm.AsObject()))
      return false;
   if(!cSeasonNorm.FeedForward(cSeasonTransp.AsObject()))
      return false;
   if(!cUnSeasonTransp.FeedForward(cSeasonNorm.AsObject()))
      return false;

季节分量处理完成后,进入短期分量提取阶段。这一步中,我们主要提取局部快速波动,这类波动中可能包含近期市场动态变化的重要信息。

   if(!cShortNorm.FeedForward(cUnSeasonTransp.AsObject()))
      return false;

预处理阶段的最后一步是执行空间归一化。该环节至关重要,用于对齐从长期、季节、短期各个分量中提取出来的信息。空间归一化能够调整输入变量的尺度与相对影响力,消除因各个特征本身波动特性差异带来的数据失真。该步骤的核心特点:实现时考虑了空间依赖关系,并引入注意力机制,让最终得到的特征表征具备很强的表达能力与信息承载能力。

   if(!cAdaptSpatNorm.FeedForward(cShortNorm.AsObject()))
      return false;

进入下一阶段前,需要将所有模块的输出结果(归一化后的数据以及计算得到的统计参数)合并为一个完整统一的张量。这一步至关重要,保证模型内部后续信息流的完整性。

需要强调的是,虽然归一化操作保留了时间序列本身的维度,但我们刻意选择不将统计参数(均值与标准差)拉伸至和序列等长。该设计是为节约内存 —— 毕竟在整条序列上重复相同数值,并不会带来任何额外信息。然而,这项优化会造成时间序列与配套统计量之间出现维度不匹配问题。

即便如此,在被分析的单变量序列数量维度上,二者仍保有统一网格结构,这正是关键所在。拼接操作正是依托这一点实现。所有运算都在这些单变量分段上依次执行,以此保证最终数据组装的正确性,防止数据结构失真。

在合并阶段最开始,我们构建第一个拼接块,包含原始输入数据与长期归一化模块的输出。这样既能保留原始序列的上下文信息,又补充了归一化过程识别出的长期趋势信息。除此之外,我们将计算得到的统计参数(均值和标准差)一并加入该数据集,用作尺度与波动程度的标记。

   uint windows[3] = {NeuronOCL.Neurons() / iWindow,
                      cLongNorm.GetPeriod()*cLongNorm.GetUnits(),
                      2 * cLongNorm.GetUnits()
                     };
   if(!Concat(NeuronOCL.getOutput(), cLongNorm.getOutput(), cLongNorm.GetMeanSTDevs().getOutput(),
              cConcatenated.getOutput(), windows[0], windows[1], windows[2], iWindow))
      return false;

下一步中,我们利用从季节分量中提取的信息对张量进行扩充。

   windows[0] = windows[0] + windows[1] + windows[2];
   windows[1] = cSeasonNorm.GetPeriod() * cSeasonNorm.GetUnits();
   windows[2] = 2 * cSeasonNorm.GetUnits();
   if(!cConcatenated.SwapOutputs() ||
      !Concat(cConcatenated.getPrevOutput(), cSeasonNorm.getOutput(),
              cSeasonNorm.GetMeanSTDevs().getOutput(), cConcatenated.getOutput(),
              windows[0], windows[1], windows[2], iWindow))
      return false;

加入季节分量信息之后,再依次将短期分量以及空间归一化模块的数据拼入张量。在这一阶段,我们继续沿用沿着单变量序列进行合并的思路,不断新增特征以扩充张量。

   windows[0] = windows[0] + windows[1] + windows[2];
   windows[1] = cShortNorm.GetPeriod() * cShortNorm.GetUnits();
   windows[2] = 2 * cShortNorm.GetUnits();
   if(!cConcatenated.SwapOutputs() ||
      !Concat(cConcatenated.getPrevOutput(), cShortNorm.getOutput(),
              cShortNorm.GetMeanSTDevs().getOutput(), cConcatenated.getOutput(),
              windows[0], windows[1], windows[2], iWindow))
      return false;
   windows[0] = windows[0] + windows[1] + windows[2];
   windows[1] = cAdaptSpatNorm.GetUnits();
   windows[2] = 2 * cAdaptSpatNorm.GetUnits();
   if(!cConcatenated.SwapOutputs() ||
      !Concat(cConcatenated.getPrevOutput(), cAdaptSpatNorm.getOutput(),
              cAdaptSpatNorm.GetMeanSTDevs().getOutput(), cConcatenated.getOutput(),
              windows[0], windows[1], windows[2], iWindow))
      return false;

短期分量为模型提供最新的上下文信息,包括局部波动、脉冲尖峰与微观形态,这些信息对于提升预测灵敏度尤为关键。而空间归一化则进一步完善预处理阶段,为全部变量的原始数据带来额外稳定性并做平滑处理。

最终,我们得到具备信息价值且均衡的时间序列表征,所有分量都以统一格式呈现。这份合并后的张量送入投影层。投影层的作用是结合预测视界,生成输入数据的加权表征。在此阶段,数据被调整至目标输出维度,适配后续分析任务。正是在这一步,为生成高质量预测结果奠定基础,该预测能够同时兼顾历史纵深与预期变化的结构特征。

   if(!cProjection.FeedForward(cConcatenated.AsObject()))
      return false;

在前序阶段得到的各个单变量序列表征,会在融合(Fusion)模块中对齐,构成多模态时间序列结构。这里,表征的组织形式从单变量序列转换为时间步维度:张量执行转置操作,后续处理将沿着时间轴开展。

在这一阶段,使用两层连续的卷积层。第一层采用TANH激活函数,能够学习每个特征丰富的非线性表征,本质上得到特征信号的归一化表达。第二层使用SIGMOID激活函数,充当门控单元:通过对输出值做柔性加权,判断在时间上下文内各个特征的重要程度。

将两层输出做逐元素相乘后得到最终结果,该方式可以有效滤除噪声,并在适配预测任务的对齐特征空间中,凸显时间序列里最关键的特征。

   if(!cTranspose.FeedForward(cProjection.AsObject()))
      return false;
   for(uint i = 0; i < caFusion.Size(); i++)
      if(!caFusion[i].FeedForward(cTranspose.AsObject()))
         return false;
   if(!ElementMult(caFusion[0].getOutput(), caFusion[1].getOutput(), cFusionOut.getOutput()))
      return false;
//---
   return CNeuronTransposeOCL::feedForward(cFusionOut.AsObject());
  }

在前向传播的最后阶段,数据执行逆转置运算,从时间维度表征还原回单变量序列的原始结构。以此保证编码器的输入与输出格式保持一致。

由此可见,feedForward方法是一套精心设计、逻辑严谨的架构,其中每个组件都承担明确固定的功能。从归一化、分解,到特征投影与多模态聚合,整套流程旨在生成对时间序列信息丰富、结构化且具备高信息量的描述。

完成前向传播算法分析后,接下来进入同等重要的环节 —— 反向传播。模型正是在这一步开始学习:在输出端计算得到梯度值,沿着整条运算链路反向回传,以此调整各个模块的内部参数。calcInputGradients方法实现该过程,严格保持运算顺序,并且和前向传播架构保持对应一致。

bool CNeuronSCNNEncoder::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;
//---
   if(!CNeuronTransposeOCL::calcInputGradients(cFusionOut.AsObject()))
      return false;
   if(!ElementMultGrad(caFusion[0].getOutput(), caFusion[0].getGradient(),
                       caFusion[1].getOutput(), caFusion[1].getGradient(),
                       cFusionOut.getGradient(), caFusion[0].Activation(), caFusion[1].Activation()))
      return false;
   if(!cTranspose.CalcHiddenGradients(caFusion[0].AsObject()))
      return false;

反向传播从模型顶层启动,其输入是来自模型下一层的梯度;如果当前层本身就是输出层,则输入梯度直接由损失函数生成。首先处理融合模块内部的卷积块。需要明确的是,在前向传播阶段,我们采用两条并行卷积通道:一条使用TANH激活函数生成非线性特征;另一条使用SIGMOID充当门控单元,用于判定特征的重要程度。两条通道的输出在末端做逐元素相乘,得到最终的特征表征。

在反向传播过程中,该乘法运算需要特殊处理。ElementMultGrad方法结合每种激活函数的特性,将梯度正确拆分回两个分支。这一步对于计算精度至关重要,因为该环节决定卷积权重的更新方式。

接下来,我们将梯度传递至转置模块。回顾前向传播,该模块负责调整张量轴方向,把数据表征从单变量序列形式转换为时间步形式。数据正是以这种形式送入融合模块的两层卷积。因此反向传播的任务,是正确合并来自两个分支的梯度。

第一步是向下传递其中一个卷积层的误差梯度。此处不用复制整张张量的数据,而是替换数据缓冲区指针。该方式可以高效切换内存区域、保存计算结果,避免多余的资源开销。完成指针替换后,我们再执行第二个卷积层的反向传播。

   CBufferFloat* temp = cTranspose.getGradient();
   if(!cTranspose.SetGradient(cTranspose.getPrevOutput(), false) ||
      !cTranspose.CalcHiddenGradients(caFusion[1].AsObject()) ||
      !SumAndNormilize(temp, cTranspose.getGradient(), temp, cTranspose.GetCount(),
                                                              false, 0, 0, 0, 1) ||
      !cTranspose.SetGradient(temp, false))
      return false;

之后,两路梯度流通过逐元素求和进行累加,以此体现两条分支共同对最终误差产生的影响。最后,缓冲区指针恢复为初始状态,保证数据结构能够正确支持后续梯度传播。该方法确保训练过程中模型逻辑自洽,同时实现高效的内存管理。

随后误差梯度传递到投影模块,投影模块的作用是在卷积模块之前对信息做加权处理。梯度继续向下传递至拼接模块,这里进入技术复杂度最高的环节之一:反向拆分拼接(反拼接)。

   if(!cProjection.CalcHiddenGradients(cTranspose.AsObject()))
      return false;
   if(!cConcatenated.CalcHiddenGradients(cProjection.AsObject()))
      return false;

回顾一下,在前向传播阶段,我们逐步合并来自各个归一化模块的数据:长期归一化、季节归一化、短期归一化以及空间归一化模块。除此之外,合并张量中除归一化数据外,还纳入了均值、标准差这类统计参数。

现在要执行逆向操作:将拼接在一起的张量 拆解开,还原成原先各个组成部分。该过程通过顺序执行的反拼接(DeConcat)流程实现,每个分量对应的窗口大小严格按照之前排布规则计算。这一步要求极高的精度,窗口尺寸哪怕出现微小偏差,都会造成数据错位或者信息丢失。

   uint windows[3] = {0};
   windows[1] = cAdaptSpatNorm.GetUnits();
   windows[2] = 2 * cAdaptSpatNorm.GetUnits();
   windows[0] = cConcatenated.Neurons() / iWindow - windows[1] - windows[2];
   if(!DeConcat(cConcatenated.getPrevOutput(), cAdaptSpatNorm.getGradient(),
                cAdaptSpatNorm.GetMeanSTDevs().getGradient(),
                cConcatenated.getGradient(), windows[0], windows[1], windows[2], iWindow))
      return false;
   windows[1] = cShortNorm.GetPeriod() * cShortNorm.GetUnits();
   windows[2] = 2 * cShortNorm.GetUnits();
   windows[0] = windows[0] - windows[1] - windows[2];
   if(!DeConcat(cConcatenated.getGradient(), cShortNorm.getPrevOutput(),
                cShortNorm.GetMeanSTDevs().getGradient(),
                cConcatenated.getPrevOutput(), windows[0], windows[1], windows[2], iWindow))
      return false;
   windows[1] = cSeasonNorm.GetPeriod() * cSeasonNorm.GetUnits();
   windows[2] = 2 * cSeasonNorm.GetUnits();
   windows[0] = windows[0] - windows[1] - windows[2];
   if(!DeConcat(cConcatenated.getPrevOutput(), cSeasonNorm.getPrevOutput(),
                cSeasonNorm.GetMeanSTDevs().getGradient(),
                cConcatenated.getGradient(), windows[0], windows[1], windows[2], iWindow))
      return false;
   windows[1] = cLongNorm.GetPeriod() * cLongNorm.GetUnits();
   windows[2] = 2 * cLongNorm.GetUnits();
   windows[0] = windows[0] - windows[1] - windows[2];
   if(!DeConcat(NeuronOCL.getPrevOutput(), cLongNorm.getPrevOutput(),
                cLongNorm.GetMeanSTDevs().getGradient(),
                cConcatenated.getPrevOutput(), windows[0], windows[1], windows[2], iWindow))
      return false;

在这一阶段,有一处关键技术要点需要重点说明。除空间归一化之外,其余所有归一化模块都并非数据处理链路的末端环节。它们的输出结果会作为后续处理流程的输入。这就意味着在反向传播时,这类模块接收的误差梯度来源不止一处。如果沿用简易模型里常见的做法,直接覆盖梯度值,就无法正确核算后续所有运算带来的贡献。因此,方案中引入了备份缓冲区:每个模块在接收新梯度前,会先把当前梯度保存至该缓冲区。

由此,归一化链路的反向传播依靠专用缓冲区完成梯度累加。该机制确保精准还原模型各个组件的影响,避免信息丢失。

反拼接完成之后,各个归一化模块(依次为短期归一化cShortNorm、季节归一化 cSeasonNorm、长期归一化cLongNorm)都会分到对应的误差梯度分量,该梯度体现了前向传播整条数据处理链路带来的影响。但正如前文所述,这些归一化模块的数据输出会供给多处使用。所以仅仅沿着单一路径回传梯度是不够的。

具体实现方式如下:当归一化模块从使用其输出数据的上游对象拿到误差梯度后,会把这份梯度和反拼接阶段已经得到的梯度值相加。也就是说,每一步都需要仔细汇总多路来源的梯度信息。完成这次聚合之后,合并得到的梯度才会沿着链路继续回传给上一层。

   if(!cShortNorm.CalcHiddenGradients(cAdaptSpatNorm.AsObject()) ||
      !SumAndNormilize(cShortNorm.getGradient(), cShortNorm.getPrevOutput(),
                       cShortNorm.getGradient(), cShortNorm.GetPeriod(), false, 0, 0, 0, 1))
      return false;
   if(!cUnSeasonTransp.CalcHiddenGradients(cShortNorm.AsObject()))
      return false;
   if(!cSeasonNorm.CalcHiddenGradients(cUnSeasonTransp.AsObject()) ||
      !SumAndNormilize(cSeasonNorm.getGradient(), cSeasonNorm.getPrevOutput(),
                       cSeasonNorm.getGradient(), cSeasonNorm.GetPeriod(), false, 0, 0, 0, 1))
      return false;
   if(!cSeasonTransp.CalcHiddenGradients(cSeasonNorm.AsObject()))
      return false;
   if(!cLongNorm.CalcHiddenGradients(cSeasonTransp.AsObject()) ||
      !SumAndNormilize(cLongNorm.getGradient(), cLongNorm.getPrevOutput(),
                       cLongNorm.getGradient(), cLongNorm.GetPeriod(), false, 0, 0, 0, 1))
      return false;

该机制确保前向传播建立的所有信息链路,在反向传播过程中不会丢失或失真。正是这种严谨、精细的梯度传递机制,让SCNN框架在实盘行情环境下兼具稳定性与高可解释性。

反向传播的收尾工作,是在最输入端 —— NeuronOCL对象完成梯度计算。这一步至关重要,所有信息流经过一长串复杂变换与归一化处理后,最终都会汇聚于此。

   if(!NeuronOCL.CalcHiddenGradients(cLongNorm.AsObject()) ||
      !SumAndNormilize(NeuronOCL.getGradient(), NeuronOCL.getPrevOutput(),
                       NeuronOCL.getGradient(), cLongNorm.GetPeriod(), false, 0, 0, 0, 1))
      return false;
//---
   return true;
  }

需要注意的是,有两条核心梯度流在此处汇聚:一路来自复杂的归一化体系,另一路来自投影层(投影层负责构建预测所用的特征空间)。对两路梯度的合理聚合,可以保证梯度流连续,并且在训练过程中正确更新模型权重。

由此可见,calcInputGradients方法不只是沿着运算链路逆向执行的一个技术步骤,而是一套经过精细设计、模块化、结构化的反向传播流程。该方法中的每一步运算都和前向传播的流程严格对应,这使得SCNN编码器 架构具备自洽、对称、易于解释的特点。

编码器CNeuronSCNNEncoder可训练参数优化工作,交由对应的内部模块分别完成;每个模块都维护自身权重,并实现独立的更新逻辑。updateInputWeights方法按顺序将控制流转交给各个子模块,以此实现训练流程集中管控、同时保持模块化的管理模式。

bool CNeuronSCNNEncoder::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cAdaptSpatNorm.UpdateInputWeights(cShortNorm.AsObject()))
      return false;
   if(!cProjection.UpdateInputWeights(cConcatenated.AsObject()))
      return false;
   for(uint i = 0; i < caFusion.Size(); i++)
      if(!caFusion[i].UpdateInputWeights(cTranspose.AsObject()))
         return false;
//---
   return true;
  }

得益于模块化架构,权重更新算法简洁清晰,不会引入多余依赖,造成代码臃肿。

CNeuronSCNNEncoder类的完整源码(包含全部核心方法实现)见附件。这套代码结构清晰、模块划分细致,可作为后续实验与二次开发的基础。



顶层模块

SCNN框架原作者提出采用层级架构,将多个编码器堆叠在一起。该设计旨在增强模型的表征能力,从而改善预测效果。在这条链路里,后一级编码器并非独立运算,而是把前一级编码器的输出作为输入数据。残差连接为分析过程补充额外上下文信息。最终模型能够纳入更广范围的上下文,开展更深层次的分析,同时捕捉时间序列内部的局部依赖与长期依赖关系。

然而,这种灵活性是有代价的。层与层之间传递数据时会遇到一个特殊问题:输入张量与输出张量尺寸不一致。原因在于每个SCNN编码器输出的虽然是格式兼容的单变量序列,但序列末尾附带了指定预测步长的预测值。

由此产生一个棘手问题:一方面,送入下一层的数据不能包含预测值;另一方面,这些预测值又不能从整体计算流程中简单丢弃,因为它们仍需用于所有层输出的最终求和。简单来说,我们既要保留模型已经观测到的历史数据,也要保留预测出来的结果,还不能破坏数据的时间结构。

为解决该问题,我们新建一个顶层对象 —— CNeuronSCNN。它是一个总控单元,封装了整套编码器堆叠结构。它的职责不只是依次调用各个编码器,还要保障整套架构正常运行:完成层间数据传递、张量尺寸对齐、预测结果累加,以及训练和反向传播流程的调度。该组件是保障模型计算逻辑与架构一致性的核心纽带。

该新对象的结构如下。

class CNeuronSCNN    :  public   CNeuronBaseOCL
  {
protected:
   CLayer            cLayers;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)  override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL)  override;

public:
                     CNeuronSCNN(void) {};
                    ~CNeuronSCNN(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint units_count, uint variables, uint forecast,
                          uint season_period, uint short_period, uint layers,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual bool      Save(const int file_handle) override;
   virtual bool      Load(const int file_handle) override;
   //---
   virtual int       Type(void) override const  {  return defNeuronSCNN; }
   virtual void      TrainMode(bool flag) override;
   virtual void      SetOpenCL(COpenCLMy *obj) override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      SetActivationFunction(ENUM_ACTIVATION value) override { }
  };

CNeuronSCNN内部包含一个CLayer类型对象,该对象本质上作为容器,承载所有内嵌的编码器。该类仅内嵌一个CLayer对象,因此无需在构造函数和析构函数中显式处理该成员的内存管理。

要让整套系统正常运行,必须基于各个组件正确组装架构。该任务在Init方法中完成,CNeuronSCNN对象的整套动态结构都在这里创建并配置参数。

bool CNeuronSCNN::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                       uint units_count, uint variables, uint forecast,
                       uint season_period, uint short_period, uint layers,
                       ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, (units_count + forecast)*variables,
                                                                   optimization_type, batch))
      return false;
   SetActivationFunction(None);

整个流程首先调用父类同名方法完成基础初始化,创建神经层的基础接口。接下来,禁用激活函数:顶层模块本身不需要激活函数,因为各个编码器已经提供了全部所需的非线性变换与数据预处理能力。

之后清空cLayers容器,为存放所需子对象做好准备。

   cLayers.Clear();
   cLayers.SetOpenCL(OpenCL);
   CNeuronSCNNEncoder* encoder = NULL;
   CNeuronBaseOCL* residual = NULL;
   for(uint l = 0; l < layers; l++)
     {
      encoder = new CNeuronSCNNEncoder();
      if(!encoder)
         return false;
      if(!encoder.Init(0, l, OpenCL, units_count, variables, forecast, season_period,
                                                  short_period, optimization, iBatch) ||
         !cLayers.Add(encoder))
         return false;
      encoder.SetActivationFunction(None);
      if((l + 1) == layers)
         break;

按照层数参数layers开启循环,逐个创建CNeuronSCNNEncoder类型的编码器。每一层都会新建一个编码器对象,并使用以下参数完成初始化:输入维度、变量数量、预测长度以及季节周期。如果初始化成功,则将该对象加入层容器。

然而,工作并未到此结束。为维持层与层之间的残差连接,编码器之间会插入额外层,充当对齐缓冲单元。这些中间模块接收前一级编码器的输出,并剔除其中的预测值,保障层级之间数据流连续。这里同样禁用激活函数 —— 毕竟这些模块只承担辅助功能,不参与核心计算。

      residual = new CNeuronBaseOCL();
      if(!residual)
         return false;
      if(!residual.Init(0, l, OpenCL, units_count * variables, optimization, iBatch) ||
         !cLayers.Add(residual))
         return false;
      residual.SetActivationFunction(None);
     }
   if(!SetGradient(encoder.getGradient(), true))
      return false;
//---
   return true;
  }

需要重点说明的是,堆叠结构中最后一个编码器的后方不再配置辅助层。该编码器传回的梯度直接作为整个顶层模块的主梯度,省去一次额外的数据拷贝操作。

因此,Init方法已经不只是简单的初始化函数,而相当于一个对象构建器,它在运行时动态组装整套编码器堆叠结构;构建过程中精细处理每一处细节,兼顾张量尺寸对齐、预测视界以及梯度流管控。这套架构设计精良,不仅支持模型高效训练,还能够拓展适配各类实际任务,既可以用于短期价格预测,也能够开展季节性趋势的长期分析。

初始化阶段完成,多层SCNN模型结构组装完毕、就绪可用之后,就进入核心步骤 —— 执行前向传播。模型在此环节执行核心功能:处理原始数据、生成预测值,并在每一层完成信息累积。

bool CNeuronSCNN::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;
   if(!Output.Fill(0))
      return false;
   CNeuronBaseOCL* inputs = NeuronOCL;
   CNeuronSCNNEncoder* current = NULL;
   CNeuronBaseOCL* residual = NULL;
   int layers = cLayers.Total();

流程首先校验传入的源数据指针是否有效。如果数据缺失,则终止执行。随后清空结果缓冲区,避免残留上一轮迭代产生的无效数据。接着将inputs变量初始化为外部数据源指针,该数据源将作为编码器的输入。之后开始遍历所有层级。

该模型架构采用交替排布设计:SCNN编码器放在偶数位置,而以基础神经元对象实现的残差连接缓冲层放在奇数位置。之所以将循环步长设置为2,就是为了在单次循环中同时处理一个编码器及其配套的残差缓冲块

   for(int l = 0; l < layers; l += 2)
     {
      current = cLayers[l];
      if(!current ||
         !current.FeedForward(inputs) ||
         !SumAndNormilize(Output, current.getOutput(), Output, current.GetCount(), false, 0, 0, 0, 1))
         return false;
      if((l + 1) == layers)
         break;

每次循环迭代中,取出当前编码器,调用FeedForward方法,再把该模块的输出累加到预测结果中。因此,在前向传播阶段,来自所有层级预测值的累加总和就开始逐步形成。

但如果当前不是最后一层,后续仍有处理步骤。取出下一个(奇数位置)对象,也就是残差连接缓冲单元。它的作用是为下一级编码器调整输入数据维度,剔除因附加预测值带来的冗余部分。该功能通过DeConcat方法实现:把预测片段和纯净的历史数据分离开,将数据还原为下一个编码器所需的、仅包含历史样本的格式。之后,新的输入数据与上一层输入相加,实现残差连接效果,再做归一化处理。每一层都不是独立运算,而是结合前序层级的输出进行计算,获得信息更丰富的时间序列表征。

      uint variables = current.GetWindow();
      uint dimension = inputs.Neurons() / variables;
      uint forecast = current.GetCount() - dimension;
      residual = cLayers[l + 1];
      if(!residual)
         return false;
      if(!DeConcat(residual.getOutput(), current.getPrevOutput(), current.getOutput(),
                   dimension, forecast, variables) ||
         !SumAndNormilize(residual.getOutput(), inputs.getOutput(), residual.getOutput(),
                          dimension, true, 0, 0, 0, 1))
         return false;
      inputs = residual;
     }
//---
   return true;
  }

经过反拼接与归一化处理后的数据送入下一个编码器,循环持续执行,直到完成全部层级的遍历。最终输出为聚合预测结果,是模块内所有编码器协同运算得到的汇总结果。

由此可见,feedForward方法是一套结构严谨的流程:每一步都旨在丰富信息、消除偏差,输出均衡可靠的预测值。它实现了堆叠架构的核心设计理念:在不丢失历史上下文信息的前提下逐步深化分析,同时兼顾每一层输出的贡献。

前向传播执行完毕后,进入同等关键的阶段 —— 反向传播。梯度从输出层向输入数据方向回传,以此完成模型参数的优化。该过程在calcInputGradients方法中实现。

bool CNeuronSCNN::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;
   if(!PrevOutput.Fill(0))
      return false;
//---
   CNeuronBaseOCL* inputs = NULL;
   CNeuronSCNNEncoder* current = cLayers[-1];
   CNeuronBaseOCL* residual = NULL;
   int layers = cLayers.Total() - 2;

该流程首先执行基础校验:检查外部NeuronOCL对象指针是否有效,并清空辅助缓冲区PrevOutput。接下来确定参与训练的层级总数。需要说明的是,层级遍历是逆序进行的,由最后一个编码器开始,因为反向传播要求从模型输出端向输入端回传。

将循环步长设置为"-1",遍历包含残差连接对象在内的全部层级,并根据层级类型,分别执行对应的处理逻辑。

   for(int l = layers; l >= 0; l--)
      switch(cLayers[l].Type())
        {
         case defNeuronBaseOCL:
            inputs = cLayers[l];
            if(!inputs ||
               !inputs.CalcHiddenGradients(current))
               return false;
            if(!!residual)
               if(!SumAndNormilize(inputs.getGradient(), residual.getGradient(), inputs.getGradient(),
                                   current.GetWindow(), false, 0, 0, 0, 1))
                  return false;
            residual = inputs;
            break;

如果当前处理的是基础对象CNeuronBaseOCL,则执行两项操作:第一,基于当前编码器计算隐藏层梯度;第二,如果存在前序残差块(变量residual),就将其梯度与当前梯度相加,对向下传递的梯度值进行修正。该机制保障层与层之间信息连续传递,避免因架构的残差结构带来信息损失。

而如果当前层是SCNN编码器,处理逻辑则更为复杂。先检查残差对象residual是否已实例化,如果不存在,则直接跳过;如果存在,则取出往前两层的对象,即前向传播时为当前编码器提供输入的那个模块,以便精准还原数据结构。

 case defNeuronSCNNEncoder:
    current = cLayers[l];
    if(!residual)
       break;
    inputs = cLayers[l + 2];
    if(!inputs)
       return false;
    if(!Concat(residual.getGradient(), PrevOutput, current.getGradient(),
               residual.Neurons() / current.GetWindow(),
               current.GetCount() - residual.Neurons() / current.GetWindow(),
               current.GetWindow()) ||
       !SumAndNormilize(current.getGradient(), inputs.getGradient(), current.getGradient(),
                        current.GetWindow(), false, 0, 0, 0, 1))
       return false;
    break;
 default:
    return false;
    break;
}

接下来调用Concat函数恢复梯度形状:在残差层的当前梯度之后,按PrevOutput中预测片段对应的位置补入零值。之所以要这样处理,是因为在前向传播过程中数据被拆分为历史部分与预测部分;现在误差梯度也必须严格复现该结构。接下来,将这份梯度和后一级编码器传回的梯度相加。这一步十分关键:信息会沿着两条通路传递,最终必须合并为单一梯度。

遍历完全部层级后,执行最后一步:把梯度传递到最外层,由NeuronOCL接收最后一级SCNN编码器传回的梯度;如果存在残差层,则在此完成梯度的最终求和。

   if(!NeuronOCL.CalcHiddenGradients(current))
      return false;
   if(!!residual)
      if(!SumAndNormilize(NeuronOCL.getGradient(), residual.getGradient(), NeuronOCL.getGradient(),
                          current.GetWindow(), false, 0, 0, 0, 1))
         return false;
//---
   return true;
  }

因此,calcInputGradients方法体现了堆叠架构的核心思想:每一层调整参数时,不仅依据自身产生的误差,还要考虑该层对模型整体输出带来的影响。这种机制支持精细调参,让模型对时序特征具备很高的敏感度。

当堆叠架构所有层级的梯度都计算完毕后,训练进入最后阶段 —— 权重更新。前面所有计算的价值在此实现:把累积得到的误差信息转化为模型可训练参数的修正量。

updateInputWeights方法负责逐层更新模型内全部可训练模块的权重。流程首先将inputs变量初始化为外部数据源指针,也就是NeuronOCL对象。

bool CNeuronSCNN::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   CNeuronBaseOCL* inputs = NeuronOCL;
   CNeuronBaseOCL* current = NULL;
//---
   for(int l = 0; l < cLayers.Total(); l++)
     {
      current = cLayers[l];
      if(!current)
         return false;
      if(current.Type() == defNeuronSCNNEncoder)
         if(!current.UpdateInputWeights(inputs))
            return false;
      inputs = current;
     }
//---
   return true;
  }

接下来,该方法遍历模型的全部层级。每一轮循环都会校验层级类型。我们只关心SCNN编码器,因为只有这些组件包含可训练参数。如果当前层级属于该类型,则调用其UpdateInputWeights方法,并传入输入数据。模型便可依据已经算出的梯度对权重进行正确修正。如果更新成功,就替换inputs变量内的指针,使其作为下一层的输入。从而保证前向传播与反向传播过程固有的逻辑连续性。

updateInputWeights方法完成整个训练循环:各个编码器根据模型误差自适应调整参数,共同参与全局优化。整套实现简洁务实,没有多余冗余逻辑,同时严格遵循架构设计思路。

CNeuronSCNN类及其全部方法的完整代码见附件。



模型架构

在介绍完搭建SCNN框架所需各类对象的内部逻辑之后,顺理成章下一步就是深入模型本身的架构。正是这套架构,决定了系统从数据中提取模式的精度与稳健性。神经网络流水线的配置构建逻辑在CreateDescriptions方法中实现。

该方法负责创建并填充层描述信息,这些层描述就是后续组装计算模型的基本单元。简言之,在此逐层搭建出未来神经网络的结构:从输入数据层,到编码器,再到多个预测分支。首先,我们会创建并初始化六个容器:一个用于环境状态主编码器,三个存放不同版本的预测模型,另外两个分别用于强化学习里的Actor(行动者)分支与Critic分支。

环境状态编码器从一个基础全连接层起步,接收由历史K线生成的输入向量。

bool CreateDescriptions(CArrayObj *&encoder,
                        CArrayObj *&forecast1,
                        CArrayObj *&forecast2,
                        CArrayObj *&forecast3,
                        CArrayObj *&actor,
                        CArrayObj *&critic
                       )
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
   if(!forecast1)
     {
      forecast1 = new CArrayObj();
      if(!forecast1)
         return false;
     }
   if(!forecast2)
     {
      forecast2 = new CArrayObj();
      if(!forecast2)
         return false;
     }
   if(!forecast3)
     {
      forecast3 = new CArrayObj();
      if(!forecast3)
         return false;
     }
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
   if(!critic)
     {
      critic = new CArrayObj();
      if(!critic)
         return false;
     }
//--- Encoder
   encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   uint 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 = defNeuronBatchNormWithNoise;
   descr.count = prev_count;
   descr.batch = BatchSize;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- Layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatDiff;
   prev_count = descr.count = HistoryBars;
   descr.layers = BarDescr;
   descr.step = 1;
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

接下来是一阶差分特征拼接层CNeuronConcatDiff,用于构造差分衍生特征,帮助网络更好捕捉局部变化。

需要重点关注的是CMamba4CastEmbeding层,这是一种新式结构组件。该层会结合多个时间窗口(本例为日窗口与月窗口)提取隐特征,生成包含时序谐波信息的嵌入向量。神经网络正是在这一层首次感知季节性与长期趋势。

//--- Layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defMamba4CastEmbeding;
   prev_count = descr.count = HistoryBars;
   descr.window = 2 * BarDescr;
   uint prev_out = descr.window_out = NSkills;
     {
      uint temp[] = {PeriodSeconds(PERIOD_D1), PeriodSeconds(PERIOD_MN1)};
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- Layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   descr.count = prev_count;
   prev_count = descr.window = prev_out;
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
   prev_out = descr.count;

随后,嵌入向量经过转置,转换为单变量序列表征形式。

整个环境状态编码器设计中最关键的一环,正是集成了前文介绍的SCNN编码器堆叠模块。正是这个四层嵌套、经过精细调参以捕捉季节性与短期模式的模块,构成了智能信息处理的核心。

//--- Layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSCNN;
   descr.variables = prev_count;
   {
      uint temp[]={prev_out,NForecast,SeasonPeriod,ShortPeriod};
      if(ArrayCopy(descr.windows,temp)<(int)temp.Size())
        return false;
   }
   descr.count=descr.windows[0]+descr.windows[1];
   descr.layers=4;
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
   uint variables=descr.variables;
   uint count=descr.count;

此处远不止简单的数据变换。该模块承担核心的分析计算任务:它不仅提取特征,还将特征组织成多层隐表征,每一层都在各自的尺度上对观测到的模式做泛化。在输出端,我们得到已经融合残差连接以及上层记忆信息的预测向量。

本质上讲,原始输入数据正是在这个组件内部转化为对市场状态的高层描述。抽象的市场行为模式在此转化为数值结构,预测模块与控制分支均可直接读取解析。也正是到这一步,整套架构才算闭环完成:从数据预处理、归一化,过渡到分析、解读,直至决策环节。

这里有一个重要的技术要点需要着重说明。SCNN编码器堆叠模块的输出是一组单变量序列,这种形式对后续预测模型而言既友好又合理,非常适合按指定预测视界生成预测值。但该格式并不完全适配Actor和Critic模块所要开展的分析工作。

原因在于智能体做决策时,不能只单独看待每一个预测结果,而要把它们视为连贯的时序结构 —— 一种多模态动态信息,同时覆盖系统行为的短期与长期特征。这就需要另一种数据组织方式:按时间步顺序排列,并且保留各时间步之间的关联关系。

这正是额外增设一层转置层的原因。该步骤将张量从一组单变量预测序列,转换为多模态序列按时间步排布的格式。每一个时间节点都可以拿到来自不同通道的全部预测变量。最终,Actor与Critic能够把环境随时间演变的动态当成一个统一整体来感知,而不是一堆相互割裂的片段。

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   descr.count = variables;
   prev_count = descr.window = count;
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

因此,这最后一层变换层完成编码器架构的搭建,同时承担关键作用,保障预测模块与决策模块之间接口适配。

预测模型与决策模型的架构沿用了我们之前项目的方案,几乎没有改动。这些组件的有效性已经得到验证,因此本文不再展开详述。如果希望深入理解其架构方案,建议查阅附件资料。

此外,附件中附带模型训练与测试所用程序的源代码。借助这些资料,读者不仅可以理清系统设计的内在逻辑,也能完整复现整个实验流程。



测试

模型训练分为两个阶段。第一阶段为离线训练,采用2024全年EURUSD欧元兑美元货币对H1(1 小时)周期历史数据开展训练。该时间段覆盖了丰富多样的市场情景。多样化的数据让模型有能力应对常规行情与罕见市场异动。

离线训练完成后,我们进入第二阶段 —— 在线微调,环境尽可能贴近真实市场。训练在MetaTrader 5策略测试器中进行,模型逐步处理流式K线数据。这种方式既能检验模型对噪声与市场异常扰动的耐受能力,也能验证其对环境变化的适应性,从而显著提升鲁棒性、降低过拟合风险、增强泛化能力。

最后使用全新数据集对模型开展测试:测试样本为2025年1月至3月的报价数据。训练阶段得到的全部参数与配置均保留原样,不作任何修改。因此,本次测试结果可以客观评估该方案的预测精度以及在实际应用中的可靠性。测试结果呈现如下。

在为期三个月的测试中,模型资金从100美元增长至约430美元;单笔平均盈利(13.17美元)高于单笔平均亏损(11.71美元)。盈利交易占比接近48%、约1.04的盈利因子表明,该系统仅具备微弱的正向收益优势。

与此同时,最大回撤超过82%,最深回撤发生在3月的第二个十天区间。该时段距离训练样本最远。说明模型面对全新市场环境时力不从心,难以抵御突发剧烈波动。

总体来看,测试证明SCNN架构可以实现盈利,并在收益与风险之间取得一定平衡;但想要投入实盘使用,还需要增设额外风控机制,同时延长训练周期。经过优化后,模型不仅可以在熟悉的行情里稳定运行,也能更从容地适应未知、不可预测的市场环境。



结论

本文完成了面向时序预测的SCNN模型开发与实际测试。这是一项相当繁重的工作:既要研究时序分解的理论原理,又要搭建可完整训练的编码器堆叠结构,还要把整套方案集成到Actor-Critic架构中。

基于EURUSD货币对2025年1至3月数据的回测证实:模型具备盈利能力,并能生成较为均衡的预测结果;但同时也暴露出短板:面对训练集之外的剧烈行情变化,模型应对乏力。3月出现的深度回撤清晰地表明,模型仍有很大的改进余地。


参考文献


文中所用的程序

# 名称 类型 描述
1 Study.mq5 EA 用于离线模型训练的EA
2 StudyOnline.mq5 EA 用于在线模型训练的EA
3 Test.mq5 EA 用于模型测试的EA
4 Trajectory.mqh 类库 用于描述系统状态与模型架构的结构
5 NeuroNet.mqh 类库 用于构建神经网络的类库
6 NeuroNet.cl 库 OpenCL程序代码库


本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/19022

附加的文件 |
MQL5.zip (2956.64 KB)
交易策略 交易策略
各种交易策略的分类都是任意的,下面这种分类强调从交易的基本概念上分类。
MQL5自动化交易策略(第二十九部分):构建基于价格行为的加特利谐波形态交易系统 MQL5自动化交易策略(第二十九部分):构建基于价格行为的加特利谐波形态交易系统
本文将使用MQL5开发一套加特利形态交易系统。该系统依托枢轴点与斐波那契比率识别看涨、看跌加特利谐波形态,并依据精准的入场、止损和止盈价位执行交易。系统可通过三角形、趋势线、文本标签等图表对象实现可视化展示,清晰呈现XABCD形态结构,辅助交易者分析行情。
新手在交易中的10个基本错误 新手在交易中的10个基本错误
新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
神经网络在交易中的应用:一种端到端多变量时间序列预测模型(GinAR) 神经网络在交易中的应用:一种端到端多变量时间序列预测模型(GinAR)
我们诚邀您探索一种使用 GinAR 框架预测缺失数据时间序列的创新方法。本文展示了使用 OpenCL 实现关键组件的过程,确保了高性能。在我们的下一篇文章中,我们将详细介绍如何将这些解决方案集成到 MQL5 中。这将有助于理解如何在交易实践中应用该方法。