English Русский Español Deutsch 日本語 Português
preview
神经网络变得简单(第 69 部分):基于密度的行为政策支持约束(SPOT)

神经网络变得简单(第 69 部分):基于密度的行为政策支持约束(SPOT)

MetaTrader 5交易系统 | 4 九月 2024, 10:29
32 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

离线强化学习允许基于从环境交互中收集的数据来训练模型。这明显能够减少与环境交互的过程。甚至,考虑到环境建模的复杂性,我们可以从多个研究智能体收集实时数据,然后使用这些数据训练模型。

同时,使用静态训练数据集会显著减少我们需用到的环境信息。由于资源有限,我们无法在训练数据集中保留环境的全部多样性。

然而,在学习 Agent 的最优政策的过程中,其动作大概率会超出训练数据集的分布范围。显然,由于缺乏来自环境的反馈,我们无法对该种动作进行真实的估测。由于训练数据集中缺乏数据,我们的 Critic 也无法生成相应的估测。在这种情况下,我们可以同时获得或高/或低的期望值。

必须说,高期望比低期望危险得多。如果估值被低估,模型可能会拒绝执行这些动作,这将导致去学习次优的 Agent 政策。在高估的情况下,模型则倾向于重复类似的动作,这可能导致运行过程中的重大损失。因此,在训练数据集中维护 Agent 的政策成为保证离线训练可靠性的一个重要方面。

用于解决此问题的各种离线强化学习方法都会采取参数化或正则化,其限制 Agent 的政策于训练数据集的支持集之内执行动作。详细的结构通常会干扰 Agent 模型,这会导致额外的运营成本,并阻止充分利用已建立的在线强化学习方法。正则化方法减少了学习政策和训练数据集之间的差异,其也许不符合基于密度的支持定义,因此无法有效地避免在分布之外起作用。

在这种情况下,我建议研究“支持政策优化(SPOT)”方法的适用性,该方法曾在《离线强化学习的支持政策优化》一文中讲述。其方式直接来自基于训练数据集密度分布的政策约束的理论形式化。SPOT 使用基于变分自动编码器(VAE)的密度估算器,其是一种简单而有效的正则化元素。它可以内置到现成的强化学习算法当中。SPOT 在标准离线 RL 基准测试中达成了一流的性能。多谢其灵活的设计,经 SPOT 离线预训练的模型也可在线优调。


1. 支持的政策优化(SPOT)算法

执行支持约束是减少离线强化学习中误差的典型方法。反过来,支持约束可以根据行为策略的密度形式化。支持政策优化方法的作者从行为密度的显式估测的角度提出了一种正则化算法。SPOT 包括一个正则化项,其直接来自密度支持约束的理论形式化。正则化元素使用条件变分自动编码器(CVAE)来学习训练数据集的密度。

类似于从最优 Q-函数中提取最优策略,支持的最优策略也可以使用贪婪选择来恢复:

在函数近似的情况下,其对应于约束策略优化问题。

与其它方法中用于约束支持的特定 Agent 政策参数化或发散惩罚不同,SPOT 的作者建议直接使用训练数据集的密度作为约束:

其中 ϵ'=log ϵ 是为便于标注。

在支持约束的上下文中,基于密度的行为约束简单明了。该方法的作者建议使用对数似然函数,替代概率,因为其在数学上更便捷。

反之,这又施加了额外的约束,由此行为策略的密度在状态空间的每个点上都受到约束。实际上不可能解决此类问题,即便存在庞大、甚至无限数量的约束。代之,SPOT 算法作者运用启发式近似值,其参考了平均行为密度:

我们将约束优化问题转换为无约束优化问题。为此,我们将约束项视为惩罚。故此,我们获得的政策学习意向为:

其中 λ 是拉格朗日(Lagrangian)乘子。

上面介绍的损失函数中的直接正则化项,需要访问收集训练数据集所用的行为政策。但我们只有该政策生成的离线数据。我们可以运用各种密度估计方法,显式估测任意点的概率密度。变分自动编码器 (VAE) 是最好的神经密度估测模型之一。该方法的作者决定采用条件变分自动编码器作为他们的密度估测器。训练完 VAE 后,我们可以简单地将其用作下限

上面介绍的通用框架可以建立在各种强化学习算法之上,只需进行最少的修改。在有关它们的论文中,该方法的作者采用 TD3 作为基础算法。


2. 利用 MQL5 实现

在研究了支持政策优化方法的理论方面之后,我们转到利用 MQL5 实现它。我们将基于 Real-ORL 方法文章中相关的智能系统来实现我们的模型。我要提醒你,所用的基本模型是基于接近 TD3 的软性扮演者-评论者方法,SPOT 的作者亦采用了这种方法。不过,我们的模型将由之前文章中讨论的众多方式加以补充。

