English Русский Español Deutsch 日本語 Português
preview
神经网络变得轻松(第三十二部分):分布式 Q-学习

神经网络变得轻松(第三十二部分):分布式 Q-学习

MetaTrader 5交易系统 | 28 二月 2023, 09:49
1 268 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

我们在文章“神经网络变得轻松(第二十七部分):深度 Q-学习(DQN)”中领路了 Q-学习方法。 在那篇文章中,我们近似估算了 Q-函数,它是依赖对系统状态奖励,并所采取行动的函数。 但问题是,现实世界是多方面演化的。 在评估当前状态时,我们无法始终考虑所有影响因素。 因此,评估参数后发现在描述系统状态、执行的操作和奖励之间没有直接关系。 作为 Q-函数近似的结果,我们只得到最可能的期望奖励均值。 在这个过程中,我们看不到模型训练过程中得到奖励的全部分布。 另外,由于明显尖锐的异常值,平均值可能会失真。 2017 年发表了两篇文章。 他们的作者提出了算法来研究所获得奖励的价值分布。 在这两篇文章中,作者设法显著改善了 Atari 电脑游戏中经典 Q-学习的结果。


1. 分布式 Q-学习的特征

分布式 Q-学习与原始 Q-学习一样,近似估算动作功效函数。 同样,我们将近似估算 Q-函数,来预测预期奖励。 主要区别在于,我们不会针对已完成操作在特定状态下近似单独的奖励值,而是整体期望奖励的概率分布。 当然,由于资源有限,我们无法估算每个单独奖励值出现的概率。 但是我们能把可能的奖励范围划分为多个范围,即分位数。

引入了其它参数来判定分位数 这些是期望奖励范围内的最小(Vmin)和最大(Vmax)值,以及分位数(N)。 以下公式计算一个分位数的数值范围。

与暗示自然奖励值近似估算的原始 Q-学习方法不同,分布式 Q-学习算法在特定状态下执行特定操作时,近似估算在分位数内获得奖励的概率分布。 将问题转换为概率分布任务,我们就可以将 Q-函数近似问题转换为标准分类问题。 这会导致损失函数发生变化。 原本的 Q-学习采用标准差作为损失函数,但分布式 Q-学习方法将采用 LogLoss。 我们之前在研究政策梯度时曾研究过这个函数。

LogLoss

以这样方式,我们可以近似估算出每个状态-行动配对的奖励概率分布。 因此,在选择动作时,我们在判定预期奖励及其概率能得到更高的准确性。 另一个优点是能够估算特定奖励水平的概率,而非平均奖励的概率。 当评估从系统当前状态执行一个动作后,其获得正面和负面奖励的概率时,这样能够使用基于风险的方式。

若在类似情况下的相同行动,环境同时返回积极和消极的奖励时,其结果就会产生最大的成效。 原始的 Q-学习算法使用期望奖励的均值,在这种情况下,我们通常会得到的数值接近 0。 结果就是,将跳过该动作。 而改用分布式 Q-学习算法时,我们能够估算获得真实奖励的概率。 使用基于风险的方法将有助于做出正确的决定。

请注意,当代理者执行任何可能的动作时,环境肯定会给予奖励。 因此,对于从当前环境状态执行的代理的任何操作,我们期望获得奖励的概率 100%。 对于每个代理者动过的概率总和应等于 1。 此结果可以通过在可能的动作中调用 SoftMax 函数来达成。

我们仍将使用原始 Q-学习算法的所有工具。 其中包括体验重播缓冲区,和预测未来奖励的目标网络模型。 自然而然,我们将利用折扣因子来获得未来奖励。

模型训练基于原始 Q-学习的原理。 该过程本身基于贝尔曼(Bellman)方程。

贝尔曼(Bellman)方程

如上所述,我们将利用目标网络来评估未来奖励的预测值,目标网络是正在训练模型的“冻结”副本。 我想详谈一下运用它的方式。

