English Русский Español Deutsch 日本語 Português
preview
神经网络变得轻松(第四十五部分):训练状态探索技能

神经网络变得轻松(第四十五部分):训练状态探索技能

MetaTrader 5交易系统 | 20 十二月 2023, 07:27
2 158 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

分层强化学习算法可以成功解决相当复杂的问题。 这是通过将问题切分为更小的子任务来达成的。 在这种境况下,主要问题之一是正确选择和训练技能,令代理者能够有效地采取行动,并在可能的情况下尽可能多地管理环境以达成目标。

之前,我们已领略了 DIAYNDADS 技能训练算法。 在第一种情况下,我们所教导的是拥有最大多样性行为的技能,从而确保对环境的最大探索。 与此同时,我们也乐于训练对我们当前任务无用的技能。

在第二种算法(DADS)中,我们从技能对环境的影响这一角度来学习技能。 在此,我们旨在预测环境动态,并使用能令我们从变化中获得最大收益的技能。

在这两种情况下,先前分派的技能都被用作针对代理者的输入,并在训练过程中进行探索。 这种方式的实际应用表现出对状态空间的覆盖不足。 有因于此,已训练技能无法有效地与所有可能的环境状态进行交互。

在本文中,我建议去领略一些教导技能的替代方法 探索、发现和学习(EDL)。 EDL 从不同的角度对待该问题,这令其能够克服状态覆盖有限的问题,并提供更灵活、和适应性更强的代理者行为。


1. “探索、发现和学习”算法

探索、发现和学习(EDL)方法于 2020 年 8 月发表的科学性文章 “探索、发现和学习:状态覆盖技能的无监督发现”中提出。 它提出了一种方式,允许代理者在没有任何状态和技能先验知识的情况下,发现和学习能在环境中使用不同的技能。 它还允许训练跨越不同状态的各种技能,从而可在未知环境中代理者的探索和学习更有效。

EDL 方法具有固定的结构,由三个主要阶段组成:探索、发现、和技能训练。

我们在没有任何环境和所需技能的先验知识的情况下开始探索。 在这个阶段,我们必须创建一个初始状态的训练集,最大程度地覆盖与所有可能环境行为相对应的各种状态。 在我们的工作中,我们将在训练期间使用统一采样的系统状态。 不过,其它方式也是可能的,尤其是在训练特殊的代理者行为模式时。 应该注意的是,EDL 不需要访问由智能策略造就的轨迹或操作。 但它也不排除会用到它们。

在第二阶段,我们搜素隐藏在特定环境条件下的技能。 这种方法的基本思想是,环境的状态(或状态空间)与代理者应该使用的特定技能之间存在某种联系。 我们必须判定这种依赖关系。

应该指出的是,在这个阶段,我们对环境条件一无所知。 只有这种状态的样本。 甚至,我们缺乏有关必要技能的知识。 与此同时,我们之前曾注意到,EDL方法涉及在没有教导员的情况下发现技能。 该算法使用变分自动编码器来搜索指定的依赖项。 在模型输入和输出中都存在环境状态。 在自动编码器的潜在状态下,我们期望从环境的当前状态中获得潜在技能的识别。 在这种方式中,我们的自动编码器构建了一个函数,即技能如何依赖于环境的当前状态。 模型解码器执行逆函数,并依据所用技能构建状态的依赖性。 使用变分自动编码器可以令我们从清晰的“状态-技能”对应关系,转移至某种概率分布。 这通常会提高模型在复杂随机环境中的稳定性。

因此,在缺乏有关状态和技能的附加知识的情况下,使用 EDL 方法中的变分自动编码器为我们提供了探索和发现与不同环境状态相关的隐藏技能的机会。 建立环境状态与所需技能之间关系的函数,令我们能够把环境的新状态解释成在未来的一组最相关技能。

请注意,在前面讨论的方法中,我们首先训练了技能。 然后,调度器查找一个策略来使用备用的技能来达成目标。 EDL 方法采用相反的方式。 我们首先构建状态和技能之间的依赖关系。 之后,我们教导技能。这令我们能够更准确地将技能与特定的环境条件匹配,并判定哪些技能在某些情况下最有效。