首先,我们应该注意,SPOT 方法根据训练集中的数据密度增加了 Agent 政策的正则化。该正则化会在 Agent 政策离线训练阶段用到。它不会影响与环境交互的过程。故此,收集和测试训练数据集的智能系统将保持不变。您可以在附件中掌握它们。

因此,我们可以立即转至模型训练智能系统。不过,需要注意的是,在我们开始训练政策之前,我们先要训练训练数据集密度函数的自动编码器。因此,我们将学习过程分为 2 个阶段。自动编码器将在单独的智能系统 “...\SPOT\StudyCVAE.mq5” 中进行训练。

2.1密度模型训练

在我们开始构建密度模型训练 EA 之前,我们首先讨论一下我们将训练什么、以及如何训练。SPOT 方法的作者提议使用扩展的自动编码器来研究训练数据集的密度。从实践的观点来看,这意味着什么?

我们已经讨论了自动编码器压缩和恢复数据的属性。我们还提到,神经网络只能在类似于训练数据集的环境中稳定运行。故此,当我们向模型投喂的初始数据远离训练数据集分布时,其操作结果将接近随机值。因此,这会导致数据解码误差显著增加。我们将利用自动编码器模型的这些属性的组合。

我们依据来自训练数据集的 Agent 动作分布来训练自动编码器。在训练 Agent 的过程中,我们会将更新后 Agent 政策建议的动作馈送到自动编码器当中。数据解码中的误差将间接指示预测动作与训练数据集分布的距离。

如此,现在我们对功能有了一定的了解,它适合自动解码器的架构。但我们是否足以了解训练数据集中 Agent 动作的存在呢?我们非常清楚,在不同的环境条件下,相同的动作可能会产生完全相悖的结果。因此,我们必须训练自动编码器来提取不同环境状态下的动作分布。因此,我们得出结论,我们必须将“状态-动作”成对馈送到自动编码器的输入之中。在这种情况下,在自动编码器的输出端,我们期待接收馈送到输入端的 Agent 动作。

注意,当我们将“状态-动作”成对馈送到自动编码器的输入时,我们期待其在潜在状态下会有关于状态和动作的压缩信息。不过,通过训练自动编码器仅会解码动作,大概率我们会训练自动编码器忽略有关环境状态的信息。它还将使用潜在状态的整个大小来传输所需的动作。这最终将我们带回到无状态动作编码和解码的情况,这是极其不可取的。因此,将自动解码器的注意力集中在原始状态-动作数据的两个部件上对我们来说很重要。为了达成这个结果,该方法的作者用到扩展的自动编码器,其架构可为解码数据提供输入某个密匙。该密匙,与潜在表达一起被馈送到解码器的输入端。在本例中,我们将使用环境的状态作为密匙。

因此,我们必须构建一个自动编码器模型,其应该接收 3 个张量作为前馈传递的输入:

  • 环境状态(编码器输入)
  • 智能体动作(编码器输入)
  • 环境状态(解码器输入密匙)

以前,我们仅用到来自 2 个张量的初始数据构建模型。现在我们必须实现来自 3 个张量的初始数据。这个问题可以通过多种途径来解决。

首先,我们可以将状态-动作数据对组合成单一张量。然后密匙将作为源数据的第二个张量,这适合我们之前所用的模型,其中源数据包含 2 个张量。但是,将不同的环境数据和智能体动作相结合可能会对模型性能产生负面影响,并限制我们预处理原始环境数据的能力。

第二个选项是添加一种方法,用于操控含有 3 个原始数据张量的模型。这是一个劳力密集型过程,可能会导致为每个特定任务无休止地创建方法。这将令我们的函数库变得繁琐,且难以理解和维护。

在本文中,我选择了第三个选项,这对我来说似乎是最简单的。我们将创建单独的编码器和解码器模型。每个都会操控初始数据的 2 个张量。它们的实现完全符合我们之前开发的方法。

这是一个理论上的决定。现在我们转到描述自动编码器模型的架构。这将在 CreateCVAEDescriptions 方法中完成。我们将指向 2 个动态数组的指针馈送到方法之中,在其中我们将汇集 2 个模型的架构,即编码器和解码器。在方法的主体中,我们检查接收的指针,并在必要时创建动态数组对象的新实例。

