English Русский Español Deutsch 日本語 Português
preview
神经网络变得简单(第 66 部分):离线学习中的探索问题

神经网络变得简单(第 66 部分):离线学习中的探索问题

MetaTrader 5交易系统 | 19 七月 2024, 09:49
75 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

当我们继续阅读有关强化学习方法的系列文章时,我们面临着一个与环境探索和利用所学策略之间的平衡有关的问题。我们之前已经探讨过激发智能体(Agent)探索的各种方法。但是,在在线学习中表现出优秀结果的算法,在离线学习中往往并不那么有效。问题在于,在离线模式下,环境信息受到训练数据集大小的限制。通常情况下,选择用于模型训练的数据目标很窄,因为这些数据都是在任务的一个较小的子空间内收集的。这使我们对环境的了解更加有限。然而,为了找到最佳解决方案,智能体需要对环境及其模式有最全面的了解。我们在前面已经指出,学习结果往往取决于训练数据集。

此外,在训练过程中,代理的决策往往会超出训练数据集的子空间。在这种情况下,很难预测随后的结果。这就是为什么在初步模型训练后,我们还将轨迹收集到训练数据集中,这可以调整学习过程。

在线环境模型训练有时可以缓解上述问题。然而,遗憾的是,由于种种原因,并非总能训练出环境模型。通常情况下,训练一个模型可能比训练一个代理策略还要昂贵。有时,这根本不可能。

第二个明显的方向是扩展训练数据集。但在这方面,我们主要受限于可用资源的实际规模和研究环境的成本。

在本文中,我们将了解用于离线 RL(ExORL)的探索性数据框架,该框架发表于论文 "不要改变算法,要改变数据:离线强化学习的探索性数据"。这篇文章介绍的结果表明,正确的数据收集方法对最终的学习成果有重大影响。这种影响与选择学习算法和模型结构的影响相当。


1.离线 RL(ExORL)方法的探索数据

离线 RL(ExORL)方法探索数据的作者并没有提供新的学习算法或模型架构解决方案。相反,重点在于收集数据以训练模型的过程。他们使用五种不同的学习方法进行实验,以评估训练数据集内容对学习结果的影响。

ExORL 方法可分为 3 个主要阶段。第一阶段是收集未标记的探索性数据。这个阶段可以使用各种无监督学习算法。该方法的作者并没有限制适用算法的范围。此外,在与环境互动的过程中,在每个回合(episode)中,我们会根据之前互动的历史记录使用一种策略 π。每一回合都以状态St、行动At和后续状态St+1 的序列保存在数据集中。训练数据的收集一直持续到训练数据集全部填满为止。该训练数据集的规模受到技术规格或可用资源的限制。

在实践中,该论文的作者评估了九种不同的无监督数据收集算法:

  • 一个始终输出一致随机策略的简单基线。
  • 使预测模型误差最大化的方法:ICM分歧RND
  • 最大化状态空间覆盖范围估计的算法:APTProto-RL
  • 基于能力的算法,学习多种技能:DIAYNSMMAPS

在收集了状态和行动数据集之后,下一阶段就是利用给定的奖励函数对数据进行重新标记。这一阶段意味着对数据集中每个元组的奖励进行评估。

在实验中,该方法的作者使用了标准或手动奖励函数。拟议的框架还允许对奖励函数进行训练。也就是说,它允许实现反向 RL。

ExORL 的最后一个阶段是训练模型。该策略使用离线强化学习算法在标注数据集上进行训练。离线训练完全使用训练数据集中的离线数据,通过随机选择元组来实现。然后在真实环境中对最终政策进行评估。

下面是作者对该方法的可视化介绍。


在论文中,作者展示了五种不同的离线强化学习算法的结果。基本选项是简单的行为克隆。他们还展示了三种离线强化学习算法的结果,每种算法都使用不同的机制来防止数据中动作之外的外推。经典的TD3也被作为基线测试,用于评估离线模式对最初为在线学习设计的方法的影响,这些方法没有明确的机制来防止超出训练数据集的外推。

基于实验结果,该方法的作者得出结论,使用不同的数据可以通过消除处理外推问题的需要来大大简化离线强化学习算法。结果表明,探索性数据提高了离线强化学习在各种问题上的性能。此外,以前开发的离线 RL 算法在特定任务数据上表现良好,但在无标记的 ExORL 数据上却不如 TD3。理想情况下,离线强化学习算法应能自动适应所使用的数据集,以恢复两全其美的效果。


2.使用 MQL5 实现

离线 RL 探索性数据(ExORL)方法的作者给出了构建框架的总体方向。在该论文中,作者尝试了各种模型训练方法。在文章的实践部分,我决定建立一个 ExORL 实现,尽可能接近前几篇文章中的模型。不过,请注意一个建设性的问题。DWSL 算法意味着要根据优势来权衡S 状态下的行动。在我们的实现中,我们通过嵌入来定位所有轨迹中最接近的状态。根据行动对结果的影响,对选定状态的行动进行了权衡。

