English Русский Español Deutsch 日本語 Português
preview
交易中的神经网络:探索局部数据结构

交易中的神经网络:探索局部数据结构

MetaTrader 5交易系统 |
180 2
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

点云中的物体检测任务越来越受到关注。解决该任务的有效性在很大程度上取决于有关局部区域结构的信息。然而,点云的稀疏和不规则性质经常会导致不完整、和嘈杂的局部结构。

传统的基于卷积的物体检测依赖于固定的内核,均等地对待所有相邻点。如是结果,来自其它物体的不相关点、或噪点就不可避免地包含在分析当中。

变换器已证明其在处理各种任务方面的有效性。与卷积相比,自注意力机制能够自适应过滤掉嘈杂、或不相关的点。无论如何,原版变换器在序列中的所有元素上应用相同的变换函数。这种各向同性方式忽略了空间关系和局部结构信息,诸如从中心点到其相邻点的方向和距离。如果重新排列点的位置,则变换器的输出维持不变。这给识别物体的方向性带来了挑战,其对于检测价格形态至关重要。

论文《SEFormer:检测 3D 物体结构的嵌入转换器》的作者旨在通过开发一种新的转换器架构 — 结构嵌入 transFormerSEFormer)来结合这两种方式的优势,其有能力配以注意力方向和距离为局部结构编码。所提议 SEFormer 学习来自不同方向和距离点的 数值 的不同变换。相较之,局部空间结构的变化会反映在模型的输出之中,为准确识别物体方向性提供了关键。

基于提议的 SEFormer 模块,该研究引入了一个针对 3D 物体检测的多尺度网络。


1. SEFormer 算法

卷积的局部性和空间不变性与图像数据中的归纳乖离非常吻合。卷积的另一个关键优势是它有能力针对数据中的结构信息进行编码。SEFormer 方法的作者将卷积分解为两步操作:变换和聚合。在变换步骤期间,每个点都乘以相应的内核 wδ。然后,这些数值简单地与固定聚合系数 α=1 汇总。在卷积中,根据内核的方向、及与内核中心的距离,内核所学不同。如是结果,卷积有额能力针对局部空间结构进行编码。不过,在聚合期间,所有相邻点都同等对待(α=1)。标准卷积算子使用一个静态和刚性内核,但点云通常是不规则的,甚至是不完整的。由此,卷积不可避免地会将不相关、或嘈杂的点参入生成的特征当中。

与卷积相比,变换器中的 自注意力 机制为在点云中保留不规则形状和物体边界提供了一种更有效的方法。对于由 N 个元素 𝒑=[p1,…, pN] 组成的点云,变换器 按如下计算每个点的响应:

此处的 αδ 表示局部邻域中点之间的自注意力系数,而 𝑾v 表示数值变换。与卷积中的静态 α=1 相比,自注意力系数允许自适应选择点进行聚合,有效地排除了不相关点的影响。不过,相同的 变换应用于 变换器 中的所有点,这意味着它缺乏卷积固有的结构编码能力。

鉴于上述情况,SEFormer 的作者观察到卷积有能力针对数据结构进行编码,而 变换器 可有效地预留它。因此,直截了当的思路是开发一种结合卷积和 变换器 优优势的新算子。这导致了 SEFormer 的提议,它可公式化为:

SEFormer 和原版 变换器 之间的主要区别在于 变换函数,其基于点的相对位置来学习。

鉴于点云的不规则性,SEFormer 的作者遵循 点变换 范例,在将每个 查询 点传递到 变换器 之前从周围相邻点独立采样。在他们的方法中,作者选择使用网格插值来生成关键点。围绕每个所分析点,将生成多个虚拟点,这些点排列在预定义的网格上。两个网格元素之间的距离固定为 d

然后,这些虚拟点将采用所分析点云中最近的相邻点进行插值。与 K 最近邻(KNN)等传统采样方法相比,网格采样的优势在于它有从不同方向强制选择点的能力。网格插值可以更精确地表示局部结构。然而,鉴于采用固定距离 d 进行网格插值,作者采用多半径策略来提高采样灵活性。

SEFormer 构造一个包含多个 变换矩阵(𝑾v)的记忆池。插值关键点基于其相对于原始点的相对坐标搜索其对应的 𝑾v。如是结果,它们的特征发生了不同的变化。这令 SEFormer 能够对结构信息进行编码 — 这是原版 变换器 所缺乏的能力。

由作者提议的目标检测模型中,首先构造了一个基于 3D 卷积的骨干,以便提取多尺度体元素特征,并生成初始提案。卷积主干将原始输入转换为一组体元素特征,其下采样因子为 1×, 2×, 4× 和 8×。这些不同比例的特征会在不同的深度级别处理。特征提取后,3D 体积沿 Z-轴压缩,并转换为 2D 鸟瞰图(BEV)特征映射。这些 BEV 映射之后用来生成初始候选物体预测。