算法的最后阶段是训练技能模型(代理者)。 在此,代理者学到了一个策略,该策略最大化状态和隐藏变量之间的互动信息。 运用强化学习方法对代理者进行训练。 形成奖励的结构与 DADS 方法相似,但该方法的作者略微简化了方程式。 也许如您所记,DADS 中的代理者内部奖励是根据以下方程式形成的:

从数学课程中,我们知道

因此:

正如您所见,减数是所有用到技能的常数。 因此,我们只能用被减数来优化策略。 这种方式令我们能够在不损失模型训练品质的情况下降低计算量。

这最后一步可被认为是在训练一个策略模仿解码器,处于马尔可夫(Markov)决策过程当中,也就是说,该策略将参与解码器为每个隐藏技能生成状态。 需要注意的是,奖励函数是固定的,这与以前的方法不同,它根据策略行为不断变化。 这令训练更加稳定,并增加了模型的收敛性。


2. 利用 MQL5 实现

在研究过探索、发现和学习(EDL)方法的理论方面之后,我们转入本文的实施部分。 在利用 MQL5 实现该方法之前,我们需要看看实现特征。

在上一篇文章的 测试 章节中,我们演示了当辨别代理者源数据中所用技能时,使用独热向量和全部分布的 结果相似性。 这允许我们能够根据拥有的数据,使用一种或另一种方法来减少数学运算。 通常来说,这令我们能够减少所执行操作的数量。 与此同时,我们能够提高模型训练和操作的速度。

我们需要注意的第二点是,我们将相同的初始数据提交给调度器和代理者的输入(价格走势、参数值和余额状态的历史数据)。 在代理者输入数据中也会添加技能 ID。

另一方面,在研究自动编码器时,我们提到自动编码器的潜在状态是其原始数据的压缩表示。 换言之,通过将源数据的向量,与变分自动编码器的潜在数据向量连接起来,我们以完整和压缩的表示形式分两次传递相同的数据。

如果使用类似的初步源数据处理模块,则此方式也许有点多余。 故此,在该实现中,我们只会将自动编码器的潜在状态发送到代理者输入,其已包含所有必要的信息。 这将令我们能够显著减少执行的操作量,以及训练模型的总时间。

当然,只有在调度器和代理者输入中使用类似的初始数据时,这种方式才可行。 其它选项也是可能的。 例如,自动编码器只能在历史数据和技能之间建立依赖关系,而不考虑帐户的状态。 在代理者输入中,它能够连接自动编码器潜在状态向量,和计数状态的描述向量。 使用全部数据并不是一个错误,就像我们在实现前面讨论的方法时所做的那样。 您可以在实现中尝试不同的方式。

所有这些决策都必须反映在 CreateDescriptions 函数中我们指定的模型架构当中。 在方法参数中,我们传递指向 2 个动态数组的指针,其中定义了调度器和代理者模型。 请注意,在实现 EDL 方法时,我们不会创建鉴别器,因为它的作用是由自动编码器(调度器)解码器来扮演的。

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

首先创建调度器的变分自动编码器。 我们为该模型投喂历史数据和帐户状态,其反映在源数据层的规模当中。 与往常一样,原生数据在批量常规化层中进行预处理。

//--- Scheduler
   scheduler.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr + AccountDescr);
   descr.window = 0;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!scheduler.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(!scheduler.Add(descr))
     {
      delete descr;
      return false;
     }

接着来到的是卷积模块,它降低数据维度,并提取特殊特征。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count - 1;
   descr.window = 2;
   descr.step = 1;
   descr.window_out = 4;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!scheduler.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronProofOCL;
   prev_count = descr.count = prev_count;
   descr.window = 4;
   descr.step = 4;
   if(!scheduler.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count - 1;
   descr.window = 2;
   descr.step = 1;
   descr.window_out = 4;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!scheduler.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronProofOCL;
   prev_count = descr.count = prev_count;
   descr.window = 4;
   descr.step = 4;
   if(!scheduler.Add(descr))
     {
      delete descr;
      return false;
     }

然后是三个全连接层,维度逐渐减小。 请注意,最后一层的规模是正在训练的技能数量的 2 倍。 这是变分自动编码器的一个显著特征。 与传统的自动编码器不同,在变分自动编码器中,每个特征都由 2 个参数表达:平均值,和分布的离散度。

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.optimization = ADAM;
   descr.activation = TANH;
   if(!scheduler.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 128;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!scheduler.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NSkills;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!scheduler.Add(descr))
     {
      delete descr;
      return false;
     }

在下一层中运作重新参数化技巧,它是专门为实现变分自动编码器而创建的。 在此,参数也是从给定的分布中抽样而来。 该层的规模与正训练的技能数量相对应。

//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NSkills;
   if(!scheduler.Add(descr))
     {
      delete descr;
      return false;
     }

解码器以 3 个全连接层的形式实现。 后者并无激活函数,因为很难判定非常规化数据的激活函数。

//--- layer 10
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 128;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!scheduler.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 11
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!scheduler.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 12
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = AccountDescr;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!scheduler.Add(descr))
     {
      delete descr;
      return false;
     }

