English Русский Español Deutsch 日本語 Português
preview
交易中的神经网络:二维连接空间模型(终篇)

交易中的神经网络:二维连接空间模型(终篇)

MetaTrader 5交易系统 |
28 1
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

上一篇文章中,我们领略了 奇美拉(Chimera) 框架 — 这款基于时间轴和所分析变量轴进行线性变换的二维状态空间模型(2D-SSM)。它结合了沿两个轴的状态空间模型,及其相互作用机制。

状态空间模型(SSM)已在时间序列分析中被广泛运用,在于它们允许遵照复杂的依赖关系建模。然而,传统的 SSM 仅考虑时态轴,这限制了它们针对多维问题的适用性。奇美拉 将特征轴纳入建模过程,从而拓展了这一概念。

该框架配以 2D-SSM 的离散化形式,引入离散化步长 Δ1 和 Δ2。第一个参数影响时态依赖关系,第二个参数则管控内变量的关系。较小的 Δ1 值有助于捕捉长期趋势,而较大值则强调季节性变化。类似地,沿变量轴的离散化在分析中管制精细等级。

为确保正确的过程重造,框架作者针对矩阵A1, A2(时态依赖性)和 A3, A4(内变量关系)引入了结构化约束。2D-SSM 的因果性质约束了沿特征轴的信息传送;因此,奇美拉 使用两个模块,依据所分析环境的前后特征,来分析依赖关系。

奇美拉 框架的灵活性允许参数 BiCi、和 Δi 即当数据无关常数、亦当输入数据的函数使用。使用上下文依赖参数令模型更适应复杂多维系统的条件。

该框架使用一个 2D-SSMs 堆栈,层间进行非线性变换,近似深度模型的架构。它能够将时间序列分解为趋势和季节分量,从而提供准确的形态分析。

以下是作者对 奇美拉 框架的可视化。

作者的奇美拉框架可视化。

在文章的实施部分,我们开发了一个架构,遵照作者对拟议方法的设想,实现了 MQL5 版本,并开始着手实现。我们实证了 OpenCL 程序所做的变更。我们开发了 2D-SSM 对象的结构,并出示其初始化方法。如今,我们继续构建算法,将所提议方式集成到我们自己的模型之中。



2D-SSM 对象

我们在上一篇文章的结尾实证了 CNeuron2DSSMOCL 对象的初始化方法,其中我们计划实现构造和训练 2D-SSM 的功能。该对象的结构出示如下。

