English Русский Español Deutsch 日本語 Português
preview
交易中的神经网络:对比形态变换器

交易中的神经网络:对比形态变换器

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

概述

在使用机器学习分析市场形势时,我们往往会专注单根烛条及其属性,而忽略了频繁提供更有意义信息的烛条形态。形态代表的是在类似市场条件下显露出的稳定烛条结构,能够揭示至关重要的行为趋势。

之前,我们探索了从分子性质预测领域借鉴的 Molformer 框架。Molformer 的作者将原子和基序表示组合成单一序列,令模型能够访问有关所分析数据的结构信息。不过,该方式引入了分离不同类型节点之间依赖关系的复杂挑战。幸运的是,已有能避免该问题的替代方法提出。

一个这样的例子是原子-基序对比变换器AMCT),在论文《预测分子性质的原子-基序对比变换器》中有所阐述。为了整合两个层次的相互作用,并增强分子的表征能力,AMCT 的作者提议在原子和基序表征之间应用对比学习。由于分子的原子和基序表征本质上是同一实体的两种不同视图,故它们在训练期间会自然统调。这种统调令它们相互提供自我监督信号,从而提升学到的分子表征的健壮性。

据观察,跨不同分子的雷同基序,往往展现出相似的化学性质。这表明雷同的基序在跨分子间应具有一致的表征。由此,使用对比损失可最大限度地提高不同分子中雷同基序的统调,从而出品更易区分的基序表征。

进而,为了有效地识别那些对于判定每个分子属性至关重要的基序,作者协同一种注意力机制,即经由一个交叉注意力模块整合属性信息。具体是,交叉注意力模块捕获分子属性嵌入和基序表征之间的依赖关系。如是结果,可基于交叉注意力权重来识别关键基序。

1. AMCT 算法

输入到模型中的分子描述被初期分解为一组原子、及一组基序分段。然后这些结果序列被投喂至并行的原子和基序编码层,于其中生成相应的嵌入。两个独立的编码器用于获得原子和基序水平的分子表征。此外,解码器和全连接层用于生成预测输出。在模型训练期间,损失函数包括原子-基序统调损失、基序水平对比损失、和属性预测损失。

在原子编码过程中,我们首先获取原子嵌入。这些嵌入随后经原子编码器处理,提取出分子内各个原子之间的依赖关系。输出是原子级的分子表征。

AMCT 的作者使用节点中心化来编码原子之间的结构信息,特别是分子内原子的键合关系。由于中心化被应用到每个原子,故只需将其添加到原子嵌入之中。

虽然原子级依赖关系有效地捕获了低级细节,但它们忽略了不同原子之间的高级别结构信息。由此,该方式或许不足以准确预测分子属性。为了解决这个问题,AMCT 框架在基序级别上引入了一条平行的分子表征通路。在基序编码时,首先从原始数据集中提取基序,然后转换为嵌入。这些嵌入由基序编码器处理,捕获基序之间的依赖关系。

AMCT 框架还用中心化来为基序之间的结构信息进行编码,其被添加到它们相应的嵌入之中。

为了探索基序提供的附加信息,该框架参考了原子和基序级别的分子表征之间的相似性关系。由于单个分子的原子和基序表征本质上是同一实体的两个不同视图,故它们自然会统调在模型训练期间生成自监督信号。作者采用 Kullback-Leibler 散度来统调两种表征。

因为原子-基序统调损失操作是在分子内,且仅限于强制同一分子的原子和基序表征之间的一致性,故 AMCT 的作者还旨在探索分子间对比、并调研不同分子之间表征的一致性。不同分子中给定的雷同基序往往展现出相似的化学性质,期待这些基序在跨所有分子都应具有一致的表征。为达成这一点,作者提出了基序对比损失,它把不同分子之间雷同基序表征的一致性最大化,同时推离部分属于不同类别的基序表征。

健壮的解码过程对于生成可靠的表征也至关重要。AMCT 引入了属性感知解码。首先生成属性嵌入,然后解码器针对单一属性提取基本的分子表征。最终预测是通过线性投影获得的。