bool CreateCVAEDescriptions(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 = 1000;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

接下来,我们压缩数据,同时使用一个卷积层模块提取已建立的形态。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = HistoryBars;
   descr.window = BarDescr;
   descr.step = BarDescr;
   int prev_wout = descr.window_out = BarDescr / 2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = prev_wout;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count;
   descr.window = prev_wout;
   descr.step = prev_wout;
   prev_wout = descr.window_out = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = prev_wout;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

以这种方式获得的环境状态嵌入与 Agent 动作向量相结合。

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = NActions;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

然后,我们用 2 个全连接层压缩数据。

//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * EmbeddingSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

在编码器的输出端,我们利用变分自动编码器的内层创建一个随机的潜在表示。

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

以下是解码器的架构说明。模型输入是由编码器生成的潜在表示。

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

我们立即将生成的张量与环境状态向量串联。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = EmbeddingSize;
   descr.window = prev_count;
   descr.step = (HistoryBars * BarDescr);
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

我们将描述环境状态的原始未处理数据输入到编码器之中,并在批量常规化层中执行它们的主要处理。但在解码器中,我们没有机会运作这样的常规化。我决定不对数据进行 2 次常规化。代之,在训练和运算过程中,我将从编码器中获取常规化后的数据。这将令我们能够简化一点解码器,并减少数据处理时间。

接下来,我们利用全连接层,依据收到的初始数据中重建动作向量。

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

在描述了我们的自动解码器架构之后,我们转到构建一个智能系统来训练这个自动解码器。如上所述,我们将训练 2 个模型:编码器和解码器。

CNet                 Encoder;
CNet                 Decoder;

在程序的 OnInit 初始化方法中,我们首先加载训练数据集。不要忘记检查操作结果,因为如果数据加载错误,就没有什么可以拿来训练模型。

//+------------------------------------------------------------------+
//| 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) ||
      !Decoder.Load(FileName + "Dec.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Init new CVAE");
      CArrayObj *encoder = new CArrayObj();
      CArrayObj *decoder = new CArrayObj();
      if(!CreateCVAEDescriptions(encoder,decoder))
        {
         delete encoder;
         delete decoder;
         return INIT_FAILED;
        }
      if(!Encoder.Create(encoder) || !Decoder.Create(decoder))
        {
         delete encoder;
         delete decoder;
         return INIT_FAILED;
        }
         delete encoder;
         delete decoder;
     }

然后,我们将两个模型移动到单个 OpenCL 关联环境之中,这允许我们在模型之间交换数据,而无需将它们转储到主程序的内存之中。

   OpenCL = Encoder.GetOpenCL();
   Decoder.SetOpenCL(OpenCL);

在此,我们针对加载(或创建)模型的架构进行最低限度的必要控制。请务必检查操作结果。

   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;
     }
//---
   Encoder.getResults(Result);
   int latent_state = Result.Total();
   Decoder.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Decoder doesn't match result of Encoder (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }

然后,我们初始化一个事件的创建,以便启动模型训练过程。之后,我们采用 INIT_SUCCEEDED 结果完成程序初始化方法。

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

在 OnDeinit 逆初方法中,我们保存经过训练的模型,并清除在程序中创建的对象的内存。

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//---
   Encoder.Save(FileName + "Enc.nnw", 0, 0, 0, TimeCurrent(), true);
   Decoder.Save(FileName + "Dec.nnw", Decoder.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   delete Result;
   delete OpenCL;
  }

请注意,我们将所有模型保存在终端通用子目录当中。这令它们在终端和策略测试器中都可供程序所用。

模型训练过程在 Train 方法中实现。在方法主体中,我们首先创建所需的局部变量。

//+------------------------------------------------------------------+
//| Train function                                                   |
//+------------------------------------------------------------------+
void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   uint ticks = GetTickCount();
   int bar = (HistoryBars - 1) * BarDescr;

然后我们创建一个训练循环。

   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int tr = int((MathRand() * MathRand() / MathPow(32767, 2)) * (total_tr));
      int i = int((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2));
      if(i < 0)
         continue;

注意,与我们近期的工作不同,我们在此未用到轨迹优先化。这是一个完全有意识、且有意的步骤。这是因为在该阶段,我们努力研究训练数据集中的真实数据密度。虽然使用轨迹优先级可能会扭曲信息,偏向具有更高优先级的轨迹。由此,我们在其中对轨迹和状态进行均匀抽样。

在对轨迹和状态进行抽样后,我们把来自训练数据集的数据填充到环境状态和 Agent 动作的描述缓冲区。

      State.AssignArray(Buffer[tr].States[i].state);
      Actions.AssignArray(Buffer[tr].States[i].action);
      if(Actions.GetIndex() >= 0)
         Actions.BufferWrite();

我记得通常在“环境描述”的概念中,我们包含一个描述账户状态和持仓的向量。在此,我并未关注账户的状态,因为开仓或持有的方向是由市场状态决定的。分析账户状态,以便管理风险,并检测持仓的规模。在这个阶段,我决定将过程限制在研究独立市场情况下的行动密度,而不关注风险管理模型。

