English Русский Español Deutsch 日本語 Português
preview
交易中的神经网络:点云分析(PointNet)

交易中的神经网络:点云分析(PointNet)

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

概述

点云是简单而统一的结构,可避免组合不一致、以及与困境相关的复杂性。由于点云没有传统格式,故将其传送到深度网络架构之前,大多数研究人员通常会将该类数据集转换为常规的 3D 体素网格或图像集。不过,这种转换会令生成的数据无必要地增大,并可能引入量化伪影,这往往会掩盖数据的自然不变性。

出于这个原因,一些研究人员转向一种 3D 几何的替代表示,直接使用点云。模型配以这样的原始数据表示操作,必须考虑到这样一个事实,即点云只是点的集合,并且不受其元素排列的影响。这需要在模型的计算中具有一定程度的对称性。

论文《PointNet:依据点集深度学习,进行 3D 分类和分段 》中阐述了这样一种解决方案。这项工作中引入的模型名为 PointNet,是一套统一的架构解决方案,它直接将点云作为输入,并输出整个数据集的类标签、或数据集中单个点的分段标签。

该模型的基本架构非常简单。在初始阶段,每个点都以雷同且独立地加以处理。在默认配置中,每个点仅由其三个坐标 (x, y, z) 表示。可以通过计算法线和其它局部或全局特征来协同附加维度。

PointNet 方式的关键方面是使用单一对称函数 MaxPooling。本质上,网络学习一组优化函数,这些函数在点云内选择重要或富含信息的元素,并编码所选元素背后的原因。输出阶段的全连接层将这些学到的最优值聚合到整个形状的全局描述符之中。

这种输入数据格式很容易与刚性或仿射变换兼容,因为每个点都是独立变换的。由此,该方法作者引入了一个数据依赖性空间变换模型,其意图在 PointNet 处理之前,先将数据归一化,从而强化该解决方案的效率。


1. PointNet 算法

PointNet 的作者开发了一个深度学习框架,其直接利用无序的点集作为输入数据。点云表示为一组 3D 点 {Pi|i=1,…,n},其中每个点 Pi 是其坐标 (x, y, z) 的向量,加上其它特征通道,譬如颜色和其它属性。

模型的输入数据表示来自欧几里德空间的点子集,特征是三个关键属性:

  • 无序。不像是图像中的像素数组,点云是一组没有定义顺序的元素。换言之,一个模型消费的一组 N3D 点,必须与输入数据集 N! 的排列顺序不变。
  • 点交互。这些点存在于具有距离量值的空间。这意味着它们不是孤立的;而是,相邻点形成有意义的子集。由此,该模型必须能够从附近的点捕获局部结构、以及局部结构之间的组合交互。
  • 变换不变性。作为几何实体,所学习的点集表示应与特定转换无关。举例,点的同时旋转和平移不应令点云的全局类别或其分段交替。

PointNet 架构如此这般设计,令分类和分段模型分享其结构的很大一部分。它由三个关键模块组成:

  • 最大池化层作为对称函数,用于聚合来自所有点的信息。
  • 组合局部和全局数据表示的结构。
  • 两个联合对齐网络,用于对齐原生输入点和所学习特征表示。

为了确保模型对输入数据的排列不变性,提出了三种策略:

  1. 将输入数据排序为规范顺序。
  2. 处置输入数据作为训练 RNN 的序列,但依据所有可能的排列来补充训练集。
  3. 使用简单的对称函数聚合来自每个点的信息。对称函数取 n 个向量作为输入,并输出一个与输入顺序无关的新向量。

对源数据进行排序听起来像是一个简单的解决方案。然而,在一个多维空间中,在一般意义上的点扰动下没有稳定的排序。因此,排序并不能完全解决顺序问题。这令模型难以学习输入和输出数据之间的一致映射。实验结果表明,将 MLP 直接应用于一组已排序点的性能很糟糕,但略优于处理原生未排序数据。

虽然 RNN 对于短序列(数十个元素)的输入序列表现出合理的健壮性,但将它们扩展到数千个输入元素则具有挑战性。原始论文中提出的实证结果还表明,基于 RNN 的模型并未优于所提议的 PointNet 算法。

PointNet 的核心思路是通过将对称函数应用于集合中转换的元素,来近似优一组点定义的一般函数:

如是实证,作者提出了一个非常简单的基本模块:首先,h 使用 MLP 进行近似,g 由单变量函数、和最大池化函数组成。实验验证确认了这种方式的有效性。经由一套 h 函数,可以学习 f 函数的一个范围来捕获输入数据集的各种属性。

尽管这个关键模块很简化,但它表现出卓越的性能,并在跨多种应用中达成了高性能。

在所提议密钥模块的输出处,形成一个向量 [f1,…,fK],作为输入数据集的全局签名。这样就可在全局特征形状上训练 SVMMLP 分类器,应对分类任务。不过,逐点分段需要结合局部和全局知识。这可以通过一种简单而高效的措施来达成。

在计算了整个点云的全局特征向量之后,PointNet 的作者提议将全局表示与每个点连接起来,将该向量返回给每个单独的点对象。这允许基于组合的逐点对象提取每个新点的特征 — 现在同时参考局部和全局信息。

