English Русский Español Português
preview
交易中的神经网络:具有相对编码的变换器

交易中的神经网络:具有相对编码的变换器

MetaTrader 5交易系统 |
82 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

价格预测和市场趋势预测是成功交易和风险管理的核心任务。高品质价格走势预测令交易者能够及时做出决策,并避免财产损失。然而,在高度波动的市场中,传统机器学习模型的功能或许会受到限制。

自从头开始训练模型过渡到依据大量无标签数据进行预训练,然后针对特定任务进行微调,令我们能够达成高精度预测,且无需收集大量新数据。例如,基于变换器架构的模型适用于财务数据,可以利用资产相关性、时态依赖关系、和其它因素的信息来生成更准确的预测。替代注意力机制的实现有助于梳理关键的市场依赖性,从而显著提高模型性能。这为开发交易策略打开新机会,同时最小化手工调整、以及基于复杂规则模型的依赖。

论文《相对分子注意力变换器》中简述了这样的一个替代注意力算法。作者提出了一种新的针对分子图形的自注意力公式,精心处理各种输入特征,以便能在许多化学领域达到更高的准确性和可靠性。相对分子注意力变换器R-MAT)是基于变换器架构的预训练模型。它代表了相对自我注意力的一种新颖变体,有效地整合了距离和邻里信息。R-MAT 可在各种任务中提供最先进的、有竞争力的性能。



1. R-MAT 算法

在自然语言处理中,原版的 自注意力 层不考虑输入词元的位置信息,也就是说,如果重新排列输入数据,结果将保持不变。为了将位置信息与输入数据协同,原版变换器配以绝对位置编码对其进行丰富。对比之下,相对位置编码引入了每对词元之间的相对距离,导致某些任务的实质性改进。R-MAT 算法采用相对词元位置编码。

其核心思路是强化处理有关图形和距离信息的灵活性。R-MAT 方法的作者对相对位置编码进行了调整,从而依据输入序列中元素相对位置的有效表示来丰富 自注意力 模块。

分子中两个原子的相互定位由三个相互关联的因素表征:

  • 它们的相对距离,
  • 它们在分子图中的距离,
  • 它们的物理化学关系。

两个原子由维度 D 的向量 𝒙i 和 𝒙j 表示。作者建议按维度 D′的原子对嵌入 𝒃ij 来编码它们的关系。然后,该嵌入将在 自注意力 模块的投影层之后所用。

该过程首先对两个原子之间的邻域顺序进行编码,其中包含有关原始分子图中节点 ij 之间包含多少其它节点的定位信息。然后是径向基距离编码。最后,每个键都以高亮显示,以反映原子对之间的物理化学关系。

作者指出,虽然这些特征在预训练过程中很容易学会,在较小的数据集上训练 R-MAT 这样的构造高度有益。

分子中每个原子对的结果词元 𝒃ij 用于定义一个新的自注意力层,作者将其称为相对分子自注意力

在这个新的架构中,作者镜像了原版 自注意力查询-键-值 设计。使用两个神经网络 φV 和 φK.,将词元 𝒃ij 转换为特定于键和值的向量 𝒃ijV 和 𝒃ijK。每个神经网络由两层组成。其中包括一个跨所有注意力头之间共享的隐藏层,以及一个为不同的注意力头创建不同相对嵌入的内容输出层。相对自注意力可以表示如下:

其中 𝒖 和 𝒗 是可学习的向量。

按这种方式,作者通过嵌入原子关系来丰富 自注意力 模块。在注意力权重的计算期间,它们引入了一个内容相关的位置偏差、一个全局上下文偏差、和一个全局位置偏差,所有这些都是基于 𝒃ijK 计算。然后,在计算加权平均注意力期间,作者还协同来自替代嵌入 𝒃ijV 的信息。

相对自注意力 模块用于构造 相对分子注意力R-MAT)。

输入数据表示为大小为 Natoms×36 的矩阵,其由一堆 N 层的相对分子自注意力 进行处理。每个注意力层后面都跟随一个带有残差连接的 MLP,类似于原版 变换器 模型。

在经由注意力层处理输入数据之后,作者把表示聚合至一个固定大小的向量。自注意力 池化即针对该目的。

