English Русский Español Deutsch 日本語 Português
preview
神经网络变得简单(第 80 部分):图形变换器生成式对抗模型(GTGAN)

神经网络变得简单(第 80 部分):图形变换器生成式对抗模型(GTGAN)

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

概述

分析环境的初始状态,通常依靠运用卷积层或各种关注度机制的模型。然而,由于固有的归纳偏差,对于原始数据中长期依赖关系卷积架构缺乏理解。基于关注度的架构允许您针对长期或全局关系进行编码,并学习函数的高度表达形式。另一方面,图形卷积模型很好地利用了基于图形拓扑的局部和连接顶点相关性。因此,尝试将图形卷积网络和变换器结合起来是有意义的,其可针对局部和全局交互进行建模,以便实现搜索最优交易策略。

最近发表的论文《架构布局生成 — 配备掩模式图形建模的图形变换器 GANs》讲述了图形变换器生成式架构模型(GTGAN)的算法,该算法简洁地结合了这两种方式。GTGAN 算法的作者解决了依据输入图形来创建逼真的房屋架构设计问题。他们提出的生成器模型由三个组件组成:消息传递卷积神经网络(Conv-MPN)、图形变换器编码器(GTE)、和生成头。

按论文中所述,依据三个数据集合生成的三个复杂图形约束架构布局,对其进行定性和定量实验后,表明所提议方法能够生成优于以前所述算法的结果。

1. GTGAN 算法

为了描述该方法,我们以创建房屋布局为例。生成器 G 接收每个房间的噪声向量和气泡图作为输入。然后,它会生成一个房屋布局,其中每个房间都表示为一个轴对齐的矩形。该方法的作者将每个气泡图表示为图形,其中每个节点表示某种类型的房间,每条边表示房间的空间邻接。具体来说,它们为每间房生成一个矩形。两间房具有图形边缘则应在空间上相邻,而两间房没有图形边缘则应在空间上不相邻。

给定一个气泡示意图,它们首先为每间房生成一个节点,并用从正态分布中采样的 128-维噪声向量对其进行初始化。然后,它们将噪声向量与一个 10-维独热房间类型向量(tr)相结合。因此,它们可以获得一个 138-维向量 gr 来表示原始气泡示意图。

注意,在这种情况下,图形节点用作所提议变换器的输入数据。

卷积消息传递模块 Conv-MPN 表示输出设计空间中的 3D 张量。它们应用一般的线层将 gr 扩展到大小为 16×8×8 的特征体积 gr,l=1,其中 l=1 是从第一个 Conv-MPN 层中提取的对象。它将使用转置卷积进行两次上采样,从而变成大小为 16x32x32 的对象 gr,l=3

Conv-MPN 层通过传递卷积消息来更新特征图形。具体上,它们更新 gr,l=1 时涵盖以下步骤:

  1. 它们使用一个 GTE 来捕获输入图形中连接房间的长期相关性;
  2. 使用另一个 GTE 来捕获输入图形中非连接房间的长期依赖关系;
  3. 它们组合了输入图形中跨连接房间函数; 
  4. 组合跨不相关房间函数 
  5. 我们依据组合特征应用卷积模块(CNN)。

该过程可以公式化如下:

其中 N(r) 表示连接和不连接房间集合;“+” 和 “;” 分别表示像素级加法和通道级连。

为了反映图形节点之间的局部和全局关系,该方法的作者提议一种新的 GTE 编码器。GTE 结合了变换器自关注图形卷积模型,分别捕获全局和局部相关性。请注意,GTGAN 不使用位置嵌入,因为该任务的目标是指示所生成房屋布局中节点的位置。

GTGAN 将多头自关注扩展为多头节点关注,旨在捕获连接房间/节点之间的全局相关性,和未连接房间/节点之间的全局依赖关系。为此目的,该方法的作者提议两个新的图形节点关注度模块,即:连接节点关注度(CNA),和非连接节点关注度(NNA)。两个模块都具有相同的网络架构。

CNA 的目标是针对跨连接房间之间的全局相关性进行建模。AttN(r) 测量节点对其它连接节点的影响。然后,它们用转置的AttN(r) 执行矩阵乘法 gr,l。之后,它们将结果乘以缩放参数 ɑ

其中 ɑ 是可学习的参数。

N(r) 中的每个连接节点都表示所有连接节点的加权和。因此,CNA 获得了空间图形结构的全局视图,并能够根据连接关注度映射选择性地调整房间,从而提高房屋布局表示,和高级语义一致性。

类似地,NNA 旨在捕获非连接房间中的全局关系。它使用其可学习参数 ß

最后,它们执行元素级求和 gr,l,如此更新的节点特征能够捕获连接和非连接两者的空间关系。

虽然 CNANNA 对于提取长期和全局依赖关系很实用,但它们在复杂的房屋数据结构中捕获精细局部信息时效果较差。为了修复这一限制,该方法的作者提出了一个新的图形建模模块。

具体而言,给定特征 gr,l,在上述方程中生成,它们使用卷积图形网络进一步改善了局部相关性。

其中 A 表示图形的邻接矩阵,G.C.(•) 表示图形的卷积,且 P 表示可学习参数。σ 是线性高斯误差单位(GeLU)。

提供有关全局图形中节点关系的信息有助于创建更准确的房屋布局。为了区分这个过程,该方法的作者提出了一种基于邻接矩阵的新损失函数,该矩阵对应于真实值和所生成图形之间的空间关系。准确地说,这些图形捕捉了不同房间中每个节点之间的邻接关系,然后通过提出的环路一致性损失函数来确保真实值与所生成图形之间的对应关系。这个损失函数旨在准确地维护节点之间的相互关系。一方面,非重叠部分必须预测为非重叠部分。另一方面,相邻节点必须预测为邻居,并与邻近系数相对应。