接下来,所提议空间调制结构将多尺度特征 [𝑭1, 𝑭2, 𝑭3, 𝑭4] 聚合为若干个点级嵌入 𝑬。从 𝑬init 开始,针对每个所分析元素关键点的插值来自最小尺度特征映射 𝑭1。作者使用 m 个不同的网格距离 d 来生成多尺度的关键特征集合,表示为 𝑭1,1, 𝑭2,1,…, 𝑭m,1。这种多半径策略强化了模型处理点云稀疏和不规则分布的能力。然后应用 m 个并行 SEFormer 模块来生成 m 个更新的嵌入 𝑬1,1, 𝑬2,1,…, 𝑬m,1。这些嵌入被串联起来,并利用原版变换器转换为统一的嵌入 𝑬1。然后 𝑬1 重复前面讲述的过程,并将 [𝑭2, 𝑭3, 𝑭4] 聚合到最终嵌入 𝑬final 当中。与原始体元素特征 𝑭 相比,最终嵌入 𝑬final 提供了更详细的局部区域结构表示。

根据生成的点级嵌入 𝑬final,作者提议的模型头将它们聚合到几个对象级嵌入之中,以便生成最终的物体提案。更具体地说,每个初始阶段提案都分为多个立方子区域,每个子区域都与周围的点级物体嵌入进行插值。由于点云的稀疏性,某些区域通常是空的。传统方法只是针对非空区域中的特征求和。相较之,SEFormer 有能力利用来自填充区域和空区域的信息。SEFormer 强化的结构嵌入能力,允许更丰富的物体级结构表示,从而生成更准确的提案。

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



2. 利用 MQL5 实现

在回顾了所提议 SEFormer 方法的理论层面之后,我们现在转到论文的实践部分,其中我们实现了对所提议方式的解释。我们从研究我们的未来模型架构开始。

置于初始特征提取,SEFormer 方法的作者提议使用基于体元素的 3D 卷积。然而,在我们的例子中,单根柱线的特征向量可能包含更多的属性。如此这般,该方式对于我们的目的来说似乎效率较低。因此,我提议依靠我们之前所用方法,其用具有不同注意力级联级别的稀疏注意力模块来聚合特征。

值得强调的第二点是围绕所分析点构造网格。在 SEFormer 作者解决的 3D 物体检测任务当中,数据可以沿高度维度进行压缩,从而允许在平面映射上分析物体。然而,在我们的例子中,数据表示是多维的,每个维度在任意给定时刻都能扮演关键角色。我们不能沿任何单一维度压缩数据。甚至,在高维空间中构造 “网格” 就是一个相当大的挑战。元素的数量随着所分析元素数量而呈几何级数增加。在我看来,在这种情况下,更有效的解决方案是让模型学习多维空间中的最优质心点。

鉴于上述情况,我提议通过继承 CNeuronPointNet2OCL 类的核心功能来构建我们的新对象。新类 CNeuronSEFormer 的一般结构如下所示。

class CNeuronSEFormer   :    public CNeuronPointNet2OCL
  {
protected:
   uint              iUnits;
   uint              iPoints;
   //---
   CLayer            cQuery;
   CLayer            cKey;
   CLayer            cValue;
   CLayer            cKeyValue;
   CArrayInt         cScores;
   CLayer            cMHAttentionOut;
   CLayer            cAttentionOut;
   CLayer            cResidual;
   CLayer            cFeedForward;
   CLayer            cCenterPoints;
   CLayer            cFinalAttention;
   CNeuronMLCrossAttentionMLKV SEOut;
   CBufferFloat      cbTemp;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      AttentionOut(CBufferFloat *q, CBufferFloat *kv, int scores, CBufferFloat *out);
   virtual bool      AttentionInsideGradients(CBufferFloat *q, CBufferFloat *q_g,
                                              CBufferFloat *kv, CBufferFloat *kv_g,
                                              int scores, CBufferFloat *gradient);
   //---
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;
   //---

public:
                     CNeuronSEFormer(void) {};
                    ~CNeuronSEFormer(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint units_count, uint output, bool use_tnets,
                          uint center_points, uint center_window,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override   const   {  return defNeuronSEFormer; }
   //---
   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;
  };

在上面表述的结构之中,我们已能看到熟悉的可重写方法清单,及众多嵌套对象。其中一些组件的名称或许会令我们想起 变换器 架构 — 这并非巧合。SEFormer 方法作者旨在强化原版 变换器 算法。但特事特例。

我们的类其所有内部对象都声明为静态,这样就允许我们将构造函数和析构函数留空。已声明组件和继承组件的初始化都在 Init 方法中处理,如您所知,其参数包含定义所创建对象架构的核心常量。

bool CNeuronSEFormer::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                           uint window, uint units_count, uint output, bool use_tnets,
                           uint center_points, uint center_window,
                           ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronPointNet2OCL::Init(numOutputs, myIndex, open_cl, window, units_count, output, use_tnets,
                                 optimization_type, batch))
      return false;

