English Русский Español Deutsch 日本語 Português
preview
神经网络变得简单(第 79 部分):在状态上下文中的特征聚合查询(FAQ)

神经网络变得简单(第 79 部分):在状态上下文中的特征聚合查询(FAQ)

MetaTrader 5交易系统 | 25 十一月 2024, 13:16
194 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

我们之前讨论的大多数方法所分析的环境状态都是静态的,这与马尔可夫(Markov)过程的定义完全对应。当然,我们用历史数据填充了环境状态的描述,以便为模型提供尽可能多的必要信息。但该模型未估测状态变化的动态。这也参考了上一篇文章中讲述的方法:DFFT 是为检测静态图像中的对象而开发的。

然而,对价格走势的观察表明,变化的动态有时其概率足以示意即将到来的走势强度和方向。逻辑上,我们现在将关注度转向检测视频中对象的方法。

检测视频中对象具有许多特定的特性,必须解决运动引起的对象特征变化问题,而这些在图像域中并不会遇到。其中一种解决方案是使用时态信息,并组合来自相邻帧的特征。论文《FAQ:用于基于变换器的检测视频中对象的特征聚合查询》提出了一种检测视频中对象的新方法。该文章作者通过聚合来提升基于变换器的模型查询品质。为了达成这个目标,提议一种实用方法,根据输入帧的特征生成和聚合查询。论文中提供的大量实验结果佐证了所提议方法的有效性。所提议方式可以扩展到广泛的检测图像和视频中对象的方法,从而提高其效率。


1. 特征聚合查询算法

FAQ 方法并不是第一个使用变换器架构来检测视频中对象的方法。不过,采用变换器的现有视频对象检测器通过聚合查询来改进对象特征的表述。原版的天真想法是把来自相邻帧的查询均化。查询是随机初始化的,并用在训练期间。相邻查询会被聚合到当前帧 𝑰 的 Δ𝑸 之中,并表示为:

其中 w 是针对聚合的可学习权重。

创建可学习权重的简单思路是基于输入帧特征的余弦相似性。根据现有的视频对象检测器,FAQ 方法的作者遵照以下公式生成聚合权重:

其中 α、β 是映射函数,而 |⋅| 表示常规化。

当前帧 𝑰 及其相邻帧 𝑰i 的相关特征表示为 𝑭 和 𝑭i。结果就是,识别一个对象的概率可以表达为:

其中 𝑷v 是使用聚合查询 Δ𝑸v 的预测概率。

原版查询聚合模块中存在一个问题:这些相邻的查询 𝑸i 是随机初始化的,且不与它们对应的帧 𝑰i 关联。因此,相邻查询 𝑸i 没有提供足够的时态或语义信息来克服由快速移动引起的性能下降问题。尽管用于聚合的权重 wi 与函数 𝑭 和 𝑭i 相关,但这些随机初始化的查询数量并无足够的制约。因此,FAQ 方法的作者建议将聚合模块 Query 更新为动态版本,即往查询里添加约束,并能依据相邻帧调整权重。简单的实现思路是直接从输入帧的特征 𝑭i 生成查询 𝑸i。然而,该方法作者所做的实验表明,这种方法很难训练,且总是会产生更差的结果。与上面提到的天真想法相反,该方法的作者提议从随机初始化的查询中生成新的查询,以便适应原始数据。首先,我们定义两种类型的查询向量:基本和动态。在学习和操作期间,动态查询是根据特征 𝑭i基本查询生成的,输入帧 𝑭 如下:

其中 M 是一个映射函数,根据特征 𝑭 和 𝑭i,构建基本查询 Qb 与动态 Qd 的关系。

首先,我们根据 r 查询将基本查询划分成几组。然后,对于每个组,我们使用相同的权重 𝑽 来判定当前组中的加权平均查询:

为了在动态查询 𝑸d 和相应的帧 𝑰i 之间建立关系,该方法的作者提议使用全局特征生成权重 𝑽:

其中 A 是一个全局池化操作,可更改特征张量的维度,并创建全局层面特征,
      G 是一个映射函数,允许您将全局特征投影到动态张量 Query.的维度当中。

因此,基于源数据特征的动态查询聚合过程可以按如下方式更新:

在训练期间,该方法的作者提议同时聚合动态和基本查询。这两种类型的查询都采用相同的权重进行聚合,并生成相应的预测 𝑷d 和 𝑷b。此处,我们还计算了两个预测的双向一致误差。超参数 γ 用于平衡误差的影响。

在操作期间,我们只使用动态查询 𝑸d 及其相应的预测 𝑷d 作为最终结果,这仅会令原始模型稍微复杂一些。

以下是作者对该方法的可视化

作者的 FAQ 方法可视化



2. 利用 MQL5 实现

我们已经研究过算法的理论层面。现在,我们转到本文的实践部分,于其中我们将利用 MQL5 实现提议的方式。

从上面对 FAQ 方法的描述中可以看出,它的主要贡献是在变换器解码器中创建了一个生成和聚合动态查询张量的模块。我想提醒您,DFFT 方法的作者因其无效而排除了解码器。好吧,在当前的工作中,我们将添加一个解码器,并评估其在由 FAQ 方法作者提议的动态查询实体的境况中的有效性。

2.1动态查询类

为了生成动态查询,我们将创建一个新类 CNeuronFAQOCL。新对象将继承自神经层基类 CNeuronBaseOCL

class CNeuronFAQOCL  : public CNeuronBaseOCL
  {
protected:
   //---
   CNeuronConvOCL       cF;
   CNeuronBaseOCL       cWv;
   CNeuronBatchNormOCL  cNormV;
   CNeuronBaseOCL       cQd;
   CNeuronXCiTOCL       cDQd;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   //---
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);

public:
                     CNeuronFAQOCL(void) {};
                    ~CNeuronFAQOCL(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_out, uint heads,
                          uint units_count, uint input_units,
                          ENUM_OPTIMIZATION optimization_type,
                          uint batch);
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer);
   //---
   virtual int       Type(void)   const   {  return defNeuronFAQOCL;   }
   //--- methods for working with files
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   virtual CLayerDescription* GetLayerInfo(void);
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