强化学习和 Q-学习的特点之一是能够建立动过策略,从而获得最佳结果。 为了能够构建策略,贝尔曼方程包括一个未来状态的数值。 事实上,对环境未来状态的评估应该包括从状态到场次结束的最大可能奖励。 缺了此标量,经训练的模型仅能预测当前过渡到新状态的预期奖励。

但我们从另一面看这个过程。 在场次结束之前,我们并没有真正得到全额奖励。 因此,我们利用第二个神经网络来预测缺失的数据。 为了避免并行训练两个模型,我们取具有冻结权重的可训练模型的副本,来预测未来状态的奖励。 来自未经训练的模型的预测是否准确? 它们很可能是完全随机的。 但通过为训练模型目标引入随机值,我们干扰了对环境的感知,并将训练引向错误的方向。

通过在初始阶段排除使用目标网络,我们能够训练模型以一定的准确性预测当前过度期的奖励。 好吧,该模型将无法构建策略。 但这只是学习的第一阶段。 如果我们有一个能够提前一步给出合理预测的模型,我们就能将其当作目标网络。 之后,我们能另外训练模型,从而提前两步构建策略。

这种方式经由目标网络分阶段更新,并采用合理的预测未来状态值,将令模型能够构建正确的策略。 以这种方式,我们就能得到想要的结果。

我想再补充几句关于未来奖励价值的折扣因子。 这是在策略构建中管理模型预见的工具。 此超参数在很大程度上影响正在构建的策略类型。 选取接近 1 的系数指示模型构建长线策略。 在这种情况下,该模型将构建长期投资策略。

与此对比,减少此参数且令值接近 0,会迫使模型忘记未来的奖励,而更加关注在短期内的盈利。 那么,该模型将构建剥头皮策略。 当然,持仓时间会受到所取时间帧的影响。

我们来总结一下以上内容。

  1. 分布式 Q-学习方法基于经典的 Q-学习,并对其进行了补充。
  2. 有一个神经网络当作模型。
  3. 在训练过程中,我们根据状态-操作配对,近似地估算过渡到新状态的预期奖励的概率分布。
  4. 分布由一组固定薪酬范围的分位数表示。
  5. 分位数和可能值的范围由超参数确定。
  6. 每个可能动作的分布由相同的概率向量表示。
  7. 为了规范化概率分布,我们在每个动作的关联中调用 SoftMax 函数。
  8. 该模型是基于贝尔曼方程进行训练的。
  9. 解决问题的概率方式需要调用 LogLoss 作为损失函数。
  10. 为了令学习过程稳定,我们利用原始 Q-学习算法(目标网络,体验回播缓冲区)的启发式方法。

与往常一样,理论部分之后就是利用 MQL5 实际实现。


2. 利用 MQL5 实现

在继续利用 MQL5 实现分布式 Q-学习方法之前,我们先来制定一个工作计划。 正如理论部分所述,该方法基于原始的 Q-学习算法。 我们之前已经实现了这个算法。 因此,我们可以基于先前所用的那个创建一个智能系统。

采用概率方式需要修改传输模型目标值所在的模块。

在模型输出中,我们需要调用 SoftMax 函数对数据进行归一化。 我们已在有关政策梯度的文章中遇到了这个函数,并实现了它。 在那篇文章中,我们还对概率进行了归一化。 那次,我们用到了选择动作的概率。 数据在整个神经层内归一化。 现在我们需要分别规范化每个动作的分布概率。 这意味着我们不能以纯净形式使用之前创建的 CNeuronSoftMaxOCL 类。

因此,我们有 2 个选项。 我们可以创建一个新类,或修改现有类。 我决定采取第二个选项。 之前创建的类其结构如下。

class CNeuronSoftMaxOCL    :  public CNeuronBaseOCL
  {
protected:
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return true; }

