English Русский Español Deutsch 日本語 Português
preview
神经网络变得轻松(第三十四部分):全部参数化的分位数函数

神经网络变得轻松(第三十四部分):全部参数化的分位数函数

MetaTrader 5EA交易 | 10 四月 2023, 10:52
1 084 0
Dmitriy Gizlyk
Dmitriy Gizlyk

内容

概述

我们继续研究分布式 Q-学习算法。 早前我们已经研究过两种算法。 在第一个 [4] 之中,我们的模型学习了在给定值范围内所获奖励的概率。 在第二种算法 [5] 之中,我们采用了不同的方式来解决问题。 我们训练模型来预测给定概率的奖励水平。

显然易见,在这两种算法当中,我们为了解决问题,还需要一些关于奖励分配性质的先验知识。 在第一种算法之中,我们将预期的奖励水平输入到模型中;而在第二种算法之中,用户的任务要相对容易一些。 我们需要在模型中输入一系列分位数,其大小在 0 到 1 的范围内归一化,并按升序排列。 然而,若是不知道奖励值的真实分布,就很难判定所需的分位数数量,和每个分位数的体量。

此处应当注意的是,我们采用的是所研究序列的均匀分布假设。 如此,我们采用了均匀的分位数范围。 主要的调节超参数是这种分位数的数量。 它是根据验证数据集的凭经验而判定的。


1. 完全参数化的理论方面

上述两种方法都需要对训练数据集进行初步研究,并优化超参数。 与此同时,应当注意的是,在优化超参数时,我们选择一些平均值。 换言之,我们选择那些可以令我们尽可能接近预期目标的内容。 所选参数应尽可能满足所研究系统的所有可能状态。 我们还假设了均匀分布。 故此,我们实际上拥有的是一个充满各种妥协的模型。 显而易见,这样的模型远非最优。

为了提高可信度,并最小化预测误差,我们必须增加要训练的分位数数量。 而这反过来又会增加模型训练时间和模型大小。 在大多数情况下,这种方式是无效的。 然而,我们的目的是尽可能彻底地研究环境。 故此,似乎一种合适的方式,就是放弃第一种算法中的固定数值类别,以及第二种算法中的固定分位数。

1.1. 隐式分位数网络(IQN)

采用分位数在此看起来更有希望。 由于为了判定类别,我们需要充分研究原始分布,并定义其极限。 但模型并未给超出指定范围的数值做好准备。 类别模型并非通用的,且其在不同的任务中有所不同。

与此同时,事件发生的概率明确限制在 0 到 1 的范围内。 但采用分位数的均匀分布限制了我们的自由度,和可优化函数的范围。 最好找能到这样的算法,其模型本身可以在不增加分位数的情况下判定最优分位数分布。

第一个这样的算法发表于 2018 年 6 月,文章是分布强化学习的隐式分位数网络。 但作者以略有不同的方式来处理最优分位数问题。 他们在构建算法时基于我们早前讨论的 QR-DQN。 但作者并未去寻找最优分位数,取而代之是决定随机生成它们,并将它们与描述环境状态的初始数据一起喂入模型。 思路如下:在训练过程中,把具有不同分位数分布的相同系统状态输入到模型之中。 结果就是,模型被迫弃用分位数函数的特定切片,而是采用其完全近似。

这种方式可以训练对“分位数”超参数不太敏感的模型。 它们的随机分布允许将近似函数的范围扩展到非均匀分布的函数。

在将数据输入到模型之前,会根据以下公式创建随机生成的分位数的嵌入。

将嵌入的成果与原始数据的张量相结合会有不同的选择。 这可以是两个张量的简单串联,也可以是两个矩阵的 Hadamard(逐元素)相乘。

以下是它与本文作者提议研究架构的比较。


模型的有效性经由 57 次 Atari 游戏测试得到确认。 以下是与原文章的比较表格 [8]  


假设,给定模型的大小不受限制,这种方法允许学习预测奖励的任何分布。

1.2. 完全参数化分位数函数(FQF)

所提议的隐式分位数网络模型拥有逼近各种函数的能力。 但这个过程与模型的成长有关。 然而,实际上资源有限。 当生成随机分位数时,始终存在所得并非非最佳值的风险,无论是在模型训练、亦或实际应用之时。

2019 年 11 月,提出了分布强化学习的全参数化分位数函数

从本质上讲,这是相同的 IQN 模型。 但它不是随机分位数生成器,而是由完全连接的神经层替代,该神经层根据作为输入给出的环境的当前状态,返回分位数的分布。 该模型为每个“状态-动作”数值对生成分位数分布。 这允许在特定系统状态下,针对每个动作的预期奖励进行最优分布近似。 这就是我们在本文开头讨论的内容。

分位数的主要需求仍然保留。 这些都包括在 0 到 1 的范围之内。 为了达成这种效果,该算法在神经层输出中用到了数据规范化。 数据经由 Softmax 函数进行归一化,然后把归一化向量的元素的累积(累加)。

在原始文献中,作者介绍了 55 次 Atari 游戏的算法测试结果。 以下是原始文章的结果摘要表。 所提供的数据证明了分位数函数全参数化算法优于其它分布式 Q-学习算法。 但它的成本是模型的性能。 额外的分位数生成模型需要额外的计算资源。


方法作者进行了最优分位数选择实验,并建议使用 32 个分位数的分布。

我们将更详细地研究该方法的算法,同时在后续主题中实现它。 


2. 以 MQL5 实现

在他们的文章中,该方法作者讨论了两个神经网络的用例:一是生成分位数的分布;另一个是近似分位数函数。 然而,所描述的算法实际上也需用到第三个卷积网络来创建环境状态的嵌入。 此状态嵌入是所研究算法的源数据。