其中 𝐇 代表从自注意力层获得的隐藏状态,W1W2 是注意力池化权重。

然后,调用 leaky-ReLU 激活函数将图嵌入 𝐠 馈送到两级 MLP 中,从而输出最终预测。

作者对该方法的可视化如下所示。


2. 利用 MQL5 实现

在研究了所提议相对分子注意力变换器R-MAT) 方法的理论层面之后,我们转到利用 MQL5 针对所提议方法开发自己的解释。当下,我应该提到,我已决定将所提议算法的构造划分至单独的模块。我们首先创建一个实现相对 自注意力 算法的专用对象,然后将 R-MAT 模型汇编成一个单独的高级类。

2.1相对自注意力模块


如您所知,我们已将大部分计算卸载到 OpenCL 关联环境之中。相较之,当我们开始实现新算法时,我们需要将缺失的内核添加到我们的 OpenCL 程序之中。我们将创建的第一个内核是前馈内核 MHRelativeAttentionOut。尽管该内核是基于前面讨论过的 自注意力 算法实现,但此处我们的全局缓冲区数量显著增加,我们将在构建算法时探讨其用途。

__kernel void MHRelativeAttentionOut(__global const float *q,         ///<[in] Matrix of Querys
                                     __global const float *k,         ///<[in] Matrix of Keys
                                     __global const float *v,         ///<[in] Matrix of Values
                                     __global const float *bk,        ///<[in] Matrix of Positional Bias Keys
                                     __global const float *bv,        ///<[in] Matrix of Positional Bias Values
                                     __global const float *gc,        ///<[in] Global content bias vector
                                     __global const float *gp,        ///<[in] Global positional bias vector
                                     __global float *score,           ///<[out] Matrix of Scores
                                     __global float *out,             ///<[out] Matrix of attention
                                     const int dimension              ///< Dimension of Key
                                    )
  {
//--- init
   const int q_id = get_global_id(0);
   const int k_id = get_global_id(1);
   const int h = get_global_id(2);
   const int qunits = get_global_size(0);
   const int kunits = get_global_size(1);
   const int heads = get_global_size(2);

该内核设计为在三维任务空间内运行,其中每个维度对应于 查询。在第二个维度内,我们创建工作组。

在内核主体中,我们立即跨任务空间所有维度识别当前线程,并判定其边界。然后,我们定义常量在数据缓冲区中的偏移量,以便访问必要的元素。

   const int shift_q = dimension * (q_id * heads + h);
   const int shift_kv = dimension * (heads * k_id + h);
   const int shift_gc = dimension * h;
   const int shift_s = kunits * (q_id *  heads + h) + k_id;
   const int shift_pb = q_id * kunits + k_id;
   const uint ls = min((uint)get_local_size(1), (uint)LOCAL_ARRAY_SIZE);
   float koef = sqrt((float)dimension);
   if(koef < 1)
      koef = 1;

然后,我们在局部内存中创建一个数组,在工作组内交换信息。

   __local float temp[LOCAL_ARRAY_SIZE];

接下来,根据相对 自注意力 算法,我们需要计算注意力系数。为了达成这一点,我们计算若干向量的点积,并将结果值相加。在此,我们所据事实即所有相乘向量的维度都相同。相较之,单一循环就足以执行所有向量的所需乘法。

//--- score
   float sc = 0;
   for(int d = 0; d < dimension; d++)
     {
      float val_q = q[shift_q + d];
      float val_k = k[shift_kv + d];
      float val_bk = bk[shift_kv + d];
      sc += val_q * val_k + 
	    val_q * val_bk + 
            val_k * val_bk + 
            gc[shift_q + d] * val_k + 
            gp[shift_q + d] * val_bk;
     }

下一步涉及归一化跨越各个 查询 的注意力系数计算。对于归一化,我们调用 Softmax 函数,就如原版算法一样。因此,归一化过程是自我们已有实现中继承而来,未经任何修改。在该步骤中,我们首先计算系数的指数值。

   sc = exp(sc / koef);
   if(isnan(sc) || isinf(sc))
      sc = 0;

然后,我们用早前在局部内存中创建的数组,汇总工作组内得到的系数。

//--- sum of exp
   for(int cur_k = 0; cur_k < kunits; cur_k += ls)
     {
      if(k_id >= cur_k && k_id < (cur_k + ls))
        {
         int shift_local = k_id % ls;
         temp[shift_local] = (cur_k == 0 ? 0 : temp[shift_local]) + sc;
        }
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   uint count = min(ls, (uint)kunits);
//---
   do
     {
      count = (count + 1) / 2;
      if(k_id < ls)
         temp[k_id] += (k_id < count && (k_id + count) < kunits ? temp[k_id + count] : 0);
      if(k_id + count < ls)
         temp[k_id + count] = 0;
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);

现在我们能够将先前获得的系数除以总和,并将归一化值保存到相应全局缓冲区当中。

//--- score
   float sum = temp[0];
   if(isnan(sum) || isinf(sum) || sum <= 1e-6f)
      sum = 1;
   sc /= sum;
   score[shift_s] = sc;
   barrier(CLK_LOCAL_MEM_FENCE);

在计算完归一化依赖系数之后,我们能够计算注意力操作的结果。此处的算法非常接近原版。我们只是在乘以注意力因子之前,把 bijV 向量的总和相加。

//--- out
   for(int d = 0; d < dimension; d++)
     {
      float val_v = v[shift_kv + d];
      float val_bv = bv[shift_kv + d];
      float val = sc * (val_v + val_bv);
      if(isnan(val) || isinf(val))
         val = 0;
      //--- sum of value
      for(int cur_v = 0; cur_v < kunits; cur_v += ls)
        {
         if(k_id >= cur_v && k_id < (cur_v + ls))
           {
            int shift_local = k_id % ls;
            temp[shift_local] = (cur_v == 0 ? 0 : temp[shift_local]) + val;
           }
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      //---
      count = min(ls, (uint)kunits);
      do
        {
         count = (count + 1) / 2;
         if(k_id < count && (k_id + count) < kunits)
            temp[k_id] += temp[k_id + count];
         if(k_id + count < ls)
            temp[k_id + count] = 0;
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      while(count > 1);
      //---
      if(k_id == 0)
         out[shift_q + d] = (isnan(temp[0]) || isinf(temp[0]) ? 0 : temp[0]);
     }
  }

值得再次强调,为了同步工作组内线程之间的操作,仔细放置屏障的重要性。屏障的排列方式必须按这般方式,即工作组中的各个线程到达屏障的次数相同。在访问所有同步点之前,代码不得包含屏障的旁路或提前退出。否则,我们将面临内核卡顿的风险,即已完成其操作的单个线程在屏障处等待另一条线程。

反向传播算法在 MHRelativeAttentionInsideGradients 内核中实现。该实现完全颠倒了前面实现的前馈通验内核的操作,并且在很大程度上改编自以前的实现。因此,我建议您自行探索它。附件中提供了整个 OpenCL 程序的完整代码。

现在,我们继续主程序实现。此处,我们将创建 CNeuronRelativeSelfAttention 类,其中我们将实现相对自注意力算法。不过,在我们开始实现之前,有必要讨论一些相对位置编码的层面。

R-MAT 框架的作者提出他们的算法来解决化工行业的问题。他们基于手头任务的具体性质构造分子中原子的位置描述。对我们来说,蜡烛之间的距离、及其特性也很重要,但还有一个额外的细微差别。除了距离,方向也很重要。仅有单向价格走势才会形成趋势,并演变成市场趋势。

第二个层面关注所分析序列的大小。分子中的原子数通常限制为相对较小的数量。在这种情况下,我们能够计算每个原子对的偏差向量。不过,在我们的示例中,正在分析的历史数据体量可能相当庞大。因此,计算和存储每对所分析蜡烛的单个偏差向量,可能变为一项高度资源密集型的任务。

因此,我们决定不用作者建议的方法来计算单个序列元素之间的偏差。为了寻找替代机制,我们转向了一个相当简单的解决方案:将输入数据的矩阵乘以其转置副本。从数学角度来看,两个向量的点积等于它们的大小与它们之间角度的余弦的乘积。因此,垂直向量的乘积等于零。指向同一方向的向量给出正值,而指向相反方向的向量给出负值。因此,当将一个向量与其它几个向量进行比较时,向量积的值会随着向量之间的角度减小、以及第二个向量长度的增加而增加。    

现在我们已经确定了方法,我们可以继续构造我们的新对象,其结构如下所示。

class CNeuronRelativeSelfAttention   :  public CNeuronBaseOCL
  {
protected:
   uint                    iWindow;
   uint                    iWindowKey;
   uint                    iHeads;
   uint                    iUnits;
   int                     iScore;
   //---
   CNeuronConvOCL          cQuery;
   CNeuronConvOCL          cKey;
   CNeuronConvOCL          cValue;
   CNeuronTransposeOCL     cTranspose;
   CNeuronBaseOCL          cDistance;
   CLayer                  cBKey;
   CLayer                  cBValue;
   CLayer                  cGlobalContentBias;
   CLayer                  cGlobalPositionalBias;
   CLayer                  cMHAttentionPooling;
   CLayer                  cScale;
   CBufferFloat            cTemp;
   //---
   virtual bool      AttentionOut(void);
   virtual bool      AttentionGraadient(void);

   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronRelativeSelfAttention(void) : iScore(-1) {};
                    ~CNeuronRelativeSelfAttention(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key,
                          uint units_count, uint heads,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override   const   {  return defNeuronRelativeSelfAttention; }
   //---
   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 方法中执行。该方法的参数包含常量,这些常量令我们精确定义所创建对象的架构。该方法的所有参数都是直接继承自原版多头自注意力实现,无需任何修改。唯一的参数 “lost along the way” 是指定内部层数的参数。这是一个深思熟虑的决定,因为在此实现中,层数将由更高级别的对象通过创建必要数量的内部对象来确定。

bool CNeuronRelativeSelfAttention::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                                        uint window, uint window_key, uint units_count, uint heads, 
                                        ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type, batch))
      return false;

在方法主体中,我们立即调用父类的同名方法,并把接收到的参数传递一部分给它。如您所知,父类方法已实现针对接收参数进行最小验证和继承对象初始化的算法。我们只需检查这些操作的逻辑结果。

然后,我们将接收常量存储到类的内部变量之中,以备后续使用。

   iWindow = window;
   iWindowKey = window_key;
   iUnits = units_count;
   iHeads = heads;

接下来,我们继续初始化已声明的内部对象。我们首先初始化内层,在相关的内部对象中生成 查询 实体。所有三个层我们都用相同的参数。

   int idx = 0;
   if(!cQuery.Init(0, idx, OpenCL, iWindow, iWindow, iWindowKey * iHeads, iUnits, 1, optimization, iBatch))
      return false;
   idx++;
   if(!cKey.Init(0, idx, OpenCL, iWindow, iWindow, iWindowKey * iHeads, iUnits, 1, optimization, iBatch))
      return false;
   idx++;
   if(!cValue.Init(0, idx, OpenCL, iWindow, iWindow, iWindowKey * iHeads, iUnits, 1, optimization, iBatch))
      return false;

接下来,我们需要准备对象来计算我们的距离矩阵。为此,我们首先为输入数据创建一个转置对象。

   idx++;
   if(!cTranspose.Init(0, idx, OpenCL, iUnits, iWindow, optimization, iBatch))
      return false;

然后我们创建一个对象来记录输出。矩阵乘法运算已在父类中实现。

   idx++;
   if(!cDistance.Init(0, idx, OpenCL, iUnits * iUnits, optimization, iBatch))
      return false;

接下来,我们需要组织生成 BKBV 张量的过程。如理论部分所述,它们的产生涉及由两层组成的 MLP。第一层在所有关注头之间共享,而第二层为每个关注头生成单独的词元。在我们的实现中,每个实体我们都用两个连续的卷积层。我们应用双曲正切(tanh)函数在层之间引入非线性。

   idx++;
   CNeuronConvOCL *conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, iUnits, iUnits, iWindow, iUnits, 1, optimization, iBatch) ||
      !cBKey.Add(conv))
      return false;
   idx++;
   conv.SetActivationFunction(TANH);
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, iWindow, iWindow, iWindowKey * iHeads, iUnits, 1, optimization, iBatch) ||
      !cBKey.Add(conv))
      return false;
   idx++;
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, iUnits, iUnits, iWindow, iUnits, 1, optimization, iBatch) ||
      !cBValue.Add(conv))
      return false;
   idx++;
   conv.SetActivationFunction(TANH);
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, iWindow, iWindow, iWindowKey * iHeads, iUnits, 1, optimization, iBatch) ||
      !cBValue.Add(conv))
      return false;