据此修改,PointNet 能基于局部几何和全局语义预测每个点的分数。例如,它可以准确预测每个点的法线,从而示意该模型能够汇总来自该点的局部邻域的信息。来自原始研究的实验结果表明,所提议模型在形状的零星分段和场景分段任务中的性能成就最高水平。

当点云经历某些几何变换时,诸如刚性变换,点云的语义标签应保持不变。因此,作者期待所学习点集表示对于这种变转是不变的。

一个自然的解决方案是在特征提取之前将整个输入集与规范空间对齐。点云输入格式令我们能够以简单的途径达成这个目标。我们只需用迷你网络(T-net) 预测仿射变换矩阵,并将此变换直接应用于输入的点坐标。迷你网络本身汇集更大的网络,由与点无关的特征提取、最大池化、和全连接层的基本模块组成。

这个思路可以拓展到特征空间对齐。可以在点特征级别插入额外的对齐网络,以便针对来自不同输入点云的对齐对象预测特征变换矩阵。然而,特征空间变换矩阵的维度数量比空间变换矩阵高得多,这大大增加了优化的复杂性。因此,作者在 SoftMax 损失函数中引入了一个正则化项。为此,我们将特征变换矩阵约束为接近正交矩阵:

其中 A 是由迷你网络预测的特征对齐矩阵。

正交变换不会导致输入阶段的信息丢失,因此它们是可取的。PointNet 的作者发现,添加这个正则化项可令优化稳定,并改进模型性能。

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


2. 利用 MQL5 实现

在上一章节中,我们探讨了 PointNet 中所提议方式的理论基础。现在,到了进入本文实践部分的时候了,我们将实现我们自己所提议方式的 MQL5 版本。

2.1创建 PointNet


为了在代码中实现 PointNet 算法,我们将创建一个新类 CNeuronPointNetOCL,它继承了全连接层 CNeuronBaseOCL 的基本功能。新类结构如下所示。

