English Русский Español Deutsch 日本語 Português
preview
神经网络变得轻松(第四十八部分):降低 Q-函数高估的方法

神经网络变得轻松(第四十八部分):降低 Q-函数高估的方法

MetaTrader 5交易系统 | 23 二月 2024, 09:47
383 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

上一篇文章中,我研究了深度判定性策略梯度(DDPG)方法,该方法专为在连续动作空间中训练模型而设计。这令我们能够将模型训练提升到一个新的水平。结果就是,我们的最终代理者不仅能够预测即将到来的价格走势方向,而且还能够执行资本和风险管理功能。它指示要开仓的最优规模,以及止损和止盈价位。

不过,DDPG 也有其缺点。像 Q-学习的其它追随者一样,它存在高估 Q-函数值的问题。在训练过程中,误差会累积,最终导致代理者学到次优策略。

您可能还记得,在 DDPG 中,评论者模型基于与环境交互的结果学习 Q-函数(预期奖励的预测),而代理者模型仅基于评论者对动作的估算结果进行训练,以便将预期奖励最大化。故而,评论者的训练质量极大地影响到代理者的行为策略,及其做出最优决策的能力。


1. 降低高估的方法

在利用 DQN 方法及其衍生品训练各种模型时,经常会出现高估 Q-函数值的问题。这是具有离散动作的两种模型在连续动作空间求解的特征。这种现象的原因,以及消除其后果的方法会因人而异。故此,解决这个问题的一套综合方式很重要。2018 年 2 月发表的文章“解决扮演者—评论者方法中的函数逼近误差”中提到过一种方式。它提议一种称为“孪生延迟深度判定性策略梯度(TD3)”的算法。该算法是 DDPG 的逻辑延续,并对其进行了一些改进,从而提高了模型训练的品质。

首先,作者增加了第二个评论者。这个思路并不新鲜,以前曾用于离散动作空间模型。然而,针对第二位评论者的参与,该方法的作者奉献了他们的理解、愿景和方式。

这个思路是,两个评论者都以随机参数进行初始化,并基于相同数据并行训练。采用不同的初始参数初始化后,它们从不同的状态开始训练。但两位评论者都基于相同数据训练,因此他们应该朝着相同的(期待的全局)最小值迈进。在训练过程中,他们的预测结果会聚合,这是很自然的。不过,由于各种因素的影响,它们并不会完全雷同。他们中的每一个都存在高估 Q-函数的问题。但在某个时间点,一个模型会高估 Q-函数,而第二个模型则会低估它。即使两个模型都高估了 Q-函数,一个模型的误差也会小于第二个的误差。基于这些假设,该方法作者建议使用最小预测来训练两个评论者。因此,我们把学习过程中高估 Q-函数的影响和积累的误差最小化。

从数学上讲,该方法可以表示如下:

与 DDPG 类似,TD3 的作者建议使用目标模型的软更新。作者通过真实示例证明,使用目标模型的软更新可以带来更稳定的 Q-函数学习过程,且结果的差距更小。同时,在训练过程中使用更稳定(更新较少)的目标会导致 Q-函数累积的重新估算误差降低。

实验结果促令方法作者能大幅降低更新扮演者政策。 

如您所知,训练神经网络是一个逐渐减少误差的迭代过程。训练速度由训练系数和参数更新算法决定。这种方式允许均化训练样本上的误差,并构建尽可能接近所研究过程的模型。

扮演者模型的结果是评论者训练集的一部分。扮演者政策的罕有更新允许我们降低评论者训练样本的随机性,从而提高其训练的稳定性。

反过来,基于来自评论者的更准确估算结果的数据来训练扮演者,令我们能够提高扮演者工作的品质,并消除不必要的更新操作和错误的结果。