不过,我们之前创建的函数库专注于构建顺序模型。 它不包括在模型之间传递误差梯度的算法,这在训练多个顺序模型时可能是必需的。

当然,我们可利用迁移学习机制并按顺序训练每个单独的模型。 但我决定在单独模型中实现整个算法。 

为了创建环境状态嵌入,我们采用前面讨论的卷积模型 [1]。 故此,我们可以利用现有工具轻松地构建这样的模型。

接下来,我们需要实现 FQF 算法。 在我看来,按我们的函数库概念来实现它的最简单方法是创建一个新的神经层类。 我们输入正在分析的系统当前状态嵌入,而该层将输出代理者动作。 因此,在新类中,我们将构建模型的代理者。

我们将创建一个新的 CNeuronFQF 类,它派生自神经层基类 CNeuronBaseOCL。 新类将覆盖我们通常的方法集。 在受保护的模块当中,我们声明在实现 FQF 算法时将会用到的内部对象。 我们将在构建算法的过程中更多地了解对象的用途。

class CNeuronFQF : protected CNeuronBaseOCL
  {
protected:
   //--- Fractal Net
   CNeuronBaseOCL    cFraction;
   CNeuronSoftMaxOCL cSoftMax;
   //--- Cosine embeding
   CNeuronBaseOCL    cCosine;
   CNeuronBaseOCL    cCosineEmbeding;
   //--- Quantile Net
   CNeuronBaseOCL    cQuantile0;
   CNeuronBaseOCL    cQuantile1;
   CNeuronBaseOCL    cQuantile2;
   //--- 
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override; 
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; 

public:
                     CNeuronFQF();
                    ~CNeuronFQF();
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                          uint actions, uint quantiles, uint numInputs, 
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);
   //---
   virtual bool      Save(int const file_handle) override;
   virtual bool      Load(int const file_handle) override;
   //---
   virtual int       Type(void) override        const { return defNeuronFQF; }
   virtual CLayerDescription* GetLayerInfo(void) override;
  };

在我们的类中,我们采用静态内部对象,因此类构造函数和析构函数均可留空。

类和内部对象在 Init 方法中初始化。 为了初始化内部对象,我们需要以下参数:

  • numOutputs — 下一层中的神经元数量
  • myIndex — 层中当前神经元的索引
  • open_cl — 指向所操控 OpenCL 设备的对象指针
  • actions — 可能的代理者动作数量 
  • quantiles — 分位数
  • numInputs — 前一个神经层的大小
  • optimization_type — 优化模型参数所用的函数
  • batch — 参数更新的批量大小

bool CNeuronFQF::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,

                      uint actions, uint quantiles, uint numInputs,
                      ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, actions, optimization, batch))
      return false;
   SetActivationFunction(None);

在方法主体中,我们没有定义一个模块来检查接收的参数。 取而代之,我们调用父类的类似方法,该方法已经包含所有必要的控制。 父类方法控制外部参数,并初始化继承的对象。 如此,在执行成功后,我们只需要初始化新声明的对象。

另外,不要忘记禁用对象激活函数。 所有必要的激活函数都由算法定义,并将为内部对象指定。

根据 FQF 算法,系统状态嵌入被输入到分位数生成网络当中。 出于这些目的,该方法作者使用了一个全连接层,同时调用 Softmax 函数规范化数据。 在我们的实现中,这些将是两个对象:一个没有激活函数的全连接层,以及一个 Softmax 层。

由于我们将为每个可能的动作生成分位数分布,因此所用层的大小定义等于可能动作数量乘以给定分位数的数量。 在 Softmax 的情况下,数据规范化也将在动作关联环境中实现。

//---
   if(!cFraction.Init(0, myIndex, open_cl, actions * quantiles, optimization, batch))
      return false;
   cFraction.SetActivationFunction(None);
//---
   if(!cSoftMax.Init(0, myIndex, open_cl, actions * quantiles, optimization, batch))
      return false;
   cSoftMax.SetHeads(actions);
   cSoftMax.SetActivationFunction(None);

进而,根据算法,我们必须创建所获分位数的嵌入。 它会分两步来创建。 首先,我们准备数据,并将其保存在 cCosine 神经层缓冲区之中。 然后我们利用 ReLU 激活函数将其传递到全连接层 cCosine Embedding。 此外,cCosineEmbeding 层还令嵌入张量的大小等于源数据的大小,以便后续的 Hadamard 张量乘法。

   if(!cCosine.Init(numInputs, myIndex, open_cl, actions * quantiles, optimization, batch))
      return false;
   cCosine.SetActivationFunction(None);
//---
   if(!cCosineEmbeding.Init(0, myIndex, open_cl, numInputs, optimization, batch))
      return false;
   cCosineEmbeding.SetActivationFunction(LReLU);

最后,我们需要把数据传递给分位数函数模型。 它将包含一个隐藏的全连接层,其神经元数量是动作数量与分位数的数量之乘积的 4 倍,以及 ReLU 激活函数。 还有一个全连接层,输出端没有激活函数。 结果层的大小等于可能动作数量乘以分位数的数量。

   if(!cQuantile0.Init(4 * actions * quantiles, myIndex, open_cl, numInputs, optimization, batch))
      return false;
   cQuantile0.SetActivationFunction(None);
//---
   if(!cQuantile1.Init(actions * quantiles, myIndex, open_cl,
                       4 * actions * quantiles, optimization, batch))
      return false;
   cQuantile1.SetActivationFunction(LReLU);
//---
   if(!cQuantile2.Init(0, myIndex, open_cl, actions * quantiles, optimization, batch))
      return false;
   cQuantile2.SetActivationFunction(None);
