English Русский Español Deutsch 日本語 Português
preview
神经网络变得轻松(第四十九部分):软性扮演者-评价者

神经网络变得轻松(第四十九部分):软性扮演者-评价者

MetaTrader 5交易系统 | 13 三月 2024, 09:59
318 1
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

我们继续精练在连续动作空间中运用强化学习解决问题的算法。在之前的文章中,我们研究了深度判定性策略梯度(DDPG)孪生延迟深度判定性策略梯度(TD3)算法。在本文中,我们将把注意力集中在另一种算法上 — 软性扮演者-评论者(SAC)。它首次出现在 2018 年 1 月 发表的文献 “软性扮演者-评论者:随机扮演者异政策最大熵值深度强化学习” 之中。该方法几乎与 TD3 同步提出。它们有一些相似之处,但在算法上也存在差异。SAC 的主要目标是在给定策略的最大熵的情况下最大化预期回报,其能在随机环境中找到各种最优解。


1. 软性扮演者-评价者算法

在研究 SAC 算法时,我们应该能立即注意到它不是 TD3 方法的直系后代(反之亦然)。但它们有一些相似之处。特别是:

  • 它们都是异政策算法
  • 它们都利用 DDPG 方法
  • 它们都用到 2 个评论者。

但不像之前讨论的两种方法,SAC 使用随机扮演者政策。这允许算法探索不同的策略,并找到最优解,同时考虑到扮演者动作的最大可变性。

说到环境的随机性,我们明白在 S 状态下,当执行 A 动作时,我们在 [Rmin, Rmax] 内获得 R 奖励,概率为 Psa

软性扮演者-评价者用到的扮演者具有随机政策。这意味着处于 S 状态的扮演者能够以一定的 Pa' 概率从整个动作空间中选择 A' 动作。换言之,在每个特定状态下,扮演者的政策允许我们不一定选择特定的最优动作,而是任何可能的行动(但具有一定程度的概率)。在训练过程中,扮演者学习获得最大奖励的概率分布。

随机扮演者政策的这一属性令我们能够探索不同的策略,并发现在运用判定性策略时可能隐藏的最优解。此外,随机扮演者政策还考虑到环境中的不确定性。在出现噪声或随机因素的情况下,该种类政策可能更具弹性和适应性,因为它们可以生成各种动作,以便有效地与环境交互。

不过,训练扮演者的随机政策也会对训练进行调整。经典强化学习旨在最大化预期回报。在训练过程中,对于每个 S 动作,我们选择 A* 动作,其最有可能给我们带来更大的盈利能力。这种判定性方式建立了明确的关系 St → At → St+1 ⇒ R,并且没有给随机动作留下余地。为了训练随机政略,软性扮演者-评论者算法的作者在奖励函数当中引入了熵正则化。

在这种情况下,entropy (H) 是衡量政策不确定性或多样性的指标。ɑ>0 参数是一个温度系数,令我们能够在所研究环境和动作模型之间取得平衡。

如您所知,熵是随机变量不确定性的度量,且由方程判定

注意,我们谈论的是 [0, 1] 范围内选择动作的概率的对数。在这个可接受值的间隔内,熵函数的图形正在降低,且位于正值区域。因此,选择动作的概率越低,奖励就越高,并且鼓励模型探索环境。

如您所见,有关于此,对 ɑ 超参数的选择提出了相当高的要求。目前,有多种选项可用于实现 SAC 算法。传统的固定参数方法就是其中之一。往往是,我们可以找到参数逐渐减少的实现。很容易就能看出,当 ɑ=0 时,我们得出了判定性强化学习。此外,在训练过程中,模型本身还有多种方式可以优化 ɑ 参数。

我们转入训练评论者。与 TD3 类似,SAC 并行训练 2 个评论者模型,并采用 MSE 作为损失函数。对于未来状态的预测值,从两个评论者目标模型中取较小值。但这里有两个关键区别。

第一个是上面讨论的奖励函数。我们对于当前和后续状态都使用熵正则化,并针对系统下一个状态的成本,参考应用一个折扣因子。

第二个区别是扮演者。SAC 不使用目标扮演者模型。若要选择当前和后续状态下的动作,会用到一个经过训练的扮演者模型。因此,我们强调,实现未来回报是利用现行政策来达成的。此外,使用单个扮演者模型可降低内存和计算资源的成本。

 

为了训练扮演者政策,我们采用 DDPG 方式。我们通过反向传播贯穿评论者模型的预测行动成本的误差梯度,来获得动作误差梯度。但不像 TD3(其中我们只用了 Critic 1 模型),SAC 的作者建议取用估算行动成本较低的模型。

此处还有一件事。在训练期间,我们更改政策,这会导致在系统特定状态下扮演者的行为发生变化。此外,随机扮演者政策的使用也有助于扮演者动作的多样性。同时,我们依据来自经验回放缓冲区的数据来训练模型,并为其它代理者动作提供奖励。在这种情况下,我们以理论假设为指导,即在训练扮演者的过程中,我们朝着最大化预测奖励的方向前进。这意味着,在任何 S 状态下,采用 π 政策的动作成本都不低于 π 政策的动作成本。