除了我们已经熟悉的参数之外,我们现在还引入了可训练质心的数量,和表示其状态的向量维数。

重点要注意,我们设计模块架构是按这样的方式,即质心描述符向量的维数,与描述单根分析柱线的特征数量可以不同。

在方法主体内,如常,我们首先调用父类的相应方法,其中已实现了继承组件的参数验证、和初始化机制。我们只需验证父方法执行的逻辑结果。

之后,我们存储若干个架构参数,这些参数在所构建算法的执行期间是必需的。

   iUnits = units_count;
   iPoints = MathMax(center_points, 9);

作为内部对象的数组,我使用了 CLayer 对象。为了实现它们的正确操作,我们传递一个指向 OpenCL 关联环境对象的指针。

   cQuery.SetOpenCL(OpenCL);
   cKey.SetOpenCL(OpenCL);
   cValue.SetOpenCL(OpenCL);
   cKeyValue.SetOpenCL(OpenCL);
   cMHAttentionOut.SetOpenCL(OpenCL);
   cAttentionOut.SetOpenCL(OpenCL);
   cResidual.SetOpenCL(OpenCL);
   cFeedForward.SetOpenCL(OpenCL);
   cCenterPoints.SetOpenCL(OpenCL);
   cFinalAttention.SetOpenCL(OpenCL);

为了学习质心表示,我们将创建一个由 2 个连续的全连接层组成的小型 MLP

//--- Init center points
   CNeuronBaseOCL *base = new CNeuronBaseOCL();
   if(!base)
      return false;
   if(!base.Init(iPoints * center_window * 2, 0, OpenCL, 1, optimization, iBatch))
      return false;
   CBufferFloat *buf = base.getOutput();
   if(!buf || !buf.BufferInit(1, 1) || !buf.BufferWrite())
      return false;
   if(!cCenterPoints.Add(base))
      return false;
   base = new CNeuronBaseOCL();
   if(!base.Init(0, 1, OpenCL, iPoints * center_window * 2, optimization, iBatch))
      return false;
   if(!cCenterPoints.Add(base))
      return false;

注意,我们创建的质心两倍于指定数字。以这种方式,我们创建了 2 组质心,模拟不同比例的网格构造。

然后我们将创建一个循环,在该循环中,我们将根据特征缩放层的数量初始化内部对象。

我要提醒你,在父类中,我们用两个注意力集中系数聚合原始数据。相应地,我们的循环将包含 2 次迭代。