此外,我们需要全局内容偏差、及位置偏差的可学习向量。为了创建这些,我们将采用我们之前工作中的方式。我指的是构建一个具有两层的 MLP。其中一个是包含 '1' 的静态层,第二个是生成所需张量的可学习层。我们将指向这些对象的指针存储在数组 cGlobalContentBiascGlobalPositionalBias 当中。

   idx++;
   CNeuronBaseOCL *neuron = new CNeuronBaseOCL();
   if(!neuron ||
      !neuron.Init(iWindowKey * iHeads * iUnits, idx, OpenCL, 1, optimization, iBatch) ||
      !cGlobalContentBias.Add(neuron))
      return false;
   idx++;
   CBufferFloat *buffer = neuron.getOutput();
   buffer.BufferInit(1, 1);
   if(!buffer.BufferWrite())
      return false;
   neuron = new CNeuronBaseOCL();
   if(!neuron ||
      !neuron.Init(0, idx, OpenCL, iWindowKey * iHeads * iUnits, optimization, iBatch) ||
      !cGlobalContentBias.Add(neuron))
      return false;
   idx++;
   neuron = new CNeuronBaseOCL();
   if(!neuron ||
      !neuron.Init(iWindowKey * iHeads * iUnits, idx, OpenCL, 1, optimization, iBatch) ||
      !cGlobalPositionalBias.Add(neuron))
      return false;
   idx++;
   buffer = neuron.getOutput();
   buffer.BufferInit(1, 1);
   if(!buffer.BufferWrite())
      return false;
   neuron = new CNeuronBaseOCL();
   if(!neuron ||
      !neuron.Init(0, idx, OpenCL, iWindowKey * iHeads * iUnits, optimization, iBatch) ||
      !cGlobalPositionalBias.Add(neuron))
      return false;