作者的 GTGAN 可视化如下所示。

2. 利用 MQL5 实现

在研究了 GTGAN 方法的理论层面之后,我们转到本文的实践部分,其中我们利用 MQL5 实现所提议方法。

不过,请注意该方法作者解决的问题与我们解决的问题之间的差异。我们的目标不是生成价格走势图表。我们的目标是为个体找到最优行为策略。在模型的输出中,我们打算获得个体在环境的特定状态下的最优动作。初看,我们的任务截然不同。

但如果您仔细研究 GTGAN 方法,那么您会看到方法作者主要专注于编码器(GTE)。它们更多关注于编码器架构及其训练。

该方法的作者提议针对编码器进行初步训练,且为两类节点随机掩模。他们提议为多达 40% 的原始数据掩模,在相邻连接中给每个节点和边线留下潜在的间隙。为了恢复缺失的数据,每个节点和边线嵌入都必须吸取和解释其局部境况。这就是,每项投资都必须理解其直接环境的具体细节。提议的高比率随机掩模和随后重构的方式克服了预测子级图形的大小和形状所带来的限制。结果就是,鼓励节点和边线嵌入来理解局部境况细节。

此外,当去除系数高的节点或边线时,可以将剩余的节点和边线视为一组子级图形,其任务是预测整体图形。与其它自我预训练任务相比,这样能表示更复杂的每图预测任务,其它自我预训练任务通常使用较小的图形或境况作为预测目标来捕获全局图形细节。拟议的掩模和图形重构的“强化”预训练任务,为学习高级节点-边线嵌入提供了更广阔的视角,这些嵌入能够在单个节点/边线级别、及整个图形级别上都能捕获复杂的细节。

所拟议系统中的编码器充当桥梁,将可见的、未掩模的节点和边线的原始属性转换为它们在潜在特征空间中的相应嵌入。这个过程包括编码器的节点和边线层面,其中包括拟议的图形建模和多头节点关注度机制。这些函数是本着变换器架构的精髓设计的,该技术因其针对顺序数据有效建模的能力而广为所知。该模块有助于创建健壮的表示形式,即在图形内封装整体关系动态。

因之,我们能够使用提议的编码器来研究源数据中的局部和全局依赖关系。我们将在名为 CNeuronGTE 的新类中实现提议的编码器算法。

2.1GTE 编码器类

GTE 编码器类 CNeuronGTE 将 继承自我们的神经层基类 CNeuronBaseOCL。提议的编码器结构与之前研究的变换器选项明显不同。因此,尽管之前创建了大量采用关注度机制的神经层,但我们决定放弃自其中之一继承。尽管在工作过程中,我们将使用以前创建的开发。

新类的结构如下所示。 

class CNeuronGTE : public CNeuronBaseOCL
  {
protected:
   uint              iHeads;                                      ///< Number of heads
   uint              iWindow;                                     ///< Input window size
   uint              iUnits;                                      ///< Number of units
   uint              iWindowKey;                                  ///< Size of Key/Query window
   //---
   CNeuronConvOCL    cQKV;
   CNeuronSoftMaxOCL cSoftMax;
   int               ScoreIndex;
   CNeuronBaseOCL    cMHAttentionOut;
   CNeuronConvOCL    cW0;
   CNeuronBaseOCL    cAttentionOut;
   CNeuronCGConvOCL  cGraphConv[2];
   CNeuronConvOCL    cFF[2];
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   virtual bool      AttentionOut(void);
   //---
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);
   virtual bool      AttentionInsideGradients(void);

public:
                     CNeuronGTE(void) {};
                    ~CNeuronGTE(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key, uint heads,
                          uint units_count,
                          ENUM_OPTIMIZATION optimization_type,
                          uint batch);
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer);
   //---
   virtual int       Type(void)   const   {  return defNeuronGTE;   }
   //--- methods for working with files
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   virtual CLayerDescription* GetLayerInfo(void);
   virtual void      SetOpenCL(COpenCLMy *obj);
   virtual void      TrainMode(bool flag);                ///< Set Training Mode Flag
  };

您可以在此处看到已经熟悉的局部变量:

  • iHeads;
  • iWindow;
  • iUnits;
  • iWindowKey. 

它们的功能目的保持不变。在实现方法时,我们将领略内部层的用途。

我们将所有内部对象声明为静态,这允许我们将类的构造函数和析构函数留空。请注意,在类构造函数中,我们甚至没有为局部变量指定数值。

如常,类的完整初始化是在 Init 方法中执行。在该方法的参数中,我们会收到创建正确类架构所需的所有信息。在方法的主体中,我们调用父类的相关方法,其中实现针对接收到的初始参数和继承对象的初始化的最小必要控制。

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

成功执行父类方法之后,我们将接收到的数据保存到局部变量之中。

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

然后初始化添加的对象。首先,我们初始化内部卷积层 cQKV。在该层中,我们计划在并行线程中生成所有 3 个实体(Query、KeyValue)的表示形式。源数据窗口及其步骤的大小等于一个序列元素的描述大小。卷积滤波器的数量等于序列内一个元素的一个实体的描述向量大小的乘积,再乘以关注度头的数量,再乘以 3(实体的数量)。元素的数量等于所分析序列的大小。

   if(!cQKV.Init(0, 0, OpenCL, iWindow, iWindow, iWindowKey * 3 * iHeads, iUnits, optimization, iBatch))
      return false;