//---
   return true;
  }

在实现该方法时,不要忘记控制操作的执行。 成功初始化所有内部对象之后,退出该方法,并显示正面结果。

2.1. 前馈

初始化对象后,我们继续构建前馈过程。 但是在继续创建 CNeuronFQF::feedForward 方法之前,我们必须在 OpenCL 程序中创建所需的内核。 我们已完成了神经层的实现。 不过,我们仍然还要实现新功能。

根据 FQF 算法,源数据作为当前状态的嵌入输入到分位数生成模型之中。 两个神经网络(完全连接的 cFractioncSoftMax)的操作已经实现。 但 Softmax 输出一个张量,其中每个动作的数值总和等于 1。 而我们需要增加分位数的分数。 之后,我们需要利用以下公式创建这些分位数的嵌入。

上述公式完全重复了具有 ReLU 激活函数的全连接神经层的公式。 此处的区别在于源数据是 cos(πi)。由此,我们将准备一个此类余弦的张量,并存储到神经层结果缓冲区 cCosine 之中。

为了实现此功能,我们将创建 FQF_Cosine 内核。 我们将在内核中输入两个指向数据缓冲区的指针。 其一将从 Softmax 层提供数据,而其二将用于写入内核操作结果。

根据 FQF 算法,应为每个可能的动作创建分位数。 因此,我们将在考虑二维问题空间的情况下构建内核算法。 一个维度将用于分位数,第二个维度用于可能的代理者动作。

在内核主体中,确定两个维度的线程 ID。 此外,请求第一维中的线程总数,基于这些线程,我们可以判定张量到所分析动作的第一个分位数的偏移量。

接下来,我们需要计算当前分位数的累积份额。 这将在一个循环中完成。

请注意以下事项。 与 QR-DQN 算法一样,我们判定的不是分位数的上限,而是它的平均值。 故此,我们将 Softmax 在上一步中判定的所有以前分位数的份额相加,并与当前分位数份额的一半相加。

然后,我们记下得自当前分位数的平均值、Pi 值和分位数序数的乘积的余弦。

__kernel void FQF_Cosine(__global float* softmax,
                         __global float* output)
  {
   size_t i = get_global_id(0);
   size_t total = get_global_size(0);
   size_t action = get_global_id(1);
   int shift = action * total;
//---
   float result = 0;
   for(int it = 0; it < i; it++)
      result += softmax[shift + it];
   result += softmax[shift + i] / 2.0f;
   output[shift + i] = cos(i * M_PI_F * result);
  }

创建分位数嵌入的进一步操作将利用 cCosine Embedding内层的功能实现。 不过,我们必须执行分位数嵌入张量与初始数据张量(系统状态嵌入)的 Hadamard 乘积。 我们需要另一个内核来实现此操作。 但在创建新内核之前,我查看了以前创建的神经网络的内核。 我关注到我们为 Dropout 层创建的内核。 请记住,对于这一层,我们创建了一个内核,其中我们将系数张量与原始数据逐个元素相乘。 现在,我们必须执行类似的数学运算,但采用的数据和操作的逻辑含义不同。 不过,这并不影响数学运算的过程。 因此,我们将运用这个现成的解决方案。

随后是分位数网络的操作,我们已完成的实现,是将其当作拥有一个隐藏层的感知器。 感知器输出类似于 QR-DQN 模型的期望奖励分布。 但与前面所研究的方法不同,代理者的每个可能动作都采用自己的概率分布。 为了获得离散奖励值,我们需要将每个分位数的奖励水平乘以其概率。 然后,我们应该在代理者动作的关联环境中添加获得的数值。

在我们的特定情况下,所有层结果的概率增量都已在 cSoftMax 缓冲区中计算出。 现在,我们只需要将指定缓冲区的元素数值,逐个与来自 cQuantile2 神经层的分位数函数感知器的结果缓冲区相乘。 我们将在代理者可能的动作关联环境中汇总操作的结果。

为了执行这些操作,我们将创建一个新的内核 FQF_Output。 在核参数中,我们将传递三个数据缓冲区的指针:分位数函数结果、概率增量、和结果缓冲区。 我们还要指示分位数。

我们将在一维任务空间中运行内核,该空间与可能的代理者动作的数量相对应。

在内核主体中,我们首先请求一个线程标识符,并判定数据缓冲区内相应分位数分布向量的偏移。

接下来,我们将概率向量乘以循环中的分位数分布向量。 操作的结果都会写入相应的结果缓冲区。

请注意,结果缓冲区会明显小于原始数据缓冲区,因为它对于每个可能的动作仅包含一个离散值。 与其对比,源数据包含每个动作的完整数值向量。 相应地,结果缓冲区中的偏移量等于当前线程的标识符。

__kernel void FQF_Output(__global float* quantiles,
                         __global float* delta_taus,
                         __global float* output,
                         uint total)
  {
   size_t action = get_global_id(0);
   int shift = action * total;
//---
   float result = 0;
   for(int i = 0; i < total; i++)
      result += quantiles[shift + i] * delta_taus[shift + i];
   output[action] = result;
  }

我们已经讨论了整个前馈算法 FQF,并创建了缺失的内核。 现在,我们能够返回我们的类,并利用 MQL5 重现整个算法。 像往常一样,为了执行前馈传递,我们覆盖了 CNeuronFQF::feedForward 方法。

前馈方法在参数中接收指向前一个神经层的指针,其结果缓冲区(根据我们的预期)包含当前系统状态的嵌入。

我们不会在方法主体中创建源数据控制模块。 取而代之,我们将调用内部神经层的前馈方法 cFractioncSoftMax。 在这种情况下,排除源数据控制模块不会带来任何风险,因为每个被调用的方法都有自己的控制模块。 我们只需要检查被调用方法的结果。