此外,TD3 算法的作者建议在训练过程中添加平滑的目标函数。使用子流程是基于类似动作应导致相似结果的假设。我们假设执行两个略有不同的动作会导出相同的结果。因此,在代者理的动作中添加轻微的噪音不会改变来自环境的奖励。但这将令我们能够为评论者的学习过程增加一些随机性,并在目标值的特定环境中令其估算更加平滑。

这种方法允许在评论者的训练中引入一种正则化,并平滑导致高估 Q-函数值的峰值。

因此,孪生延迟深度判定性策略梯度(TD3)为 DDPG 算法引入了 3 个主要补充:

  1. 2 名评论者的并行训练
  2. 延迟更新扮演者参数
  3. 平滑目标函数。

如您所见,所有 3 个添加项仅与训练安排有关,且不会影响模型的架构。


2. 利用 MQL5 实现

在本文的实践部分,我们将研究以 MQL5 实现 TD3 算法。在此实现中,我们仅用到 3 个添加项中的 2 个。由于金融市场本身的随机性,我没有把目标函数加上平滑。我们不太可能在整个训练集中找到 2 个完全雷同的状态。

我们还回到使用 3 个 EA 的体验:

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

此外,我们正在对模型结果的解释,以及 EA 的交易算法进行更改。

2.1. 交易算法的改变

首先,我们谈谈改变交易算法。我决定摆脱按照“开仓即忘”原则(基于当前市场状况的分析结果开仓,并按止损或止盈平仓)无休止地开新仓。取而代之,我们只开立并维护一笔持仓。同时,我们不排除加仓和部分平仓。

在这个范式中,我们改变了对模型信号的解释。如前,代理者返回 6 个值:2 个交易方向的持仓规模、止损、和止盈。但现在我们比较当前持仓的交易量,并在必要时加仓或部分平仓。我们将利用标准手段添加资金。为了部分平仓,我们将创建 ClosePartial 函数。 

我们可以利用标准手段将一笔持仓部分平仓。但我们假设因加仓而存在若干笔持仓。因此,所创建函数的任务是从总交易量里按照 FIFO(先进先出)方法平仓。

在参数中,该函数接收仓位类型和平仓交易量。在函数体中,我们立即检查接收到的平仓交易量,如果收到的值不正确,我们将终止该函数。

接下来,我们安排一个遍历搜素所有持仓的循环。在循环体中,检查金融产品和开仓类型。当找到所需持仓时,我们会检查其交易量。这里有 2 个选项:

  • 持仓交易量小于或等于平仓交易量 — 我们会彻底平仓,且把平仓交易量减去持仓交易量
  • 持仓交易量大于平仓交易量 — 我们部分平仓,并将平仓交易量重置为零。

我们进行循环的迭代,直到搜索完毕所有持仓,或直到平仓交易量大于 “0”。

bool ClosePartial(ENUM_POSITION_TYPE type, double value)
  {
   if(value <= 0)
      return true;
//---
   for(int i = 0; (i < PositionsTotal() && value > 0); i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      if(PositionGetInteger(POSITION_TYPE) != type)
         continue;
      double pvalue = PositionGetDouble(POSITION_VOLUME);
      if(pvalue <= value)
        {
         if(Trade.PositionClose(PositionGetInteger(POSITION_TICKET)))
           {
            value -= pvalue;
            i--;
           }
        }
      else
        {
         if(Trade.PositionClosePartial(PositionGetInteger(POSITION_TICKET), value))
            value = 0;
        }
     }
//---
   return (value <= 0);
  }

我们已依据持仓规模有所决定。现在我们来谈谈止损和止盈水平。来自交易的经验,我们知道,当价格逆仓移动时,平移止损水平是一种坏毛病,这只会导致风险增加以及亏损累积。因此,我们仅在交易方向上尾随停止。我们允许止盈水平在两个方向上移动。此处的逻辑很简单。我们最初可以更保守地设定止盈,但市场发展表明走势更强劲。因此,我们可以跟踪止损,且仍然提升预期盈利。如果我们迷失了预期市场走势,我们可以降低盈利能力标准。我们只取市场给予的东西。