在新方法中,除了覆盖方法的基本集合外,我们还将添加 5 个内部神经层。我们将在实现过程中解释它们的目的。我们将所有内部对象声明为静态,这允许我们将类的构造函数和析构函数留空。

类对象在 CNeuronFAQOCL::Init 方法中初始化。在方法参数中,我们得到初始化内部对象的所有关键参数。在方法的主体中,我们调用父类的相关方法。如您所知,该方法针对接收的参数实现了最小必要控制,以及继承对象的初始化。

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

没有为我们的类指定激活函数。

   activation = None;

接下来,我们初始化内部对象。于此,我们转向由 Query 方法的作者提议的生成动态查询的方式。为了基于源数据的特征生成基础查询的聚合权重,我们来创建 3 个层。首先,我们将源数据的特征通过卷积层传递,在该层中,我们分析相邻环境状态的形态。

   if(!cF.Init(0, 0, OpenCL, 3 * window, window, 8, fmax((int)input_units - 2, 1), optimization_type, batch))
      return false;
   cF.SetActivationFunction(None);

为了提高模型训练和操作过程的稳定性,我们把接收到的数据进行常规化。

   if(!cNormV.Init(8, 1, OpenCL, fmax((int)input_units - 2, 1) * 8, batch, optimization_type))
      return false;
   cNormV.SetActivationFunction(None);

然后我们将数据压缩到基本查询聚合的权重张量的大小。为了确保得到的权重在 [0,1] 范围内,我们调用 sigmoid 激活函数。

   if(!cWv.Init(units_count * window_out, 2, OpenCL, 8, optimization_type, batch))
      return false;
   cWv.SetActivationFunction(SIGMOID);

根据 FAQ 算法,我们必须将聚合系数的结果向量乘以训练开始时随机生成的基础 Queries 矩阵。在我的实现中,我决定更进一步,训练基础查询。好吧,我没有想出比使用完全连接神经层更好的方法。我们为该层投喂聚合系数向量,而全连接层的权重矩阵是正在训练的基础查询的张量。

   if(!cQd.Init(0, 4, OpenCL, units_count * window_out, optimization_type, batch))
      return false;
   cQd.SetActivationFunction(None);

接下来是动态查询的聚合。FAQ 方法作者在他们的论文中展示了运用各种聚合方法的实验结果。最有效的是运用变换器架构的动态查询聚合。根据上述结果,我们使用 CNeuronXCiTOCL 类对象来聚合动态查询。

   if(!cDQd.Init(0, 5, OpenCL, window_out, 3, heads, units_count, 3, optimization_type, batch))
      return false;
   cDQd.SetActivationFunction(None);

为了消除不必要的数据复制操作,我们替换了类和误差梯度的结果缓冲区。

   if(Output != cDQd.getOutput())
     {
      Output.BufferFree();
      delete Output;
      Output = cDQd.getOutput();
     }
   if(Gradient != cDQd.getGradient())
     {
      Gradient.BufferFree();
      delete Gradient;
      Gradient = cDQd.getGradient();
     }
//---
   return true;
  }

初始化对象之后,我们转到 CNeuronFAQOCL::feedForward 方法中组织前馈过程。此处的一切都非常简单明了。在方法参数中,我们会收到一个指向源数据层的指针,其中包含描述环境状态的参数。在方法的主体中,我们交替调用内部对象的相关前馈方法。

bool CNeuronFAQOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
//---
   if(!cF.FeedForward(NeuronOCL))
      return false;

我们首先通过卷积层传送环境描述,并对结果数据进行常规化。

   if(!cNormV.FeedForward(GetPointer(cF)))
      return false;

然后我们生成基础查询的聚合系数。

   if(!cWv.FeedForward(GetPointer(cNormV)))
      return false;

创建动态查询

   if(!cQd.FeedForward(GetPointer(cWv)))
      return false;

将它们聚合到 CNeuronXCiTOCL 类对象之中。

   if(!cDQd.FeedForward(GetPointer(cQd)))
      return false;
//---
   return true;
  }

由于我们有数据缓冲区的替换,因此内部层 cDQd 的结果反映在我们的 CNeuronFAQOCL 类的结果缓冲区中,而免予不必要的复制操作。因此,我们可以完成该方法。

接下来,我们创建反向传播方法 CNeuronFAQOCL::calcInputGradientsCNeuronFAQOCL::updateInputWeights。与前馈方法类似,于此我们依据内部对象调用相关方法,但按逆顺。因此,在本文中,我们不会详细研究它们的算法。您可以使用本文的附件来研究动态查询生成类 CNeuronFAQOCL 全部方法的完整代码。

2.2交叉关注度类

下一步是创建一个交叉关注度类。早些时候,在 ADAPT 方法的实现框架内,我们已创建过一个交叉关注度层 CNeuronMH2AttentionOCL。不过,那一次我们分析了一个张量的不同维度之间的关系。现在的任务略有不同。我们需要评估依据 CNeuronFAQOCL 类生成的动态查询的依赖关系,以便压缩来自我们模型编码器的环境状态。换言之,我们需要评估 2 个不同张量之间的关系。

为了实现该功能,我们将创建一个新类 CNeuronCrossAttention,其将继承上述 CNeuronMH2AttentionOCL 类的部分必要功能。

class CNeuronCrossAttention : public CNeuronMH2AttentionOCL
  {
protected:
   uint              iWindow_K;
   uint              iUnits_K;
   CNeuronBaseOCL    *cContext;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL, CNeuronBaseOCL *Context);
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Context);
   virtual bool      attentionOut(void);
   //---
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL, CNeuronBaseOCL *Context);
   virtual bool      AttentionInsideGradients(void);

public:
                     CNeuronCrossAttention(void) {};
                    ~CNeuronCrossAttention(void) { delete cContext; }
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key, uint heads,
                          uint units_count, uint window_k, uint units_k,
                          ENUM_OPTIMIZATION optimization_type,
                          uint batch);
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer, CNeuronBaseOCL *Context);
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer, CBufferFloat *SecondInput, 
                                        CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Context);
   //---
   virtual int       Type(void)   const   {  return defNeuronCrossAttenOCL;   }
   //--- methods for working with files
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   virtual CLayerDescription* GetLayerInfo(void);
  };