//--- Inside layers
   for(int i = 0; i < 2; i++)
     {
      //--- Interpolation
      CNeuronMVCrossAttentionMLKV *cross = new CNeuronMVCrossAttentionMLKV();
      if(!cross ||
         !cross.Init(0, i * 12 + 2, OpenCL, center_window, 32, 4, 64, 2, iPoints, iUnits, 
                                                         2, 2, 2, 1, optimization, iBatch))
         return false;
      if(!cCenterPoints.Add(cross))
         return false;

对于质心插值,我们利用一个交叉注意力模块,将质心的当前表示与所分析输入数据集对齐。该过程的核心思想是识别一组质心,即最准确、最有效地将输入数据分割为局部区域。如此行事,我们旨在学习输入数据的结构。

接下来,我们继续按照原作者的提议初始化 SEFormer 模块组件。该模块旨在据有关点云结构的信息来丰富所分析点的嵌入。技术上,我们将交叉注意力机制从所分析点应用于我们的质心,其已丰富了点云结构信息。

在此,我们使用卷积层根据分析点的嵌入生成 查询 实体。

      //--- Query
      CNeuronConvOCL *conv = new CNeuronConvOCL();
      if(!conv ||
         !conv.Init(0, i * 12 + 3, OpenCL, 64, 64, 64, iUnits, optimization, iBatch))
         return false;
      if(!cQuery.Add(conv))
         return false;

我们按类似方式生成 实体,但在此我们使用质心表示。

      //--- Key
      conv = new CNeuronConvOCL();
      if(!conv ||
         !conv.Init(0, i * 12 + 4, OpenCL, center_window, center_window, 32, iPoints, 2, optimization, iBatch))
         return false;
      if(!cKey.Add(conv))
         return false;

为了生成 数值 实体,SEFormer 方法的作者提议对序列的每个元素使用单独的转换矩阵。因此,我们应用了一个类似的卷积层,但序列中的元素数量设置为 1。同时,质心的总数将作为输入变量的参数传递。这种方式令我们能够达成预期成果。

      //--- Value
      conv = new CNeuronConvOCL();
      if(!conv ||
         !conv.Init(0, i * 12 + 5, OpenCL, center_window, center_window, 32, 1, iPoints * 2,
                                                                       optimization, iBatch))
         return false;
      if(!cValue.Add(conv))
         return false;

不过,我们所有的内核都是由交叉注意力算法创建的,以便搭配 键-值 实体的串联张量操作。故此,为了不修改 OpenCL 程序,我们简单地添加指定张量的串联。

      //--- Key-Value
      base = new CNeuronBaseOCL();
      if(!base ||
         !base.Init(0, i * 12 + 6, OpenCL, iPoints * 2 * 32 * 2, optimization, iBatch))
         return false;
      if(!cKeyValue.Add(base))
         return false;

依赖系数矩阵仅在 OpenCL 关联环境中使用,并在每次前馈通验时重新计算。因此,在主内存中创建该缓存区没有意义。故此,我们仅在 OpenCL 关联环境内存中创建它。

      //--- Score
      int s = int(iUnits * iPoints * 4);
      s = OpenCL.AddBuffer(sizeof(float) * s, CL_MEM_READ_WRITE);
      if(s < 0 || !cScores.Add(s))
         return false;

接下来,我们创建一个记录多头注意力数据的层。

      //--- MH Attention Out
      base = new CNeuronBaseOCL();
      if(!base ||
         !base.Init(0, i * 12 + 7, OpenCL, iUnits * 64, optimization, iBatch))
         return false;
      if(!cMHAttentionOut.Add(base))
         return false;

我们还添加了一个卷积层来缩放获得的结果。

      //--- Attention Out
      conv = new CNeuronConvOCL();
      if(!conv ||
         !conv.Init(0, i * 12 + 8, OpenCL, 64, 64, 64, iUnits, 1, optimization, iBatch))
         return false;
      if(!cAttentionOut.Add(conv))
         return false;

根据变换器算法,所获自注意力结果与原始数据相加,并归一化。

      //--- Residual
      base = new CNeuronBaseOCL();
      if(!base ||
         !base.Init(0, i * 12 + 9, OpenCL, iUnits * 64, optimization, iBatch))
         return false;
      if(!cResidual.Add(base))
         return false;

接下来,我们添加 2 层前馈模块。

      //--- Feed Forward
      conv = new CNeuronConvOCL();
      if(!conv ||
         !conv.Init(0, i * 12 + 10, OpenCL, 64, 64, 256, iUnits, 1, optimization, iBatch))
         return false;
      conv.SetActivationFunction(LReLU);
      if(!cFeedForward.Add(conv))
         return false;
      conv = new CNeuronConvOCL();
      if(!conv ||
         !conv.Init(0, i * 12 + 11, OpenCL, 256, 64, 64, iUnits, 1, optimization, iBatch))
         return false;
      if(!cFeedForward.Add(conv))
         return false;

以及一个对象,组织剩余沟通。

      //--- Residual
      base = new CNeuronBaseOCL();
      if(!base ||
         !base.Init(0, i * 12 + 12, OpenCL, iUnits * 64, optimization, iBatch))
         return false;
      if(!base.SetGradient(conv.getGradient(), true))
         return false;
      if(!cResidual.Add(base))
         return false;

注意,在这种情况下,我们覆盖了残差连接层内的梯度误差缓冲区。这令我们能够避免将梯度误差数据从残差层复制到前馈通验模块最后一层的操作。

为了总结 SEFormer 模块,作者建议使用原版 变换器。不过,我通过合并场景感知注意力模块,选择了更复杂的架构。

      //--- Final Attention
      CNeuronMLMHSceneConditionAttention *att = new CNeuronMLMHSceneConditionAttention();
      if(!att ||
         !att.Init(0, i * 12 + 13, OpenCL, 64, 16, 4, 2, iUnits, 2, 1, optimization, iBatch))
         return false;
      if(!cFinalAttention.Add(att))
         return false;
     }

在该阶段,我们已初始化了单个内部层的所有组件,现在正转入循环的下一次迭代。

内层初始化循环的所有迭代完成后,重点要注意,我们不会单独使用每个内层的输出。逻辑上,可将它们级联到一个张量,并将这个统一的张量传递给父类,从而生成全局点云嵌入。当然,我们首先需要将生成的张量缩放到所需的维度。不过,在这种情况下,我决定采用另一种方式。取而代之,我们使用交叉注意力模块,用来自较高尺度层的信息来丰富较低尺度的数据。

   if(!SEOut.Init(0, 26, OpenCL, 64, 64, 4, 16, 4, iUnits, iUnits, 4, 1, optimization, iBatch))
      return false;

在方法结束时,我们初始化一个临时数据存储的辅助缓冲区。

   if(!cbTemp.BufferInit(buf_size, 0) ||
      !cbTemp.BufferCreate(OpenCL))
      return false;
//---
   return true;
  }

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

在该阶段,我们已完成了类对象初始化方法上的工作。现在,我们转入在 feedForward 方法中构造前馈通验算法。如您所知,在该方法参数中,我们接收指向源数据对象的指针。