class CNeuronPointNetOCL   :  public CNeuronBaseOCL
  {
protected:
   CNeuronPointNetOCL   *cTNet1;
   CNeuronBaseOCL       *cTurned1;
   CNeuronConvOCL       cPreNet[2];
   CNeuronBatchNormOCL  cPreNetNorm[2];
   CNeuronPointNetOCL   *cTNet2;
   CNeuronBaseOCL       *cTurned2;
   CNeuronConvOCL       cFeatureNet[3];
   CNeuronBatchNormOCL  cFeatureNetNorm[3];
   CNeuronTransposeOCL  cTranspose;
   CNeuronProofOCL      cMaxPool;
   CNeuronBaseOCL       cFinalMLP[2];
   //---
   virtual bool         OrthoganalLoss(CNeuronBaseOCL *NeuronOCL, bool add = false);
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override ;
   //---
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;

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

您应当已经习惯于观察在类结构中的大量嵌套对象。然而,这种情况有其自身的细微差别。首先,除了静态对象,我们还有若干个动态对象。在类析构函数中,我们必须将它们从设备的内存中删除。

CNeuronPointNetOCL::~CNeuronPointNetOCL(void)
  {
   if(!!cTNet1)
      delete cTNet1;
   if(!!cTNet2)
      delete cTNet2;
   if(!!cTurned1)
      delete cTurned1;
   if(!!cTurned2)
      delete cTurned2;
  }

不过,我们未在类构造函数中创建这些对象,允许我们将其保持为空。

第二个细微差别是,两个嵌套的动态对象是我们正在创建的类 CNeuronPointNetOCL 的实例。故此,它们是在嵌套对象中的嵌套。

这两个细微差别都源于作者的方式,即把输入数据和特征对齐到某个规范空间。我们将在实现类方法期间进一步讨论这一点。

入常,初始化类对象的新实例是在 Init 方法中实现的。该方法的参数包括定义所创建对象架构的关键常量。

在这种情况下,该算法专为点云分类而设计。一般的思路是使用 PointNet 方式构建环境状态编码器。该编码器返回将当前环境状态映射到特定类型的概率分布。参与者的政策随后将特定的环境状态类型映射到一组交易参数,在给定状态下它有产生最大化盈利的潜力。由此,类架构的主要参数出现:

  • window — 所分析云中单个点的参数窗口大小;
  • units_count ― 云中点的数量;
  • output — 结果张量的大小;
  • use_tnets — 是否创建将输入数据和特征投影到规范空间的模型。

output 参数指定结果缓冲区的总尺寸。它不应与之前采用的结果窗口参数混淆。在这种情况下,我们希望输出是所分析环境状态的描述符。结果张量大小在逻辑上与可能的环境状态分类类型的数量相对应。 

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

在方法主体中,如常,我们首先调用父类的同名方法,其中已实现了针对接收参数的最低必要验证,以及继承对象的初始化。同时,我们确保检查方法操作的执行结果。

接下来,我们继续初始化嵌套对象。最初,我们验证是否有必要创建内部模型,以便将源数据和特征投影到规范空间。

//--- Init T-Nets
   if(use_tnets)
     {
      if(!cTNet1)
        {
         cTNet1 = new CNeuronPointNetOCL();
         if(!cTNet1)
            return false;
        }
      if(!cTNet1.Init(0, 0, OpenCL, window, units_count, window * window, false, optimization, iBatch))
         return false;

如果需要创建投影模型,我们首先检查指向模型对象指针的有效性,如果需要,生成 CNeuronPointNetOCL 类的新对象实例。随后,我们继续初始化它。

注意,投影矩阵生成对象的源数据大小,与主类从外部程序接收的源数据大小相匹配。不过,结果缓冲区大小等于源数据窗口的平方。这是因为期望该模型的输出是一个将源数据投影到规范空间的方阵。甚而,我们把指示为源数据和特征创建投影矩阵的参数明确设置为 false。这可以防止不受控制的递归创建对象。此外,将源数据的转换模型嵌入到源数据的另一个转换模型中是不合逻辑的。 

最后,我们验证指向负责记录更正数据对象的指针,并在必要时创建该对象的新实例。

      if(!cTurned1)
        {
         cTurned1 = new CNeuronBaseOCL();
         if(!cTurned1)
            return false;
        }
      if(!cTurned1.Init(0, 1, OpenCL, window * units_count, optimization, iBatch))
         return false;

然后我们初始化这个内层。它的大小与原始数据的张量相对应。

我们对特征投影模型执行类似的操作。唯一的区别是内层的尺寸。

      if(!cTNet2)
        {
         cTNet2 = new CNeuronPointNetOCL();
         if(!cTNet2)
            return false;
        }
      if(!cTNet2.Init(0, 2, OpenCL, 64, units_count, 64 * 64, false, optimization, iBatch))
         return false;
      if(!cTurned2)
        {
         cTurned2 = new CNeuronBaseOCL();
         if(!cTurned2)
            return false;
        }
      if(!cTurned2.Init(0, 3, OpenCL, 64 * units_count, optimization, iBatch))
         return false;
     }

接下来,我们形成一个主要提取点特征的 MLP。在该阶段,PointNet 的作者提出了一种独立提取点特征的方法。因此,我们将全连接层替换为步长等于所分析数据窗口大小的卷积层。在我们的例子中,它们等于描述一个点的向量大小。

//--- Init PreNet
   if(!cPreNet[0].Init(0, 0, OpenCL, window, window, 64, units_count, optimization, iBatch))
      return false;
   cPreNet[0].SetActivationFunction(None);
   if(!cPreNetNorm[0].Init(0, 1, OpenCL, 64 * units_count, iBatch, optimization))
      return false;
   cPreNetNorm[0].SetActivationFunction(LReLU);
   if(!cPreNet[1].Init(0, 2, OpenCL, 64, 64, 64, units_count, optimization, iBatch))
      return false;
   cPreNet[1].SetActivationFunction(None);
   if(!cPreNetNorm[1].Init(0, 3, OpenCL, 64 * units_count, iBatch, optimization))
      return false;
   cPreNetNorm[1].SetActivationFunction(None);

在卷积层之间,我们插入批量归一化层,并对其应用激活函数。在这种情况下,我们使用每种类型的 2 层,其维度由该方法的作者提议。

类似地,我们添加了一个三层感知器,是为提取高阶特征。

//--- Init Feature Net
   if(!cFeatureNet[0].Init(0, 4, OpenCL, 64, 64, 64, units_count, optimization, iBatch))
      return false;
   cFeatureNet[0].SetActivationFunction(None);
   if(!cFeatureNetNorm[0].Init(0, 5, OpenCL, 64 * units_count, iBatch, optimization))
      return false;
   cFeatureNet[0].SetActivationFunction(LReLU);
   if(!cFeatureNet[1].Init(0, 6, OpenCL, 64, 64, 128, units_count, optimization, iBatch))
      return false;
   cFeatureNet[1].SetActivationFunction(None);
   if(!cFeatureNetNorm[1].Init(0, 7, OpenCL, 128 * units_count, iBatch, optimization))
      return false;
   cFeatureNetNorm[1].SetActivationFunction(LReLU);
   if(!cFeatureNet[2].Init(0, 8, OpenCL, 128, 128, 512, units_count, optimization, iBatch))
      return false;
   cFeatureNet[2].SetActivationFunction(None);
   if(!cFeatureNetNorm[2].Init(0, 9, OpenCL, 512 * units_count, iBatch, optimization))
      return false;
   cFeatureNetNorm[2].SetActivationFunction(None);

本质上,最后两个模块的架构是雷同的。它们仅在层数和大小上有所不同。逻辑上,它们可以合并成一个模块。不过,在这种情况下,它们是分开的,只是为了允许把特征变换模块插入到它们之间的规范空间之中。

下一阶段,跟随提取点特征,涉及应用 PointNet 算法指定的 MaxPooling 函数。该函数从整体分析的点云中选择每个特征向量元素的最大值。相应地,点云由一个特征向量表示,其包含所分析云中所有点的相应单元的最大值。

在我们的工具包中已有 CNeuronProofOCL 类,其执行类似的功能,但在不同的维度上。因此,我们首先转置得到的点特征矩阵。

   if(!cTranspose.Init(0, 10, OpenCL, units_count, 512, optimization, iBatch))
      return false;

然后我们形成一个最大值的向量。

   if(!cMaxPool.Init(512, 11, OpenCL, units_count, units_count, 512, optimization, iBatch))
      return false;

获得的分析点云的描述符会由一个三层 MLP 处理。然而,在这种情况下,我决定采用一个小技巧,仅声明 2 个内部全连接层。对于第三层,我们使用已创建的对象本身,因为它从父类继承了所有必要的功能。

//--- Init Final MLP
   if(!cFinalMLP[0].Init(256, 12, OpenCL, 512, optimization, iBatch))
      return false;
   cFinalMLP[0].SetActivationFunction(LReLU);
   if(!cFinalMLP[1].Init(output, 13, OpenCL, 256, optimization, iBatch))
      return false;
   cFinalMLP[1].SetActivationFunction(LReLU);

在类对象初始化方法的末尾,我们显式指定激活函数,并将操作的逻辑结果返回给调用程序。

   SetActivationFunction(None);
//---
   return true;
  }