这是一个非常主观的假设,但它与我们的模型训练范式完全一致。为了不累积可能的误差,我建议在训练期间更频繁地更新经验回放缓冲区,同时考虑更新扮演者政策。

使用类似于 TD3 的 τ 因子平滑目标模型的更新。

这与 TD3 方法还有一点区别。软性扮演者-评论者算法在扮演者训练和更新目标模型时不能使用延迟。在此,所有模型都会在每个训练步骤中更新。

我们总结一下软性扮演者-评论者算法:

  • 在奖励函数中引入熵正则化。
  • 在训练开始时,扮演者和 2 个评论者模型以随机参数进行初始化。
  • 作为与环境交互的结果,将填充经验回放缓冲区。我们保持环境状态、动作、后续状态和奖励不变。
  • 填满经验回放缓冲区后,我们训练模型
    • 我们从经验回放缓冲区中随机提取一组数据
    • 判定未来状态的动作,同时考虑扮演者的当前政策
    • 使用至少 2 个目标评论者模型的当前政策来判定未来状态的预测值
    • 更新评论者模型
    • 更新动作政策
    • 更新目标模型。

训练模型的过程是迭代的,并重复进行,直至得到所需的结果,或达到评论者损失函数图形上的最小极值。


2. 利用 MQL5 实现

在软性扮演者-评论者算法理论概述之后,我们转入利用 MQL5 实现它。我们面临的第一件事是检测特定动作的概率。实际上,对于扮演者政策的表格化实现来说,这是一个非常简单的问题。但在使用神经网络时这会造成困难。毕竟,我们不保留有关环境条件和所采取动作的统计数据。它被“硬连线”到我们模型的可定制参数之中。有关于此,我想起了分布式 Q-训练。您也许还记得,我们谈到过有关预期回报概率分布的研究。分布式 Q-学习允许我们获得给定数量的固定区间奖励值的概率分布。完全参数化的 Q-函数(FQF)模型允许我们研究区间值及其概率。

2.1创建一个新的神经层类

继承自 CNeuronFQF 类,我们将创建一个新的神经层类来实现所提出的 CNeuronSoftActorCritic 算法。新类的方法集非常标准,但它也有自己的特性。

特别是,在我们的实现中,我们决定使用自定义熵正则化参数。为此目的,添加了 cAlphas 神经层。该实现使用 CNeuronConcatenate 类型的层。为了决定比率的大小,我们将用到当前状态的嵌入,以及在输出中用到分位数分布。

此外,我们还添加了一个单独的缓冲区来记录熵值,稍后我们将在奖励函数中用到它。

添加的两个对象都声明为静态,这允许我们将类构造函数和析构函数留空。

class CNeuronSoftActorCritic  :  public CNeuronFQF
  {
protected:
   CNeuronConcatenate   cAlphas;
   CBufferFloat         cLogProbs;
 
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronSoftActorCritic(void) {};
                    ~CNeuronSoftActorCritic(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint actions, uint quantiles, 
                          uint numInputs, ENUM_OPTIMIZATION optimization_type, uint batch);
   virtual bool      calcAlphaGradients(CNeuronBaseOCL *NeuronOCL);
   virtual bool      GetAlphaLogProbs(vector<float> &log_probs)       { return (cLogProbs.GetData(log_probs) > 0); }
   virtual bool      CalcLogProbs(CBufferFloat *buffer);
   //---
   virtual bool      Save(int const file_handle) override;
   virtual bool      Load(int const file_handle) override;
   //---
   virtual int       Type(void) override        const                 {  return defNeuronSoftActorCritic;          }
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

首先,我们将查看类的 Init 初始化方法。方法参数完全重复父类相似方法的参数。我们立即在方法主体中调用父类方法。我们经常使用这种技术,因为所有必要的控制都已在父类中实现了。所有继承对象的初始化也一并执行。一次检查父类方法的结果取代了所提及操作的完全控制。我们所要做的就是初始化添加的对象。

首先,我们初始化 ɑ 比率计算层。如上所述,我们将向该模型的输入提交当前状态的嵌入,其大小将等于前一个神经层的大小。此外,我们将在当前层的输出中添加一个分位数分布,该分位数分布将包含在内部层 cQuantile2 当中(其在父类中声明和初始化)。在 cAlphas 层的输出端,我们将获得每个单独动作的温度系数。相应地,层的大小将等于动作的数量。

系数应为非负数。为了满足这个需求,我们将该层的激活函数定义为 Sigmoid。

在方法结束时,我们用零值初始化熵缓冲区。它的大小也等于动作的数量。马上在当前 OpenCL 关联环境中创建缓冲区。

bool CNeuronSoftActorCritic::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                                  uint actions, uint quantiles, uint numInputs, 
                                  ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronFQF::Init(numOutputs, myIndex, open_cl, actions, quantiles, numInputs, optimization_type, batch))
      return false;
//---
   if(!cAlphas.Init(0, 0, OpenCL, actions, numInputs, cQuantile2.Neurons(), optimization_type, batch))
      return false;
   cAlphas.SetActivationFunction(SIGMOID);
//---
   if(!cLogProbs.BufferInit(actions, 0) || !cLogProbs.BufferCreate(OpenCL))
      return false;
//---
   return true;
  }