bool CNeuronSEFormer::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
//---
   CNeuronBaseOCL *neuron = NULL, *q = NULL, *k = NULL, *v = NULL, *kv = NULL;

在方法主体中,我们声明了一些局部变量,来临时存储指向内部对象的指针。然后我们生成质心的表示。

//--- Init Points
   if(bTrain)
     {
      neuron = cCenterPoints[1];
      if(!neuron ||
         !neuron.FeedForward(cCenterPoints[0]))
         return false;
     }

注意,我们仅在模型训练过程期间生成质心表示。在操作过程中,质心点是静态的。故此,我们不需要在每次通验时都生成它们。

接下来,我们组织一个遍历内层的循环,

//--- Inside Layers
   for(int l = 0; l < 2; l++)
     {
      //--- Segmentation Inputs
      if(l > 0 || !cTNetG)
        {
         if(!caLocalPointNet[l].FeedForward((l == 0 ? NeuronOCL : GetPointer(caLocalPointNet[l - 1]))))
            return false;
        }
      else
        {
         if(!cTurnedG)
            return false;
         if(!cTNetG.FeedForward(NeuronOCL))
            return false;
         int window = (int)MathSqrt(cTNetG.Neurons());
         if(IsStopped() ||
            !MatMul(NeuronOCL.getOutput(), cTNetG.getOutput(), cTurnedG.getOutput(), 
                                      NeuronOCL.Neurons() / window, window, window))
            return false;
         if(!caLocalPointNet[0].FeedForward(cTurnedG.AsObject()))
            return false;
        }

在主体中,我们首先对源数据进行分段(该算法借鉴自父类)。然后,我们用得到的数据丰富质心。

      //--- Interpolate center points
      neuron = cCenterPoints[l + 2];
      if(!neuron ||
         !neuron.FeedForward(cCenterPoints[l + 1], caLocalPointNet[l].getOutput()))
         return false;

接下来,我们转到数据结构编码的注意力模块。首先,我们从数组中提取相应的内层。

      //--- Structure-Embedding Attention
      q = cQuery[l];
      k = cKey[l];
      v = cValue[l];
      kv = cKeyValue[l];

然后我们按顺序生成所有必要的实体。

      //--- Query
      if(!q || !q.FeedForward(GetPointer(caLocalPointNet[l])))
         return false;
      //--- Key
      if(!k || !k.FeedForward(cCenterPoints[l + 2]))
         return false;
      //--- Value
      if(!v || !v.FeedForward(cCenterPoints[l + 2]))
         return false;

生成结果被串连到一个张量之中。

      if(!kv ||
         !Concat(k.getOutput(), v.getOutput(), kv.getOutput(), 32 * 2, 32 * 2, iPoints))
         return false;

之后,我们可用经典的多头自注意力方法。

      //--- Multi-Head Attention
      neuron = cMHAttentionOut[l];
      if(!neuron ||
         !AttentionOut(q.getOutput(), kv.getOutput(), cScores[l], neuron.getOutput()))
         return false;

我们将获得的数据缩放到原始数据的大小。

      //--- Scale
      neuron = cAttentionOut[l];
      if(!neuron || !neuron.FeedForward(cMHAttentionOut[l]))
         return false;

然后,我们将两个信息流相加,并归一化结果数据。

      //--- Residual
      q = cResidual[l * 2];
      if(!q ||
         !SumAndNormilize(caLocalPointNet[l].getOutput(), neuron.getOutput(), q.getOutput(), 64, true,
                                                                                           0, 0, 0, 1))
         return false;

与原版变换器 解码器类似,我们调用 FeedForward 模块,后跟残差关联、和数据归一化。

      //--- Feed Forward
      neuron = cFeedForward[l * 2];
      if(!neuron || !neuron.FeedForward(q))
         return false;
      neuron = cFeedForward[l * 2 + 1];
      if(!neuron || !neuron.FeedForward(cFeedForward[l * 2]))
         return false;
      //--- Residual
      k = cResidual[l * 2 + 1];
      if(!k ||
         !SumAndNormilize(q.getOutput(), neuron.getOutput(), k.getOutput(), 64, true, 0, 0, 0, 1))
         return false;

我们把获得的结果经由注意力模块传递,同时参考场景。然后我们转到循环的下一次迭代。

      //--- Final Attention
      neuron = cFinalAttention[l];
      if(!neuron || !neuron.FeedForward(k))
         return false;
     }

所有内层操作成功完成后,我们用大尺度信息丰富较小尺度的点嵌入。

//--- Cross scale attention
   if(!SEOut.FeedForward(cFinalAttention[0], neuron.getOutput()))
      return false;

然后我们传送得到的结果,以便形成所分析点云的全局嵌入。

//--- Global Point Cloud Embedding
   if(!CNeuronPointNetOCL::feedForward(SEOut.AsObject()))
      return false;
//--- result
   return true;
  }

在前馈通验方法的末尾,我们返回给调用程序一个布尔值,指示操作成功。