此刻,我们已准备好所有必要的对象,以便正确设置我们的相对注意力模块的输入数据。在下一阶段,我们转入讨论处理注意力输出的组件。首先,我们将创建一个对象来存储多头注意力的结果,并将其指针添加到 cMHAttentionPooling 数组。

   idx++;
   neuron = new CNeuronBaseOCL();
   if(!neuron ||
      !neuron.Init(0, idx, OpenCL, iWindowKey * iHeads * iUnits, optimization, iBatch) ||
      !cMHAttentionPooling.Add(neuron)
     )
      return false;

接下来,我们添加 MLP 池化操作。

   idx++;
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, iWindowKey * iHeads, iWindowKey * iHeads, iWindow, iUnits, 1, 
                                                                       optimization, iBatch) ||
      !cMHAttentionPooling.Add(conv)
     )
      return false;
   idx++;
   conv.SetActivationFunction(TANH);
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, iWindow, iWindow, iHeads, iUnits, 1, optimization, iBatch) ||
      !cMHAttentionPooling.Add(conv)
     )
      return false;

我们在输出处添加一个 Softmax 层。

   idx++;
   conv.SetActivationFunction(None);
   CNeuronSoftMaxOCL *softmax = new CNeuronSoftMaxOCL();
   if(!softmax ||
      !softmax.Init(0, idx, OpenCL, iHeads * iUnits, optimization, iBatch) ||
      !cMHAttentionPooling.Add(conv)
     )
      return false;
   softmax.SetHeads(iUnits);