接下来,我们转入实现前向验算。在此,我们从父类借用训练分位数和概率分布的过程,不做任何修改。但我们必须加入一些过程,安排检测温度系数,以及计算熵值。甚至,虽然温度的计算涉及调用经由 cAlphas 层的直接验算,但检测熵值应从 “0” 开始实现。

我们必须计算扮演者每个动作的熵。在这个阶段,我们期望此处的动作不会太多。由于所有源数据都在 OpenCL 关联环境存储器当中,故逻辑上应将我们的操作转移到该环境。首先,我们将创建 OpenCL 内核程序 SAC_AlphaLogProbs,来实现此功能。

在内核参数中,我们将传递 5 个数据缓冲区和 2 个常量:

  • outputs — 结果缓冲区包含每个动作的分位数值的概率加权总和
  • quantiles — 平均分位数值(cQuantile2 内层结果缓冲区)
  • probs — 概率张量(cSoftMax 内层结果缓冲区)
  • alphas — 温度系数的向量
  • log_probs — 熵值的向量(在本例中,记录结果的缓冲区)
  • count_quants — 每个动作的分位数
  • activation — 激活函数类型。

CNeuronFQF 类在输出端不使用激活函数。我甚至要说这与类背后的观点相矛盾。毕竟,在模型训练过程中,预期奖励的分位数平均值的分布是由实际奖励本身来界定的。在我们的例子中,在自层输出端,我们期望从连续分布取得扮演者动作的确定值。由于各种技术或其它境况,代理者允许的动作界域也许会受到限制。激活函数允许我们这样做。但对于我们,在判定实际动作的概率后,获得所应用激活函数的实际概率估算非常重要。因此,我们将其实现添加到此内核之中。

__kernel void SAC_AlphaLogProbs(__global float *outputs,
                                __global float *quantiles,
                                __global float *probs,
                                __global float *alphas,
                                __global float *log_probs,
                                const int count_quants,
                                const int activation
                               )
  {
   const int i = get_global_id(0);
   int shift = i * count_quants;
   float quant1 = -1e37f;
   float quant2 = 1e37f;
   float prob1 = 0;
   float prob2 = 0;
   float value = outputs[i];

我们辨别内核主体中的当前操作流。它将向我们展示正在分析的操作的序号。然后,我们将检测分位数和概率缓冲区的偏移。

接下来,我们将声明局部变量。为了检测特定动作的概率,我们需要找到 2 个最接近的分位数。在 quant1 变量中,我们将写入最低分位数的平均值。quant2 变量将包含最接近顶部的分位数的平均值。在初始阶段,我们以明显的极值来初始化指定的变量。我们将相应的概率存储在 prob1 和 prob2 变量当中,此刻我们将用零值初始化它们。确切说,在我们的理解中,获得这种极值的概率是 “0”。

我们将来自缓冲区中的所需值保存到局部变量之中。

由于 OpenCL 关联环境的特殊内存组织,访问局部变量比从全局内存缓冲区检索数据快很多倍。依据局部变量操作,我们提升了整体 OpenCL 程序的性能。

现在我们已将所需值存储在局部变量之中,我们可以 毫不费力地将激活函数应用于神经层操作结果的缓冲区。

   switch(activation)
     {
      case 0:
         outputs[i] = tanh(value);
         break;
      case 1:
         outputs[i] = 1 / (1 + exp(-value));
         break;
      case 2:
         if(value < 0)
            outputs[i] = value * 0.01f;
         break;
      default:
         break;
     }

接下来,我们安排循环,搜索所有平均分位数值,并寻找最接近的值。 

此处应注意的是,我们并未针对平均分位数值进行排序。加权平均检测不受此影响,且我们之前已避免了执行不必要的操作。因此,在极高概率下,最接近所需值的分位数并不会位于分位数缓冲区的相邻元素当中。因此,我们要遍历所有值。

为免于将同一分位数的值写入两个变量,我们使用逻辑运算符 “>=” 作为下限,严格使用 “<” 作为上限。当一个分位数更接近先前存储的分位数时,我们将先前声明的相应变量中的值重写为分位数平均值及其概率。

   for(int q = 0; q < count_quants; q++)
     {
      float quant = quantiles[shift + q];
      if(value >= quant && quant1 < quant)
        {
         quant1 = quant;
         prob1 = probs[shift + q];
        }
      if(value < quant && quant2 > quant)
        {
         quant2 = quant;
         prob2 = probs[shift + q];
        }
     }

在完成循环的所有迭代后,我们的局部变量内将包含最接近分位数的数据。必要的值在该范围内的某处。然而,我们对动作概率分布的认知仅受所研究分布的限制。在这种情况下,我们假设两个最接近的分位数之间概率具有线性依赖性。有了足够多的分位数,考虑到实际动作区域的数值分布范围有限,我们的假设与事实相距不远。

   float prob = fabs(value - quant1) / fabs(quant2 - quant1);
   prob = clamp((1-prob) * prob1 + prob * prob2, 1.0e-3f, 1.0f);
   log_probs[i] = -alphas[i] * log(prob);
  }

在检测动作概率之后,我们检测动作的熵,并将结果值乘以温度系数。为了避免熵值过高,我将概率的下限限制在 0.001。