class CNeuron2DSSMOCL  :  public CNeuronBaseOCL
  {
protected:
   uint                 iWindowOut;
   uint                 iUnitsOut;
   CNeuronBaseOCL       cHiddenStates;
   CLayer               cProjectionX_Time;
   CLayer               cProjectionX_Variable;
   CNeuronConvOCL       cA;
   CNeuronConvOCL       cB_Time;
   CNeuronConvOCL       cB_Variable;
   CNeuronConvOCL       cC_Time;
   CNeuronConvOCL       cC_Variable;
   CNeuronConvOCL       cDelta_Time;
   CNeuronConvOCL       cDelta_Variable;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      feedForwardSSM2D(void);
   //---
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradientsSSM2D(void);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuron2DSSMOCL(void)  {};
                    ~CNeuron2DSSMOCL(void)  {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                          uint window_in, uint window_out, uint units_in, uint units_out, 
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const   {  return defNeuron2DSSMOCL; }
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
   virtual void      SetOpenCL(COpenCLMy *obj);
   //---
   virtual bool      Clear(void) override;
  };

今天,我们继续这项工作。我们首先研究构造该对象前馈通验方法的算法:feedForward

bool CNeuron2DSSMOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   CNeuronBaseOCL *inp = NeuronOCL;
   CNeuronBaseOCL *x_time = NULL;
   CNeuronBaseOCL *x_var = NULL;

在方法参数中,我们会收到指向输入数据对象的指针,并立即将其存储在局部变量之中。此处我们还声明了两个额外的局部变量,存储指向输入数据投影对象的指针,即时间和特征上下文。在该阶段,我们仍需要形成这些预测。

回忆一下,为了形成这些投影,我们在初始化方法中创建了两个内部序列,并把指向它们对象的指针保存在动态数组 cProjectionX_TimecProjectionX_Variable 之中。我们现在能用它们来获得所需的投影。

首先,我们在时态上下文中生成投影。我们已将指向输入数据对象的指针存储在一个局部变量之中。接下来,我们创建一个环路,在时态上下文中顺序迭代投影模型中的对象。

//--- Projection Time
   int total = cProjectionX_Time.Total();
   for(int i = 0; i < total; i++)
     {
      x_time = cProjectionX_Time.At(i);
      if(!x_time ||
         !x_time.FeedForward(inp))
         return false;
      inp = x_time;
     }

在环路主体内,我们首先获得指向序列中下一个对象的指针。我们检查所获指针的有效性。成功传递该控制点后,调用对象的前向通验方法,将其传递给输入数据对象的指针。

然后我们将指向当前对象的指针存储在代表输入数据的局部变量当中,并推动环路的下一次迭代。

所有环路迭代完成后,存储输入数据在时态上下文中投影的局部变量会包含指向对应序列中最后一个对象的指针。该对象的缓冲区将包含我们所需的投影。

按类似方式,我们获得输入数据在特征上下文中的投影。

//--- Projection Variable
   inp = NeuronOCL;
   total = cProjectionX_Variable.Total();
   for(int i = 0; i < total; i++)
     {
      x_var = cProjectionX_Variable.At(i);
      if(!x_var ||
         !x_var.FeedForward(inp))
         return false;
      inp = x_var;
     }

为了获得两个隐藏状态的四个投影,调用相应投影对象的单次前向通验方法就足矣。在其参数中,我们传递一个指向包含隐藏状态级联张量的对象指针。

   if(!cA.FeedForward(cHiddenStates.AsObject()))
      return false;

我们 2D-SSM 的其余参数则依赖上下文。因此,接下来我们基于输入数据的相应投影生成模型参数。为此目的,我们顺序遍历模型参数生成对象,调用其前向通验方法,传递指向相应输入数据投影对象的指针。

if(!cB_Time.FeedForward(x_time) ||
   !cB_Variable.FeedForward(x_var))
   return false;
if(!cC_Time.FeedForward(x_time) ||
   !cC_Variable.FeedForward(x_var))
   return false;
if(!cDelta_Time.FeedForward(x_time) ||
   !cDelta_Variable.FeedForward(x_var))
   return false;

在该阶段,我们已准备完毕二维状态空间模型的参数。我们只需要生成隐藏状态和模型输出的新值。正如您所知,在前一篇文章中,这些进程被迁移到了 OpenCL 端创建的独立内核之中。现在调用该内核的包装方法就足矣。然而,在如此行事之前,重点是注意生成新的隐藏状态会覆盖当前值,而我们需要执行反向传播。因此,我们首先交换数据缓冲对象的指针,然后调用包装方法 feedForwardSSM2D

   if(!cHiddenStates.SwapOutputs())
      return false;
//---
   return feedForwardSSM2D();
  }

我们工作的下一阶段是为我们的对象构造反向传播算法。我们来考察误差梯度分布 calcInputGradients 方法。在该方法的参数中,我们收到指向同一输入数据对象的指针,但这次我们必须向它传递对应的输入数据对模型整体结果影响的误差梯度。

bool CNeuron2DSSMOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;

仅当对象指针有效时,数据传输才有可能。因此,算法的第一步是检查所接收指针,这有助于预防访问已释放、或未初始化的资源。这种方式对于确保计算过程的稳定性,以及预防数据处理过程中的故障至关重要。

控制模块成功通过后,开始分派误差梯度的操作。该过程是从模型输出结果层级朝向输入数据运作,遵循反向传播机制,符合前馈通验的数据流,但为逆序。

我们调用内核包装方法,产生隐藏状态,并计算 i0>2D-SSM 的输出,如此前向通验方法完成。相应地,误差梯度传播过程从调用一个类似的包装方法开始,但针对执行误差分派的内核。在该内核里,根据对模型输出形成的贡献,梯度在 2D-SSM 的各个元素之间被正确分派。

if(!calcInputGradientsSSM2D())
   return false;

重点要注意,该阶段仅沿模型的结构化组件执行梯度值分派。然而,该内核中不执行对象激活函数导数的直接调整。因此,在贯穿模型内部对象传播误差梯度之前,有必要检查这些对象是否包含激活函数。如有需要,应考虑非线性变换对传播梯度的影响,施加相应的修正。这样就确保了每个模型参数在更新时都会充分考虑其对输出信号形成的实际贡献。