新类的初始化方法的实现完成之后,我们转到为 PointNet 构建前馈通验算法。这是在 feedForward 方法中完成的。如前,在该方法参数中,我们接收指向源数据对象的指针。

bool CNeuronPointNetOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
//--- PreNet
   if(!cTNet1)
     {
      if(!cPreNet[0].FeedForward(NeuronOCL))
         return false;
     }

在方法主体中,我们立即看到算法的分支,取决于将原始数据投影到规范空间的需要。请注意,当初始化对象时,我们在内部变量中保存了一个标志,指示需要执行数据投影。不过,为了检查是否需要数据投影,我们可用指向相应对象的指针来验证。这是因为仅在必要时才会创建投影模型。默认情况下,它们不存在。

因此,如果没有指向模型对象的有效指针来生成原始数据的投影矩阵,我们简单地将获取的原始数据对象的指针传递给预特征提取模块的第一个卷积层的前馈方法即可。

如果需要将数据投影到规范空间,我们将接收到的数据传送到模型的前馈通验方法,以便生成数据转换矩阵。

   else
     {
      if(!cTurned1)
         return false;
      if(!cTNet1.FeedForward(NeuronOCL))
         return false;

在投影模型的输出中,我们得到一个数据转换方阵。相应地,我们可以通过结果张量的大小来判定数据窗口的维度。

      int window = (int)MathSqrt(cTNet1.Neurons());

然后,我们使用矩阵乘法获得原始点云到规范空间的投影。

      if(IsStopped() ||
         !MatMul(NeuronOCL.getOutput(), cTNet1.getOutput(), cTurned1.getOutput(), NeuronOCL.Neurons() / window,
                                                                                                window, window))
         return false;

然后,在规范空间中初始点的投影被输入到主特征提取模块的第一层。

      if(!cPreNet[0].FeedForward(cTurned1.AsObject()))
         return false;
     }

在该阶段,无论是否需要将原始数据投影到规范空间之中,我们已经对主要特征提取模块的第一层进行了前馈通验。然后我们依次调用指定模块的所有层的前馈通验方法。

   if(!cPreNetNorm[0].FeedForward(cPreNet[0].AsObject()))
      return false;
   if(!cPreNet[1].FeedForward(cPreNetNorm[0].AsObject()))
      return false;
   if(!cPreNetNorm[1].FeedForward(cPreNet[1].AsObject()))
      return false;

接下来,我们面临着需要将点特征投影到规范空间的问题。此处的算法类似于初始点的投影。

//--- Feature Net
   if(!cTNet2)
     {
      if(!cFeatureNet[0].FeedForward(cPreNetNorm[1].AsObject()))
         return false;
     }
   else
     {
      if(!cTurned2)
         return false;
      if(!cTNet2.FeedForward(cPreNetNorm[1].AsObject()))
         return false;
      int window = (int)MathSqrt(cTNet2.Neurons());
      if(IsStopped() ||
         !MatMul(cPreNetNorm[1].getOutput(), cTNet2.getOutput(), cTurned2.getOutput(),
                                     cPreNetNorm[1].Neurons() / window, window, window))
         return false;
      if(!cFeatureNet[0].FeedForward(cTurned2.AsObject()))
         return false;
     }

随后。我们完成从初始数据的分析云中提取点特征的操作。

   if(!cFeatureNetNorm[0].FeedForward(cFeatureNet[0].AsObject()))
      return false;
   uint total = cFeatureNet.Size();
   for(uint i = 1; i < total; i++)
     {
      if(!cFeatureNet[i].FeedForward(cFeatureNetNorm[i - 1].AsObject()))
         return false;
      if(!cFeatureNetNorm[i].FeedForward(cFeatureNet[i].AsObject()))
         return false;
     }

在下一步中,我们转置得到的特征张量。然后我们为所分析云形成一个描述符向量。

   if(!cTranspose.FeedForward(cFeatureNetNorm[total - 1].AsObject()))
      return false;
   if(!cMaxPool.FeedForward(cTranspose.AsObject()))
      return false;

接下来,根据 PointNet 分类算法,我们需要在 MLP 中处理接收到的数据。于此,我们在 2 个内部全连接层上执行前馈通验操作。

   if(!cFinalMLP[0].FeedForward(cMaxPool.AsObject()))
      return false;
   if(!cFinalMLP[1].FeedForward(cFinalMLP[0].AsObject()))
      return false;