为了实现所描述的功能,我们创建了 TrailPosition 函数。在函数参数中,我们指定持仓类型、止损和止盈价格。请注意,我们准确指示交易水平的价格,而非距当前价格的相对点数。

我们不在函数体中检查指定的级别。我们把这个问题留给用户,并注明需要在主程序一侧进行这种控制。

接下来,我们安排一个遍历搜素所有持仓的循环。与部分平仓的函数类似,在循环的主体中,我们检查金融产品和持仓类型。

当我们找到期待的持仓时,我们将当前止损和仓位的止盈保存到局部变量之中。同时,我们将持仓修改标志设置为 “false”。

此后,我们检查持仓的交易水平与参数中得到的交易水平的偏差。取决于持仓类型检查是否需要对其修改。因此,我们在 “switch” 语句的主体中执行该控制,并检查持仓类型。若有必要修改改至少一处交易水平,我们替换局部变量中的相应数值,并将持仓修改标志改为 “true”。

在循环操作结束时,我们检查持仓修改标志的值,并在必要时更新其交易水平。操作结果存储在局部变量之中。

在搜索所有持仓后,我们的函数完成,并将所执行操作的逻辑结果返回给调用程序。

bool TrailPosition(ENUM_POSITION_TYPE type, double sl, double tp)
  {
   int total = PositionsTotal();
   bool result = true;
//---
   for(int i = 0; i <total; i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      if(PositionGetInteger(POSITION_TYPE) != type)
         continue;
      bool modify = false;
      double psl = PositionGetDouble(POSITION_SL);
      double ptp = PositionGetDouble(POSITION_TP);
      switch(type)
        {
         case POSITION_TYPE_BUY:
            if((sl - psl) >= Symb.Point())
              {
               psl = sl;
               modify = true;
              }
            if(MathAbs(tp - ptp) >= Symb.Point())
              {
               ptp = tp;
               modify = true;
              }
            break;
         case POSITION_TYPE_SELL:
            if((psl - sl) >= Symb.Point())
              {
               psl = sl;
               modify = true;
              }
            if(MathAbs(tp - ptp) >= Symb.Point())
              {
               ptp = tp;
               modify = true;
              }
            break;
        }
      if(modify)
         result = (Trade.PositionModify(PositionGetInteger(POSITION_TICKET), psl, ptp) && result);
     }
//---
   return result;
  }

谈到扮演者信号解释的变化,值得关注的还有一点。以前,我们在扮演者的输出端使用 LReLU 作为激活函数。这令我们能够依据上限值获得无限的结果。它还允许我们显示负面结果,然我们将其视为无交易信号。在当前对扮演者信号的解释范式中,我们决定将激活函数更改为 0 到 1 范围的 sigmoid。作为交易量,我们对这些值非常满意。至于交易水平这样说就不成了。为了破译交易水平的数值,我们引入了 2 个常量,它们判定止损和止盈距价格的最大缩进尺度。通过将这些常数乘以相应的扮演者数据,我们将从当前价格中获得以点数为单位的交易水平。

#define                    MaxSL          1000
#define                    MaxTP          1000

在所有其它层面,我们模型的架构保持不变。因此,我不会在此赘述。您可以在附件中找到它。一如既往,模型架构的描述位于 “TD3\Trajectory.mqh” 当中,即 CreateDescriptions 函数。

2.2. 构建收集样本数据库的 EA

现在我们已经确定了破译 Actor 信号的原理和交易算法的基础知识,我们可以直接开始模型训练 EA 的工作。

首先,我们将创建 “TD3\Research.mq5” EA 来收集示例的训练样本。EA 建立在先前审查过的类似 EA 基础上。在本文中,我们将只参考 OnTick 方法,它实现了上述交易算法。其它方面,新的 EA 版本与以前的版本没有太大区别。