//--- Deactivation
   CNeuronBaseOCL *x_time = cProjectionX_Time[-1];
   CNeuronBaseOCL *x_var = cProjectionX_Variable[-1];
   if(!x_time || !x_var)
      return false;
   if(x_time.Activation() != None)
      if(!DeActivation(x_time.getOutput(), x_time.getGradient(), x_time.getGradient(), x_time.Activation()))
         return false;
   if(x_var.Activation() != None)
      if(!DeActivation(x_var.getOutput(), x_var.getGradient(), x_var.getGradient(), x_var.Activation()))
         return false;
   if(cB_Time.Activation() != None)
      if(!DeActivation(cB_Time.getOutput(), cB_Time.getGradient(), cB_Time.getGradient(), cB_Time.Activation()))
         return false;
   if(cB_Variable.Activation() != None)
      if(!DeActivation(cB_Variable.getOutput(), cB_Variable.getGradient(), cB_Variable.getGradient(),
                                                                           cB_Variable.Activation()))
         return false;
   if(cC_Time.Activation() != None)
      if(!DeActivation(cC_Time.getOutput(), cC_Time.getGradient(), cC_Time.getGradient(), cC_Time.Activation()))
         return false;
   if(cC_Variable.Activation() != None)
      if(!DeActivation(cC_Variable.getOutput(), cC_Variable.getGradient(), cC_Variable.getGradient(), 
                                                                           cC_Variable.Activation()))
         return false;
   if(cDelta_Time.Activation() != None)
      if(!DeActivation(cDelta_Time.getOutput(), cDelta_Time.getGradient(), cDelta_Time.getGradient(), 
                                                                           cDelta_Time.Activation()))
         return false;
   if(cDelta_Variable.Activation() != None)
      if(!DeActivation(cDelta_Variable.getOutput(), cDelta_Variable.getGradient(), cDelta_Variable.getGradient(), 
                                                                                   cDelta_Variable.Activation()))
         return false;
   if(cA.Activation() != None)
      if(!DeActivation(cA.getOutput(), cA.getGradient(), cA.getGradient(), cA.Activation()))
         return false;

接下来,我们推进到贯穿 2D-SSM 内部对象分派误差梯度的过程。首先,我们需要经由负责生成上下文相关模型参数的对象传播梯度值。我要提醒您,这些参数是基于输入数据的相应投影形成的。

于此重点要注意,输入数据投影对象已经参与了模型输出的主要过程,且在之前的操作期间已接收误差梯度值。为了保留之前获得的数值,我们执行对应数据缓冲区指针的互换。

//--- Gradient to projections X
   CBufferFloat *grad_x_time = x_time.getGradient();
   CBufferFloat *grad_x_var = x_var.getGradient();
   if(!x_time.SetGradient(x_time.getPrevOutput(), false) ||
      !x_var.SetGradient(x_var.getPrevOutput(), false))
      return false;

接下来,我们 经由形成上下文依赖参数的对象顺序传播误差梯度,并在每个阶段,并累加之前存储的数值。

//--- B -> X
   if(!x_time.calcHiddenGradients(cB_Time.AsObject()) ||
      !SumAndNormilize(grad_x_time, x_time.getGradient(), grad_x_time, iWindowOut, false, 0, 0, 0, 1))
      return false;
   if(!x_var.calcHiddenGradients(cB_Variable.AsObject()) ||
      !SumAndNormilize(grad_x_var, x_var.getGradient(), grad_x_var, iWindowOut, false, 0, 0, 0, 1))
      return false;
//--- C -> X
   if(!x_time.calcHiddenGradients(cC_Time.AsObject()) ||
      !SumAndNormilize(grad_x_time, x_time.getGradient(), grad_x_time, iWindowOut, false, 0, 0, 0, 1))
      return false;
   if(!x_var.calcHiddenGradients(cC_Variable.AsObject()) ||
      !SumAndNormilize(grad_x_var, x_var.getGradient(), grad_x_var, iWindowOut, false, 0, 0, 0, 1))
      return false;
//--- Delta -> X
   if(!x_time.calcHiddenGradients(cDelta_Time.AsObject()) ||
      !SumAndNormilize(grad_x_time, x_time.getGradient(), grad_x_time, iWindowOut, false, 0, 0, 0, 1))
      return false;
   if(!x_var.calcHiddenGradients(cDelta_Variable.AsObject()) ||
      !SumAndNormilize(grad_x_var, x_var.getGradient(), grad_x_var, iWindowOut, false, 0, 0, 0, 1))
      return false;

来自所有信息流的误差梯度成功传播后,我们把对象指针还原到原始状态。