为了提高模块的稳定性,我们调用 SoftMax 层对生成的实体进行常规化。 

   if(!cSoftMax.Init(0, 1, OpenCL, iWindowKey * 3 * iHeads * iUnits, optimization, iBatch))
      return false;
   cSoftMax.SetHeads(3 * iHeads * iUnits);

下一步是在 OpenCL 关联环境中创建一个依赖系数缓冲区。它的大小比平常大 2 倍 — 这是分别记录连接和未连接顶点的系数所必需的。

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

我们将多头关注度的结果保存在局部层 cMHAttentionOut 之中。

   if(!cMHAttentionOut.Init(0, 2, OpenCL, iWindowKey * 2 * iHeads * iUnits, optimization, iBatch))
      return false;

请注意,多头关注度结果层的大小也比前面讨论的变换器实现的类似层大 2 倍。这也可以启用从连接和未连接的顶点写入数据来完成。

此外,以这种方式,无需实现单独的功能来训练缩放参数 ɑß。取而代之,我们将使用 W0 层的功能。在这种情况下,它将结合关注度头,以及连接和未连接顶点的影响。

   if(!cW0.Init(0, 3, OpenCL, 2 * iWindowKey * iHeads, 2 * iWindowKey* iHeads, iWindow, iUnits,
                                                                             optimization, iBatch))
      return false;

在关注度模块之后,我们需要添加包含原始数据的结果,并对结果进行常规化。结果值将被写入 cAttentionOut 层。

   if(!cAttentionOut.Init(0, 4, OpenCL, iWindow * iUnits, optimization, iBatch))
      return false;

接下来的 2 层,每层 2 个模块。其中包括图形卷积和前馈模块。我们在循环中初始化指定模块的对象。

   for(int i = 0; i < 2; i++)
     {
      if(!cGraphConv[i].Init(0, 5 + i, OpenCL, iWindow, iUnits, optimization, iBatch))
         return false;
      if(!cFF[i].Init(0, 7 + i, OpenCL, (i == 0 ? iWindow : 4 * iWindow),
                      (i == 0 ? iWindow : 4 * iWindow),
                      (i == 1 ? iWindow : 4 * iWindow),
                      iUnits, optimization, iBatch))
         return false;
     }

最后,我们替换误差梯度缓冲区。

   if(cFF[1].getGradient() != Gradient)
     {
      if(!!Gradient)
         delete Gradient;
      Gradient = cFF[1].getGradient();
     }
//---
   return true;
  }

这样就完成了该方法。

类经初始化后,我们继续组织类的前馈验算算法。此处我们从 OpenCL 程序开始,在其中我们必须创建一个新的内核 GTEFeedForward。在这个内核之内,我们将分析已连接和未连接节点的依赖关系。在 GTGAN 方法的方法中,在 GTEFeedForward 内核主体中,我们实现了 CNANNA 的功能。

但在转入实现之前,我们判定哪些节点应该被视为连接节点,哪些节点不应被视为连接节点。您需要知道的第一件事就是,在我们实现中的节点是一根柱线参数的描述。我们正在与时间序列分析打交道。因此,我们只能直接连接 2 根邻接的柱线。因此,对于柱线 Xt,仅有柱线 Xt-1Xt+1 与其连接。柱线 Xt-1 和 Xt+1 没有连接,因为它们之间隔着柱线 Xt

现在我们可以转到实现。在参数中,内核接收指向数据交换缓冲区的指针。