解码器设计用于提取协同属性信息的分子表征。为了判定每个分子特性,辨别最关键的基序,AMCT 构造了一种特性感知注意力机制。具体上,使用了一个交叉注意力模块,其中属性嵌入作为 查询,基序表征作为 键-值。具有较高交叉注意力权重的基序被认为对分子性质有更大的影响。

下面提供了作者提出的"原子-基序对比变换器"框架的原始可视化。


2. 利用 MQL5 实现

在涵盖了原子-基序对比变换器框架的理论层面之后,我们现在转入本文的实践部分,其中我们将呈现自己对所提议方式的 MQL5 实现。

AMCT 框架是一个相当复杂和精密的结构。然而,在仔细验证它的成分模块时,很明显它们中的大多数已经以某种形式在我们的函数库中实现。也就是说,仍有工作要做。举例,原子和基序级别的表征比较。我希望您会同意,除了简单地识别差异之外,我们还需在两种路径之间分派误差梯度,从而把这些差异最小化。有若干种通路可以解决该问题。

当然,我们可以将一条通路的结果复制到另一条通路的梯度缓冲区之中,然后用我们目前计算模型误差的基本神经层方法 calcOutputGradients 来计算误差梯度。这种方式的优点在于它的简单性,在于它只用已有的工具。不过,该方法相对需要大量资源。在模型训练期间,它需要复制两个数据缓冲区(两条路径的输出),并按顺序计算每个表征的梯度。

因此,我们决定在 OpenCL 端开发一个小型内核,这将令我们能够同时判定两条路径的误差梯度,而无需不必要的数据复制。

__kernel void CalcAlignmentGradient(__global const float *matrix_o1,
                                    __global const float *matrix_o2,
                                    __global float *matrix_g1,
                                    __global float *matrix_g2,
                                    const int activation,
                                    const int add)
  {
   int i = get_global_id(0);

在内核参数中,我们接收指向 4 个数据缓冲区的指针。其中两个缓冲区包含原子和基序通路的结果 — 在我们的例子中,烛条和形态。另两个缓冲区设计用于存储相应的误差梯度。此外,内核参数包括指向两个通路所用激活函数的指针。

重点要注意,此处我们显示约束通路采用不同的激活函数。这是因为,为了正确比较两种通路的结果,它们必须位于同一子空间当中。激活函数定义了层的输出空间。因此,对两条通路使用单一激活函数是一种合乎逻辑、且必要的方式。

此刻,我们还将引入一个标志,指示是否把误差梯度累积到先前存储的数据当中,亦或覆盖现有值。

这个内核会在一维任务空间中执行。由此,内核中定义的线程标识符将指定其在数据缓冲区中的必要偏移量。

接下来,我们将准备局部变量来存储两条通路的前馈通验相应结果,并把误差梯度初始化为零值。

   const float out1 = matrix_o1[i];
   const float out2 = matrix_o2[i];
   float grad1 = 0;
   float grad2 = 0;

我们检查前馈通验值的有效性。如果我们有正确的数值,我们计算偏差,然后我们针对激活函数的导数进行调整。我们把输出保存到已分配的局部变量之中。

   if(!isnan(out1) && !isinf(out1) &&
      !isnan(out2) && !isinf(out2))
     {
      grad1 = Deactivation(out2 - out1, out1, activation);
      grad2 = Deactivation(out1 - out2, out2, activation);
     }

现在我们可将误差梯度传输到相应的全局数据缓冲区。根据收到的标志,我们要么将值添加到先前累积的梯度当中,要么删除前一个值,并写入一个新值。之后,我们完成内核操作。

   if(add > 0)
     {
      matrix_g1[i] += grad1;
      matrix_g2[i] += grad2;
     }
   else
     {
      matrix_g1[i] = grad1;
      matrix_g2[i] = grad2;
     }
  }

这是我们对 OpenCL 程序的唯一补充。您可在附件中找到其完整代码。