如是所见,前向通验算法的实现,是个相当复杂的信息流结构,远非线性。我们观察残差连接的使用情况。某些分量依赖于两个数据源。甚至,在若干处,数据流交汇。当然了,这种复杂性已影响到后向通验算法的设计,我们在 calcInputGradients 方法中实现了该算法。

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

该方法接收指向前一层的指针作为参数。在前向通验期间,该层提供输入数据。现在,我们必须把误差梯度,即输入数据对于模型最终输出的影响传回给它。

在方法主体中,我们立即验证接收的指针,因为继续使用无效的引用将令后续所有操作变得毫无意义。

我们还声明了一组局部变量,临时存储指向内部分量的指针。

   CNeuronBaseOCL *neuron = NULL, *q = NULL, *k = NULL, *v = NULL, *kv = NULL;
   CBufferFloat *buf = NULL;

之后,我们将误差梯度从点云的全局嵌入传播到我们的内层。

//--- Global Point Cloud Embedding
   if(!CNeuronPointNetOCL::calcInputGradients(SEOut.AsObject()))
      return false;

注意,在前馈通验中,我们通过调用父类方法获得了最终结果。因此,要获得误差梯度,我们需要调用父类的相应方法。

接下来,我们将误差梯度分派到不同尺度的流中。

//--- Cross scale attention
   neuron = cFinalAttention[0];
   q = cFinalAttention[1];
   if(!neuron.calcHiddenGradients(SEOut.AsObject(), q.getOutput(), q.getGradient(), (
                                                    ENUM_ACTIVATION)q.Activation()))
      return false;