__kernel void GTEFeedForward(__global float *qkv,
                             __global float *score,
                             __global float *out,
                             int dimension)
  {
   const size_t cur_q = get_global_id(0);
   const size_t units_q = get_global_size(0);
   const size_t cur_k = get_local_id(1);
   const size_t units_k = get_local_size(1);
   const size_t h = get_global_id(2);
   const size_t heads = get_global_size(2);

在内核主体中,我们识别任务空间中的线程。在这种情况下,我们正在与一个 3-维任务空间打交道,其中一个任务被组合到一个局部组。

下一步是判定数据缓冲区中的混合物。

   int shift_q = dimension * (cur_q + h * units_q);
   int shift_k = (cur_k + h * units_k + heads * units_q);
   int shift_v = dimension * (h * units_k + heads * (units_q + units_k));
   int shift_score_con = units_k * (cur_q * 2 * heads + h) + cur_k;
   int shift_score_notcon = units_k * (cur_q * 2 * heads + heads + h) + cur_k;
   int shift_out_con = dimension * (cur_q + h * units_q);
   int shift_out_notcon = dimension * (cur_q + units_q * (h + heads));

在此,我们将声明一个 2-维局部数组。第二个维度有 2 个元素,分别是已连接和未连接节点。

   const uint ls_score = min((uint)units_k, (uint)LOCAL_ARRAY_SIZE);
   __local float local_score[LOCAL_ARRAY_SIZE][2];

下一步是判定依赖系数。首先,我们将相应的 QueryKey 张量相乘。将其除以维度的根,然后取指数值。

//--- Score
   float scr = 0;
   for(int d = 0; d < dimension; d ++)
      scr += qkv[shift_q + d] * qkv[shift_k + d];
   scr = exp(min(scr / sqrt((float)dimension), 30.0f));

然后,我们判定所分析序列元素是否连接,并将结果保存到所需的缓冲区元素。

   if(cur_q == cur_k)
     {
      score[shift_score_con] = scr;
      score[shift_score_notcon] = scr;
      if(cur_k < ls_score)
        {
         local_score[cur_k][0] = scr;
         local_score[cur_k][1] = scr;
        }
     }
   else
     {
      if(abs(cur_q - cur_k) == 1)
        {
         score[shift_score_con] = scr;
         score[shift_score_notcon] = 0;
         if(cur_k < ls_score)
           {
            local_score[cur_k][0] = scr;
            local_score[cur_k][1] = 0;
           }
        }
      else
        {
         score[shift_score_con] = 0;
         score[shift_score_notcon] = scr;
         if(cur_k < ls_score)
           {
            local_score[cur_k][0] = 0;
            local_score[cur_k][1] = scr;
           }
        }
     }
   barrier(CLK_LOCAL_MEM_FENCE);

现在我们可以找到序列中每个元素的系数和。

   for(int k = ls_score; k < units_k; k += ls_score)
     {
      if((cur_k + k) < units_k)
        {
         local_score[cur_k][0] += score[shift_score_con + k];
         local_score[cur_k][1] += score[shift_score_notcon + k];
        }
     }
   barrier(CLK_LOCAL_MEM_FENCE);
//---
   int count = ls_score;
   do
     {
      count = (count + 1) / 2;
      if(cur_k < count)
        {
         if((cur_k + count) < units_k)
           {
            local_score[cur_k][0] += local_score[cur_k + count][0];
            local_score[cur_k][1] += local_score[cur_k + count][1];
            local_score[cur_k + count][0] = 0;
            local_score[cur_k + count][1] = 0;
           }
        }
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
   barrier(CLK_LOCAL_MEM_FENCE);

然后,我们将序列中每个元素的依赖系数之和化为 1。为此,只需将每个元素的值除以相应的总和即可。

   score[shift_score_con] /= local_score[0][0];
   score[shift_score_notcon] /= local_score[0][1];
   barrier(CLK_LOCAL_MEM_FENCE);

一旦依赖系数找到后,我们就可以判定连接节点和非连接节点的影响。

   shift_score_con -= cur_k;
   shift_score_notcon -= cur_k;
   for(int d = 0; d < dimension; d += ls_score)
     {
      if((cur_k + d) < dimension)
        {
         float sum_con = 0;
         float sum_notcon = 0;
         for(int v = 0; v < units_k; v++)
           {
            sum_con += qkv[shift_v + v * dimension + cur_k + d] * score[shift_score_con + v];
            sum_notcon += qkv[shift_v + v * dimension + cur_k + d] * score[shift_score_notcon + v];
           }
         out[shift_out_con + cur_k + d] = sum_con;
         out[shift_out_notcon + cur_k + d] = sum_notcon;
        }
     }
  }

所有迭代成功完成之后,我们完成内核操作,并返回到主程序的工作。此处我们首先创建 AttentionOut 方法来调用上面创建的内核。这个的方法将从同一类的另一个方法中调用。它仅依据内部对象工作,且不包含参数。

在方法的主体中,我们首先检查指向类对象的指针相关性,以便配合 OpenCL 关联环境工作。

bool CNeuronGTE::AttentionOut(void)
  {
   if(!OpenCL)
      return false;

然后我们判定任务空间和工作组的大小。在这种情况下,我们使用一个 3-维任务空间,分为 1-维工作组。

   uint global_work_offset[3] = {0};
   uint global_work_size[3] = {iUnits/*Q units*/, iUnits/*K units*/, iHeads};
   uint local_work_size[3] = {1, iUnits, 1};

然后我们将必要的参数传递到内核。

   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_GTEFeedForward, def_k_gteff_qkv, cQKV.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_GTEFeedForward, def_k_gteff_score, ScoreIndex))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_GTEFeedForward, def_k_gteff_out, cAttentionOut.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_GTEFeedForward, def_k_gteff_dimension, (int)iWindowKey))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

把内核放入执行队列当中。

   if(!OpenCL.Execute(def_k_GTEFeedForward, 3, global_work_offset, global_work_size, local_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }
//---
   return true;
  }

不要忘记在每个步骤控制操作。在方法完成后,我们返回方法结果的逻辑值,这将允许我们控制调用程序中的进程。

准备工作完成后,我们将创建一个 CNeuro.nGTE::feedForward 类的顶级前馈验算方法。在该方法的参数中,与之前讨论的其它类中的相关方法类似,我们接收到指向前一层对象的指针,该对象的缓冲区包含方法操作的初始数据。

bool CNeuronGTE::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cQKV.FeedForward(NeuronOCL))
      return false;

不过,在方法的主体中,我们不检查接收到的指针的相关性,但会立即调用类似的前馈方法为对象形成 Query、KeyValue 实体。所有必要的控件都已在被调用方法的主体中实现。在形成实体成功之后,我们可以判断调用方法返回的结果,我们在 SoftMax 层中常规化接收到的数据。

   if(!cSoftMax.FeedForward(GetPointer(cQKV)))
      return false;

接下来,我们使用上面创建的 AttentionOut 方法,并判定连接和非连接顶点的影响。

   if(!AttentionOut())
      return false;

我们将多头关注度的结果维度缩小到原始数据的张量值。

   if(!cW0.FeedForward(GetPointer(cMHAttentionOut)))
      return false;

然后我们添加并常规化数据。

   if(!SumAndNormilize(NeuronOCL.getOutput(), cW0.getOutput(), cAttentionOut.getOutput(), iWindow, true))
      return false;

在这个阶段,我们已经完成了多头关注度模块,并正在转向图形卷积模块 GC。此处我们使用 CrystalGraph 卷积网络的 2 层。为了实现该功能,我们只需要按顺序调用它们的直接验方法。

   if(!cGraphConv[0].FeedForward(GetPointer(cAttentionOut)))
      return false;
   if(!cGraphConv[1].FeedForward(GetPointer(cGraphConv[0])))
      return false;