然后我们调用父类的类似方法,将指针传递给内层。

   if(!CNeuronBaseOCL::feedForward(cFinalMLP[1].AsObject()))
      return false;
//---
   return true;
  }

我要提醒您,在本例中,父类是一个完全连接层。相应地,当调用父类的前馈通验方法时,我们将执行全连接层的前馈通验。仅有的区别是,这一次,我们使用从父类继承的对象,而非嵌套层的对象。

前馈通验方法的所有操作成功完成之后,我们将给调用程序返回一个布尔值,指示操作执行的结果。

此刻,我们总结了前馈方法的工作,并转到研究反向传播通验方法,这些分为误差梯度分布、和模型参数调整两部分。

正如我们多次提到的,误差梯度分布遵循与前馈通验完全相同的算法,除了信息流是逆向的。不过,在这种情况下,有一些特殊的细微差别。为了数据投影矩阵,PointNet 方法的作者引入了一种正则化技术,可确保投影矩阵尽可能接近正交矩阵。这些正则化操作不会影响前馈通验算法;它们只参与优化模型参数。甚至,执行这些操作将需要在 OpenCL 程序内的额外的计算。

开工伊始,我们先检查一下提议的正则化公式。

很明显,作者利用了将正交矩阵乘以其转置副本得到单位矩阵的特性。

如果我们将其分解,将矩阵乘以其转置副本,意味着结果矩阵中的每个元素都表示相应两行的点积。对于正交矩阵,一行的点积自身应为 1。在所有其它情况下,两个不同行的点积产生 0。

不过,重点要注意,我们正着手反向传播通验中的正则化。这意味着我们不仅需要计算误差,还需要计算每个元素的误差梯度。

为了在 OpenCL 程序中实现该算法,我们将创建一个名为 OrthogonalLoss 的内核。该内核的参数将包括两个指向数据缓冲区的指针。其中一个包含原始矩阵,另一个存储相应的误差梯度。此外,我们将引入一个标志来指定梯度值是应该被覆盖、亦或与以前存储的数值累积。

__kernel void OrthoganalLoss(__global const float *data,
                             __global float *grad,
                             const int add
                            )
  {
   const size_t r = get_global_id(0);
   const size_t c = get_local_id(1);
   const size_t cols = get_local_size(1);

在这种情况下,我们不指示矩阵的维度。但于此,一切都很简单。我们计划根据矩阵中的行数和列数在二维任务空间中运行内核。

在内核主体中,我们立即在任务空间的两个维度中识别当前线程。

还值得记住的是,我们正与一个方阵打交道。因此,为了了解矩阵的完整大小,我们仅需判定在维度之一中的线程数量。

为了在多个线程之间分派向量乘法运算,我们在原始矩阵的行中创建局部工作组。为了组织线程之间的数据交换过程,我们将在 OpenCL 关联环境的局部内存中使用一个数组。

   __local float Temp[LOCAL_ARRAY_SIZE];
   uint ls = min((uint)cols, (uint)LOCAL_ARRAY_SIZE);

接下来,我们为源数据缓冲区中所需的对象定义偏移常量。

   const int shift1 = r * cols + c;
   const int shift2 = c * cols + r;

我们从数据缓冲区加载相应元素的数值。

   float value1 = data[shift1];
   float value2 = (shift1==shift2 ? value1 : data[shift2]);

注意,为了全局内存访问最小化,我们避免重新读取对角线元素。

在此,我们立即检查所获数值的有效性,并将无效数字替换为零值。

   if(isinf(value1) || isnan(value1))
      value1 = 0;
   if(isinf(value2) || isnan(value2))
      value2 = 0;

其后,我们通过强制检查结果的有效性来计算它们的乘积。

   float v2 = value1 * value2;
   if(isinf(v2) || isnan(v2))
      v2 = 0;

下一步是在局部数组的各个元素中组织一个并行求和的循环,其中包含工作组线程的强制同步。

   for(int i = 0; i < cols; i += ls)
     {
      //---
      if(i <= c && (i + ls) > c)
         Temp[c - i] = (i == 0 ? 0 : Temp[c - i]) + v2;
      barrier(CLK_LOCAL_MEM_FENCE);
     }

然后我们创建一个循环,汇总得到的局部数组元素的数值。

   uint count = min(ls, (uint)cols);
   do
     {
      count = (count + 1) / 2;
      if(c < ls)
         Temp[c] += (c < count && (c + count) < cols ? Temp[c + count] : 0);
      if(c + count < ls)
         Temp[c + count] = 0;
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);

我们还特别注意同步工作组线程。

作为在局部数组的第一个元素中执行操作的结果,我们获得了矩阵中两个分析行的乘积值。现在我们可以计算误差值。

   const float sum = Temp[0];
   float loss = -pow((float)(r == c) - sum, 2.0f);

然而,这仅是工作的一部分。接下来,我们需要判定原始矩阵中每个元素的误差梯度。首先,我们计算向量积水平的误差梯度。

   float g = (2 * (sum - (float)(r == c))) * loss;

然后,我们将误差梯度传播到当前线程数值乘积中的第一个元素。

   g = value2 * g;

确保检查所获误差梯度数值的有效性。

   if(isinf(g) || isnan(g))
      g = 0;

之后,我们将其保存在全局误差梯度缓冲区的相应元素之中。

   if(add == 1)
      grad[shift1] += g;
   else
      grad[shift1] = g;
  }