public:
                     CNeuronSoftMaxOCL(void) {};
                    ~CNeuronSoftMaxOCL(void) {};
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);   
   virtual bool      calcOutputGradients(CArrayFloat *Target, float& error) override;
   //---
   virtual int       Type(void) override  const   {  return defNeuronSoftMaxOCL; }
  };

首先,我们添加一个变量来存储可规范化向量 iHeads 的数量,以及指定此参数的方法 - SetHeads。 默认情况下,我们将指定 1 个向量。 这对应于整个层内数据的规范化。

class CNeuronSoftMaxOCL    :  public CNeuronBaseOCL
  {
protected:
   uint              iHeads;
.........
.........
public:
                     CNeuronSoftMaxOCL(void) : iHeads(1) {};
                    ~CNeuronSoftMaxOCL(void) {};
.........
.........
   virtual void      SetHeads(int heads)  { iHeads = heads; }
.........
.........
  };

如您所知,添加新变量并不会更改类方法的逻辑。 接下来,我们应修改方法的算法。 我们主要对前馈和反向传播方法感兴趣。 前馈传递是通过 feedForward 方法实现的。 请注意,此方法仅实现了调用 OpenCL 程序相应内核的辅助算法。 所有计算都在 OpenCL 关联环境端的多线程模式下执行。 因此,在更改将内核放入执行队列相关的操作之前,我们需要在程序的 OpenCL 端进行更改。

我们说明一下原因。 SoftMax 函数的具体特征是对数据进行规范化,令整个结果向量的总和等于 1。 该函数的数学公式如下所示。

SoftMax

如您所见,规范化的数据取自整个源数据向量的指数值之合计。 使用局部数据数组,我们在同一内核的不同线程之间传输数据。 这样就可在 OpenCL 关联环境端创建函数的多线程实现。 我们创建的算法在一维问题空间中运行。 它规范化单个向量中的数据。 为了解决新算法的问题,我们需要将整个初始数据量划分成几个相等的部分,并分别对每个部分进行归一化。 此处的难题在于我们不知道这些散件的数量。

即便硬币也有好的一面。 每个单独的模块可以彼此独立地规范化。 这完全符合我们对于多线程计算的概念。 因此,对于分布式数据规范化,我们可以运行先前创建的内核的其它实例。

我们只需要将源数据缓冲区和结果缓冲区的总体积派发到相应的模块当中。 之前,我们是在一维任务空间中启动内核。 OpenCL 技术支持使用三维任务空间。 在这种情况下,我们不需要第三个维度。 无论如何,我们能够用第二维来识别规范化模块。

因此,为任务空间添加另一个维度,我们在之前创建的 SoftMax_FeedForward 类中启用了分布式规范化。 我们仍然需要在内核代码中进行修改。 但这些修改很微小。 我们需要将第二个任务空间维度的处理添加到内核算法当中。

内核参数保持不变。 在参数中,我们传递指向数据缓冲区的指针,和一个数据规范化向量的大小。