接着来到 FeedForward 模块。

   if(!cFF[0].FeedForward(GetPointer(cGraphConv[1])))
      return false;
   if(!cFF[1].FeedForward(GetPointer(cFF[0])))
      return false;

在方法结束时,我们再次添加并常规化结果。

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

实现前馈验算后,我们转到组织反向验算过程。同样,我们首先在 OpenCL 程序端创建一个新的内核 GTEInsideGradients。在参数中,内核接收指向操作所需的数据缓冲区的指针。我们从任务空间获取所有维度。

__kernel void GTEInsideGradients(__global float *qkv, __global float *qkv_g,
                                 __global float *scores,
                                 __global float *gradient)
  {
//--- init
   const uint u = get_global_id(0);
   const uint d = get_global_id(1);
   const uint h = get_global_id(2);
   const uint units = get_global_size(0);
   const uint dimension = get_global_size(1);
   const uint heads = get_global_size(2);

类似于前馈验算内核,我们将在 3-维任务空间中运行该内核。不过,这次我们不会组织工作组。在内核的主体中,我们从各个维度识别任务空间中的当前线程。

我们内核的算法可以分为 3 个模块:

  • 数值梯度
  • 查询梯度
  • 键值渐变

与前馈验算相比,我们以相反的顺序组织反向传播验算。故此,首先我们定义 Value 实体的误差梯度。在这个模块中,我们首先判定数据缓冲区中的偏移量。

//--- Calculating Value's gradients
     {
      int shift_out_con = dimension * h * units + d;
      int shift_out_notcon = dimension * units * (h + heads) + d;
      int shift_score_con = units * h + u;
      int shift_score_notcon = units * (heads + h) + u;
      int step_score = units * 2 * heads;
      int shift_v = dimension * (h * units + 2 * heads * units + u) + d;

然后,我们组织一个循环来收集已连接和非连接节点的误差梯度。结果保存在实体误差梯度 qkv_g 全局缓冲区的相应元素之中。

      float sum = 0;
      for(uint i = 0; i <= units; i ++)
        {
         sum += gradient[shift_out_con + i * dimension] * scores[shift_score_con + i * step_score];
         sum += gradient[shift_out_notcon + i * dimension] * scores[shift_score_notcon + i * step_score];
        }
      qkv_g[shift_v] = sum;
     }

在第二步中,我们计算 Query 实体的误差梯度。类似于第一个模块,我们首先计算数据缓冲区中的偏移量。

//--- Calculating Query's gradients
     {
      int shift_q = dimension * (u + h * units) + d;
      int shift_out_con = dimension * (h * units + u) + d;
      int shift_out_notcon = dimension * (u + units * (h + heads)) + d;
      int shift_score_con = units * h;
      int shift_score_notcon = units * (heads + h);
      int shift_v = dimension * (h * units + 2 * heads * units);

不过,误差梯度的计算会稍微复杂一些。首先,我们需要判定误差梯度于依赖系数矩阵的级别,并调用 SoftMax 函数调整其导数。只有这样,我们才能将误差梯度转换到所需实体的级别。为此,我们需要创建一个嵌套循环系统。

      float grad = 0;
      for(int k = 0; k < units; k++)
        {
         int shift_k = (k + h * units + heads * units) + d;
         float sc_g = 0;
         float sc_con = scores[shift_score_con + k];
         float sc_notcon = scores[shift_score_notcon + k];
         for(int v = 0; v < units; v++)
            for(int dim = 0; dim < dimension; dim++)
              {
               sc_g += scores[shift_score_con + v] *
                       qkv[shift_v + v * dimension + dim] *
                       gradient[shift_out_con + dim] *
                       ((float)(k == v) - sc_con);
               sc_g += scores[shift_score_notcon + v] *
                       qkv[shift_v + v * dimension + dim] *
                       gradient[shift_out_notcon + dim] *
                       ((float)(k == v) - sc_notcon);
              }
         grad += sc_g * qkv[shift_k];
        }

在完成循环系统的所有迭代之后,我们将总误差梯度传送到全局数据缓冲区的相应元素。

      qkv_g[shift_q] = grad;
     }

在内核的最后一个模块中,我们定义了 Key 实体的误差梯度。在这种情况下,我们创建一个类似于前一个模块的算法。不过,在这种情况下,我们从依赖系数矩阵的另一个维度获取误差梯度。

//--- Calculating Key's gradients
     {
      int shift_k = (u + (h + heads) * units) + d;
      int shift_out_con = dimension * h * units + d;
      int shift_out_notcon = dimension * units * (h + heads) + d;
      int shift_score_con = units * h + u;
      int shift_score_notcon = units * (heads + h) + u;
      int step_score = units * 2 * heads;
      int shift_v = dimension * (h * units + 2 * heads * units);
      float grad = 0;
      for(int q = 0; q < units; q++)
        {
         int shift_q = dimension * (q + h * units) + d;
         float sc_g = 0;
         float sc_con = scores[shift_score_con + u + q * step_score];
         float sc_notcon = scores[shift_score_notcon + u + q * step_score];
         for(int g = 0; g < units; g++)
           {
            for(int dim = 0; dim < dimension; dim++)
              {
               sc_g += scores[shift_score_con + g] *
                       qkv[shift_v + u * dimension + dim] *
                       gradient[shift_out_con + g * dimension + dim] *
                       ((float)(u == g) - sc_con);
               sc_g += scores[shift_score_notcon + g] *
                       qkv[shift_v + u * dimension + dim] *
                       gradient[shift_out_notcon + g * dimension+ dim] *
                       ((float)(u == g) - sc_notcon);
              }
           }
         grad += sc_g * qkv[shift_q];
        }
      qkv_g[shift_k] = grad;
     }
  }