请注意,与之前的方法一样,我们不会完全恢复原始数据。 毕竟,代理者行为对金融产品市场价格的影响可以忽略不计。 与之对比,余额的状态直接取决于代理者所采用的策略。 故此,在自动编码器的输出中,我们仅恢复帐户状态的描述。

在调度器之后,我们创建代理者架构的描述。 如上所述,代理者源数据层减低至正训练技能的数量。

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

使用不同的模型隐藏状态,令我们可以剔除数据预处理模块。 故此,在源数据层之后有一个由 3 个全连接层组成的决策模块。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.optimization = ADAM;
   descr.activation = TANH;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

在模型的输出中,我们使用一个完全参数化的分位数函数模块,这令我们能够更详尽地研究奖励的分布。

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFQF;
   descr.count = NActions;
   descr.window_out = 32;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

如前,我们在 “\EDL\Trajectory.mqh” 头文件中包含了描述模型架构的函数。 这令我们能够使用单一模型架构贯穿 EDL 方法的所有阶段。

创建模型架构后,我们转入 EA 工序,来实现所研究的方法。 首先,我们创建第一阶段 EA — 研究。 此函数是在 “EDL\Research.mq5” EA 中执行。 我们立即要说,该 EA 的算法几乎完全复制了之前文章中的同名 EA。 但由于模型的架构不同·,故也有区别。 特别是,在以前的实现中,该 EA 的算法仅采用代理者模型,其输入提供初始数据和随机生成的技能 ID。 在该实现中,我们给调度器输入端提供历史数据。 在其直接验算之后,我们提取隐藏状态,并提交给代理者输入端,据其做出动作决策。 EA 的完整代码及其所有函数都可以在附件中找到。

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
//---
   if(!IsNewBar())
      return;
//---
........
........
//---
   if(!Scheduler.feedForward(GetPointer(State1), 1, false))
      return;
   if(!Scheduler.GetLayerOutput(LatentLayer, Result))
      return;
//---
   if(!Actor.feedForward(Result, 1, false))
      return;
   int act = Actor.getSample();
//---
........
........
//---
  }

EDL 方法的第二步是识别技能。 如理论部分所述,在此阶段,我们将训练变分自动编码器。 此函数将在 “StudyModel.mq5” EA 中执行。 EA 是基于之前文章中的模型训练 EA 而创建的。 唯一的修改是关于方法算法的。

在 OnInit 函数中,仅初始化一个调度器模型。 但主要的变化是针对 Train 模型的训练函数。 如前,我们在函数的开头声明内部变量。

//+------------------------------------------------------------------+
//| Train function                                                   |
//+------------------------------------------------------------------+
void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   uint ticks = GetTickCount();
   vector<float> account, reward;
   int bar, action;

然后,我们取 EA 外部参数中指定的迭代次数安排一个训练循环。

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

在循环的主体中,我们随机选择一次验算,然后从训练集中选择与所选验算对应的状态之一。 所选状态的描述数据将传输到源数据缓冲区,以便模型的前向验算。 这些迭代与我们之前执行的并无区别。 如您所记,我们在填写有关帐户状态的信息时均取相对项。

      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];
      State.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance);
      State.Add(Buffer[tr].States[i].account[1] / PrevBalance);
      State.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity);
      State.Add(Buffer[tr].States[i].account[2] / PrevBalance);
      State.Add(Buffer[tr].States[i].account[4] / PrevBalance);
      State.Add(Buffer[tr].States[i].account[5]);
      State.Add(Buffer[tr].States[i].account[6]);
      State.Add(Buffer[tr].States[i].account[7] / PrevBalance);
      State.Add(Buffer[tr].States[i].account[8] / PrevBalance);