不过,ExORL 方法假定智能体行为具有最大的多样性。为此,我们需要确定各个状态下行动之间的距离。利用与最近的状态-行动对的距离作为奖励,可以鼓励 智能体探索环境。因此,我们将根据行动来确定状态嵌入。

作为替代方案,可以确定后续状态之间的距离。在随机环境中工作时,这似乎很合乎逻辑。在这种环境下,以某种概率执行一个行动可能会导致各种后续状态。但是,使用这种算法会使我们进一步偏离 DWSL 方法,而我们的实现正是以 DWSL 方法为基础的。对基础算法进行最小限度的调整,将使我们能够更好地评估 ExORL 框架对模型训练结果的影响。

因此,我决定采用第一种方案,在 Encoder 模型中通过 Actor 行动向量来增加源数据层的大小。除此之外,这些模型的结构保持不变。你可以在附件里找到。文件"...\ExORL\Trajectory.mqh "中的 CreateDescriptions 方法。 

bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic, CArrayObj *convolution)
  {
//---
   CLayerDescription *descr;
//---
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
   if(!critic)
     {
      critic = new CArrayObj();
      if(!critic)
         return false;
     }
   if(!convolution)
     {
      convolution = new CArrayObj();
      if(!convolution)
         return false;
     }
//--- Actor
........
........
//--- Critic
........
........
//--- Convolution
   convolution.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = (HistoryBars * BarDescr) + AccountDescr + NActions;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
........
........
//---
   return true;
  }

收集训练数据的过程在 EA 交易 "...\ExORL\ResearchExORL.mq5" 中实现。

注意文件名中的框架指示。附件中包含文件"...\ExORL\Research.mq5",它是从上一篇文章中转来的。因此,我们不再讨论它的算法。

这两个 EA 交易旨在填充训练数据集。奇怪的是,我们将在训练过程中使用 EA。不过,我们稍后再谈这个问题。现在让我们来看看"...\ExORL\ResearchExORL.mq5" EA 交易的算法。

EA 外部参数是从基础 EA 转移过来的,用于与环境互动。

//+------------------------------------------------------------------+
//| Input parameters                                                 |
//+------------------------------------------------------------------+
input ENUM_TIMEFRAMES      TimeFrame   =  PERIOD_H1;
input double               MinProfit   =  10;
//---
input group                "---- RSI ----"
input int                  RSIPeriod   =  14;            //Period
input ENUM_APPLIED_PRICE   RSIPrice    =  PRICE_CLOSE;   //Applied price
//---
input group                "---- CCI ----"
input int                  CCIPeriod   =  14;            //Period
input ENUM_APPLIED_PRICE   CCIPrice    =  PRICE_TYPICAL; //Applied price
//---
input group                "---- ATR ----"
input int                  ATRPeriod   =  14;            //Period
//---
input group                "---- MACD ----"
input int                  FastPeriod  =  12;            //Fast
input int                  SlowPeriod  =  26;            //Slow
input int                  SignalPeriod =  9;            //Signal
input ENUM_APPLIED_PRICE   MACDPrice   =  PRICE_CLOSE;   //Applied price
input int                  Agent = 1;

在互动过程中,我们将为 Actor 训练环境研究政策。在学习过程中,我们需要 Critic 和 Encoder 模型。为了降低探索性策略的训练成本,从而提高收集训练数据的速度,我决定只使用 1 个 Critic。

CNet                 Actor;
CNet                 Critic;
CNet                 Convolution;

此外,我们还将在全局变量列表中添加一个标记,用于加载之前传递的轨迹及其嵌入矩阵。

bool                 BaseLoaded;
matrix<float>        state_embeddings;

在 OnInit EA 初始化方法中,我们首先初始化我们分析的指标。

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   if(!Symb.Name(_Symbol))
      return INIT_FAILED;
   Symb.Refresh();
//---
   if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice))
      return INIT_FAILED;
//---
   if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice))
      return INIT_FAILED;
//---
   if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod))
      return INIT_FAILED;
//---
   if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice))
      return INIT_FAILED;
   if(!RSI.BufferResize(HistoryBars) || !CCI.BufferResize(HistoryBars) ||
      !ATR.BufferResize(HistoryBars) || !MACD.BufferResize(HistoryBars))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return INIT_FAILED;
     }

指明交易操作填充类型。

//---
   if(!Trade.SetTypeFillingBySymbol(Symb.Name()))
      return INIT_FAILED;

加载预训练模型如果没有预先训练好的模型,则使用随机权重创建初始化的新模型。在此 EA 中,我决定将模型加载分为不同的区块,这样就可以在没有训练好的 Actor 或 Encoder 的情况下使用先前训练好的 Critic。

请注意,我们之前一直在谈论需要有一套完整的同步模型。在这种情况下,我们特意使用了与 Actor 分开训练的 Critic。这是有原因的。我原来有一个想法,就是构建一种算法,用于同步不同 MetaTrader 5 测试代理中模型的权重系数。不过,我决定创建几个并行的训练好的 Actor 探索模型。这些模型在使用随机参数初始化后,将在历史数据上进行并行训练。虽然它们使用的是相同的历史片段,但每个探索性 Actor 模型都有自己的学习路径。这将能扩大已探索的环境子空间。使用以前完成的轨迹缓冲区可以最大限度地减少轨迹重复。