除了标准的覆盖方法集合之外,您还会注意到此处的 2 个新变量:

  • iWindow_K — 第二个张量的一个元素的描述向量的大小;
  • iUnits_K — 第二个张量序列中的元素数量。

此外,我们将添加一个指向辅助神经层 cContext 的动态指针,如有必要,该指针将初始化为源对象。由于该对象执行可选的辅助角色,因此类的构造函数保持为空。但在类的析构函数中,我们需要删除动态对象。

                    ~CNeuronCrossAttention(void) { delete cContext; }

如常,对象在 CNeuronCrossAttention::Init 方法中初始化。在方法参数中,我们获取有关所创建层架构的必要数据。在方法的主体中,我们调用基础神经层类的相关方法 CNeuronBaseOCL::Init

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

请注意,我们调用的初始化方法并非来自直接父类 CNeuronMH2AttentionOCL,而是基类 CNeuronBaseOCL。这是由于 CNeuronCrossAttentionCNeuronMH2AttentionOCL 类的架构存在差异。因此,在方法的主体中,我们不仅初始化了新的对象,还一同初始化继承对象。

首先,我们保存层设置。

   iWindow = fmax(window, 1);
   iWindowKey = fmax(window_key, 1);
   iUnits = fmax(units_count, 1);
   iWindow_K = fmax(window_k, 1);
   iUnits_K = fmax(units_k, 1);
   iHeads = fmax(heads, 1);
   activation = None;

接下来,我们初始化 Query 实体生成层。

   if(!Q_Embedding.Init(0, 0, OpenCL, iWindow, iWindow, iWindowKey * iHeads, iUnits, optimization_type, batch))
      return false;
   Q_Embedding.SetActivationFunction(None);

对于 KeyValue 实体执行相同的操作。

   if(!KV_Embedding.Init(0, 0, OpenCL, iWindow_K, iWindow_K, 2 * iWindowKey * iHeads, iUnits_K, optimization_type, batch))
      return false;
   KV_Embedding.SetActivationFunction(None);

请不要将此处生成的 Query 实体与 CNeuronFAQOCL 类中生成的动态查询混淆。

作为 FAQ 方法实现的一部分,我们将生成的动态查询作为初始数据输入到该类之中。我们可以在这里说 Q_Embedding 层将它们分布在关注度头之间。KV_Embedding 层依据自编码器接收的环境状态的压缩表示来生成实体。

但我们回到我们的类初始化方法。初始化实体生成层后,我们将创建一个依赖系数矩阵缓冲区 Score

   ScoreIndex = OpenCL.AddBuffer(sizeof(float) * iUnits * iUnits_K * iHeads, CL_MEM_READ_WRITE);
   if(ScoreIndex == INVALID_HANDLE)
      return false;

此处,我们还创建了一个多头关注度结果层。

   if(!MHAttentionOut.Init(0, 0, OpenCL, iWindowKey * iUnits * iHeads, optimization_type, batch))
      return false;
   MHAttentionOut.SetActivationFunction(None);

以及一个关注度头聚合的层。

   if(!W0.Init(0, 0, OpenCL, iWindowKey * iHeads, iWindowKey * iHeads, iWindow, iUnits, optimization_type, batch))
      return false;
   W0.SetActivationFunction(None);
   if(!AttentionOut.Init(0, 0, OpenCL, iWindow * iUnits, optimization_type, batch))
      return false;
   AttentionOut.SetActivationFunction(None);

接下来是 FeedForward 模块。

   if(!FF[0].Init(0, 0, OpenCL, iWindow, iWindow, 4 * iWindow, iUnits, optimization_type, batch))
      return false;
   if(!FF[1].Init(0, 0, OpenCL, 4 * iWindow, 4 * iWindow, iWindow, iUnits, optimization_type, batch))
      return false;
   for(int i = 0; i < 2; i++)
      FF[i].SetActivationFunction(None);

在初始化方法的末尾,我们组织缓冲区的替换。

   Gradient.BufferFree();
   delete Gradient;
   Gradient = FF[1].getGradient();
//---
   return true;
  }

初始化类之后,我们像往常一样继续组织前馈验算。在该类中,我们不会在 OpenCL 程序端创建新的内核。在这种情况下,我们将使用所创建内核来实现父类的进程。不过,我们需要对调用内核的方法进行一些微调。例如,在 CNeuronCrossAttention::attentionOut 方法中,我们仅根据 Key 实体序列的大小(在代码中以红色高亮显示)更改指示任务空间和局部组的数组。

bool CNeuronCrossAttention::attentionOut(void)
  {
   if(!OpenCL)
      return false;
//---
   uint global_work_offset[3] = {0};
   uint global_work_size[3] = {iUnits/*Q units*/, iUnits_K/*K units*/, iHeads};
   uint local_work_size[3] = {1, iUnits_K, 1};
   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_q, Q_Embedding.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_kv, KV_Embedding.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_score, ScoreIndex))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_out, MHAttentionOut.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_MH2AttentionOut, def_k_mh2ao_dimension, (int)iWindowKey))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.Execute(def_k_MH2AttentionOut, 3, global_work_offset, global_work_size, local_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }
//---
   return true;
  }

整个前馈算法在 CNeuronCrossAttention::feedForward 方法的顶层进行了描述。与父类的相关方法不同,该方法在其参数中接收指向神经层的 2 个对象的指针。它们包含 2 个张量的数据,用于依赖性分析。

bool CNeuronCrossAttention::feedForward(CNeuronBaseOCL *NeuronOCL, CNeuronBaseOCL *Context)
  {
//---
   if(!Q_Embedding.FeedForward(NeuronOCL))
      return false;
//---
   if(!KV_Embedding.FeedForward(Context))
      return false;

在方法主体中,我们首先依据接收到的数据中生成实体。然后我们调用多头关注度方法。

   if(!attentionOut())
      return false;

我们聚合关注度的结果。

   if(!W0.FeedForward(GetPointer(MHAttentionOut)))
      return false;

并将它们与源数据汇总。之后,我们在序列的元素内对结果进行常规化。在 FAQ 方法实现的境况中,在单独的动态查询的上下文中执行常规化。

   if(!SumAndNormilize(W0.getOutput(), NeuronOCL.getOutput(), AttentionOut.getOutput(), iWindow))
      return false;

然后,数据经 FeedForward 模块验算。

   if(!FF[0].FeedForward(GetPointer(AttentionOut)))
      return false;
   if(!FF[1].FeedForward(GetPointer(FF[0])))
      return false;

然后我们再次汇总数据,并进行常规化。

   if(!SumAndNormilize(FF[1].getOutput(), AttentionOut.getOutput(), Output, iWindow))
      return false;
//---
   return true;
  }