注意,在池化 MLP 的输出中,我们获得了序列中每个元素的每个注意力头的归一化加权系数。现在,我们只需将结果向量乘以多头注意力模块的相应输出即可获得最终结果。不过,序列中每个元素的表示向量的大小将等于我们的内部维度。因此,我们还添加了伸缩对象,以便将结果调整为原始输入数据的级别。

   idx++;
   neuron = new CNeuronBaseOCL();
   if(!neuron ||
      !neuron.Init(0, idx, OpenCL, iWindowKey * iUnits, optimization, iBatch) ||
      !cScale.Add(neuron)
     )
      return false;
   idx++;
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, iWindowKey, iWindowKey, 4 * iWindow, iUnits, 1, optimization, iBatch) ||
      !cScale.Add(conv)
     )
      return false;
   conv.SetActivationFunction(LReLU);
   idx++;
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, 4 * iWindow, 4 * iWindow, iWindow, iUnits, 1, optimization, iBatch) ||
      !cScale.Add(conv)
     )
      return false;
   conv.SetActivationFunction(None);

现在我们需要替换数据缓冲区,从而消除不必要的复制操作,并将方法操作的逻辑结果返回给调用程序。

//---
   if(!SetGradient(conv.getGradient(), true))
      return false;
//---
   SetOpenCL(OpenCL);
