神经网络变得轻松(第四十四部分):动态学习技能
概述
在复杂的随机环境中求解预测问题时,训练一个能够在训练集之外展示可接受结果的模型是相当困难的,而且往往是不可能的。 与此同时,将问题分解为更小的子任务可以显著提高整体模型的性能。 在之前的文章中,我们已经熟悉了分层模型的构造。 它们的架构可以将问题的解划分为几个子任务。 每个子任务都由一个更简单的单独模型求解。 这里浮现出一个问题,即如何正确训练技能,从而令这些技能可以很容易地由模型依据特定状态下的行为来识别。
在上一篇文章中,我们精研的 DIAYN 方法,它可以令您训练可分离的技能。 这样就能够构建一个模型,可以根据当前状态改变代理者行为。 您可能还记得,DIAYN 算法为不可预测的行为提供奖励。 这令我们能够教导出拥有尽可能多的不同行为的技能。 但硬币也有另一面。 这样的技能变得难以预测。 这会令代理者的规划和管理变得复杂。
在这种范式中,学习技能显露出一个问题,即其行为很容易预测。 然此刻,对于牺牲它们的行为多样性我们还没有做好准备。 类似的问题,已由 2020 年提出的《技能的动态感知探索(DADS)》方法的作者加以解决。 与 DIAYN 不同,DADS 方法定位于教导行为多样化、且可预测的技能。
1. DADS 架构概览及基本步骤
研究多个独立行为和相应的环境变化,能够令模型预测控制用于规划行为空间,胜于动作空间。 有关于此,主要问题是我们如何获得这种行为,因为它们可能是随机且不可预测的。 动态感知技能探索(DADS)方法提出了一种用于学习低级技能的无监督强化学习系统,其明确目标是促进基于模型的控制。
利用 DADS 学到的技能直接优化了可预测性,而来自所学习的预测模型能提供更好的见解。 技能的一个关键特征是它们完全经由自主探索获得的。 这意味着在设计任务和奖励函数之前,要先学习技能工具箱及其预测模型。 如此,有了足够的数量,您就可以充分研究环境,并开发与其搭配的技能。
与 DIAYN 方法一样,DADS 算法用到 2 个模型:技能模型(代理者),和鉴别器(技能动态模型)。
模型按顺序迭代训练。 首先,训练鉴别器能基于当前状态和所用技来预测未来状态。 为此,将当前状态和独热技能识别向量馈送到代理者模型的输入之中。 代理者生成一个在环境中执行的动作。 该动作的结果就是,代理者将转进到环境的新状态。
反过来,鉴别器基于相同的初始数据,尝试预测环境的新状态。 在这种情况下,鉴别器的工作类似于前面讨论的自动编码器。 不过,在这种情况下,解码器不会从潜伏状态恢复原始数据,而是预测下一个状态。 就像我们训练自动编码器一样,我们采用梯度下降法来训练鉴别器。
正如您所见,这是 DIAYN 和 DADS 算法之间的第一个区别。 在 DIAYN 中,我们基于新状态判定出将我们带到这种状态的技能。 DADS 鉴别器执行相反的功能。 基于初始数据和已知技能,它预测环境的后续状态。
此处需要注意的是,这个过程是迭代的。 因此,我们不要妄想立即达成最大可能性。 与此同时,我们至少需要一个初始近似值来训练代理者。
在第一批鉴别器训练迭代之后,我们转进到训练代理者(技能模型)。 我们立即会说,训练鉴别器和代理者要用不同的源数据包。 不过,这并不意味着有必要创建单独的训练样本。 我们共享相同的经验回放缓冲区。 只有在每次迭代中,我们才会从该缓冲区随机生成 2 批单独的训练数据。
与 DIAYN 方法类似,训练技能模型采用的强化学习方法,基于鉴别器生成的奖励。 一如既往,区别在于细节。 DADS 使用不同的数学方程式来产生奖励。 我现在不打算详述这种方式所涉及的数学计算和理由。 您可以在原文中找到它们。 我只研究最终的奖励方程式。
在所提供的方程中,q(s'|s,z) 是鉴别器输出,s 是独立的初始状态,而 z 是技能。 L 检测技能的数量。 因此,在奖励方程的分子中,我们看到了所分析技能的预测状态。 分母包含所有可能技能的平均预测状态。
用此奖励函数可以令我们解决上面提出的问题。 由于在分子中,我们用的是当前技能的预测状态,故对于导致达成预测状态的代理者动作,我们会给予奖励。 这达成了技能行为的可预测性。
与此同时,在分母中使用所有可能技能的平均状态,可以令我们尽可能奖励更多与统计平均值不同的技能行为。
因此,DADS 方法在可预测性和技能多样性之间取得了平衡。 这样可以教导出具有结构化和可预测行为的技能,同时保持探索环境的能力。
请记住,来自鉴别器的反馈行为,和技能模型的训练,会导致代理者行为的变化。 结果就是,其行为将与来自经验再现缓冲区中累积的样本不同。 因此,为了获得最优结果,我们使用迭代过程,按顺序对鉴别器和代理者进行训练。 在此过程中,模型会反复训练多次。 此外,该方法的作者建议使用重要性系数,该系数是由当前所用的代理者策略执行动作的概率,与在经验回放缓冲区中执行此动作的概率之间的比率判定。 这样就可以更多地关注代理者建立的行为。 与此同时,随机动作的影响被拉平。
应该注意的是,DADS 方法最初是为了训练技能和创建环境模型而提出的。 正如您所看到的,训练一个可预测的技能和一个动态模型,令我们能够以足够的概率预测环境的新状态,进而我们就能把规划提前若干步。 与此同时,在规划过程中,我们可以从具体的行动转向更普遍化技能概念的操作。 具体动作则由代理者根据规划的技能判定。
然而,在这个阶段,我决定不进行长期规划,而是决定训练调度器来检测每个步骤的技能,就如同上一篇文章中一样。
2. 利用 MQL5 实现
我们转进到讨论算法的实际实现。 在直接进入算法的实现之前,我们先要决定模型的架构。
与 DIAYN 一样,我们在实现中用到 3 个模型。 它们是代理者(技能模型)、鉴别器(动态模型)和调度器。
该算法规定,代理者给予当前状态和所选技能来判定所要执行的动作。 因此,源数据层的大小应足以设置描述当前状态的向量,以及标识所选技能的独热向量。
在代理者输出中,我们收到一个可能动作空间的概率分布向量。 如您所见,代理者的初始数据、功能和结果与 DIAYN 方法的代理者的相应特征完全相似。 在此实现中,我们将保持代理者构架不变。 这令我们能够在实践中比较 2 种研究的训练技能方法的工作。 不过,这并不意味着不能用其它模型构架。
我要提醒您,在代理者构架中,我们用到了批量常规化层,来将源数据转换为可比较的形式。
//--- Actor actor.Clear(); CLayerDescription *descr; //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (int)(HistoryBars * BarDescr + AccountDescr + NSkills); descr.window = 0; 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; }
常规化数据由 2 个卷积层和子采样层组成的模块处理,这令识别源数据中的单个形态和趋势成为可能。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count - 1; descr.window = 2; descr.step = 1; descr.window_out = 4; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronProofOCL; prev_count = descr.count = prev_count; descr.window = 4; descr.step = 4; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count - 1; descr.window = 2; descr.step = 1; descr.window_out = 4; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
卷积层处理后的数据被传递到决策模块,该决策模块包含全连接层和完全参数化的分位数 FQF 模型。
以 FQF 作为决策模块的输出,我们可在采取行动后获得更准确的奖励预测,这不仅考虑了它们的平均值,还考虑到环境随机性的概率分布。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.optimization = ADAM; descr.activation = TANH; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 6 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 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 128; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFQF; descr.count = NActions; descr.window_out = 32; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
如上所述,在此实现中,我们没有创建环境模型来提前若干步进行预测。 就像以前一样,我们将定义在每步所用的技能。 因此,我们还保留了训练调度器的架构及其方法不变。 在此,我们利用批量常规化层将原始数据转换为可比较的形式。 决策模块由全连接层和 FQF 模型组成。 其结果利用 SoftMax 层传输到概率分布域之中。
//--- Scheduler scheduler.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = (HistoryBars * BarDescr + AccountDescr); descr.window = 0; descr.activation = None; descr.optimization = ADAM; if(!scheduler.Add(descr)) { delete descr; return false; } //--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1000; descr.activation = None; descr.optimization = ADAM; if(!scheduler.Add(descr)) { delete descr; return false; } //--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.optimization = ADAM; descr.activation = TANH; if(!scheduler.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.optimization = ADAM; descr.activation = LReLU; if(!scheduler.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFQF; descr.count = NSkills; descr.window_out = 32; descr.optimization = ADAM; if(!scheduler.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = NSkills; descr.step = 1; descr.optimization = ADAM; if(!scheduler.Add(descr)) { delete descr; return false; }
不过,我们对鉴别器模型的架构进行了更改。 我要提醒您,DADS 算法采用动态模型作为鉴别器。 根据所研究的算法,它应给予当前状态和所选技能预测环境的新状态。 此外,为了提前若干步制定计划,需用到 DADS 方法中的动态模型来预测未来状态。 但如上所述,我们不会制定长期计划。 这意味着我们也许会略微偏离预测未来环境状态的所有指标。 如您所知,我们对环境状态的描述由 2 个大模块组成:
- 价格走势的历史数据,和进行分析的指标之一
- 当前帐户状态的指标。
个人交易者对金融市场状况的影响是如此微不足道,以至于可以忽略不计。 故此,我们的代理者操作不会影响历史数据。 这意味着在训练我们的代理者时,我们也许会将它们排除在内部奖励的形式之外。 由于我们不会制定影响深远的计划,因此预测这些指标并无意义。 因此,要形成内部奖励,我们预测账户未来状态的指标就足够了。
还有一点应该注意。 看看形成代理者内部奖励的方程式。 除了分析技能的预测状态外,它还用到所有可能技能的平均预测状态。 这意味着要判定一个奖励,我们必须预测所有技能的未来状态。 为了加快训练模型的过程,决定创建一个多头模型输出。 该模型将根据单个初始数据返回所有可能技能的预测状态。
因此,鉴别器模型的源数据层可与调度器模型的类似层相比较,且应足以记录系统状态的描述,无需参考所选技能。
//--- Discriminator discriminator.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = (HistoryBars * BarDescr + AccountDescr); descr.window = 0; descr.activation = None; descr.optimization = ADAM; if(!discriminator.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(!discriminator.Add(descr)) { delete descr; return false; }
收到的初始数据在批量常规化层中进行初级处理,并被传输到由全连接感知器组成的决策模块。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.optimization = ADAM; descr.activation = TANH; if(!discriminator.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.optimization = ADAM; descr.activation = LReLU; if(!discriminator.Add(descr)) { delete descr; return false; }
模型的输出也用到全连接层。 它的大小等于所教导的技能数量与描述系统一种状态的元素数量的乘积。 在本例中,我们指示帐户状态描述的元素数量。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = NSkills*AccountDescr; descr.optimization = ADAM; descr.activation = None; if(!discriminator.Add(descr)) { delete descr; return false; }
值得注意的是,尽管我们在上一篇文章中决定使用指标的相对值来描述账户的状态,但这些值似乎并没有被常规化。 在预测它们时,我们不能使用任何的激活函数。 因此,鉴别器模型的输出采用无激活函数的神经层。
描述所有已用模型架构的完整代码收集在 CreateDescriptions 函数内,该函数位于函数库文件 “Trajectory.mqh” 当中。 将该函数从 EA 文件转移到函数库文件,令我们能够在训练的所有阶段使用同一模型架构,并且无需在 EA 之间手动复制模型架构定义。
在训练模型,以及测试得到的结果时,我们将用到 3 个 EA 来训练模型,类似于 DIAYN 所用的方法。 训练模型前的主要数据收集 EA “Research.mq5” 已完全转移,几乎没有任何更改。 这些变化仅影响记录模型的文件名称,和上述体系结构方案。 附件中提供了完整的智能系统代码。
实现的 DADS 算法主要变化是针对模型训练 EA “Study.mq5” 进行的。 首先,这是监视模型构架与先前声明的常量的合规性,其在 OnInit 方法中执行。 此处,我们已根据修改后的模型架构调整了控制。
Discriminator.getResults(DiscriminatorResult); if(DiscriminatorResult.Size() != NSkills * AccountDescr) { PrintFormat("The scope of the discriminator does not match the skills count (%d <> %d)", NSkills * AccountDescr, Result.Total()); return INIT_FAILED; } Scheduler.getResults(SchedulerResult); Scheduler.SetUpdateTarget(MathMax(Iterations / 100, 500000 / SchedulerBatch)); if(SchedulerResult.Size() != NSkills) { PrintFormat("The scope of the scheduler does not match the skills count (%d <> %d)", NSkills, Result.Total()); return INIT_FAILED; } Actor.getResults(ActorResult); Actor.SetUpdateTarget(MathMax(Iterations / 100, 500000 / AgentBatch * NSkills)); if(ActorResult.Size() != NActions) { PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", NActions, Result.Total()); return INIT_FAILED; }
对于训练模型训练方法也进行了重大更改。 首先,我们要查看 2 种新的辅助方法。 第一种方法是 GetNewState。 在这种方法的主体中,我们将根据账户的先前状态、计划的行动、和已知的“未来”价格走势,计算出余额指标状态。
请记住,该方法定义计算出的余额状态,而非预测状态。 这里的文字游戏背后具有颇多含义。 余额参数的预测值由动态模型(鉴别器)执行。 在当前方法中,我们基于来自经验回放缓冲区的中后续价格走势的了解来定义计算出的余额状态。 考虑到所用技能和动作更新策略而产生的代理者动作,与来自剪贴板中的代理者动作之间很有可能存在差异,故需要进行这种计算。 来自经验回放缓冲区的数据令我们能够准确计算任何代理者动作导致的账户状态和持仓,无需在策略测试器中重复该动作。 这令我们能够显著扩展训练集,从而提高模型训练的品质。 在上一篇文章中已经实现了类似的功能。 安排一个单独的方法是由于在训练模型的过程中要多次调用该函数所致。
在参数中,该方法在决策阶段接收账户描述参数的动态数组、操作 ID、和每一手多头持仓的后续价格走势的盈亏值。 操作的结果就是,该方法将返回一个数值向量,描述参考了指定动作的帐户后续状态。
在方法的主体中,我们创建一个向量来记录结果,并取账户初始状态为其赋值。
vector<float> GetNewState(float &prev_account[], int action, double prof_1l) { vector<float> result; //--- result.Assign(prev_account);
接下来,根据正在执行的动作分支运作。 在开仓或加仓的交易操作中,我们计算相应方向上持仓的新数值。 接下来,我们计算每个方向的累计盈亏变化,同时参考持仓规模和随后的价格走势。 账户的累计盈亏值等于上述计算出的 2 个指标的总和。 将结果值与余额指标相加,我们就得到账户净值。
switch(action) { case 0: result[5] += (float)SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN); result[7] += result[5] * (float)prof_1l; result[8] -= result[6] * (float)prof_1l; result[4] = result[7] + result[8]; result[1] = result[0] + result[4]; break; case 1: result[6] += (float)SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN); result[7] += result[5] * (float)prof_1l; result[8] -= result[6] * (float)prof_1l; result[4] = result[7] + result[8]; result[1] = result[0] + result[4]; break;
如果所有持仓都已平仓,我们只需将累积盈利的数值加到当前余额之中即可。 我们把结果数值复制到净值和可用保证金当中。 将其余参数重置为零。
case 2: result[0] += result[4]; result[1] = result[0]; result[2] = result[0]; for(int i = 3; i < AccountDescr; i++) result[i] = 0; break;
在等待时(代理者无操作)重新计算参数,与开仓交易操作类似,但持仓量的变化除外。 这意味着我们只需重新计算以前开立的持仓的累计盈亏和净值。
case 3: result[7] += result[5] * (float)prof_1l; result[8] -= result[6] * (float)prof_1l; result[4] = result[7] + result[8]; result[1] = result[0] + result[4]; break; } //--- return result return result; }
在重新计算所有参数之后,我们将生成的数值向量返回给调用程序。
我们要添加的第二个方法是根据鉴别器的预测值、所选技能和之前的帐户状态,来计算对代理者的内部奖励数额。
请注意,代理者会因特定动作而获得奖励。 但我们没有在方法参数中指示代理者所选择的动作。 事实上,此处我们可以注意到代理者预测和收到的奖励之间存在一定的间隙。 毕竟,代理者选择的动作有可能并不会导致鉴别器预测到的状态。 当然,在训练代理者和鉴别器的过程中,这种间隙的概率会降低。 尽管如此,它仍将保留。 与此同时,对我们来说,重要的是奖励应与导致预测状态的动作相对应。 否则,我们将无法获得技能的预测行为。 这就是为什么我们将从 2 个后续状态中判定奖励操作:所选技能的当前状态,和预测状态。
如此,GetAgentReward 函数在其参数中接收所选技能、鉴别器前向验算的结果向量、以及描述先前余额状态的数组。 作为函数操作的结果,我们计划获得代理者奖励的向量。
我们不得不在方法主体中进行一些准备工作。 鉴别器前向验算结果向量包含所有可能技能的预测状态。 为了判定奖励,我们必须隔离独立技能,并在独立参数的上下文中计算平均值。 矩阵运算将帮助我们完成这项任务。 首先,我们需要将鉴别器结果向量重新格式化为矩阵。
我们创建一个 1 行的新矩阵,而列数等于鉴别器结果向量中的元素数量。 我们将向量中的数值复制到矩阵之中。 然后将矩阵重新格式化为矩形矩阵,其中行数将对应于技能数,列数将等于描述一种状态的向量的大小。 在这种情况下,重要的是调用 Reshape 方法而非 Resize 方法,因为第一个方法会在新格式矩阵里重新分配现有数值。 第二个方法仅更改行数和列数,而不会重新分配现有元素。 在这种情况下,我们就会丢失除第一个技能之外的所有数据。 添加的行将用随机值填充。
vector<float> GetAgentReward(int skill, vector<float> &discriminator, float &prev_account[]) { //--- prepare matrix<float> discriminator_matrix; discriminator_matrix.Init(1, discriminator.Size()); discriminator_matrix.Row(discriminator,0); discriminator_matrix.Reshape(NSkills, AccountDescr); vector<float> forecast = discriminator_matrix.Row(skill);
现在,我们只需要提取相应行的数值,即可检索我们感兴趣的技能的预测状态向量。
接下来,我们需要判定代理者能获得奖励的动作。 我们交易操作的主要参数是持仓的变化。 我承认这里可能有很多约定。 但是用它们会帮助我们辨别操作,令我们能够得到合理的概率,更接近预测状态。 这是令我们的模型易于管理和可预测所需的。
首先,我们检测每个方向的持仓变化。 如果我们在两个方向上的持仓规模都在减少,那么我们认为最可能采取的行动是平仓。 否则,我们优先选择变化最大的那一笔持仓。 我们相信,在这个方向上有新的成交,或者加仓。
如果变化相等,我们只需等待。 根据使用浮点值的概率论,这种结果是最不可能的。 因此,我们希望激励模型采取积极行动。
//--- check action int action = 3; float buy = forecast[5] - prev_account[5]; float sell = forecast[6] - prev_account[6]; if(buy < 0 && sell < 0) action = 2; else if(buy > sell) action = 0; else if(buy < sell) action = 1;
现在我们已经定义了奖励动作,并准备好了计算用的数据,我们可以直接继续填充奖励向量。
首先,我们沿着动作空间的维度形成一个零值向量。 接下来,我们取我们感兴趣的技能预测值向量,除以所有技能的平均预测值向量。 从得到的向量中,我们取平均值。 我们假设执行这些操作可能会获得负数值。 因此,我们取它的绝对值并转换为对数。 该绝对值不会与主要任务相矛盾,因为我们希望大多数非标准动作的奖励最大化,其尽可能远离平均值的向量。 作为替代方案,这也将有助于消除除零,我可以建议使用分析技能向量与平均值向量之间的欧几里得(Euclidean)距离。 我们在实践中测试这些方法的品质。
//--- calculate reward vector<float> result = vector<float>::Zeros(NActions); float mean = (forecast / discriminator_matrix.Mean(0)).Mean(); result[action] = MathLog(MathAbs(mean)); //--- return result return result; }
将生成的奖励值设置到先前定义的对应动作向量元素之中。 在函数操作结束时,将生成的奖励向量返回给调用程序。
完成准备工作后,我们将继续训练我们的模型。 在此,我们首先声明一些局部变量,并检测先前加载的训练集轨迹的数量。
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { int total_tr = ArraySize(Buffer); uint ticks = GetTickCount(); vector<float> account, reward; int bar, action; int skill, shift;
接下来,我们为模型训练过程安排一个循环系统。 我应该马上说,根据 DADS 算法,模型训练是按顺序和迭代方式进行的。 我们首先训练鉴别器(阶段 0)。 然后我们训练代理者(阶段 1)。 最后,尤为重要的是调度器(阶段 2)。 整个过程反复迭代若干次。 迭代次数在 EA 的外部参数中设置。 此外,我们将在 EA 的外部参数中指出每个阶段的训练包的大小。
现在,我们将在函数主体中声明一个嵌套循环系统。 外部循环决定了训练过程的迭代次数。 嵌套循环则确定训练阶段。
//--- for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++) { for(int phase = 0; phase < 3; phase++) {
嵌套循环可以替换为一系列操作。 但这种方式可以消除常见的复制操作,例如在直接验算模型之前从回放缓冲区加载初始状态。
每个训练阶段的操作依据训练包的大小重复,在 EA 的外部参数中分别指定每个阶段的大小。 因此,我们首先检测相应训练包的大小。 然后,我们按所需重复次数创建另一个嵌套循环。
int batch = 0; switch(phase) { case 0: batch = DiscriminatorBatch; break; case 1: batch = AgentBatch; break; case 2: batch = SchedulerBatch; break; default: PrintFormat("Incorrect phase %d"); batch = 0; break; } for(int batch_iter = 0; batch_iter < batch; batch_iter++) {
接下来,直接训练模型的过程开始。 首先,我们需要准备初始数据。 我们从经回放缓冲区中随机选择它们。 在此,我们从其中随机选择一次验算和一个状态。
int tr = (int)(((double)MathRand() / 32767.0) * (total_tr - 1)); int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2));
然后,我们将描述系统当前状态的数据加载到数据缓冲区之中。
State.AssignArray(Buffer[tr].States[i].state);
之后,我们将帐户数据转换为相对单位,并添加缓冲区。
float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0]; float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1]; State.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance); State.Add(Buffer[tr].States[i].account[1] / PrevBalance); State.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity); State.Add(Buffer[tr].States[i].account[2] / PrevBalance); State.Add(Buffer[tr].States[i].account[4] / PrevBalance); State.Add(Buffer[tr].States[i].account[5]); State.Add(Buffer[tr].States[i].account[6]); State.Add(Buffer[tr].States[i].account[7] / PrevBalance); State.Add(Buffer[tr].States[i].account[8] / PrevBalance);
在这个阶段,我们可以执行直接模型验算。 但在我们根据当前训练阶段对操作流进行分支之前,我们还要准备在每个操作阶段所需的数据,以便计算帐户未来状态的估值。
bar = (HistoryBars - 1) * BarDescr; double cl_op = Buffer[tr].States[i + 1].state[bar]; double prof_1l = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE_PROFIT) * cl_op / SymbolInfoDouble(_Symbol, SYMBOL_POINT); PrevBalance = Buffer[tr].States[i].account[0]; PrevEquity = Buffer[tr].States[i].account[1]; if(IsStopped()) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); break; }
完成所有常规操作后,我们继续根据当前训练阶段划分操作流程。
如上所述,学习过程从训练鉴别器模型开始。 首先,我们根据先前准备好的源数据对模型进行直接验算,并检查操作的正确性。
switch(phase) { case 0: if(!Discriminator.feedForward(GetPointer(State))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); break; }
我们收到一个预测状态向量,对应于在鉴别器的输出中研究的所有技能。 因此,我们还必须为所有技能生成目标状态,以便准备目标值。 为了达成这一点,我们根据所研究技能的数量安排循环。 然后,我们在循环主体中直接验算代理者的每个独立技能。
for(skill = 0; skill < NSkills; skill++) { SchedulerResult = vector<float>::Zeros(NSkills); SchedulerResult[skill] = 1; StateSkill.AssignArray(GetPointer(State)); StateSkill.AddArray(SchedulerResult); if(IsStopped()) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } if(!Actor.feedForward(GetPointer(State), 1, false)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
根据前向验算的结果,我们对代理者动作进行取样。 我想特别关注动作取样。 因为这有助于尽可能地令代理者动作多样化,并有助于对环境的全面探索。
根据系统的初始状态、取样动作和从回放缓冲区中已知的后续价格走势的经验,我们计算账户的下一个状态,并填写相应的目标鉴别器数值模块。 然后我们转入处理下一个技能。
action = Actor.getSample(); account = GetNewState(Buffer[tr].States[i].account, action, prof_1l); shift = skill * AccountDescr; DiscriminatorResult[shift] = (account[0] - PrevBalance) / PrevBalance; DiscriminatorResult[shift + 1] = account[1] / PrevBalance; DiscriminatorResult[shift + 2] = (account[1] - PrevEquity) / PrevEquity; DiscriminatorResult[shift + 3] = account[2] / PrevBalance; DiscriminatorResult[shift + 4] = account[4] / PrevBalance; DiscriminatorResult[shift + 5] = account[5]; DiscriminatorResult[shift + 6] = account[6]; DiscriminatorResult[shift + 7] = account[7] / PrevBalance; DiscriminatorResult[shift + 8] = account[8] / PrevBalance; }
准备好目标数据后,我们执行鉴别器的向后验算。
if(!Result) { Result = new CBufferFloat(); if(!Result) { PrintFormat("Error of create buffer %d", GetLastError()); ExpertRemove(); break; } } Result.AssignArray(DiscriminatorResult); if(!Discriminator.backProp(Result)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); break; } break;
在下一个模块中,我们将查看训练过程下一阶段的迭代 — 代理者训练。 我要提醒您,在根据训练阶段划分操作流程之前,我们要准备初始数据。 这意味着截至到目前,我们已有一个生成的源数据缓冲区。 因此,我们执行鉴别器的前向验算,并提取运算结果,因为我们需要它们来形成内部奖励。
case 1: if(!Discriminator.feedForward(GetPointer(State))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); break; } Discriminator.getResults(DiscriminatorResult);
接下来,与上一阶段的训练一样,我们组织一个循环过程,按顺序列举当前状态的所有技能。
for(skill = 0; skill < NSkills; skill++) { SchedulerResult = vector<float>::Zeros(NSkills); SchedulerResult[skill] = 1; StateSkill.AssignArray(GetPointer(State)); StateSkill.AddArray(SchedulerResult); if(IsStopped()) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); break; } if(!Actor.feedForward(GetPointer(State), 1, false)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); break; }
在循环主体中,我们组织了代理者的正向验算、奖励向量的形成、和模型的反向验算操作。
reward = GetAgentReward(skill, DiscriminatorResult, Buffer[tr].States[i].account); Result.AssignArray(reward); StateSkill.AssignArray(Buffer[tr].States[i + 1].state); account = GetNewState(Buffer[tr].States[i].account, Actor.getAction(), prof_1l); shift = skill * AccountDescr; StateSkill.Add((account[0] - PrevBalance) / PrevBalance); StateSkill.Add(account[1] / PrevBalance); StateSkill.Add((account[1] - PrevEquity) / PrevEquity); StateSkill.Add(account[2] / PrevBalance); StateSkill.Add(account[4] / PrevBalance); StateSkill.Add(account[5]); StateSkill.Add(account[6]); StateSkill.Add(account[7] / PrevBalance); StateSkill.Add(account[8] / PrevBalance); if(!Actor.backProp(Result, DiscountFactor, GetPointer(StateSkill), 1, false)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); break; } } break;
正如您所看到的,技能的完整列举与以前所用随机状态的一般范式略有不一致。 在判定轨迹和初始状态方面,我们仍然钟情于取样。 完全列举一个独立状态的技能,我们希望将模型的注意力专门集中在技能识别指标上。 毕竟,技能的变化应该是模型改变行为策略的信号。
我们实现 DADS 算法的下一阶段是训练调度器。 此过程几乎完全重复 DIAYN 方法实现中的类似功能。 首先,通过调度器进行直接验算,我们得到技能的概率分布。 但与之前的实现不同,我们既不取样,也不进行贪婪式技能选择。 我们要明白,在真实条件下,划分一种策略或另一种策略没有明确的界限。 这些界限非常模糊。 所有的部份都充满了各种宽容和妥协。 在这种情况下,决定将全部概率分布转移到代理者进行决策。
case 2: if(!Scheduler.feedForward(GetPointer(State), 1, false)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); break; } Scheduler.getResults(SchedulerResult);
我想提请您注意这样一个事实,即在代理者训练期间,会把明确定义的技能 ID 传递给它。 因此,将完整概率分布传递给代理者以便进一步决策的实验变得更加有趣。 毕竟,这样的初始数据超出了训练集合,这令模型的行为不可预测。
State.AddArray(SchedulerResult); if(IsStopped()) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); break; } if(!Actor.feedForward(GetPointer(State), 1, false)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); break; } action = Actor.getAction();
基于前向验算的结果,我们以贪婪式选择代理者动作。 毕竟,我们的目标是训练规划者能在技能级别上管理决策政策。 这只在运用可预测技能时才有可能,这些技能的行为是由有意义、且一致的策略决定的。
接下来,我们检测账户描述指标的估算后续状态,并基于它们形成模型奖励向量。 您可能还记得,我们使用账户余额的相对变化作为模型的外部奖励。
account = GetNewState(Buffer[tr].States[i].account, action, prof_1l); SchedulerResult = SchedulerResult * (account[0] / PrevBalance - 1.0); Result.AssignArray(SchedulerResult); State.AssignArray(Buffer[tr].States[i + 1].state); State.Add((account[0] - PrevBalance) / PrevBalance); State.Add(account[1] / PrevBalance); State.Add((account[1] - PrevEquity) / PrevEquity); State.Add(account[2] / PrevBalance); State.Add(account[4] / PrevBalance); State.Add(account[5]); State.Add(account[6]); State.Add(account[7] / PrevBalance); State.Add(account[8] / PrevBalance); if(!Scheduler.backProp(Result, DiscountFactor, GetPointer(State), 1, false)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); break; } break;
在准备好模型奖励向量,以及后续系统状态后,我们向后验算调度器模型。
default: PrintFormat("Wrong phase %d", phase); break; } } } if(GetTickCount() - ticks > 500) { string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Scheduler", iter * 100.0 / (double)(Iterations), Scheduler.getRecentAverageError()); str += StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Discriminator", iter * 100.0 / (double)(Iterations), Discriminator.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } }
在模型训练循环系统主体中的操作结束时,我们会显示一条消息,通知用户模型训练进度。
其余的 EA 方法和函数,以及测试训练模型的 EA,均无变化。 本文中用到的所有程序的完整代码可以在附件中找到,并置于 MQL5\Experts\DADS 目录。
3. 测试
这些模型是依据 2023 年前 4 个月 EURUSD H1 的历史数据进行训练的。 指标采用默认参数。 如您所见,测试参数与上一篇文章相比没有变化。 这令我们能够比较两种技能训练方法的结果。
为了检查训练模型的性能,测试是在策略测试器中,依据 2023 年 5 月的时间段内进行的。 换言之,训练模型的测试是在训练样本之外,且时间间隔为训练样本 25% 进行的。
该模型展示了赚取盈利的能力,盈利因子为 1.75,恢复因子为 0.85。 盈利交易的占比为 52.64%。 与此同时,盈利交易的平均盈利为 57.37%,超过平均亏损交易(2.99 : -1.90)。
我们还注意到技能的使用几乎是统一的。 所有技能都参与了测试。
在测试已训练模型时,代理者不仅获得了一个贪婪式选择的技能,还得到了调度器生成的完整概率分布。 甚至,每个代理者动作都是依据最大预测奖励,以贪婪式策略选择的。 这种方式令调度器能够最大限度地控制模型操作,并消除代理者动作的随机性,这在取样期间是可能的。 您可能还记得,这就是我们训练调度器模型的方式。
值得注意的是,贪婪式技能选择的实验也展现出类似的结果。 贪婪式的技能选择令我们能够将盈利因子提高到 1.80。 盈利交易的占比增加了 0.91%,达到 53.55%。 在此,我们还观察到平均盈利交易增加到 3.08。
结束语
在本文中,我们讲解了另一种无监督技能训练方法,即动态感知技能探索(DADS)。 利用这种方法可以训练各种可以有效探索环境的技能。 与此同时,按所提议的方法训练的技能具有相当的可预测行为。 这令调度器训练更容易,并总体上提高了训练模型的稳定性。
我们还利用 MQL5 实现了所研究的算法,并测试了构建的模型。 该测试产生了令人鼓舞的结果,展现了该模型能够在训练集之外产生盈利。
不过,本文中讲解和用到的所有程序仅出于演示这些方式的工作,尚未准备好在实际交易中使用。
参考文献列表
本文中用到的程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | 智能交易系统 | 样本收集 EA |
2 | Study.mql5 | 智能交易系统 | 模型训练 EA |
3 | Test.mq5 | 智能交易系统 | 模型测试 EA |
4 | Trajectory.mqh | 类库 | 系统状态定义结构 |
5 | FQF.mqh | 类库 | 完全参数化模型的工作安排类库 |
6 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
7 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/12750