接下来,我们将根据下一根蜡烛的价格变化判定每手利润,并将余额和净值保存在局部变量之中,供后续计算。

      //---
      bar = (HistoryBars - 1) * BarDescr;
      double cl_op = Buffer[tr].States[i + 1].state[bar];
      double prof_1l = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE_PROFIT) * cl_op /
                       SymbolInfoDouble(_Symbol, SYMBOL_POINT);
      PrevBalance = Buffer[tr].States[i].account[0];
      PrevEquity = Buffer[tr].States[i].account[1];

准备工作完成后,我们执行模型的正向验算。

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

在成功实现正向验算之后,我们还需安排模型的逆向验算。 此处,我们需要准备模型的目标值。 按照训练自动编码器的逻辑,我们必须使用源数据缓冲区作为目标值。 但我们已修改了架构和训练逻辑。 首先,我们不会在输出时生成源数据的完整属性集,而只有描述帐户状态的参数。

其二,我们向前迈出了一小步。 我们是要训练模型来生成预测性的后续帐户状态。 不过,我们不会针对代理者所有可能的动作生成帐户状态。 在模型训练阶段,我们可以检测训练样本中的下一根蜡烛,并采取对我们最有利的行动。 以这种方式,我们形成了帐户的所需预测状态,并将其当作模型反向验算的目标值。

      if(prof_1l > 5 )
         action = (prof_1l < 10 || Buffer[tr].States[i].account[6] > 0 ? 2 : 0);
      else
        {
         if(prof_1l < -5)
            action = (prof_1l > -10 || Buffer[tr].States[i].account[5] > 0 ? 2 : 1);
         else
            action = 3;
        }
      account = GetNewState(Buffer[tr].States[i].account, action, prof_1l);
      Result.Clear();
      Result.Add((account[0] - PrevBalance) / PrevBalance);
      Result.Add(account[1] / PrevBalance);
      Result.Add((account[1] - PrevEquity) / PrevEquity);
      Result.Add(account[2] / PrevBalance);
      Result.Add(account[4] / PrevBalance);
      Result.Add(account[5]);
      Result.Add(account[6]);
      Result.Add(account[7] / PrevBalance);
      Result.Add(account[8] / PrevBalance);

请注意,在定义所需动作时,我们会引入限制:

  • 开仓交易的最低利润,
  • 平仓的最小走势(我们等待小幅波动),
  • 在新开仓之前所有反向交易平仓。

由此,我们打算形成一个拥有期望行为的预测性模型。

我们将生成的账户预测状态转换到相对单位的等级,并将其传输到数据缓冲区。 之后,我们对模型执行逆向验算。

      if(!Scheduler.backProp(Result))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }
      if(GetTickCount() - ticks > 500)
        {
         string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", 
                                    "Scheduler", 
                                    iter * 100.0 / (double)(Iterations), 
                                    Scheduler.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }

如前,在循环迭代结束时,我们会显示一条消息,供用户直观地监视模型训练过程。

模型训练循环的所有迭代完成之后,我们清除图表上的注释,并启动终止 EA 的进程。

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

调度器变分自动编码器训练 EA 的完整代码可在附件中找到。

在判定环境状态和技能之间的依赖关系后,我们需采取必要的技能训练我们的代理者。 我们在 “EDL\StudyActor.mq5” EA 中安排了这些功能。 在该 EA 中,我们用到 2 个模型(调度器和代理者)。 不过,我们只会训练其一(代理者)。 因此,我们在 EA 初始化方法中预加载了 2 个模型。 程序最后因严重故障而终止,因其无力加载调度器导致,而其应该已经预先训练过了。

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   ResetLastError();
   if(!LoadTotalBase())
     {
      PrintFormat("Error of load study data: %d", GetLastError());
      return INIT_FAILED;
     }
//--- load models
   float temp;
   if(!Scheduler.Load(FileName + "Sch.nnw", temp, temp, temp, dtStudied, true))
     {
      PrintFormat("Error of load scheduler model: %d", GetLastError());
      return INIT_FAILED;
     }

如果在加载代理者模型时发生错误,我们将初始创建新模型。

   if(!Actor.Load(FileName + "Act.nnw", dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *scheduler = new CArrayObj();
      if(!CreateDescriptions(actor, scheduler))
        {
         delete actor;
         delete scheduler;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor))
        {
         delete actor;
         delete scheduler;
         return INIT_FAILED;
        }
      delete actor;
      delete scheduler;
      //---
     }