为了识别探索性 Actor 模型,我们在模型文件名后加上后缀 "Ex" 和外部参数中的代理编号。通过优化该参数,我们可以在 MetaTrader 5 策略测试器中并行运行多个探索性 Actor。

//--- load models
   float temp;
   if(!Actor.Load(StringFormat("%sAct%d.nnw", FileName, Agent), temp, temp, temp, dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      if(!CreateDescriptions(actor, critic, critic))
        {
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor))
        {
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      //---
     }

同时,为了给所有探索性 Actor 安排相同的训练条件,我们使用了一个 Critic 模型。这就是为什么即使没有探索性 Actor 模型,也必须加载预先训练好的 Critic 模型的原因。

   if(!Critic.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Init new Critic and Encoder 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(!Critic.Create(critic))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      delete convolution;
      //---
     }

对所有代理使用单一 Encoder 模型,还能让我们在单一子空间中对状态和行动进行比较。但这对学习过程并不重要,因为每个智能体都能独立地对之前通过的轨迹进行编码。这样,它就能正确评估距离,并使 Actor 的行为多样化。

   if(!Convolution.Load(FileName + "CNN.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Init new Critic and Encoder 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(!Convolution.Create(convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      delete convolution;
      //---
     }

我同意,展示的代码看起来很繁琐。也许,按照不同的方法来划分模型结构的描述是合乎逻辑的。但这样做只能简化该 EA 的代码。另一方面,这会使文章中使用的其它程序代码复杂化。因此,我决定不对描述模型架构的方法进行分割。

我们将所有模型转移到一个 OpenCL 上下文中。这样,我们就可以同步它们的运行,减少主内存和 OpenCL 上下文内存之间的数据复制量。

   Critic.SetOpenCL(Actor.GetOpenCL());
   Convolution.SetOpenCL(Actor.GetOpenCL());
   Critic.TrainMode(false);

请注意,我们将禁用 Critic 训练模式。前面我们讨论了为所有环境探索智能体创造相同训练条件的重要性。在这一过程中,保持 Critic 的状态固定起着重要作用。

之后,我们将对模型架构实施标准的最小控制。

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

然后,初始化全局变量。

   PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE);
   PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY);
   BaseLoaded = false;
   bGradient.BufferInit(MathMax(AccountDescr, NActions), 0);
//---
   return(INIT_SUCCEEDED);
  }

成功完成上述所有操作后,我们就完成了 EA 初始化方法。

在程序初始化方法中,我们不加载之前完成的轨迹。此外,我们不会创建它们的嵌入。这是因为创建先前传递状态的嵌入过程可能非常昂贵和耗时。其持续时间取决于访问状态的数量。 

如前所述,与之前讨论的与环境互动的 EA 不同,在本例中,我们训练的是探索型 Actor。每次完成后,我们都会保存训练好的模型。

void OnDeinit(const int reason)
  {
//---
   ResetLastError();
   if(!Actor.Save(StringFormat("%sActEx%d.nnw", FileName, Agent), 0, 0, 0, TimeCurrent(), true))
      PrintFormat("Error of saving Agent %d: %d", Agent, GetLastError());
   delete Result;
  }

现在,让我们简要地探讨一下创建的辅助方法。CreateEmbeddings 方法实现了对状态和行动的编码过程。该方法没有参数,返回状态嵌入矩阵。

在方法主体中,我们首先创建局部变量。

matrix<float> CreateEmbeddings(void)
  {
   vector<float> temp;
   CBufferFloat  State;
   Convolution.getResults(temp);
   matrix<float> result = matrix<float>::Zeros(0, temp.Size());

然后,我们尝试加载之前收集的轨迹数据库。如果数据加载失败,则向调用者返回一个空矩阵。

   BaseLoaded = LoadTotalBase();
   if(!BaseLoaded)
     {
      PrintFormat("%s - %d => Error of load base", __FUNCTION__, __LINE__);
      return result;
     }

如果成功加载了轨迹数据库,我们就会计算所有轨迹中的状态总数,并改变待填充矩阵的大小。

   int total_tr = ArraySize(Buffer);
//---
   int total_states = Buffer[0].Total;
   for(int i = 1; i < total_tr; i++)
      total_states += Buffer[i].Total;
   result.Resize(total_states, temp.Size());

接下来是一个嵌套循环系统,用于编码状态和操作。在外层循环中,我们遍历加载的轨迹。在嵌套循环中,我们对状态进行遍历。

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

添加 Actor 的行动向量。

         State.AddArray(Buffer[tr].States[st].action);

我们将组装好的张量传递给 Encoder ,并调用 feedForward 方法。由此产生的嵌入结果被添加到结果矩阵中。

         if(!Convolution.feedForward(GetPointer(State), 1, false, NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         Convolution.getResults(temp);
         if(!result.Row(temp, state))
            continue;
         state++;
           }
        }
     }