现在,我们转到主程序,我们将在其中实现拟议的 AMCT 框架架构。首先,我们需要两条处理通路:一条是为原子(条线),一条是为基序(形态)。在它们的原始工作中,作者对两条通路都使用了原版变换器架构,并强化了原子和基序的结构编码。不过,我提议将其替换为包含相对位置编码(R-MAT) 的变换器,我们在之前的一篇文章中对此曾进行过研究。这应能解决有关通路架构事宜。如果不是因为一个重要的细节:基序(形态)通路需要一个预处理步骤来预先提取形态。出于这个原因,我决定在一个单独的对象中实现基序通路。

2.1构建基序通路

我们将在 CNeuronMotifEncoder 类中实现基序通路算法,其结构如下所示。

class CNeuronMotifEncoder  :  public CNeuronRMAT
  {
public:
                     CNeuronMotifEncoder(void) {};
                    ~CNeuronMotifEncoder(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) override;
   //---
   virtual int       Type(void) override   const   {  return defNeuronMotifEncoder; }
  };

如新对象结构中所见,我们使用 CNeuronRMAT 作为基类。该类实现线性模型逻辑,其中神经层被组织在一个动态数组之中。这种设计令我们 Init 方法中轻松地为我们的形态(基序)通路构造一个顺序架构。所有必要的功能都自父类继承。

初始化方法的参数结构完全继承自基类中的相应方法。

bool CNeuronMotifEncoder::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(units_count < 3)
      return false;

然而,形态的提取对输入数据序列的长度施加了限制,我们立即在方法主体中检查。之后,我们准备一个动态数组来存储指向我们将要创建的神经层的指针。

   cLayers.Clear();

重点要注意,此处我们还未调用父类方法。这意味着所有继承的对象都维持未初始化状态。另一方面,从外部程序接收的参数并未显式指定执行父类方法所需结果缓冲区的大小。为避免在该阶段计算结果缓冲区大小,我们首先初始化负责生成形态嵌入的层。由于方法参数中未指定单个形态的大小,故我们将基于序列长度动态定义它。如果序列长度超过 10 个元素,我们将分析 3-元素形态;否则,我们将使用 2-元素形态。

   int bars_to_paattern = (units_count > 10 ? 3 : 2);

我们将使用一个卷积层来生成形态嵌入,我们会立即初始化它。然后将指向所创建神经层的指针添加到我们的动态数组之中。

   CNeuronConvOCL *conv = new CNeuronConvOCL();
   int idx = 0;
   int units = (int)units_count - bars_to_paattern + 1;
   if(!conv ||
      !conv.Init(0, idx, open_cl, bars_to_paattern * window, window, window, units, 1, optimization_type, batch)||
      !cLayers.Add(conv)
     )
      return false;
   conv.SetActivationFunction(SIGMOID);

重点要注意,我们以一个小根柱线的步幅构造重叠形态的嵌入。单个形态的嵌入大小等于用来描述单根柱线的窗口大小。该方式允许更精确地分析输入序列里是否存在形态。

然而,我们将更进一步,分析稍大的形态 — 由 5 或 3 根柱组成的形态,具体取决于输入序列的长度。然后,我们将级联两个级别的形态嵌入,为模型提供更丰富的、有关输入数据的结构信息。为了实现该功能,我们使用 CNeuronMotifs 层,开发该层是我们在 Molformer 框架上工作的一部分。该层的关键优势在于它有能力把提取的形态的张量与原始输入数据级联起来。对于相同的原因,我们不能在第一个形态提取阶段就用它。在那一点,我们需要将形态从柱线表征剥离,其于并行通路中进行分析。

   idx++;
   units = units - bars_to_paattern + 1;
   CNeuronMotifs *motifs = new CNeuronMotifs();
   if(!motifs ||
      !motifs.Init(0, idx, open_cl, window, bars_to_paattern, 1, units, optimization_type, batch) ||
      !cLayers.Add(motifs)
     )
      return false;
   motifs.SetActivationFunction((ENUM_ACTIVATION)conv.Activation());

生成的形态嵌入被投喂到 R-MAT 通路。如您所知,变换器通路的输出向量大小、与其输入数据的张量大小匹配。因此,在该阶段,我们能够安全地调用基础神经层的初始化方法,基于最终形态提取层的维度指定结果缓冲区大小。

   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, motifs.Neurons(), optimization_type, batch))
      return false;
   cLayers.SetOpenCL(OpenCL);

