
神经网络变得简单(第 71 部分):目标条件预测编码(GCPC)
概述
行为克隆(BC)是解决各种离线强化学习问题的一种很有前途的方式。替代针对状态和操作估值,BC 是直接训练智能体行为政策,在设定目标、分析环境状态、和智能体动作之间建立依赖关系。这是在预先收集的离线轨迹上运用监督学习方法达成的。熟悉的决策转换器方法,及其衍生算法已经展现出序列建模对离线强化学习的有效性。
以前,在使用上述算法时,我们测验了各种选项来设置目标,以便刺激我们需要的智能体动作。然而,模型如何学习先前验算的轨迹仍然处于我们的关注范围之外。现在,浮现出关于研究整条轨迹适用性的问题。论文《离线强化学习的目标条件预测编码》的作者解决了这个问题。在他们的论文中,他们探讨了几个关键问题:
-
离线轨迹对序列建模有用吗,或者它们只简单地为监督政策学习提供更多数据?
-
支持政策学习的轨迹表示最有效的学习目标是什么?应该训练序列模型来编码历史经验、未来动态,还是两者兼而有之?
-
既然同一个序列模型可以同时用于轨迹表示学习和政策学习,那我们是否应当有相同的学习目标?
本文阐述了在 3 种人工环境中的实验结果,其作者得以提炼出以下结论:
-
序列建模,如果设计得当,当成果轨迹表示用作政策学习的输入时,可以有效地辅助决策制定。
-
代表学习目标得最优轨迹和政策学习目标之间存在差异。
基于这些观察结果,该论文的作者创建了一个两阶段框架,其采用序列建模预训练将轨迹信息压缩成紧凑的压缩表示。然后,压缩过的表示被用在基于简单多层感知器(MLP)的模型里,训练智能体行为政策。他们提出的目标条件预测编码(GCPC)方法是学习轨迹表示的最有效目标。它在所有的基准测试中都提供了有竞争力的性能。作者特别注意到它对解决长期任务的有效性。GCPC 的强劲实证性能来自过去和预测状态的潜在表示。在这种情况下,状态预测的重点是设定的目标,这些目标为决策提供了决定性的指导。
1. 目标条件预测编码算法
GCPC 方法的作者采用序列建模进行离线强化学习。为了解决离线强化学习的问题,他们使用条件化、过滤度、或加权度模仿学习。假设有一套预先收集的训练数据。但用于收集数据的政策也许未知。训练数据包含一组轨迹。每条轨迹都表示为一组状态和动作(St, At)。轨迹可以选择性地包含在时间步骤 t 处获得的奖励 Rt。
由于轨迹是由未知政策收集而来,它们也许不是最优的、或不具备足够的专业级别。我们曾讨论过,正确使用包含次优数据的离线轨迹能够带来更有效的行为政策。因为次优轨迹也许包含展示实用 “技能” 的子轨迹,这些都可以组合起来解决给定的任务。
该方法作者认为,智能体行为政策应该能够接受任何形式的有关状态或轨迹的信息作为输入,并预测下一个动作:
- 当仅用当前观察到的状态 St 和目标 G 时,智能体政策将忽略历史观察值。
- 当智能体政策是序列模型时,它可以利用整个观察到的轨迹来预测下一个动作 At。
为了优化智能体行为政策,通常用到最大似然目标函数。
序列建模可以从两个角度来制定决策:学习轨迹表示,和学习行为政策。第一个方向从原始输入轨迹中搜寻以紧缩潜在表示或预训练网络权重形式的实用表示。第二个方向搜寻转化观察和目标的最优动作,以便完成任务。
学习轨迹函数和政策函数可以使用 Transformer 模型来实现。GCPC 方法的作者建议,对于轨迹函数,采用序列建模技术将原始数据压缩为紧缩表示可能很实用。将轨迹表征学习与政策学习解耦也是可取的。解耦不仅为选择表示学习的目标提供了灵活性,还令我们能够独立研究序列建模对轨迹表示学习和政策学习的影响。因此,GCPC 使用 TrajNet(轨迹模型)和 PolicyNet(政策模型)的两阶段结构。为了训练 TrajNet,无监督学习方法,诸如掩码自动编码器、或下一个令牌预测,都可用于序列建模。PolicyNet 旨在使用监督学习目标函数从收集的离线轨迹中提取出有效的政策。
轨迹表示训练的第一阶段使用掩码自动编码。TrajNet 接收轨迹 ꚍ 和目标 G(如有必要),并学习从相同轨迹的掩码视图中复原 τ。可选项,TrajNet 还会生成轨迹 B 的紧缩表示,这可用于后续的 PolicyNet 政策训练。在他们的论文中,GCPC 方法的作者建议饲喂已游历轨迹的掩码表示作为自动编码器模型的输入。在解码器的输出中,它们努力获取已游历轨迹和后续状态的未掩蔽表示。
在第二阶段,将 TrajNet 应用于未掩蔽的观测轨迹 ꚍ,从而获得轨迹 B 的紧缩表示。然后,PolicyNet 在给定观察到的轨迹(或环境的当前状态)、目标 G 和紧缩轨迹表示 B 的情况下预测动作 A。
拟议的框架提供了一个统一的视图,用于比较实现表示学习和政策学习的不同设计。许多现有的方法可以被认为是所提议结构的特例。例如,对于 DT 实现,轨迹表示函数被设置为输入轨迹的标识映射函数,并且政策被训练为自回归生成动作。
作者的可视化方法如下表示。
2. 利用 MQL5 实现
我们已经研究了目标条件预测编码方法的理论方面。接下来,我们转到利用 MQL5 实现它。此处,您应把主要注意力集中在模型训练和操作的不同阶段所用到的模型数量不同。
2.1模型架构
在第一阶段,该方法的作者提议训练一个轨迹表示模型。模型架构使用转换器。为了训练它,我们需要构建一个自动编码器。在第二阶段,我们将仅用已训练的编码器。因此,为了在训练的第二阶段不会受到一个不必要的解码器“拖累”,我们将自动编码器切分为 2 个模型:编码器和解码器。模型的架构在 CreateTrajNetDescriptions 方法中表示。在参数中,该方法接收指向 2 个动态数组的指针,用于明示指定模型的架构。
在方法的主体中,我们检查收到的指针,并在必要时创建新的动态数组对象。
bool CreateTrajNetDescriptions(CArrayObj *encoder, CArrayObj *decoder) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; } if(!decoder) { decoder = new CArrayObj(); if(!decoder) return false; }
首先,我们来讲述编码器的架构。我们只将历史价格走势和分析指标数据输入到模型当中。
//--- Encoder encoder.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(!encoder.Add(descr)) { delete descr; return false; }
请注意,与前面所讨论的模型不同,在此阶段,我们既不用有关帐户状态的数据,也不用有关智能体之前所执行动作的信息。有一种观点认为,在某些情况下,有关先前动作的信息可能会产生负面影响。因此,GCPC 方法的作者将其从源数据中排除。有关账户状态的信息不会影响环境状态。因此,它对于预测后续环境状态并不重要。
我们始终将未处理的源数据馈送到模型之中。因此,在下一层中,我们使用批量常规化将源数据转换为可比较的形式。
//--- 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(!encoder.Add(descr)) { delete descr; return false; }
数据经过预处理之后,我们需要实现随机数据掩码,这是由 GCPC 算法提供的。为了实现该功能,我们将使用 DropOut 层。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronDropoutOCL; descr.count = prev_count; descr.probability = 0.8f; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
请注意,在一般实践中,不建议在一个模型中一同使用批量常规化层和 DropOut。这是因为排除某些信息,并将其替换为零值会扭曲原始数据分布,并对批量常规化层的操作产生负面影响。为此原因,我们首先对数据进行常规化,然后再对其掩码。以这种方式,批量常规化层据全部数据集工作,并把 DropOut 层对其操作的影响最小化。同时,我们实现了一个掩码功能来训练我们的模型,从而恢复缺失的数据,并忽略随机环境中固有的异常值。
接下来,在我们的编码器模型中,来到一个卷积模块,可减少数据的维度,并识别稳定的形态。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = HistoryBars; descr.window = BarDescr; descr.step = BarDescr; int prev_wout = descr.window_out = BarDescr / 2; descr.activation = LReLU; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = prev_wout; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = prev_wout; descr.step = prev_wout; prev_wout = descr.window_out = 8; descr.activation = LReLU; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = prev_wout; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
上述源数据的处理结果被馈送到一个全连接层模块当中,这允许我们获得初始状态的嵌入。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = EmbeddingSize; descr.activation = LReLU; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
除了历史数据之外,GCPC 方法的作者还提议向编码器饲喂目标嵌入和 Slot 令牌(之前编码器传递的结果)。我们获得最大可能盈利的全局目标不会影响环境,因此我们忽略了它。代之,我们使用串联层将编码器的最后验算结果添加到模型当中。
//--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = 2 * EmbeddingSize; descr.window = prev_count; descr.step = EmbeddingSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
进一步的数据处理由 GPT 模型执行。为了实现它,我们首先使用嵌入层创建一个数据堆栈。
//--- layer 10 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronEmbeddingOCL; prev_count = descr.count = GPTBars; { int temp[] = {EmbeddingSize, EmbeddingSize}; ArrayCopy(descr.windows, temp); } prev_wout = descr.window_out = EmbeddingSize; if(!encoder.Add(descr)) { delete descr; return false; }
它后面是关注度模块。之前,我们已用 DropOut 层创建了一个数据稀疏过程,因此在这个模型中我没有使用稀疏关注度层。
//--- layer 11 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; descr.count = prev_count * 2; descr.window = prev_wout; descr.step = 4; descr.window_out = 16; descr.layers = 4; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
在编码器的输出端,我们通过全连接层减少数据维度,并调用 SoftMax 函数对数据进行常规化。
//--- layer 12 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = EmbeddingSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 13 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = 1; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
我们将轨迹的紧缩表示馈送到解码器的输入之中。
//--- Decoder decoder.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = EmbeddingSize; descr.activation = None; descr.optimization = ADAM; if(!decoder.Add(descr)) { delete descr; return false; }
解码器的初始数据是从以前的模型中获得的,并且已拥有可比的形式。这意味着在这种情况下,我们不需要批量常规化层。生成的数据在全连接层中进行处理。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = (HistoryBars + PrecoderBars) * EmbeddingSize; descr.activation = LReLU; descr.optimization = ADAM; if(!decoder.Add(descr)) { delete descr; return false; }
然后我们在关注度层处理它。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; prev_count = descr.count = prev_count / EmbeddingSize; prev_wout = descr.window = EmbeddingSize; descr.step = 4; descr.window_out = 16; descr.layers = 2; descr.optimization = ADAM; if(!decoder.Add(descr)) { delete descr; return false; }
我们解码器的架构是这样构建的,即在关注度模块的输出端,我们为分析和预测环境状态的每根烛条都有一个嵌入。此处,我们需要明白数据的目的。我们研究以下内容。
我们为什么要分析指标?趋势指标向我们展示了趋势的方向。振荡指标旨在指示超买和超卖区域,从而指示可能的市场反转点。所有这些都在当前时刻很有价值。这样具有一定深度的预测是否有价值?我个人的看法是,考虑到数据预测误差,指标预测价值接近于零。最终,我们是从金融产品价格的变化中获得盈利和亏损,而非从指标值中获得。因此,我们将在解码器的输出中预测价格走势数据。
我们回顾一下我们在经验回放缓冲区中保存了哪些有关价格走势的信息。该信息包括 3 个偏差:
- 蜡烛实体收盘价 - 开盘价
- 最高价 - 开盘价
- 最低价 - 开盘价
因此,我们将预测这些数值。为了独立地从烛条嵌入中恢复数值,我们将用到模型融汇层。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMultiModels; descr.count = 3; descr.window = prev_wout; descr.step = prev_count; descr.activation = None; descr.optimization = ADAM; if(!decoder.Add(descr)) { delete descr; return false; } //--- return true; }
针对轨迹表示 TrajNet 训练第一阶段的自动编码器架构的描述到此结束。但在转到模型训练智能系统之前,我建议完成描述模型架构的工作。我们看看第二阶段政策训练模型 PolicyNet 的架构。该架构在 CreateDescriptions 方法中提供。
与预期对比,在第二阶段,我们将训练的不是一个扮演者行为政策模型,而是三个模型。
第一个是当前状态编码器的小模型。不要将其与第一阶段训练的自动编码器其混淆。该模型将来自自动编码器的含有关帐户状态信息的轨迹紧缩表示组合成单一表示。
第二个是扮演者政策模型,我们在上面讨论过。
第三个是目标设定模型,基于对紧缩轨迹表示的分析。
如常,在方法参数中,我们传递指向描述模型架构的动态数组的指针。在方法的主体中,我们检查接收到的指针的相关性,并在必要时创建动态数组对象的新实例。
bool CreateDescriptions(CArrayObj *actor, CArrayObj *goal, CArrayObj *encoder) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!goal) { goal = new CArrayObj(); if(!goal) return false; } if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; }
如上所述,我们将轨迹的紧缩表示馈送到编码器之中。
//--- State Encoder encoder.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = EmbeddingSize; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
接收到的数据与有关串联层中的账户状态信息相结合。
//--- layer 1 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(!encoder.Add(descr)) { delete descr; return false; }
此时,分配给编码器的任务被视为已完成,我们继续讨论扮演者架构,该架构接收前一个模型的工作结果作为输入。
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
我们将收到的数据与设定的目标相结合。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = LatentCount; descr.window = prev_count; descr.step = NRewards; descr.optimization = ADAM; descr.activation = LReLU; if(!actor.Add(descr)) { delete descr; return false; }
使用完全连接层对其进行处理。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 2 * NActions; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
在扮演者输出中,我们在其行为政策略中添加随机性。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
最后但不止的是目标生成模型。我认为,产生盈利的能力在很大程度上取决于环境状态的各个方面,这已经不是什么秘密了。因此,基于过去的经验,我决定添加一个分离的模型,用来根据环境状态生成目标。
我们将观测轨迹的紧缩表示输入到模型输入当中。在此,我们说的是轨迹,未考虑账户状态。我们的奖励函数旨在根据相对价值进行操作,而不受特定本金规模的束缚。因此,为了设定目标,我们只从环境分析出发,未考虑账户状态。
//--- Goal goal.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = EmbeddingSize; descr.activation = None; descr.optimization = ADAM; if(!goal.Add(descr)) { delete descr; return false; }
接收到的数据由 2 个全连接层进行分析。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!goal.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!goal.Add(descr)) { delete descr; return false; }
在模型输出中,我们使用完全参数化的分位数函数。此解决方案的优点是它返回最可能的结果,而不是完全连接层的典型平均值。对于具有 2 个或更多顶点的分布,结果的差异最为明显。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFQF; descr.count = NRewards; descr.window_out = 32; descr.optimization = ADAM; descr.activation = None; if(!goal.Add(descr)) { delete descr; return false; } //--- return true; }
2.2与环境交互的模型
我们继续实现目标条件预测编码方法。在讲述了模型架构之后,我们转到算法的实现。首先,我们将实现一个智能系统,用来与环境交互,并收集训练样本数据。该方法的作者并没有专注于收集训练数据的方法。事实上,训练数据集可以用任何可用的方式收集,包括我们之前讨论的算法:ExORL 和 Real-ORL。只需匹配数据记录和表达格式。但是要优化预训练模型,我们需要一个 EA,在与环境交互的过程中,它会采用我们学到的行为政策,并将交互结果保存到一条轨迹中。我们在 EA ..\Experts\GCPC\Research.mq5 中实现了这个功能。构建 EA 算法的基本原理与之前工作中所用的原理相符。然而,模型的数量保留其印记。我们特别关注一些 EA 的方法。
在这个智能系统中,我们将用到 4 个模型。
CNet Encoder; CNet StateEncoder; CNet Actor; CNet Goal;
预先训练的模型在 OnInit EA 初始化方法中加载。在附件中能找到该方法的完整代码。我在此只提及一些变化。
首先,我们加载自动编码器模型。如果存在加载错误,我们则用随机参数初始化一个新模型。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- ........ ........ //--- load models float temp; if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true)) { CArrayObj *encoder = new CArrayObj(); CArrayObj *decoder = new CArrayObj(); if(!CreateTrajNetDescriptions(encoder, decoder)) { delete encoder; delete decoder; return INIT_FAILED; } if(!Encoder.Create(encoder)) { delete encoder; delete decoder; return INIT_FAILED; } delete encoder; delete decoder; //--- }
然后我们加载剩余的 3 个模型。如有必要,我们还采用随机参数初始化它们。
if(!StateEncoder.Load(FileName + "StEnc.nnw", temp, temp, temp, dtStudied, true) || !Goal.Load(FileName + "Goal.nnw", temp, temp, temp, dtStudied, true) || !Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true)) { CArrayObj *actor = new CArrayObj(); CArrayObj *goal = new CArrayObj(); CArrayObj *encoder = new CArrayObj(); if(!CreateDescriptions(actor, goal, encoder)) { delete actor; delete goal; delete encoder; return INIT_FAILED; } if(!Actor.Create(actor) || !StateEncoder.Create(encoder) || !Goal.Create(goal)) { delete actor; delete goal; delete encoder; return INIT_FAILED; } delete actor; delete goal; delete encoder; //--- }
将所有模型传输到单一 OpenCL 关联环境之中。
StateEncoder.SetOpenCL(Actor.GetOpenCL()); Encoder.SetOpenCL(Actor.GetOpenCL()); Goal.SetOpenCL(Actor.GetOpenCL());
请务必关闭编码器的训练模式。
Encoder.TrainMode(false);
请注意,尽管我们不打算在此 EA 中使用反向传播方法,但我们在编码器中用到了 DropOut 层。因此,我们需要改变训练模式,在模型的运行条件下禁用掩码。
接下来,我们检查所加载模型架构的一致性。
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; } Encoder.getResults(Result); if(Result.Total() != EmbeddingSize) { PrintFormat("The scope of the Encoder does not match the embedding size (%d <> %d)", EmbeddingSize, Result.Total()); return INIT_FAILED; } //--- Encoder.GetLayerOutput(0, Result); if(Result.Total() != (HistoryBars * BarDescr)) { PrintFormat("Input size of Encoder doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr)); return INIT_FAILED; } //--- PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE); PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY); //--- return(INIT_SUCCEEDED); }
与环境的交互是在 OnTick 方法中实现的。在方法开始时,我们检查新柱线开盘事件的发生,并在必要时加载历史数据。接收到的信息被传输到数据缓冲区。这些操作是从之前的实现中复制而来的,没有改变,因此我们不会详述它们。我们只研究前馈模型验算的调用序列方法。根据 GCPC 算法提供,我们首先调用编码器的前馈方法。
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- ........ ........ //--- if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CNet*)GetPointer(Encoder)) ||
注意,该模型将自身递归作为第二个信息流的数据源。
接下来,我们调用状态编码器和目标模型的前馈方法。这两个模型都使用观察到的轨迹的紧缩表示作为输入数据。
!StateEncoder.feedForward((CNet *)GetPointer(Encoder), -1, (CBufferFloat *)GetPointer(bAccount)) || !Goal.feedForward((CNet *)GetPointer(Encoder), -1, (CBufferFloat *)NULL) ||
这些模型的结果将馈送到扮演者政策模型的输入中,以便生成后续动作。
!Actor.feedForward((CNet *)GetPointer(StateEncoder), -1, (CNet *)GetPointer(Goal))) return;
我们不应该忘记检查操作的结果。
接下来,已解码扮演者模型的结果,并在环境中执行动作,然后将获得的经验保存到轨迹当中。这些操作的算法保持不变。您可以在附件中找到与环境交互的 EA 完整代码。
2.3训练轨迹函数
收集训练数据集之后,我们转到构建模型训练 EA。根据 GCPC 算法,第一步是训练 TrajNet 轨迹函数模型。我们在 EA ...\Experts\GCPC\StudyEncoder.mq5 中实现此功能。
正如我们在本文的理论部分所讨论的,在第一阶段,我们训练一个掩码自动编码器模型,在我们的例子中,它由 2 个模型组成:编码器和解码器。
//+------------------------------------------------------------------+ //| Input parameters | //+------------------------------------------------------------------+ input int Iterations = 1e4; //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ STrajectory Buffer[]; CNet Encoder; CNet Decoder;
请注意以下时刻。编码器以递归方式取自身在上一轮中的结果作为第二个信息流的初始数据。对于前馈验算,我们可以简单地使用指向模型本身的指针。不过,对于反向传播验算,这种方法是不可接受的。因为模型的结果缓冲区包含的是最后的验算数据,而不是前次验算的。这对于我们的模型训练过程来说是不可接受的。因此,我们需要一个额外的数据缓冲区来存储前次验算的结果。
CBufferFloat LastEncoder;
在 EA 初始化方法中,我们首先加载训练数据集,并检查操作结果。如果没有用于训练模型的数据,则所有后续操作都毫无意义。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- ResetLastError(); if(!LoadTotalBase()) { PrintFormat("Error of load study data: %d", GetLastError()); return INIT_FAILED; }
成功加载训练数据集后,我们尝试打开预先训练的模型。如果发生错误,我们会采用随机参数初始化新模型。
//--- load models float temp; if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true) || !Decoder.Load(FileName + "Dec.nnw", temp, temp, temp, dtStudied, true)) { Print("Init new models"); CArrayObj *encoder = new CArrayObj(); CArrayObj *decoder = new CArrayObj(); if(!CreateTrajNetDescriptions(encoder, decoder)) { delete encoder; delete decoder; return INIT_FAILED; } if(!Encoder.Create(encoder) || !Decoder.Create(decoder)) { delete encoder; delete decoder; return INIT_FAILED; } delete encoder; delete decoder; //--- }
我们将两个模型放在单个 OpenCL 关联环境之中。
OpenCL = Encoder.GetOpenCL(); Decoder.SetOpenCL(OpenCL);
检查模型架构的兼容性。
Encoder.getResults(Result); if(Result.Total() != EmbeddingSize) { PrintFormat("The scope of the Encoder does not match the embedding size count (%d <> %d)", EmbeddingSize, Result.Total()); return INIT_FAILED; } //--- Encoder.GetLayerOutput(0, Result); if(Result.Total() != (HistoryBars * BarDescr)) { PrintFormat("Input size of Encoder doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr)); return INIT_FAILED; } //--- Decoder.GetLayerOutput(0, Result); if(Result.Total() != EmbeddingSize) { PrintFormat("Input size of Decoder doesn't match Encoder output (%d <> %d)", Result.Total(), EmbeddingSize); return INIT_FAILED; }
成功通过检查模块之后,我们在相同的 OpenCL 关联环境中初始化辅助缓冲区。
if(!LastEncoder.BufferInit(EmbeddingSize,0) || !Gradient.BufferInit(EmbeddingSize,0) || !LastEncoder.BufferCreate(OpenCL) || !Gradient.BufferCreate(OpenCL)) { PrintFormat("Error of create buffers: %d", GetLastError()); return INIT_FAILED; }
在 EA 初始化方法结束时,我们为学习过程的开始生成一个自定义事件。
if(!EventChartCustom(ChartID(), 1, 0, 0, "Init")) { PrintFormat("Error of create study event: %d", GetLastError()); return INIT_FAILED; } //--- return(INIT_SUCCEEDED); }
实际的训练过程在 Train 方法中实现。在该方法中,我们传统上将目标条件预测编码算法与我们之前文章中的开发相结合。在该方法开始时,我们创建了一个使用轨迹训练模型的概率向量。
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { //--- vector<float> probability = GetProbTrajectories(Buffer, 0.9);
不过要注意,在这种情况下,权重轨迹没有实际影响。在训练自动编码器的过程中,我们只用价格走势的历史数据和所分析指标。我们所有的轨迹都是在一个金融产品的一段历史间隔上收集的。因此,对于我们的自动编码器,所有轨迹都包含相同的数据。尽管如此,我将把此功能留给将来,以便能够在各种时间间隔和金融产品的轨迹上训练模型。
接下来,我们初始化局部变量和向量。我们要注意标准差的向量。它的大小等于解码器结果的向量。其使用原则将在稍后讨论。
vector<float> result, target; matrix<float> targets; STD = vector<float>::Zeros((HistoryBars + PrecoderBars) * 3); int std_count = 0; uint ticks = GetTickCount();
准备工作完成后,实现模型训练循环系统。编码器使用带有一堆潜在状态的 GPT 模块,该模块对源数据的顺序很敏感。因此,在训练模型时,我们将使用来自每个采样轨迹的整个顺序状态。
在外部循环的主体中,考虑到先前生成的概率,我们对一个轨迹进行采样,并在其上随机选择一个初始状态。
for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++) { int tr = SampleTrajectory(probability); int batch = GPTBars + 50; int state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 3 - PrecoderBars - batch)); if(state <= 0) { iter--; continue; }
然后我们清除模型堆栈,以及先前的编码器结果缓冲区。
Encoder.Clear();
Decoder.Clear();
LastEncoder.BufferInit(EmbeddingSize,0);
现在一切准备就绪,可以依据所选轨迹开始嵌套学习循环了。
int end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars); for(int i = state; i < end; i++) { State.AssignArray(Buffer[tr].States[i].state);
在循环主体中,我们据训练数据集填充初始数据缓冲区,并按顺序调用模型的前馈验算方法。首先是编码器。
if(!LastEncoder.BufferWrite() || !Encoder.feedForward((CBufferFloat*)GetPointer(State), 1, false, (CBufferFloat*)GetPointer(LastEncoder))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
然后是解码器。
if(!Decoder.feedForward(GetPointer(Encoder), -1, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
模型的前馈验算成功完成后,我们需要运行反向传播验算,并调整模型参数。但首先我们需要准备解码器的结果目标值。如您所知,在解码器的输出中,我们计划接收若干根烛条的重建数值,和价格变化的预测结果,这在描述每根烛条状态数组的前三个元素中表示。为了获得这些数据,我们将创建一个矩阵,在矩阵的每一行中,我们将存储所需时间范围内环境状态的描述。然后我们只取结果矩阵的前 3 列。这些将是我们的目标值。
target.Assign(Buffer[tr].States[i].state); ulong size = target.Size(); targets = matrix<float>::Zeros(1, size); targets.Row(target, 0); if(size > BarDescr) targets.Reshape(size / BarDescr, BarDescr); ulong shift = targets.Rows(); targets.Resize(shift + PrecoderBars, 3); for(int t = 0; t < PrecoderBars; t++) { target.Assign(Buffer[tr].States[i + t].state); if(size > BarDescr) { matrix<float> temp(1, size); temp.Row(target, 0); temp.Reshape(size / BarDescr, BarDescr); temp.Resize(size / BarDescr, 3); target = temp.Row(temp.Rows() - 1); } targets.Row(target, shift + t); } targets.Reshape(1, targets.Rows()*targets.Cols()); target = targets.Row(0);
受到上一篇文章讲述的运用封闭式运算器结果的启发,我决定稍微修改学习过程,更加注重大偏差。如此,我简单地忽略了微小偏差,将它们视为预测误差。因此,在这个阶段,我根据目标值计算模型结果的移动标准差。
Decoder.getResults(result); vector<float> error = target - result; std_count = MathMin(std_count, 999); STD = MathSqrt((MathPow(STD, 2) * std_count + MathPow(error, 2)) / (std_count + 1)); std_count++;
此处需要注意的是,我们分别控制每个参数的偏差。
然后,我们检查当前预测误差是否超过阈值。仅当至少一个参数的预测误差高于阈值时,才会执行反向传播验算。
vector<float> check = MathAbs(error) - STD * STD_Multiplier; if(check.Max() > 0) { //--- Result.AssignArray(CAGrad(error) + result); if(!Decoder.backProp(Result, (CNet *)NULL) || !Encoder.backPropGradient(GetPointer(LastEncoder), GetPointer(Gradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } }
请注意,这种方式有若干细微差别。仅在执行反向传播验算时计算模型的平均误差。因此,在这种情况下,当前误差仅在超过阈值时才会影响平均误差。结果就是,我们忽略的小误差不会影响模型的平均误差值。因此,我们得到量程的高估值。这并不重要,因为该值纯粹是信息性的。
“硬币的另一面”,在于仅关注重大偏差,我们可以帮助模型识别影响某些性能值的主要驱动因素。使用移动标准差作为阈值的准则,令我们能够在学习过程中减少许可误差的阈值。这样可以对模型进行更精细的优调。
在循环迭代结束时,我们将编码器的结果保存到辅助缓冲区之中,并通知用户模型训练过程的进度。
Encoder.getResults(result); LastEncoder.AssignArray(result); //--- if(GetTickCount() - ticks > 500) { double percent = (double(i - state) / ((end - state)) + iter) * 100.0 / (Iterations); string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Decoder", percent, Decoder.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } }
在完成训练循环系统的所有迭代后,我们清除图表上的注释字段,在日志中显示有关训练结果的信息,并启动 EA 关闭。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Decoder", Decoder.getRecentAverageError()); ExpertRemove(); //--- }
请务必记住保存经过训练的模型,并在 EA 逆初方法中清除内存。
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- if(!(reason == REASON_INITFAILED || reason == REASON_RECOMPILE)) { Encoder.Save(FileName + "Enc.nnw", 0, 0, 0, TimeCurrent(), true); Decoder.Save(FileName + "Dec.nnw", Decoder.getRecentAverageError(), 0, 0, TimeCurrent(), true); } delete Result; delete OpenCL; }
2.4政策训练
下一步是训练智能体行为政策,其在 ...\Experts\GCPC\Study.mq5 EA 中实现。此处,我们将训练一个状态编码器模型,它本质上是智能体模型不可或缺的一部分。我们还将训练目标设定模型。
尽管在功能上可以将训练智能体行为政策和目标设定模型的过程分成 2 个单独的程序,但我决定将它们合并到一个 EA 当中。从实现算法中可以看出,这两个过程紧密交织在一起,并使用了大量的公共数据。在这种情况下,将模型训练划分为 2 个并行进程,其中有大量重复操作,这几乎是没有效率的。
这个 EA 类似于与环境交互的 EA,使用 4 个模型,其中 3 个在其中训练。
CNet Actor; CNet StateEncoder; CNet Encoder; CNet Goal;
在 EA 初始化 OnInit 方法中,如上讨论的 EA 一样,我们加载训练数据集。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- ResetLastError(); if(!LoadTotalBase()) { PrintFormat("Error of load study data: %d", GetLastError()); return INIT_FAILED; }
接下来我们加载模型。首先,我们尝试打开一个预先训练的编码器。它必须在目标条件预测编码算法的第一阶段进行训练。如果该模型不可用,我们就无法进入下一阶段。
//--- load models float temp; if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true)) { Print("Cann't load Encoder model"); return INIT_FAILED; }
成功读取编码器模型后,我们尝试打开其余模型。它们都要接受该 EA 的训练。因此,当发生任何错误时,我们都会创建新模型,并采用随机参数对其进行初始化。
if(!StateEncoder.Load(FileName + "StEnc.nnw", temp, temp, temp, dtStudied, true) || !Goal.Load(FileName + "Goal.nnw", temp, temp, temp, dtStudied, true) || !Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true)) { CArrayObj *actor = new CArrayObj(); CArrayObj *goal = new CArrayObj(); CArrayObj *encoder = new CArrayObj(); if(!CreateDescriptions(actor, goal, encoder)) { delete actor; delete goal; delete encoder; return INIT_FAILED; } if(!Actor.Create(actor) || !StateEncoder.Create(encoder) || !Goal.Create(goal)) { delete actor; delete goal; delete encoder; return INIT_FAILED; } delete actor; delete goal; delete encoder; //--- }
然后,我们将所有模型移传送单个 OpenCL 关联环境之中。我们还将编码器训练模式设置为 false,从而禁用源数据的掩码。
下一步是检查所有加载模型架构的兼容性,从而消除在模型之间传输数据时可能出现的错误。
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; } Encoder.getResults(Result); if(Result.Total() != EmbeddingSize) { PrintFormat("The scope of the Encoder does not match the embedding size (%d <> %d)", EmbeddingSize, Result.Total()); return INIT_FAILED; } //--- Encoder.GetLayerOutput(0, Result); if(Result.Total() != (HistoryBars * BarDescr)) { PrintFormat("Input size of Encoder doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr)); return INIT_FAILED; } //--- StateEncoder.GetLayerOutput(0, Result); if(Result.Total() != EmbeddingSize) { PrintFormat("Input size of State Encoder doesn't match Bottleneck (%d <> %d)", Result.Total(), EmbeddingSize); return INIT_FAILED; } //--- StateEncoder.getResults(Result); int latent_state = Result.Total(); Actor.GetLayerOutput(0, Result); if(Result.Total() != latent_state) { PrintFormat("Input size of Actor doesn't match output State Encoder (%d <> %d)", Result.Total(), latent_state); return INIT_FAILED; } //--- Goal.GetLayerOutput(0, Result); latent_state = Result.Total(); Encoder.getResults(Result); if(Result.Total() != latent_state) { PrintFormat("Input size of Goal doesn't match output Encoder (%d <> %d)", Result.Total(), latent_state); return INIT_FAILED; } //--- Goal.getResults(Result); if(Result.Total() != NRewards) { PrintFormat("The scope of Goal doesn't match rewards count (%d <> %d)", Result.Total(), NRewards); return INIT_FAILED; }
在成功通过所有必要的控制之后,我们在 OpenCL 关联环境中创建辅助缓冲区。
if(!bLastEncoder.BufferInit(EmbeddingSize, 0) || !bGradient.BufferInit(MathMax(EmbeddingSize, AccountDescr), 0) || !bLastEncoder.BufferCreate(OpenCL) || !bGradient.BufferCreate(OpenCL)) { PrintFormat("Error of create buffers: %d", GetLastError()); return INIT_FAILED; }
为学习过程的开始生成自定义事件。
if(!EventChartCustom(ChartID(), 1, 0, 0, "Init")) { PrintFormat("Error of create study event: %d", GetLastError()); return INIT_FAILED; } //--- return(INIT_SUCCEEDED); }
在 EA 逆初方法中,我们保存经过训练的模型,并删除用过的动态对象。
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- if(!(reason == REASON_INITFAILED || reason == REASON_RECOMPILE)) { Actor.Save(FileName + "Act.nnw", 0, 0, 0, TimeCurrent(), true); StateEncoder.Save(FileName + "StEnc.nnw", 0, 0, 0, TimeCurrent(), true); Goal.Save(FileName + "Goal.nnw", 0, 0, 0, TimeCurrent(), true); } delete Result; delete OpenCL; }
训练模型的过程在 Train 方法中实现。在方法的主体中,我们首先生成一个概率缓冲区,用于选择轨迹来训练模型。我们根据盈利能力来权衡训练集中的所有轨迹。最有利可图的验算更有可能参与学习过程。
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { //--- vector<float> probability = GetProbTrajectories(Buffer, 0.9);
然后我们初始化局部变量。此处,您可以注意到两个标准差向量,我们将用它们来政策建模和设定目标。
vector<float> result, target; matrix<float> targets; STD_Actor = vector<float>::Zeros(NActions); STD_Goal = vector<float>::Zeros(NRewards); int std_count = 0; bool Stop = false; //--- uint ticks = GetTickCount();
尽管经过训练的模型在其架构中没有递归模块和堆栈,但我们仍然创建了一个循环系统来训练模型。因为训练模型的初始数据是由运行 GPT 架构的编码器生成的。
在外循环的主体中,我们对轨迹和初始状态进行采样。
for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++) { int tr = SampleTrajectory(probability); int batch = GPTBars + 50; int state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - PrecoderBars - batch)); if(state <= 0) { iter--; continue; }
我们清除编码器的堆栈,及其最后结果的缓冲区。
Encoder.Clear();
bLastEncoder.BufferInit(EmbeddingSize, 0);
注意,我们用缓冲区来记录编码器的最后状态,尽管我们不会执行该模型的反向传播验算。对于前馈验算,我们可以使用指向模型的指针,就像在环境交互 EA 中实现的那样。然而,当移至新的轨迹时,我们不仅需要重置潜在状态堆栈,还需要重置模型的结果缓冲区。使用额外的缓冲区更容易做到这一点。
在嵌套循环的主体中,我们从训练数据集中加载已分析状态数据,并使用编码器模型生成它的紧缩表示。
int end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars); for(int i = state; i < end; i++) { bState.AssignArray(Buffer[tr].States[i].state); //--- if(!bLastEncoder.BufferWrite() || !Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)GetPointer(bLastEncoder))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
接下来,我们填充账户状态描述缓冲区,并辅以时间戳谐波。
float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0]; float 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); double time = (double)Buffer[tr].States[i].account[7]; double x = time / (double)(D'2024.01.01' - D'2023.01.01'); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_MN1); bAccount.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_W1); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_D1); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); if(bAccount.GetIndex() >= 0) bAccount.BufferWrite();
环境分析状态的紧缩表示与描述帐户状态的向量相结合。
//--- State embedding if(!StateEncoder.feedForward((CNet *)GetPointer(Encoder), -1, (CBufferFloat*)GetPointer(bAccount))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
现在,为了运行扮演者的前馈验算,我们需要指示目标。与决策转换器的情况一样,在该阶段,我们使用通过与环境交互获得的实际结果作为目标。智能体的实际动作用作策略的目标结果。按这种方式,我们在特定环境状态下的目标和动作之间建立了联系。但有一点。在训练自动编码器时,我们的目标是提前几根蜡烛获取预测数据。因此,我们现在希望以当前状态的紧缩表示形式获得有关几根后续蜡烛的预测信息。假设在该阶段执行的智能体动作应设计为在预测的时间段内获得结果,这是合乎逻辑的。我们可以将预测期间的总奖励作为所采取动作的目标。不过,为什么当前未结的成交只能在预测期满后才能平仓呢?它可以提前或延迟平仓。对于 “延后” 的情况,我们无法看到预测值之外的内容。因此,我们只能在预测区间结束时获取结果。但是,如果价格走势的方向在预测区间内发生变化,则应提前了结业务。因此,考虑到折扣因子,我们的潜在目标应该是预测期内的最大值。
问题在于,经验回放缓冲区会存储累积奖励,直到该局结束。不过,我们需要自预测数据范围内分析出的状态奖励总额。因此,我们首先在每一步复原奖励,而不考虑折扣因子。
targets = matrix<float>::Zeros(PrecoderBars, NRewards); result.Assign(Buffer[tr].States[i + 1].rewards); for(int t = 0; t < PrecoderBars; t++) { target = result; result.Assign(Buffer[tr].States[i + t + 2].rewards); target = target - result * DiscFactor; targets.Row(target, t); }
然后我们逆向对它们进行汇总,同时参考折扣因子。
for(int t = 1; t < PrecoderBars; t++) { target = targets.Row(t - 1) + targets.Row(t) * MathPow(DiscFactor, t); targets.Row(target, t); }
从结果矩阵中,我们选择具有最大奖励的行,这将是我们的目标。
result = targets.Sum(1); ulong row = result.ArgMax(); target = targets.Row(row); bGoal.AssignArray(target);
我非常同意这样的观点,即在后续时间步骤中获得的盈利(或亏损)可与智能体更早或更晚进行的交易相关联。这里有两处。
提及以前执行的成交并不完全正确。因为事实上,智能体继续持有是当前时刻的动作。因此,它们的后续结果是该动作的持续。
至于后续的动作,在轨迹分析的框架中,我们分析的不是单个动作,而是整个扮演者的行为政策。因此,目标是为可预见的未来设定政略,而不是单独的动作。从这个观点来看,为预测期设定一个最大目标是非常相关的。
考虑到准备好的目标,我们有足够的数据来执行扮演者的前馈验算。
//--- Actor if(!Actor.feedForward((CNet *)GetPointer(StateEncoder), -1, (CBufferFloat*)GetPointer(bGoal))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
接下来,我们需要调整模型参数,从而最大限度地减少预测的动作与在与环境交互过程中实际执行的动作之间的误差。此处,我们使用监督学习方法,并注重最大偏差。与上述算法一样,我们首先计算每个参数的预测误差的移动标准差。
target.Assign(Buffer[tr].States[i].action); target.Clip(0, 1); Actor.getResults(result); vector<float> error = target - result; std_count = MathMin(std_count, 999); STD_Actor = MathSqrt((MathPow(STD_Actor, 2) * std_count + MathPow(error, 2)) / (std_count + 1));
然后,我们将当前误差与阈值进行比较。仅当至少一个参数中存在高于阈值的偏差时,才会执行反向传播验算。
check = MathAbs(error) - STD_Actor * STD_Multiplier; if(check.Max() > 0) { Result.AssignArray(CAGrad(error) + result); if(!Actor.backProp(Result, (CBufferFloat *)GetPointer(bGoal), (CBufferFloat *)GetPointer(bGradient)) || !StateEncoder.backPropGradient(GetPointer(bAccount), (CBufferFloat *)GetPointer(bGradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } }
更新扮演者参数后,我们转到训练目标设置模型。与扮演者不同,它仅用从编码器接收的分析状态的紧缩表示作为初始数据。此外,在执行前馈验算之前,我们不需要准备其它数据。
//--- Goal if(!Goal.feedForward((CNet *)GetPointer(Encoder), -1, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
对于模型训练的目标值,我们将使用上面为扮演者政策设置的目标。但增加了一点。在许多工作中,建议在为训练政策制定目标时对获得的实际结果使用递增因子。这应该会刺激行为政策选择更优的动作。我们将立即训练目标设定模型,从而获得更好的结果。为此,在形成目标值的向量时,我们将实际成就增加 2 倍。不过,请注意以下事项。我们不能简单地将实际奖励的向量乘以 2。由于收到的奖励中也可能有负值,将它们乘以 2 只会令期望值变差。因此,我们首先检测奖励的标志。
target=targets.Row(row); result = target / (MathAbs(target) + FLT_EPSILON);
作为此操作的结果,我们希望获得一个向量,其中 “-1” 表示负值,“1” 表示正值。将向量从 “2” 提高到结果向量的幂,我们得到 “2” 表示正值,得到 “1/2” 表示负值。
result = MathPow(vector<float>::Full(NRewards, 2), result);
现在我们可以将实际结果的向量乘以上面获得的系数向量,令预期奖励翻倍。我们将此作为训练目标设定模型的目标值。
target = target * result; Goal.getResults(result); error = target - result; std_count = MathMin(std_count, 999); STD_Goal = MathSqrt((MathPow(STD_Goal, 2) * std_count + MathPow(error, 2)) / (std_count + 1)); std_count++; check = MathAbs(error) - STD_Goal * STD_Multiplier; if(check.Max() > 0) { Result.AssignArray(CAGrad(error) + result); if(!Goal.backProp(Result, (CBufferFloat *)NULL, (CBufferFloat *)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } }
于此,我们还利用了封闭式表达式来优化模型的思路,注重最大偏差。
在该阶段,我们优化了所有训练模型的参数。我们将编码器的结果保存到相应的缓冲区之中。
Encoder.getResults(result); bLastEncoder.AssignArray(result);
通知用户学习过程的进度,并转到循环系统的下一次迭代。
//--- if(GetTickCount() - ticks > 500) { double percent = (double(i - state) / ((end - state)) + iter) * 100.0 / (Iterations); string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Actor", percent, Actor.getRecentAverageError()); str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Goal", percent, Goal.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } }
模型训练循环系统的所有迭代完成之后,我们清除品种图上的注释字段。将训练结果打印到日志中,并完成 EA 操作。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Goal", Goal.getRecentAverageError()); ExpertRemove(); //--- }
算法所用程序的讲述到此结束。在附件中可找到文章中用到的所有程序的完整代码。此附件还包含用于测试已训练模型的 EA,我们现在不再赘述。
3. 测试
我们已经做了大量工作来利用 MQL5 实现目标条件预测编码方法。本文的尺度即可确认已完成的工作量。现在是时候转到测试其结果了。
像往常一样,我们采用 EURUSD、H1 的历史数据来训练和测试模型。这些模型是依据 2023 年前 7 个月的历史数据训练的。为了测试已训练模型,我们采用了 2023 年 8 月的历史数据,该数据紧随在训练历史时期之后。
训练是迭代进行的。首先,我们收集了一个训练数据集,我们分 2 个阶段收集。在第一阶段,我们把基于真实信号数据的验算保存到训练集之中,就像 Real-ORL 方法中所建议的那样。然后,使用 ...\Experts\GCPC\Research.mq5 EA 和随机政策对训练数据集进行补充。
使用 ...\Experts\GCPC\StudyEncoder.mq5 EA 在该数据上训练自动编码器。如上所述,为了训练 EA,所有验算都是雷同的。模型训练不需要额外更新训练数据集。因此,我们训练一个掩码自动编码器,直到获得可接受的结果。
在第二阶段,我们训练智能体行为政策和目标设定模型。在此,我们运用迭代方法,在其中,我们训练模型,然后更新训练数据。我必须说,在这个阶段,我很惊讶。事实证明,训练过程相当稳定,并且结果动态良好。在训练过程中,获得了一项能够在训练和测试期间产生盈利的策略。
结束语
在本文中,我们领略了一种相当有趣的目标条件预测编码方法。它的主要贡献是将模型训练过程分为 2 个子过程:轨迹学习和单独的政策学习。在学习轨迹时,注意力集中在将观察到的趋势投射到未来状态的可能性上,这通常会增加传输到智能体制定决策的数据信息内容。
在本文的实践部分,我们利用 MQL5 实现了我们所提议的方法的愿景,并在实践中确认了所提议方法的有效性。
不过,我想再次提醒您注意这样一个事实,即本文中介绍的所有程序仅出于技术演示目的。它们尚未准备好在真实的金融市场中运用。
参考
文中所用程序
# | 已发行 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | 智能交易系统 | 样本收集 EA |
2 | ResearchRealORL.mq5 | 智能交易系统 | 用于使用 Real-ORL 方法收集示例的 EA |
3 | Study.mq5 | 智能交易系统 | 政策训练 EA |
4 | StudyEncoder.mq5 | 智能交易系统 | 自动编码器训练 EA |
5 | Test.mq5 | 智能交易系统 | 模型测试 EA |
6 | Trajectory.mqh | 类库 | 系统状态定义结构 |
7 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
8 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/14012
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.