在方法伊始,如前,我们检查新蜡烛开立事件。然后,我们加载交易品种价格走势的历史数据,以及分析指标的参数。

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

我们将加载的数据传递到描述环境当前状态的缓冲区。

   MqlDateTime sTime;
   float atr = 0;
   State.Clear();
   for(int b = 0; b < (int)HistoryBars; b++)
     {
      float open = (float)Rates[b].open;
      TimeToStruct(Rates[b].time, sTime);
      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;
      //---
      State.Add((float)Rates[b].close - open);
      State.Add((float)Rates[b].high - open);
      State.Add((float)Rates[b].low - open);
      State.Add((float)Rates[b].tick_volume / 1000.0f);
      State.Add((float)sTime.hour);
      State.Add((float)sTime.day_of_week);
      State.Add((float)sTime.mon);
      State.Add(rsi);
      State.Add(cci);
      State.Add(atr);
      State.Add(macd);
      State.Add(sign);
     }

下一步是准备一个描述帐户状态的向量。

   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;
//---
   Account.Clear();
   Account.Add((float)((sState.account[0] - PrevBalance) / PrevBalance));
   Account.Add((float)(sState.account[1] / PrevBalance));
   Account.Add((float)((sState.account[1] - PrevEquity) / PrevEquity));
   Account.Add(sState.account[2]);
   Account.Add(sState.account[3]);
   Account.Add((float)(sState.account[4] / PrevBalance));
   Account.Add((float)(sState.account[5] / PrevBalance));
   Account.Add((float)(sState.account[6] / PrevBalance));

正如我们所见,初始数据的准备类似于前面讨论的智能系统中的安排。

接下来,我们将准备好的数据传输到扮演者模型的输入,并执行前向验算。

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

保存我们需要的下一根柱线上的数据,并得到扮演者的工作结果。

   PrevBalance = sState.account[0];
   PrevEquity = sState.account[1];
//---
   vector<float> temp;
   Actor.getResults(temp);
   float delta = MathAbs(ActorResult - temp).Sum();
   ActorResult = temp;

请注意,我们在此 EA 中仅用到扮演者模型。毕竟,是由扮演者根据学到的政策(策略)生成行动。我们将在训练模型时用到评论者模型。

接下来,为了最大限度地探索环境,我们将在扮演者的结果中加入一点噪音。

在此,我们需要记住,我们有 2 种模式来启动 EA。在初始阶段,我们在没有预训练模型的情况下启动 EA,并按随机参数初始化我们的扮演者。在这种模式下,我们去探索环境时不需要添加噪音。毕竟,即便没有噪声,未经训练的模型也会给出混沌值。但当加载预训练模型时,添加噪声可令我们在扮演者决策附近探索环境。

我们将获得的数值限制在 sigmoid 的可接受数值范围之内,我们将其当作扮演者模型输出的激活函数。

   if(AddSmooth)
     {
      int err = 0;
      for(ulong i = 0; i < temp.Size(); i++)
         temp[i] += (float)(temp[i] * Math::MathRandomNormal(0, 0.3, err));
      temp.Clip(0.0f, 1.0f);
     }

接下来,我们转入解密扮演者结果向量的阶段。首先,我们将主要常量保存在局部变量当中:最小持仓交易量、持仓交易量的改变步长、以及交易水平的最小缩进。

   double min_lot = Symb.LotsMin();
   double step_lot = Symb.LotsStep();
   double stops = MathMax(Symb.StopsLevel(), 1) * Symb.Point();

首先,我们解密多头持仓的指标。第一个标识的向量元素含有持仓交易量。它应该大于或等于最小持仓交易量。第二个和第三个元素分别表示止盈和止损值。我们将这些元素按最大止盈和止损常数调整,并乘以单个交易品种的点值。结果就是,我们应该得到一个大于交易水平的最小缩进值。如果至少有一个参数不满足条件,我们将该方向的所有持仓平仓。

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