接下来,我们创建一个循环来初始化解码器的内层。在循环的每次迭代中,我们将按顺序初始化相对自注意力CNeuronRelativeSelfAttention)的一层,及一个残差卷积模块(CResidualConv)。

   CNeuronRelativeSelfAttention *attention = NULL;
   CResidualConv *ff = NULL;
   units = int(motifs.Neurons() / window);
   for(uint i = 0; i < layers; i++)
     {
      idx++;
      attention = new CNeuronRelativeSelfAttention();
      if(!attention ||
         !attention.Init(0, idx, OpenCL, window, window_key, units, heads, optimization, iBatch) ||
         !cLayers.Add(attention)
        )
        {
         delete attention;
         return false;
        }
      idx++;
      ff = new CResidualConv();
      if(!ff ||
         !ff.Init(0, idx, OpenCL, window, window, units, optimization, iBatch) ||
         !cLayers.Add(ff)
        )
        {
         delete ff;
         return false;
        }
     }

我们在父类初始化方法中也用过类似的循环。不过,在这种情况下,我们不能重用父类方法,因为这会删除之前创建的形态提取层。

接下来,我们只需重新赋值数据缓冲区指针,从而避免过多的复制操作。

   if(!SetOutput(ff.getOutput()) ||
      !SetGradient(ff.getGradient()))
      return false;
//---
   return true;
  }

在结束该方法之前,我们将操作结果的布尔值返回给调用程序。

如早前所述,前馈和反向传播通验的功能都完全继承自父类。至此,我们的形态通路类 CNeuronMotifEncoder 实现完毕。

2.2相对交叉注意力模块

早些时候,在构建柱线和形态通路时,我们用到了相对自注意力模块。不过,AMCT 解码器使用交叉注意力机制。因此,为了确保框架的架构连贯统一,我们现在需要实现一个具有相对位置编码的交叉注意力模块。我们不会在此讨论理论细节,在于所有必要的概念都已在专门讲述 R-MAT 框架的文章中涵盖。我们现在的任务是将第二个输入源集成到现有解决方案当中,从其生成 实体。为了完成这项任务,我们将创建一个名为 CNeuronRelativeCrossAttention 的新类,其使用相对编码实现交叉注意力机制。如您所料,相应的自注意力类将充当该新组件的基类。新对象的结构如下所示。

class CNeuronRelativeCrossAttention   :  public CNeuronRelativeSelfAttention
  {
protected:
   uint                    iUnitsKV;
   //---
   CLayer                  cKVProjection;
   //---

   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override { return false; }
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override { return false; }
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput,
                                                                   CBufferFloat *SecondGradient, 
                                                   ENUM_ACTIVATION SecondActivation = None) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return false; }
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override;

public:
                     CNeuronRelativeCrossAttention(void) {};
                    ~CNeuronRelativeCrossAttention(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key,
                          uint units_count, uint heads,
                          uint window_kv, uint units_kv,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override   const   {  return defNeuronRelativeCrossAttention; }
   //---
   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 CNeuronRelativeCrossAttention::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                                         uint window, uint window_key, uint units_count, 
                                         uint heads, uint window_kv, uint units_kv, 
                                         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;
   iUnitsKV = units_kv;
   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, iUnitsKV, 1, optimization, iBatch))
      return false;
   idx++;
   if(!cValue.Init(0, idx, OpenCL, iWindow, iWindow, iWindowKey * iHeads, iUnitsKV, 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 * iUnitsKV, optimization, iBatch))
      return false;

接下来,我们组织生成 BKBV 张量的过程。正如我们早前所见,这些张量是经含有一个隐藏层的 MLP 生成的。隐藏层跨所有关注头共享,最后一层为每个关注头生成单个词元。此处,对于每个实体,我们将创建两个连续的卷积层,它们之间有一个双曲正切,以便创建非线性。

   idx++;
   CNeuronConvOCL *conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, iUnits, iUnits, iWindow, iUnitsKV, 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, iUnitsKV, 1, optimization, iBatch) ||
      !cBKey.Add(conv))
      return false;
   idx++;
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, iUnits, iUnits, iWindow, iUnitsKV, 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, iUnitsKV, 1, optimization, iBatch) ||
      !cBValue.Add(conv))
      return false;