在此,我们必须检查判定是否应该添加、或覆盖误差梯度数值的标志,并执行相应的操作。

重点要注意,在内核中,我们只计算乘积中一个元素的误差梯度。乘积中第二个元素的梯度将在单独的线程中计算,其中矩阵的行索引和列索引互换。

调用 CNeuronPointNetOCL::OrthogonalLoss 方法将该内核放置在执行队列当中。其算法完全遵循将 OpenCL 程序内核放入执行队列的基本原则,这些都通通已在之前的文章中涵盖。我鼓励您独立查看该方法的代码。它在附件中提供。

现在,我们抵近考察误差梯度分布方法 calcInputGradients 背后的算法。如前,该方法取指向上一层对象的指针作为参数,在这种情况下,该对象充当原始数据级别的误差梯度的接收者。

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

在方法主体中,我们立即检查接收指针的相关性。否则,进一步的操作就没有意义了。

然后,我们将误差梯度传递给点云描述符解释感知器。

   if(!CNeuronBaseOCL::calcInputGradients(cFinalMLP[1].AsObject()))
      return false;
   if(!cFinalMLP[0].calcHiddenGradients(cFinalMLP[1].AsObject()))
      return false;

我们通过 MaxPooling 层传播误差梯度,并将其转置为相应点的特征。

   if(!cMaxPool.calcHiddenGradients(cFinalMLP[0].AsObject()))
      return false;
   if(!cTranspose.calcHiddenGradients(cMaxPool.AsObject()))
      return false;

然后,我们将误差梯度传播到点特征提取层,当然是以相反的顺序。

   uint total = cFeatureNet.Size();
   for(uint i = total - 1; i > 0; i--)
     {
      if(!cFeatureNet[i].calcHiddenGradients(cFeatureNetNorm[i].AsObject()))
         return false;
      if(!cFeatureNetNorm[i - 1].calcHiddenGradients(cFeatureNet[i].AsObject()))
         return false;
     }
   if(!cFeatureNet[0].calcHiddenGradients(cFeatureNetNorm[0].AsObject()))
      return false;

到截至此刻,一切都很正常。但是我们已经到了将点特征投影到规范空间的层次。当然,如果这不是必需的,我们简单地将误差梯度传递给主要特征提取模块即可。

   if(!cTNet2)
     {
      if(!cPreNetNorm[1].calcHiddenGradients(cFeatureNet[0].AsObject()))
         return false;
     }

如果我们确实需要实现这一点,算法将更加复杂。首先,我们将误差梯度传播到数据投影级别。

   else
     {
      if(!cTurned2)
         return false;
      if(!cTurned2.calcHiddenGradients(cFeatureNet[0].AsObject()))
         return false;

其后,我们在点特征和投影矩阵之间分派误差梯度。如果我们向前看几步,我们能看到误差梯度也将通过投影矩阵生成模型传播到点特征级别。为了防止以后覆盖关键数据,在该阶段,我们不会将误差梯度传递给初步特征提取模块的最后一层,而是传递给倒数第二层。

提醒一下,初步特征提取模块中的最后一层是批量归一化层。它之前的层是一个卷积层,负责从各个独立点提取特征。两层都有大小相同的误差梯度缓冲区,令我们能够安全地替换缓冲区,而不会有溢出缓冲区边界的风险。

      int window = (int)MathSqrt(cTNet2.Neurons());
      if(IsStopped() ||
         !MatMulGrad(cPreNetNorm[1].getOutput(), cPreNet[1].getGradient(), cTNet2.getOutput(), 
                     cTNet2.getGradient(), cTurned2.getGradient(), cPreNetNorm[1].Neurons() / window,
                                                                                       window, window))
         return false;

在两个数据线程之间切分误差梯度之后,我们在投影矩阵级别添加正则化误差梯度。

      if(!OrthoganalLoss(cTNet2.AsObject(), true))
         return false;

接下来,我们将误差梯度传播到投影矩阵生成模块之中。

      if(!cPreNetNorm[1].calcHiddenGradients((CObject*)cTNet2))
         return false;

我们将两个信息线程的误差梯度相加。

      if(!SumAndNormilize(cPreNetNorm[1].getGradient(), cPreNet[1].getGradient(), cPreNetNorm[1].getGradient(),
                                                                                         1, false, 0, 0, 0, 1))
         return false;
     }

然后,我们可以通过初级特征提取模块将误差梯度传播到原始数据投影的级别。

   if(!cPreNet[1].calcHiddenGradients(cPreNetNorm[1].AsObject()))
      return false;
   if(!cPreNetNorm[0].calcHiddenGradients(cPreNet[1].AsObject()))
      return false;
   if(!cPreNet[0].calcHiddenGradients(cPreNetNorm[0].AsObject()))
      return false;

此处,我们应用了一种类似于通过特征投影分派误差梯度的算法。该算法的最简单版本无需数据投影矩阵。我们简单地把误差梯度传递到前一层的缓冲区之中即可。

   if(!cTNet1)
     {
      if(!NeuronOCL.calcHiddenGradients(cPreNet[0].AsObject()))
         return false;
     }