上述所有操作成功完成后,我们终止该方法。 

如此这般,我们完成了前馈方法的讲述,并转到组织反向传播验算。于此,我们还使用父类实现的一部分创建的内核,并调用方法 CNeuronCrossAttention::AttentionInsideGradients 对该内核进行具体更改。

bool CNeuronCrossAttention::AttentionInsideGradients(void)
  {
   if(!OpenCL)
      return false;
//---
   uint global_work_offset[3] = {0};
   uint global_work_size[3] = {iUnits, iWindowKey, iHeads};
   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_q, Q_Embedding.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_qg, Q_Embedding.getGradientIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_kv, KV_Embedding.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_kvg, KV_Embedding.getGradientIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_score, ScoreIndex))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_outg, MHAttentionOut.getGradientIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_MH2AttentionInsideGradients, def_k_mh2aig_kunits, (int)iUnits_K))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.Execute(def_k_MH2AttentionInsideGradients, 3, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }
//---
   return true;
  }

通过我们的交叉关注度层传播误差梯度的过程是在 CNeuronCrossAttention::calcInputGradients 方法中实现的。与前馈方法一样,在该方法的参数中,我们将以 2 个数据线程传送指向 2个层的指针。

bool CNeuronCrossAttention::calcInputGradients(CNeuronBaseOCL *prevLayer, CNeuronBaseOCL *Context)
  {
   if(!FF[1].calcInputGradients(GetPointer(FF[0])))
      return false;
   if(!FF[0].calcInputGradients(GetPointer(AttentionOut)))
      return false;

幸亏替换了数据缓冲区,自后续层获得的误差梯度被立即传播到 FeedForward 模块第 2 层的误差梯度缓冲器。因此,我们不需要复制数据。接下来,我们立即调用 FeedForward 模块内层的误差梯度方法。

在该阶段,我们必须添加从 FeedForward 模块和后续神经层接收到的误差梯度。

   if(!SumAndNormilize(FF[1].getGradient(), AttentionOut.getGradient(), W0.getGradient(), iWindow, false))
      return false;

接下来,我们在关注度头之间分配误差梯度。

   if(!W0.calcInputGradients(GetPointer(MHAttentionOut)))
      return false;

调用该方法把误差梯度传播到 QueryKeyValue 实体。

   if(!AttentionInsideGradients())
      return false;

来自 KeyValue 实体的梯度被传送到 Context 层(编码器)。

   if(!KV_Embedding.calcInputGradients(Context))
      return false;

来自 Query 的梯度被传送到前一层。

   if(!Q_Embedding.calcInputGradients(prevLayer))
      return false;

不要忘记汇总误差梯度。

   if(!SumAndNormilize(prevLayer.getGradient(), W0.getGradient(), prevLayer.getGradient(), iWindow, false))
      return false;
//---
   return true;
  }

然后我们完成该方法。

用于更新内部对象参数的 CNeuronCrossAttention::updateInputWeights 方法非常简单。它就是逐个调用内部对象上的相关方法。您可以在附件中找到它们。此外,附件还包含所需的文件操作方法。此外,它还包含本文中用到的所有程序和类的完整代码。

如此这般,我们完成了新类的创建,并转到描述模型架构。

2.3模型架构

模型的架构在 CreateDescriptions 方法中表述。模型的当前架构在很大程度上是从 DFFT 方法的实现中复制而来的。不过,我们添加了解码器。因此,扮演者和评论者接收来自解码器的数据。因此,为了创建模型的描述,我们需要 4 个动态数组。

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

编码器模型(点)已从上一篇文章中复制而来,没有修改。您以在此处找到其详细说明。

解码器使用编码器在位置编码层级别的潜在数据作为输入数据。

//--- Decoder
   decoder.Clear();