在准备好初始数据缓冲区后,我们运行自动编码器的前馈验算。如上所述,我们在解码器输入处将指向编码器的指针馈送两次。在本例中,我们使用模型输出作为主要输入数据流。对于额外的输入数据流,我们从编码器批量常规化层中删除结果。确保监控整个过程。

      if(!Encoder.feedForward(GetPointer(State), 1,false, GetPointer(Actions)) ||
         !Decoder.feedForward(GetPointer(Encoder), -1, GetPointer(Encoder),1))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

在训练自动编码器的过程中,我们不需要分析或处理其操作结果。我们只需要指定目标值,为此我们使用 Agent 的动作向量。这与我们之前馈送到编码器中的向量相同。换言之,我们已经准备好了一个结果缓冲区,我们调用了两个自动编码器模型的反向传播方法。

      if(!Decoder.backProp(GetPointer(Actions), GetPointer(Encoder), 1) ||
         !Encoder.backPropGradient(GetPointer(Actions), GetPointer(Actions)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

注意,编码器会根据从解码器接收到的误差梯度来更新其参数。而且我们不需要为编码器生成单独的目标缓冲区。

这样就完成了自动编码器训练的一次迭代操作。我们所要做的就是将操作的进度告知用户,并转到模型训练循环的下一次迭代。

      if(GetTickCount() - ticks > 500)
        {
         string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Decoder", iter * 100.0 / (double)(Iterations), 
                                                                                    Decoder.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }

在此,我们只显示解码器的误差信息,因为误差并非是为编码器而计算。

在成功完成自动编码器训练循环的所有迭代后,我们清除图表的注释字段,并启动终止 EA 的过程。

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

完整的智能系统代码可在附件中找到。本文中用到的所有程序也都出现在那里。

2.2Agent 政策训练

在训练密度模型之后,我们继续进行智能体政策训练智能交易系统 “...\SPOT\Study.mq5”。Agent 训练过程几乎没有变化。它在规范其行为政策方面仅得到了略微的补充。所有训练模型的架构也被原封不动地复制。因此,我们只看看 EA 的一些方法 “...\SPOT\Study.mq5”。您可在附件中找到其完整代码。

无论 Agent 的政策训练算法的变化有多小,它们都涉及经过更高训练的自动编码器模型。我们需要将它们添加到程序之中。

STrajectory          Buffer[];
CNet                 Actor;
CNet                 Critic1;
CNet                 Critic2;
CNet                 TargetCritic1;
CNet                 TargetCritic2;
CNet                 Convolution;
CNet                 Encoder;
CNet                 Decoder;

在 OnInit 程序初始化方法中,我们和以前一样,加载训练数据集,并控制操作的执行。

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

然后,甚至在加载经过训练的模型之前,我们就会加载自动编码器。如果无法加载模型,我们会通知用户,并取 INIT_FAILED 结果终止初始化方法。

//--- load models
   float temp;
   if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true) ||
      !Decoder.Load(FileName + "Dec.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Cann't load CVAE");
      return INIT_FAILED;
     }

注意,在没有预训练模型的情况下,我们不会使用随机参数创建新的模型。由于未经训练的模型只会扭曲学习过程,而这种训练的结果将是不可预测的。

另一方面,我们可以添加一个标志,在没有经过训练的自动编码器模型的情况下,训练 Agent 的政策,而无需像以前那样正则化其动作。在操控实际问题时,我可能会这样做。但在这种情况下,我们要评估正则化的工作。因此,中断程序是“人为因素”的额外控制点。