bool CNeuronFQF::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cFraction.FeedForward(NeuronOCL))
      return false;
   if(!cSoftMax.FeedForward(GetPointer(cFraction)))
      return false;

接下来,我们必须创建分位数概率水平的嵌入。 此处我们首先调用上面创建的数据准备内核 FQF_Cosine。 此内核在二维任务空间中运行。 在第一维中,我们指示分位数的数字。 第二维则是可能的代理者动作数量。

请注意,我们尚未为指定的超参数创建内部变量。 但我们的 CNeuronFQF 层的结果缓冲区的大小等于可能的代理者动作的数量。 且我们可将分位数的数字定义为 cSoftMax 层结果缓冲区与动作数量的比率。

将指向缓冲区的指针传递给内核参数,并将内核添加到执行队列。 不要忘记控制每一步的操作。

     {
      uint global_work_offset[2] = {0, 0};
      uint global_work_size[2];
      global_work_size[1] = Output.Total();
      global_work_size[0] = cSoftMax.Neurons() / global_work_size[1];
      OpenCL.SetArgumentBuffer(def_k_FQF_Cosine, def_k_fqf_cosine_softmax, cSoftMax.getOutputIndex());
      OpenCL.SetArgumentBuffer(def_k_FQF_Cosine, def_k_fqf_cosine_outputs, cCosine.getOutputIndex());
      if(!OpenCL.Execute(def_k_FQF_Cosine, 2, global_work_offset, global_work_size))
        {
         printf("Error of execution kernel FQF_Cosine: %d", GetLastError());
         return false;
        }
     }

接下来,我们调用 cCosineEmbeding 内部神经层前馈方法,该方法完成分位数嵌入过程。

   if(!cCosineEmbeding.FeedForward(GetPointer(cCosine)))
      return false;

FQF 算法的下一步中,我们必须将当前系统状态(初始数据)的嵌入与分位数嵌入相结合。 如您所忆,对于此操作,我们决定使用 Dropout 神经层内核。 在这个内核的主体中,我们针对 40 个元素向量运用了向量操作。 由此,线程数将比数据缓冲区的大小少四倍。

在内核参数中传递必要的数据。 然后将内核放入执行队列之中。

     {
      uint global_work_offset[1] = {0};
      uint global_work_size[1] = {(cCosine.Neurons() + 3) / 4};
      OpenCL.SetArgumentBuffer(def_k_Dropout, def_k_dout_input, NeuronOCL.getOutputIndex());
      OpenCL.SetArgumentBuffer(def_k_Dropout, def_k_dout_map, cCosineEmbeding.getOutputIndex());
      OpenCL.SetArgumentBuffer(def_k_Dropout, def_k_dout_out, cQuantile0.getOutputIndex());
      OpenCL.SetArgument(def_k_Dropout, def_k_dout_dimension, (int)cCosine.Neurons());
      if(!OpenCL.Execute(def_k_Dropout, 1, global_work_offset, global_work_size))
        {
         printf("Error of execution kernel Dropout: %d", GetLastError());
         return false;
        }
     }

现在我们需要判定分位数分布的等级。 为此,我们在分位数函数感知器中依次调用神经层的前馈方法。

   if(!cQuantile1.FeedForward(GetPointer(cQuantile0)))
      return false;
//---
   if(!cQuantile2.FeedForward(GetPointer(cQuantile1)))
      return false;

在前馈验算方法之后,调用内核,将分位数分布转换为每个可能的代理者动作 FQF_Output 预期奖励的离散值。 将内核放入执行队列的过程是相同的:

  • 定义任务空间
  • 将指向缓冲区的指针和其它必要信息传递给内核参数
  • 调用内核执行过程

不要忘记控制每一步的结果。

     {
      uint global_work_offset[1] = {0};
      uint global_work_size[1] = { Neurons() };
      OpenCL.SetArgumentBuffer(def_k_FQF_Output, def_k_fqfout_quantiles, cQuantile2.getOutputIndex());
      OpenCL.SetArgumentBuffer(def_k_FQF_Output, def_k_fqfout_delta_taus, cSoftMax.getOutputIndex());
      OpenCL.SetArgumentBuffer(def_k_FQF_Output, def_k_fqfout_output, getOutputIndex());
      OpenCL.SetArgument(def_k_FQF_Output, def_k_fqfout_total, 
                         (uint)(cQuantile2.Neurons() / global_work_size[0]));
      if(!OpenCL.Execute(def_k_FQF_Output, 1, global_work_offset, global_work_size))
        {
         printf("Error of execution kernel FQF_Output: %d", GetLastError());
         return false;
        }
     }
//---
   return true;
  }

前馈内核的操作至此完毕。 接下来,我们继续创建反向传播内核。 它由我们类中的两个方法表示:calcInputGradientsupdateInputWeights

2.2. 反馈

我们将首先看一下calcInputGradients 方法,其中梯度传播到所有内层和前一个神经层。

此方法完全重复前馈方法,只是方向相反。 相应地,对于我们在直接验算期间创建的所有内核,有必要使用“镜像”操作创建内核。 由于整个反向传播过程与前馈验算相反,我们将以相同的顺序构建内核。

在前馈方法的输出中,我们将分位数分布转换为代理者每个可能动作的离散值。 在反向传播方法的输入处,我们希望得到每个动作的误差梯度。 然后,我们需要通过分位数函数的数值,和分位数范围的概率增量来分配结果梯度。

我们将在 FQF_OutputGradient 内核中实现所有这些。 在内核参数中,我们将传递五个数据缓冲区的指针。 其中三个将包含源数据,另外两个将用于写入内核操作结果。