然后我们组织一个遍历内层的逆向循环。

   for(int l = 1; l >= 0; l--)
     {
      //--- Final Attention
      neuron = cResidual[l * 2 + 1];
      if(!neuron || !neuron.calcHiddenGradients(cFinalAttention[l]))
         return false;

在此,我们首先将误差梯度传播到残差连接层的级别。

我要提醒您,在初始化内部对象时,我们将残差连接层的误差梯度缓冲区替换为前馈模块中该层的类似缓冲区。故现在我们可以跳过不必要的数据复制操作,并立即将误差梯度传递到以下的级别。

      //--- Feed Forward
      neuron = cFeedForward[l * 2];
      if(!neuron || !neuron.calcHiddenGradients(cFeedForward[l * 2 + 1]))
         return false;

接下来,我们将误差梯度传播到注意力模块的残差连接层。

      neuron = cResidual[l * 2];
      if(!neuron || !neuron.calcHiddenGradients(cFeedForward[l * 2]))
         return false;

在此,我们汇总来自 2 条信息流的误差梯度,并将总值传送到注意力模块。

      //--- Residual
      q = cResidual[l * 2 + 1];
      k = neuron;
      neuron = cAttentionOut[l];
      if(!neuron ||
         !SumAndNormilize(q.getGradient(), k.getGradient(), neuron.getGradient(), 64, false, 0, 0, 0, 1))
         return false;

之后,我们在注意力头之间分派误差梯度。

      //--- Scale
      neuron = cMHAttentionOut[l];
      if(!neuron || !neuron.calcHiddenGradients(cAttentionOut[l]))
         return false;

使用原版变换器算法,我们将误差梯度传播到 查询、和 实体级别。

      //--- MH Attention
      q = cQuery[l];
      kv = cKeyValue[l];
      k = cKey[l];
      v = cValue[l];
      if(!AttentionInsideGradients(q.getOutput(), q.getGradient(), kv.getOutput(), kv.getGradient(),
                                                                   cScores[l], neuron.getGradient()))
         return false;

作为该操作的结果,我们获得了 2 个误差梯度张量:在 查询 和级联的 键-值 张量级别。我们在相应内层的缓冲区之间分配 误差梯度。

      if(!DeConcat(k.getGradient(), v.getGradient(), kv.getGradient(), 32 * 2, 32 * 2, iPoints))
         return false;

然后,我们可将误差梯度从 查询 张量传播到原始数据分段的级别。但这里有一个警告。对于最后一层,该操作并不是特别困难。但对于第一层,梯度缓冲区已经存储了来自后续分段级别的误差信息。我们需要保留它。因此,我们检查当前层的索引,并在必要时替换指向数据缓冲区的指针。

      if(l == 0)
        {
         buf = caLocalPointNet[l].getGradient();
         if(!caLocalPointNet[l].SetGradient(GetPointer(cbTemp), false))
            return false;
        }

接下来,我们传播误差梯度。

      if(!caLocalPointNet[l].calcHiddenGradients(q, NULL))
         return false;

如有必要,我们将 2 条信息流的数据相加,随后返回指向数据缓冲区的已删除指针。

      if(l == 0)
        {
         if(!SumAndNormilize(buf, GetPointer(cbTemp), buf, 64, false, 0, 0, 0, 1))
            return false;
         if(!caLocalPointNet[l].SetGradient(buf, false))
            return false;
        }

接下来,我们添加注意力模块的残差连接的误差梯度。

      neuron = cAttentionOut[l];
      //--- Residual
      if(!SumAndNormilize(caLocalPointNet[l].getGradient(), neuron.getGradient(), 
                          caLocalPointNet[l].getGradient(), 64, false, 0, 0, 0, 1))
         return false;

下一步是将误差梯度分派到质心级别。在此,我们需要分派来自 实体的误差梯度。此处,我们还将替换指向数据缓冲区的指针。

      //--- Interpolate Center points
      neuron = cCenterPoints[l + 2];
      if(!neuron)
         return false;
      buf = neuron.getGradient();
      if(!neuron.SetGradient(GetPointer(cbTemp), false))
         return false;

之后,我们自 实体传播第一个误差梯度。

      if(!neuron.calcHiddenGradients(k, NULL))
         return false;

不过,它仅是针对最后一层的第一个,但对于开始那个,它已经包含有关误差梯度的信息,这些信息来自对后续层结果的影响。因此,我们检查所分析内层的索引,并在必要时汇总来自两条信息流的数据。

      if(l == 0)
        {
         if(!SumAndNormilize(buf, GetPointer(cbTemp), buf, 1, false, 0, 0, 0, 1))
            return false;
        }
      else
        {
         if(!SumAndNormilize(GetPointer(cbTemp), GetPointer(cbTemp), buf, 1, false, 0, 0, 0, 0.5f))
            return false;
        }

类似地,我们自 实体传播误差梯度,并汇总来自两条信息流的数据。

      if(!neuron.calcHiddenGradients(v, NULL))
         return false;
      if(!SumAndNormilize(buf, GetPointer(cbTemp), buf, 1, false, 0, 0, 0, 1))
         return false;

之后,我们将先前删除的指针返回到误差梯度缓冲区。

      if(!neuron.SetGradient(buf, false))
         return false;

接下来,我们在前一层质心、和当前层的分段数据之间分派误差梯度。

      neuron = cCenterPoints[l + 1];
      if(!neuron.calcHiddenGradients(cCenterPoints[l + 2], caLocalPointNet[l].getOutput(), 
                                     GetPointer(cbTemp), (ENUM_ACTIVATION)caLocalPointNet[l].Activation()))
         return false;

正是为了保留这个特定的误差梯度,我们之前覆盖了质心层中的缓冲区。甚至,重点要注意,数据分段层中的梯度缓冲区已经包含了很大一部分相关信息。因此,在该阶段,我们将误差梯度存储在一个临时数据缓冲区当中,然后将两条信息流的数据相加。

      if(!SumAndNormilize(caLocalPointNet[l].getGradient(), GetPointer(cbTemp), 
                          caLocalPointNet[l].getGradient(), 64, false, 0, 0, 0, 1))
         return false;

在该阶段,我们已在所有新声明的内部对象之间分派了误差梯度。但我们仍然需要在数据分段层之间分派误差梯度。我们自父类方法中借用了这个完整算法。

      //--- Local Net
      neuron = (l > 0 ? GetPointer(caLocalPointNet[l - 1]) : NeuronOCL);
      if(l > 0 || !cTNetG)
        {
         if(!neuron.calcHiddenGradients(caLocalPointNet[l].AsObject()))
            return false;
        }
      else
        {
         if(!cTurnedG)
            return false;
         if(!cTurnedG.calcHiddenGradients(caLocalPointNet[l].AsObject()))
            return false;
         int window = (int)MathSqrt(cTNetG.Neurons());
         if(IsStopped() ||
            !MatMulGrad(neuron.getOutput(), neuron.getGradient(), cTNetG.getOutput(), cTNetG.getGradient(), 
                                        cTurnedG.getGradient(), neuron.Neurons() / window, window, window))
            return false;
         if(!OrthoganalLoss(cTNetG, true))
            return false;
         //---
         CBufferFloat *temp = neuron.getGradient();
         neuron.SetGradient(cTurnedG.getGradient(), false);
         cTurnedG.SetGradient(temp, false);
         //---
         if(!neuron.calcHiddenGradients(cTNetG.AsObject()))
            return false;
         if(!SumAndNormilize(neuron.getGradient(), cTurnedG.getGradient(), neuron.getGradient(), 1, false, 
                                                                                               0, 0, 0, 1))
            return false;
        }
     }
//---
   return true;
  }

内层循环的所有迭代完成后,我们返回一个布尔值给调用程序,指示方法执行成功。

据此,我们通过新类的内部组件实现了前向通验和梯度传播算法。剩下的是 updateInputWeights 方法的实现,其负责更新可训练参数。在这种情况下,所有可训练参数都封装在嵌套组件之中。相应地,更新我们的类参数只涉及在每个内部对象中按顺序调用相应的方法。该算法很简单,我建议把该方法留待独立探索。