当扮演者的结果建议我们开仓或多头继续持仓时,我们会针对所分析交易品种按照经纪商的要求常规化持仓规模。我们将交易水平转换为特定的价格值。然后我们调用上述函数来修改持仓,指示 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);

接下来,我们将持仓规模按扮演者的建议对齐整。如果持仓的交易量大于建议交易量,则我们调用部分平仓函数。在此函数的参数中,我们指定 POSITION_TYPE_BUY 持仓类型,以及持仓交易量和推荐交易量之间的差值,作为平仓平仓规模。

如果建议添加,那么我们按照缺失的交易量再开立一笔额外的持仓。同时,我们示意建议的止损和止盈水平。

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

在方法结束时,我们将数据添加到轨迹数组之中,以便随后将其保存到样本数据库当中。在此,我们首先产生来自环境的奖励。作为奖励,我们采用余额的相对变化,我们之前将其记录在描述帐户状态的向量的第一个元素之中。如有必要,我们会在此奖励中增加对持仓缺失的惩罚。

我们将环境当前状态的向量和扮演者的结果添加到状态描述结构当中。我们早前输入了帐户状态描述数据。调用将当前状态添加到轨迹数组的方法。

//---
   float reward = Account[0];
   if((buy_value + sell_value) == 0)
      reward -= (float)(atr / PrevBalance);
   for(ulong i = 0; i < temp.Size(); i++)
      sState.action[i] = temp[i];
   State.GetData(sState.state);
   if(!Base.Add(sState, reward))
      ExpertRemove();
  }

其它转换而来的 EA 函数几乎没有变化。您可以在附件中找到它们。我们正在转入下一阶段的工作。

2.3. 创建模型训练 EA

该模型在 “TD3\Study.mq5” EA 中训练。在该 EA 中,我们编排了整个 TD3 算法,并训练了扮演者和 2 个评论者。

编排训练过程需要添加几个外部变量来帮助我们管理训练。如常,此处我们指示更新模型参数的迭代次数。在 TD3 方法的上下文中,这是指训练评论者模型。

input int                  Iterations     = 1000000;

为了指示扮演者的更新频率,我们将创建 UpdatePolicy 变量,在其中,我们将指示针对 1 个扮演者更新评论者的数量。

input int                  UpdatePolicy   = 3;

此外,我们还将指定目标模型的更新频率和更新比率。

input int                  UpdateTargets  = 100;
input float                Tau            = 0.01f;

在全局变量区域,我们将声明神经网络类的 6 个实例:扮演者、2 个评论者、和目标模型。

CNet                 Actor;
CNet                 Critic1;
CNet                 Critic2;
CNet                 TargetActor;
CNet                 TargetCritic1;
CNet                 TargetCritic2;

初始化 EA 的方法与之前文章中的类似 EA 几乎雷同,但要考虑到训练模型的数量不同。您可以在附件中找到它。

但在逆初始化方法中,我们更新并保存目标模型,而不是训练的那个(如前所做)。目标模型更静态,更不容易出错。