__kernel void SoftMax_FeedForward(__global float *inputs,
                                  __global float *outputs,
                                  const uint total)
  {
   uint i = (uint)get_global_id(0);
   uint l = (uint)get_local_id(0);
   uint h = (uint)get_global_id(1);
   uint ls = min((uint)get_local_size(0), (uint)256);
   uint shift_head = h * total;

在内核实体中,我们为每个维度请求线程 ID。 它们定义当前线程的工作量,以及正在处理的元素在数据缓冲区中的偏移量。 第一个维度指示线程在数据规范化算法中的位置。 通过第二个维度,我们判定数据缓冲区中的偏移量。 在上面的代码中,我高亮显示了添加的行。

接下来,内核算法有一个第一阶段的循环,其中累加初始数据的指数值。 添加调整以便跳转到要规范化的源数据块的第一个元素(在代码中高亮显示)。

请注意,我们仅针对全局源数据缓冲区使用偏移量。 对于局部数据数组,我们忽略它。 这是因为每个工作组都隔离工作,并取用自己的局部数据数组。

   __local float temp[256];
   uint count = 0;
   if(l < 256)
      do
        {
         uint shift = shift_head + count * ls + l;
         temp[l] = (count > 0 ? temp[l] : 0) + (shift < ((h + 1) * total) ? exp(inputs[shift]) : 0);
         count++;
        }
      while((count * ls + l) < total);
   barrier(CLK_LOCAL_MEM_FENCE);

在前一个模块中,我们在局部数组的元素中收集到总体的部分。 接下来通过一个循环,在其中合并局部数组值的合计。 此处我们只取局部数组。 这个过程完全独立于我们任务空间的第二个维度,并且保持不变。

   count = ls;
   do
     {
      count = (count + 1) / 2;
      if(l < 256)
         temp[l] += (l < count && (l + count) < total ? temp[l + count] : 0);
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
//---
   float sum = temp[0];

在内核结束时,我们规范化初始数据,并将结果值保存到结果缓冲区之中。 在此,如同第一个循环,我们在全局数据缓冲区中使用先前计算的偏移量。

   if(sum != 0)
     {
      count = 0;
      while((count * ls + l) < total)
        {
         uint shift = shift_head + count * ls + l;
         if(shift < ((h + 1) * total))
            outputs[shift] = exp(inputs[shift] / 10) / (sum + 1e-37f);
         count++;
        }
     }
  }

在对内核进行修改时,我们使用类似的方法,梯度分布到前一层 SoftMax_HiddenGradient。 在全局数据缓冲区中添加偏移量,而无需更改内核的常规算法。

__kernel void SoftMax_HiddenGradient(__global float* outputs,
                                     __global float* output_gr,
                                     __global float* input_gr)
  {
   size_t i = get_global_id(0);
   size_t outputs_total = get_global_size(0);
   size_t h = get_global_id(1);
   uint shift = h * outputs_total;
   float output = outputs[shift + i];
   float result = 0;
   for(int j = 0; j < outputs_total ; j++)
      result += outputs[shift + j] * output_gr[shift + j] * ((float)(i == j) - output);
   input_gr[shift + i] = result;
  }

无需在判定与参考分布偏差的 SoftMax_OutputGradient 内核中进行任何修改。 这是因为此内核中的偏移量是为了判定序列中的特定元素,而不管特定元素属于哪个模块。

__kernel void SoftMax_OutputGradient(__global float* outputs,
                                     __global float* targets,
                                     __global float* output_gr)
  {
   size_t i = get_global_id(0);
   output_gr[i] = targets[i] / (outputs[i] + 1e-37f);
  }

这样 OpenCL 程序端的操作至此完毕。 我们回到我们的 CNeuronSoftMaxOCL 类的代码。 我们从前馈内核的变化开始。 类似地,我们对类的方法进行修改。

我们在内核中未添加或更改任何参数。 因此,数据准备算法和内核调用保持不变。 唯一的变化将在如何指定任务空间方面完成。

首先,我们定义一个数据规范化向量的维度。 只需将结果缓冲区大小除以要归一化的向量数量,即可轻松判定。 我们将结果值保存在局部变量 size 之中。 在此,我们还要填充全局任务空间的 global_work_size 数组。 在第一维中,指示上面计算出的一个归一化向量的大小。 并在第二个维度中,指示此种向量的数量。

为了实现线程的同步,和线程之间的数据交换,我们之前曾创建了一个相当于全局任务空间的工作组。 这是因为我们把整个数据缓冲区中的数据进行规范化。 现在情况有点不同。 我们需要规范化数据缓冲区中的几个单独模块。 在构建前馈内核时,我们注意到使用局部数据数组的操作保持不变。 这是通过计划将每个向量的归一化拆分到一个单独的工作组来实现的。 故此,在这种情况下,我们需要为局部组任务空间 local_work_size 创建一个单独的数组。

全局和局部任务空间的维度必须相同。 因此,我们需要定义一个二维的局部任务空间。 全局线程数必须是每个任务空间维度中局部线程数的倍数。

之前,我们根据第一维中的一个可归一化向量,和第二维中此类向量的数量,来指定全局任务空间。 在每个工作组中,我们仅计划规范化一个向量。 从逻辑上讲,我们应该指示局部任务空间第一维中一个可归一化向量的大小。 我们将在第二个维度中指示 1。 这对应于一个向量。

下面是 feedForward 方法修改后的代码。 所有修改都以高亮显示。 如您所见,也并无太多变化。 但重要的是,这些考虑到所有的关键点。

bool CNeuronSoftMaxOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!OpenCL || !NeuronOCL)
      return false;
   uint global_work_offset[2] = {0, 0};
   uint size = Output.Total() / iHeads;
   uint global_work_size[2] = { size, iHeads };
   uint local_work_size[2] = { size, 1 };
   OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_inputs, NeuronOCL.getOutputIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_outputs, getOutputIndex());
   OpenCL.SetArgument(def_k_SoftMax_FeedForward, def_k_softmaxff_total, size);
   if(!OpenCL.Execute(def_k_SoftMax_FeedForward, 2, global_work_offset, global_work_size, local_work_size))
     {
      printf("Error of execution kernel SoftMax FeedForward: %d", GetLastError());
      return false;
     }