//---
   return true;
  }

注意,在本例中,我们仅替换了梯度缓存区指针。这是由于在注意力模块中创建残差连接引起的。但是,我们将在实现 feedForward 方法时再讨论这部分。

在前馈方法参数中,我们收到一个指向源数据对象的指针,我们立即将其传递给内部对象的同名方法,以便生成 查询 实体。

bool CNeuronRelativeSelfAttention::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cQuery.FeedForward(NeuronOCL) ||
      !cKey.FeedForward(NeuronOCL) ||
      !cValue.FeedForward(NeuronOCL)
     )
      return false;

我们不会检查指向接收来自外部程序源数据对象指针的相关性。因为这个操作已在内部对象的方法中实现了。因此,在这种情况下不需要这样的控制点。

接下来,我们转去生成实体,以便判定所分析对象之间的距离。我们转置原始数据张量。

   if(!cTranspose.FeedForward(NeuronOCL) ||
      !MatMul(NeuronOCL.getOutput(), cTranspose.getOutput(), cDistance.getOutput(), iUnits, iWindow, iUnits, 1)
     )
      return false;

然后我们立即执行原始数据张量与其转置副本矩阵的乘法。我们按操作结果生成 BKBV 实体。为此,我们组织循环遍历相应内部模型的各层。

   if(!((CNeuronBaseOCL*)cBKey[0]).FeedForward(cDistance.AsObject()) ||
      !((CNeuronBaseOCL*)cBValue[0]).FeedForward(cDistance.AsObject())
     )
      return false;
   for(int i = 1; i < cBKey.Total(); i++)
      if(!((CNeuronBaseOCL*)cBKey[i]).FeedForward(cBKey[i - 1]))
         return false;
   for(int i = 1; i < cBValue.Total(); i++)
      if(!((CNeuronBaseOCL*)cBValue[i]).FeedForward(cBValue[i - 1]))
         return false;

然后我们运行循环,生成全局偏差实体。

   for(int i = 1; i < cGlobalContentBias.Total(); i++)
      if(!((CNeuronBaseOCL*)cGlobalContentBias[i]).FeedForward(cGlobalContentBias[i - 1]))
         return false;
   for(int i = 1; i < cGlobalPositionalBias.Total(); i++)
      if(!((CNeuronBaseOCL*)cGlobalPositionalBias[i]).FeedForward(cGlobalPositionalBias[i - 1]))
         return false;

工作的准备阶段就这样完成了。我们调用上述相对注意力前馈内核的包装器方法。

   if(!AttentionOut())
      return false;

之后,我们继续处理结果。首先,我们使用池化 MLP 来生成注意力头的影响力张量。

   for(int i = 1; i < cMHAttentionPooling.Total(); i++)
      if(!((CNeuronBaseOCL*)cMHAttentionPooling[i]).FeedForward(cMHAttentionPooling[i - 1]))
         return false;

然后我们将结果向量乘以多头注意力的结果。

   if(!MatMul(((CNeuronBaseOCL*)cMHAttentionPooling[cMHAttentionPooling.Total() - 1]).getOutput(),
              ((CNeuronBaseOCL*)cMHAttentionPooling[0]).getOutput(),
              ((CNeuronBaseOCL*)cScale[0]).getOutput(),
              1, iHeads, iWindowKey, iUnits)
     )
      return false;

接下来,我们只需用伸缩 MLP 来伸缩获得的数值。

   for(int i = 1; i < cScale.Total(); i++)
      if(!((CNeuronBaseOCL*)cScale[i]).FeedForward(cScale[i - 1]))
         return false;