概率的增量张量和分位数函数的结果,按分位数和可能的代理者动作的关联性,以表格逻辑构造。 类似地,我们将在分位数和代理者动作的二维任务空间中运行内核。

在内核主体中,我们请求两个维度的线程 ID、第一维度中的线程数字,和数据缓冲区中的偏移量。

__kernel void FQF_OutputGradient(__global float* quantiles,
                                 __global float* delta_taus,
                                 __global float* output_gr,
                                 __global float* quantiles_gr,
                                 __global float* taus_gr
                                )
  {
   size_t i = get_global_id(0);
   size_t total = get_global_size(0);
   size_t action = get_global_id(1);
   int shift = action * total;

接下来,我们必须传播误差梯度。 在前馈验算期间,我们得到的结果由 2 个变量相乘。 乘法运算的导数是第二个乘数。 因此,为了传播梯度,我们需要将得到的误差梯度乘以相反张量的相应元素。

请注意,我们必须将所获梯度缓冲区的一个元素乘以两个张量的相应元素。 也就是说,我们必须访问两次全局缓冲区的同一元素。 但我们还记得,访问全局存储的元素是“昂贵的”。 为了减少操作的总体执行时间,我们首先将全局缓冲区元素的数值转移到更快的私密存储变量中。 执行进一步的操作将用到该快速变量。

操作结果将保存在两个结果缓冲区的相应元素之中。

   float gradient = output_gr[action];
   quantiles_gr[shift + i] = gradient * delta_taus[shift + i];
   taus_gr[shift + i] = gradient * quantiles[shift + i];
  }

我们直接从前馈方法里调用的下一个内核是 Dropout。 我们在其中执行了两个嵌入张量的 Hadamard 乘法:环境状态嵌入,和分位数嵌入。 在前馈传递中,我们利用了之前创建的 Dropout 内核。 现在,为了在两个方向上传播误差梯度,我们需要多次调用此内核,且每次的输入也不同。 不过,我们正在努力实现操作的最大并行性,来最大程度地减少模型训练时间。 因此,我们花些时间来创建一个新的内核 FQF_QuantileGradient

这个内核的算法完全重复了前一个内核的算法。 这并不奇怪。 两个内核执行均类似的功能。 区别仅在于所得梯度的缓冲区中的偏移。 在前一种情况下,所得梯度缓冲区大小与其它缓冲区不同,因为对于每个可能的代理者动作,它只有一个离散值。 在这种情况下,所有缓冲区的大小相同。 因此,在接收梯度的缓冲区之中,我们采用与其余缓冲区一样的偏移量。

__kernel void FQF_QuantileGradient(__global float* state_embeding,
                                   __global float* taus_embeding,
                                   __global float* quantiles_gr,
                                   __global float* state_gr,
                                   __global float* taus_gr
                                  )
  {
   size_t i = get_global_id(0);
   size_t total = get_global_size(0);
   size_t action = get_global_id(1);
   int shift = action * total;
//---
   float gradient = quantiles_gr[shift + i];
   state_gr[shift + i] = gradient * taus_embeding[shift + i];
   taus_gr[shift + i] = gradient * state_embeding[shift + i];
  }

最后一个我们必须要加以研究的内核是 FQF_CosineGradient,它执行一个逆向过程来准备分位数嵌入的数据。 数据准备操作的导数如下:

作为该内核操作的结果,我们期望在分位数概率预测模型的 Softmax 层的输出中获得误差梯度。 请注意,每个分位数都采用 Softmax 结果张量的累积值。 这意味着张量的每个元素都会影响所有后续分位数。 这是合乎逻辑的,张量的每个元素应据其在最终结果当中的参与度,接收其梯度份额。 因此,我们将从所接收梯度缓冲区的所有元素中收集误差梯度,这些元素受到 Softmax 结果张量分析元素的影响。

我们来研究内核的实现。 在参数中,我们将传递三个数据缓冲区的指针:

  • Softmax 层结果
  • 所获误差梯度
  • 结果缓冲区 — SoftMax 层结果缓冲区级别的误差梯度

与本文中讨论的大多数内核一样,这些内核将在二维任务空间中运行:一个用于分位数;另一个用于可能的代理者动作。

在内核主体当中,我们请求两个维度的线程 ID,及数据缓冲区中的偏移量。 所有数据缓冲区的大小相同。 因此,所有这些的偏移量都是相同的。

__kernel void FQF_CosineGradient(__global float* softmax,
                                 __global float* output_gr,
                                 __global float* softmax_gr
                                )
  {
   size_t i = get_global_id(0);
   size_t total = get_global_size(0);
   size_t action = get_global_id(1);
   int shift = action * total;

每个元素仅影响其自身和后续分位数。 因此,我们首先计算上述元素的总和。

   float cumul = 0;
   for(int it = 0; it < i; it++)
      cumul += softmax[shift + it];

然后我们据相应元素计算梯度。

请注意,在前馈验算期间,我们将分位数的平均值传递给嵌入。 相应地,我们根据分位数概率的平均值计算误差梯度。

   float result = -M_PI_F * i * sin(M_PI_F * i * (cumul + softmax[shift + i] / 2)) * output_gr[shift + i];

接下来,在一个循环中,我们将判定后续分位数的误差梯度。 在此过程中,我们还将根据当前元素在梯度分位数总概率中的份额来调整梯度的影响。

   for(int it = i + 1; it < total; it++)
     {
      cumul += softmax[shift + it - 1];
      float temp = cumul + softmax[shift + it] / 2;
      result += -M_PI_F * it * sin(M_PI_F * it * temp) * output_gr[shift + it] * 
                                                         softmax[shift + it] / temp;
     }
   softmax_gr[shift + i] += result;
  }