加载或创建新模型后,我们检查源数据的神经层的大小,以及与功能相对应的结果。

//---
   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.SetOpenCL(Scheduler.GetOpenCL());
   Actor.SetUpdateTarget(MathMax(Iterations / 100, 10000));
//---
   Scheduler.getResults(Result);
   if(Result.Total() != AccountDescr)
     {
      PrintFormat("The scope of the scheduler does not match the account description (%d <> %d)", 
                   AccountDescr, Result.Total());
      return INIT_FAILED;
     }
//---
   Actor.GetLayerOutput(0, Result);
   int inputs = Result.Total();
   if(!Scheduler.GetLayerOutput(LatentLayer, Result))
     {
      PrintFormat("Error of load latent layer %d", LatentLayer);
      return INIT_FAILED;
     }
   if(inputs != Result.Total())
     {
      PrintFormat("Size of latent layer does not match input size of Actor (%d <> %d)", 
                   Result.Total(), inputs);
      return INIT_FAILED;
     }

在模型成功加载和初始化之后,以及所有控制通过之后,我们初始化事件以便启动模型训练过程,并完成 EA 初始化函数的操作。

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

代理者训练过程是在 Train 方法里组织。 该方法的第一部分包括选择一次验算,调度器直通状态和组织如上所述,并且已转移到此 EA 中,未经更改。 故此,我们会跳过这个模块,并立即转入安排我们的代理者的直接验算。 这里的一切都十分简单。 我们只提取自动编码器的潜在状态,并将接收到的数据传递到代理者的输入。 记住要控制操作的执行。

//+------------------------------------------------------------------+
//| Train function                                                   |
//+------------------------------------------------------------------+
void Train(void)
  {
........
........
      //---
      if(!Scheduler.GetLayerOutput(LatentLayer, Result))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }
      //---
      if(!Actor.feedForward(Result, 1, false))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }

正向验算操作成功完成之后,我们需要安排贯穿代理者模型的逆向验算。 正如理论部分所言,代理者训练是使用强化训练方法进行的。 我们要对直接验算过程中产生的动作进行奖励。 EDL 方法涉及根据 Discriminator 生成的奖励来训练代理者。 在这种情况下,它的角色由调度器自动编码器解码器来扮演。 不过,我们略微偏离了作者提出的奖励形成原则。 通常这与方法意识形态并不矛盾。

如上所述,在训练自动编码器时,考虑到引入的限制,我们用到了期待的计算出的帐户状态。 现在,我们将奖励代理者的行为,这令我们尽可能地接近期待的结果。 作为我们的余额期望状态和预测状态之间的衡量度,我们取 2 个向量之间距离的欧几里德衡量度。 我们将结果距离乘以 “-1” 作为奖励,如此由动作所得的最大奖励可令我们尽可能接近期待状态。

这种方式能令我们安排一个循环,并为代理者的所有可能行动填写奖励,而不仅只针对一个单独的动作。 这通常会提高模型训练过程的稳定性和性能。

      Scheduler.getResults(SchedulerResult);
      ActorResult = vector<float>::Zeros(NActions);
      for(action = 0; action < NActions; action++)
        {
         reward = GetNewState(Buffer[tr].States[i].account, action, prof_1l);
         reward[0] = reward[0] / PrevBalance - 1.0f;
         reward[3] = reward[2] / PrevBalance;
         reward[2] = reward[1] / PrevEquity - 1.0f;
         reward[1] /= PrevBalance;
         reward[4] /= PrevBalance;
         reward[7] /= PrevBalance;
         reward[8] /= PrevBalance;
         reward=MathPow(SchedulerResult - reward, 2.0);
         ActorResult[action] = -reward.Sum();
        }

完成循环遍历列举出的所有可能代理者动作之后,我们获得一个距离向量,即从代理者的每个可能动作计算出的状态,至自动编码器预测的期待状态的距离向量。 您也许还记得,我们记录的距离所带符号相反。 因此,我们的最大距离是最大负值,或只是最小值。 如果我们从向量的每个元素中减去这个最小值,我们就会把该动作的奖励清零,令我们远离期待的结果。 所有其它奖励将在不改变其结构的情况下转换到正值区域。

      ActorResult = ActorResult - ActorResult.Min();