void OnDeinit(const int reason)
  {
//---
   TargetActor.WeightsUpdate(GetPointer(Actor), Tau);
   TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
   TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
   TargetActor.Save(FileName + "Act.nnw", 0, 0, 0, TimeCurrent(), true);
   TargetCritic1.Save(FileName + "Crt1.nnw", TargetCritic1.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   TargetCritic1.Save(FileName + "Crt2.nnw", TargetCritic2.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   delete Result;
  }

模型训练编排在 Train 函数中。在函数体中,将训练样本的加载轨迹数量保存到局部变量当中,并根据外部参数中指定的迭代次数安排训练循环。

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   uint ticks = GetTickCount();

在循环体中,我们从所选轨迹中随机选择一条轨迹和一个状态。

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

首先,我们对目标模型进行前向验算,这将令我们能够获得后续状态的预测值。

理论上,我们可以在没有目标函数的情况下训练模型。毕竟,我们可以从累积的实际后续奖励中判定后续状态的数值。如果与我们打交道的是环境的最终状态,这种方式也许是合适的。但我们正在为金融市场训练模型,在可预见的时间域内,金融市场是无限的。因此,1 或 3 个月前的类似状态对我们来说具有相同的价值,因为我们希望未来能从这种经验里取得好处。因此,无论历史深度如何,训练良好的评论者模型都会令结果具有可比性。

我们回到我们的 EA。我们将数据从样本数据库传输到描述环境状态的缓冲区,并形成一个描述帐户状态的向量。请注意,我们获取的数据不是针对所选状态,而是针对后续状态。

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

然后,我们编排直接验算目标扮演者模型。

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

接下来,我们直接验算 2 个目标评论者模型。这两个模型的源数据都是目标扮演者模型。

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

获得的数据令我们能够生成训练评论者模型的目标值。

我要提醒您,在当前条件下,每个评论者只返回预测动作成本的一个值。因此,我们的目标值会是一个数字。

根据 TD3 算法,我们从评论者模型的 2 个目标结果中获取最小值。将结果值乘以折扣系数,然后加上从样本数据库取得的动作实际奖励。

      TargetCritic1.getResults(Result);
      float reward = Result[0];
      TargetCritic2.getResults(Result);
      reward = DiscFactor * MathMin(reward, Result[0]) + (Buffer[tr].Revards[i] - Buffer[tr].Revards[i + 1]);

在这一点上,我们有一个评论者的目标值。TD3 算法仅为 2 个评论者模型提供一个目标值。但在回去之前,我们需要对评论者进行一次前向验算。这里有一个细微差别。如您所知,评论者架构不提供主数据处理单元。此功能由扮演者执行,我们将扮演者的隐含状态作为描述环境状态的初始数据传输给评论者。因此,我们首先从样本数据库中获取初始数据,并通过扮演者模型执行前向验算。

      //--- Q-function study
      State.AssignArray(Buffer[tr].States[i].state);
      PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
      PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
      Account.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);
      //---
      if(Account.GetIndex() >= 0)
         Account.BufferWrite();
      //---
      if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }

在此,我们应当记住,在训练过程中,扮演者很可能会返回与数据库中存储的样本不同的动作。不过,奖励与存储的动作不对应。由此,我们卸载了扮演者的隐含状态。从样本数据库中加载完美的动作。利用这些数据,我们对两个评论者进行了直接验算。

      if(!Critic1.feedForward(Result,1,false, GetPointer(Actions)) ||
         !Critic2.feedForward(Result,1,false, GetPointer(Actions)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }

此处,我们应留意一件事。理论上,我们可以在收集样本数据库的阶段保存扮演者的隐含状态,现在只需采用保存的数据即可。但在模型训练过程中,所有神经层的参数都会发生变化。故此,在训练扮演者时,数据预处理模块也会发生变化。由此,同一环境状态的隐含表示也会发生变化。如果我们采用不正确的初始数据来训练评论者,那么在训练扮演者时,我们最终会得到一个不可预测的结果。当然,我们想避免这种情况。因此,为了训练评论者,我们采用环境状态的正确隐含表示,以及样本数据库中已完成动作和相应奖励。

接下来,我们填充目标值缓冲区,并执行两个评论者的逆向验算。

      Result.Clear();
      Result.Add(reward);
      if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
         !Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }

我们转入扮演者训练。正如本文的理论部分所述,扮演者参数的更新频率较低。因此,我们首先在当前迭代中检查是否需要此过程。

      //--- Policy study
      if(iter > 0 && (iter % UpdatePolicy) == 0)
        {

当需要更新扮演者的参数时,我们会随机选择新的初始数据,以保持客观性。

         tr = (int)((MathRand() / 32767.0) * (total_tr - 1));
         i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2));
         State.AssignArray(Buffer[tr].States[i].state);
         PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
         PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
         Account.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);

接下来,我们执行扮演者前向验算。

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