然后,我们从轨迹缓冲区进入下一个状态。

在完成状态编码循环系统的所有迭代后,我们会将结果矩阵的大小减小到实际保存的嵌入数,并清除缓冲区中之前加载的轨迹。之后,我们将只使用嵌入的数据。

   if(state != total_states)
      result.Reshape(state, result.Cols());
   ArrayFree(Buffer);

将结果返回给调用程序并终止方法。

//---
   return result;
  }

接下来,我们建立了一种产生内部奖励的方法 ResearchReward。请注意,在训练探索型 Actor 时,为了创建一个有效探索环境的系统,我们将只使用内部奖励,旨在鼓励智能体执行多样化和非重复性的行动。因此,在这个阶段,我们不需要会限制环境空间的标记数据或外在奖励。在这方面,应特别注意内部奖励的形成。

在 ResearchReward 方法的参数中,我们传入:

  • 用于形成内部奖励的最接近状态和行动的量值
  • 分析状态的嵌入
  • 使用上述方法形成的状态嵌入矩阵

在方法主体中,我们会准备一个零结果向量,并检查分析状态的嵌入大小是否与之前创建的矩阵中的嵌入相匹配。

vector<float> ResearchReward(double quant, vector<float> &embedding, matrix<float> &state_embedding)
  {
   vector<float> result = vector<float>::Zeros(NRewards);
   if(embedding.Size() != state_embedding.Cols())
     {
      PrintFormat("%s -> %d Inconsistent embedding size", __FUNCTION__, __LINE__);
      return result;
     }

成功传递控制块后,初始化本地变量。

   ulong size = embedding.Size();
   ulong states = state_embedding.Rows();
   ulong k = ulong(states * quant);
   matrix<float> temp = matrix<float>::Zeros(states, size);
   vector<float> min_dist = vector<float>::Zeros(k);
   matrix<float> k_embedding = matrix<float>::Zeros(k + 1, size);
   matrix<float> U, V;
   vector<float> S;

下一步,我们将计算先前保存在经验回放缓冲区中的 "状态-行动" 分析对之间的距离。为了获得距离的软估计值,我们使用了DWSL方法作者提出的 LogSumExp 函数。

   for(ulong i = 0; i < size; i++)
      temp.Col(MathAbs(state_embedding.Col(i) - embedding[i]), i);
   float alpha = temp.Max();
   if(alpha == 0)
      alpha = 1;
   vector<float> dist = MathLog(MathExp(temp / (-alpha)).Sum(1)) * (-alpha);

接下来,我们选择最近的"状态-行动"对的所需嵌入数。

   float max = dist.Quantile(quant);
   for(ulong i = 0, cur = 0; (i < states && cur < k); i++)
     {
      if(max < dist[i])
         continue;
      min_dist[cur] = dist[i];
      k_embedding.Row(state_embedding.Row(i), cur);
      cur++;
     }
   k_embedding.Row(embedding, k);

利用核规范算法,我们为选定的 Actor 行动和潜伏状态生成内部奖励。

   k_embedding.SVD(U, V, S);
   result[NRewards - 2] = S.Sum() / (MathSqrt(MathPow(k_embedding, 2.0f).Sum() * MathMax(k + 1, size)));
   result[NRewards - 1] = EntropyLatentState(Actor);
//---
   return result;
  }

结果将返回给调用程序。

请注意,在结果向量中,外在奖励元素的值为零。这与 ExORL 框架是一致的。有关 EA 的设计目的是组织对环境进行不受控制的探索。如上所述,在这一阶段使用外在奖励只会缩小研究的子空间。

OnTick 分时处理方法实现了与环境互动和探索性 Actor 训练的过程。请注意,在这一阶段,学习过程已经简化。学习过程中只使用 1 个 Critic。此外,在探索性 Actor 模型训练过程中,我们确实取消了经验回放缓冲区的使用。策略测试器中的额外通道可能会弥补这一缓冲区的缺失。

我们将对每个烛形进行一次反向传播。参数根据 Actor 的最后一个行动进行调整。

这种方法可能不是最有效或最容易实施的。不过,这对评估该方法的有效性非常适用。

在方法主体中,首先检查是否出现新的柱形。

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
//---
   if(!IsNewBar())
      return;

然后加载历史数据。

//---
   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
      return;
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();
   Symb.Refresh();
   Symb.RefreshRates();