我们将获得的结果与原始数据相加,并将结果写入从父类继承的顶级结果缓冲区。为了执行该操作,我们需要保持指向结果缓冲区的指针不变。

   if(!SumAndNormilize(NeuronOCL.getOutput(), 
                       ((CNeuronBaseOCL*)cScale[cScale.Total() - 1]).getOutput(), 
                       Output, iWindow, true, 0, 0, 0, 1))
      return false;
//---
   return true;
  }

在实现前馈通验方法之后,我们通常会继续构造反向传播算法,这些算法在 calcInputGradientsupdateInputWeights 方法中组织。第一种方法根据误差梯度对最终结果的影响,将误差梯度分派至所有模型元素。第二种方法调整模型参数,以便降低总体误差。请查阅随附的代码了解更多详情。您能在那里找到该类及其所有方法的完整代码。现在,我们转到下一阶段的工作 — 构造实现 R-MAT 框架的顶级对象。

2.2实现 R-MAT 框架


为了规划 R-MAT 框架的高级算法,我们将创建一个名为 CNeuronRMAT 的新类。其结构如下所示。

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

public:
                     CNeuronRMAT(void) {};
                    ~CNeuronRMAT(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key,
                          uint units_count, uint heads, uint layers,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override   const   {  return defNeuronRMAT; }
   //---
   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 CNeuronRMAT::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                       uint window, uint window_key, uint units_count, uint heads, 
                       uint layers, ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type, batch))
      return false;

初始化方法参数包括常量,可明确解释用户对所创建对象的要求。在此,我们遇到了熟悉的注意力模块参数集,包括内部层数。

我们执行的第一个操作是按标准调用父类的同名方法。接下来,准备局部变量。

   cLayers.SetOpenCL(OpenCL);
   CNeuronRelativeSelfAttention *attention = NULL;
   CResidualConv *conv = NULL;

接下来,我们添加一个循环,其迭代次数等于内部层数。

   for(uint i = 0; i < layers; i++)
     {
      attention = new CNeuronRelativeSelfAttention();
      if(!attention ||
         !attention.Init(0, i * 2, OpenCL, window, window_key, units_count, heads, optimization, iBatch) ||
         !cLayers.Add(attention)
        )
        {
         delete attention;
         return false;
        }

在循环主体内,我们首先创建先前实现的相对注意力对象的新一个实例,并按传递而来的从外部程序接收的常量初始化它。

您或许还记得,相对注意力类的前馈通验方法组织了残差连接流。因此,我们可在该级别跳过这个操作,迈步前行。

下一步是创建一个类似于原版 变换器FeedForward 模块。不过,为了创建一个看起来更简单的高级对象,我们决定稍微修改这个模块的架构。代之,我们初始化了一个带有残差连接 CResidualConv 的卷积模块。顾名思义,这个模块还包括残差连结,无需在上层类中实现它们。

      conv = new CResidualConv();
      if(!conv ||
         !conv.Init(0, i * 2 + 1, OpenCL, window, window, units_count, optimization, iBatch) ||
         !cLayers.Add(conv)
        )
        {
         delete conv;
         return false;
        }
     }

因此,我们仅需创建两个对象就可构造一个相对关注意力层。我们按照其后续调用的顺序将指向已创建对象的指针添加到我们的动态数组当中,并继续生成内部注意力层循环的下一次迭代。

所有循环迭代成功完成后,我们将最后一个内层的数据缓冲区指针替换为相应的上层缓冲区。

   SetOutput(conv.getOutput(), true);
   SetGradient(conv.getGradient(), true);
//---
   return true;
  }

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

如您所见,通过将 R-MAT 框架算法划分为单独的模块,我们能够构造一个相当简洁的高级对象。

应当注意的是,这种简洁性也反映在类的其它方法中。以 feedForward 方法为例。该方法接收指向输入数据对象的指针作为参数。

bool CNeuronRMAT::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   CNeuronBaseOCL *neuron = cLayers[0];
   if(!neuron.FeedForward(NeuronOCL))
      return false;

在方法主体中,我们首先调用第一个嵌套对象的同名方法。然后,我们规划一个循环,按顺序迭代所有嵌套对象,并调用它们各自的方法。在每次调用期间,我们将指向前一个对象输出的指针作为输入传递。

   for(int i = 1; i < cLayers.Total(); i++)
     {
      neuron = cLayers[i];
      if(!neuron.FeedForward(cLayers[i - 1]))
         return false;
     }