所有循环迭代完毕之后,将结果写入结果缓冲区的相应元素。

我们已经准备好了所有内核来组织我们类的反向传播验算。 如此,现在我们可以继续创建梯度反向传播方法 calcInputGradients

在参数中,该方法接收指向前一个神经层对象的指针,其误差就是需传播的。 控件块亦在方法中实现。 在此,我们检查指向所接收对象和内部数据缓冲区的指针。

bool CNeuronFQF::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL || !Gradient || !Output)
      return false;

请注意,与前馈方法不同,此处我们创建一个控制模块。 这是因为此方法的操作从 OpenCL 程序内核调用开始。 当它传递指向数据缓冲区的指针时,我们必须确保它们存在。 否则,我们可能会在执行操作的过程中遇到严重错误。

控件模块成功通过后,我们继续进行误差梯度反向传播操作。 首先,我们调用 FQF_OutputGradient 内核,在其中我们将误差梯度传播到分位数函数感知器和分位数预测模块。 将内核放入执行队列的过程类似于前馈:内核运行二维任务空间。 第一个维度对应于分位数,第二个维度对应于代理者可能动作。

     {
      uint global_work_offset[2] = {0, 0};
      uint global_work_size[2] = { cSoftMax.Neurons() / Neurons(), Neurons() };
      OpenCL.SetArgumentBuffer(def_k_FQF_OutputGradient, def_k_fqfoutgr_quantiles,
                                                              cQuantile2.getOutputIndex());
      OpenCL.SetArgumentBuffer(def_k_FQF_OutputGradient, def_k_fqfoutgr_taus,
                                                                 cSoftMax.getOutputIndex());
      OpenCL.SetArgumentBuffer(def_k_FQF_OutputGradient, def_k_fqfoutgr_output_gr,
                                                                 getGradientIndex());
      OpenCL.SetArgumentBuffer(def_k_FQF_OutputGradient, def_k_fqfoutgr_quantiles_gr,
                                                             cQuantile2.getGradientIndex());
      OpenCL.SetArgumentBuffer(def_k_FQF_OutputGradient, def_k_fqfoutgr_taus_gr,
                                                               cSoftMax.getGradientIndex());
      if(!OpenCL.Execute(def_k_FQF_OutputGradient, 2, global_work_offset, global_work_size))
        {
         printf("Error of execution kernel FQF_OutputGradient: %d", GetLastError());
         return false;
        }
     }

接下来,我们将误差梯度传递到分位数函数的感知器当中。 为此,我们将按顺序调用指定模块的内部神经层的反向传播方法。

   if(!cQuantile1.calcHiddenGradients(GetPointer(cQuantile2)))
      return false;
   if(!cQuantile0.calcHiddenGradients(GetPointer(cQuantile1)))
      return false;

我们必须将来自分位数函数的误差梯度分布到当前系统状态嵌入(以前的神经层)和分位数概率嵌入之中。 为了执行此功能,已创建了 FQF_QuantileGradient 内核。 我们按照类似的过程调用此内核。

     {
      uint global_work_offset[2] = {0, 0};
      uint global_work_size[2] = { cCosineEmbeding.Neurons(), 1 };
      OpenCL.SetArgumentBuffer(def_k_FQF_QuantileGradient, def_k_fqfqgr_state_enbeding,
                                                                  NeuronOCL.getOutputIndex());
      OpenCL.SetArgumentBuffer(def_k_FQF_QuantileGradient, def_k_fqfqgr_taus_embedding,
                                                            cCosineEmbeding.getOutputIndex());
      OpenCL.SetArgumentBuffer(def_k_FQF_QuantileGradient, def_k_fqfqgr_quantiles_gr,
                                                               cQuantile0.getGradientIndex());
      OpenCL.SetArgumentBuffer(def_k_FQF_QuantileGradient, def_k_fqfqgr_state_gr,
                                                                NeuronOCL.getGradientIndex());
      OpenCL.SetArgumentBuffer(def_k_FQF_QuantileGradient, def_k_fqfqgr_taus_gr,
                                                          cCosineEmbeding.getGradientIndex());
      if(!OpenCL.Execute(def_k_FQF_QuantileGradient, 2, global_work_offset, global_work_size))
        {
         printf("Error of execution kernel FQF_OutputGradient: %d", GetLastError());
         return false;
        }
     }

在下一步中,我们通过分位数嵌入传递误差梯度。 此处我们首先调用内部神经层 cCosine 的反向传播方法。

   if(!cCosine.calcHiddenGradients(GetPointer(cCosineEmbeding)))
      return false;

然后调用 FQF_CosineGradient

     {
      uint global_work_offset[2] = {0, 0};
      uint global_work_size[2] = { cSoftMax.Neurons() / Neurons(), Neurons() };
      OpenCL.SetArgumentBuffer(def_k_FQF_CosineGradient, def_k_fqfcosgr_softmax,
                                                                  cSoftMax.getOutputIndex());
      OpenCL.SetArgumentBuffer(def_k_FQF_CosineGradient, def_k_fqfcosgr_output_gr,
                                                                  cCosine.getGradientIndex());
      OpenCL.SetArgumentBuffer(def_k_FQF_CosineGradient, def_k_fqfcosgr_softmax_gr,
                                                                 cSoftMax.getGradientIndex());
      if(!OpenCL.Execute(def_k_FQF_CosineGradient, 2, global_work_offset, global_work_size))
        {
         printf("Error of execution kernel FQF_CosineGradient: %d", GetLastError());
         return false;
        }
     }