//---
   return true;
  }

针对将误差梯度传播到前一层的方法也进行了类似的修改:calcInputGradients。 但在这种情况下,我们并没有创建工作组。

bool CNeuronSoftMaxOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;
   uint global_work_offset[2] = {0, 0};
   uint size = Output.Total() / iHeads;
   uint global_work_size[2] = {size, iHeads};
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_input_gr, NeuronOCL.getGradientIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_output_gr, getGradientIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_outputs, getOutputIndex());
   if(!OpenCL.Execute(def_k_SoftMax_HiddenGradient, 2, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel SoftMax InputGradients: %d", GetLastError());
      return false;
     }
//---
   return true;
  }

添加分布式规范化是一项设计功能,应反映在文件处理方法中。 我们继续 CNeuronSoftMaxOCL 类。 我们以前没有为这个类创建过文件方法。 父类中类似方法的功能就足够了。 但是,由于添加了一个新变量,必须保存其数值才能正确恢复对象操作,故需要重新定义此类方法。

再一次,我们从 Save 数据的方法开始。 它的算法非常简单。 该方法在参数中接收用于写入数据的文件句柄。 通常,这样的方法从检查收到的句柄的正确性开始。 我们不会创建控件模块。 取而代之,我们将调用父类的类似方法,并将接收到的句柄传递给它。 以此方式,我们仅用一行代码解决了两个任务。 所有必要的控制均已在父类方法中实现。 这意味着由它执行控制函数。 此外,它还实现了所有继承对象和变量的保存。 因此,数据保存函数也会被执行。 我们只需要检查父类方法的结果,找出指定功能的执行状态。

父类方法执行成功后,我们保存新变量的值,并完成方法。

bool CNeuronSoftMaxOCL::Save(const int file_handle)
  {
   if(!CNeuronBaseOCL::Save(file_handle))
      return false;
   if(FileWriteInteger(file_handle, iHeads) <= 0)
      return false;
//---
   return true;
  }

数据加载方法 CNeuronSoftMaxOCL 遵循类似的操作顺序。 它还控制可规范化方法的最小数量。

bool CNeuronSoftMaxOCL::Load(const int file_handle)
  {
   if(!CNeuronBaseOCL::Load(file_handle))
      return false;
   iHeads = (uint)FileReadInteger(file_handle);
   if(iHeads <= 0)
      iHeads = 1;
//---
   return true;
  }

CNeuronSoftMaxOCL 类的操作到此结束。 剩下的就是为用户添加指定可能的规范化向量数量。 我们不会对神经层描述对象进行任何修改。 我们将利用 step 参数来指定要规范化的向量数量。 在神经网络初始化方法 CNet::Create 中,在创建 SoftMax 层的同时,我们会将指定的参数传递给创建的 CNeuronSoftMaxOCL 类实例。 下面的代码中高亮显示出这些修改。