接下来,我们加载经过训练的模型,并在必要时创建采用随机参数初始化的新模型。

   if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Init new models");
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      CArrayObj *convolution = new CArrayObj();
      if(!CreateDescriptions(actor, critic, convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || !Critic1.Create(critic) || !Critic2.Create(critic) ||
         !Convolution.Create(convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!TargetCritic1.Create(critic) || !TargetCritic2.Create(critic))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      delete convolution;
      //---
      TargetCritic1.WeightsUpdate(GetPointer(Critic1), 1.0f);
      TargetCritic2.WeightsUpdate(GetPointer(Critic2), 1.0f);
      StartTargetIter = StartTargetIteration;
     }
   else
      StartTargetIter = 0;

   if(!Convolution.Load(FileName + "CNN.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Init new Encoder model");
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      CArrayObj *convolution = new CArrayObj();
      if(!CreateDescriptions(actor, critic, convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!Convolution.Create(convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      delete convolution;
     }

一旦新模型成功加载和/或初始化,我们就会将它们移到单个 OpenCL 关联环境之中。此外,在学习模型中,我们禁用了参数更新模式。也就是说,在此阶段,我们不会针对自动编码器进行额外的下游训练。

   OpenCL = Actor.GetOpenCL();
   Critic1.SetOpenCL(OpenCL);
   Critic2.SetOpenCL(OpenCL);
   TargetCritic1.SetOpenCL(OpenCL);
   TargetCritic2.SetOpenCL(OpenCL);
   Convolution.SetOpenCL(OpenCL);
   Encoder.SetOpenCL(OpenCL);
   Decoder.SetOpenCL(OpenCL);
   Encoder.TrainMode(false);
   Decoder.TrainMode(false);

这里需要注意的一点是,虽然随机编码器也没有经过训练,但我们没有改变它的训练模式标志。没有必要这样做。学习模式更改方法不会删除未使用的缓冲区。因此,它不会清除内存。它只是更改了控制反向传播算法的标志。我们不会在程序中调用编码器的反向传播方法。这意味着更改随机编码器训练标志的影响接近于零。对于自动编码器,情况略有不同。我们稍后将在模型训练 Train 方法中研究它。现在我们回到 EA 初始化方法。

在创建模型,并将其传输到单个 OpenCL 关联环境之后,我们对它们的架构与程序中所用的常量的合规性执行最低限度的控制。

首先,我们检查 Actor 的结果层的大小是否与 Agent 的动作向量的大小匹配。

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

Actor 的初始数据的大小必须与描述环境状态的向量的大小相对应。

   Actor.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Actor doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr));
      return INIT_FAILED;
     }

我们还要确保检查 Actor 的潜在层的大小与 Critic 的源数据缓冲区之间的对应关系。

   Actor.GetLayerOutput(LatentLayer, Result);
   int latent_state = Result.Total();
   Critic1.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Critic doesn't match latent state Actor (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }

我们对自动编码器的 Encoder 和 Decoder 模型进行类似的检查。

   Decoder.getResults(Result);
   if(Result.Total() != NActions)
     {
      PrintFormat("The scope of the Decoder 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;
     }

   Encoder.getResults(Result);
   latent_state = Result.Total();
   Decoder.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Decoder doesn't match result of Encoder (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }

模型准备工作到此结束。我们初始化辅助缓冲区,并生成一个事件来启动学习过程。

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

然后,我们完成初始化 EA 交易的方法,并得到肯定的结果。

由于我们在训练过程中不会改变自动编码器模型的参数,因此在程序完成后我们不需要保存它们。因此,OnDeinit 方法保持不变。您可以在附件中找到其代码。接下来,我们转到训练模型的过程。好了,我们来研究 Train 方法。

与上面讨论的密度模型训练方法相比,Actor 政策训练方法的算法更加全面和复杂。我们更详细地讨论一下。

在该方法开始时,我们准备了几个局部变量和矩阵,我们将在稍后的训练模型过程中用到它们。 

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   uint ticks = GetTickCount();
//---
   int total_states = Buffer[0].Total;
   for(int i = 1; i < total_tr; i++)
      total_states += Buffer[i].Total;
   vector<float> temp, next;
   Convolution.getResults(temp);
   matrix<float> state_embedding = matrix<float>::Zeros(total_states, temp.Size());
   matrix<float> rewards = matrix<float>::Zeros(total_states, NRewards);
   matrix<float> actions = matrix<float>::Zeros(total_states, NActions);

接下来,我们创建一个循环系统,从经验回放缓冲区生成所有状态的嵌入。我们系统的外部循环将迭代训练数据集中的轨迹。嵌套循环将迭代 Agent 在传递轨迹时访问的环境状态。

   int state = 0;
   for(int tr = 0; tr < total_tr; tr++)
     {
      for(int st = 0; st < Buffer[tr].Total; st++)
        {
         State.AssignArray(Buffer[tr].States[st].state);

在循环系统的主体中,我们从训练样本中加载一个描述环境特定状态的向量。补充账户状态和持仓的描述。

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

 此处,我们将时间戳的谐波添加到缓冲区中。

         double x = (double)Buffer[tr].States[st].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_MN1);
         State.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_W1);
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_D1);
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         State.AddArray(vector<float>::Zeros(NActions));

为了评估状态而不管 Agent 采取什么动作,我们用零值填充缓冲区的其余部分。

成功填充源数据缓冲区之后,我们调用随机编码器的前馈验算方法。

         if(!Convolution.feedForward((CBufferFloat *)GetPointer(State), 1, false, (CBufferFloat *)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            ExpertRemove();
            return;
           }

我们将它的工作结果保存在 embedding 矩阵当中。

         Convolution.getResults(temp);
         if(!state_embedding.Row(temp, state))
            continue;

同时,我们会保存已完成的操作,以及来自后续转换的奖励。

         if(!temp.Assign(Buffer[tr].States[st].action) ||
            !actions.Row(temp, state))
            continue;
         if(!temp.Assign(Buffer[tr].States[st].rewards) ||
            !next.Assign(Buffer[tr].States[st + 1].rewards) ||
            !rewards.Row(temp - next * DiscFactor, state))
            continue;

在成功将所有实例添加到局部矩阵后,我们递增已处理状态的计数器。我们告知用户状态嵌入过程的进度,并转到循环系统的下一次迭代。

         state++;
         if(GetTickCount() - ticks > 500)
           {
            string str = StringFormat("%-15s %6.2f%%", "Embedding ", state * 100.0 / (double)(total_states));
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

在循环系统的所有迭代成功完成后,如有必要,我们将局部矩阵的大小调整为正在使用的数据的实际大小。

   if(state != total_states)
     {
      rewards.Resize(state, NRewards);
      actions.Resize(state, NActions);
      state_embedding.Reshape(state, state_embedding.Cols());
      total_states = state;
     }

然后,我们转到准备工作的下一阶段,在该阶段,我们准备了一些局部变量,并检测来自模型训练过程的训练数据集中抽样轨迹的优先级。

   vector<float> rewards1, rewards2, target_reward;
   STarget target;
   int bar = (HistoryBars - 1) * BarDescr;
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);

此时,准备工作已完成,我们直接开始训练模型。为此,我们取 EA 外部参数中指定的迭代次数创建一个训练循环。

在循环的主体中,我们参考优先级对轨迹进行抽样,并在其上随机选择一个状态。

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

接下来,根据 SAC 方法,我们需要计算直到该局结束的预期奖励。为此,我们使用 Critic 的目标模型。不过,我们将仅使用预先训练的模型来执行这些操作。因此,在开始操作之前,我们会检查是否已完成所需的最低初步训练迭代次数。

      target_reward = vector<float>::Zeros(NRewards);
      //--- Target
      if(iter >= StartTargetIter)
        {
         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);

此外,我们将时间戳谐波添加到同一个缓冲区当中。

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

所收集数据足以完成 Actor 的前馈验算。

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

注意,我们正在依据后续环境状态训练的 Actor 模型上调用前馈验算方法。这将根据更新的政策生成 Actor 动作。因此,目标 Critics 会估测更新后的政策到该局结束的预期奖励。

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

我们使用从 2 个目标 Critics 收到的最低分数作为后续操作的预期值。

         TargetCritic1.getResults(rewards1);
         TargetCritic2.getResults(rewards2);
         target_reward.Assign(Buffer[tr].States[i + 1].rewards);
         if(rewards1.Sum() <= rewards2.Sum())
            target_reward = rewards1 - target_reward;
         else
            target_reward = rewards2 - target_reward;
         target_reward *= DiscFactor;
         target_reward[NRewards - 1] = EntropyLatentState(Actor);
        }

下一步,我们训练我们的 Critics。为了确保估测的正确性,训练基于训练数据集中实际动作和奖励的比较。我要提醒您,在我们的模型中,我们使用 Actor 来预处理环境状态。因此,如前,我们使用环境采样状态的描述来填充初始数据缓冲区。

      //--- Q-function study
      State.AssignArray(Buffer[tr].States[i].state);

我们填充描述账户状态和持仓缓冲区。

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

添加时间戳状态。

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

接下来,执行 Actor 的前馈验算。

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

请注意,在该阶段,我们有一套完整的数据来执行自动编码器的前馈验算。我们不会把现在能做的事情推迟到以后。如此,我们调用编码器和解码器的验算方法。

      if(!Encoder.feedForward((CBufferFloat *)GetPointer(State), 1, false, (CNet *)GetPointer(Actor)) ||
         !Decoder.feedForward(GetPointer(Encoder), -1, GetPointer(Encoder), 1))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

如上所述,Critic 根据训练数据集中 Actor 的实际动作进行训练。因此,我们将它们加载到数据缓冲区之中,并调用两个 Critic 的前馈方法。

      Actions.AssignArray(Buffer[tr].States[i].action);
      if(Actions.GetIndex() >= 0)
         Actions.BufferWrite();
      //---
      if(!Critic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)) ||
         !Critic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