在这种情况下,我们故意不用 SoftMax。 毕竟,转移到概率领域只会保留结构,并抵消与期待结果相距甚远的影响。 在构建整体策略时,这种影响极其重要。

此外,请记住,自动编码器的预测状态并不完全符合真实环境的随机性。 因此,评估自动编码器的预测品质非常重要。 代理者训练的品质最终取决于自动编码器预测状态与代理者交互的真实环境状态之间的对应关系。

我还要提醒您,在构建策略时,代理者不仅要考虑当前的奖励,还要考虑在场次结束前获得奖励的总体可能性。 在这种情况下,我们将用目标模型(目标网络)来判定下一个状态的成本。 此功能已在完全参数化的分位数函数模型中实现。 但为了令其正常运行,我们需要将系统的下一个状态传递给逆向方法。

在这种情况下,我们需要首先使用来自经验回放缓冲区中的下一个系统状态,来执行自动编码器的正向验算。

      State.AssignArray(Buffer[tr].States[i+1].state);
      State.Add((Buffer[tr].States[i+1].account[0] - PrevBalance) / PrevBalance);
      State.Add(Buffer[tr].States[i+1].account[1] / PrevBalance);
      State.Add((Buffer[tr].States[i+1].account[1] - PrevEquity) / PrevEquity);
      State.Add(Buffer[tr].States[i+1].account[2] / PrevBalance);
      State.Add(Buffer[tr].States[i+1].account[4] / PrevBalance);
      State.Add(Buffer[tr].States[i+1].account[5]);
      State.Add(Buffer[tr].States[i+1].account[6]);
      State.Add(Buffer[tr].States[i+1].account[7] / PrevBalance);
      State.Add(Buffer[tr].States[i+1].account[8] / PrevBalance);
      //---
      if(!Scheduler.feedForward(GetPointer(State), 1, false))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }

然后,我们可以从自动编码器的潜在状态中提取系统下一个状态的压缩表示。 然后,我们执行代理者的逆向验算。

      if(!Scheduler.GetLayerOutput(LatentLayer, Result))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }
      State.AssignArray(Result);
      Result.AssignArray(ActorResult);
      if(!Actor.backProp(Result,DiscountFactor,GetPointer(State),1,false))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }

接下来,我们通知用户代理者训练过程的进度,并转入循环的下一次迭代。

      if(GetTickCount() - ticks > 500)
        {
         string str = StringFormat("%-15s %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__, "Actor", 
                Actor.getRecentAverageError());
   ExpertRemove();
//---
  }

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

3. 测试

我们基于 2023 年前 4 个月的 EURUSD 历史数据上测试了该方法的有效性。 一如既往,我们使用 H1 时间帧。 指标采用默认参数。 首先,我们收集了一个包含 50 次验算样本的数据库,其中既有盈利的,也有无盈利的。 以前,我们有意只取盈利的验算。 这样做,就是想教导可以产生盈利的技能。 在本例中,我们向样本数据库添加了若干个无盈利的验算,以便向模型演示无盈利的状态。 毕竟,在实际交易中,我们需接受回撤的风险。 但我们希望有一个策略,能以最小的亏损摆脱它们。

然后我们训练模型 — 首先是自动编码器,然后是代理者。

采用 2023 年 5 月的历史数据,在策略测试器中测试已训练模型。 这些数据不包含在训练集中,可令我们在新数据上测试模型的性能。

最初的结果比我们预期的要差。 包含的正面结果则是,测试样本中所用的技能分布相当均匀。 这就是最终我们的测试正面结果所在。 在对自动编码器和代理者进行了多次迭代训练后,我们仍然无法获得能够在训练集上产生盈利的模型。 显然,问题在于自动编码器无法足够准确地预测状态。 结果就是,余额曲线与预期结果相去甚远。

为了验证我们的假设,创建了一个替代的代理者训练 EA “EDL\StudyActor2.mq5”。 替代选项与之前研究的选项之间的唯一区别就是生成奖励的算法。 我们依旧用该循环来预测帐户状态的变化。 这一次,我们取相对余额变化指标作为奖励。

      ActorResult = vector<float>::Zeros(NActions);
      for(action = 0; action < NActions; action++)
        {
         reward = GetNewState(Buffer[tr].States[i].account, action, prof_1l);
         ActorResult[action] = reward[0]/PrevBalance-1.0f;
        }