为了调用所述内核,我们将创建 CNeuronGTE::AttentionInsideGradients 方法。其构造算法类似于 CNeuronGTE::AttentionOut 方法。因此,我们不再详研。您若想研究它,我建议您在附件中找到本文中用到的所有程序的完整代码。

误差梯度分布的整个过程在 CNeuronGTE::calcInputGradients 方法中讲述。在参数中,该方法接收指向前一个神经层对象的指针,误差梯度应传递给该对象。

bool CNeuronGTE::calcInputGradients(CNeuronBaseOCL *prevLayer)
  {
   if(!cFF[1].calcInputGradients(GetPointer(cFF[0])))
      return false;

多亏了我们的方式,其已经被多次使用,随着数据缓冲区的替换,当研究后续神经层的反向传播方法时,我们将接收到的误差梯度直接放到 FeedForward 模块最后一层的缓冲区当中。因此,我们无需滥用复制数据。在反向传播方法中,我们立即开始把误差梯度传播到 FeedForward 模块的各层。

   if(!cFF[0].calcInputGradients(GetPointer(cGraphConv[1])))
      return false;

此后,我们同样经由图形卷积模块传播误差梯度。

   if(!cGraphConv[1].calcInputGradients(GetPointer(cGraphConv[0])))
      return false;
   if(!cGraphConv[1].calcInputGradients(GetPointer(cAttentionOut)))
      return false;

在该步骤中,我们将来自 2 个线程的误差梯度组合在一起。

   if(!SumAndNormilize(cAttentionOut.getGradient(), Gradient, cW0.getGradient(), iWindow, false))
      return false;

然后,我们跨关注度头分布误差梯度。

   if(!cW0.calcInputGradients(GetPointer(cMHAttentionOut)))
      return false;

并通过关注度模块传播它。

   if(!AttentionInsideGradients())
      return false;

所有 3 个实体(Query、Key、Value)的误差梯度都包含在 1 个串联缓冲区中,这允许我们一次并行处理所有实体。首先,我们将通过 SoftMax 函数的导数来调整误差梯度,我们用它来常规化数据。

   if(!cSoftMax.calcInputGradients(GetPointer(cQKV)))
      return false;

然后我们将误差梯度传播到前一层级。

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

此处,我们只需要添加第二个数据流的误差梯度。

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

完成该方法。
 
分布误差梯度后,我们所要做的就是更新模型参数,最小化误差。我们类的所有可学习参数都包含在内部对象之中。因此,为了调整参数,我们将依次调用内部对象的相应方法。

bool CNeuronGTE::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cQKV.UpdateInputWeights(NeuronOCL))
      return false;
   if(!cW0.UpdateInputWeights(GetPointer(cMHAttentionOut)))
      return false;
   if(!cGraphConv[0].UpdateInputWeights(GetPointer(cAttentionOut)))
      return false;
   if(!cGraphConv[1].UpdateInputWeights(GetPointer(cGraphConv[0])))
      return false;
   if(!cFF[0].UpdateInputWeights(GetPointer(cGraphConv[1])))
      return false;
   if(!cFF[1].UpdateInputWeights(GetPointer(cFF[0])))
      return false;
//---
   return true;
  }

CNeuronGTE 类的方法讲述到此结束。所有的类服务方法,包括文件操作方法,都可在附件中看到。如常,附件包含了准备文章时用到的所有程序的完整代码。

2.2模型架构

创建新类后,我们转到依据我们的模型工作。我们将创建它们的架构,并对其培训。根据 GTGAN 方法,我们需要对编码器进行预训练。因此,我们将创建 2 个方法来创建模型架构的描述。在第一种方法 CreateEncoderDescriptions 中,我们创建仅用于预训练的编码器和解码器架构的描述。