接下来,我们用必要的数据补充当前状态描述缓冲区,并采用随机编码器执行分析状态的嵌入。

      if(!State.AddArray(GetPointer(Account)) || !State.AddArray(vector<float>::Zeros(NActions)) ||
         !Convolution.feedForward((CBufferFloat *)GetPointer(State), 1, false, (CBufferFloat *)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

根据嵌入结果,我们为 Actor 和 Critics 生成目标值。

      Convolution.getResults(temp);
      target = GetTargets(Quant, temp, state_embedding, rewards, actions);

之后,我们更新 Critic 的参数。正如我们之前所看到的,CAGrad 方法调整梯度向量,从而提高模型收敛性。

      Critic1.getResults(rewards1);
      Result.AssignArray(CAGrad(target.rewards + target_reward - rewards1) + rewards1);
      if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

      Critic2.getResults(rewards2);
      Result.AssignArray(CAGrad(target.rewards + target_reward - rewards2) + rewards2);
      if(!Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

成功更新 Critic 模型后,我们继续优化 Actor 政策。该过程可分为 3 个模块。在第一个模块中,我们调整 Agent 的政策,以便从训练数据集收集的动作中按类似状态重复执行特定动作,并据收到的奖励加权。

      //--- Policy study
      Actor.getResults(rewards1);
      Result.AssignArray(CAGrad(target.actions - rewards1) + rewards1);
      if(!Actor.backProp(Result, GetPointer(Account), GetPointer(Gradient)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

在第二阶段,我们使用自动编码器的结果,并检查生成的 Agent 动作与训练数据的偏差。如果超过 Action 的解码误差阈值,我们将尝试把 Actor 的政策返回到训练数据集分布。为此,我们运行自动编码器的反向传播验算,并将编码误差作为误差梯度直接传递给 Actor,类似于从 Critic 传递误差梯度。为了安全实现此操作,我们在程序初始化阶段禁用编码器和解码器中的学习模式。

      Decoder.getResults(rewards2);
      if(rewards2.Loss(rewards1, LOSS_MSE) > MeanCVAEError)
        {
         Actions.AssignArray(rewards1);
         if(!Decoder.backProp(GetPointer(Actions), GetPointer(Encoder), 1) ||
            !Encoder.backPropGradient((CNet*)GetPointer(Actor)) ||
            !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
        }

在训练 Actor 政策的下一阶段,我们会检查 Critic 预测的可靠性。如果预测足够有信心,我们会调整 Actor 的政策,以达到最有可能的最大奖励。在这个阶段,我们还关闭了 Critic 参数更新模式,从而避免模型相互适配的效果。 

      CNet *critic = NULL;
      if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError())
         critic = GetPointer(Critic1);
      else
         critic = GetPointer(Critic2);
      if(MathAbs(critic.getRecentAverageError()) <= MaxErrorActorStudy)
        {
         if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         critic.getResults(rewards1);
         Result.AssignArray(CAGrad(target.rewards + target_reward - rewards1) + rewards1);
         critic.TrainMode(false);
         if(!critic.backProp(Result, GetPointer(Actor)) ||
            !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            critic.TrainMode(true);
            break;
           }
         critic.TrainMode(true);
        }

接下来,我们需要更新目标 Critic 模型。

      //--- Update Target Nets
      if(iter >= StartTargetIter)
        {
         TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
         TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
        }
      else
        {
         TargetCritic1.WeightsUpdate(GetPointer(Critic1), 1);
         TargetCritic2.WeightsUpdate(GetPointer(Critic2), 1);
        }

我们还需要告知用户学习过程的进度。

      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());
         str += StringFormat("%-14s %5.2f%% -> Error %15.8f\n", "Actor", iter * 100.0 / (double)(Iterations), 
                                                                                      Actor.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }

完成模型训练周期的所有迭代后,我们清除图表上的注释字段。我们还将有关模型训练结果的信息输出到日志中,并启动 EA 终止。

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

我们利用 MQL5 实现支持政策优化方法的工作到此结束。在附件中可找到文章中用到的所有程序的完整代码。现在我们转到本文的下一部分,其中我们将使用一个实际案例来检查结果。


3. 测试

我们已经利用 MQL5 工具实现了支持政策优化(SPOT) 方法。现在是时候在实践中检验我们的工作成果了。如常,我想提请您注意这样一个事实,即在该项工作中,针对该方法作者所提议方式,我展示了自己看法。甚而,它们被叠加在以前所用其它方法创建的开发项目上。结果就是,我们构建了一个模型,作为我对流程的愿景收集的各种想法的集合体。因此,所有可能观察到的缺点都不能完全投影到所使用的任何方法上。

如前,这些模型依据 EURUSD H1 的历史数据进行了训练和测试。所有指标都采用默认参数。该模型取 2023 年前 7 个月的数据进行训练。为了测试已训练模型,我们用到了 2023 年 8 月的历史数据。

如上所述,与环境交互的模型保持不变。因此,对于训练的第一阶段,我们可以使用为 Real-ORL 文章的一部分收集的训练数据集,它是模型的供体。我复制了训练数据集,并将其保存为 “SPOT.bd”。

在第一阶段,我们训练自动编码器。训练数据集包括 500 条轨迹,每条轨迹有 3591 个环境状态。这总共相当于近 180 万个“状态-行动-奖励”集合。我运行了 5 个自动编码器训练循环,每个循环都有 50 万次迭代,这比训练数据集的规模大了 40%。

在自动编码器的初始训练之后,我们开始在 EA “...\SPOT\Study.mq5” 中训练模型的过程。注意,模型训练过程的持续时间明显超过自动编码器训练时间。

还应该注意的是,将 Agent 的政策保留在训练数据集中,没有希望获得优于训练数据集中的验算结果。因此,为了获得更优政策,我们需要迭代更新经验回放缓冲区,并更新模型,包括自动编码器。

因此,在模型训练过程的同时,我在策略测试器中运行 “ResearchExORL.mq5” EA 的优化,以研究训练集之外的策略。

在完成模型训练循环后,我们执行 “Research.mq5” EA 的 200 次优化,它在学习 Actor 政策的某些环境中探索环境。

基于更新的训练集,我们重复自动编码器训练 50 万次迭代。然后执行 Actor 政策的下游训练。

作为几个训练循环的结果,我设法训练了能够在训练和测试历史期间产生利润的 Actor 政策。2023 年 8 月的模型结果如下所示。

测试结果

测试结果

从提供的数据中可以看出,在测试策略的一个月中,该模型进行了 124 笔交易(92 笔空头和 32 笔多头)。其中,近 47% 获利了结。值得注意的是,盈利的多头和空头持仓的份额很接近(分别为 50% 和 46%)。甚至,平均盈利交易比平均亏损高 25%。最大的盈利交易几乎是最大亏损的 2 倍。一般来说,基于交易结果,盈利因子为 1.15。


结束语

在本文中,我们明晰了支持政策优化(SPOT) 方法,它代表了在有限训练数据集条件下离线学习问题的成功解决方案。它根据估测的行为策略密度调整政策的能力表明,它在标准测试场景中表现出卓越的性能。SPOT 可轻松集成到现有的离线 RL 算法中,为不同上下文中的应用提供灵活性。其模块化结构允许与不同的学习方法配合使用。

SPOT 的一个独特功能是它使用基于训练集数据密度的显式估测的正则化。这提供了对可接受的政策动作的精确控制,并有效地防止了超出训练数据集的外推。

在实践部分,我们利用 MQL5 实现了我们所提议方法的愿景。基于测试结果,我们能得出关于这种方法有效性的结论。在训练过程中,我们还可以注意到过程的稳定性。基于训练结果,我们设法为 Actor 的行为找到了一个可盈利的策略。

不过,请注意,将 Actor 的政策保持在训练数据集内,会限制刺激其研究外推。一方面,这令学习过程更加稳定。另一方面,它限制了探索环境未知子空间的可能性。基于此,我们可以得出结论,当训练数据集已有次优验算时,使用该方法有可能更有效。

同时,为了刺激对环境的探索,您可以尝试 “翻折” 该方法,并刺激研究训练数据集之外的动作。但这是未来研究的话题。


参考

  • 支持离线强化学习的策略优化
  • 神经网络变得简单(第 67 部分):利用过去的经验解决新问题

  • 文中所用程序

    # 已发行 类型 说明
    1 Research.mq5 EA 样本收集 EA
    2 ResearchRealORL.mq5
    EA
    用于使用 Real-ORL 方法收集示例的 EA
    3 ResearchExORL.mq5 EA 使用 ExORL 方法收集示例的 EA
    4 Study.mq5  EA 智能体训练 EA
    5 StudyCVAE.mq5 EA
    自动编码器学习智能系统
    6 Test.mq5 EA 模型测试 EA
    7 Trajectory.mqh 类库 系统状态定义结构
    8 NeuroNet.mqh 类库 创建神经网络的类库
    9 NeuroNet.cl 代码库 OpenCL 程序代码库


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

    附加的文件 |
    MQL5.zip (653.77 KB)
    交易策略 交易策略
    各种交易策略的分类都是任意的,下面这种分类强调从交易的基本概念上分类。
    时间序列分类问题中的因果推理 时间序列分类问题中的因果推理
    在本文中,我们将研究使用机器学习的因果推理理论,以及 Python 中的自定义方法实现。因果推理和因果思维植根于哲学和心理学,在我们理解现实中起着重要作用。
    新手在交易中的10个基本错误 新手在交易中的10个基本错误
    新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
    MQL5 简介(第 3 部分):掌握 MQL5 的核心元素 MQL5 简介(第 3 部分):掌握 MQL5 的核心元素
    在这篇便于初学者阅读的文章中,我们将为您揭开数组、自定义函数、预处理器和事件处理的神秘面纱,并对所有内容进行清晰讲解,让您可以轻松理解每一行代码,从而探索 MQL5 编程的基础知识。加入我们,用一种独特的方法释放 MQL5 的力量,确保每一步都能理解。本文为掌握 MQL5 奠定了基础,强调了对每行代码的解释,并提供了独特而丰富的学习体验。