if(!x_time.SetGradient(grad_x_time, false) ||
   !x_var.SetGradient(grad_x_var, false))
   return false;

在该阶段,我们已获得两个上下文在输入数据投影层面的误差梯度值。接下来,我们需要将梯度传播到相应的内部投影模型之中。为此,我们创建环路,按相应对象序列反向迭代。

//--- Projection Variable
   int total = cProjectionX_Variable.Total() - 2;
   for(int i = total; i >= 0; i--)
     {
      x_var = cProjectionX_Variable[i];
      if(!x_var ||
         !x_var.calcHiddenGradients(cProjectionX_Variable[i + 1]))
         return false;
     }
//--- Projection Time
   total = cProjectionX_Time.Total() - 2;
   for(int i = total; i >= 0; i--)
     {
      x_time = cProjectionX_Time[i];
      if(!x_time ||
         !x_time.calcHiddenGradients(cProjectionX_Time[i + 1]))
         return false;
     }

注意,在经由上下文投影的内部模型传播误差梯度时,我们会在每个序列的第一层停止。应当强调的是,我们的两种投影序列在生成其数值时,方法参数都基于来自外部程序的输入。现在我们必须将误差梯度传递给两个内部投影模型的输入数据对象。 

按照惯例,在此情况下,我们首先经由一条信息流传播误差梯度。

//--- Projections -> inputs
   if(!NeuronOCL.calcHiddenGradients(x_var.AsObject()))
      return false;

然后,我们交换指向梯度缓冲对象的指针,并经由第二条信息流传播误差。

   grad_x_time = NeuronOCL.getGradient();
   if(!NeuronOCL.SetGradient(x_time.getPrevOutput(), false) ||
      !NeuronOCL.calcHiddenGradients(x_time.AsObject()) ||
      !SumAndNormilize(grad_x_time, NeuronOCL.getGradient(), grad_x_time, 1, false, 0, 0, 0, 1) ||
      !NeuronOCL.SetGradient(grad_x_time, false))
      return false;
//---
   return true;
  }

最后,我们汇总两条信息流的数值,并将数据缓冲点还原至原始状态。

应当注意的是,我们不会将误差梯度向下传播到隐藏状态对象,因为该对象仅用于数据存储,且不包含可训练参数。

现在我们已经将误差梯度值分派至所有内部对象,剩下的就是将操作的逻辑结果返回给调用程序,并完结方法的执行。

据此,我们构造 CNeuron2DSSMOCL 对象方法的算法考证至此完结。附件中提供了该对象、及其所有方法的完整代码,供进一步研究。



Chimera(奇美拉)模块

我们工作的下一阶段是构造奇美拉模块。该框架的作者提议使用两个具有不同离散化水平、及残差连接的并行 2D-SSMs。结合两个独立的状态空间模型,运行在不同离散化层次,能够更深入地分析依赖关系,并构造适应多尺度数据的高效预测模型。

运用具有不同离散化参数的 2D-SSMs,令执行时间序列的微分分析成为可能。高频模型捕捉长期形态,而低频模型则专注于识别季节性周期。这种分离提升了预测准确性,因为每个模型都能适应自身数据部分,把因时态特征过度聚合而导致的信息丢失和误差降至最低。添加离散化模块,能够令两种模型的输出实现可比形式。

奇美拉模块的额外优点是使用残差连接,其确保模型层间信息的高效传输。它们允许在反向传播期间保持梯度并传播,预防梯度消失。这在训练深度模型时尤为重要,其中梯度下降常常遭遇数字稳定性问题。层间数据传输期间,模型对于信息丢失更加健壮,训练过程也更加稳定,即使配合更长的时间序列工作亦如此。

我们将所提议机制在 CNeuronChimera 对象中实现,其结构如下所示。

class CNeuronChimera    :  public CNeuronBaseOCL
  {
protected:
   CNeuron2DSSMOCL    caSSM[2];
   CNeuronConvOCL     cDiscretization;
   CLayer             cResidual;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   //---
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronChimera(void) {};
                    ~CNeuronChimera(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window_in, uint window_out, uint units_in, uint units_out,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const   {  return defNeuronChimera; }
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
   virtual void      SetOpenCL(COpenCLMy *obj);
   //---
   virtual bool      Clear(void) override;
  };

在所呈现的结构中,我们见到一组熟悉的覆盖方法、及若干内部对象,其功能很容易从它们的名称推断出。

所有内部对象都声明为静态,这令类构造器和析构器留空。所有对象的初始化均在 Init 方法里执行。

bool CNeuronChimera::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window_in, uint window_out, uint units_in, uint units_out,
                          ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, units_out * window_out, optimization_type, batch))
      return false;
   SetActivationFunction(None);