void CNet::Create(CArrayObj *Description)
  {
.........
.........
//---
   for(int i = 0; i < total; i++)
     {
.........
.........
      if(!!opencl)
        {
.........
.........
         CNeuronSoftMaxOCL *softmax = NULL;
         switch(desc.type)
           {
.........
.........
            case defNeuronSoftMaxOCL:
               softmax = new CNeuronSoftMaxOCL();
               if(!softmax)
                 {
                  delete temp;
                  return;
                 }
               if(!softmax.Init(outputs, 0, opencl, desc.count, desc.optimization, desc.batch))
                 {
                  delete softmax;
                  delete temp;
                  return;
                 }
               softmax.SetHeads(desc.step);
               if(!temp.Add(softmax))
                 {
                  delete softmax;
                  delete temp;
                  return;
                 }
               softmax = NULL;
               break;
.........
.........
           }
        }
.........
.........
//---
   return;
  }

实现该方法,但不需要对神经网络架构进行其它更改。

模型学习过程在 “DistQ-learning.mq5” EA 中实现。 改 EA 是基于 Q-learning.mq5 EA 创建的,其利用原始的 Q-学习方法训练模型。

根据分布式 Q-学习算法,我们需要引入额外的超参数来判定概率分布中预期奖励的范围和分位数。

在建议的实现中,我从不同的角度处理了这个问题。 如同前面的测试,我们将利用 NetCreator 工具创建模型。 分位数的数量是依据具有模型操作结果的层规模判定的。 这里面考虑了由 EA 的 Action 参数指定的可能的动作数量。

int                  Actions     =  3; 

在学习过程中,我们需要将环境中的特定奖励值与某个分位数相匹配。 我们做出以下假设。 根据我们制定的奖励政策,可以有正面奖励,也可以有负面奖励。 可把它们称为奖励和惩罚。 我们假设向量的中位数将对应于零奖励。 为了能以物理奖励项来衡量分位数的大小,我们引入了一个外部参数 Step

input double               Step = 5e-4;

EA 的其它外部参数保持不变。

在 EA 初始化函数 OnInit 中,成功加载模型后,我们依据模型输出的神经层大小,和中位数的数量,来判定分位数的数量。

int OnInit()
  {
.........
.........
//---
   float temp1, temp2;
   if(!StudyNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, false) ||
      !TargetNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, false))
      return INIT_FAILED;
   if(!StudyNet.TrainMode(true))
      return INIT_FAILED;
//---
   if(!StudyNet.GetLayerOutput(0, TempData))
      return INIT_FAILED;
   HistoryBars = TempData.Total() / 12;
   StudyNet.getResults(TempData);
   action_dist = TempData.Total() / Actions;
   if(action_dist <= 0)
      return INIT_PARAMETERS_INCORRECT;
   action_midle = (action_dist + 1) / 2;
//---
.........
.........
//---
   return(INIT_SUCCEEDED);
  }

接下来,移到模型训练函数。 数据准备模块保持不变,因为我们不会更改训练样本的任何数据。 这些更改仅影响指示用于预测预期奖励的目标结果的模块。

首先,我们准备一个预测未来状态成本的向量。 此向量将包含三个元素,每个动作一个值。 我们将利用向量运算来计算向量值。 首先,我们将结果缓冲区目标网络传输到行矩阵之中。 然后我们将矩阵重新格式化为 3 行的表格,每个动作一行。 在每一行中,找到具有最大概率的元素。 将最大元素的分位数转换为自然奖励表达式。