//--- Input layer
   CLayerDescription *po = dot.At(LatentLayer);
   if(!po || !(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = po.count * po.window;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

我要提醒您,在这个层级,我们删除了存储在局部堆栈中的几个环境状态的嵌入,并添加了位置编码标签。实际上,这些嵌入包含一系列符号,描述 GPTBars 烛条的环境状态。这可比之视频序列的帧。基于这些数据,我们生成动态查询

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFAQOCL;
     {
      int temp[] = {QueryCount, po.count};
      ArrayCopy(descr.units, temp);
     }
   descr.window = po.window;
   descr.window_out = 16;
   descr.optimization = ADAM;
   descr.step = 4;
   descr.activation = None;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

并实现交叉-关注度。

//--- layer 2
   CLayerDescription *encoder = dot.At(dot.Total() - 1);
   if(!encoder || !(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronCrossAttenOCL;
     {
      int temp[] = {QueryCount, encoder.count};
      ArrayCopy(descr.units, temp);
     }
     {
      int temp[] = {16, encoder.window};
      ArrayCopy(descr.windows, temp);
     }
   descr.window_out = 16;
   descr.step = 4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

扮演者接收来自解码器的数据。

//--- Actor
   actor.Clear();
//--- Input layer
   encoder = decoder.At(decoder.Total() - 1);
   if(!encoder || !(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = encoder.units[0] * encoder.windows[0];
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

并将其与帐户状态的描述相结合。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = AccountDescr;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

之后,数据经由 2 个全连接层验算。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NActions;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

在输出中,我们往扮演者政策里添加随机性。

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

评论者模型几乎是原封不动地复制。唯一的变化是初始数据源已从编码器更改为解码器。

//--- Critic
   critic.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.Copy(actor.At(0));
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.Copy(actor.At(1));
   descr.step = NActions;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = NRewards;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

2.4环境交互 EA

在准备本文时,我用到了 3 个环境交互 EA:

  • Research.mq5
  • ResearchRealORL.mq5
  • Test.mq5

EA “...\Experts\FAQ\ResearchRealORL.mq5” 未链接到模型架构。由于所有 EA 都是通过分析描述环境的相同初始数据来训练和测试的,该 EA 无需修改就可在不同的文章中运用。您可在此处找到其代码和使用方法的完整描述。

在 EA 代码 “...\Experts\FAQ\Research.mq5” 中,我们添加了一个解码器模型。

CNet                 DOT;
CNet                 Decoder;
CNet                 Actor;

因此,在初始化方法中,我们添加了该模型的加载,并在必要时使用随机参数对其进行初始化。

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
........
........
//--- load models
   float temp;
//---
   if(!DOT.Load(FileName + "DOT.nnw", temp, temp, temp, dtStudied, true) ||
      !Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *dot = new CArrayObj();
      CArrayObj *decoder = new CArrayObj();
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      if(!CreateDescriptions(dot, decoder, actor, critic))
        {
         delete dot;
         delete decoder;
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      if(!DOT.Create(dot) ||
         !Decoder.Create(decoder) ||
         !Actor.Create(actor))
        {
         delete dot;
         delete decoder;
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      delete dot;
      delete decoder;
      delete actor;
      delete critic;
     }
//---
   Decoder.SetOpenCL(DOT.GetOpenCL());
   Actor.SetOpenCL(DOT.GetOpenCL());
//---
........
........
//---
   return(INIT_SUCCEEDED);
  }

请注意,在这种情况下,我们未用到评论者模型。其功能不参与环境交互和收集训练数据的过程。 

与环境交互的实际过程在 OnTick 方法中组织。在方法主体中,我们首先检查新柱线开盘事件的发生。

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
//---
   if(!IsNewBar())
      return;

整个过程基于对收盘蜡烛的分析。

当所需事件发生时,我们首先下载历史数据。

   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
      return;
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();
   Symb.Refresh();
   Symb.RefreshRates();

我们将数据传送到描述环境当前状态的缓冲区。

   float atr = 0;
   for(int b = 0; b < (int)HistoryBars; b++)
     {
      float open = (float)Rates[b].open;
      float rsi = (float)RSI.Main(b);
      float cci = (float)CCI.Main(b);
      atr = (float)ATR.Main(b);
      float macd = (float)MACD.Main(b);
      float sign = (float)MACD.Signal(b);
      if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
         continue;
      //---
      int shift = b * BarDescr;
      sState.state[shift] = (float)(Rates[b].close - open);
      sState.state[shift + 1] = (float)(Rates[b].high - open);
      sState.state[shift + 2] = (float)(Rates[b].low - open);
      sState.state[shift + 3] = (float)(Rates[b].tick_volume / 1000.0f);
      sState.state[shift + 4] = rsi;
      sState.state[shift + 5] = cci;
      sState.state[shift + 6] = atr;
      sState.state[shift + 7] = macd;
      sState.state[shift + 8] = sign;
     }
   bState.AssignArray(sState.state);

然后我们收集有关账户状态和持仓的数据。

   sState.account[0] = (float)AccountInfoDouble(ACCOUNT_BALANCE);
   sState.account[1] = (float)AccountInfoDouble(ACCOUNT_EQUITY);
//---
   double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0;
   double position_discount = 0;
   double multiplyer = 1.0 / (60.0 * 60.0 * 10.0);
   int total = PositionsTotal();
   datetime current = TimeCurrent();
   for(int i = 0; i < total; i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      double profit = PositionGetDouble(POSITION_PROFIT);
      switch((int)PositionGetInteger(POSITION_TYPE))
        {
         case POSITION_TYPE_BUY:
            buy_value += PositionGetDouble(POSITION_VOLUME);
            buy_profit += profit;
            break;
         case POSITION_TYPE_SELL:
            sell_value += PositionGetDouble(POSITION_VOLUME);
            sell_profit += profit;
            break;
        }
      position_discount += profit - (current - PositionGetInteger(POSITION_TIME)) * multiplyer * MathAbs(profit);
     }
   sState.account[2] = (float)buy_value;
   sState.account[3] = (float)sell_value;
   sState.account[4] = (float)buy_profit;
   sState.account[5] = (float)sell_profit;
   sState.account[6] = (float)position_discount;
   sState.account[7] = (float)Rates[0].time;

接收到的数据被分组到账户状态缓冲区之中。

   bAccount.Clear();
   bAccount.Add((float)((sState.account[0] - PrevBalance) / PrevBalance));
   bAccount.Add((float)(sState.account[1] / PrevBalance));
   bAccount.Add((float)((sState.account[1] - PrevEquity) / PrevEquity));
   bAccount.Add(sState.account[2]);
   bAccount.Add(sState.account[3]);
   bAccount.Add((float)(sState.account[4] / PrevBalance));
   bAccount.Add((float)(sState.account[5] / PrevBalance));
   bAccount.Add((float)(sState.account[6] / PrevBalance));

我们还于此处添加了时间戳谐量。

   double x = (double)Rates[0].time / (double)(D'2024.01.01' - D'2023.01.01');
   bAccount.Add((float)MathSin(2.0 * M_PI * x));
   x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_MN1);
   bAccount.Add((float)MathCos(2.0 * M_PI * x));
   x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_W1);
   bAccount.Add((float)MathSin(2.0 * M_PI * x));
   x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_D1);
   bAccount.Add((float)MathSin(2.0 * M_PI * x));

所收集数据首先被投喂未到编码器输入。

   if(bAccount.GetIndex() >= 0)
      if(!bAccount.BufferWrite())
         return;
//---
   if(!DOT.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }

编码器操作结果将传送到解码器。

   if(!Decoder.feedForward((CNet*)GetPointer(DOT), LatentLayer,(CNet*)GetPointer(DOT)))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }

然后,它们将传送到扮演者。

//--- Actor
   if(!Actor.feedForward((CNet *)GetPointer(Decoder), -1, (CBufferFloat*)GetPointer(bAccount)))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }
//---
   PrevBalance = sState.account[0];
   PrevEquity = sState.account[1];

我们加载扮演者的预测动作,并排除清算操作。

   vector<float> temp;
   Actor.getResults(temp);
   if(temp.Size() < NActions)
      temp = vector<float>::Zeros(NActions);
//---
   double min_lot = Symb.LotsMin();
   double step_lot = Symb.LotsStep();
   double stops = MathMax(Symb.StopsLevel(), 1) * Symb.Point();
   if(temp[0] >= temp[3])
     {
      temp[0] -= temp[3];
      temp[3] = 0;
     }
   else
     {
      temp[3] -= temp[0];
      temp[0] = 0;
     }

然后我们解码预测动作,并执行必要的交易动作。首先,我们实现多头持仓。

//--- buy control
   if(temp[0] < min_lot || (temp[1] * MaxTP * Symb.Point()) <= stops || (temp[2] * MaxSL * Symb.Point()) <= stops)
     {
      if(buy_value > 0)
         CloseByDirection(POSITION_TYPE_BUY);
     }
   else
     {
      double buy_lot = min_lot + MathRound((double)(temp[0] - min_lot) / step_lot) * step_lot;
      double buy_tp = NormalizeDouble(Symb.Ask() + temp[1] * MaxTP * Symb.Point(), Symb.Digits());
      double buy_sl = NormalizeDouble(Symb.Ask() - temp[2] * MaxSL * Symb.Point(), Symb.Digits());
      if(buy_value > 0)
         TrailPosition(POSITION_TYPE_BUY, buy_sl, buy_tp);
      if(buy_value != buy_lot)
        {
         if(buy_value > buy_lot)
            ClosePartial(POSITION_TYPE_BUY, buy_value - buy_lot);
         else
            Trade.Buy(buy_lot - buy_value, Symb.Name(), Symb.Ask(), buy_sl, buy_tp);
        }
     }

然后是空头持仓。

//--- sell control
   if(temp[3] < min_lot || (temp[4] * MaxTP * Symb.Point()) <= stops || (temp[5] * MaxSL * Symb.Point()) <= stops)
     {
      if(sell_value > 0)
         CloseByDirection(POSITION_TYPE_SELL);
     }
   else
     {
      double sell_lot = min_lot + MathRound((double)(temp[3] - min_lot) / step_lot) * step_lot;;
      double sell_tp = NormalizeDouble(Symb.Bid() - temp[4] * MaxTP * Symb.Point(), Symb.Digits());
      double sell_sl = NormalizeDouble(Symb.Bid() + temp[5] * MaxSL * Symb.Point(), Symb.Digits());
      if(sell_value > 0)
         TrailPosition(POSITION_TYPE_SELL, sell_sl, sell_tp);
      if(sell_value != sell_lot)
        {
         if(sell_value > sell_lot)
            ClosePartial(POSITION_TYPE_SELL, sell_value - sell_lot);
         else
            Trade.Sell(sell_lot - sell_value, Symb.Name(), Symb.Bid(), sell_sl, sell_tp);
        }
     }

在方法结束时,我们将保存环境交互的结果至经验回放缓冲区当中。

   sState.rewards[0] = bAccount[0];
   sState.rewards[1] = 1.0f - bAccount[1];
   if((buy_value + sell_value) == 0)
      sState.rewards[2] -= (float)(atr / PrevBalance);
   else
      sState.rewards[2] = 0;
   for(ulong i = 0; i < NActions; i++)
      sState.action[i] = temp[i];
   if(!Base.Add(sState))
      ExpertRemove();
  }

EA 的其余方法没有发生任何变化。 

针对 EA “...\Experts\FAQ\Test.mq5“ 也进行了类似地修改。您可用附件中这两个 EA 的完整代码自行学习。

2.5模型训练 EA

模型在 “...\Experts\FAQ\Study.mq5” EA 中进行训练。与之前开发的 EA 一样,EA 的结构是从以前的作品中复制而来的。根据模型架构的变化,我们添加了一个解码器。

CNet                 DOT;
CNet                 Decoder;
CNet                 Actor;
CNet                 Critic;

如您所见,评论者也参与了模型训练过程。

在 EA 初始化方法中,我们首先加载训练数据。

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   ResetLastError();
   if(!LoadTotalBase())
     {
      PrintFormat("Error of load study data: %d", GetLastError());
      return INIT_FAILED;
     }

然后我们尝试加载预训练过的模型。如果我们无法加载模型,那么我们创建新模型,并使用随机参数初始化它们。

//--- load models
   float temp;
   if(!DOT.Load(FileName + "DOT.nnw", temp, temp, temp, dtStudied, true) ||
      !Decoder.Load(FileName + "Dec.nnw", temp, temp, temp, dtStudied, true) ||
      !Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic.Load(FileName + "Crt.nnw", temp, temp, temp, dtStudied, true)
     )
     {
      CArrayObj *dot = new CArrayObj();
      CArrayObj *decoder = new CArrayObj();
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      if(!CreateDescriptions(dot, decoder, actor, critic))
        {
         delete dot;
         delete decoder;
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      if(!DOT.Create(dot) ||
         !Decoder.Create(decoder) ||
         !Actor.Create(actor) ||
         !Critic.Create(critic))
        {
         delete dot;
         delete decoder;
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      delete dot;
      delete decoder;
      delete actor;
      delete critic;
     }

然后,我们将所有模型传送到一个 OpenCL 关联环境当中。

   OpenCL = DOT.GetOpenCL();
   Decoder.SetOpenCL(OpenCL);
   Actor.SetOpenCL(OpenCL);
   Critic.SetOpenCL(OpenCL);

我们对模型架构的合规性实现最低限度的控制。

   Actor.getResults(Result);
   if(Result.Total() != NActions)
     {
      PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", NActions, Result.Total());
      return INIT_FAILED;
     }
//---
   DOT.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Encoder doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr));
      return INIT_FAILED;
     }