我们还要再添加 2 个 MLP 来生成全局上下文和位置偏差向量。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;

接下来,我们添加一个基于依赖关系的池化层。该层将多头注意力机制的输出聚合为加权和。基于依赖关系分析,为序列的每个元素单独判定影响系数。

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

重点要注意,描述池化层输出处每个序列元素的向量大小与内部维度相对应,其或许与输入序列中的原始元素向量长度不同。因此,我们添加了一个额外的 MLP 缩放模块,以便将结果带回原始数据维度。该模块由两个卷积层组成,在它们之间应用 LReLU 激活函数,从而引入非线性。

//---
   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;

现在让我们回到输入数据源之间的维度差异问题。在我们的交叉注意力模块中,第一个输入源用于形成 查询 实体,并在注意力机制中充当主流。它也用在残差连接。故它的维度维持不变。因此,为了统调两个输入源的维度,我们将执行第二个输入源值的投影。为了实现这种可学习的数据投影,我们将创建两个连续神经层。指向这些层的指针将存储在 cKVProjection 数组当中。第一层将是全连接层。它负责对来自第二个数据源的原始输入进行初始处理和存储。

   cKVProjection.Clear();
   cKVProjection.SetOpenCL(OpenCL);
   idx++;
   neuron = new CNeuronBaseOCL;
   if(!neuron ||
      !neuron.Init(0, idx, OpenCL, window_kv * iUnitsKV, optimization, iBatch) ||
      !cKVProjection.Add(neuron)
     )
      return false;

第二个卷积层执行数据投影到所需的子空间。

   idx++;
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, window_kv, window_kv, iWindow, iUnitsKV, 1, optimization, iBatch) ||
      !cKVProjection.Add(conv)
     )
      return false;

现在,执行给定功能所需的所有对象初始化之后,我们把操作的布尔结果返回给调用程序,并终止该方法。

//---
   SetOpenCL(OpenCL);
//---
   return true;
  }

新对象实例的初始化完成后,我们转到 feedForward 方法构造前馈算法。注意,该算法需要两个单独的输入源。因此,我们覆盖从父类继承的方法,其仅接受单个输入源,始终返回 false,指示方法调用无效或不正确。正确的前向通验算法是由两个输入源作为参数的方法版本中实现的。