void Train(void)
  {
//---
.........
.........
//---
   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
.........
.........
      for(int batch = 0; batch < (Batch * UpdateTarget); batch++)
        {
.........
.........
//---
         vectorf add = vectorf::Zeros(Actions); 
         if(use_target)
           {
            if(!TargetNet.feedForward(GetPointer(State2), 12, true))
               return;
            TargetNet.getResults(TempData);
            vectorf temp;
            TempData.GetData(temp);
            matrixf target = matrixf::Zeros(1, temp.Size());
            if(!target.Row(temp, 0) || !target.Reshape(Actions, action_dist))
               return;
            add = DiscountFactor * (target.ArgMax(1) - action_midle) * Step;
           }

判定未来状态的预测值之后,我们就可为模型准备目标值缓冲区。 首先,我们要做一些准备工作。 用零值填充奖励缓冲区,并判定一根烛条之前,来自系统当前状态的潜在利润。

         Rewards.BufferInit(Actions * action_dist, 0);
         double reward = Rates[i].close - Rates[i].open;

进一步的步骤取决于烛条方向。 在看涨烛条的情况下,为买入动作创建正值奖励,为卖出动作创建增长的负值奖励。 此外,我们为市外状态设置了负值奖励,作为对亏损的惩罚。 然后我们将未来状态的计算值添加到得到的奖励之中。 但当构建原始的 Q-学习算法时,我们将目标结果缓冲区中的奖励表示为自然表达式。 这一次,我们判定每个动作的奖励分位数,并把对应事件的概率写入 1。 缓冲区的其余元素的概率为零。

         if(reward >= 0)
           {
            int rew = (int)fmax(fmin((2 * reward + add[0]) / Step + action_midle, action_dist - 1), 0);
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-5 * reward + add[1]) / Step + action_midle, action_dist - 1), 0) + action_dist;
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-reward + add.Max()) / Step + action_midle, action_dist - 1), 0) + 2 * action_dist;
            if(!Rewards.Update(rew, 1))
               return;
           }

看跌烛条的行动算法与此类似。 唯一的区别在于买卖行为的奖励和惩罚。

         else
           {
            int rew = (int)fmax(fmin((5 * reward + add[0]) / Step + action_midle, action_dist - 1), 0);
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-2 * reward + add[1]) / Step + action_midle, action_dist - 1), 0) + action_dist;
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((reward + add.Max()) / Step + action_midle, action_dist - 1), 0) + 2 * action_dist;
            if(!Rewards.Update(rew, 1))
               return;
           }

函数代码的其余部分保持不变, EA 的所有代码于此并未讲述。 完整的 EA 代码可在附件中找到。 


3. 测试

创建的 EA 用于训练模型,包括:

  • 3个卷积数据预处理层,
  • 3 个完全连接的隐藏层,每个隐藏层有 1000 个神经元,
  • 1 个由 45 个神经元组成的全连接决策层(三个动作概率分布中的每一个都有 15 个神经元),
  • 1 个 SoftMax 层,用于概率分布的归一化。

该模型依据过去两年的历史 EURUSD 数据进行训练。 取时间帧:H1。 在整个系列文章中采用相同的指标列表,和相同的指标参数。

经训练的模型在策略测试器中依据过去两周的历史数据进行了测试;此数据未包含在训练样本当中。 这确保了实验的纯粹性,因为模型是依据新数据进行测试的。

为了在策略测试器中测试模型,我们创建了 “DistQ-learning-test.mq5” EA。 该EA 几乎是 “Q-learning-test.mq5” 的完整副本,测试原始 Q-学习方法训练的模型。 EA 代码中唯一的变化是添加了动作选择函数 GetAction

该函数在参数中接收指向概率分布缓冲区的指针,其中是模型对当前状况的估算结果。 此缓冲区包含所有可能的概率分布。 为了令数据处理更方便,我们将缓冲区值移动到矩阵,并将矩阵格式更改为表格。 其中的行数等于代理者可能的动作数量。

接下来,我们判定每个单独动作最可能奖励的分位数。 