//---
   return true;
  }

所有循环迭代完成后,我们甚至不需要复制数据,这在于我们之前安排了缓冲区指针替换。因此,我们简单地将操作的逻辑结果返回给调用程序即可,并结束该方法。

类似的方式也适用于反向通验方法,我建议您自行审查。至此,我们完成了 MQL5 版本的 R-MAT 框架实现算法的验证。您可在附件中找到本文中讲述的类、及其所有方法的完整代码。

在那里,您还可找到环境互动和模型训练程序的完整代码。这些都是从以前的项目中完整转移过来的,未经修改。至于模型架构,只做了细微调整,替换了环境状态编码器中的单个层。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronRMAT;
   descr.window=BarDescr;
   descr.count=HistoryBars;
   descr.window_out = EmbeddingSize/2;                // Key Dimension
   descr.layers = 5;                                  // Layers
   descr.step = 4;                                    // Heads
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

您可在附件中找到已训练模型架构的完整描述。



3. 测试

我们在利用 MQL5 实现 R-MAT 框架方面做了大量工作。现在我们进入工作的最后阶段 — 训练模型,并测试生成的政策。在该项目中,我们遵循前面描述的模型训练算法。在这种情况下,我们同时训练所有三个模型:账户状态编码器、参与者、和评论者。第一个模型执行解释市场状况的准备工作。参与者 基于学到的政策制定交易决策。评论者 评估 参与者 的动作,并指示政策调整的方向。

如前,这些模型依据 EURUSD 的 2023 年全年,H1 时间帧的真实历史数据进行训练。所有指标参数均按其默认值设置。

这些模型以迭代方式进行训练,并定期更新训练数据集。

训练政策的有效性在 2024 年 1 月的历史数据上进行了验证。测试结果呈现如下。

该模型在测试阶段达到 60% 的盈利交易水平。甚至,每笔仓位的平均和最大盈利都超过了相应的亏损指标。

然而,有一点 “美中不足”。在测试期间,该模型仅执行了 15 笔交易。余额图显示,主要盈利是在月初获得的。然后观察到一个平坦的趋势。因此,在这种情况下,我们只能谈论模型的潜力;为了令其适合长期交易,需要进一步开发。


结束语

相对分子注意力变换器R-MAT)代表了预测复杂特性领域的重大进步。在交易境况中,R-MAT 可被视为分析各种市场因素之间错综复杂关系的强大工具,同时参考它们的相对距离和时态依赖性。

在实践部分,我们利用 MQL5 实现了对所提议方式的解释,并据真实世界的数据训练了出品模型。测试结果展现出所建议解决方案的潜力。不过,该模型需要进一步优调才能用于实时交易场景。

参考

文章中所用程序

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

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

附加的文件 |
MQL5.zip (1976.95 KB)
在外汇数据分析中使用关联规则 在外汇数据分析中使用关联规则
如何将超市零售分析中的预测规则应用于真实的外汇市场?购买饼干、牛奶和面包与证券交易所的交易有何关联?本文讨论了一种基于关联规则的算法交易的创新方法。
基于MQL5的自动化交易策略(第一部分):Profitunity系统(比尔·威廉姆斯的《交易混沌》) 基于MQL5的自动化交易策略(第一部分):Profitunity系统(比尔·威廉姆斯的《交易混沌》)
在本文中,我们研究了比尔·威廉姆斯(Bill Williams)的Profitunity系统,深入剖析其核心组成部分以及在混沌市场中独特的交易方法。我们指导读者在MQL5中实现该系统,专注于自动化关键指标和入场/出场信号。最后,我们对策略进行测试和优化,提供其在不同市场环境下的表现。
新手在交易中的10个基本错误 新手在交易中的10个基本错误
新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
从基础到中级:数组和字符串(二) 从基础到中级:数组和字符串(二)
在本文中,我将展示,尽管我们仍处于编程的一个非常基本的阶段,但我们已经可以实现一些有趣的应用程序。在这种情况下,我们将创建一个相当简单的密码生成器。通过这种方式,我们将能够应用到目前为止已经解释过的一些概念。此外,我们将研究如何为一些具体问题制定解决方案。