现在我们转入主程序。在此,我们为该类创建一个前向验算方法 CNeuronSoftActorCritic::feedForward。

如您所记,在此,我们广泛利用了继承对象中虚拟方法的能力。因此,方法参数完全重复了前面讨论过的所有类的相似方法。

在方法主体中,我们为了计算温度系数,首先调用父类的前向验算方法,和相似层方法。在此,我们只需检查这些方法的执行结果。

bool CNeuronSoftActorCritic::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!CNeuronFQF::feedForward(NeuronOCL))
      return false;
   if(!cAlphas.FeedForward(GetPointer(cQuantile0), cQuantile2.getOutput()))
      return false;

接下来,我们必须计算奖励函数的熵分量。为此,我们安排了启动上面讨论的内核的过程。我们将根据正在分析的动作数量在一维任务空间中运行它。

   uint global_work_offset[1] = {0};
   uint global_work_size[1] = {Neurons()};

与往常一样,在将内核放入执行队列之前,我们将初始数据传递给其参数。

   if(!OpenCL.SetArgumentBuffer(def_k_SAC_AlphaLogProbs, def_k_sac_alp_alphas, cAlphas.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_SAC_AlphaLogProbs, def_k_sac_alp_log_probs, cLogProbs.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_SAC_AlphaLogProbs, def_k_sac_alp_outputs, getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_SAC_AlphaLogProbs, def_k_sac_alp_probs, cSoftMax.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_SAC_AlphaLogProbs, def_k_sac_alp_quantiles, cQuantile2.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_SAC_AlphaLogProbs, def_k_sac_alp_count_quants, (int)(cSoftMax.Neurons() / global_work_size[0])))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_SAC_AlphaLogProbs, def_k_sac_alp_activation, (int)activation))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

请注意,我们不检查任何缓冲区。事实是,在父类的直接验算方法,以及该层计算温度比率的阶段,所有用到的缓冲区均已通过检查。唯一尚未检查的是记录内核操作结果的内部缓冲区。但这是一个内部对象。其创建是在类对象的初始化阶段控制的。无法从外部程序访问该对象。这里出错的概率非常低。因此,我们冒着这样的风险来加快我们的程序。

在方法结束时,我们将内核放入执行队列,并检查操作结果。

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

我想再次指出,在这种情况下,我们检查的是将内核放入执行队列的结果,而非在内核内部执行操作的结果。为了获得结果,我们需要将 cLogProbs 缓冲区数据加载到主内存之中。此功能是在 GetAlphaLogProbs 方法中实现的。该方法代码只有一行,且是在类结构描述模块中提供。

我们转入创建反向验算功能。该功能的主要部分已在父类方法中实现。尽管看起来很奇怪,但我们不至于重新定义贯穿神经层的误差梯度分配方法。事实是,熵正则化的误差梯度分布并不完全符合我们的一般结构。我们从评论者模型的最后一层按动作获得误差梯度。我们在奖励函数之中包含熵正则化本身。相应地,它的误差也将在奖励预测的级别上,即在评论者结果层的级别上。在此,我们遇到 2 个问题:

  1. 引入额外的梯度缓冲区将破坏反向验算方法的虚拟化模型。
  2. 在扮演者反向验算阶段,我们根本没有关于评论者的误差数据。有必要为整个模型构建一个新流程。

为了简化起见,我创建了一个新的并行过程,仅用于熵正则化误差的梯度,且无需彻底修改模型中的反向验算过程。

首先,我们将在 OpenCL 程序中创建一个内核。它的代码非常简单。我们只需将得到的误差梯度乘以熵即可。然后,我们通过层的激活函数的导数来调整结果值,以便计算温度比率。

__kernel void SAC_AlphaGradients(__global float *outputs,
                                 __global float *gradient,
                                 __global float *log_probs,
                                 __global float *alphas_grad,
                                 const int activation
                                )
  {
   const int i = get_global_id(0);
   float out = outputs[i];
//---
   float grad = -gradient[i] * log_probs[i];
   switch(activation)
     {
      case 0:
         out = clamp(out, -1.0f, 1.0f);
         grad = clamp(grad + out, -1.0f, 1.0f) - out;
         grad = grad * max(1 - pow(out, 2), 1.0e-4f);
         break;
      case 1:
         out = clamp(out, 0.0f, 1.0f);
         grad = clamp(grad + out, 0.0f, 1.0f) - out;
         grad = grad * max(out * (1 - out), 1.0e-4f);
         break;
      case 2:
         if(out < 0)
            grad = grad * 0.01f;
         break;
      default:
         break;
     }
//---
   alphas_grad[i] = grad;
  }

于此我们应当注意,为了简化计算,我们只需将梯度乘以来自 log_probs 缓冲区中的值即可。如您所记,在前向验算期间,我们在此设置熵值,同时考虑温度比率。从数学角度来看,我们需要将来自缓冲区的值除以该值。但对于温度,我们使用 sigmoid 作为激活函数。因此,其值始终在 [0,1] 范围内。除以小于 1 的正数只会增加误差梯度。在这种情况下,我们故意不这样做。