int GetAction(CBufferFloat* probability)
  {
   vectorf prob;
   if(!probability.GetData(prob))
      return -1;
   matrixf dist = matrixf::Zeros(1, prob.Size());
   if(!dist.Row(prob, 0))
      return -1;
   if(!dist.Reshape(Actions, prob.Size() / Actions))
      return -1;
   prob = dist.ArgMax(1);

之后,我们比较当前状态下买入和卖出的预期回报。 如果预期回报相等,我们选择获得奖励概率最高的那个动作。

   if(prob[0] == prob[1])
     {
      if(prob[2] > prob[0])
         return 2;
      if(dist[0, (int)prob[0]] >= dist[1, (int)prob[1]])
         return 0;
      else
         return 1;
     }

否则,我们选择具有最大预期奖励的动作。

//---
   return (int)prob.ArgMax();
  }

如您所见,在这种情况下,我们采用贪婪策略来选择回报最高的动作。

完整的 EA 代码可在附件中找到。

当测试 EA 在 MetaTrader 5 策略测试器中取两周区间数据运行时,基于模型信号进行交易,它产生了约 20 美元的利润。 所有操作都是最低手数。 下图展示出余额值有明显上升趋势。

策略测试器中的模型测试

分布式 Q-学习模型的测试

交易操作统计数据显示,近 56% 的操作是盈利的。 然而,请注意,EA 仅在策略测试器中测试了模型,尚不适合金融市场的真实交易。

本文所有程序的完整代码可在附件中找到。


结束语

在本文中,我们领略了另一种强化训练算法:分布式 Q-学习。 通过该算法,研究该模型在特定环境状态下执行动作时奖励的概率分布。 通过研究概率分布,替代预测奖励的平均值,我们可以获得更多关于奖励性质的信息,并提高模型训练的稳定性。 此外,当我们知道预期收益的概率分布时,我们可以在进行交易操作时更好地评估风险。

模型在 MetaTrader 5 策略测试器中经过测试,验证了该方法的潜在盈利能力。 该算法可深入开发,并构建交易决策。

请在附件中找到所有程序和函数库的完整代码。


参考

  1. 神经网络变得轻松(第二十六部分):强化学习
  2. 神经网络变得轻松(第二十七部分):深度 Q-学习(DQN)
  3. 神经网络变得轻松(第二十八部分):政策梯度算法
  4. 强化学习之上的分布视角
  5. 使用分位数回归的分布强化学习

本文中用到的程序

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

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

附加的文件 |
MQL5.zip (82.71 KB)
如何利用 MQL5 处理指示线 如何利用 MQL5 处理指示线
在本文中,您将发现利用 MQL5 处理最重要的指示线(如趋势线、支撑线和阻力线)的方法。
您应该知道的 MQL5 向导技术(第 04 部分):线性判别分析 您应该知道的 MQL5 向导技术(第 04 部分):线性判别分析
今天的交易者都是哲学家,几乎总是在寻找新的想法,尝试提炼它们,选择修改或丢弃它们:一个探索性的过程,肯定会花费相当的勤奋程度。 这些系列文章将提出 MQL5 向导应该是交易者在此领域努力的中流砥柱。
DoEasy. 控件 (第 26 部分): 完成 ToolTip(工具提示)WinForms 对象,并转移至 ProgressBar(进度条)开发 DoEasy. 控件 (第 26 部分): 完成 ToolTip(工具提示)WinForms 对象,并转移至 ProgressBar(进度条)开发
在本文中,我将完成 ToolTip(工具提示)控件的开发,并启动 ProgressBar(进度条) WinForms 对象开发。 在处理对象时,我将针对控件及其组件开发动画处理的通用功能。
数据科学与机器学习(第 09 部分):K-最近邻算法(KNN) 数据科学与机器学习(第 09 部分):K-最近邻算法(KNN)
这是一种惰性算法,它不是基于训练数据集学习,而是以存储数据集替代,并在给定新样本时立即采取行动。 尽管它很简单,但它能用于各种实际应用。