接下来,我们创建探索 Actor 的源数据缓冲区。在这里,我们首先用接收到的历史数据填充环境状态描述缓冲区。

   float atr = 0;
   for(int b = 0; b < (int)HistoryBars; b++)
     {
      float open = (float)Rates[b].open;
      float rsi = (float)RSI.Main(b);
      float cci = (float)CCI.Main(b);
      atr = (float)ATR.Main(b);
      float macd = (float)MACD.Main(b);
      float sign = (float)MACD.Signal(b);
      if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
         continue;
      //---
      int shift = b * BarDescr;
      sState.state[shift] = (float)(Rates[b].close - open);
      sState.state[shift + 1] = (float)(Rates[b].high - open);
      sState.state[shift + 2] = (float)(Rates[b].low - open);
      sState.state[shift + 3] = (float)(Rates[b].tick_volume / 1000.0f);
      sState.state[shift + 4] = rsi;
      sState.state[shift + 5] = cci;
      sState.state[shift + 6] = atr;
      sState.state[shift + 7] = macd;
      sState.state[shift + 8] = sign;
     }
   bState.AssignArray(sState.state);

接下来,我们检查当前账户状态和未结头寸。

   sState.account[0] = (float)AccountInfoDouble(ACCOUNT_BALANCE);
   sState.account[1] = (float)AccountInfoDouble(ACCOUNT_EQUITY);
//---
   double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0;
   double position_discount = 0;
   double multiplyer = 1.0 / (60.0 * 60.0 * 10.0);
   int total = PositionsTotal();
   datetime current = TimeCurrent();
   for(int i = 0; i < total; i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      double profit = PositionGetDouble(POSITION_PROFIT);
      switch((int)PositionGetInteger(POSITION_TYPE))
        {
         case POSITION_TYPE_BUY:
            buy_value += PositionGetDouble(POSITION_VOLUME);
            buy_profit += profit;
            break;
         case POSITION_TYPE_SELL:
            sell_value += PositionGetDouble(POSITION_VOLUME);
            sell_profit += profit;
            break;
        }
      position_discount += profit - (current - PositionGetInteger(POSITION_TIME)) * multiplyer * MathAbs(profit);
     }
   sState.account[2] = (float)buy_value;
   sState.account[3] = (float)sell_value;
   sState.account[4] = (float)buy_profit;
   sState.account[5] = (float)sell_profit;
   sState.account[6] = (float)position_discount;
   sState.account[7] = (float)Rates[0].time;

根据接收到的数据,我们创建一个描述账户状态的缓冲区。

   bAccount.Clear();
   bAccount.Add((float)((sState.account[0] - PrevBalance) / PrevBalance));
   bAccount.Add((float)(sState.account[1] / PrevBalance));
   bAccount.Add((float)((sState.account[1] - PrevEquity) / PrevEquity));
   bAccount.Add(sState.account[2]);
   bAccount.Add(sState.account[3]);
   bAccount.Add((float)(sState.account[4] / PrevBalance));
   bAccount.Add((float)(sState.account[5] / PrevBalance));
   bAccount.Add((float)(sState.account[6] / PrevBalance));

在这个缓冲区中,我们添加了时间戳谐波向量。

   double x = (double)Rates[0].time / (double)(D'2024.01.01' - D'2023.01.01');
   bAccount.Add((float)MathSin(2.0 * M_PI * x));
   x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_MN1);
   bAccount.Add((float)MathCos(2.0 * M_PI * x));
   x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_W1);
   bAccount.Add((float)MathSin(2.0 * M_PI * x));
   x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_D1);
   bAccount.Add((float)MathSin(2.0 * M_PI * x));

生成的数据足以运行 Actor 的 feedForward 过程。

   if(bAccount.GetIndex() >= 0)
      if(!bAccount.BufferWrite())
         return;
//---
   if(!Actor.feedForward(GetPointer(bState), 1, false, GetPointer(bAccount)))
      return;

在 Actor 的 feedForward 成功后,我们会得到一个预测行动的向量,并将其解密后传送给环境。

   PrevBalance = sState.account[0];
   PrevEquity = sState.account[1];
//---
   vector<float> temp;
   Actor.getResults(temp);
//---
   double min_lot = Symb.LotsMin();
   double step_lot = Symb.LotsStep();
   double stops = MathMax(Symb.StopsLevel(), 1) * Symb.Point();
   if(temp[0] >= temp[3])
     {
      temp[0] -= temp[3];
      temp[3] = 0;
     }
   else
     {
      temp[3] -= temp[0];
      temp[0] = 0;
     }

首先,我们作为多头头寸的一部分与环境互动。

//--- buy control
   if(temp[0] < min_lot || (temp[1] * MaxTP * Symb.Point()) <= stops || (temp[2] * MaxSL * Symb.Point()) <= stops)
     {
      if(buy_value > 0)
         CloseByDirection(POSITION_TYPE_BUY);
     }
   else
     {
      double buy_lot = min_lot + MathRound((double)(temp[0] - min_lot) / step_lot) * step_lot;
      double buy_tp = NormalizeDouble(Symb.Ask() + temp[1] * MaxTP * Symb.Point(), Symb.Digits());
      double buy_sl = NormalizeDouble(Symb.Ask() - temp[2] * MaxSL * Symb.Point(), Symb.Digits());
      if(buy_value > 0)
         TrailPosition(POSITION_TYPE_BUY, buy_sl, buy_tp);
      if(buy_value != buy_lot)
        {
         if(buy_value > buy_lot)
            ClosePartial(POSITION_TYPE_BUY, buy_value - buy_lot);
         else
            Trade.Buy(buy_lot - buy_value, Symb.Name(), Symb.Ask(), buy_sl, buy_tp);
        }
     }