在完成 SAC_AlphaGradients 内核的工作后,我们把工作转入主程序,并创建 CNeuronSoftActorCritic::calcAlphaGradients 方法。在这个阶段,我们首先将内核放入执行队列中,然后调用内部对象的方法。因此,我们在开始该过程之前安排了一个控制单元。

bool CNeuronSoftActorCritic::calcAlphaGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!OpenCL || !NeuronOCL || !NeuronOCL.getGradient() ||
      !NeuronOCL.getGradientIndex()<0)
      return false;

接下来,我们定义内核的任务空间,并将输入数据传递给其参数。

   uint global_work_offset[1] = {0};
   uint global_work_size[1] = {Neurons()};
   if(!OpenCL.SetArgumentBuffer(def_k_SAC_AlphaGradients, def_k_sac_alg_outputs, cAlphas.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_SAC_AlphaGradients, def_k_sac_alg_alphas_grad, cAlphas.getGradientIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_SAC_AlphaGradients, def_k_sac_alg_gradient, NeuronOCL.getGradientIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_SAC_AlphaGradients, def_k_sac_alg_log_probs, cLogProbs.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_SAC_AlphaGradients, def_k_sac_alg_activation, (int)cAlphas.Activation()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

之后,我们将内核放入执行队列,并监视操作的执行。

   if(!OpenCL.Execute(def_k_SAC_AlphaGradients, 1, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }

在方法的最后,我们调用内部温度系数计算层的反向验算方法。

   return cAlphas.calcHiddenGradients(GetPointer(cQuantile0), cQuantile2.getOutput(), cQuantile2.getGradient());
  }

此外,我们将覆盖 CNeuronSoftActorCritic::updateInputWeights 神经层更新参数的方法。该方法算法十分简单。它只调用父类和内部对象的相似方法。该方法的完整代码可在附件中找到。您还可从中找到本文中所用的所有方法和类的完整代码,包括操控新类文件的方法,这些我现在都不会赘述。

2.2修改 CNet 类

新类完成后,我们声明一些常量,它们都服务于所创建的内核。我们还要把新内核添加到上下关联对象的初始化过程、以及 OpenCL 程序之中。在创建每个新内核时,我都讲解过该功能,50 多次了,故我不会再啰嗦。

我们的函数库功能不允许用户直接访问特定的神经层。整个交互过程都经由 CNet 类层次的整体模型功能构建的。为了获取熵分量的值,我们将创建 CNet::GetLogProbs 方法。

在参数中,该方法接收指向设置值的向量指针。

在方法主体中,我们安排了一个控件模块,并逐步降低对象的级别。首先,我们检查神经层的动态数组对象是否存在。然后我们去往下一层,检查指向最后一个神经层对象的指针。接下来,我们再往下走,检查最后一个神经层的类型。这应该是我们新的 CNeuronSoftActorCritic 层。

bool CNet::GetLogProbs(vectorf &log_probs)
  {
//---
   if(!layers)
      return false;
   int total = layers.Total();
   if(total <= 0 || !layers.At(total - 1))
      return false;
   CLayer *layer = layers.At(total - 1);
   if(!layer.At(0) || layer.At(0).Type() != defNeuronSoftActorCritic)
      return false;
//---
   CNeuronSoftActorCritic *neuron = layer.At(0);

仅当所有控制级别都成功通过之后,我们才会转向神经层的相似方法。

   return neuron.GetAlphaLogProbs(log_probs);
  }

请注意,在此阶段,我们仅限于模型中的最后一层。这意味着该层只能用作扮演者的最后一层。

此外,该方法仅从缓冲区读取数据,且不会启动计算。因此,仅当扮演者直接验之后调用它才有意义。事实上,这并非是一项限制。确切说,熵正则化仅用于在收集原始数据和训练模型时形成奖励。在这些过程中,扮演者依据生成动作执行前向验算才是主要的。

对于反向验算的需要,我们将创建 CNet::AlphasGradient 方法。正如我们上面所说,梯度的熵分布超出了我们之前所构建过程的界域。这也反映在方法算法当中。我们按这样一种方式构建了这种方法,我们将它称为评论者。在方法参数中,我们将传递指向扮演者对象的指针。

该方法的控制单元算法也要相应构建。首先,我们检查指向扮演者对象的指针是在有效期,并且它包含最新的 CNeuronSoftActorCritic 层。

bool CNet::AlphasGradient(CNet *PolicyNet)
  {
   if(!PolicyNet || !PolicyNet.layers)
      return false;
   int total = PolicyNet.layers.Total();
   if(total <= 0)
      return false;
   CLayer *layer = PolicyNet.layers.At(total - 1);
   if(!layer || !layer.At(0))
      return false;
   if(layer.At(0).Type() != defNeuronSoftActorCritic)
      return true;
//---
   CNeuronSoftActorCritic *neuron = layer.At(0);

控制模块的第二部分针对最后一个评论者层运作相似的检查。此处对于神经层的类型没有限制。

   if(!layers)
      return false;
   total = layers.Total();
   if(total <= 0 || !layers.At(total - 1))
      return false;
   layer = layers.At(total - 1);

所有控制成功通过之后,我们转向新神经层梯度的分配方法。

   return neuron.calcAlphaGradients((CNeuronBaseOCL*) layer.At(0));
  }

公平而言,使用完全参数化的模型可以让我们检测独立动作的概率。但它不允许创建真正的随机扮演者政策。扮演者随机性涉及从学习的分布中抽取动作,这在 OpenCL 关联环境端是无法做到的。在变分自动编码器中,为了解决类似的问题,我们曾用过一个重新参数化的技巧,并在主程序一端生成了一个随机值的向量。但在这种情况下,我们需要加载概率分布以便进行抽样。取而代之,在收集样本数据库阶段,我们将在某个环境中对计算出的值进行抽样(类似于 TD3),然后询问模型此类动作的熵。出于这些目的,我们将创建 CNet::CalcLogProbs 方法。它的算法类似于 GetLogProbs 方法的构造,但不像前一个,在参数中,我们将收到一个指向含有样本值的数据缓冲区指针。方法在同一缓冲区中进行操作的结果,我们将得到它们的概率。

附件中提供了所有类及其方法的完整代码。

2.3创建模型训练 EA

在完成为模型创建新对象的工作后,我们转入编排其创建和训练的过程。如前,将用到 3 个 EA:

  • 研究 — 收集样本数据库
  • 学习 — 模型训练
  • 测试 ― 检查得到的结果。

为了减少文章的篇幅,并节省您的时间,我将重点放在针对上一篇文章中相似智能系统版本所做的修改,按问题编排算法。

首先是模型架构。在此,我们只修改了最后一个扮演者层,将其替换为新的 CNeuronSoftActorCritic 类。我们按照动作数量、及每个动作的 32 个分位数来指定层大小(正如 FQF 方法的作者所建议的那样)。

我们用 sigmoid 作为激活函数,类似于上一篇文章中的实验。

bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic)
  {
//--- Actor
.........
.........
//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftActorCritic;
   descr.count = NActions;
   descr.window_out = 32;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- Critic
.........
.........
//---
   return true;
  }

“...\SoftActorCritic\Research.mq5” EA 算法已从上一篇文章中转移而来,几乎没做任何修改。历史数据收集模块和交易操作模块均未发生任何变化。仅在环境奖励方面修改了 OnTick 函数。如上所述,软性扮演者-评论者算法将熵正则化添加到奖励函数当中。

如前,我们采用账户余额的相对变化作为补偿。我们还针对缺乏持仓增加了处罚。但接下来我们需要添加熵正则化。我为此创建了上面提到的 CalcLogProbs 方法。但有一处细微差别。我们类的分位数分布存储的值直至激活函数起作用。在制定决策过程中,我们使用扮演者模型的激活结果。我们使用 sigmoid 作为扮演者输出端的激活函数。

通过数学变换,我们得出

我们用此属性,并将抽样动作调整为所需的形式。然后,我们将数据从向量传输到数据缓冲区,如果可能,将信息传输到 OpenCL 关联环境内存。 

在完成这样的准备工作后,我们询问扮演者所执行动作的熵。

注意,考虑到温度比率,我们得到了 6 个动作的熵。但我们的奖励是一个数字来评估当前状态和动作的整体。在这个实现中,我们使用了总熵值,它非常适合概率和对数的上下文,因为复杂事件的概率等于其组成部分事件的概率的乘积。乘积的对数等于各个因子的对数之和。不过,可能还有其它方法。在训练期间可以检查它们与每个个案的相适度。不要害怕实验。

void OnTick()
  {
//---
.........
.........
//---
   float reward = Account[0];
   if((buy_value + sell_value) == 0)
      reward -= (float)(atr / PrevBalance);
   for(ulong i = 0; i < temp.Size(); i++)
      sState.action[i] = temp[i];
   temp.Clip(0.001f, 0.999f);
   temp = MathLog((temp - 1.0f) * (-1.0f) / temp) * (-1);
   Result.AssignArray(temp);
   if(Result.GetIndex() >= 0)
      Result.BufferWrite();
   if(Actor.CalcLogProbs(Result))
     {
      Result.GetData(temp);
      reward += temp.Sum();
     }
   if(!Base.Add(sState, reward))
      ExpertRemove();
  }

最重要的修改是针对 “...\SoftActorCritic\Study.mq5” EA 中的模型训练。我们来仔细看看指定 EA 的 Train 函数。这是安排整个模型训练过程的地方。

在函数开始时,我们从经验回放缓冲区中抽样一组数据,就像之前一样。

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   uint ticks = GetTickCount();
//---
   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int tr = (int)((MathRand() / 32767.0) * (total_tr - 1));
      int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2));