bool CreateEncoderDescriptions(CArrayObj *encoder, CArrayObj *decoder)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
   if(!decoder)
     {
      decoder = new CArrayObj();
      if(!decoder)
         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 = MathMax(1000, GPTBars);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

之后,我们创建最后一根柱线的嵌入,并将其添加到堆栈之中。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronEmbeddingOCL;
     {
      int temp[] = {prev_count};
      ArrayCopy(descr.windows, temp);
     }
   prev_count = descr.count = GPTBars;
   int prev_wout = descr.window_out = EmbeddingSize / 2;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

此处应注意的是,不同于以前的工作,此前嵌入是在一层中创建的,我们使用了来自 GTGAN 方法作者关于 Conv-MPN 消息传输模块的建议,并将创建嵌入的过程分为 2 个阶段。因此,嵌入层后随另一个卷积层,其会完成生成状态嵌入的工作。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = prev_count;
   descr.step = descr.window = prev_wout;
   prev_wout = descr.window_out = EmbeddingSize;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

接下来,我们将在预训练阶段的表示训练期间添加一个 DropOut 层来掩模数据。

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronDropoutOCL;
   descr.count = prev_count*prev_wout;
   descr.probability= 0.4f;
   descr.activation=None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

在下一步中,我们将略微偏离建议的算法,并添加位置编码。这是由于分配的任务存在显著差异。

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronPEOCL;
   descr.count = prev_count;
   descr.window = prev_wout;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

在此之后,我们将在循环中给新编码器添加 8 层。

//--- layer 6 - 14
   for(int i = 0; i < 8; i++)
     {
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronGTE;
      descr.count = prev_count;
      descr.window = prev_wout;
      descr.step  =  4;
      descr.window_out = prev_wout / descr.step;
      if(!encoder.Add(descr))
        {
         delete descr;
         return false;
        }
     }

解码器架构将明显缩短。我们将编码器的结果馈送到模型的输入。

//--- Decoder
   decoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = prev_count * prev_wout;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

我们通过卷积层传递它们。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count=prev_count;
   descr.window = prev_wout;
   descr.step=prev_wout;
   descr.window_out=EmbeddingSize/4;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

调用 SoftMax 进行常规化。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_wout;
   descr.step = prev_count;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

在解码器的输出端,我们创建一个全连接层,其元素数量等于嵌入层的结果。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = prev_count*EmbeddingSize/2;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

因此,我们据模型编译了一个非对称自动编码器,它经训练以便恢复嵌入层堆栈中的数据。嵌入层潜在状态的选择是经过深思熟虑的。在训练期间,我们希望将编码器的关注度集中在完整的历史数据集上,而不仅仅是最后一根烛条。

我们在 CreateDescriptions 方法中描述扮演者和评论者的架构。

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

在扮演者架构中,我还决定添加一点实验精神。我们向模型投喂账户当前状态的描述。

//--- Actor
   actor.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = AccountDescr;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

全连接层将为我们创建结果状态的某种嵌入。

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

接下来,我们添加一个由 3 个交叉关注度层组成的模块,在其中评估账户当前状态、及环境状态的依赖关系。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronCrossAttenOCL;
     {
      int temp[] = {prev_count,GPTBars};
      ArrayCopy(descr.units, temp);
     }
     {
      int temp[] = {EmbeddingSize, EmbeddingSize};
      ArrayCopy(descr.windows, temp);
     }
   descr.window_out = 16;
   descr.step = 4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronCrossAttenOCL;
     {
      int temp[] = {prev_count,GPTBars};
      ArrayCopy(descr.units, temp);
     }
     {
      int temp[] = {EmbeddingSize, EmbeddingSize};
      ArrayCopy(descr.windows, temp);
     }
   descr.window_out = 16;
   descr.step = 4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronCrossAttenOCL;
     {
      int temp[] = {prev_count,GPTBars};
      ArrayCopy(descr.units, temp);
     }
     {
      int temp[] = {EmbeddingSize, EmbeddingSize};
      ArrayCopy(descr.windows, temp);
     }
   descr.window_out = 16;
   descr.step = 4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

获得的结果由 2 个全连接层处理。

//--- layer 5
   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 6
   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 7
   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.type = defNeuronBaseOCL;
   prev_count=descr.count = GPTBars*EmbeddingSize;
   descr.activation = None;
   descr.optimization = ADAM;
  if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }

将扮演者动作添加到接收的数据当中。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type=defNeuronConcatenate;
   descr.window=prev_count;
   descr.step = NActions;
   descr.count=LatentCount;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!critic.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(!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.3所述学习智囊

创建模型架构之后,我们转到构建 EA 来训练它们。首先,我们将创建所述预训练 EA “...\Experts\GTGAN\StudyEncoder.mq5”。EA 的结构在很大程度上复制自以前的作品。并且为了减少文章的长度,我们将只专注于模型训练方法 Train

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

在方法的主体中,我们首先生成一个概率向量,基于其性能从经验回放缓冲区中选择验算。

接下来我们声明局部变量。

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

然后我们组织一个模型训练循环系统。在外循环的主体中,我们对轨迹和在其上学习的初始状态进行采样。

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

清除编码器缓冲区,并判定训练数据包的最终状态。

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

准备工作完成后,我们组织了一个嵌套循环直接训练模型。

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

此处,我们从经验回放缓冲区加载环境当前状态的描述,并调用编码器的前馈方法。

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

接下来是解码器的前馈验算。

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

在前馈验算之后,我们需要为模型定义训练目标。执行自动编码器的自学习,以便恢复原始数据。正如我们之前所讨论的,在视图模型训练期间,我们将使用来自嵌入层的隐藏状态。我们将该数据加载到局部缓冲区之中。

         Encoder.GetLayerOutput(LatentLayer,Result);

并将其作为目标值传递,从而优化我们模型的参数。

         if(!Decoder.backProp(Result,(CBufferFloat*)NULL) ||
            !Encoder.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", "Decoder", percent, 
                                                                Decoder.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

模型训练过程成功完成之后,我们清除图表上的注释字段。

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

将训练结果打印到日志中,并启动终止 EA 工作的过程。

在这个阶段,我们可以使用以前工作的训练数据集,并开始训练所述模型的过程。在模型训练时,我们转到创建扮演者政策训练 EA。

2.4扮演者政策训练 EA

为了训练扮演者的行为政策,我们将创建 EA “...\Experts\GTGAN\Study.mq5”。这里需要注意的是,在训练过程中,我们将使用 3 个模型,且只训练其中 2 个(扮演者和评论者)。编码器模型已在前一步中训练。

CNet                 Encoder;
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(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true))
      {
         Print("Can't load pretrained Encoder");
         return INIT_FAILED;
      }

但如果加载扮演者和/或评论者时出错,我们将创建新模型,并用随机参数初始化。

   if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic.Load(FileName + "Crt.nnw", temp, temp, temp, dtStudied, true)
     )
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      if(!CreateDescriptions(actor, critic))
        {
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) ||
         !Critic.Create(critic))
        {
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
     }

将所有模型传送到单一 OpenCL 关联环境之中。

   OpenCL = Encoder.GetOpenCL();
   Actor.SetOpenCL(OpenCL);
   Critic.SetOpenCL(OpenCL);

请务必关闭编码器训练模式。

   Encoder.TrainMode(false);

其架构使用 DropOut 层,该层会随机掩模数据。在操作模型时,我们需要禁用掩模,这是通过禁用模型的训练模式来完成的。

