
神经网络变得轻松(第五十一部分):行为-指引的扮演者-评论者(BAC)
概述
最后两篇文章专门讨论软性扮演者-评论者算法。如您所知,该算法用于在连续动作空间中训练随机模型。该方法的主要特点是在奖励函数中引入了熵分量,这令我们能够调整环境探索和模型操作之间的平衡。同时,这种方式给所训练模型施加了一些限制。使用熵需要对采取动作的概率有一定的了解,这对于连续动作空间来说是相当困难的。
我们使用了分位数分布方式。此处,我们加上了对分位数分布超参数的调整。使用分位数分布的方式令我们稍微远离了连续动作空间。毕竟,每次我们选择一个动作时,我们都会从学习概率分布中选择一个分位数,并以其平均值作为动作。有了足够多的分位数和足够小的可能值范围,我们就接近了一个连续动作空间。但这导致模型越来越复杂,其训练和操作成本增加。此外,这对训练模型的架构施加了限制。
在本文中,我们将谈及另一替代方式,即 2021 年 4 月推出的行为指引扮演者-评论者(BAC)。
1. 算法构造特点
首先,我们谈谈研究环境的必要性。我想每个人都同意这个过程是必要的。但究竟是为了什么,在什么阶段?
我们从一个简单的例子开始。假设我们发现自己身处一间有三扇相同房门的屋内,我们需要走到街上。我们该怎么办?我们逐扇打开房门,直至找到我们需要的那扇。当我们再次进入同一间屋时,我们外出就不再需要打开所有房门,取而代之的是立即前往已知的出口。如果我们有不同的任务,那么可能有一些选项。我们可以再次打开除已知出口外的所有门,并寻找合适的。或者我们可以先记住早前我们在寻找出路时打开了哪些门,以及我们需要的那扇门是否在其中。如果我们记得正确的门,我们就会走向它。否则,我们会检查以前没有尝试过的门。
结论:我们需要在不熟悉的情况下研究环境,据此选择正确的动作。找到所需的路线后,对环境的额外探索只会成为阻碍。
不过,当任务在已知状态下发生变化时,我们也许需要额外研究环境。这可能包括寻找更优化的路线。在上面的例子中,如果我们需要穿过更多的房间,或者我们发现自己在建筑物的错误一侧,也许就会发生这种情况。
因此,我们需要一种算法,允许我们能够在未探索的状态下强化环境探索,并在先前探索的状态中将其最小化。
软性扮演者-评价者中使用的熵正则化可以满足此要求,但仅在满足一定数量条件的情况下。当动作概率越低时,动作的熵越高。实际上,我们在低概率行动后进入的状态可能知之甚少。熵正则化促使我们重复它,以便更好地研究后续状态。但是在研究了这个运动矢量之后会发生什么?如果我们发现了一条更优路径,那么在训练模型的过程中,则动作的概率会提升,熵会降低。这符合我们的需求。不过,其它动作的概率降低,它们的熵提升。这促使我们在其它方向上进行更多研究。只有明显的积极回报才能令我们专注于这条路径。
另一方面,如果新路线不满足我们的需求,那么我们在训练模型时会降低此类动作的可能性。同时,它的熵增长得更多,这促使我们再次这样做。只有重大的负面奖励(罚款)才能推动我们避免再次轻率地迈出一步。
因此,这就是为什么正确选择温度比率的权重对于确保模型研究和操作之间的期望平衡非常重要。
这也许看看似有点奇怪。我们从ε-贪婪策略开始,在这种策略中,勘探和开发之间的平衡依据概率常数调节。现在,我们将模型复杂化,并再次谈及选择比率的重要性。这纯粹是一种似曾相识的感觉。
为了寻找其它的解决方案,我们将注意力转向《行为指引扮演者-评论者:针对深度强化学习通过学习政策行为表征改进探索》一文中介绍的行为指引扮演者-评论者(BAC) 算法。该方法的作者建议用某个值代替奖励函数中的熵分量,以便通过状态-动作配对模型评估学习水平。
状态-行动配对的选择非常明显 — 这是我们在特定时刻所知道的。发现我们自己处于某种状态,我们选择一种动作。在某种程度上,我们取决于它向下一个状态过渡,以及得到过渡的回报。在相同动作背后,也许会过渡到预期的新状态,或也许存在不同的状态(具有一定程度的概率)。例如,要打开一扇门,我们先需要接近它。此处可以预料到,在每一步之后,我们都会离门更近。然后我们打开它,转动门把手。但它可能会被锁住(这是我们无法控制的因素)。门外有奖励或罚款等着我们。但我们到达那里之前,我们不会知道是哪个。因此,只有考虑到所有可能的动作,我们才能谈论对一个独立状态的完整研究。
该方法的作者建议使用自动编码器作为研究“状态-动作”配对的衡量标准。我们已在不同的算法中多次遇到过自动编码器的使用。但这总是与数据压缩、或某些相互依赖模型的构造有关。经验表明,建立金融市场模型是一项相当艰巨的任务,因为有大量的影响因素并非始终显而易见。在这种情况下,要用到自动编码器的另一个属性。
纯形式的自动编码器可以很好地复制源数据。但自动编码器是一种神经网络。在最开始,我就说过神经网络只在已研究过的数据上工作良好。否则,它们的结果也许不可预测。这就是为什么我们始终关注训练样本的代表性,和模型超参数在训练和操作过程中的不变性。
方法作者利用了神经网络这个属性的优点。在依据一组特定的状态和相应动作进行训练之后,我们在自动编码器的输出端得到了一个不错的副本。但是,一旦我们在模型输入端提交未知的“状态-动作”配对,数据复制错误就会大大增加。我们将用数据复制错误来衡量单独的“状态-动作”配对的知识。
与熵正则化相比,这种方式具有许多优点。首先,该方式适用于随机模型和确定性模型两者。使用自动编码器不会影响扮演者架构的选择。
其次,状态-动作配对的激励奖励随着训练的增加而减少,与获得的奖励和未来执行动作的可能性无关。由于自动编码器已训练过,它趋于 “0”,这会导致模型全部操作。
不过,当出现新状态时(考虑到神经网络的泛化能力,它与之前研究的并不相似),环境探索模式会立即被激活。
一个状态-动作配对的刺激奖励,与同一状态下另一个动作的训练程度、表现概率、或其它因素绝对无依赖。
当然,我们正在与一个连续行动空间打交道,并且该模型能够概括所获得的经验。在研究一个“状态-行动”配对时,它可以应用以前在类似状态和类似行动上获得的经验。但同时,数据传输误差也会不断变化,并取决于状态和动作的接近程度(相似性)。
从数学上讲,政策训练可以表示如下:
其中 γ 是折扣因子,
α — 温度比率,
ψ(St+1,At=1) — 后续状态行为的函数(自动编码器的复制误差)。
我们再次看到温度比率用来调节模型探索和开发之间的平衡。这又导致了上述超参数优调和模型训练的困难。方法作者建议略微改变策略训练函数。
α 温度比率本身应采用以下公式判定
其中 σ 是西格玛函数,
ω 等于 10,
Q — 用于评估动作质量的神经网络。
这里使用的 Q-神经网络类似于评论者,它评估特定状态下的动作质量,同时考虑到当前政策。
从所提出的方程中可以看出,温度比(1−α)的范围为 0 至 0.5。它随着动作质量的评估提高而增加。显然,此时自动编码器复制数据的误差趋于 “0”。由于概率很高,该模型目前处于某种局部最小值,研究环境可以帮助从这种状态摆脱。
当数据复制的准确性较低时,给定状态下的动作评估质量也会降低。这导致西格玛函数内部表达式的分母增加。因此,西格玛参数的整体值降低,其结果趋于 0.5。
请记住,我们总是从较大的误差中减去较小的误差。因此,西格玛参数始终大于 “0”。它几乎不会等于 “0”,因为我们不能除以 “0”。
所提出的算法仍然是扮演者-评论者算法大家族的成员,并使用该算法家族的一般方式。与软性扮演者-评论者一样,该算法用于在连续动作空间中学习扮演者政策。我们将用 2 个评论者模型来评估动作的品质,以及从奖励到动作的误差梯度分布。我们还要用目标模型的软更新,经验缓冲区,和其它常用方式来训练扮演者-评论者模型。
2. 利用 MQL5 实现
在研究了所建议方式的理论层面之后,我们来利用 MQL5 实现它。我们首先从模型的架构开始。为了令方法具有可比性,我没有对上一篇文章中的模型架构进行太多更改。不过,我稍微简化了扮演者架构,并删除了我们创建的最后一个复杂神经层,从软性扮演者-评论者方法中实现随机扮演者算法。不过,我保留了随机扮演者政策的使用。但这一次,它是通过使用变分自编码器隐性层来实现的。如您所记得,该神经层的输入随数据张量一起提供,其大小正好是其结果缓冲区的两倍。指定的源数据张量包含结果的每个元素的分布均值和方差。以这种方式,我们降低了计算复杂性,但将随机扮演者模型留在连续动作空间当中。
bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic, CArrayObj *autoencoder) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; } if(!autoencoder) { autoencoder = new CArrayObj(); if(!autoencoder) return false; } //--- Actor actor.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(!actor.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(!actor.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 = 8; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = 8; descr.step = 8; descr.window_out = 8; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.optimization = ADAM; descr.activation = LReLU; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = 128; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = LatentCount; descr.window = prev_count; descr.step = AccountDescr; descr.optimization = ADAM; descr.activation = SIGMOID; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 7 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 8 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 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 2*NActions; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 10 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
评论者模型已经转换,没有变化,我们不再详述它。
我们来谈谈自动编码器模型。如前所述,自动编码器用作前面讨论的“状态-动作”配对的内存元素。我们可以称其为这些配对的访问次数计数器。但我们要记住,它是我们评估的评论者“状态-动作”配对。更准确地说,是估算评论者在特定状态下的独立动作。这看似是在玩文字和概念游戏,但这只是一组初始数据。
以前,为了节省训练模型的资源和时间,我们从评论者架构中剔除了源数据预处理模块。取而代之,我们使用来自扮演者模型隐藏状态的已处理数据。在评论者的输入端,我们将隐藏状态和扮演者的结果缓冲区连接起来,从而将状态和动作组合成一个张量。
现在,我们将走得更远。我们将把其中一个评论者的隐藏状态作为自动编码器的输入。类似于评论者,我们可以使用两个原始数据张量的串联层。但我们必须解决拿 1 个自编码器结果缓冲区与 2 个源数据缓冲区进行比较的问题。采用来自评论者隐性表示的一个源数据缓冲区,令我们可用更简单的自动编码器模型,并将源数据与其工作结果 “1:1” 进行比较。因此,我们将仅在自编码器架构中使用完全连接层。
//--- Autoencoder autoencoder.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = None; descr.optimization = ADAM; if(!autoencoder.Add(descr)) { delete descr; return false; } //--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = prev_count / 2; descr.optimization = ADAM; descr.activation = LReLU; if(!autoencoder.Add(descr)) { delete descr; return false; } //--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = prev_count / 2; descr.activation = LReLU; descr.optimization = ADAM; if(!autoencoder.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = 20; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!autoencoder.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; if(!(descr.Copy(autoencoder.At(2)))) { delete descr; return false; } if(!autoencoder.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; if(!(descr.Copy(autoencoder.At(1)))) { delete descr; return false; } if(!autoencoder.Add(descr)) { delete descr; return false; } //--- layer 6 if(!(descr = new CLayerDescription())) return false; if(!(descr.Copy(autoencoder.At(0)))) { delete descr; return false; } if(!autoencoder.Add(descr)) { delete descr; return false; } //--- return true; }
请注意,从自动编码器的第四层开始,我们并未彻底创建一个新神经层的描述。取而代之,我们简单地按相反的顺序复制了之前创建的描述。这令我们能够在解码器中创建编码器的镜像副本。编码器架构中的任何变化(添加新层除外)都将立即反映在解码器的相应层中。在各种情况下,可以使用一种相当方便的方式来同步神经层架构的描述。
在创建模型架构的描述之后,我们转入安排收集训练模型所用的样本数据库的过程。如前,这个过程被组织在 “..\BAC\Research.mq5“ EA。BAC 方法不会对主数据收集算法进行任何修改。因此,该 EA 中的变化最小。
我们通过往模型架构里添加自动编码器的描述来修改描述模型架构的函数。由此,在 Research.mq5 EA 的 OnInit 方法中调用此函数时,我们需要传递指向模型架构描述动态数组的三个指针。但由于在这个 EA 中,我们只用扮演者,而不需要对其它模型的描述,因此我们不会创建额外的对象数组,而是指向评论者架构的描述数组两次。按如此调用,将首先在函数中创建对评论者架构的描述,然后将其删除,并将自动编码器架构写入数组。在这种情况下,这对我们来说并不重要,因为既没有用到评论者模型,也未用到自动编码器模型。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- ........ ........ //--- load models float temp; if(!Actor.Load(FileName + "Act.nnw", 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; //--- } //--- ........ ........ //--- return(INIT_SUCCEEDED); }
此外,我们从奖励函数中排除了熵分量。智能交易系统代码的其余部分保持不变。您可以在附件中找到 EA,及其所有函数的完整代码。
“..\BAC\Study.mq5“ 模型训练 EA 代码需要更多的工作。在此,我们使用并初始化所有模型。因此,在调用创建模型架构描述的方法之前,我们先为自动编码器创建一个额外的动态数组。
int OnInit() { //--- ResetLastError(); if(!LoadTotalBase()) { PrintFormat("Error of load study data: %d", GetLastError()); return INIT_FAILED; } //--- load models float temp; 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) || !Autoencoder.Load(FileName + "AEnc.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)) { CArrayObj *actor = new CArrayObj(); CArrayObj *critic = new CArrayObj(); CArrayObj *autoencoder = new CArrayObj(); if(!CreateDescriptions(actor, critic, autoencoder)) { delete actor; delete critic; delete autoencoder; return INIT_FAILED; }
在获得模型架构后,我们初始化所有模型,并控制操作。
if(!Actor.Create(actor) || !Critic1.Create(critic) || !Critic2.Create(critic) || !Autoencoder.Create(autoencoder)) { delete actor; delete critic; delete autoencoder; return INIT_FAILED; }
不要忘记评论者的目标模型。
if(!TargetCritic1.Create(critic) || !TargetCritic2.Create(critic)) { delete actor; delete critic; delete autoencoder; return INIT_FAILED; } delete actor; delete critic; delete autoencoder; //--- TargetCritic1.WeightsUpdate(GetPointer(Critic1), 1.0f); TargetCritic2.WeightsUpdate(GetPointer(Critic2), 1.0f); }
之后,确保将所有模型传输到一个 OpenCL 关联环境中。自动编码器也不例外。
OpenCL = Actor.GetOpenCL(); Critic1.SetOpenCL(OpenCL); Critic2.SetOpenCL(OpenCL); TargetCritic1.SetOpenCL(OpenCL); TargetCritic2.SetOpenCL(OpenCL); Autoencoder.SetOpenCL(OpenCL);
接着到了模型对应检查模块。
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; } //--- 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; }
此处,我们添加了自动编码器和评论者架构之间的一致性检查。
Critic1.GetLayerOutput(1, Result); latent_state = Result.Total(); Autoencoder.GetLayerOutput(0, Result); if(Result.Total() != latent_state) { PrintFormat("Input size of Autoencoder doesn't match latent state Critic (%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 反初始化方法中。于此,我们注意所有模型的保护,以及下载过程期间指定的文件名后缀和相应模型。
void OnDeinit(const int reason) { //--- TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau); TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau); Actor.Save(FileName + "Act.nnw", 0, 0, 0, TimeCurrent(), true); TargetCritic1.Save(FileName + "Crt1.nnw", Critic1.getRecentAverageError(), 0, 0, TimeCurrent(), true); TargetCritic1.Save(FileName + "Crt2.nnw", Critic2.getRecentAverageError(), 0, 0, TimeCurrent(), true); Autoencoder.Save(FileName + "AEnc.nnw", Autoencoder.getRecentAverageError(), 0, 0, TimeCurrent(), true); delete Result; }
至此,准备工作已完成,我们可以继续在 EA 的 Train 方法中实现直接模型训练算法。
该方法的开始是相当标准的。如前,我们在 EA 外部参数中指定训练循环的迭代次数。
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));
在循环的主体中,我们依据样本数据库和特定的轨迹步长, 随机判定轨迹。然后,我们将有关后续状态的信息加载到数据缓冲区当中。
//--- 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); 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();
接下来,我们针对扮演者和 2 个目标评论者模型进行前向验算,并参考更新的扮演者策略检测未来状态值。
if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); break; } //--- if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) || !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } TargetCritic1.getResults(Result); float reward = Result[0]; TargetCritic2.getResults(Result); reward = Buffer[tr].Revards[i] + DiscFactor * (MathMin(reward, Result[0]) - Buffer[tr].Revards[i + 1]);
第一眼,一切都与使用软性扮演者-评论者算法时相同。我们还用到了从两位评论者那里得到的最少条件评估。但请注意,我们已经排除了熵分量。鉴于运用的是 BAC 方法,这就十分合乎逻辑。不过,我们还没有添加行为分量。这是自原始算法的刻意背离。事实是,我们使用一个样本数据库,这是依据各种政策的扮演者验算获得的结果。现在引入行为分量会扭曲评论者的评估,但不会直接刺激扮演者。之后,当基于评论者的评估进行训练时,我们将收到扮演者的间接刺激。但如同硬币都有另一面。在训练评论者时使用“状态-动作”配对的次数,与在训练扮演者时使用的相同或相似的“状态-动作”配对之间的对应关系是什么?偏向一方或另一方都是可能的。因此,我决定在训练扮演者时使用自动编码器来评估状态和动作。在我看来,考虑到其行为政策的更新,这可能会更准确地评估访问状态的频率和扮演者所用的动作。
下一阶段是训练评论者。将所选状态的数据从样本数据库加载到数据缓冲区之中。
//--- Q-function study State.AssignArray(Buffer[tr].States[i].state); PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0]; PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1]; Account.Update(0, (Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance); Account.Update(1, Buffer[tr].States[i].account[1] / PrevBalance); Account.Update(2, (Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity); Account.Update(3, Buffer[tr].States[i].account[2]); Account.Update(4, Buffer[tr].States[i].account[3]); Account.Update(5, Buffer[tr].States[i].account[4] / PrevBalance); Account.Update(6, Buffer[tr].States[i].account[5] / PrevBalance); Account.Update(7, Buffer[tr].States[i].account[6] / PrevBalance); 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)); Account.BufferWrite();
现在是时候进行扮演者的直接验算了。我要提醒您,在这种情况下,我们使用它来初步处理有关环境状态的初始数据。
if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
然后,我们需要对评论者进行向前和向后验算,以便调整其参数。当使用软性扮演者-评论者方法训练模型时,我们所用的是模型交替。在这种情况下,我们将使用相同的样本同时训练两个评论者。我们针对来自样本数据库的动作调用评论者的直接验算方法。
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; }
但在我们进行反向验算之前,我们将准备数据来计算奖励函数的行为分量的温度比。首先,我们将第一个评论者估算结果与上面计算的未来状态估算值进行比较,并更新最小、最大和平均误差值。
注意,在第一次迭代中,我们只需将当前误差转移到所有三个变量。然后,我们根据比较结果更新最大值和最小值。然后我们计算指数平均值。
Critic1.getResults(Result); float error = reward - Result[0]; if(iter == 0) { MaxCriticError = error; MinCriticError = error; AvgCriticError = error; } else { MaxCriticError = MathMax(error, MaxCriticError); MinCriticError = MathMin(error, MinCriticError); AvgCriticError = 0.99f * AvgCriticError + 0.01f * error; }
对于第二个评论者,我们已经有了变量的初始值。无论模型训练迭代如何,我们都会更新它们的值。
Critic2.getResults(Result); error = reward - Result[0]; MaxCriticError = MathMax(error, MaxCriticError); MinCriticError = MathMin(error, MinCriticError); AvgCriticError = 0.99f * AvgCriticError + 0.01f * error;
在更新评论者参数的最后,我们所要做的就是对两个模型进行向后验算,指示来自目标模型的未来状态的最小估算值为参考值。
Result.Update(0, reward); if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) || !Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
这样就完成了评论者参数的更新,我们转入训练扮演者。训练扮演者的 BAC 方法的作者建议使用评论者,并对所选动作进行最低限度的估算。为了避免运作贯穿两个评论家的直接验算,并比较它们的结果,我们将做一些不同的事情。我们将采用一个在预测状态和行动估算时平均误差最小的评论者。该值将在评论者模型的每次返回验算中重新估值。它的提取将需要最低的成本,与运作一个贯穿模型的直接验算相比,成本可以忽略不计。
为了避免为一个和第二个评论者模型创建重复操作的复杂分支结构,我们将简单地把指向所需模型的指针保存在局部变量之中。然后我们将操控这个局部变量。
//--- Policy study CNet *critic = NULL; if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError()) critic = GetPointer(Critic1); else critic = GetPointer(Critic2);
不像 TD3,扮演者-评论者方法在每次迭代时都会更新扮演者政策。我们将用我们所选的同一组初始数据来训练评论者。我要提醒您,在训练评论者时,我们已基于当前初始数据集执行了扮演者直接验算。因此,考虑到其政策的更新,对选定的评论者执行直接验算,从而评估扮演者在当前状态下的动作就足够了。
if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) || !Autoencoder.feedForward(critic, 1, NULL, -1)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
在评论者向前验算之后,我们执行自动编码器的向前验算。此处一个细微差别。事实是,在早前,将两个模型链接到单一整体中,我们添加了将后续模型的源数据层替换为指向提供这些源数据的模型隐性层的指针。当我们用一个扮演者作为两个评论者的捐赠者时,这很有效。在第一次迭代期间,评论者会删除不必要的源数据层,并存储指向扮演者隐性状态层的指针。在自动编码器的情况下,我们的状况正好相反。我们用了 2 个评论者模型作为一个自动编码器的捐赠者。在第一次迭代中,自动编码器会删除原始数据中不必要的层,并存储指向所用评论者隐性层的指针。但在更改评论者时,将删除一个评论者层,且保存指向另一个评论者层的指针。这个过程对我们来说是极端不可取的。甚至,这对我们的整体训练是有害的。因此,在第一次删除源数据层后,我们需要在更新神经层数组时禁用对象删除标志。
bool CNet::feedForward(CNet *inputNet, int inputLayer = -1, CNet *secondNet = NULL, int secondLayer = -1) { ........ ........ //--- if(layer.At(0) != neuron) if(!layer.Update(0, neuron)) { if(del_second) delete second; return false; } else layer.FreeMode(false); //--- ........ ........ //--- return true; }
这与训练过程和 BAC 算法略有背离,但它对于我们实现流程设计至关重要。
我们回到训练模型的 Train 方法的算法。在自动编码器直接验算后,我们必须评估数据复制的误差。为此,我们加载自动编码器的结果,以及来自评论者隐性状态的初始数据。为了提高代码的效率,我们用到向量变量,把两个数据缓冲区都加载到其中。
Autoencoder.getResults(AutoencoderResult);
critic.GetLayerOutput(1, Result);
Result.GetData(CriticResult);
此处,我们将立即上传评论者对动作的估值结果。
critic.getResults(Result);
在训练扮演者政策时,我们需要两个信息流来判定目标值。因此,我们将整个计算合并到一个模块之中。
以前,我们准备了计算温度比的数据。现在我们将首先计算西格玛参数。然后我们判定函数的值,并从 “1” 中减去它。
float alpha = (MaxCriticError == MinCriticError ? 0 : 10.0f * (AvgCriticError - MinCriticError) / (MaxCriticError - MinCriticError)); alpha = 1.0f / (1.0f + MathExp(-alpha)); alpha = 1 - alpha; reward = Result[0]; reward = (reward > 0 ? reward + PoliticAdjust : PoliticAdjust); reward += AutoencoderResult.Loss(CriticResult, LOSS_MSE) * alpha;
接下来,类似于 TD3 中的方式,我们将扮演者参数朝着提升操作盈利能力偏移。因此,我们在当前动作的估值中加上了一个小常数,刺激梯度朝向提升盈利能力偏移。
为了完成目标值的形成,我们添加了行为分量,同时考虑到自动编码器的损失函数。由于向量运算,无关于数据缓冲区的大小,损失函数的大小都在单个字符串中定义。
现在,在生成目标值后,我们可以执行评论者和扮演者的逆向验算,以便在动作之前分配误差梯度,并随后调整扮演者的参数。
如前,为了防止评论者和扮演者的参数交互调整,在执行逆向验算之前,我们关闭了评论者的训练模式,并在执行操作后重新打开。
Result.Update(0, reward); critic.TrainMode(false); if(!critic.backProp(Result, GetPointer(Actor)) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); critic.TrainMode(true); break; } critic.TrainMode(true);
注意,我们正在针对扮演者执行两种类型的反向验算。我们首先在数据预处理单元中分配误差梯度,这令我们能够基于评论者的要求优调卷积层的滤波器。然后我们进行逆向验算,从而调整选择特定动作的决策模块。按此顺序执行操作非常重要。调整决策模块参数的前向验算执行完毕后,数据初步处理模块的误差梯度也将被重写。在这种情况下,调用额外的反向验算不会产生积极影响。甚至,它也许还会有负面影响。
在此阶段,我们已更新评论者和扮演者参数。我们所要做的就是更新自动编码器参数。此处的一切都十分简单。我们将评论者的隐性状态数据作为参考值传递,并执行贯穿模型的向后验算。
//--- Autoencoder study Result.AssignArray(CriticResult); if(!Autoencoder.backProp(Result, critic, 1)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
在训练周期迭代结束时,我们会更新两个评论者的目标模型,并通知用户训练的进度。
//--- Update Target Nets TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau); TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau); //--- if(GetTickCount() - ticks > 500) { string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic1", iter * 100.0 / (double)(Iterations), Critic1.getRecentAverageError()); str += StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic2", iter * 100.0 / (double)(Iterations), Critic2.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } }
训练方法的结局是相当符合惯例的:
- 清除注释字段,
- 显示训练结果,
- 初始化 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(); //--- }
训练 EA 的完整代码,及用到的所有程序都可以在附件中找到。在那里,您还可以找到测试 EA 的代码,该代码几乎保持不变。在保持遍历轨迹的同时,EA 代码中仅删除了熵分量。
如此这般,我们完成了构建 EA 的工作,并转入测试已完工的代码,并训练模型。
在我看来,这项工作导致了相当多的主存储器和 OpenCL 关联环境之间数据交换迭代。这在判定奖励函数的行为分量的模块中很引人注目。这里有一些事情需要思考。我们看看这对模型训练的整体性能有何影响。
3. 测试
我们在实现行为指引扮演者-评论者算法方面做了相当令人印象深刻的工作,现在是时候看看结果了。如前,这些模型依据 2023 年前 5 个月的 EURUSD H1 进行了训练。所有指标参数采用默认值。初始本金为 10,000 美元。
在第一阶段,创建了一个包含 300 个随机验算的训练集,该训练集提供了超过 75 万组单独的“状态→行动→新状态→奖励”数据。当心,我在此提到了“随机验算”。现阶段我们还没有预训练的模型。在策略测试器中每次验算时,“..\BAC\Research.mq5“ EA 生成一个新模型,并用随机参数填充它。相应地,此类模型的操作将如同其参数一样随机。在这个阶段,我没有限制验算的最低盈利能力水平,并将样本保存到数据库。
收集样本后,我们对模型进行初始训练。为此,我们运行 “..\BAC\Study.mq5“ EA,执行 500,000 次模型训练迭代。
我必须说,在针对模型进行初始训练之后,可以非常强烈地感受到扮演者政策的随机性。这反映在独立验算结果的宽幅分散上。
在第二阶段,我们在策略测试器的优化模式下重新启动训练数据收集 EA,按完整搜索参数进行 300 次迭代。这一次,我们将最低回报水平限制为正结果(0 或略高)。结果就是,只添加了相对较少的结果(15-20 次)。
请注意,在初始训练后运行数据收集 EA 时,所有验算都使用相同的预训练模型。结果的整体分散是由于扮演者政策的随机性。
接下来,我们将重新运行模型训练过程,同样是 500,000 次迭代。
收集样本和训练模型重复若干次,直到获得所需的结果,或收集样本和训练模型的下一次迭代没有产生任何进展,达到局部最小值。
注意,在下次运行样本数据库集合 EA 期间,不会删除先前收集的验算。新数据的将添加到文件末尾。MaxReplayBuffer 常量已添加到 “..\BAC\Trajectory.mqh“ 文件,是为了防止积累过大的样本数据库。该常量指定最大验算次数(而不是文件大小)。当缓冲区填满时,旧的验算将被删除。我建议您根据设备的技术能力利用该常量调整样本数据库的大小。
#define MaxReplayBuffer 500
经过大约 7 次更新样本数据库和训练模型的迭代,我能够得到一个能够在训练时间间隔内产生盈利的模型。所呈现的图表清楚地显示了资本增长的趋势。不过,也有一些无盈利的区域。
在覆盖训练区间的 5 个月内,EA 赚取了 16% 的利润,最大回撤为净值的 8.41%。在余额账目上,回撤略低,为 6.68%。总共进行了 99 笔交易,其中 51.5% 以盈利平仓。可盈利交易数量几乎等于无盈利交易数量。但平均胜出交易比之平均亏损交易大于 50%。盈利系数为 1.53,恢复因子指标几乎处于同一水平。
不过,我们训练模型以备将来使用,而不仅只是在策略测试器当中。因此,在训练集之外的数据上测试模型对我们来说更为重要。我们依据 2023 年 6 月的历史数据测试了相同的模型。所有其它测试参数保持不变。
在新数据上测试模型的结果与训练集上的结果相当。在 1 个月内,EA 获得了略高于 3% 的利润,这与训练样本 16 个月的 5% 相当。进行了 11 笔交易,低于训练样本上的相应指标。不幸的是,盈利交易的占比也低于训练样本,仅为 36.4%。然而,平均盈利交易几乎是平均亏损交易的 6 倍。得益于此,盈利系数增加到 3.12。
结束语
在本文中,我们研究了另一种训练行为指引扮演者-评论者模型的算法。与软性扮演者-评论者方法一样,它属于扮演者-评论者算法的大家族,是使用软性扮演者-评论者方法的替代。所研究算法的优点包括能够在连续动作空间中训练随机和确定性模型。这种方法的运用在训练模型的构造中没有任何限制。
在本文的实践部分,所提出的算法是利用 MQL5 实现的。测试结果确认了我们的实现效率。
我再说一遍,所有提出的程序都只是为展示使用该技术的可能性。它们还没有准备好在真正的金融市场中使用。在真实市场上推出之前,EA 需要经过改进和额外测试。
链接
本文中用到的程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
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/13024