提醒一下,CNeuronSEFormer 类及其所有方法的完整实现可在附件中找到。在那里,您还可找到在该类内被覆盖的,早前声明的支持方法。

最后,值得注意的是,整体模型架构在很大程度上继承了上一篇文章。我们所做的唯一更改是替换了环境状态编码器中的单个层。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSEFormer;
     {
      int temp[] = {BarDescr, 8};                  // Variables, Center embedding
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
     {
      int temp[] = {HistoryBars, 27};              // Units, Centers
      if(ArrayCopy(descr.units, temp) < (int)temp.Size())
         return false;
     }
   descr.window_out = LatentCount;                 // Output Dimension
   descr.step = int(true);                         // Use input and feature transformation
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

这同样适用于与环境互动和训练模型的所有程序,这些程序均完全继承自上一篇文章。因此,我们现在不再赘述它们。附件中包含本文中用到的所有程序的完整代码。


3. 测试

现在,大量工作完成之后,我们又到了流程的最后 — 也许是最令人期待的部分:训练模型,并据真实历史数据上测试生成的参与者政策。

如常,为了训练模型,我们采用 EURUSD 金融产品整个 2023 年的真实历史数据,以及 H1 时间帧。所有指标参数均按其默认值设置。

采用的模型训练算法来自以前文章,延及的训练和测试程序亦如此。

为了测试经过训练的参与者政策,我们采用 2024 年 1 月的真实历史数据,同时保持所有其它参数不变。测试结果呈现如下。 

在测试期间,经过训练的模型执行了 21 笔交易,其中略高于 47% 的交易以盈利了结。值得注意的是,多头仓位的盈利能力明显更高(66% 对比 22%)。显然,需要额外的模型训练。无论如何,平均盈利交易比平均亏损交易大 2.5 倍,令该模型在测试期间实现了整体盈利。

以我的主观看法,这个模型相当沉重。这在很大程度上部分好像是因使用了条件化场景注意力机制。然而,在 HyperDet3D 方法中采用类似的方式,可产生更佳结果,以及更低的计算成本。

也就是说,在这两种情况下,交易数量少且测试周期短,都不允许我们对该方法的长期有效性得出任何明确的结论。


结束语

SEFormer 方法非常适合点云分析,即使在嘈杂的条件下也能有效地捕获局部依赖关系 — 这是准确预测的关键因素。这为更精确的行情走势预测,和改进决策策略提供了有前景的机会。

在本文的实践部分,我们利用 MQL5 实现了我们所提议方式的愿景。已依据真实历史数据训练并测试了模型。结果证明了所提议方法的潜力。然而,在真实交易场景中部署模型之前,必须要在更长的历史区间内对其进行训练,并针对训练过的政策进行全面测试。


参考

文章中所用程序

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

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

附加的文件 |
MQL5.zip (1823.3 KB)
最近评论 | 前往讨论 (2)
Arda Kaya
Arda Kaya | 24 4月 2025 在 16:15
好文章
Dmitriy Gizlyk
Dmitriy Gizlyk | 26 4月 2025 在 13:28
Arda Kaya #:
文章不错

谢谢。

Connexus助手(第五部分):HTTP方法和状态码 Connexus助手(第五部分):HTTP方法和状态码
在本文中,我们将了解HTTP方法和状态码,这是网络上客户端与服务器之间通信的两个非常重要的部分。了解每种方法的作用,可以让您更精确地发出请求,告知服务器您想要执行的操作,从而提高效率。
交易中的神经网络:场景感知物体检测(HyperDet3D) 交易中的神经网络:场景感知物体检测(HyperDet3D)
我们邀请您来领略一种利用超网络检测物体的新方式。超网络针对主模型生成权重,允许参考具体的当前市场形势。这种方式令我们能够通过令模型适配不同的交易条件来提升预测准确性。
在 MQL5 中创建交易管理员面板(第五部分):双因素认证(2FA) 在 MQL5 中创建交易管理员面板(第五部分):双因素认证(2FA)
今天,我们将讨论如何增强当前正在开发的交易管理员面板的安全性。我们将探讨如何在新的安全策略中实施 MQL5,并将 Telegram API 集成到双因素认证(2FA)中。本次讨论将提供有关 MQL5 在加强安全措施方面的应用的宝贵见解。此外,我们还将研究 MathRand 函数,重点关注其功能以及如何在我们构建的安全框架中有效利用它。继续阅读以了解更多信息!
创建一个基于日波动区间突破策略的 MQL5 EA 创建一个基于日波动区间突破策略的 MQL5 EA
在本文中,我们将创建一个基于日波动区间突破策略的 MQL5 EA。我们阐述该策略的关键概念,设计EA框架蓝图,并在 MQL5 语言中实现突破策略逻辑。最后,我们将探讨用于回测和优化EA的技术,以最大限度地提高其有效性。