在空头头寸上重复。

//--- sell control
   if(temp[3] < min_lot || (temp[4] * MaxTP * Symb.Point()) <= stops || (temp[5] * MaxSL * Symb.Point()) <= stops)
     {
      if(sell_value > 0)
         CloseByDirection(POSITION_TYPE_SELL);
     }
   else
     {
      double sell_lot = min_lot + MathRound((double)(temp[3] - min_lot) / step_lot) * step_lot;;
      double sell_tp = NormalizeDouble(Symb.Bid() - temp[4] * MaxTP * Symb.Point(), Symb.Digits());
      double sell_sl = NormalizeDouble(Symb.Bid() + temp[5] * MaxSL * Symb.Point(), Symb.Digits());
      if(sell_value > 0)
         TrailPosition(POSITION_TYPE_SELL, sell_sl, sell_tp);
      if(sell_value != sell_lot)
        {
         if(sell_value > sell_lot)
            ClosePartial(POSITION_TYPE_SELL, sell_value - sell_lot);
         else
            Trade.Sell(sell_lot - sell_value, Symb.Name(), Symb.Bid(), sell_sl, sell_tp);
        }
     }

然后,与环境交互的结果会被收集到一个用于描述状态和行动的结构中。然后再加上外在奖励。之后,将所有这些添加到轨迹中,根据通过结果,轨迹将被添加到经验重放缓冲区中。

//---
   sState.rewards[0] = bAccount[0];
   sState.rewards[1] = 1.0f - bAccount[1];
   if((buy_value + sell_value) == 0)
      sState.rewards[2] -= (float)(atr / PrevBalance);
   else
      sState.rewards[2] = 0;
   for(ulong i = 0; i < NActions; i++)
      sState.action[i] = temp[i];
   sState.rewards[3] = 0;
   sState.rewards[4] = 0;
   if(!Base.Add(sState))
      ExpertRemove();

注意奖励向量。到目前为止,我们一直在谈论无控制的探索,而向量则使用外部奖励填充。相反,内部奖励元素的值为零。请注意,保存的轨迹将用于在 ExORL 框架的第 3 阶段训练主 Actor 策略。然而,奖励缓冲区的群体是与重新评估状态和行动有关的第二阶段的实现。因此,我们的所有行动都符合 ExORL 算法的框架。

如你所见,上面介绍的算法与我们之前讨论的与环境互动的方法几乎相同。但在这里,我们并不像之前那样完成方法操作。接下来,我们将实现探索性 Actor 策略的学习过程。

首先,我们需要嵌入当前状态和已完成的行动。为了获得这些信息,我们会在当前环境状态的缓冲区中添加有关账户状态和 Actor 所执行行动的信息。我们将生成的缓冲区输入 Encoder,并调用 feedForward 方法。

   bState.AddArray(GetPointer(bAccount));
   bState.AddArray(temp);
   bActions.AssignArray(temp);
   if(!Convolution.feedForward(GetPointer(bState), 1, false, NULL))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }
   Convolution.getResults(temp);

运行成功后,我们会收到当前状态的嵌入信息。

接下来,我们会检查是否有任何加载的关于之前行驶轨迹的数据,如有必要,我们会调用上文介绍的 CreateEmbeddings 方法对其进行编码。

   if(!BaseLoaded)
     {
      state_embeddings = CreateEmbeddings();
      BaseLoaded = true;
     }

请注意,无论运行结果如何,我们都会将数据加载标志设置为true。这将使我们今后不再重复尝试加载已通过状态的数据库。

接下来,我们检查状态嵌入矩阵的大小。如果该矩阵的大小为零,可能表示没有之前走过的轨迹。在这种情况下,我们现阶段没有数据来更新模型参数。因此,我们只需将当前状态的嵌入添加到矩阵中即可。然后,我们继续等待下一根烛形的开启。

   ulong total_states = state_embeddings.Rows();
   if(total_states <= 0)
     {
      ResetLastError();
      if(!state_embeddings.Resize(total_states + 1, state_embeddings.Cols()) ||
         !state_embeddings.Row(temp, total_states))
         PrintFormat("%s -> %d: Error of adding new embedding %", __FUNCTION__, __LINE__, GetLastError());
      return;
     }

如果传递的状态嵌入矩阵中有数据,我们就会生成内部奖励,并将当前状态嵌入添加到矩阵中。

   vector<float> rewards = ResearchReward(Quant, temp, state_embeddings);
   ResetLastError();
   if(!state_embeddings.Resize(total_states + 1, state_embeddings.Cols()) ||
      !state_embeddings.Row(temp, total_states))
      PrintFormat("%s -> %d: Error of adding new embedding %", __FUNCTION__, __LINE__, GetLastError());

只有在生成内部奖励后,才将当前状态嵌入添加到已传递状态的嵌入矩阵中,这一点非常重要。否则,在计算内部奖励时会两次考虑当前的嵌入,从而导致数据失真。

