
神经网络变得简单(第 60 部分):在线决策转换器(ODT)
概述
最近两篇文章专门介绍了决策转换器方法,其在期望奖励的自回归模型境况下针对动作序列进行建模。您也许还记得,根据这两篇文章的实际测试成果,测试期开始时,看到所训练模型的盈利能力有了相当不错的提升。随着深入,模型的性能下降,并观察到许多无盈利业务,从而导致亏损。得到的亏损金额可能超过以前赚到的利润。
模型的定期额外训练可能会有所帮助。不过,这种方式令模型的操作变得异常复杂。故此,研究如何选择在线模型训练是相当合理的。在此,我们面临着许多必须解决的问题。
《在线决策变换器》一文(2022 年 2 月)中阐述了实施决策转换器在线训练的选项之一。值得注意的是,所提议方法使用了经典 DT 的初级离线训练。在线训练应用在模型的后续优调。作者文章中阐述的实验结果表明,依据 D4RL 测试样本,ODT 的绝对性能能够与领先者竞争。此外,在优调过程中,它显示出更显著的提升。
我们在解决问题的背景下看看所提议的方法。
1. ODT 算法
研究在线决策转换器算法之前,我建议简要回顾一下经典的决策转换器。DT 将 τ 轨迹作为若干个输入令牌序列处理:在途回报(RTG)、状态和动作。特别是,初始 RTG 值等于整个轨迹的回报。在 t 临时步骤中,DT 使用 K 上次步骤的令牌来生成 At 的动作。在本例中,K 是一个超参数,指定转换器的上下文长度。在操作期间,上下文长度也许比训练期要短。
DT 学习 π(At|St, RTGt) 判定政策,其中 St 是从 t-K+1 至 t 的最后状态 K 序列。类似地,RTGt 将最后一个在途回报 K 拟人化。这是一个 K 阶的自动回归模型。智能体政策经训练后,可使用标准 MSE(均方误差)损失函数来预测动作。
在操作过程中,我们示意 RTG 初始化,并初始状态 S0 的所需性能。然后 DT 生成 A0 动作。生成 At 动作后,我们执行它,并观察下一个状态 St+1 接收奖励 rt。这会产生 RTGt+1。
如前,DT 基于包括 A0、S0、S1 和 RTG0、RTG1 在内的轨迹生成 A1 动作。重复该过程,直到该世代完成。
由于训练集数据有限,仅依据离线数据集上训练的政策通常是次优的。离线轨迹的回报率也许较低,并且仅覆盖状态和动作空间的有限部分。提高性能的一个自然策略是进一步训练 RL 智能体与环境的在线交互。但是,标准的决策转换器方法不足以进行在线训练。
在线决策转换器算法对决策转换器进行了关键修改,从而确保高效的在线训练。第一步是泛化概率训练目标。在这种境况下,目标是训练一个随机政策,取最大化重复轨迹的概率。
在线 RL 算法的主要属性是它能够平衡探索和开发。即使采用随机政策,传统的 DT 公式也并未考虑探索。为了解决这个问题,ODT 方法的作者定义通过政策的熵来研究,这取决于轨迹中数据的分布。在离线预训练期间这种分布是静态的,但在在线设置期间则是动态的,因为它依赖于环境交互期间获得的新数据。
与许多现有的最大熵 RL 算法类似,例如软性扮演者-评论者,ODT 方法的作者明确定义了政策熵的下限,来鼓励探索。
ODT 损失函数与 SAC 和其它经典 RL 方法的区别在于,在 ODT 中,损失函数是负对数似然,而非贴现回报。基本上,我们仅专注使用动作序列形态的训练,替代明确地最大化回报。在离线和在线训练中,主观函数都会自动适配相应的扮演者政策。在离线训练期间,交叉熵控制分布的发散程度,而于在线训练期间,交叉熵驱动探索政策。
与经典最大熵 RL 方法的另一个重要区别是,在 ODT 中,政策熵是在序列级别定义的,而不是在过渡级别。虽然 SAC 在所有时间步骤上都对政策熵施加了 β 下限,但 ODT 限制了在 K 个连续时间步长骤求熵的均值。因此,约束条件仅要求在 K 个时间步骤序列上的熵均值高于指定的 β 值。因此,任何满足过渡级别约束的政策也满足序列级别约束。因此,当 K > 1 时,可行的政策空间更大。当 K = 1 时,序列级别约束简化为类似于 SAC 的过渡级别约束。
在模型训练期间,回放缓冲区记录以前的经验,并定期更新。对于大多数现有的 RL 算法,经验渲染缓冲区由转换组成。在一个世代内的每个在线交互阶段之后,智能体的政策和 Q-函数将据梯度下降进行更新。然后,执行该政策来收集新的过渡,并将其添加到经验回放缓冲区。在 ODT 的情况下,经验回放缓冲区由轨迹组成,而非过渡。经过初步的离线训练后,我们从离线数据集中取得具有最大结果的轨迹初始化经验回放缓冲区。每次我们与环境交互时,我们都会依据当前政策完整执行世代。然后,我们取按照 FIFO 顺序收集的轨迹更新经验回放缓冲区。接下来,我们更新智能体政策,并执行新世代。利用平均行动评估政策,通常会导致较高的奖励,但这在使用随机行动进行在线研究时很实用,因为它会产生更加多样化的轨迹和行为形态。
此外,ODT 算法需要一个初始 RTG 形式的超参数,以便收集额外的在线数据。多种作用验明,从经验上讲,离线 DT 的实际评估回报与初始 RTG 具有很强的相关性,并且推断出的 RTG 值经常超出离线数据集中观察到的最大回报。ODT 的作者发现,最好取现有智能系统的结果按一个小的固定比例来设置这个超参数。该方法的作者在他们的工作中采用的是 2 倍缩放。原始论文提供的实验结果具有更大数值,以及在训练过程中发生变化的结果(例如,离线和在线数据集中最佳评估回报的分位数)。但在实践中,它们不如固定比例的 RTG 有效。
与 DT 一样,ODT 算法使用两步采样程序,确保对回放缓冲区中 K 长度的子轨迹采样时保持一致性。首先,我们按与其长度成正比的概率对一条轨迹进行采样。然后以相等的概率选择 K 长度的子轨迹。
我们将在本文的下一节中再把握该方法的实际实现。
2. 利用 MQL5 实现
在熟悉了该方法的理论层面之后,我们转入其实际实现。本节将讲述我们自己对拟议方式的实现看法,并从前几篇文章的开发作为补充。在实施中,ODT 算法包括两阶段模型训练:
- 初步离线训练。
- 与环境在线交互期间对模型进行优调。
出于本文的目的,我们将取用上一篇文章中的预训练模型。故因早前已运作过训练,我们跳过第一阶段的离线训练,立即转入模型训练的第二部分。
这里还应该注意的是,在上一篇文章研究 DoC 方法时,我们构建并主导了两个模型的离线训练:
- RTG 生成;
- 扮演者政策。
利用 RTG 模型生成与原版 ODT 算法有背离,其提议这牛初始 RTG 使用智能系统评估比例,随后根据获得的实际结果调整目标。
此外,因使用先前已训练模型,故不允许我们更改模型的架构。但我们来看看所用模型的架构如何与 ODT 算法相对应。
该方法的作者提议使用随机扮演者政策。这是我们在之前文章中所用的模型。
ODT 提议使用轨迹经验回放缓冲区,替代单独轨迹。这正是我们操控的缓冲区。
在训练模型时,我们没有使用损失函数的熵分量来鼓励环境探索。在该阶段,我们不会添加它,并承受可能的风险。我们期望随机扮演者政策和 RTG 生成模型在与环境的在线交互过程中提供充分的探索。
从我的实现中排除的另一点,涉及经验回放缓冲区。在离线训练之后,该方法的作者提议选择一些最有盈利性的轨迹,这些轨迹将于在线训练的第一阶段用到。我们最初限制了经验回放缓冲区中的轨迹数量。在转向在线训练时,我们将使用整个现有的经验复现缓冲区,我们将在与环境交互的过程中添加新的轨迹。同时,在添加新轨迹时,我们不会立即删除最旧的轨迹。在完成验算后将数据保存到文件时,我们将使用先前创建的方法限制缓冲区大小。
因此,考虑到可能的风险,我们可以很容易地使用上一篇文章中训练的模型。然后,我们将尝试通过使用 ODT 方式优调模型在线训练过程,从而提高它们的效率。
在此,我们有必要解决一些建设性问题。交易过程本质上是有条件的,且无止境。我说“有条件的”,因为出于多种原因,它仍然是有限的。但在可预见的未来发生这种事件的概率性非常小,以至于我们认为它是无限的。由此,我们并未遵照该方法作者的建议,在世代结束后运作附加训练过程,而是按一定的频率。
在此,我想提醒您,在我们的 DT 实现中,只把最后一根柱线的数据提供到模型输入端。整个历史数据上下文存储在嵌入图层的结果缓冲区当中。这种方式令我们能够减少再次处理冗余数据的资源消耗。但这成为在线训练道路上的“绊脚石”之一。事实是,嵌入缓冲区中的数据是按严格的历史顺序存储的。模型的定期附加训练过程,会导致来自其它轨迹或相同轨迹,但来自不同历史段的历史数据重新填充缓冲区。模型进行附加训练后,继续与环境交互时,这会令数据失真。
为了解决此问题,实际上有若干种选项。在运行过程中,它们具有各自不同的实现复杂性和资源消耗。初看,最简单的事情是创建一个缓冲区的副本,并在继续与环境交互之前,将缓冲区返回到开始训练之前的状态。然而,在仔细查验该过程后,我们明白,在主模型一侧,仅用模型的顶级类运作,无需访问神经层的独立缓冲区。在这种境况下,从模型的一个缓冲区把数据复制回模型的简单过程,会导致许多设计变化。这令该方法的实现变得非常复杂。
在完成额外的训练之后,我们可以将整组历史数据重复传输到模型之中,无需对模型进行构造性的更改。但这会导致大量重复的模型前向验算操作。此种操作的数量随着上下文大小的增加而增长。这令该方法效率低下。数据再处理所消耗的时间和计算资源,可能会超过将嵌入历史记录存储在神经层缓冲区中所节省的成本。
该问题的另一种解决方案是使用重复模型。一个是与环境互动所必需的。其二是为了附加训练。这种方式在内存资源方面更加昂贵,但彻底解决了嵌入层缓冲区中的数据问题。但模型之间的数据交换问题又浮现了。毕竟,经过附加训练后,与环境交互的模型应使用更新的智能体政策。RTG 生成模型也是如此。在此,我们可以回忆起软性扮演者-评论者方法,及其对目标模型的软更新。也许看似很奇怪,但这种机制允许我们在模型之间传输更新的权重比率,而无需更改剩余的缓冲区,包括嵌入层结果的缓冲区。
若要使用这种方式,我们必须在嵌入层中添加一个权重交换方法,该方法以前在 SAC 实现中未曾用过。
在此我们应该说,在添加方法时,我们仅直接对 CNeuronEmbeddingOCL 类进行添加,因为其运行所需的所有 API 早前已由我们制定,并以 CNeuronBaseOCL 神经层基类的虚拟方法的形式实现。还应该注意的是,如果不进行指定的修改,我们的模型操作不会产生错误。毕竟,默认情况下将调用父类的方法。但在这种情况下,如此操作是不完整,也不正确。
为了维护一致性,及正确覆盖虚拟方法,我们声明了一个方法,来保存参数。在该方法主体中,我们立即调用父类的类似方法。
bool CNeuronEmbeddingOCL::WeightsUpdate(CNeuronBaseOCL *source, float tau) { if(!CNeuronBaseOCL::WeightsUpdate(source, tau)) return false;
正如我们不止一次说过的那样,这种调用父类的方式允许我们在一个动作中实现所有必要的控制,没必要重复,并对继承对象执行必要的操作。
在父类方法的操作成功结束后,我们转入在嵌入类中直接声明对象的工作。但为了获得供体类的相似对象访问权,我们应该覆盖结果对象的类型。
//---
CNeuronEmbeddingOCL *temp = source;
接下来,我们需要传输 WeightsEmbedding 缓冲区的参数。但在继续操作之前,我们将比较当前对象和供体对象的缓冲区大小。
if(WeightsEmbedding.Total() != temp.WeightsEmbedding.Total()) return false;
然后,我们必须将内容从一个缓冲区传输到另一个缓冲区。但我们要记住,所有缓冲区操作都是在 OpenCL 关联和环境端执行的。因此,我们将在关联环境端运作数据传输。我故意使用“数据传输”短语,而非“复制”。我保留了“软性复制”的可能性,就像目标模型的 SAC 算法所规定的那样。OpenCL 程序内核是早前就已创建的。现在我们只需要安排调用它们。
我们根据权重比缓冲区的大小来定义内核任务空间。
uint global_work_offset[1] = {0}; uint global_work_size[1] = {WeightsEmbedding.Total()};
接下来,取决于更新算法所采用参数转进算法的分支。分支是必要的,因为如果我们使用 Adam 方法,我们将需要更多的缓冲区和超参数。这导致要用到不同的内核。
首先,我们创建 Adam 方法分支。为了调用它,应满足两个条件:
- 在创建对象时指定相应的更新参数方法,因为创建对象的相应数据缓冲区取决于此;
- 更新比率应与 1 不同,否则无论使用何种参数更新方法,都需要数据的完整副本。
if(tau != 1.0f && optimization == ADAM) { if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_target, WeightsEmbedding.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_source, temp.WeightsEmbedding.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_matrix_m, FirstMomentumEmbed.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_matrix_v, SecondMomentumEmbed.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_SoftUpdateAdam, def_k_sua_tau, (float)tau)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_SoftUpdateAdam, def_k_sua_b1, (float)b1)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_SoftUpdateAdam, def_k_sua_b2, (float)b2)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.Execute(def_k_SoftUpdateAdam, 1, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } }
然后,我们将 SoftUpdateAdam 内核发送到执行队列。
我们在算法的第二个分支中执行类似的操作,但针对的是 SoftUpdate 内核。
else { if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_target, WeightsEmbedding.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_source, temp.WeightsEmbedding.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_SoftUpdate, def_k_su_tau, (float)tau)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.Execute(def_k_SoftUpdate, 1, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } } //--- return true; }
构造性的问题已经解决,我们能转入在线训练方法的实际实现。我们在 “...\DoC\OnlineStudy.mq5” EA 中安排与环境交互的过程,并同时对模型进行附加训练。该 EA 是前几篇文章中所讨论 EA 的一种孪生,是为模型训练和直接离线训练收集数据。它包含与环境交互所需的所有外部参数,特别是指标参数。但同时,我们添加了参数来指示在线训练迭代的频率和次数。默认 EA 包含主观数据。我指定了 120 根蜡烛的训练频率,在 H1 时间帧内大约对应于 1 周(5 天 * 24 小时)。在优化期间,您可以选择更适合模型的数值。
//+------------------------------------------------------------------+ //| Input parameters | //+------------------------------------------------------------------+ input ENUM_TIMEFRAMES TimeFrame = PERIOD_H1; //--- input group "---- RSI ----" input int RSIPeriod = 14; //Period input ENUM_APPLIED_PRICE RSIPrice = PRICE_CLOSE; //Applied price //--- input group "---- CCI ----" input int CCIPeriod = 14; //Period input ENUM_APPLIED_PRICE CCIPrice = PRICE_TYPICAL; //Applied price //--- input group "---- ATR ----" input int ATRPeriod = 14; //Period //--- input group "---- MACD ----" input int FastPeriod = 12; //Fast input int SlowPeriod = 26; //Slow input int SignalPeriod= 9; //Signal input ENUM_APPLIED_PRICE MACDPrice = PRICE_CLOSE; //Applied price //--- input int StudyIters = 5; //Iterations to Study input int StudyPeriod = 120; //Bars between Studies
在 EA 初始化方法中,我们首先加载之前创建的经验回放缓冲区。我们在 “Study.mql5” 训练 EA 中针对各种离线训练方法执行类似的操作。只是现在,如果数据加载失败,我们不会终止 EA。不像离线模式,我们只允许依据与环境交互时收集的新数据训练模型。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { LoadTotalBase();
接下来,我们将为与环境的交互准备指标,就像我们早前在 EA 中所做的那样。
if(!Symb.Name(_Symbol)) return INIT_FAILED; Symb.Refresh(); //--- if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice)) return INIT_FAILED; //--- if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice)) return INIT_FAILED; //--- if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod)) return INIT_FAILED; //--- if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice)) return INIT_FAILED; if(!RSI.BufferResize(NBarInPattern) || !CCI.BufferResize(NBarInPattern) || !ATR.BufferResize(NBarInPattern) || !MACD.BufferResize(NBarInPattern)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); return INIT_FAILED; } //--- if(!Trade.SetTypeFillingBySymbol(Symb.Name())) return INIT_FAILED;
我们加载模型,并根据源数据层的大小和结果检查它们的合规性。如有必要,我们会据预定义的架构创建新模型。这有点超出了模型附加训练的范围。但我们给用户留下了“从头开始”运作在线训练的机会。
//--- load models float temp; if(!Agent.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) || !RTG.Load(FileName + "RTG.nnw", dtStudied, true) || !AgentStudy.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) || !RTGStudy.Load(FileName + "RTG.nnw", dtStudied, true)) { PrintFormat("Can't load pretrained models"); CArrayObj *agent = new CArrayObj(); CArrayObj *rtg = new CArrayObj(); if(!CreateDescriptions(agent, rtg)) { delete agent; delete rtg; PrintFormat("Can't create description of models"); return INIT_FAILED; } if(!Agent.Create(agent) || !RTG.Create(rtg) || !AgentStudy.Create(agent) || !RTGStudy.Create(rtg)) { delete agent; delete rtg; PrintFormat("Can't create models"); return INIT_FAILED; } delete agent; delete rtg; //--- } //--- Agent.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; } AgentResult = vector<float>::Zeros(NActions); //--- Agent.GetLayerOutput(0, Result); if(Result.Total() != (NRewards + BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions)) { PrintFormat("Input size of Actor doesn't match state description (%d <> %d)", Result.Total(), (NRewards + BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions)); return INIT_FAILED; } Agent.Clear(); RTG.Clear();
请注意,我们正在加载(或初始化)每个模型的两个副本。一个是与环境互动所必需的。第二个用于训练。经训练的模型会收到 Study 后缀。
接下来,我们初始化全局变量,并终止该方法。
PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE); PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY); //--- return(INIT_SUCCEEDED); }
在 EA 逆初始化方法中,我们保存经训练的模型,及积累的经验再现缓冲区。
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- AgentStudy.Save(FileName + "Act.nnw", 0, 0, 0, TimeCurrent(), true); RTGStudy.Save(FileName + "RTG.nnw", TimeCurrent(), true); delete Result; int total = ArraySize(Buffer); printf("Saving %d", MathMin(total + 1, MaxReplayBuffer)); SaveTotalBase(); Print("Saved"); }
请注意,我们保存过经训练的模型,因为它们的缓冲区包含模型后续训练和操作所需的所有信息。
与环境交互的过程安排在 OnTick 跳价处理方法当中。在方法伊始,我们检查是否发生了新柱线开盘事件,并在必要时更新指标参数。我们还下载价格走势数据。
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- if(!IsNewBar()) return; //--- int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), NBarInPattern, Rates); if(!ArraySetAsSeries(Rates, true)) return; //--- RSI.Refresh(); CCI.Refresh(); ATR.Refresh(); MACD.Refresh(); Symb.Refresh(); Symb.RefreshRates();
准备从终端接收到的数据,作为输入数据传输到与环境交互的模型。
//--- History data float atr = 0; for(int b = 0; b < (int)NBarInPattern; b++) { float open = (float)Rates[b].open; float rsi = (float)RSI.Main(b); float cci = (float)CCI.Main(b); atr = (float)ATR.Main(b); float macd = (float)MACD.Main(b); float sign = (float)MACD.Signal(b); if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE) continue; //--- int shift = b * BarDescr; sState.state[shift] = (float)(Rates[b].close - open); sState.state[shift + 1] = (float)(Rates[b].high - open); sState.state[shift + 2] = (float)(Rates[b].low - open); sState.state[shift + 3] = (float)(Rates[b].tick_volume / 1000.0f); sState.state[shift + 4] = rsi; sState.state[shift + 5] = cci; sState.state[shift + 6] = atr; sState.state[shift + 7] = macd; sState.state[shift + 8] = sign; } bState.AssignArray(sState.state);
我们取有关帐户状态信息来补充数据缓冲区。
//--- Account description sState.account[0] = (float)AccountInfoDouble(ACCOUNT_BALANCE); sState.account[1] = (float)AccountInfoDouble(ACCOUNT_EQUITY); //--- double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0; double position_discount = 0; double multiplyer = 1.0 / (60.0 * 60.0 * 10.0); int total = PositionsTotal(); datetime current = TimeCurrent(); for(int i = 0; i < total; i++) { if(PositionGetSymbol(i) != Symb.Name()) continue; double profit = PositionGetDouble(POSITION_PROFIT); switch((int)PositionGetInteger(POSITION_TYPE)) { case POSITION_TYPE_BUY: buy_value += PositionGetDouble(POSITION_VOLUME); buy_profit += profit; break; case POSITION_TYPE_SELL: sell_value += PositionGetDouble(POSITION_VOLUME); sell_profit += profit; break; } position_discount += profit - (current - PositionGetInteger(POSITION_TIME)) * multiplyer * MathAbs(profit); } sState.account[2] = (float)buy_value; sState.account[3] = (float)sell_value; sState.account[4] = (float)buy_profit; sState.account[5] = (float)sell_profit; sState.account[6] = (float)position_discount; sState.account[7] = (float)Rates[0].time; //--- bState.Add((float)((sState.account[0] - PrevBalance) / PrevBalance)); bState.Add((float)(sState.account[1] / PrevBalance)); bState.Add((float)((sState.account[1] - PrevEquity) / PrevEquity)); bState.Add(sState.account[2]); bState.Add(sState.account[3]); bState.Add((float)(sState.account[4] / PrevBalance)); bState.Add((float)(sState.account[5] / PrevBalance)); bState.Add((float)(sState.account[6] / PrevBalance));
接下来,创建一个时间戳。
//--- Time label double x = (double)Rates[0].time / (double)(D'2024.01.01' - D'2023.01.01'); bState.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_MN1); bState.Add((float)MathCos(2.0 * M_PI * x)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_W1); bState.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_D1); bState.Add((float)MathSin(2.0 * M_PI * x));
添加将我们引导至当前状态的智能体最新操作的向量。
//--- Prev action
bState.AddArray(AgentResult);
收集的数据足以执行 RTG 生成模型的前向验算。
//--- Return to go if(!RTG.feedForward(GetPointer(bState))) return;
事实上,我们的初始数据向量只缺少这些数据来预测智能体在当前时间段内的最优动作。因此,在第一个模型前向验算成功后,我们将获得的结果添加到源数据缓冲区之中,并调用扮演者的前向验算方法。请务必检查操作结果。
RTG.getResults(Result); bState.AddArray(Result); //--- if(!Agent.feedForward(GetPointer(bState), 1, false, (CBufferFloat*)NULL)) return;
模型的前向验算执行成功后,我们将破译它们的工作成果,并在环境中执行选定的动作。这个过程与前面讨论过的与环境交互模型中的算法完全一致。
//--- PrevBalance = sState.account[0]; PrevEquity = sState.account[1]; //--- vector<float> temp; Agent.getResults(temp); //--- double min_lot = Symb.LotsMin(); double step_lot = Symb.LotsStep(); double stops = MathMax(Symb.StopsLevel(), 1) * Symb.Point(); if(temp[0] >= temp[3]) { temp[0] -= temp[3]; temp[3] = 0; } else { temp[3] -= temp[0]; temp[0] = 0; } float delta = MathAbs(AgentResult - temp).Sum(); AgentResult = temp; //--- buy control if(temp[0] < min_lot || (temp[1] * MaxTP * Symb.Point()) <= stops || (temp[2] * MaxSL * Symb.Point()) <= stops) { if(buy_value > 0) CloseByDirection(POSITION_TYPE_BUY); } else { double buy_lot = min_lot + MathRound((double)(temp[0] - min_lot) / step_lot) * step_lot; double buy_tp = Symb.NormalizePrice(Symb.Ask() + temp[1] * MaxTP * Symb.Point()); double buy_sl = Symb.NormalizePrice(Symb.Ask() - temp[2] * MaxSL * Symb.Point()); if(buy_value > 0) TrailPosition(POSITION_TYPE_BUY, buy_sl, buy_tp); if(buy_value != buy_lot) { if(buy_value > buy_lot) ClosePartial(POSITION_TYPE_BUY, buy_value - buy_lot); else Trade.Buy(buy_lot - buy_value, Symb.Name(), Symb.Ask(), buy_sl, buy_tp); } } //--- sell control if(temp[3] < min_lot || (temp[4] * MaxTP * Symb.Point()) <= stops || (temp[5] * MaxSL * Symb.Point()) <= stops) { if(sell_value > 0) CloseByDirection(POSITION_TYPE_SELL); } else { double sell_lot = min_lot + MathRound((double)(temp[3] - min_lot) / step_lot) * step_lot;; double sell_tp = Symb.NormalizePrice(Symb.Bid() - temp[4] * MaxTP * Symb.Point()); double sell_sl = Symb.NormalizePrice(Symb.Bid() + temp[5] * MaxSL * Symb.Point()); 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); } }
评估过渡到当前状态的环境奖励。传输所有收集到的信息,以便形成当前轨迹。
//--- int shift = BarDescr * (NBarInPattern - 1); sState.rewards[0] = bState[shift]; sState.rewards[1] = bState[shift + 1] - 1.0f; if((buy_value + sell_value) == 0) sState.rewards[2] -= (float)(atr / PrevBalance); else sState.rewards[2] = 0; for(ulong i = 0; i < NActions; i++) sState.action[i] = AgentResult[i]; if(!Base.Add(sState)) ExpertRemove();
与环境交互的过程至此完成。但在退出该方法之前,我们将检查是否需要开始对模型追加训练的过程。我大概用了最简单的控制来测试方法的效率。我简单地检查金融产品在训练期的分析时间帧内整体历史记录大小的多重性。在日常工作中,建议使用更深思熟虑的方式,将附加训练转移到闭市或金融产品波动性降低的时期。此外,延迟更新模型参数也许很实用,直到所有持仓都被平仓。一般来说,对于真实模型的使用,我建议采用一种更平衡和有意义的方式来选择频率和时间,以便针对模型追加训练。
//--- if((Bars(_Symbol, TimeFrame) % StudyPeriod) == 0) Train(); }
接下来,我们将注意力转向训练模型的 Train方法。在此应该注的是,运作追加训练应参考在当前与环境互动过程中获得的经验。在跳价处理方法中,我们将从环境中接收到的所有信息收集到单独的轨迹当中。不过,该轨迹不会添加到经验回放缓冲区之中。以前,我们只在世代结束后才运作这样的操作。但在定期更新参数的情况下,这种方式是不可接受的。毕竟,当智能体的政策仅依据以前经验的固定轨迹进行训练时,它带我们更接近离线训练。因此,在开始训练之前,我们会把收集到的数据添加到经验回放缓冲区之中。
为了防止记录太短暂、且信息量不足的轨迹,我们将限定所保存轨迹的最小规模。在给出的示例中,我限定了模型参数更新期间轨迹的最小规模。
如果累积轨迹的规模满足最低要求,则我们将其添加到经验回放缓冲区当中,并重新计算累积奖励额度。
于此要注意的是,我们只重新计算传输到经验回放缓冲区的轨迹副本的累积奖励量。在积累有关当前轨迹信息的初始缓冲区中,奖励应保持不计算。随着后续与环境的相互作用,轨迹将得到补充。因此,随着已更新轨迹的进一步追加,重复计算累积奖励将导致数据翻倍。为了防止这种情况,我们始终将未重新计算的奖励保留在轨迹累积缓冲区之中。
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { int total_tr = ArraySize(Buffer); if(Base.Total >= StudyPeriod) if(ArrayResize(Buffer, total_tr + 1) == (total_tr + 1)) { Buffer[total_tr] = Base; Buffer[total_tr].CumRevards(); total_tr++; }
接下来,我们应当记住,轨迹累积缓冲区的大小受 Buffer_Size 常数的限制。为了防止超出数组限制错误,确保轨迹累积缓冲区中有足够的可用单元来记录步骤,直到下一次保存轨迹。如有必要,删除一些太老旧的步骤。
请注意,我们正在删除主轨迹累积缓冲区中的数据。同时,这些信息都会存储到我们已保存在经验回放缓冲区里的轨迹副本之中。
当指定模型常量和参数时,我们要确保轨迹缓冲区的大小能够至少多保存一个模型附加训练的历史记录。
int clear = Base.Total + StudyPeriod - Buffer_Size; if(clear > 0) Base.ClearFirstN(clear);
之后我又加了一个额外的控制,这看似没有必要。我检查经验回放缓冲区中的过短轨迹,如果找到,则删除它们。初看,在将轨迹添加到经验复现缓冲区之前由于存在类似的控制,故不太可能存在这样的轨迹。但我仍然承认,轨迹读取和写入文件时,可能会出现一些失败。我们执行该检查,可消除后续错误。
//--- int count = 0; for(int i = 0; i < (total_tr + count); i++) { if(Buffer[i + count].Total < StudyPeriod) { count++; i--; continue; } if(count > 0) Buffer[i] = Buffer[i + count]; } if(count > 0) { ArrayResize(Buffer, total_tr - count); total_tr = ArraySize(Buffer); }
接下来,我们安排一个模型训练循环系统。该过程在很大程度上重复了上一篇文章中的过程。规划的外部循环根据 EA 外部参数中指定的模型训练迭代次数。
在循环的主体中,我们随机选择一条轨迹和该轨迹的一个元素,我们将自其开始模型训练的下一次迭代。
uint ticks = GetTickCount(); //--- bool StopFlag = false; for(int iter = 0; (iter < StudyIters && !IsStopped() && !StopFlag); iter ++) { int tr = (int)((MathRand() / 32767.0) * (total_tr - 1)); int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * MathMax(Buffer[tr].Total - 2 * HistoryBars, MathMin(Buffer[tr].Total, 20))); if(i < 0) { iter--; continue; }
然后,我们将清除模型嵌入缓冲区,和先前扮演者动作向量。
vector<float> Actions = vector<float>::Zeros(NActions); AgentStudy.Clear(); RTGStudy.Clear();
在该阶段,我们的准备工作已经完成,能够开始训练模型了。安排嵌套学习循环。
在循环的主体中,我们重复准备源数据缓冲区的过程,类似于上面在跳价处理方法中讲述的过程。将数据写入缓冲区的顺序完全重复。然而,我们之前从终端请求数据,但现在我们从经验回放缓冲区获取数据。
for(int state = i; state < MathMin(Buffer[tr].Total - 2, int(i + HistoryBars * 1.5)); state++) { //--- History data bState.AssignArray(Buffer[tr].States[state].state); //--- Account description float prevBalance = (state == 0 ? Buffer[tr].States[state].account[0] : Buffer[tr].States[state - 1].account[0]); float prevEquity = (state == 0 ? Buffer[tr].States[state].account[1] : Buffer[tr].States[state - 1].account[1]); bState.Add((Buffer[tr].States[state].account[0] - prevBalance) / prevBalance); bState.Add(Buffer[tr].States[state].account[1] / prevBalance); bState.Add((Buffer[tr].States[state].account[1] - prevEquity) / prevEquity); bState.Add(Buffer[tr].States[state].account[2]); bState.Add(Buffer[tr].States[state].account[3]); bState.Add(Buffer[tr].States[state].account[4] / prevBalance); bState.Add(Buffer[tr].States[state].account[5] / prevBalance); bState.Add(Buffer[tr].States[state].account[6] / prevBalance); //--- Time label double x = (double)Buffer[tr].States[state].account[7] / (double)(D'2024.01.01' - D'2023.01.01'); bState.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_MN1); bState.Add((float)MathCos(2.0 * M_PI * x)); x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_W1); bState.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_D1); bState.Add((float)MathSin(2.0 * M_PI * x)); //--- Prev action bState.AddArray(Actions);
在收集初始数据的第一部分后,我们执行 RTG 生成模型的前向验算。然后我们立即运作一次直接验算,以便尽量减少实际收到的奖励误差。故此,我们构建了一个自回归模型,基于先前的状态和行为轨迹预测可能的奖励。
//--- Return to go if(!RTGStudy.feedForward(GetPointer(bState))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; } Result.AssignArray(Buffer[tr].States[state + 1].rewards); if(!RTGStudy.backProp(Result)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
出于训练智能体政策的目的,我们指示在源数据缓冲区接收的实际奖励,替代预测 RTG,并执行直接验算。
//--- Policy Feed Forward bState.AddArray(Buffer[tr].States[state + 1].rewards); if(!AgentStudy.feedForward(GetPointer(bState), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
智能体的政策经过训练,可以最大程度地降低预测动作和实际运作动作之间的误差,从而获得奖励。
//--- Policy study Actions.Assign(Buffer[tr].States[state].action); vector<float> result; AgentStudy.getResults(result); Result.AssignArray(CAGrad(Actions - result) + result); if(!AgentStudy.backProp(Result, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
这令我们能够建立一个自回归模型,用于选择最优动作,以便据先前访问的状态和智能体已完成动作的境况中获得所需的奖励。
模型训练迭代成功完成后,我们会通知用户训练操作的进度,并转入模型训练循环的下一次迭代。
模型训练过程的嵌套循环系统完成所有迭代后,我们清除图上的注释字段,并将参数从已训练模型转移到环境交互模型。在上面的示例中,我完全复制了权重系数数据。因此,我模拟用一种模型进行训练和操作。不过,我也允许据不同的数据复制比率进行实验。1
Comment(""); //--- Agent.WeightsUpdate(GetPointer(AgentStudy), 1.0f); RTG.WeightsUpdate(GetPointer(RTGStudy), 1.0f); //--- }
转换器在线训练 EA 算法解决方案至此完结。在附件中找到完整的 EA 代码,及其所有方法。
请注意,“...\DoC\OnlineStudy.mq5” EA 位于 “DoC” 子目录中,其中包含上一篇文章中的 EA。我并未将它分成一个单独的子目录,因为从功能上讲,它只是针对自上一篇文章中的离线 EA 训练过的模型运作附加的训练。以这种方式,我们维持了模型训练文件集的完整性。
您还可以在附件中找到当前和以前文章中用到的所有程序。
3. 测试
我们研究了在线决策转换器方法的理论层面,并针对所提议方法构建了我们自己的解释。下一阶段是测试已完成的工作。事实上,我们针对来自上一篇文章中的模型进行了优调。出于这些目的,我们在策略测试器中,依据训练数据历史记录运作新 EA 的单次运行循环。
在上一篇文章中,我们依据 2023 年前 7 个月的历史数据运作了模型离线训练。在同一历史区间,我们对模型进行了优调。
通过对模型进行优调,ODT 提高了模型的整体盈利能力。依据 2023 年 8 月的测试样本,该模型能够获得约 10% 的盈利。收益率图表并不完美,但一些趋势已然可见。
上面给出了测试训练模型的结果。在测试期间,总共进行了 271 笔业务。其中 128 笔获利了结,占比超过 47%。正如我们所见,盈利交易的份额略低于亏损。但最大盈利交易比最大亏损高 26%。平均盈利交易比平均亏损交易高出 20% 以上。所有这些共同将模型的盈利因子提高到 1.10。
结束语
在本文中,我们继续研究提高决策转换器方法效率的选项,并掌握在线决策转换器(ODT)训练模式下模型优调的算法。这种方法可提高模型离线训练的效率,并允许智能体适应不断变化的环境,从而通过与环境的交互来改进其政策。
在本文的实践部分,我们利用 MQL5 实现了该方法,并对上一篇文章中的模型进行了在线训练。此处值得注意的是,模型的优化是经由所研究的 ODT 方法获得的。在线训练期间,我们用到了上一篇文章中离线训练的模型。我们尚对模型架构设计实现任何更改。仅提供了附加的在线训练。这令提高模型效率成为可能,其本身就验证在线决策转换器方法的效率。
我想再次提醒您,本文中讲述的所有程序仅用于演示该技术,尚未准备好在实际交易中运用。
链接
- 决策转换器:通过序列建模进行强化学习
- 在线决策转换器
- 控制的二分法:将你能控制的东西和你不能控制的东西分开
- 神经网络变得简单(第 34 部分):完全参数化的分位数函数
- 神经网络变得简单(第 58 部分):决策转换器(DT)
- 神经网络变得简单(第 59 部分):控制二分法(DoC)
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | 智能交易系统 | 样本收集 EA |
2 | Study.mq5 | 智能交易系统 | 智能体训练 EA |
3 | OnlineStudy.mq5 | 智能交易系统 | 智能体附加在线训练 EA |
4 | Test.mq5 | 智能交易系统 | 模型测试 EA |
5 | Trajectory.mqh | 类库 | 系统状态定义结构 |
6 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
7 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/13596