接下来,我们实现对模型架构的最低限度的必要控制。

   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;
     }
   Encoder.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);
  }

如常,训练模型的过程在 Train 方法中组织。

//+------------------------------------------------------------------+
//| Train function                                                   |
//+------------------------------------------------------------------+
void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);
//---
   vector<float> result, target;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

在方法的主体中,与之前的 EA 一样,我们首先生成一个概率向量,用于根据其盈利能力从经验回放缓冲区中选择轨迹。我们还初始化了局部变量。然后我们组织一个模型训练循环系统。

在外部循环的主体中,我们从经验回放缓冲区抽取轨迹,以及学习过程的开始状态。

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

我们清除编码器堆栈,并判定训练数据包的最后状态。

      Encoder.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(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
           {
            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();

接下来,我们运行扮演者前馈验算。

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

评论者前馈:

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

我们从经验回放缓冲区中获取这两个模型的目标值。首先,我们执行扮演者反向传播验算。

         Result.AssignArray(Buffer[tr].States[i].action);
         if(!Actor.backProp(Result, GetPointer(Encoder)))
           {
            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)) ||
            !Actor.backPropGradient(GetPointer(Encoder)))
           {
            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("");
//---
   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. 测试

在本文的前几节中,我们领略了新的 GTGAN 方法,并做了大量工作来利用 MQL5 实现所提议的方式。在本文的这一部分,我们像往常一样,在 MetaTrader 5 策略测试器中测试已完成的工作,并评估依据真实数据获得的结果。这些模型采用 EURUSD H1 的历史数据进行训练和测试。这包括依据 2023 年前 7 个月的历史数据进行模型训练。训练之后则是依据 2023 年 8 月的数据测试。

本文所创建模型据源数据工作,类似于之前文章中的模型。扮演者动作的向量,和已完成新状态转换的奖励,也与之前的文章相同。因此,为了训练模型,我们可以使用之前文章中模型训练过程中收集的经验回放缓冲区。只需将文件重命名为 “GTGAN.bd”。

这些模型分两个阶段进行训练。首先,我们训练编码器(所述模型)。然后我们训练扮演者的行为政策。必须说,将学习过程分为 2 个阶段是有积极影响的。模型训练十分快捷和稳定。

基于训练结果,我们可以说该模型很快就自经验回放缓冲区中学会了普适、并遵守动作政策。不幸的是,在我的经验回放缓冲区中并无太多积极的验算结果。故此,该模型从训练样本中学到的政策,只接近平均值,而这并没有给出积极的结果。我认为值得尝试依据积极的验算结果训练模型。


结束语

在本文中,我们讨论了 GTGAN 算法,该算法于 2024 年 1 月推出,用于解决复杂的架构问题。出于我们的目的,我们尝试借助编码器 GTE 中当前状态的综合分析方法,它简洁地结合了关注度方法和卷积图形模型的优势。

在本文的实践部分,我们利用 MQL5 实现了所提议方法,并在 MetaTrader 5 策略测试器中依据真实数据测试了结果模型。

测试结果表明,与所提议方法相关的工作还需要更多额外的工作。

 

参考

  • 架构布局生成 — 配备掩模式图形建模的图形变换器 GANs
  • 本系列的其它文章

  • 文中所用程序

    # 已发行 类型 说明
    1 Research.mq5 EA 样本收集 EA
    2 ResearchRealORL.mq5
    EA
    运用 Real-ORL 方法收集示例的 EA
    3 Study.mq5  EA 模型训练 EA
    4 StudyEncoder.mq5  EA 表述模型学习 EA
    4 Test.mq5 EA 模型测试 EA
    5 Trajectory.mqh 类库 系统状态定义结构
    6 NeuroNet.mqh 类库 创建神经网络的类库
    7 NeuroNet.cl 代码库 OpenCL 程序代码库


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

    附加的文件 |
    MQL5.zip (1008.2 KB)
    如何不通过翻找历史交易记录直接在图表上查看交易情况 如何不通过翻找历史交易记录直接在图表上查看交易情况
    在本文中,我们将创建一个简单的工具,通过按键导航方式方便地直接在图表上查看持仓和交易。这将使交易者能够直观地检查每笔交易,并当场获取有关交易结果的所有信息。
    开发回放系统(第 47 部分):Chart Trade 项目(六) 开发回放系统(第 47 部分):Chart Trade 项目(六)
    最后,我们的 Chart Trade 指标开始与 EA 互动,以交互方式传输信息。因此,在本文中,我们将对该指标进行改进,使其功能足以与任何 EA 配合使用。这样,我们就可以访问 Chart Trade 指标,并像实际连接 EA 一样使用它。不过,我们将以比以前更有趣的方式来实现这一目标。
    在MetaTrader 5中集成隐马尔可夫模型 在MetaTrader 5中集成隐马尔可夫模型
    在本文中,我们将展示如何将使用Python训练的隐马尔可夫模型(Hidden Markov Models, HMMs)集成到MetaTrader 5应用程序中。HMM是一种强大的统计工具,用于对时间序列数据进行建模,其中被建模的系统以不可观察(隐藏)的状态为特征。HMM的一个基本前提是,在特定时间处于给定状态的概率取决于该过程在前一个时间点的状态。
    在MQL5中相关性分析的要素:皮尔逊卡方独立性检验和相关比率 在MQL5中相关性分析的要素:皮尔逊卡方独立性检验和相关比率
    该文章探讨了相关性分析中的经典工具。文章重点介绍了皮尔逊卡方独立性检验和相关比率的理论背景概述,以及它们的实际应用。