但如果我们需要投影数据,我们首先要将误差梯度传播到投影级别。

      if(!cTurned1)
         return false;
      if(!cTurned1.calcHiddenGradients(cPreNet[0].AsObject()))
         return false;

然后,我们根据误差梯度对结果的影响,跨两个线程之间分派误差梯度。

      int window = (int)MathSqrt(cTNet1.Neurons());
      if(IsStopped() ||
         !MatMulGrad(NeuronOCL.getOutput(), NeuronOCL.getGradient(), cTNet1.getOutput(), cTNet1.getGradient(),
                     cTurned1.getGradient(), NeuronOCL.Neurons() / window, window, window))
         return false;

我们将正则化数值添加到得到的误差梯度之中。

      if(!OrthoganalLoss(cTNet1, true))
         return false;

此处,我们遇到了覆盖误差梯度的问题。在该阶段,我们没有空闲缓冲区可供存储数据。当在两个方向上分派误差梯度时,我们立即将其写入前一层的缓冲区之中。现在,我们需要经由投影矩阵生成模块传播误差梯度,该操作将覆盖梯度值,从而导致以前存储的数据丢失。为了防止数据丢失,我们需要将梯度值复制到相应的数据缓冲区。但我们在何处能找到这样的缓冲区呢?当初始化类时,我们并未创建存储中间数据的缓冲区。然而,经过仔细观察,我们注意到了数据投影记录层。它的大小与原始数据张量的大小雷同。此外,存储在该层中的误差梯度已被跨两条计算路径分派,且不会在后续操作中用到。

同时,缓冲区大小的相等性提出了一种替代方法。如果我们互换缓冲区指针,替代直接复制数据呢?指针互换比完整数据复制更廉价,并且与缓冲区大小无关。

      CBufferFloat *temp = NeuronOCL.getGradient();
      NeuronOCL.SetGradient(cTurned1.getGradient(), false);
      cTurned1.SetGradient(temp, false);

在重新布局指向数据缓冲区的指针之后,我们可将误差梯度从数据投影矩阵传递到前一层的级别。

      if(!NeuronOCL.calcHiddenGradients(cTNet1.AsObject()))
         return false;
      if(!SumAndNormilize(NeuronOCL.getGradient(), cTurned1.getGradient(), NeuronOCL.getGradient(),
                                                                            1, false, 0, 0, 0, 1))
         return false; 
     }
//---
   return true;
  }

在方法操作结束时,我们将两条信息线程的误差梯度相加,并将方法操作的逻辑结果返回给调用程序。

可训练模型参数的更新由 updateInputWeights 方法处理。如常,它的算法很简单:我们按顺序调用包含可训练参数的内部对象同名方法。同时,我们确保调用父类的相应方法,在于其功能会在基于 MLP 的点云描述符估算的第三层用到。在本文中,我们不会去详述该方法的实现。我鼓励您独立查看其在附件中的代码。

我们研究构造 CNeuronPointNetOCL 类方法的算法至此完结。在本文的附件中,您将找到该类、及其所有方法的完整代码。

2.2模型架构


在实现基于 PointNet 方法的 MQL5 版本之后,我们现在继续将新对象集成到我们的模型架构之中。如早前所述,我们的新类 CNeuronPointNetOCL 已合并到环境状态编码器模型之中,其在 CreateEncoderDescriptions 方法中定义。

重点需强调的是,我们几乎在单一模块中实现了整个算法。这令我们能够构造一个拥有简洁紧凑的高级架构的模型。我在此强调 “高级架构” 这个术语。尽管 CNeuronPointNetOCL 模块的命名很简单,但它封装了一个高度复杂的多层神经网络架构。

如常,模型的输入由原生的、未经处理的数据组成,其使用批量归一化层进行归一化,从而确保兼容性。

bool CreateEncoderDescriptions(CArrayObj *&encoder)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
//--- Encoder
   encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

之后,我们立即将它们传递到新的 PointNet 模块。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronPointNetOCL;
   descr.window = BarDescr;                        // Variables
   descr.count = HistoryBars;                      // Units
   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;
     }

此处值得注意的是,我们没有在 CNeuronPointNetOCL 模块的输出中指定激活函数。有意采取此步骤是为用户提供扩展点云识别模块架构的能力。然而,在本实验中,我们仅添加一个 SoftMax 层,将获得的结果转换至概率值领域。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = LatentCount;
   descr.activation = None;
   descr.optimization = ADAM;
   descr.layers = 1;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

这样我们新的环境状态编码器模型的架构就完成了。

应当说,我们还简化了参与者评论者模型的架构。在它们中,我们用简单的数据连接层替换了多头交叉注意力模块。但我建您自行熟悉附件中的这些具体编辑。

关于模型训练计划,也应当说几句。模型架构的变化不会影响源数据和结果的结构,这令我们能够使用以前创建的程序与环境,以及为离线训练它们而收集的数据进行交互。不过,我们早前收集的数据集缺少所选环境状态的类标签。创建它们需要额外的成本。我们决定走一条不同的路,在参与者政策训练过程中训练环境状态编码器。因此,我们排除了单独的环境状态编码器训练 EA “StudyEncoder.mq5”。取而代之,我们对参与者评论者训练 EA “Study.mq5” 进行了小幅编辑,令其能够训练环境状态编码器。我建议您独立熟悉它们。