我们创建辅助数据缓冲区。

   if(!bGradient.BufferInit(MathMax(AccountDescr, NForecast), 0) ||
      !bGradient.BufferCreate(OpenCL))
     {
      PrintFormat("Error of create buffers: %d", GetLastError());
      return INIT_FAILED;
     }

生成自定义事件,以便启动学习过程。

   if(!EventChartCustom(ChartID(), 1, 0, 0, "Init"))
     {
      PrintFormat("Error of create study event: %d", GetLastError());
      return INIT_FAILED;
     }
//---
   return(INIT_SUCCEEDED);
  }

在 EA 逆初始化方法中,我们保存经过训练的模型,并清除动态对象占用的内存。

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//---
   if(!(reason == REASON_INITFAILED || reason == REASON_RECOMPILE))
     {
      Actor.Save(FileName + "Act.nnw", 0, 0, 0, TimeCurrent(), true);
      DOT.Save(FileName + "DOT.nnw", 0, 0, 0, TimeCurrent(), true);
      Decoder.Save(FileName + "Dec.nnw", 0, 0, 0, TimeCurrent(), true);
      Critic.Save(FileName + "Crt.nnw", 0, 0, 0, TimeCurrent(), true);
     }
   delete Result;
   delete OpenCL;
  }

训练模型的过程在 Train 方法中实现。在该方法的主体中,我们首先根据其盈利能力判定选择轨迹的概率。