在方法结束时,调用其反向传播方法,通过内层 cSoftMax 传播误差梯度。

   if(!cSoftMax.calcInputGradients(GetPointer(cFraction)))
      return false;
//---
   return true;

请注意,我们不会将误差梯度从分位数概率预测模块传递到前一层。 这是由于判定预期奖励相关任务的优先级,超过概率分布。

第二个反向传播方法 updateInputWeights,我们必须覆盖它,负责更新模型参数。 这很简单。 交替调用内部神经层的相关方法,检查运算结果。

bool CNeuronFQF::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cFraction.UpdateInputWeights(NeuronOCL))
      return false;
   if(!cCosineEmbeding.UpdateInputWeights(GetPointer(cCosine)))
      return false;
   if(!cQuantile1.UpdateInputWeights(GetPointer(cQuantile0)))
      return false;
   if(!cQuantile2.UpdateInputWeights(GetPointer(cQuantile1)))
      return false;
//---
   return true;
  }

新类 CNeuronFQF 的主要功能到此结束。 我们已经研究了前馈和反向传播过程的组织。 将数据保存到文件,以及从文件还原类的方法也在类中被重写。 在这些方法中,我们调用了内部对象的相应方法。 您可以自行研究它们。 您可以在附件中找到所有用到的类,及其方法的完整代码。

我们正在继续前行。 我们已构建了一个类,通过分位数函数的完全参数化方法来组织模型学习算法。 但这只是过程的一部分。 这仍然是使用数据缓冲区和目标网络的相同 Q-学习。 为了便于在 Q-学习过程中直接使用所讲述的方法,我们创建了从模型的基类 CNet 派生的 CFQF 类。

class CFQF : protected CNet
  {
private:
   uint              iCountBackProp;
protected:
   uint              iUpdateTarget;
   //---
   CNet              cTargetNet;
public:
                     CFQF(void);
                     CFQF(CArrayObj *Description)  { Create(Description); }
   bool              Create(CArrayObj *Description);
                    ~CFQF(void);
   bool              feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true)
                     { return        CNet::feedForward(inputVals, window, tem); }
   bool              backProp(CBufferFloat *targetVals, float discount = 0.9f,
                              CArrayFloat *nextState = NULL, int window = 1, bool tem = true);
   void              getResults(CBufferFloat *&resultVals);
   int               getAction(void);
   int               getSample(void);
   float             getRecentAverageError() { return recentAverageError; }
   bool              Save(string file_name, datetime time, bool common = true)
     { return CNet::Save(file_name, getRecentAverageError(), (float)iUpdateTarget, 0, time, common); }
   virtual bool      Save(const int file_handle);
   virtual bool      Load(string file_name, datetime &time, bool common = true);
   virtual bool      Load(const int file_handle);
   //---
   virtual int       Type(void)   const   {  return defFQF;   }
   virtual bool      TrainMode(bool flag) { return CNet::TrainMode(flag); }
   virtual bool      GetLayerOutput(uint layer, CBufferFloat *&result)
     { return        CNet::GetLayerOutput(layer, result); }
   //---
   virtual void      SetUpdateTarget(uint batch)   { iUpdateTarget = batch; }
   virtual bool      UpdateTarget(string file_name);
  };

该类与上一篇文章中的 CQRDQN 类似。 其结构与该类的结构几乎雷同。 我已删除了未使用的变量和概率矩阵。 所有这些都是在单独的神经网络中完成的。 我还针对类方法进行了必要的修改。 我现在不会详述该类的所有方法。 您可以在附件中自行检查它们。 我只提及其中的一些。

我们从反向传播方法开始。 该方法在参数中接收目标值和系统的下一个状态。 下一个状态是可选参数。 它可以在训练新模型时使用,当使用未经训练的模型来预测未来的奖励时,会产生噪音,并令学习过程复杂化。

在方法主体中,检查是否存在目标值缓冲区形式的强制参数。

bool CFQF::backProp(CBufferFloat *targetVals, float discount = 0.9f,
                    CArrayFloat *nextState = NULL, int window = 1, bool tem = true)
  {
//---
   if(!targetVals)
      return false;

然后,我们还要检查是否存在可选参数,并在必要时预测未来的奖励。 在此,我们还调整了未来奖励数额的目标值,同时考虑到折扣因素。

   if(!!nextState)
     {
      vectorf target;
      if(!targetVals.GetData(target) || target.Size() <= 0)
         return false;
      if(!cTargetNet.feedForward(nextState, window, tem))
         return false;
      cTargetNet.getResults(targetVals);
      if(!targetVals)
         return false;
      target = target + discount * targetVals.Maximum();
      if(!targetVals.AssignArray(target))
         return false;
     }

之后,检查我们是否需要更新目标网络

   if(iCountBackProp >= iUpdateTarget)
     {
#ifdef FileName
      if(UpdateTarget(FileName + ".nnw"))
#else
      if(UpdateTarget("FQF.upd"))
#endif
         iCountBackProp = 0;
     }
   else
      iCountBackProp++;

在方法结束处,调用父类的回调方法。

   return CNet::backProp(targetVals);
  }

贪婪动作选择方法也已修改。 在此,我们只需从模型的结果缓冲区中判定奖励最高的项。

int CFQF::getAction(void)
  {
   CBufferFloat *temp;
   CNet::getResults(temp);
   if(!temp)
      return -1;
//---
   return temp.Maximum(0, temp.Total());
  }

动作取样方法 getSample 亦进行了修改。 在该方法中,我们首先获取模型最后一次前馈传递的结果。