在方法参数中,我们接收一组常量,明确定义了所创建对象的架构。应当注意的是,参数列表完全继承自之前描述的 CNeuron2DSSMOCL 对象的类似方法,并指定了其中一个内部 2D-SSM 的架构。

如常,初始化算法从调用父类的方法开始。在这种情况下,它是基准全连通层。

接下来,我们开始初始化内部对象。如上所述,我们用到的二维状态空间模型,它们拥有不同的细节层。在对象结构中,内部模型以数组 caSSM 表示。为了初始化该数组对象,我们组织一个环路。

int index = 0;
for(int i = 0; i < 2; i++)
  {
   if(!caSSM[i].Init(0, index, OpenCL, window_in, (i + 1)*window_out, units_in, units_out, optimization, iBatch))
      return false;
   index++;
  }

第一个状态空间模型的初始化参数接收自外部程序。第二个模型接收一个特征空间维度,是输出结果的双倍,从而能够捕捉更复杂的依赖关系。由于两种模型都在共同的输入数据集合上运行,关键配置参数维持不变,确保结构的完整性和一致性。

接着,我们初始化额外的离散化层,将第二个模型的结果投影到第一个模型的子空间之中。这是一个标准卷积层,将特征空间降低至指定的大小。

   if(!cDiscretization.Init(0, index, OpenCL, 2 * window_out, 2 * window_out, window_out, units_out, 1,
                                                                                 optimization, iBatch))
      return false;
   cDiscretization.SetActivationFunction(None);

为了预防数据丢失,我们禁用了该对象的激活函数。

两个状态空间模型的信息流对象初始化之后,我们推进至组织残差连接。在该阶段,浮现出一个问题:汇总张量时,或许一个或多个数轴的大小不同。为了解决这个问题,有必要首先将输入数据投影到指定的结果子空间之中。为此目的,创建了一个内部数据投影模型,类似于前述的上下文投影模型。该方式令数据维度能够正确对齐,确保架构稳定性,及准确处理时态依赖性。

首先,我们准备一个动态数组来存储指向模型对象的指针,并声明局部变量,以便暂时持有这些指针。

//--- Residual
   cResidual.Clear();
   cResidual.SetOpenCL(OpenCL);
   CNeuronConvOCL *conv = NULL;
   CNeuronTransposeOCL *transp = NULL;

我们创建一个数据置换对象,随后是一个卷积层,将单元序列投影到指定的时间序列维度之中。

   transp = new CNeuronTransposeOCL();
   if(!transp ||
      !transp.Init(0, index, OpenCL, units_in, window_in, optimization, iBatch) ||
      !cResidual.Add(transp))
     {
      delete transp;
      return false;
     }
   index++;
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, index, OpenCL, units_in, units_in, units_out, window_in, 1, optimization, iBatch) ||
      !cResidual.Add(conv))
     {
      delete conv;
      return false;
     }
   conv.SetActivationFunction(None);

该方式令我们能够在分析多变量时间序列时保持单个单元序列内的结构依赖关系。

再随后是另一个由置换对象和卷积层组成的模块,其执行输入数据沿特征轴投影。

   index++;
   transp = new CNeuronTransposeOCL();
   if(!transp ||
      !transp.Init(0, index, OpenCL, window_in, units_out, optimization, iBatch) ||
      !cResidual.Add(transp))
     {
      delete transp;
      return false;
     }
   index++;
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, index, OpenCL, window_in, window_in, window_out, units_out, 1, optimization, iBatch) ||
      !cResidual.Add(conv))
     {
      delete conv;
      return false;
     }
   conv.SetActivationFunction(None);

注意,这两个卷积层都未用到激活函数,这令输入数据在投影时信息损失最小。

在对象的输出处,我们计划汇总三条信息流。如常,我们将所有误差梯度沿全部分支传播。为了避免不必要的数据复制操作,我们同步指向误差梯度缓冲区的指针。然而,值得注意的是,数据投影的卷积层或许包含激活函数。当然,在这个特定情况下我们并未用到它们,故可忽略这一层面。但为了构建更通用的解决方案,我们不要忽视它。因此,误差梯度只有在被主动激活函数的导数修正后才会传递至卷积层。

   if(!SetGradient(caSSM[0].getGradient(), true))
      return false;