另一方面,如果完全排除向矩阵添加嵌入的过程,就无法在生成内部奖励时考虑当前的传递状态。

我们将生成的内部奖励传送到数据缓冲区。之后,我们为 Critic 运行前馈和反向传播过程。随后是用于探索 Actor 的反向传播过程。

   Result.AssignArray(rewards);
   if(!Critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(bActions)) ||
      !Critic.backProp(Result, GetPointer(bActions), GetPointer(bGradient)) ||
      !Actor.backPropGradient(GetPointer(bAccount), GetPointer(bGradient), LatentLayer))
      PrintFormat("%s -> %d: Error of backpropagation %", __FUNCTION__, __LINE__, GetLastError());
  }

请注意,在这种情况下,我们在一次操作中连续调用了 Critic 的前馈和反向传播方法。这是因为在这种情况下,我们不对 Critic 进行训练,也不对其前馈传递的结果进行评估。我们只需要它将误差梯度传递给 Actor。因此,这两个方法都会作为 Actor 反向传播过程的一部分被调用。这导致方法调用的安排很不寻常,但在其他方面并不影响最终结果。

关于探索性 Actor 策略的环境互动和在线学习方法的介绍到此结束。使用其他 EA 方法时不需要改动。您可以在附件中找到它们。

我们接下来调整模型训练 EA 交易。尽管该方法的作者在实验中使用了训练模型的基本方法,但实现我们的方法需要对前一篇文章中的训练 EA 做一些改动。改动的主要原因是 Encoder 架构的变化,这导致了与模型交互相关的变化。但首先要做的是最重要的。

所做的更改不是全局性的,因此,我们将只重点探讨模型训练方法 "Train"。在方法主体中,我们检查已加载轨迹的数量。

//+------------------------------------------------------------------+
//| Train function                                                   |
//+------------------------------------------------------------------+
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);

然后,我们组织一个循环系统,对之前传递的状态进行编码,并编制一个嵌入矩阵。这一过程与之前描述的过程类似。但还有一个警告。

与之前一样,在循环系统的主体中,我们填充当前环境状态缓冲区。

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

这种解决方案消除了已完成操作对状态嵌入的影响。这样,我们又回到了上一篇文章中的 DWSL 方法的实现过程,平衡了 Encoder 架构的变化。因此,根据 ExORL 方法作者的建议,我们使用不变的方法来训练模型。在这种情况下,在训练所有模型的过程中,我们使用一个状态-动作 Encoder。这样就能正确地训练探索性 Actor 策略和主要 Actor 策略。

接下来,我们执行编码器的前馈传递过程。操作结果以状态嵌入的形式加入到矩阵中。

         if(!Convolution.feedForward(GetPointer(State), 1, false, NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            ExpertRemove();
            return;
           }
         Convolution.getResults(temp);
         if(!state_embedding.Row(temp, state))
            continue;

与此同时,我们根据 DWSL 算法填充学习过程中使用的行动和奖励矩阵。与之前一样,奖励矩阵中填入了所采取的行动的优势值。

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

向用户通知状态编码进度,并进入循环系统的下一次迭代。

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

成功完成所有状态编码迭代后,我们将矩阵大小减小到实际保存的数据量。不过,与上文讨论的 CreateEmbeddings 编码方法不同,我们不会清除轨迹数组,因为我们在训练模型时仍然需要它。

   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;
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);
   int bar = (HistoryBars - 1) * BarDescr;

然后,我们创建一个训练循环。在循环主体中,我们对轨迹和轨迹上的状态进行采样。

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

然后,我们检查是否需要在回合结束前生成奖励。如果需要生成,我们会填充环境后续状态的缓冲区。

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

生成的数据足以执行 Actor 的前馈传递,后者将根据更新后的策略生成一个行动。

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

由此产生的行动由 2 个目标 Critics 进行评估。

         if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
            !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         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 模型。为此,我们要构造一个描述当前环境状态的向量。

      //--- 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));

之后是 Actor 的前馈过程。

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

大家可能还记得,我们使用与环境互动时的实际行动来训练 "Critics"。但我们需要 Actor 的前馈传递来形成潜在状态。

接下来,我们将训练集中的实际操作复制到数据缓冲区,并对 "Critics" 进行前馈传递。

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

然后,我们将当前环境状态描述的缓冲区加入账户状态数据和一个零向量,以替换 Actor 的行动。然后,我们生成环境分析状态的嵌入。

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

根据接收到的嵌入信息,我们生成目标结构来训练模型。生成目标值方法的算法已在上一篇文章中介绍过。 

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

在这一步中,我们获得了 Critics 反向传播所需的所有数据。但是,由于我们将使用 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;
        }

在这一步中,我们要训练 Actor 的基本策略。与以往一样,我们将采用多种方法组合来训练策略。首先,我们使用 DWSL 算法,训练 Actor 重复行动,并根据操作对最终结果的影响进行加权。

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