int CFQF::getSample(void)
  {
   CBufferFloat* resultVals;
   CNet::getResults(resultVals);
   if(!resultVals)
      return -1;

我们将从缓冲区接收到的数据复制到向量中,并应用 Softmax 函数处理它们。 然后我们计算向量值的累积总和。

   vectorf temp;
   if(!resultVals.GetData(temp))
     {
      delete resultVals;
      return -1;
     }
   delete resultVals;
//---
   if(!temp.Activation(temp, AF_SOFTMAX))
      return -1;
   temp = temp.CumSum();

生成的向量是代理者动作的一种分位数概率分布。 然后我们从这个分布中取一个样本值,并将其返回给调用者方

   int err_code;
   float random = (float)Math::MathRandomNormal(0.5, 0.5, err_code);
   if(random >= 1)
      return (int)temp.Size() - 1;
   for(int i = 0; i < (int)temp.Size(); i++)
      if(random <= temp[i] && temp[i] > 0)
         return i;
//---
   return -1;
  }

在每一步中,我们都要检查操作的结果。 如果发生错误,则向调用程序返回 -1。

关于实现 FQF 算法的类的讨论到此结束。 附件中提供了所有类及其方法的完整代码。


3. 测试

为了通过完全参数化的分位数函数的方法训练模型,我创建了 FQF-learning.mq5 EA。 它的算法与来自上一篇文章中的 QRDQN-learning.mq5 非常相似。 我只更改了文件名和用到的对象。 故此,我不再详述其体系结构。 EA 的完整代码附在文后。

该模型根据过去 2 年的 EURUSD 历史数据进行了训练,时间帧为 H1。 所有指标都采用默认参数。 如您所见,这些参数与我们在本系列文章中测试所有模型时采用的参数相同。

在训练过程中,该模型表现出相当平滑和稳定的降低误差动态。 这是模型训练稳定性的一个很好的标志。

训练好的模型在策略测试器中进行了测试。 一个单独的 EA FQF-learning-test.mq5 就是为测试目的而创建的。 它是上一篇文章中 QRDQN-learning-test.mq5 的副本。 因此,我们现在不再研究它的算法。 只修改了文件名和模型类。 完整的 EA 代码可在附件中找到。

在测试期间,该模型展现了产生盈利的能力。 基于测试结果,模型显示盈利因子为 1.78,恢复因子为 3.7。 获胜交易的占比超过 57%。 最大的获胜交易几乎是最高亏损交易的 2.5 倍。 最长连胜 10 笔交易,而最长连亏是 4 笔交易。 一般来说,平均盈利交易比平均亏损交易高出 ⅓。

模型测试图形

模型测试结果


结束语

在本文中,我们继续研究了分布式强化学习算法,并构建了与强化学习中实现的完全参数化分位数函数类以的学习方法。 我们运用这种方法训练模型,并在策略测试器中检验经训练模型的性能。 在学习过程中,该方法展现出稳定降低误差的趋势。 在策略测试器中针对已训练模型的测试,模型展现出产生盈利的能力。

我想再次提醒您,金融市场交易是一种高风险的投资方法。 本文中介绍的程序仅用于演示方法和算法的操作。 它们还不适用于实时交易。 尽管如此,它们可以用作创建可操作交易工具的基础。 无论如何,在使用之前,您必须对开发的工具进行彻底而全面的测试。 您应当明白并接受在真实交易中使用程序的风险。


参考

  1. 神经网络变得轻松(第三部分):卷积网络
  2. 神经网络变得轻松(第十二部分):舍弃
  3. 神经网络变得轻松(第二十六部分):强化学习
  4. 神经网络变得轻松(第二十七部分):深度 Q-学习(DQN)
  5. 神经网络变得轻松(第二十八部分):政策梯度算法
  6. 神经网络变得轻松(第三十二部分):分布式 Q-学习
  7. 神经网络变得轻松(第三十三部分):分布式 Q-学习中的分位数回归
  8. 强化学习之上的分布视角
  9. 使用分位数回归的分布强化学习
  10. 分布强化学习的隐式分位数网络
  11. 分布强化学习的完全参数化分位数函数

本文中用到的程序

# 发行 类型 说明
1 FQF-learning.mq5 EA 优化模型的 EA
2 FQF-learning-test.mq5 EA
在策略测试器中测试模型的智能系统
3 FQF.mqh  类库 FQF 模型类
4 NeuroNet.mqh 类库 创建神经网络模型的类库
5 NeuroNet.cl 代码库
创建神经网络模型的 OpenCL 程序代码库
NetCreator.mq5 EA 模型构建工具
7 NetCreatotPanel.mqh  类库 创建工具的类库


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

附加的文件 |
MQL5.zip (106.93 KB)
种群优化算法:灰狼优化器(GWO) 种群优化算法:灰狼优化器(GWO)
我们来研究一种最新的现代优化算法 — 灰狼优化。 测试函数的原始行为令该算法成为以前研究过的算法中最有趣的算法之一。 这是训练神经网络的顶级算法之一,具有许多变量的平滑函数。
种群优化算法:人工蜂群(ABC) 种群优化算法:人工蜂群(ABC)
在本文中,我们将研究人工蜂群的算法,并用研究函数空间得到的新原理来补充我们的知识库。 在本文中,我将陈列我对经典算法版本的解释。
构建自动运行的 EA(第 08 部分):OnTradeTransaction 构建自动运行的 EA(第 08 部分):OnTradeTransaction
在本文中,我们将目睹如何利用事件处理系统快速有效地处理与订单系统相关的问题。 配合这个系统,EA 就能更快地工作,如此它就不必持续不断地搜索所需的数据。
非线性指标 非线性指标
在本文中,我将尝试研究一些构建非线性指标的方法,并探索其在交易中的用处。 MetaTrader 交易平台中有相当多的指标采用非线性方式。