//+------------------------------------------------------------------+
//| Train function                                                   |
//+------------------------------------------------------------------+
void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);

然后我们声明局部变量。

   vector<float> result, target;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

然后,我们为学习过程创建一个嵌套循环系统。

为了积累历史数据,编码器架构提供了带有一个内部缓冲区的嵌入层。这种架构解决方案对于所接收源数据的历史序列非常敏感。因此,为了训练模型,我们组织了一个嵌套循环系统。外部循环会计量训练批次的数量。在训练批次内的嵌套循环中,初始数据按历史时间顺序投喂。

在外部循环的主体中,我们对轨迹和状态进行采样,以便启动训练批次。

   for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++)
     {
      int tr = SampleTrajectory(probability);
      int batch = GPTBars + 48;
      int state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - PrecoderBars - batch));
      if(state <= 0)
        {
         iter--;
         continue;
        }

清除累积历史数据的内部缓冲区。

      DOT.Clear();

判定训练数据包结束的状态。

      int end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars);

然后我们组织一个嵌套的学习循环。在其主体中,我们首先从经验回放缓冲区加载环境状态的历史描述。

      for(int i = state; i < end; i++)
        {
         bState.AssignArray(Buffer[tr].States[i].state);

依据所供数据,我们通过编码器和解码器运行前馈验算。

         //--- Trajectory
         if(!DOT.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         if(!Decoder.feedForward((CNet*)GetPointer(DOT), LatentLayer, (CNet*)GetPointer(DOT)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

我们还从经验回放缓冲区加载帐户状态的相应描述,并将数据传送到相应的缓冲区

         //--- Policy
         float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
         float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
         bAccount.Clear();
         bAccount.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance);
         bAccount.Add(Buffer[tr].States[i].account[1] / PrevBalance);
         bAccount.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity);
         bAccount.Add(Buffer[tr].States[i].account[2]);
         bAccount.Add(Buffer[tr].States[i].account[3]);
         bAccount.Add(Buffer[tr].States[i].account[4] / PrevBalance);
         bAccount.Add(Buffer[tr].States[i].account[5] / PrevBalance);
         bAccount.Add(Buffer[tr].States[i].account[6] / PrevBalance);

添加时间戳谐波。

         double time = (double)Buffer[tr].States[i].account[7];
         double x = time / (double)(D'2024.01.01' - D'2023.01.01');
         bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = time / (double)PeriodSeconds(PERIOD_MN1);
         bAccount.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = time / (double)PeriodSeconds(PERIOD_W1);
         bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = time / (double)PeriodSeconds(PERIOD_D1);
         bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         if(bAccount.GetIndex() >= 0)
            bAccount.BufferWrite();

该过程完全重复与环境交互 EA 的过程。不过,我们不会轮询终端,而是从经验回放缓冲区加载所有数据。

收到数据之后,我们可以针对扮演者和评论者执行顺序前馈验算。

         //--- Actor
         if(!Actor.feedForward((CNet *)GetPointer(Decoder), -1, (CBufferFloat*)GetPointer(bAccount)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         //--- Critic
         if(!Critic.feedForward((CNet *)GetPointer(Decoder), -1, (CNet*)GetPointer(Actor)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

前馈验算后随反向传播验算,在此期间,模型参数将得到优化。首先,我们将执行扮演的反向传播验算,将来自经验回放缓冲区中的动作误差最小化。

         Result.AssignArray(Buffer[tr].States[i].action);
         if(!Actor.backProp(Result, (CBufferFloat *)GetPointer(bAccount), (CBufferFloat *)GetPointer(bGradient)) ||

来自扮演者的误差梯度被传送到解码器。

            !Decoder.backPropGradient((CNet *)GetPointer(DOT), -1, -1, false) ||

反过来,解码器将误差梯度传送到编码器。请注意,解码器的初始数据取自编码器的 2 层,并将误差梯度传送到 2 个相应的层。为了正确更新模型参数,我们需要首先从潜在层传播梯度。

            !DOT.backPropGradient((CBufferFloat*)NULL, (CBufferFloat*)NULL, LatentLayer) ||

只有这样 — 通过整个编码器模型。

            !DOT.backPropGradient((CBufferFloat*)NULL)
           )
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

接下来,我们判定即将到来的过渡奖励。

         result.Assign(Buffer[tr].States[i + 1].rewards);
         target.Assign(Buffer[tr].States[i + 2].rewards);
         result = result - target * DiscFactor;
         Result.AssignArray(result);

我们优化了评论者参数,随后将误差梯度传送到所有参与模型。

         if(!Critic.backProp(Result, (CNet *)GetPointer(Actor)) ||
            !Decoder.backPropGradient((CNet *)GetPointer(DOT), -1, -1, false) ||
            !DOT.backPropGradient((CBufferFloat*)NULL, (CBufferFloat*)NULL, LatentLayer) ||
            !DOT.backPropGradient((CBufferFloat*)NULL) ||
            !Actor.backPropGradient((CBufferFloat *)GetPointer(bAccount), (CBufferFloat *)GetPointer(bGradient), -1, false) ||
            !Decoder.backPropGradient((CNet *)GetPointer(DOT), -1, -1, false) ||
            !DOT.backPropGradient((CBufferFloat*)NULL, (CBufferFloat*)NULL, LatentLayer) ||
            !DOT.backPropGradient((CBufferFloat*)NULL)
           )
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

在循环系统内的操作结束时,我们通知用户训练进度,并转到下一次迭代。

         if(GetTickCount() - ticks > 500)
           {
            double percent = (double(i - state) / ((end - state)) + iter) * 100.0 / (Iterations);
            string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Actor", percent, Actor.getRecentAverageError());
            str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Critic", percent, Critic.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

模型训练循环系统的所有迭代成功完成后,我们清除图表上的注释段。

   Comment("");

我们还将模型训练结果打印到日志中,并启动 EA 终止。

   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic", Critic.getRecentAverageError());
   ExpertRemove();
//---
  }

所用程序的算法描述到此结束。该 EA 的完整代码附在文后。现在我们转到本文的最后一部分,我们将在其中测试算法。


3. 测试

在本文中,我们领略了特征聚合查询方法,并利用 MQL5 实现了它的方法。现在是时候评估我们所做工作的成果了。如常,我在 H1 时间帧下使用 EURUSD 金融产品的历史数据训练和测试了我的模型。这些模型依据 2023 年前 7 个月的历史时期进行训练。为了测试已训练过的模型,我们采用 2023 年 8 月的历史数据。

本文中讨论的模型所分析的输入数据与之前文章中的模型类似。扮演者动作的向量,和已完成新状态转换的奖励,也与之前的文章相同。因此,为了训练模型,我们可取之前文章中训练模型时已收集的经验回放缓冲区。为此,我们将文件重命名为 “FAQ.bd”。

不过,如果您还没有来自以前工作的文件,或者由于某些原因想要创建一个新文件,我建议首先使用真实信号的交易历史记录保存少量验算。在 RealORL 方法的文章中已进行了讲述。

然后,您可以使用 EA “...\Experts\FAQ\Research.mq5” 以随机验算来补充经验回放缓冲区。为此,在 MetaTrader 5 策略测试器中,依据训练期间的历史数据运行该 EA 的慢速优化。

您可以使用任何指标参数。不过,请确保在收集训练数据集和测试训练模型时采用相同的参数。此外,保存模型操作的参数。在准备本文时,对于所有指标我采用了默认设置。

为了监管所收集验算的数量,我采用优化的扮演者参数。该参数添加到 EA 中只是为了规范优化验算,且未在 EA 代码中使用。

收集训练数据后,我们在图表上实时运行 EA “...\Experts\FAQ\Study.mq5”。EA 使用所收集训练数据集训练模型,并未执行交易操作。因此,在真实图表上的 EA 操作不会影响您的账户余额。

典型情况,我采用迭代方法来训练模型。在该过程期间,我交替使用训练模型为训练集合收集附加数据。依这种方法,我们的训练数据集合的大小是有限的,并且无法覆盖环境中的所有个体行为。在 EA “...\Experts\FAQ\Research.mq5” 的下一次启动期间,在与环境交互的过程中,它不再受随机政策的指导。而是由我们的训练政策替代。因此,我们用接近我们政策的状态和动作来补充经验回放缓冲区。通过这样做,我们围绕我们的政策探索了环境,类似于在线学习过程。这意味着在后续训练中,我们会收到真正的动作奖励,替代那个插值。这将帮助我们的扮演者朝着正确的方向调整政策。

同时,我们会定期监控依据训练数据集合中未包含的数据训练结果。

在训练过程期间,我设法获得了一个能够在训练和测试数据集上均产生盈利的模型。在测试已训练模型时,在 2023 年 8 月期间,EA 执行了 87 笔交易,其中 45 笔以盈利了结。这等于 51.72%。最高和平均盈利交易的利润超过亏损交易的相应值。在测试期间,EA 的盈利因子达到了 1.61,恢复因子达到了 1.65。


结束语

在本文中,我们领略了检测视频中对象的特征聚合查询(FAQ)方法。该方法的作者专注于初始化查询,并基于变换器架构的检测器输入数据聚合查询,从而平衡模型的效率和性能。他们开发了一个查询聚合模块,将它们的表示扩展到对象检测器。这可以提高它们在视频任务上的性能。

此外,FAQ 方法的作者将查询聚合模块扩展为动态版本,可以自适应地生成查询初始化,并根据源数据调整查询聚合权重。

所提议方法是即插即用的模块,可以集成到大多数现代基于变换器的对象检测器之中,以便解决视频和其它时间序列中的问题。

在本文的实践部分,我们利用 MQL5 实现了所提议方法。我们依据真实历史数据训练了模型,并在训练集合之外的时间段对其进行了测试。我们的测试结果确认了所提议方法的有效性。不过,训练和测试区间很短,无法得出任何具体结论。本文中表述的所有程序仅用于演示和测试所提议方法。


参考

  • FAQ:基于变换器的视频对象检测器的特征聚合查询
  • 本系列的其它文章

  • 文中所用程序

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

    附加的文件 |
    MQL5.zip (972.89 KB)
    如何开发各种类型的追踪止损并将其加入到EA中 如何开发各种类型的追踪止损并将其加入到EA中
    在本文中,我们将探讨用于便捷创建各种追踪止损的类,并学习如何将追踪止损加入到EA中。
    矩阵分解:更实用的建模 矩阵分解:更实用的建模
    您可能没有注意到,矩阵建模有点奇怪,因为只指定了列,而不是行和列。在阅读执行矩阵分解的代码时,这看起来非常奇怪。如果您希望看到列出的行和列,那么在尝试分解时可能会感到困惑。此外,这种矩阵建模方法并不是最好的。这是因为当我们以这种方式对矩阵建模时,会遇到一些限制,迫使我们使用其他方法或函数,而如果以更合适的方式建模,这些方法或函数是不必要的。
    带有预测性的三角套利 带有预测性的三角套利
    本文简化了三角套利的过程,向您展示如何利用预测和专业软件更明智地进行货币交易,即使您是新手也能轻松入门。准备好凭借专业知识进行交易了吗?
    彗星尾算法(CTA) 彗星尾算法(CTA)
    在这篇文章中,我们将探讨彗星尾优化算法(CTA),该算法从独特的太空物体——彗星及其接近太阳时形成的壮观尾部中汲取灵感。该算法基于彗星及其尾部运动的概念设计而成,旨在寻找优化问题中的最优解。