在此之后,我们将调整 Actor 的行动,使其朝着回报增加的方向发展。只有当我们对 Critics 对行动的评估的正确性非常有信心时,才会使用第二阶段的训练。

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

在训练过程的迭代结束时,我们会调整目标模型的参数。

      //--- 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();
//---
  }

关于所用程序算法的说明到此为止。文章中使用的所有程序的完整代码见附件。我们现在开始对已完成的工作进行测试。


3.测试

在本文的前几节中,我们了解了离线 RL 方法的探索数据,并使用 MQL5 实现了我们对所介绍方法的设想。现在是评估结果的时候了。一如既往,模型的训练和测试在 EURUSD H1 上进行。指标使用默认参数。模型根据 2023 年前 7 个月的历史数据进行训练。为了测试训练好的模型,我们使用了 2023 年 8 月的历史数据。

文章中介绍的算法可以训练全新的模型,也就是从零开始的训练。不过,这种方法也允许对先前训练的模型进行微调。因此,我决定测试第二种方案。正如我在文章开头所说,我使用了前一篇文章中的 EA 作为这项工作的基础。并且,我们将优化这一模式。首先,我们需要重命名模型文件。

DWSL.bd ==> ExORL.bd
DWSLAct.nnw ==>
ExORLAct.nnw
DWSLCrt1.nnw ==>
ExORLCrt1.nnw
DWSLCrt2.nnw ==>
ExORLCrt2.nnw

我们不转移 Encoder 模型,因为我们改变了它的架构。

重命名文件后,我们启动 EA ResearchExORL.mq5,对训练数据的环境进行进一步研究。在我的工作中,我从 5 个测试代理那里收集了 100 个额外的通过数据。

实际经验表明,通过不同方法收集的回放缓冲区可以并行使用。我使用了之前讨论过的 EA Research.mq5 和 EA ResearchExORL.mq5 收集的轨迹。第一,指出了学习完毕的 Actor 策略的优缺点。其次,我们可以尽可能多地探索环境,评估未被考虑的机会。

在反复训练模型的过程中,我设法提高了模型的性能。

测试结果

测试结果

虽然测试期间的交易次数总体减少了 3 倍(56 对 176),但利润却增加了近 3 倍。最大盈利交易额增加了一倍多。平均盈利交易增加了 5 倍。此外,我们还发现,在整个测试期间,余额都在增加。因此,模型的利润系数从 1.3 提高到 2.96。 


结论

在这篇文章中,我们介绍了一种新方法 - 离线 RL 的探索数据,它主要侧重于为离线模型训练的训练数据集收集数据的方法。该方法的作者所做的实验表明,选择源数据是一个关键问题,它与选择模型结构及其训练方法一样,都会对结果产生影响。

在文章的实践部分,我们实现了对所提方法的设想,并使用 MetaTrader 5 策略测试器的历史数据对其进行了测试。测试结果证实了该方法作者关于训练样本收集算法对模型训练结果影响的结论。因此,通过改变收集训练轨迹的方法,我们成功地优化了前一篇文章中介绍的模型的性能。

不过,我想再次提醒大家,文章中介绍的所有程序仅用于演示技术,并不是准备用于实际交易的。


参考

  • 不要改变算法,要改变数据:离线强化学习的探索数据
  • 神经网络变得简单(第 65 部分):距离加权监督学习(DWSL)

  • 本文中用到的程序

    # 名称 类型 描述
    1 Research.mq5 EA 示例收集 EA
    2 ResearchExORL.mq5 EA 使用 ExORL 方法收集示例的 EA
    3 Study.mq5  EA 智能体训练 EA
    4 Test.mq5 EA 模型试验 EA
    5 Trajectory.mqh 类库 系统状态描述结构
    6 NeuroNet.mqh 类库 用于创建神经网络的类库
    7 NeuroNet.cl 代码库 OpenCL 程序代码库

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

    附加的文件 |
    MQL5.zip (622.43 KB)
    交易策略 交易策略
    各种交易策略的分类都是任意的,下面这种分类强调从交易的基本概念上分类。
    软件开发和 MQL5 中的设计范式(第 3 部分):行为范式 1 软件开发和 MQL5 中的设计范式(第 3 部分):行为范式 1
    来自设计范式文献的一篇新文章,我们将看到类型其一,即行为范式,从而理解我们如何有效地在所创建对象之间构建通信方法。通过完成这些行为范式,我们就能够理解创建和构建可重用、可扩展、经过测试的软件。
    新手在交易中的10个基本错误 新手在交易中的10个基本错误
    新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
    MQL5 简介(第 1 部分):算法交易新手指南 MQL5 简介(第 1 部分):算法交易新手指南
    通过我们的 MQL5 编程新手指南,进入算法交易的迷人领域。在揭开自动化交易世界的神秘面纱之际,让我们探索支持MetaTrader 5 的语言 MQL5 的精髓。从了解基础知识到迈出编码的第一步,本文是您即使没有编程背景也能释放算法交易潜力的关键。加入我们的旅程,在令人兴奋的 MQL5 世界里,体验简单与复杂的结合吧。