
神经网络变得轻松(第五十二部分):研究乐观情绪和分布校正
概述
提高 Q-函数学习稳定性的基本要素之一是利用经验回放缓冲区。增加缓冲区可以收集更多样化的环境交互样本。这令我们的模型能够更好地研究和重现环境的 Q-函数。该技术广泛用于各种强化学习算法,包括扮演者-评论者系列算法。
但硬币也有另一面。在学习过程中,扮演者的动作与存储在经验回放缓冲区中的样本差异增加。更新模型参数的迭代次数越多,这种差异就越大。这会导致训练扮演者政策的效率下降。2021 年 10 月,文章《依据乐观情绪探索政策外强化学习及分布校正》中提出了一种可能的解决方案。该方法的作者建议将分布校正估算(DICE)方法适配到软性扮演者-评论者算法。
同时,方法作者还注意到了另一个细微差别。在训练政策时,软性扮演者-评论者方法使用了最小动作评估。这种方式的实际使用表明了一种悲观的倾向,即对环境的研究不足,以及动作的定向同质化。为了将这种影响最小化,本文的作者提议额外训练一个扮演者乐观情绪研究模型。这反过来又进一步增加了扮演者乐观情绪模型与环境之间的交互样本,以及已训练目标模型的动作分布之间的差距。
然而,结合使用分布校正估算和扮演者乐观情绪模型的研究,可以提升目标模型的训练结果。
1. 乐观情绪研究
有关乐观情绪的环境研究的第一个思路是在《与乐观情绪扮演者-评评论者一起进行更好的探索》一文(2019 年 10 月)中讲述的。其作者注意到,扮演者的贪婪式更新与评论者的悲观评估相结合,导致规避了代理者不知道的动作。这种现象被称为“悲观的探索不足”。此外,大多数算法都没有被告知研究方向。随机抽取的动作同样可能位于当前平均值的反侧,而我们通常需要在确定区域的动作,远超其它区域。为了校正这些现象,提出了乐观情绪扮演者-评论者(OAC)算法,其近似状态-动作值函数的置信度下限和上限。这令乐观情绪理论可用于按上限进行不确定性定向研究。同时,下限有助于避免高估动作。
方法作者提取并发展了乐观情绪扮演者-评论者的思路。与软性扮演者-评价者一样,我们将训练 2 个评论者模型。但同时,我们还将训练 2 个扮演者模型:πе 和目标 πт 研究。
πе 政策是为了学习 QUB Q-函数值的最大近似上限。同时, πт 是训练期间 QLB Q-函数的最大近似下限。OAC 表明,与软性扮演者-评论者相比,涉及 πе 的研究可以更有效地使用抽样。
为了获得 QUB Q-函数的近似上限,首先计算两个评论者的评分均值和方差:
接下来,我们定义 QUB 的方程:
其中 βUB ∈ R,并管制乐观情绪水平。
注意,QLB Q-函数的先前近似下限可以表示为
在悲观水平 βLB = 1 QLB 等于评论者的最低评分值。
乐观情绪扮演者-评论者在 πе 和 πт 之间应用了一个最大 KL 离散度约束,这令我们能够获得 πе的接近解,及稳定的训练。同时,这限制了πе 执行更翔实动作的潜力,故可校正评论者的错误评分。这种限制不允许 πе 产生差距太大的动作,这是比之基于评论者的最低评分,按 πт 政策进行保守训练产生的动作。
在 SAC+DICE 算法中,增加了分布校正,剔除了使用 KL 约束,从而解锁了乐观情绪政策的所有探索可能性。在这种情况下,在训练政策时显式校正有偏差的梯度估值,可维持训练的稳定性。
而正在训练的扮演者行为政策 πт 是防止高估 Q-函数,使用 QLB 的近似下限作为评论者,如同软性扮演者-评论者方法。不过,已加上用 dπт(s,a)/dD(s,a) 比率对抽样分布进行调整。我们得到以下训练目标:
其中 dπт(s,a) 表示当前政策的状态-动作分布,而 dD(s,a) 定义来自经验回放缓冲区的状态-动作分布。 这种训练目标的梯度提供了政策梯度的无背离估值,这与以前的扮演者-评论者学习算法不同,后者在训练目标政策时使用了背离的估值。
πе 研究政策应从乐观情绪相对于 Q-函数估值的偏差入手,以便获得有效校正虚假估值的经验。因此,该方法的作者建议使用类似于乐观情绪扮演者-评论者(QUB)的近似上限作为目标函数中的评论者。πе 政策和更好的 Q-函数估值最终目标是促进更准确地估算 πт 目标政策的梯度。因此,πе 损失函数的抽样分布应与 πт 行为政策一致。有因于此,方法作者提议使用与扮演者目标政策的损失函数相同的校正系数。
至于评论者,则保留了之前讨论过的软性扮演者-评论者方法。目标模型的 Q-函数的下限用于训练它们。然而,有众多研究都证明了采用相同的样本来训练扮演者和评论者的效率。因此,在评论者损失函数中还添加了分布校正因子。
正如您所见,从上述所有内容中分布校正系数引发了最多的问题。我们来详细研究一下。
2. 分布校正
分布校正估算(DICE)算法家族旨在解决政策外估算(OPE)校正问题。这些方法允许我们训练政策值的估算器,即基于 D 静态重试缓冲区的单步常规化预期奖励。DICE 接收一个无背离估算器,用来估算分布校正系数。
为了估算分布校正系数,方法作者采用了 DICE 优化结构,该结构可以表述为具有各种正则化的最小/最大线性分布程序。直接把 DICE 算法应用于政策外强化学习设置会带来重大的优化挑战。免估算训练假定固定目标政策,及具有足够状态-动作空间覆盖的静态回放缓冲区,而在 RL 中,目标政策和经验回放缓冲区在训练期间会发生变化。因此,SAC+DICE 方法的作者进行了一些修改,以便克服这些难点。我们现在不会扎进数学领域,也不会详述这些修改。您可以在原文中找到它们。我仅介绍由拟议的修改而获得的损失函数。
此处 ζ(s,a) 和 v(s,a) 是神经网络模型,而 λ 是可调的拉格朗日(Lagrange)系数。ζ(s,a) 近似于分布校正因子。v(s,a) 是一类评论者。为了稳定训练,我们将用到 v 目标模型,并对其参数进行软更新,类似于评论者。
为了优化所有参数,作者提议使用 Adam 方法。
以上所有内容都普及到单个 SAC+DICE 算法之中。与传统的政策外强化学习算法一样,我们遵循 πе 乐观情绪探索政策,按顺序执行与环境的交互,并将数据保存到经验回放缓冲区。在每个训练步骤中,所研究的算法首先更新模型,并依据上述损失函数使用 SGD 更新 DICE 参数 (v, ζ, λ)。
然后,我们据更新的模型计算 ζ 分布的校正比率。
然后,使用 ζ,我们训练 RL 来更新 πт, πе,Q1 和 Q2。
在每个训练步骤结束时,Q1, Q2 和 v 目标模型都会软性更新。
3. 以 MQL5 实现
在阅读理论部分时,您也许已经注意到已训练模型和参数的数量是如何急剧增长的。事实上,已训练模型数量已从 3 个增长到 6 个。它们的互动变得更加复杂。同时,我们期望收到一个扮演者行为政策模型。为了向用户隐藏所有例行工作,我们将稍微改变我们的方式,将整个训练包装在一个单独的类 CNet_SAC_DICE 之中。我们的新类将是 CNet 神经网络模型基类的继任者。在类主体中,我们将声明 5 个可训练模型,和 3 个目标模型。此处,我们还将声明一些内部变量。我们将在实现过程中看到它们的功能。
class CNet_SAC_DICE : protected CNet { protected: CNet cActorExploer; CNet cCritic1; CNet cCritic2; CNet cTargetCritic1; CNet cTargetCritic2; CNet cZeta; CNet cNu; CNet cTargetNu; float fLambda; float fLambda_m; float fLambda_v; int iLatentLayer; //--- float fLoss1; float fLoss2; float fZeta; //--- vector<float> GetLogProbability(CBufferFloat *Actions); public: //--- CNet_SAC_DICE(void); ~CNet_SAC_DICE(void) {} //--- bool Create(CArrayObj *actor, CArrayObj *critic, CArrayObj *zeta, CArrayObj *nu, int latent_layer = -1); //--- virtual bool Study(CArrayFloat *State, CArrayFloat *SecondInput, CBufferFloat *Actions, vector<float> &ActionsLogProbab, CBufferFloat *NextState, CBufferFloat *NextSecondInput, float reward, float discount, float tau); virtual void GetLoss(float &loss1, float &loss2) { loss1 = fLoss1; loss2 = fLoss2; } //--- virtual bool Save(string file_name, bool common = true); bool Load(string file_name, bool common = true); };
请注意,我们最初提到了 6 个可训练模型,但仅声明了 5 个。在公布的模型中,没有扮演者的目标政策。然而,整个训练的目标恰恰是为了获得它。如前所述,我们的新类是基准神经网络类的继任者。这意味着它本身就是一个学习模型。因此,将使用父类来训练基准的扮演者政策。
此外,正在创建的新 CNet_SAC_DICE 类仅用于模型训练。在操作期间,创建其它模型的对象是没有意义的,且是不必要的资源消耗。因此,我们计划在操作期间使用基准模型对象。出于上述原因,新类没有向前或向后验算方法。所有功能都将在 Study 方法中实现。
当然,有一些方法可以进行“保存”和“加载”文件操控。但首事首做。
在类构造函数中,我们用初始值初始化内部变量。所有内部对象都声明为静态的,且不参与初始化。相应地,我们不需要在析构函数中清理内存,这样就允许我们将析构函数留空。
CNet_SAC_DICE::CNet_SAC_DICE(void) : fLambda(1.0e-5f), fLambda_m(0), fLambda_v(0), fLoss1(0), fLoss2(0), fZeta(0) { }
模型的完全初始化是在 Create 方法中执行的。在方法参数中,我们将传递所有用到的模型架构描述的动态数组,和扮演者隐含层的 ID,以及环境分析状态的压缩表示。
在方法主体中,我们将首先创建扮演者模型。乐观情绪模型是在 cActorExploer 对象中创建的。目标模型是使用已继承的工具在我们的类主文中创建的。
bool CNet_SAC_DICE::Create(CArrayObj *actor, CArrayObj *critic, CArrayObj *zeta, CArrayObj *nu, int latent_layer) { ResetLastError(); //--- if(!cActorExploer.Create(actor) || !CNet::Create(actor)) { PrintFormat("Error of create Actor: %d", GetLastError()); return false; } //--- if(!opencl) { Print("Don't opened OpenCL context"); return false; }
我们立即检查创建的 OpenCL 关联环境指针。
接着,我们为两个评论者创建可训练模型。
if(!cCritic1.Create(critic) || !cCritic2.Create(critic)) { PrintFormat("Error of create Critic: %d", GetLastError()); return false; }
它们后随的模块是 DICE 对象和目标模型。
if(!cZeta.Create(zeta) || !cNu.Create(nu)) { PrintFormat("Error of create function nets: %d", GetLastError()); return false; } //--- if(!cTargetCritic1.Create(critic) || !cTargetCritic2.Create(critic) || !cTargetNu.Create(nu)) { PrintFormat("Error of create target models: %d", GetLastError()); return false; }
成功创建所有模型后,我们会将它们传递到单个 OpenCL 关联环境。
cActorExploer.SetOpenCL(opencl); cCritic1.SetOpenCL(opencl); cCritic2.SetOpenCL(opencl); cZeta.SetOpenCL(opencl); cNu.SetOpenCL(opencl); cTargetCritic1.SetOpenCL(opencl); cTargetCritic2.SetOpenCL(opencl); cTargetNu.SetOpenCL(opencl);
并将模型参数复制到它们的目标副本。此外,我们不应忘记在每一步控制操作的执行。
if(!cTargetCritic1.WeightsUpdate(GetPointer(cCritic1), 1.0) || !cTargetCritic2.WeightsUpdate(GetPointer(cCritic2), 1.0) || !cTargetNu.WeightsUpdate(GetPointer(cNu), 1.0)) { PrintFormat("Error of update target models: %d", GetLastError()); return false; }
成功创建所有必要的对象后,我们将数据传输到内部变量,并终止该方法。
fLambda = 1.0e-5f; fLambda_m = 0; fLambda_v = 0; fZeta = 0; iLatentLayer = latent_layer; //--- return true; }
类的内部对象初始化之后,我们转入 CNet_SAC_DICE::Study 模型训练方法的工作。在该类的参数中,我们收到训练模型的一个步骤所需的所有信息。以下是环境的当前和未来状态。在本例中,每个状态在两个数据缓冲区中定义:历史数据和平衡状态。在此,您还将看到动作缓冲区和奖励变量。还有折扣率和目标模型的软更新变量。对于首次,我们添加了原始政策概率的对数向量(用于收集样本)。
bool CNet_SAC_DICE::Study(CArrayFloat *State, CArrayFloat *SecondInput, CBufferFloat *Actions, vector<float> &ActionsLogProbab, CBufferFloat *NextState, CBufferFloat *NextSecondInput, float reward, float discount, float tau) { //--- if(!Actions || Actions.Total()!=ActionsLogProbab.Size()) return false;
在方法的主体中,我们首先安排一个小的控制模块,在其中检查指向动作缓冲区指针的相关性,以及其大小与概率对数向量大小的对应关系。我们不检查指向其它缓冲区的指针,因为它们的控制是在所调用方法中实现的。
成功通过控制模块之后,我们根据当前政策对目标模型进行后续状态估算。为此,我们首先实现保守的扮演者政策的直接验算。我们用它来预处理描述当前状态的原始数据,并自该状态预测动作向量。我们将得到的数据传递给两个评论者目标模型,和来自 DICE 模块的 v 模型。
if(!CNet::feedForward(NextState, 1, false, NextSecondInput)) return false; if(!cTargetCritic1.feedForward(GetPointer(this), iLatentLayer, GetPointer(this), layers.Total() - 1) || !cTargetCritic2.feedForward(GetPointer(this), iLatentLayer, GetPointer(this), layers.Total() - 1)) return false; //--- if(!cTargetNu.feedForward(GetPointer(this), iLatentLayer, GetPointer(this), layers.Total() - 1)) return false;
下一步是准备当前状态数据。与后续状态一样,我们使用当前保守的扮演者模型来预处理当前状态的描述。
if(!CNet::feedForward(State, 1, false, SecondInput)) return false; CBufferFloat *output = ((CNeuronBaseOCL*)((CLayer*)layers.At(layers.Total() - 1)).At(0)).getOutput(); output.AssignArray(Actions); output.BufferWrite();
此处,我们执行一个小技巧来替换前向验算的结果。我们将把动作张量从经验回放缓冲区保存到最后一个神经层的结果缓冲区当中,替代了当前扮演者政策获得的动作。此操作的目的是保持动作与环境奖励之间的对应关系。我们知道,其它动作很可能是在前向验算期间形成的。但我们的 CNeuronSoftActorCritic 神经层研究的是动作分布,以及其内部对象深层的概率。在反向验算期间,将判定对应于来自经验回放缓冲区中动作的分位数和概率。在这种情况下,无背离梯度将精确地传递到这些分位数,这将令扮演者模型能够更准确地训练,并且不会失真。
在准备好当前环境状态数据之后,我们能够执行贯穿 DICE模型的前向验算。记住要控制操作的执行。
if(!cNu.feedForward(GetPointer(this), iLatentLayer, GetPointer(this))) return false; if(!cZeta.feedForward(GetPointer(this), iLatentLayer, GetPointer(this))) return false;
根据 SAC+DICE 算法,我们首先更新了 DICE 模块的模型和参数。但在更新参数之前,我们需要针对 v, ζ, λ. 计算损失函数值。
注意,为了获得损失函数值,我们需要在当前保守政策中、以及样本库收集期间环境交互时的状态-动作的目标值概率比。此处应是说,描述环境状态的历史数据不依赖于扮演者政策。甚至,我们将当前状态视为制定决策、及构建后续行动轨迹的起点。因此,初始状态的概率会当作等于 1,因为我们处于其中。
在政策训练期间,只有动作的概率分布会根据所学习策略而变化。因此,我们的目标值将是两种政策中动作概率的比率。在运算过程中,我们将使用概率对数的差值,替代概率比。在这种情况下,替代所有操作的概率相乘,我们将使用所有动作的对数之和,并通过指数重建该值。
vector<float> nu, next_nu, zeta, ones; cNu.getResults(nu); cTargetNu.getResults(next_nu); cZeta.getResults(zeta); ones = vector<float>::Ones(zeta.Size()); vector<float> log_prob = GetLogProbability(output); float policy_ratio = MathExp((log_prob - ActionsLogProbab).Sum()); vector<float> bellman_residuals = next_nu * discount * policy_ratio - nu + policy_ratio * reward; vector<float> zeta_loss = zeta * (MathAbs(bellman_residuals) - fLambda) * (-1) + MathPow(zeta, 2.0f) / 2; vector<float> nu_loss = zeta * MathAbs(bellman_residuals) + MathPow(nu, 2.0f) / 2.0f; float lambda_los = fLambda * (ones - zeta).Sum();
判定损失函数值后,我们将定义误差梯度,并更新参数。首先,我们更新拉格朗日系数值。在参数调整过程中,我们用到 Adam 方法算法。
//--- update lambda float grad_lambda = (ones - zeta).Sum() * (-lambda_los); fLambda_m = b1 * fLambda_m + (1 - b1) * grad_lambda; fLambda_v = b2 * fLambda_v + (1 - b2) * MathPow(grad_lambda, 2); fLambda += lr * fLambda_m / (fLambda_v != 0.0f ? MathSqrt(fLambda_v) : 1.0f);
接下来,我们需要更新 v, ζ 模型的参数。记住,我们已定义了损失函数值,并非是目标值。甚至,每个模型的损失函数都是独立的,与我们以前所用的损失函数有很大不同。目前,我们无法将运算拟合到模型的基本损失函数。取而代之,我们将立即计算误差梯度。我们将结果值传输到相应的模型缓冲区,并在模型参数之间传播误差梯度。
首先,更新 v 模型参数。
//--- CBufferFloat temp; temp.BufferInit(MathMax(Actions.Total(), SecondInput.Total()), 0); temp.BufferCreate(opencl); //--- update nu int last_layer = cNu.layers.Total() - 1; CLayer *layer = cNu.layers.At(last_layer); if(!layer) return false; CNeuronBaseOCL *neuron = layer.At(0); if(!neuron) return false; CBufferFloat *buffer = neuron.getGradient(); if(!buffer) return false; vector<float> nu_grad = nu_loss * (zeta * bellman_residuals / MathAbs(bellman_residuals) + nu); if(!buffer.AssignArray(nu_grad) || !buffer.BufferWrite()) return false; if(!cNu.backPropGradient(output, GetPointer(temp))) return false;
然后针对 ζ 模型执行类似的运算。
//--- update zeta last_layer = cZeta.layers.Total() - 1; layer = cZeta.layers.At(last_layer); if(!layer) return false; neuron = layer.At(0); if(!neuron) return false; buffer = neuron.getGradient(); if(!buffer) return false; vector<float> zeta_grad = zeta_loss * (zeta - MathAbs(bellman_residuals) + fLambda) * (-1); if(!buffer.AssignArray(zeta_grad) || !buffer.BufferWrite()) return false; if(!cZeta.backPropGradient(output, GetPointer(temp))) return false;
此刻,我们已经更新了 DICE 模块参数,并正在直接转入强化学习过程。首先,运作两位评论者的直接验算。在这种情况下,我们不执行扮演者的直接验算,因为我们在更新模块的 DICE 对象参数时已经执行了此操作。
//--- feed forward critics if(!cCritic1.feedForward(GetPointer(this), iLatentLayer, output) || !cCritic2.feedForward(GetPointer(this), iLatentLayer, output)) return false;
接下来,与更新 DICE 参数一样,我们将判定损失函数的值。但首先,我们要做一些准备工作。为了提高模型训练的稳定性,我们对分布校正系数进行了常规化,并计算了目标评论者模型预测的参考值,同时考虑了当前的扮演者政策。
vector<float> result; if(fZeta == 0) fZeta = MathAbs(zeta[0]); else fZeta = 0.9f * fZeta + 0.1f * MathAbs(zeta[0]); zeta[0] = MathPow(MathAbs(zeta[0]), 1.0f / 3.0f) / (10.0f * MathPow(fZeta, 1.0f / 3.0f)); cTargetCritic1.getResults(result); float target = result[0]; cTargetCritic2.getResults(result); target = reward + discount * (MathMin(result[0], target) - LogProbMultiplier * log_prob.Sum());
尽管存在目标值,但我们无法实现评论者模型后向验算的基本方法,因为所用的分布校正系数不适合它。因此,我们使用上述开发的技术来计算误差梯度,并将其直接转移到结果的神经层缓冲区,随后是涵盖模型的梯度分布。
//--- update critic1 cCritic1.getResults(result); float loss = zeta[0] * MathPow(result[0] - target, 2.0f); if(fLoss1 == 0) fLoss1 = MathSqrt(loss); else fLoss1 = MathSqrt(0.999f * MathPow(fLoss1, 2.0f) + 0.001f * loss); float grad = loss * 2 * zeta[0] * (target - result[0]); last_layer = cCritic1.layers.Total() - 1; layer = cCritic1.layers.At(last_layer); if(!layer) return false; neuron = layer.At(0); if(!neuron) return false; buffer = neuron.getGradient(); if(!buffer) return false; if(!buffer.Update(0, grad) || !buffer.BufferWrite()) return false; if(!cCritic1.backPropGradient(output, GetPointer(temp)) || !backPropGradient(SecondInput, GetPointer(temp), iLatentLayer)) return false;
同时,我们计算模型的平均误差,将其展示给用户,以便对模型训练过程进行直观控制。
针对第二个评论者重复这些操作。
//--- update critic2 cCritic2.getResults(result); loss = zeta[0] * MathPow(result[0] - target, 2.0f); if(fLoss2 == 0) fLoss2 = MathSqrt(loss); else fLoss2 = MathSqrt(0.999f * MathPow(fLoss1, 2.0f) + 0.001f * loss); grad = loss * 2 * zeta[0] * (target - result[0]); last_layer = cCritic2.layers.Total() - 1; layer = cCritic2.layers.At(last_layer); if(!layer) return false; neuron = layer.At(0); if(!neuron) return false; buffer = neuron.getGradient(); if(!buffer) return false; if(!buffer.Update(0, grad) || !buffer.BufferWrite()) return false; if(!cCritic2.backPropGradient(output, GetPointer(temp)) || !backPropGradient(SecondInput, GetPointer(temp), iLatentLayer)) return false;
更新评论者的参数之后,我们继续更新扮演者的政策。我们将首先更新保守的扮演者政策。此处,我们计算目标值,同时考虑 Q-函数值的下限和动作的当前概率分布。我们将通过分布校正系数校正结果值,并通过评论者模型导出误差梯度。首先,我们将禁用评论者的训练模式。
//--- update policy cCritic1.getResults(result); float mean = result[0]; float var = result[0]; cCritic2.getResults(result); mean += result[0]; var -= result[0]; mean /= 2.0f; var = MathAbs(var) / 2.0f; target = zeta[0] * (mean - 2.5f * var + discount * log_prob.Sum() * LogProbMultiplier) + result[0]; CBufferFloat bTarget; bTarget.Add(target); cCritic2.TrainMode(false); if(!cCritic2.backProp(GetPointer(bTarget), GetPointer(this)) || !backPropGradient(SecondInput, GetPointer(temp))) { cCritic2.TrainMode(true); return false; }
在更新扮演者的乐观情绪研究政策的参数之前,我们先执行指定模型的前向验算,并替换结果缓冲区的值(就像我们之前针对悲观情绪模型所做的那样)。
然后,我们重新计算目标值,同时考虑乐观情绪系数,以及贯穿评论者模型的分布误差梯度。
//--- update exploration policy if(!cActorExploer.feedForward(State, 1, false, SecondInput)) { cCritic2.TrainMode(true); return false; } output = ((CNeuronBaseOCL*)((CLayer*)cActorExploer.layers.At(layers.Total() - 1)).At(0)).getOutput(); output.AssignArray(Actions); output.BufferWrite(); cActorExploer.GetLogProbs(log_prob); target = zeta[0] * (mean + 2.0f * var + discount * log_prob.Sum() * LogProbMultiplier) + result[0]; bTarget.Update(0, target); if(!cCritic2.backProp(GetPointer(bTarget), GetPointer(cActorExploer)) || !cActorExploer.backPropGradient(SecondInput, GetPointer(temp))) { cCritic2.TrainMode(true); return false; } cCritic2.TrainMode(true);
操作完成后,我们开启评论者训练模式,并更新目标模型的参数。
if(!cTargetCritic1.WeightsUpdate(GetPointer(cCritic1), tau) || !cTargetCritic2.WeightsUpdate(GetPointer(cCritic2), tau) || !cTargetNu.WeightsUpdate(GetPointer(cNu), tau)) { PrintFormat("Error of update target models: %d", GetLastError()); return false; } //--- return true; }
我们已经完成了模型训练方法的工作。现在是时候转入构建文件操控方法了。首先,我们创建一个保存模型的方法。与前面讨论过的类似方法不同,我们不会将所有数据保存在一个文件当中。相比之下,每个经过训练的模型将收入一个单独的文件。这将令我们能够在使用每个单独的模型之时独立于其它模型。
在参数中,数据保存方法 CNet_SAC_DICE::Save 将在终端共用文件夹中接收通用文件名(不带扩展名)以及保存标志。在方法主体中,我们立即检查生成的文本变量中是否存在文件名。
bool CNet_SAC_DICE::Save(string file_name, bool common = true) { if(file_name == NULL) return false;
接下来,我们按给定名称加上 “.set” 扩展名创建文件。内部变量的值将保存到其中。
int handle = FileOpen(file_name + ".set", (common ? FILE_COMMON : 0) | FILE_BIN | FILE_WRITE); if(handle == INVALID_HANDLE) return false; if(FileWriteFloat(handle, fLambda) < sizeof(fLambda) || FileWriteFloat(handle, fLambda_m) < sizeof(fLambda_m) || FileWriteFloat(handle, fLambda_v) < sizeof(fLambda_v) || FileWriteInteger(handle, iLatentLayer) < sizeof(iLatentLayer)) return false; FileFlush(handle); FileClose(handle);
之后,我们逐个调用保存模型的方法,并控制执行操作的过程。这里值得注意的是指定文件名。具有保守政策的扮演者会得到文件名后缀 “Act.nnw”(正如我们之前为扮演者指定的那样)。乐观情绪的扮演者模型得到带有 “ActExp.nnw” 后缀的文件。此外,我们只存储评论者和 v 模型的目标模型。不会保存相应的训练模型。
if(!CNet::Save(file_name + "Act.nnw", 0, 0, 0, TimeCurrent(), common)) return false; //--- if(!cActorExploer.Save(file_name + "ActExp.nnw", 0, 0, 0, TimeCurrent(), common)) return false; //--- if(!cTargetCritic1.Save(file_name + "Crt1.nnw", fLoss1, 0, 0, TimeCurrent(), common)) return false; //--- if(!cTargetCritic2.Save(file_name + "Crt2.nnw", fLoss2, 0, 0, TimeCurrent(), common)) return false; //--- if(!cZeta.Save(file_name + "Zeta.nnw", 0, 0, 0, TimeCurrent(), common)) return false; //--- if(!cTargetNu.Save(file_name + "Nu.nnw", 0, 0, 0, TimeCurrent(), common)) return false; //--- return true; }
在数据加载方法中,我们严格按照设置数据的顺序重复操作。在这种情况下,训练模型和目标模型是从同一对应文件中加载的。
bool CNet_SAC_DICE::Load(string file_name, bool common = true) { if(file_name == NULL) return false; //--- int handle = FileOpen(file_name + ".set", (common ? FILE_COMMON : 0) | FILE_BIN | FILE_READ); if(handle == INVALID_HANDLE) return false; if(FileIsEnding(handle)) return false; fLambda = FileReadFloat(handle); if(FileIsEnding(handle)) return false; fLambda_m = FileReadFloat(handle); if(FileIsEnding(handle)) return false; fLambda_v = FileReadFloat(handle); if(FileIsEnding(handle)) return false; iLatentLayer = FileReadInteger(handle);; FileClose(handle); //--- float temp; datetime dt; if(!CNet::Load(file_name + "Act.nnw", temp, temp, temp, dt, common)) return false; //--- if(!cActorExploer.Load(file_name + "ActExp.nnw", temp, temp, temp, dt, common)) return false; //--- if(!cCritic1.Load(file_name + "Crt1.nnw", fLoss1, temp, temp, dt, common) || !cTargetCritic1.Load(file_name + "Crt1.nnw", temp, temp, temp, dt, common)) return false; //--- if(!cCritic2.Load(file_name + "Crt2.nnw", fLoss2, temp, temp, dt, common) || !cTargetCritic2.Load(file_name + "Crt2.nnw", temp, temp, temp, dt, common)) return false; //--- if(!cZeta.Load(file_name + "Zeta.nnw", temp, temp, temp, dt, common)) return false; //--- if(!cNu.Load(file_name + "Nu.nnw", temp, temp, temp, dt, common) || !cTargetNu.Load(file_name + "Nu.nnw", temp, temp, temp, dt, common)) return false;
加载这些模型之后,我们将它们传输到单个 OpenCL 关联环境之中。
cActorExploer.SetOpenCL(opencl); cCritic1.SetOpenCL(opencl); cCritic2.SetOpenCL(opencl); cZeta.SetOpenCL(opencl); cNu.SetOpenCL(opencl); cTargetCritic1.SetOpenCL(opencl); cTargetCritic2.SetOpenCL(opencl); cTargetNu.SetOpenCL(opencl); //--- return true; }
我们在 CNet_SAC_DICE 类上的工作就完成了。您可以在附件中找到其所有方法的完整代码。您可能还记得,上面讨论的训练方法的参数表示动作概率的对数向量。但是我们之前没有将此类数据保存到经验回放缓冲区当中。因此,现在我们需要将相应的数组添加到文件 “..\SAC&DICE\Trajectory.mqh“。数组的大小等于动作的数量。
struct SState { float state[HistoryBars * BarDescr]; float account[AccountDescr - 4]; float action[NActions]; float log_prob[NActions]; //--- SState(void); //--- bool Save(int file_handle); bool Load(int file_handle); //--- overloading void operator=(const SState &obj) { ArrayCopy(state, obj.state); ArrayCopy(account, obj.account); ArrayCopy(action, obj.action); ArrayCopy(log_prob, obj.log_prob); } };
不要忘记在复制结构和操控文件的方法算法中添加数组。完整的结构代码可以在附件中找到。
我们转入创建和训练模型。关于模型架构,它是从文章中转移过来的,描述了软性扮演者-评论者方法,没有变化。同时,我们没有为 v 和 ζ 模型创建单独的架构。针对它们我们使用了评论者架构。
在训练模型时,我们像以前一样用到三个 EA:
- 研究 — 收集样本数据库
- 学习 — 模型训练
- 测试 ― 检查得到的结果。
在研究 EA 中收集样本数据库数据时,我们采用乐观情绪扮演者政策(带有 “ActExp.nnw” 后缀的文件)。不过,为了测试已训练模型,我们将用保守模型(带有 “Act.nnw” 后缀的文件)。从相应文件中加载模型时,我们应该注意这一点。此外,在将数据收集到经验回放缓冲区时,不要忘记加载动作分布概率的对数。完整的 EA 代码可在附件中找到。
Study 训练 EA 经历了最大的变化。这并不奇怪。我们将其很大一部分功能转移到了 CNet_SAC_DICE 类的 Study 训练方法之中。
我们首先更改包含模型的函数库。
#include "Net_SAC_DICE.mqh"
在全局变量块中,我们只声明新创建的 CNet_SAC_DICE 类的一个模型。同时,我们增加了数据缓冲区的数量。这是因为以前我们可以在训练的不同阶段使用一个缓冲区处理两种状态。现在,我们必须把有关两个后续状态的信息同步传输到模型。
STrajectory Buffer[]; CNet_SAC_DICE Net; //--- float dError; datetime dtStudied; //--- CBufferFloat bState; CBufferFloat bAccount; CBufferFloat bActions; CBufferFloat bNextState; CBufferFloat bNextAccount;
如前,在 EA 初始化方法中,我们首先加载训练模型的经验回放缓冲区。
int OnInit() { //--- ResetLastError(); if(!LoadTotalBase()) { PrintFormat("Error of load study data: %d", GetLastError()); return INIT_FAILED; }
之后,我们加载单个模型。如果模型尚未创建,则我们形成模型架构的描述数组,并创建一个模型,将所有架构描述传递给它。我们只检查一次操作结果。
如上所述,我们提供了 DICE 模块模型的评论者架构的描述。但其它选项也是可能的。当该模块创建您自己的模型时,注意使用扮演者模型作为源数据的主要处理模块。这正是我们如何构建整个模型训练算法的。在创建模型架构时,我们需要遵循它,或者对方法算法进行相应的修改。
//--- load models if(!Net.Load(FileName, true)) { CArrayObj *actor = new CArrayObj(); CArrayObj *critic = new CArrayObj(); if(!CreateDescriptions(actor, critic)) { delete actor; delete critic; return INIT_FAILED; } if(!Net.Create(actor, critic, critic, critic, LatentLayer)) { delete actor; delete critic; return INIT_FAILED; } delete actor; delete critic; }
当我说“只有一个模型”时,我也许并不完全准确。在训练期间中,我们创建了 6 个更新的模型和 3 个目标模型。所有模型都是在我们的新类之内创建的,并且对用户是隐藏的。在顶层,我们只操控一个类。
在 EA 初始化方法的末尾,我们生成一个模型训练事件。
if(!EventChartCustom(ChartID(), 1, 0, 0, "Init")) { PrintFormat("Error of create study event: %d", GetLastError()); return INIT_FAILED; } //--- return(INIT_SUCCEEDED); }
所有操作成功完成之后,我们的 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)); if(i<0) { iter--; continue; }
在循环中,我们从当前模型训练迭代采样轨迹和独立步骤。
接着,我们将进行准备工作,并将必要的数据收集到先前声明的数据缓冲区之中。首先,我们将缓冲描述环境后续状态的历史数据。
//--- Target bNextState.AssignArray(Buffer[tr].States[i + 1].state); float PrevBalance = Buffer[tr].States[i].account[0]; float PrevEquity = Buffer[tr].States[i].account[1]; if(PrevBalance==0) { iter--; continue; } bNextAccount.Clear(); bNextAccount.Add((Buffer[tr].States[i + 1].account[0] - PrevBalance) / PrevBalance); bNextAccount.Add(Buffer[tr].States[i + 1].account[1] / PrevBalance); bNextAccount.Add((Buffer[tr].States[i + 1].account[1] - PrevEquity) / PrevEquity); bNextAccount.Add(Buffer[tr].States[i + 1].account[2]); bNextAccount.Add(Buffer[tr].States[i + 1].account[3]); bNextAccount.Add(Buffer[tr].States[i + 1].account[4] / PrevBalance); bNextAccount.Add(Buffer[tr].States[i + 1].account[5] / PrevBalance); bNextAccount.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'); bNextAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_MN1); bNextAccount.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_W1); bNextAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_D1); bNextAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
在另一个缓冲区中,我们将创建帐户状态的描述,并添加时间戳。
以类似的方式,我们将准备描述环境分析状态的缓冲区。
bState.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]; bAccount.Clear(); bAccount.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[1] / PrevBalance); bAccount.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity); bAccount.Add(Buffer[tr].States[i].account[2]); bAccount.Add(Buffer[tr].States[i].account[3]); bAccount.Add(Buffer[tr].States[i].account[4] / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[5] / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[6] / PrevBalance); x = (double)Buffer[tr].States[i].account[7] / (double)(D'2024.01.01' - D'2023.01.01'); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_MN1); bAccount.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_W1); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_D1); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
然后,我们将已完成动作移至缓冲区。概率对数将被加载到向量之中。
bActions.AssignArray(Buffer[tr].States[i].action); vector<float> log_prob; log_prob.Assign(Buffer[tr].States[i].log_prob);
在这个阶段,我们完成了准备工作。一次训练迭代所需的所有数据都已收集在数据缓冲区当中。我们调用模型的 CNet_SAC_DICE::Study 训练方法,在参数中传递必要的数据。
if(!Net.Study(GetPointer(bState), GetPointer(bAccount), GetPointer(bActions), log_prob, GetPointer(bNextState), GetPointer(bNextAccount), Buffer[tr].Revards[i] - DiscFactor * Buffer[tr].Revards[i + 1], DiscFactor, Tau)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
请注意,在经验回放缓冲区中,我们存储的奖励是累计总数。现在,我们将一个单独步骤的净奖励传送至模型训练方法之中。缺失的数据将由目标模型预测。
我们在类训练方法中已实现了所有模型训练操作。现在我们只需要检查方法操作的结果。然后,我们将告知用户有关模型训练过程的信息。
if(GetTickCount() - ticks > 500) { float loss1, loss2; Net.GetLoss(loss1, loss2); string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic1", iter * 100.0 / (double)(Iterations), loss1); str += StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic2", iter * 100.0 / (double)(Iterations), loss2); Comment(str); ticks = GetTickCount(); } }
完成循环迭代后,我们清除注释字段,并启动 EA 关闭过程。
Comment(""); //--- float loss1, loss2; Net.GetLoss(loss1, loss2); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic1", loss1); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic2", loss2); ExpertRemove(); //--- }
正如我们所见,将模型训练操作放在一个单独的类方法中,可以令我们显著降低主程序层面的代码,以及劳动力成本。同时,这种方式降低了模型训练的灵活性,以及用户调整模型的能力。这两种方式各有其积极和消极的一面。具体方式的选择取决于手头的任务和个人喜好。
附件中提供了 EA 的完整代码,及本文中用到的所有程序。
4. 测试
该模型基于 2023 年 1 月至 5 月期间 EURUSD H1 的历史数据进行了训练。指标参数和所有超参数均按默认值设置。在训练期间,获得了一个能够在训练集上产生盈利的模型。
在覆盖 5 个月的训练区间,该模型赚取了 15% 的利润。开仓 314 笔,其中 45.8% 获利了结。最大盈利交易超过最大亏损近 2 倍。甚至,平均盈利交易比平均亏损高 1/3。正是这种损益比令我们能够得到 1.13 的盈利系数。
如常,我们对模型在新数据上的绩效更感兴趣。依据 2023 年 6 月的历史数据在策略测试器中测试了模型对陌生数据的普适能力和绩效。正如我们所见,测试区间紧随训练集之后。这确保了训练和测试样本的最大同质性。测试结果呈现如下。
所呈现的图表展示出月度前十天有一段回撤区域。但之后是一段盈利期,一直持续到月底。因此,EA 在本月收获了 7.7% 的盈利1,最大净值回撤为 5.46%。就余额而言,回撤幅度更小,不超过 4.87%。
测试结果表格显示,在测试期间,EA 在两个方向上均执行了交易。共有 48 笔开仓。其中 54.17% 盈利了结。最大盈利交易比最大亏损交易高出 3 倍以上。平均盈利交易是平均亏损交易的一半。就量化值来说,平均每 3 笔盈利交易就有 2 笔无盈交易。所有这些都给出了 1.74 的盈利因子,和 1.41 的恢复因子。
结束语
本文研究了扮演者-评论者系列中的另一种算法 — SAC+DICE 算法,其基于软性扮演者-评论者算法的两个主要修改方向。使用乐观情绪的环境研究模型,令我们能够扩大环境研究的领域。该研究是朝着提高常见政策的盈利能力方向进行的。当然,这会导致环境研究政策和训练保守政策分布的断层。为了获得梯度的无背离估值,我们使用了改进的 DICE 方法,并引入了可训练的分布校正系数。所有这些都令提高模型训练的效率成为可能,这在我们文章的实践部分得到了证实。
我们以 MQL5 实现了所提出的算法。在此实现期间,演示了一种将模型训练过程移至单独类方法中的方式。这令我们能够显著减少主程序层面的工作量,并简化用法。
我们在新数据上训练和测试了已训练模型。测试结果证明了我们的实现效果。已训练模型能够将获得的经验转至新数据。在测试期间,EA 获得盈利。
不过,所示的所有程序仅演示了使用该技术的可能性。它们还没有准备好在真正的金融市场中运用。在真实市场上推出之前,EA 需要经过更多改进和额外测试。
链接
本文中用到的程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | 智能交易系统 | 样本收集 EA |
2 | Study.mq5 | 智能交易系统 | 代理者训练 EA |
3 | Test.mq5 | 智能交易系统 | 模型测试 EA |
4 | Trajectory.mqh | 类库 | 系统状态定义结构 |
5 | Net_SAC_DICE.mqh | 类库 | 模型类 |
6 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
7 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/13055