然后我们执行一个评论者的向前验算。请注意,我们这里没有采用样本数据库中的数据。评论者前向验算完全基于扮演者的新结果进行,因为评估当前模型策略对我们来说很重要。

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

为了更新扮演者参数,我使用了 Critic1。根据我的观察,在这种情况下,评论者模型的选择并不那么重要。尽管评分不同,但两位评论者在测试期间向扮演者返回了相同的误差梯度值。

扮演者训练旨在最大化预期回报。我们采用评论者对行动评价的当前结果,并在其上添加一个小的正常数。当收到对动作的负面评价时,我采用我的正常数作为目标值。以这种方式,我试图加快退出负面评价领域。

         Critic1.getResults(Result);
         float forecast = Result[0];
         Result.Update(0, (forecast > 0 ? forecast + PoliticAdjust : PoliticAdjust));

在更新扮演者参数时,评论者模型仅当作一种损失函数。它只会在扮演者的输出端生成一个误差梯度。在这种情况下,评论者参数不会更改。为此,我们在逆向验算之前禁用了评论者的训练模式。将误差梯度传输到扮演者之后,我们将返回到评论者训练模式。

         Critic1.TrainMode(false);
         if(!Critic1.backProp(Result, GetPointer(Actor)) ||
            !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
           {
            Critic1.TrainMode(true);
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            ExpertRemove();
            break;
           }
         Critic1.TrainMode(true);
        }

在收到来自评论者的误差梯度后,我们执行扮演者的逆向验算。

在这个阶段,我们安排了评论者对 Q-函数的训练,并给扮演者传授了政策。我们所要做的就是实现目标模型的软更新。这在上一篇文章中进行了详细描述。在此,我们只需检查模型何时更新,并为每个目标模型调用相应的方法。

      //--- Update Target Nets
      if(iter > 0 && (iter % UpdateTargets) == 0)
        {
         TargetActor.WeightsUpdate(GetPointer(Actor), Tau);
         TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
         TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
        }

在循环迭代结束时,我们会通知用户有关训练的信息,并显示两个评论者的当前错误。我们不显示扮演者训练品质的指标,因为没有计算该模型的误差。

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

完成循环迭代后,我们清除注释区,并启动 EA 关闭过程。

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

我们不会详述测试训练模型的 “TD3\Test.mq5” 算法细节。它的代码几乎完全重复了样本数据库收集 EA。我只排除了在扮演者的工作结果中添加噪声,因为我们想要评估模型训练的品质,其·不包括对环境的研究。同时,我留下了收集轨迹,以及将其记录在样本数据库中的模块。这令我们能够保存成功与不成功的验算。后续允许我们能够在训练过程的下一次开始期间进行纠错。

在附件中能找到所有用到程序的完整代码。


3. 测试

我们继续训练和测试获得的结果。如常,这些模型是基于 2023 年 1 月至 5 月的 EURUSD H1 历史数据上训练的。指标参数和所有超参数均设置为默认值。

训练时间冗长且反复。在第一阶段,创建了一个包含 200 条轨迹的数据库。第一个训练过程运行了 1,000,000 次迭代。评论者参数迭代更新十次后,扮演者的政策更新一次。评论者更新每 1,000 次迭代后,都会对目标模型进行软更新。


之后,另有 50 条轨迹添加到样本数据库中,并启动了模型训练的第二阶段。同时,更新扮演者和目标模型之前的迭代次数分别减至 3 次和 100 次。

经过大约 5 次训练循环(每次循环添加 50 条轨迹),获得了一个能够在训练集上产生利润的模型。经过 5 个月的训练样本,该模型能够获得近 10% 的收入。这不是最好的结果。进行了 58 笔业务。盈利的份额接近微薄的 40%。盈利系数 - 1.05,恢复系数 - 1.50。由于可盈利持仓规模,达成了盈利。一笔交易的平均利润是平均亏损的 1.6 倍。对于一笔交易操作最大利润是最大损失的 3.5 倍。