//---
   return true;
  }

最后,我们返回一个布尔结果至调用程序,并结束初始化方法。

一旦初始化完成后,我们继续在 feedForward 方法中实现前向通验算法。

bool CNeuronChimera::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   for(uint i = 0; i < caSSM.Size(); i++)
     {
      if(!caSSM[i].FeedForward(NeuronOCL))
         return false;
     }

前馈通验算法相当简单。在方法参数中,我们接收一个指向输入数据对象的指针,并将其传递给内部状态空间模型。为此,我们组织了一个环路,迭代内部 2D-SSM,并顺序调用它们的前馈通验方法。

完成所有环路迭代后,我们将获得的结果投影为可比的形式。

   if(!cDiscretization.FeedForward(caSSM[1].AsObject()))
      return false;

接下来,我们需要将输入数据投影到结果子空间之中。为此目的,我们组织了一个环路,顺序迭代内部投影模型中的对象,调用相应对象的前馈通验方法。

   CNeuronBaseOCL *inp = NeuronOCL;
   CNeuronBaseOCL *current = NULL;
   for(int i = 0; i < cResidual.Total(); i++)
     {
      current = cResidual[i];
      if(!current ||
         !current.FeedForward(inp))
         return false;
      inp = current;
     }

最后,我们汇总三条信息流的结果,随后归一化数据。

   if(!SumAndNormilize(caSSM[0].getOutput(), cDiscretization.getOutput(), Output, 1, false, 0, 0, 0, 1) ||
      !SumAndNormilize(Output, current.getOutput(), Output, cDiscretization.GetFilters(), true, 0, 0, 0, 1))
      return false;
//---
   return true;
  }

之后,我们将所执行操作的逻辑结果返回调用程序,并结束方法的执行。

然而,前馈通验算法表面上的简洁背后,实际上要依靠使用了三条信息流,这在组织误差梯度分派过程时带来了一定的复杂性。该过程在 calcInputGradients 方法中实现。

bool CNeuronChimera::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;

在方法参数中,我们接收一个指向输入数据对象的指针,我们现在必须根据误差梯度对模型最终结果的影响传递于内。在方法主体中,我们立即检查所接收指针的相关性。这样验证的必要性我们早前已讨论过。

接下来,我们通过第二个 2D-SSM 层投影层的激活函数修正后续对象接收到的误差梯度,并将其传播到该模型的下层。

   if(!DeActivation(cDiscretization.getOutput(), cDiscretization.getGradient(), Gradient, cDiscretization.Activation()))
         return false;
   if(!caSSM[1].calcHiddenGradients(cDiscretization.AsObject()))
      return false;

类似地,我们通过内部输入数据投影模型最后一层的激活函数的导数调整误差梯度,并贯穿该序列对象顺序传播。

   CNeuronBaseOCL *residual = cResidual[-1];
   if(!residual)
      return false;
   if(!DeActivation(residual.getOutput(), residual.getGradient(), Gradient, residual.Activation()))
         return false;
   for(int i = cResidual.Total() - 2; i >= 0; i--)
     {
      residual = cResidual[i];
      if(!residual ||
         !residual.calcHiddenGradients(cResidual[i + 1]))
         return false;
     }

在该阶段,我们到达把误差梯度沿三条分支传递至输入数据级别的步骤。在梯度传播期间,之前存储的数值会被覆盖。所幸,我们已学会了如何处理这个问题。首先,我们从一个状态空间模型传播误差梯度。

   if(!NeuronOCL.calcHiddenGradients(caSSM[0].AsObject()))
      return false;

然后交换数据缓冲指针,沿第二分支传播误差梯度,然后汇总两条信息流的数据。

   CBufferFloat *temp = NeuronOCL.getGradient();
   if(!NeuronOCL.SetGradient(residual.getPrevOutput(), false) ||
      !NeuronOCL.calcHiddenGradients(caSSM[1].AsObject()) ||
      !SumAndNormilize(temp, NeuronOCL.getGradient(), temp, 1, false, 0, 0, 0, 1))
      return false;

以同样途径,我们加上第三条信息流的数值。

   if(!NeuronOCL.calcHiddenGradients((CObject*)residual) ||
      !SumAndNormilize(temp, NeuronOCL.getGradient(), temp, 1, false, 0, 0, 0, 1) ||
      !NeuronOCL.SetGradient(temp, false)
     )
      return false;