我要提醒您,在附件中,您将找到本文中所讲述类的完整代码、及其所有方法,以及准备本文时用到的所有程序的算法。现在,我们转到工作的最后阶段 — 测试和评估已完工的成果。


3. 测试

在本文中,我们讲述了以点云形式处理原生数据的新 PointNet 方法,并利用 MQL5 实现了作者所提议方式的愿景。现在,是时候评测所提议方式在解决我们的任务方面的有效性了。为此,我们将采用来自 EURUSD 金融产品的真实历史数据来训练本文中讨论的模型。在我们的实验中,我们将采用 2023 年的历史数据作为训练数据集。之后的模型测试,将采用 2024 年 1 月的数据。在这两种情况下,我们所有分析的指标都采用 H1 时间帧和默认参数。

实质上,我们在多篇文章中测试模型时均采用了不变的训练和测试参数。因此,初始训练是用以前收集的数据集进行的。

同时,环境状态编码器的训练与参与者政策训练并发进行。如您所知,参与者政策是迭代训练的,会定期更新训练数据集。这种方式可确保训练数据集保持相关性,并与当前参与者政策的操作空间保持一致。这反过来又令训练过程更精细。

模型经过若干次迭代训练之后,我们设法获得了一个参与者政策,其在训练和测试数据集上都能产生盈利。测试结果呈现所示。

在测试期间,该模型执行了 52 笔交易,其中 55.77% 交易获利了结。值得注意的是,该模型在多头和空头持仓之间展现出实际的均衡性(分别是 24 对 28)。最大和平均盈利交易都超过了相应的亏损仓位。盈利因子达到 1.76。余额曲线展现出明显的上升趋势。然而,较短的测试区间,和相对较少的交易数量,不允许我们得出学到的政策能覆盖较长时区保持稳定性的结论。

总之,所实现方式是有前景的,但需要进一步测试。


结束语

在本文中,我们领略了一种新方法 PointNet,这是一种直接将点云作为输入数据的统一架构方案。PointNet 在交易中的应用可以有效地分析复杂的多维数据,譬如价格形态,而无需将它们转换为其它格式。这为更准确地预测市场趋势,及改进决策算法开辟了新的机会。这种分析有潜力提高金融市场交易策略的效率。

在本文的实践部分,我们利用 MQL5 实现了我们对所提议方式的愿景,依据真实历史数据上训练模型,并利用 MetaTrader 5 策略测试器中学到的政策测试了智能交易系统。基于测试结果,我们获得了有前景的结果。不过,应当记住,本文中所述所有程序仅供参考目的,所创建功能也仅用于演示所提议方式。在所有可能的条件下进行重大改进和全面测试,并在实际使用之前进行全面测试。


参考
  • PointNet:的依靠点集深度学习,进行 3D 分类和分段
  • 本系列的其它文章

  • 文章中所用程序

    # 发行 类型 说明
    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/15747

    附加的文件 |
    MQL5.zip (1723.58 KB)
    最近评论 | 前往讨论 (1)
    juk
    juk | 5 9月 2024 在 08:18
    您好。您能把样品寄给我试试吗?我没有收到好东西。
    Connexus的头(第三部分):掌握HTTP请求头的使用方法 Connexus的头(第三部分):掌握HTTP请求头的使用方法
    我们继续开发Connexus库。在本章中,我们探讨HTTP协议中请求头的概念,解释它们是什么、它们的用途以及如何在请求中使用它们。我们将涵盖用于与API通信的主要头信息,并展示了如何在库中配置它们的实例。
    MQL5 简介(第 9 部分):理解和使用 MQL5 中的对象 MQL5 简介(第 9 部分):理解和使用 MQL5 中的对象
    学习使用当前和历史数据在 MQL5 中创建和自定义图表对象。本基于项目的指南可帮助您可视化交易并实际应用 MQL5 概念,从而更容易构建适合您交易需求的工具。
    从基础到中级:Include 指令 从基础到中级:Include 指令
    在今天的文章中,我们将讨论一个在 MQL5 中可以找到的各种代码中广泛使用的编译指令。虽然这里对这个指令的解释相当肤浅,但重要的是你要开始了解如何使用它,因为随着你进入更高层次的编程,它很快就会变得不可或缺。此处提供的内容仅用于教育目的。在任何情况下,除了学习和掌握所提出的概念外,都不应出于任何目的使用此应用程序。
    重构经典策略(第九部分):多时间框架分析(第二部分) 重构经典策略(第九部分):多时间框架分析(第二部分)
    在今天的讨论中,我们探讨了多时间框架分析的策略,以确定我们的人工智能(AI)模型在哪个时间框架上表现最优。分析结果表明,在欧元兑美元(EURUSD)货币对上,月度和小时时间框架生成的模型具有相对较低的误差率。我们利用这一优势,开发了一个交易算法,该算法在月度时间框架上进行人工智能预测,并在小时时间框架上执行交易。