值得注意的是,余额的回撤接近 32%,而净值的回撤几乎未超过 6%。正如您在图表上看到的,我们观察到余额的回撤,净值曲线持平甚至增长。这种效应可以通过同时打开多向持仓来解释。当亏损持仓的止损被触发时,我们观察到余额的回撤。同时,相反方向的持仓累积盈利,这反映在净值曲线上。

基于训练集测试

基于训练集测试

如我们所记,在上一篇文章中,该模型在训练集上显示出更耀眼的结果,但在新数据上却无法重现它。现在情况正好相反。我们没有在训练集上获得超额利润,但模型在训练集之外展现出稳定的结果。当基于训练集中未包含的后续数据上测试模型时,我们会看到前一个测试的“较小副本”。该模型在 1 个月内获得了 2.5% 的盈利。盈利系数 - 1.07,恢复系数 - 1.16。只有 27% 的盈利交易,但平均盈利交易几乎是平均亏损交易的 3 倍。就余额而言,回撤为 32%,而净值仅为 2%。

训练集之外的测试

训练集之外的测试


结束语

在本文中,我们熟悉了孪生延迟深度判定性策略梯度(TD3)算法。方法作者针对 DDPG 算法提出了几项重要的改进,可以提高方法的效率和模型训练的稳定性。

作为本文的一部分,我们以 MQL5 实现了这种方法,并基于历史数据上对其进行了测试。在训练过程中,获得了一个模型,该模型不仅能够从训练数据上产生盈利,而且还能够利用获得的经验基于新数据上产生盈利。值得注意的是,在新数据上,模型得到的成果与训练集结果相当。结果并不完全是我们想要的。有些事情仍然需要努力。但有一点是肯定的 — TD3 算法允许训练一个可靠地处理新数据的模型。

通常,我们可以采用该算法进行深入研究,以便构建真实交易的模型。


参考文献列表

  • 解决 Actor-Critic 方法中的函数逼近错误
  • 神经网络变得轻松(第二十七部分):深度 Q-学习(DQN)
  • 神经网络变得轻松(第二十九部分):优势扮演者-评价者算法
  • 神经网络变得轻松(第四十七部分):连续动作空间

  • 本文中用到的程序

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


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

    附加的文件 |
    MQL5.zip (345.13 KB)
    测试不同的移动平均类型以了解它们的洞察力 测试不同的移动平均类型以了解它们的洞察力
    我们都知道移动平均指标对很多交易者的重要性。还有其他移动平均线类型在交易中也很有用,我们将在本文中确定这些类型,并将它们中的每一种与最流行的简单移动平均线进行简单比较,看看哪一种可以显示出最好的结果。
    交易事务. 请求和响应结构、描述和记录 交易事务. 请求和响应结构、描述和记录
    本文探讨了处理交易请求结构,即创建请求、将其发送到服务器之前的初步验证、服务器对交易请求的响应以及交易交易的结构。我们将创建简单方便的函数,将交易订单发送到服务器,并根据所讨论的内容,创建EA来通知交易事务。
    软件开发和 MQL5 中的设计范式(第一部分):创建范式 软件开发和 MQL5 中的设计范式(第一部分):创建范式
    有一些方法可以用来解决许多重复性的问题。一旦明白如何运用这些方法,就可助您有效地创建软件,并贯彻 DRY(不要重复自己)的概念。在这种境况下,设计范式的主题就非常好用,因为它们为恰当描述过,且重复的问题提供了解决方案。
    MQL5 中的范畴论 (第 12 部分):秩序(Orders) MQL5 中的范畴论 (第 12 部分):秩序(Orders)
    本文是范畴论系列文章之以 MQL5 实现图论的部分,深入研讨秩序(Orders)。我们通过研究两种主要的秩序类型,实测秩序论的概念如何支持幺半群集合,从而为交易决策提供信息。