//---
   return true;
  }

仅在所有信息流的数据汇总后,我们才能将对象指针恢复到原始状态。

我们将执行操作的逻辑结果返回给调用程序,并结束方法的执行。

据此,我们实现奇美拉框架算法 MQL5 版本的分析至此完毕。所出示对象、及其所有方法的完整代码,已在附件中提供。



模型架构

在之前的章节中,我们执行了繁重工作,按奇美拉框架作者所提议方法,实现了 MQL5 版本。然而,框架作者建议采用由一组这样的对象组成的架构,它们之间遵循非线性组织。采用这种架构有助于构造一个灵活、且自适的系统,具备动态响应操作条件变化的能力。因此,我们将简要介绍可训练模型的架构。

我们先声明,在本实验纵深内,我们在多任务学习框架内实现了奇美拉方式。

所训练模型的架构在 CreateDescriptions 方法中定义。

bool CreateDescriptions(CArrayObj *&actor, CArrayObj *&probability)
  {
//---
   CLayerDescription *descr;
//---
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
   if(!probability)
     {
      probability = new CArrayObj();
      if(!probability)
         return false;
     }

在方法参数中,我们接收指向两个动态数组的指针,于其中必须存储模型架构的描述。在方法主体中,我们检查所接收指针的相关性,并在必要时创建新的对象实例。

首先,我们描述参与者的架构,其中还包括环境状态编码模块。在模型输入处,我们计划投喂原始、未处理的描述环境状态数据。这些数据被传递到足够大小的全连接层。

//--- Actor
   actor.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(!actor.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(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

处理完的数据随后被投喂至第一个奇美拉模块,我们期望在该模块输出处获得一个多维的时态序列,由 64 个元素组成,每个元素包含 16 个特征。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronChimera;
//--- Window
     {
      int temp[] = {BarDescr, 16}; //In, Out
      if(ArrayCopy(descr.windows, temp) < int(temp.Size()))
         return false;
     }
//--- Units
     {
      int temp[] = {HistoryBars, 64}; //In, Out
      if(ArrayCopy(descr.units, temp) < int(temp.Size()))
         return false;
     }
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

随后是带有 SoftPlus 激活函数的卷积层,引入非线性。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = 64;
   descr.window = 16;
   descr.step = 16;
   descr.window_out = 16;
   descr.activation = SoftPlus;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

以类似的举措,我们加入了两个奇美拉模块,并在它们之间插入非线性。

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronChimera;
//--- Window
     {
      int temp[] = {16, 32}; //In, Out
      if(ArrayCopy(descr.windows, temp) < int(temp.Size()))
         return false;
     }
//--- Units
     {
      int temp[] = {64, 32}; //In, Out
      if(ArrayCopy(descr.units, temp) < int(temp.Size()))
         return false;
     }
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = 32;
   descr.window = 32;
   descr.step = 32;
   descr.window_out = 16;
   descr.activation = SoftPlus;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronChimera;
//--- Window
     {
      int temp[] = {16, 32}; //In, Out
      if(ArrayCopy(descr.windows, temp) < int(temp.Size()))
         return false;
     }
//--- Units
     {
      int temp[] = {32, 16}; //In, Out
      if(ArrayCopy(descr.units, temp) < int(temp.Size()))
         return false;
     }
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

同时,类比 ResNeXt 框架,我们降低序列长度,并按比例增加特征空间的维度。

接着到来的是决策制定头,由三个连续的全连接层组成。

//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 512;
   descr.batch = 1e4;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = TANH;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = NActions;
   descr.activation = SoftPlus;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

其操作结果使用一个批处理归一化层进行归一化。

//--- layer 10
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

如之前讨论过的模型,在参与者输出处添加了风险管理模块。

//--- layer 11
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMacroHFTvsRiskManager;
//--- Windows
     {
      int temp[] = {3, 15, NActions, AccountDescr}; //Window, Stack Size, N Actions, Account Description
      if(ArrayCopy(descr.windows, temp) < int(temp.Size()))
         return false;
     }
   descr.count = 10;
   descr.window_out = 16;
   descr.step = 4;                              // Heads
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 12
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = NActions / 3;
   descr.window = 3;
   descr.step = 3;
   descr.window_out = 3;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

来自之前文章的估测即将到来价格走势方向概率的模型几乎未变。仅对隐藏层中所用的激活功能做了细微调整。因此,我们于此不再详细实证。模型架构的完整描述可见附件。那里还提供了训练和测试模型的完整代码,且是从之前的工作中迁移而来,未作更改。



测试

我们遵照奇美拉框架作者所提议方式的自我诠释完成实现后,进入最后阶段 — 依据真实历史数据训练和测试模型。

为了训练模型,我们采用了之前讨论过模型训练期间收集的训练数据集。该训练数据集基于 EURUSD 货币对,2024 全年 M1 时间帧的历史数据构建。所有指标参数均按其默认值设置。训练数据集准备过程的详细描述可在此链接中找到。

已训练模型测试在 MetaTrader 5 策略测试器中运作,基于 2025 年 1 月的历史数据,其它训练参数保持不变。测试结果呈现如下。

根据测试结果,该模型能够盈利。超过 70% 的交易以盈利了结。盈利因子记录为 1.53。

不过,有几点应当注意。这些模型是在 M1 时间帧内测试的。与此同时,该模型仅执行了 27 笔交易,这对于在极短时间帧内的高频交易来说相当低。甚至,该模型仅开仓做空,这也浮现出问题。

持仓时间也引发了担忧。故可以说,最快持仓在开盘近一小时后就被平仓了。平均持仓时间超过 14 小时。而且模型测试是在 M1 时间帧。

为了在单一图表窗口中显示开仓和平仓,有必要提升时间帧。以这种形式,我们清楚地观察到交易顺应全局趋势方向。当然,这与 M1 时间帧上进行高频交易的注解符不一致。然而,显然该模型能够捕捉长线趋势,同时忽略短线扰动。



结束语

在前两篇文章中,我们研究了基于二维状态空间模型的奇美拉框架。该方式引入了多变量时间序列建模的创新技术,令其能够参考时态背景和特征空间两者来审示复杂关系。

在我们工作的实施部分,我们遵照该框架方式的自我解释,实现了 MQL5 版本。所构造模型经过了训练,并在真实历史数据上进行了测试。测试结果有些出乎意料。在测试期间,该模型实现了盈利。然而,相较于预期,我们观察到交易是按全局趋势方向做多,持仓时间也足够长,尽管模型是在 M1 时间帧上测试的。


参考


文章中所用程序

# 名称 类型 说明
1 Research.mq5 智能系统 收集样本的智能系统
2 ResearchRealORL.mq5
智能系统
利用 Real-ORL 方法收集样本的智能系统
3 Study.mq5 智能系统 模型训练智能系统
4 Test.mq5 智能系统 模型测试智能系统
模型测试智能系统 Trajectory.mqh 类库 系统状态和模型架构描述结构
6 NeuroNet.mqh 类库 创建神经网络的类库
7 NeuroNet.cl 代码库 OpenCL 程序代码

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

附加的文件 |
MQL5.zip (2458.9 KB)
最近评论 | 前往讨论 (1)
Charles Antoine Dominique Julien Fournel
Charles Antoine Dominique Julien Fournel | 18 1月 2026 在 16:22
非常有趣!谢谢!

从图表结果来看,RRR 条件下的收盘应该更有利可图,但这不是重点。
交易策略 交易策略
各种交易策略的分类都是任意的,下面这种分类强调从交易的基本概念上分类。
价格行为分析工具包开发(第二十六部分):针形线、吞没形态与RSI背离(多模式)工具 价格行为分析工具包开发(第二十六部分):针形线、吞没形态与RSI背离(多模式)工具
与我们开发实用型价格行为工具的初衷相一致,本文将探讨如何开发一款 EA。该 EA 能够识别 Pin Bar 和吞没形态,并利用 RSI 背离作为确认信号,仅在条件满足时生成交易提示。
新手在交易中的10个基本错误 新手在交易中的10个基本错误
新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
价格行为分析工具包开发(第二十五部分):双指数移动平均线(EMA)分形突破策略 价格行为分析工具包开发(第二十五部分):双指数移动平均线(EMA)分形突破策略
价格行为分析是识别盈利交易机会的基础方法。然而,人工监测价格走势和形态不仅困难而且极其耗时。为解决这一痛点,我们开发了自动分析价格行为的工具,一旦检测到潜在机会,就会立刻发出信号。本文将介绍一款强大的工具,该工具结合分形突破以及14周期指数移动平均线(EMA 14)和200周期指数移动平均线(EMA 200)来生成可靠的交易信号,帮助交易者更自信地做出明智决策。