贯穿测试区间,使用修改后的奖励函数进行训练的代理者显示出相当平缓的盈利增长能力。 

基于测试样本的余额曲线图
测试结果

利用一种改进的奖励生成方式对代理者进行训练,无需重新训练自动编码器,以及更改代理者本身的架构。 两种代理者的训练完全在可比条件下运营。 只有修改奖励形成方式,才有可能提高模型效率。 这再次证实了正确选择奖励函数的重要性,奖励函数在强化训练方法中起着关键作用。

技能用法分布


结束语

在这篇文章中,我们介绍了另一种技能训练方法 — 探索、发现和学习(EDL)。 该算法允许代理者探索环境,并发现新技能,且无需条件或所需技能的先验知识。 这是通过使用变分自动编码器,查找环境状态和所需技能之间的依赖关系来实现的。

在该方法的第一阶段,先运作环境研究。 形成状态的训练样本,最大程度覆盖与各种行为对应的各种状态。 之后,利用变分自动编码器搜索状态和技能之间的依赖关系。 自动编码器的潜在状态用作状态的压缩表示,以及所需技能的一种标识符。 模型解码器和编码器在状态和技能之间形成依赖函数。

通过尝试获取自动编码器预测的状态,按框架训练代理者。 自动编码器提供的预测状态缺乏真实环境中固有的随机性,这提高了代理者训练的稳定性和速度。 与此同时,这也是该方式的瓶颈,因为模型的性能很大程度上取决于自动编码器的状态预测品质。 这就是测试期间所演示的内容。

如今,金融市场是相当复杂和难以预测的随机环境。 投资其内仍然颇具高风险。 只有严格遵守有节制和平衡的策略,才有可能在交易中达成积极的结果。


参考文献列表

  • 探索、发现和学习:状态覆盖技能的无监督发现
  • 神经网络变得轻松(第二十一部分):变分自动编码器(VAE)
  • 神经网络变得轻松(第四十三部分):无需奖励函数精通技能
  • 神经网络变得轻松(第四十四部分):动态学习技能


  • 本文中用到的程序

    # 名称 类型 说明
    1 Research.mq5 智能交易系统 样本收集 EA
    2 StudyModel.mq5 智能交易系统 自动编码器模型训练 EA
    StudyActor.mq5  智能交易系统 代理者训练 EA
    StudyActor2.mq5  智能交易系统 替代代理者训练 EA(奖励函数已修改)
    5 Test.mq5 智能交易系统 模型测试 EA
    6 Trajectory.mqh 类库 系统状态定义结构
    7 FQF.mqh 类库 完全参数化模型的工作安排类库
    8 NeuroNet.mqh 类库 用于创建神经网络的类库
    9 NeuroNet.cl 代码库 OpenCL 程序代码库
    10 VAE.mqh
    类库
    变分自动编码器潜伏层类库


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

    附加的文件 |
    MQL5.zip (318.64 KB)
    MQL5 中的范畴论 (第 9 部分):幺半群(Monoid)— 动作 MQL5 中的范畴论 (第 9 部分):幺半群(Monoid)— 动作
    本文是以 MQL5 实现范畴论系列的延续。 在这里,我们继续将“幺半群 — 动作”当为幺半群变换的一种手段,如上一篇文章所涵盖的内容,从而增加了应用。
    利用回归衡量度评估 ONNX 模型 利用回归衡量度评估 ONNX 模型
    回归是一项依据未标记样本预测真实数值的任务。 所谓的回归衡量度则是用来评估回归模型的预测准确性。
    开发回放系统 — 市场模拟(第 13 部分):模拟器的诞生(III) 开发回放系统 — 市场模拟(第 13 部分):模拟器的诞生(III)
    为了下一阶段的工作,我们将于此简化一些与操作相关的元素。 我还会解释如何让您把模拟器随机生成的内容可视化。
    如何利用 MQL5 创建自定义唐奇安(Donchian)通道指标 如何利用 MQL5 创建自定义唐奇安(Donchian)通道指标
    有许多技术工具可用于可视化围绕价格的通道,其中一种工具是唐奇安(Donchian)通道指标。 在本文中,我们将学习如何创建唐奇安(Donchian)通道指标,以及如何在 EA 中将其作为自定义指标进行交易。