bool CNeuronRelativeCrossAttention::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput)
  {
   CNeuronBaseOCL *neuron = cKVProjection[0];
   if(!neuron || !SecondInput)
      return false;
   if(neuron.getOutput() != SecondInput)
      if(!neuron.SetOutput(SecondInput, true))
         return false;

在方法主体中,我们首先验证指向第二个输入数据源的指针有效性。如果指针有效,我们将其传递给投影模型的第一层,该层的指针存储在 cKVProjection 数组当中。然后,我们初始化一个循环,顺序迭代遍历投影模型的所有层。在该循环中,我们调用每一层的前馈通验方法,使用前一神经层的输出作为当前神经层的输入。

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

来自第二个源的输入数据投影成功后,我们转而生成 查询 实体。查询 实体是用来自第一个源的输入数据生成的。对于 实体,我们使用第二个源数据的投影结果。

   if(!cQuery.FeedForward(NeuronOCL) ||
      !cKey.FeedForward(neuron) ||
      !cValue.FeedForward(neuron)
     )
      return false;

接下来,我们需要计算对象之间的距离系数。为此,我们首先转置来自第一个源的数据。然后,我们将来自第二个源的数据投影结果乘以来自第一个源的转置数据。

   if(!cTranspose.FeedForward(NeuronOCL) ||
      !MatMul(neuron.getOutput(), cTranspose.getOutput(), cDistance.getOutput(), iUnitsKV, 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;

我们经由池化模型传递多头交叉注意力结果。

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

然后,我们将其伸缩至第一个数据源张量的大小。该功能由内部缩放模型执行。

   if(!((CNeuronBaseOCL*)cScale[0]).FeedForward(cMHAttentionPooling[cMHAttentionPooling.Total() - 1]))
      return false;
   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;
  }

在该方法完成之前,我们返回一个布尔值,告知调用程序操作成功。

在实现前馈方法之后,我们继续反向传播算法。如您所知,反向传播通验分为两个阶段。根据误差梯度对最终结果的贡献,将误差梯度分派到所有参与的分量,这是在 calcInputGradients 方法中完成的。优化模型参数,以致模型误差最小化,是在 updateInputWeights 方法中实现的。后者的算法相当简单。它按顺序调用包含可训练参数的所有内部对象的相应 updateInputWeights 方法。前一种方法的算法值得更详细地考察。

calcInputGradients 方法参数中,我们接收指向两个输入数据对象的指针,每个输入数据对象都包含相应误差梯度记录的缓冲区。

bool CNeuronRelativeCrossAttention::calcInputGradients(CNeuronBaseOCL *NeuronOCL, 
                                                       CBufferFloat *SecondInput, 
                                                       CBufferFloat *SecondGradient, 
                                                       ENUM_ACTIVATION SecondActivation = -1)
  {
   if(!NeuronOCL || !SecondGradient)
      return false;

在方法主体中,我们检查接收指针的相关性,如果指针无效或过时,则任何进一步的作都毫无意义。

回想一下,在前馈通验期间,我们保存了一个指向内层第二个数据源输入缓冲区的指针。现在,我们对相应的误差梯度缓冲区执行类似的操作,并立即同步激活函数。

   CNeuronBaseOCL *neuron = cKVProjection[0];
   if(!neuron)
      return false;
   if(neuron.getGradient() != SecondGradient)
      if(!neuron.SetGradient(SecondGradient))
         return false;
   if(neuron.Activation() != SecondActivation)
      neuron.SetActivationFunction(SecondActivation);

准备阶段就绪后,我们继续误差梯度的实际反向传播,基于它们对最终结果的贡献将其分派给所有参与者。

我们在对象初始化期间替换了指向误差梯度缓冲区的指针。因此,反向传播直接从遍历内层开始。在该阶段,重要的是记住 AMCT 框架在基序级别引入了对比学习。遵循该逻辑,我们在交叉注意力模块的输出中添加了多样性损失。

   if(!DiversityLoss(AsObject(), iUnits, iWindow, true))
      return false;

通过在这一点引入多样性损失,我们旨在最大限度地提高特征表示在嵌入子空间中的扩散,特别是投喂到下一层的输出。同时,正如我们经由模型对象反向传播梯度时,我们间接地将进入我们模块的所分析初始数据的对象与两个来源分开。

接下来,整个梯度经由内部结果缩放模型传递。

   for(int i = cScale.Total() - 2; i >= 0; i--)
      if(!((CNeuronBaseOCL*)cScale[i]).calcHiddenGradients(cScale[i + 1]))
         return false;
   if(!((CNeuronBaseOCL*)cMHAttentionPooling[cMHAttentionPooling.Total() - 1]).calcHiddenGradients(cScale[0]))
      return false;

然后我们使用池化模型在头部之间分派注意力。

   for(int i = cMHAttentionPooling.Total() - 2; i > 0; i--)
      if(!((CNeuronBaseOCL*)cMHAttentionPooling[i]).calcHiddenGradients(cMHAttentionPooling[i + 1]))
         return false;

AttentionGradient 方法中,我们将误差梯度传播到 查询 实体,以及偏置张量,具体取决于它们对最终输出的影响。

   if(!AttentionGradient())
      return false;

接下来,我们将执行反向迭代遍历神经层,跨可训练全局偏差的内部模型分派误差梯度 。

   for(int i = cGlobalContentBias.Total() - 2; i > 0; i--)
      if(!((CNeuronBaseOCL*)cGlobalContentBias[i]).calcHiddenGradients(cGlobalContentBias[i + 1]))
         return false;
   for(int i = cGlobalPositionalBias.Total() - 2; i > 0; i--)
      if(!((CNeuronBaseOCL*)cGlobalPositionalBias[i]).calcHiddenGradients(cGlobalPositionalBias[i + 1]))
         return false;

类似地,我们经由偏置实体传播误差梯度,基于 BKBV 对象的结构生成模型。

   for(int i = cBKey.Total() - 2; i >= 0; i--)
      if(!((CNeuronBaseOCL*)cBKey[i]).calcHiddenGradients(cBKey[i + 1]))
         return false;
   for(int i = cBValue.Total() - 2; i >= 0; i--)
      if(!((CNeuronBaseOCL*)cBValue[i]).calcHiddenGradients(cBValue[i + 1]))
         return false;

然后我们将其传播到 cDistance 数据结构矩阵。不过,有一个关键的细微差别。我们用结构矩阵来生成这两个实体。故此,需要从两个信息流中收集误差梯度。因此,我们首先从 BK 获得误差梯度。

   if(!cDistance.calcHiddenGradients(cBKey[0]))
      return false;

然后我们将指针替换为该对象的误差梯度缓冲区,并从 BV 获取梯度。之后,我们汇总来自两个模型的误差梯度,并将指向数据缓冲区的指针返回到其原始状态。

   CBufferFloat *temp = cDistance.getGradient();
   if(!cDistance.SetGradient(GetPointer(cTemp), false) ||
      !cDistance.calcHiddenGradients(cBValue[0]) ||
      !SumAndNormilize(temp, GetPointer(cTemp), temp, iUnits, false, 0, 0, 0, 1) ||
      !cDistance.SetGradient(temp, false)
     )
      return false;

在结构矩阵上收集的误差梯度在输入数据对象之间分派。但在这种情况下,我们不是直接分派数据,而是经由第一个数据源的转置层、和第二个数据源的投影模型来分派数据。

   neuron = cKVProjection[cKVProjection.Total() - 1];
   if(!neuron ||
      !MatMulGrad(neuron.getOutput(), neuron.getGradient(),
                  cTranspose.getOutput(), cTranspose.getGradient(),
                  temp, iUnitsKV, iWindow, iUnits, 1)
     )
      return false;

接下来,我们将误差梯度从转置层向下传播到第一个输入数据源的级别。此刻,我们立即将获得的值与来自残差连接流的梯度相加。该操作的结果被写入数据转置层的梯度缓冲区之中。该缓冲区的大小很理想,且其以前存储的数值能被安全地丢弃。

   if(!NeuronOCL.calcHiddenGradients(cTranspose.AsObject()) ||
      !SumAndNormilize(NeuronOCL.getGradient(), Gradient, cTranspose.getGradient(), iWindow, false, 0, 0, 0, 1))
      return false;

然后,我们在第一个输入源级别计算误差梯度,派生自 查询 实体,并将其添加到累积数据之中。这一次,求和的结果保存在输入数据的梯度缓冲区当中。

   if(!NeuronOCL.calcHiddenGradients(cQuery.AsObject()) ||
      !SumAndNormilize(NeuronOCL.getGradient(), cTranspose.getGradient(), NeuronOCL.getGradient(), 
                                                                       iWindow, false, 0, 0, 0, 1) ||
      !DiversityLoss(NeuronOCL, iUnits, iWindow, true)
     )
      return false;

我们还在该阶段添加了多样性损失。

据此,我们结束第一个输入数据源的梯度传播,并继续第二个数据流。

我们之前已将结构矩阵的误差梯度保存在第二个输入数据源的内部投影模型最后一层的缓冲区当中。现在,我们需要向其添加 实体中的误差梯度。为此,我们首先替换接收对象中的梯度缓冲区。然后按顺序调用各实体的梯度分派方法,将中间结果添加到先前累积的数值之中。

   temp = neuron.getGradient();
   if(!neuron.SetGradient(GetPointer(cTemp), false) ||
      !neuron.calcHiddenGradients(cKey.AsObject()) ||
      !SumAndNormilize(temp, GetPointer(cTemp), temp, iWindow, false, 0, 0, 0, 1) ||
      !neuron.calcHiddenGradients(cValue.AsObject()) ||
      !SumAndNormilize(temp, GetPointer(cTemp), temp, iWindow, false, 0, 0, 0, 1) ||
      !neuron.SetGradient(temp, false)
     )
      return false;

此刻,我们简单地执行反向迭代,遍历投影模型的各层,贯穿模型将误差梯度向下传播。

   for(int i = cKVProjection.Total() - 2; i >= 0; i--)
     {
      neuron = cKVProjection[i];
      if(!neuron ||
         !neuron.calcHiddenGradients(cKVProjection[i + 1]))
         return false;
     }
//---
   return true;
  }

重点要注意,通过将模型第一层中的梯度缓冲区指针,替换为方法参数中外部程序提供的缓冲区,我们消除了多余的数据复制需求。如是结果,当梯度向下传递到第一层时,它们会自动写入外部系统提供的缓冲区。

所有剩下的就是将操作的布尔结果返回给调用程序,并完成方法。

我们有关相对交叉注意力对象 CNeuronRelativeCrossAttention 中方法的实现和讨论至此完结。附件中提供了该类及其所有方法的完整源代码。

不幸的是,在我们的实现完成之前,我们已达到了本文的篇幅限制。因此,我们要短暂休止,并在下一篇文章中继续工作。

结束语

在本文中,我们讲述了原子-基序对比转换器AMCT) 框架,其建立在原子元素(蜡烛)和基序(形态)的概念之上。该方法的核心思想在于采用对比学习来帮助模型区分不同结构层次上的信息和非信息形态,从基本元素到复杂结构。这令该模型不仅可以捕捉市场走势的局部特征,还可以识别有意义的形态,或许能为预测未来的市场行为带来额外的能力。变换器架构作为该方法的基础,能够对蜡烛和基序之间的长期依赖关系、和复杂相互关系进行高效建模。

在实践部分,我们开始利用 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/16163

附加的文件 |
MQL5.zip (2027.98 KB)
最近评论 | 前往讨论 (2)
Khaled Ali E Msmly
Khaled Ali E Msmly | 21 10月 2024 在 12:42
感谢您的努力,我正迫不及待地等待着您的下一篇文章。
zhai nan
zhai nan | 21 7月 2025 在 10:49
解决完编译报错了,剩下个测试器报错,整得脑袋都烧了,也搞不懂哪里解决
人工喷淋算法(ASHA) 人工喷淋算法(ASHA)
本文介绍了人工喷淋算法(Artificial Showering Algorithm,ASHA),这是一种为解决一般优化问题而开发的新型元启发式方法。基于对水流和积聚过程的模拟,该算法构建了理想场的概念,其中要求每个资源单元(水)找到最优解。我们将了解 ASHA 如何调整流和累积原则来有效地分配搜索空间中的资源,并查看其实现和测试结果。
使用经典机器学习方法预测汇率:逻辑回归(logit)模型和概率回归(probit)模型 使用经典机器学习方法预测汇率:逻辑回归(logit)模型和概率回归(probit)模型
本文尝试构建一款用于预测汇率报价的EA。该算法以经典分类模型——逻辑回归与概率回归为基础。并利用似然比检验作为交易信号的筛选器。
创建一个基于布林带PIRANHA策略的MQL5 EA 创建一个基于布林带PIRANHA策略的MQL5 EA
在本文中,我们将创建一个MQL5 EA,它基于PIRANHA策略,并使用布林带来提升交易表现。我们会系统梳理该策略的核心原理、代码实现细节,以及测试与优化方法。并助您轻松将 EA 部署到实际的交易环境中。
从新手到专家:MQL5中的协作式调试指南 从新手到专家:MQL5中的协作式调试指南
问题解决法能为掌握复杂技能(如MQL5编程)构建高效路径。该方法让您在专注攻克问题的同时,潜移默化地提升技能水平。解决的难题越多,大脑积累的专业知识就越深厚。就我个人而言,调试是精通编程最有效的途径。本文将带你逐步梳理代码清理流程,并探讨将杂乱程序转化为简洁高效代码的核心技巧。阅读本文,洞悉其中的宝贵见解。