接下来,我们检测未来状态的预测值。该算法重复了在 TD3 方法中实现的类似过程。唯一的区别是没有目标扮演者模型。在此,我们使用可训练的扮演者模型来判定未来状态下的动作。

      //--- Target
      State.AssignArray(Buffer[tr].States[i + 1].state);
      float PrevBalance = Buffer[tr].States[i].account[0];
      float PrevEquity = Buffer[tr].States[i].account[1];
      Account.Clear();
      Account.Add((Buffer[tr].States[i + 1].account[0] - PrevBalance) / PrevBalance);
      Account.Add(Buffer[tr].States[i + 1].account[1] / PrevBalance);
      Account.Add((Buffer[tr].States[i + 1].account[1] - PrevEquity) / PrevEquity);
      Account.Add(Buffer[tr].States[i + 1].account[2]);
      Account.Add(Buffer[tr].States[i + 1].account[3]);
      Account.Add(Buffer[tr].States[i + 1].account[4] / PrevBalance);
      Account.Add(Buffer[tr].States[i + 1].account[5] / PrevBalance);
      Account.Add(Buffer[tr].States[i + 1].account[6] / PrevBalance);
      //---
      if(Account.GetIndex() >= 0)
         Account.BufferWrite();

填充源数据缓冲区,调用扮演者的前向验算方法和 2 个评论者 目标模型。

      if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }
      //---
      if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
         !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

如 TD3 方法一样,我们使用最低的预测状态成本值来训练评论者。但在这种情况下,我们添加了一个熵分量。

      vector<float> log_prob;
      if(!Actor.GetLogProbs(log_prob))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
      TargetCritic1.getResults(Result);
      float reward = Result[0];
      TargetCritic2.getResults(Result);
      reward = Buffer[tr].Revards[i] + DiscFactor * (MathMin(reward, Result[0]) + log_prob.Sum() - Buffer[tr].Revards[i + 1]);

此处需要注意的是,在保存轨迹的过程中,考虑到折扣因素,我们保存了累计奖励额度,直到验算结束。在这种情况下,每次向新状态转换的独立奖励都包括熵正则化。为了训练评论者模型,我们调整了存储的累积奖励,考虑更新政策所用。为此,我们取后续状态的最低预测成本之间的差值,考虑到熵分量与保存在回放缓冲区中的该状态的累积奖励经验。按折扣系数调整结果值,并将其加到当前状态的保存值之中。在这种情况下,我们假设在优化模型的过程中动作成本不会降低。

接下来,我们将面对训练评论者模型阶段。为此,我们用系统的当前状态填充数据缓冲区。

      //--- Q-function study
      State.AssignArray(Buffer[tr].States[i].state);
      PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
      PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
      Account.Update(0, (Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance);
      Account.Update(1, Buffer[tr].States[i].account[1] / PrevBalance);
      Account.Update(2, (Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity);
      Account.Update(3, Buffer[tr].States[i].account[2]);
      Account.Update(4, Buffer[tr].States[i].account[3]);
      Account.Update(5, Buffer[tr].States[i].account[4] / PrevBalance);
      Account.Update(6, Buffer[tr].States[i].account[5] / PrevBalance);
      Account.Update(7, Buffer[tr].States[i].account[6] / PrevBalance);
      //---
      Account.BufferWrite();

请注意,在这种情况下,我们不再检查 OpenCL 关联环境中是否存在帐户状态描述缓冲区。保存数据后,我们立即调用将数据传输到关联环境的方法。因为我们所有的模型都在同一个 OpenCL 关联环境中工作,故这成为可能。我们之前已探讨过这种方式的优点。在目标模型上调用前向验算方法时,缓冲区已在关联环境中创建好了。否则,我们在执行时会收到错误。因此,我们现阶段不再将时间和资源浪费在不必要的验证上。

加载数据后,我们调用扮演者的前向验算方法,并加载奖励的熵分量。

      if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
      //---
      Actor.GetLogProbs(log_prob);

在此阶段,我们掌握了评论者的正向和反向验算的所有必要数据。但在此阶段,我们略微偏离了作者的算法。事实上,该方法的作者在更新了评论者的参数后,建议采用具有最低分数的评论者来更新扮演者的政策。根据我们的观察,尽管估算值存在偏差,但动作误差的梯度几乎没有变化。故此,我决定简单地轮换评论者模型。在偶数迭代中,我们根据经验回放缓冲区中的动作更新 Critic2 模型。我们基于第一位评论者的评估来训练扮演者的政策。

      Actions.AssignArray(Buffer[tr].States[i].action);
      if(Actions.GetIndex() >= 0)
         Actions.BufferWrite();
//---
      if((iter % 2) == 0)
        {
         if(!Critic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
            !Critic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         Result.Clear();
         Result.Add(reward-log_prob.Sum());
         if(!Critic1.backProp(Result, GetPointer(Actor)) ||
            !Critic1.AlphasGradient(GetPointer(Actor)) ||
            !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer) ||
            !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         Result.Update(0,Buffer[tr].Revards[i]);
         if(!Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
        }

在奇数迭代中更改 Critic1 模型。

      else
        {
         if(!Critic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
            !Critic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         Result.Clear();
         Result.Add(reward);
         if(!Critic2.backProp(Result, GetPointer(Actor)) ||
            !Critic2.AlphasGradient(GetPointer(Actor)) ||
            !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer) ||
            !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         Result.Update(0,Buffer[tr].Revards[i]);
         if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
        }

注意调用反向验算方法的顺序。首先,我们执行评论者反向验算。然后我们通过熵分量传递梯度。接下来,我们通过扮演者的主数据处理模块执行反向验算。这令我们能够根据评论者的需求定制卷积层。做完所有之后,我们针对扮演者执行彻底的反向验算,从而优化其动作政策。

在函数操作结束时,我们更新目标模型,并向用户显示信息消息,从而直观地监控训练过程。

      //--- Update Target Nets
      TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
      TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
      //---
      if(GetTickCount() - ticks > 500)
        {
         string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic1", 
                                    iter * 100.0 / (double)(Iterations), Critic1.getRecentAverageError());
         str += StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic2", 
                                    iter * 100.0 / (double)(Iterations), Critic2.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }
   Comment("");
//---
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic1", Critic1.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic2", Critic2.getRecentAverageError());
   ExpertRemove();
//---
  }

完整的智能系统代码可在文后附件中找到。在那里,您还可以找到测试 EA 代码。针对它所做的更改类似于主要数据收集 EA 中的更改,我们不再详述它们。


3. 测试

该模型依据 EURUSD H1 自 2023 年 1 月至 5 月期间的历史数据进行了训练和测试。指标参数和所有超参数均设置为默认值。

我深表遗憾,我必须承认,在撰写本文时,我没能训练出一个能够在训练集上产生盈利的模型。根据测试结果,我的模型在 5 个月的训练期间亏损了 3.8%。 

训练

从积极的一面来看,最大的盈利交易高于最大亏损交易的 3.6 倍。平均盈利交易仅略高于平均亏损交易。但可盈利交易份额为 49%。本质上,这 1% 不足以达到 “0”。

对于训练集之外的数据,形势几乎没有变化。甚至盈利交易的份增加到了 51%。但平均盈利交易的规模下降,再次造成亏损。

训练集之外的测试

模型在训练集之外的稳定性是一个积极因素。但问题仍然是我们如何摆脱亏损。也许,原因在于算法的变化,或膨胀的温度比率刺激了更多的市场研究。

此外,原因可能是抽样动作值过于分散。当抽样动作概率接近 “0” 时,高熵会令其奖励膨胀,这会扭曲扮演者政策。为了找到原因,我们需要额外的测试。我将与您分享他们的结果。


结束语

在本文中,我们介绍了软性扮演者-评论者(SAC)算法,该算法旨在解决连续动作空间中的问题。它基于最大化政策熵的思想,它允许代理者探索不同的策略,并在随机环境中找到最优解,同时考虑到最大化行动变数。

该方法的作者建议利用熵正则化,将其添加到训练标的物函数之中。这令算法能够鼓励对新动作的探索,防止它过于僵化、并固守在某些策略上。

我们利用 MQL5 实现了该方法,但不幸的是,我们未能训练出一个可盈利的策略。不过,经过训练的模型在训练集内外都表现出稳定的性能。这表明该方法能够普适所获得的经验,并将其转移到未知的环境条件下。

我给自己设定了一个目标,即寻找机会来训练可盈利的扮演者政策。结果将在稍后公布。


参考文献列表


本文中用到的程序

# 名称 类型 说明
1 Research.mq5 智能交易系统 样本收集 EA
2 Study.mq5  智能交易系统 代理者训练 EA
3 Test.mq5 智能交易系统 模型测试 EA
4 Trajectory.mqh 类库 系统状态定义结构
5 NeuroNet.mqh 类库 用于创建神经网络的类库
6 NeuroNet.cl 代码库 OpenCL 程序代码库

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

附加的文件 |
MQL5.zip (1294.79 KB)
最近评论 | 前往讨论 (1)
lidaxing
lidaxing | 13 3月 2024 在 10:36
我的交易软件mt4账号连接不上,无法交易下单。什么问题?
开发回放系统 — 市场模拟(第 19 部分):必要的调整 开发回放系统 — 市场模拟(第 19 部分):必要的调整
在此,我们要做好准备,如此当我们需要往代码里添加新函数时,就能顺滑轻松地发生。当前代码还不能涵盖或处理那些显著推进过程所必需的事情。我们需要将所有东西都结构化,以便能够以最小的工作量实现某些事情。如果我们正确地做好所有事情,我们就能得到一个真正通用的系统,可以轻松地适应任何需要处理的状况。
时间序列挖掘的数据标签(第2部分):使用Python制作带有趋势标记的数据集 时间序列挖掘的数据标签(第2部分):使用Python制作带有趋势标记的数据集
本系列文章介绍了几种时间序列标记方法,这些方法可以创建符合大多数人工智能模型的数据,而根据需要进行有针对性的数据标记可以使训练后的人工智能模型更符合预期设计,提高我们模型的准确性,甚至帮助模型实现质的飞跃!
为EA交易提供指标的现成模板(第2部分):交易量和比尔威廉姆斯指标 为EA交易提供指标的现成模板(第2部分):交易量和比尔威廉姆斯指标
在本文中,我们将研究交易量和比尔威廉姆斯指标类别的标准指标。我们将创建现成的模板,用于EA中的指标使用——声明和设置参数、指标初始化和析构,以及从EA中的指示符缓冲区接收数据和信号。
时间序列挖掘的数据标签(第1部分):通过EA操作图制作具有趋势标记的数据集 时间序列挖掘的数据标签(第1部分):通过EA操作图制作具有趋势标记的数据集
本系列文章介绍了几种时间序列标记方法,这些方法可以创建符合大多数人工智能模型的数据,而根据需要进行有针对性的数据标记可以使训练后的人工智能模型更符合预期设计,提高我们模型的准确性,甚至帮助模型实现质的飞跃!