
神经网络变得轻松(第四十七部分):连续动作空间
概述
在上一篇文章中,我们训练的代理者只是为了判定交易方向。代理者的动作范围仅限于 4 个选项:
- 买入,
- 卖出,
- 持有/等待,
- 所有持仓平仓。
于此,我们没看到资本和风险管理功能。我们在所有交易操作中采用了最小手数。这足以评估训练方式,但构建交易策略尚嫌不足。一个可盈利交易策略再简单也必须有一个资金管理算法。
此外,为了创建一个稳定的交易策略,我们需要管理风险。我们的设计中也缺少这个模块。EA 在每根新交易蜡烛处评估市场形势,并就交易操作做出决定。但每一根即将到来的柱线都会给我们的账户带来风险。柱线内的价格走势可能不利于我们的余额。这就是为什么始终建议使用止损的原因。这种方式虽简单,但令我们能够限定每笔交易的风险。
1. 连续动作空间训练特征
逻辑上,在训练代理者并建立其交易策略时,我们需要考虑这些特征。但这里浮现一个问题:如何训练模型来预测交易量,以及持仓的平仓价位。这可以利用监督学习算法轻松达成,其中我们可以指定由教师提供的所需目标值。但在使用强化学习算法时也有一些复杂性。
您也许还记得,我们之前用过 2 种方式训练强化模型:奖励预测,和接收最大奖励的概率。
解决此问题的一种可能途径是为交易操作的所有参数定义离散值,并为每个可能的选项创建单独的动作。这将令我们能够从资本和风险管理的某些方面加以考虑。
但这种方式并非没有缺点。选择离散事务参数需要在数据准备阶段进行一些工作。它们的选取原则始终要在选项数量和代理者制定足够灵活的决策之间做出妥协。在这种情况下,可能动作的组合数量也许会显著增加,这将导致模型更加复杂,并相应增加其训练时间。毕竟,在训练期间,您需要研究每个可能动作的奖励。
例如,如果我们只取 3 个交易量离散值、3 个止损位和 5 个止盈位,那么我们将需要 90 个元素只为了定义 2 个交易方向(3 * 3 * 5 * 2 = 90)的动作空间。另外,不要忘记持仓和平仓的动作。在可能的代理者操作范围内已经有 92 个选项。
同意,代理者动作的这种微弱自由度,导致模型输出端神经元数量的显著增加。任何交易参数的每个离散值相加都会导致进展中的神经元数量增加。
此外,训练更复杂的模型也许需要其它训练样本集,并配以所有随之而来的后果。
但还有其它方式,即所谓在连续动作空间中训练代理者的算法。通过此类算法训练的代理者能从连续数值范围内选择动作。这能令其更灵活、更准确地管理交易参数,包括交易量、止损和止盈级别。
在连续动作空间中训练代理者的最流行算法之一,就是深度判定性策略梯度(DDPG)。在 DDPG 中,该模型由两个神经网络组成:扮演者(Actor)和评论者(Critic)。扮演者基于当前状态预测最优动作,而评论者则审评此动作。我们已在 “优势扮演者-评论者算法”一文中看到了类似的解决方案。在这些算法中,各方式都有相似之处,但区别在于扮演者训练算法。
在 DDPG 中,利用梯度提升训练 Actor,来优化判定性策略。扮演者基于当前状态直接预测最优动作,而不是像优势扮演者-评论者算法那样对动作的概率分布进行建模。
DDPG 中的扮演者训练是通过计算评论者为扮演者动作给出的梯度值,并据此梯度更新扮演者的参数。这听起来有点复杂,但它允许扮演者找到最优动作,可令评论者的评分最大化。
重点注意的是,DDPG 指的是非策略算法。该模型依据来自之前与环境交互获得的数据进行训练,与当前的决策策略无关。该算法的这一重要特征令其可用于复杂和随机的环境,其中预测环境的动态也许很困难或不准确。在测试 EDL 算法时,我们遇到了金融市场预测品质低劣的问题。
深度判定性策略梯度算法基于深度 Q-网络(DQN)的核心原理,并结合了它的许多方式,包括经验回放缓冲区和目标模型。我们来仔细查看算法。
如上所述,该模型由 2 个神经网络组成:扮演者(Actor)和评论者(Critic)。扮演者接收环境状态作为输入。在扮演者的输出中,我们从连续的数值分布中获取动作。在我们的例子中,我们将形成交易量、止损和止盈水平。根据模型架构和问题陈述,我们可以采用绝对值或相对值。为了提高对环境的探索水平,可以在生成的动作中添加一些噪音。
我们执行的动作由扮演者选择,并进入新的环境状态。回应我们所采取的行动,我们从环境中得到奖励。
我们收集“状态 - 动作 - 新状态 - 奖励”数据集合,并放进经验回放缓冲区之中。这是强化学习算法的典型动作过程。
与 DQN 中一样,我们从经验回放缓冲区中选择一个训练模型的数据包。来自该训练数据包的状态将馈送到扮演者的输入之中。在更改参数之前,我们很可能会获得类似于存储在经验回放缓冲区中的动作。但与优势扮演者-评论者不同的是,扮演者返回的不是概率分布,而是来自连续分布的动作。
为了评估给定动作的价值,我们将当前状态和生成的动作传输给评论者。基于收到的数据,评论者预测奖励,就如同传统的 DQN 中一样。
与 DQN 类似,经过训练的评论者,把预测奖励与来自经验回放缓冲区的实际值之间的标准偏差最小化。为了构建全面策略,使用了目标网络模型。但是由于评论者需要来自状态和动作的一组数据才能评估后续状态,因此我们还要用扮演者的目标模型依据后续状态来形成动作。
DDPG 的亮点在于我们不会使用目标输出值来训练扮演者。取而代之,我们只需取评论者模型的误差梯度值置于我们的动作之上,并将其传递给进一步的扮演者模型。
因此,在训练评论者的 Q-函数时,我们使用置于动作上的误差梯度来优化扮演者的动作。可以说扮演者是 Q-函数的一个组成部分。训练 Q-函数会导致扮演者函数的优化。
但此处我们应当注意,在评论者的训练过程中,我们优化其参数,以便对状态-行动对进行最正确的评估。在训练扮演着时,我们优化其参数,以便提升预测奖励,而所有其它事宜等同。
该方法的作者建议使用目标模型的软更新。考虑到针对已训练模型参数的更新率,以重新计算目标模型的参数取代简单地在特定频率下替换已训练目标模型。根据作者的说法,这种方法减慢了目标模型的更新速度,但提升了训练稳定性。
2. 利用 MQL5 实现
深度判定性策略梯度(DDPG)方法的理论介绍完毕后,我们转入利用 MQL5 实际实现。我们将从安排目标模型的软更新开始。2 个参数的加权求和函数本身并不复杂,但有 2 个关键点。
首先,操作必须依据所有模型参数执行。由于每个单独参数的操作完全独立于同一模型的其它参数,因此它们可以很容易地并行执行。
其次,所有训练操作,以及模型操作都是在 OpenCL 的关联环境中执行。关联环境内存和主内存之间的数据复制操作非常昂贵。我们一直在努力将它们降到最低。逻辑上,参数也应该在 OpenCL 的关联环境中重新计算。
2.1. 目标模型的软更新
首先,我们将创建 SoftUpdate 内核来执行这些操作。内核算法非常简单。在内核参数中,我们将传递 2 个指向数据缓冲区(目标模型和训练模型的参数)的指针,以及更新因子作为常量。
__kernel void SoftUpdate(__global float *target, __global const float *source, const float tau ) { const int i = get_global_id(0); target[i] = target[i] * tau + (1.0f - tau) * source[i]; }
我们将在每个单独的线程中只更新一个参数。因此,线程数量会等于要更新的参数数量。
接下来,我们必须在主程序的一端安排该过程。
我要提醒您,我们的模型参数根据神经层的类型分布在不同的对象上。这意味着我们需要为每个类添加一个更新参数的方法,以便安排神经层的工作。我们看一下 CNeuronBaseOCL 神经层的基类示例。
鉴于我们将更新当前神经层的参数,故我们只需要在方法参数中传递一个指向训练模型神经层的指针,和更新系数即可。
bool CNeuronBaseOCL::WeightsUpdate(CNeuronBaseOCL *source, float tau) { if(!OpenCL || !Weights || !source || !source.Weights) return false;
在方法的主体中,我们检查收到的指向神经层对象指针的有效性。我们还一并检查指向必要内部对象的指针。
此处,我们检查两个神经层的类型与参数矩阵的维度之间的对应关系。
if(Type() != source.Type()) return false; if(Weights.Total() != source.Weights.Total()) return false;
成功传递控制模块后,我们组织把参数传输到内核。
uint global_work_offset[1] = {0}; uint global_work_size[1] = {Weights.Total()}; ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_target, Weights.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, source.getWeightsIndex())) { 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; }
方法执行完毕。
由于在我们类中各种架构的神经层操作中安置的所有对象都继承自 CNeuronBaseOCL 基类,因此所有类都会继承已创建的方法。但它只允许我们更新基类的权重矩阵。我们应该在所有类中重写该方法,添加辅助的内部可优化对象。例如,在 CNeuronConvOCL 卷积层中,我们添加了一个卷积矩阵参数。若要更新它,我们将重写 WeightsUpdate 方法。为了支持重写继承的方法,我们保持方法的所有参数不变。
bool CNeuronConvOCL::WeightsUpdate(CNeuronBaseOCL *source, float tau) { if(!CNeuronBaseOCL::WeightsUpdate(source, tau)) return false;
我们不会在方法主体中重复整个控件模块。代之,我们调用父类方法,并检查操作结果。
接下来,在参数中,我们接收指向神经网络基类对象的指针。这是有意为之的。通过指定父类的类型,可以将指针传递到其任何衍生后代。这就是为啥我们要的在所有继承类中安排虚拟方法。
但问题是,在这种状态下,我们无法访问从参数中获得的层卷积权重矩阵。父类中根本没有这样的对象。它只在卷积层类中出现。我们毫不怀疑,指向卷积层的指针是在参数中传递的。在父类方法中,我们检查当前神经层的类型与参数中获得的神经层类型的对应关系。为了操控这个卷积层对象,我们只需赋值生成的指针指向动态卷积层对象。然后我们检查矩阵大小是否合规。
CNeuronConvOCL *temp = source; if(WeightsConv.Total() != temp.WeightsConv.Total()) return false;
接下来,我们重复传输数据的过程,并将内核放入执行队列。注意,仅更改所应用的数据缓冲区对象。
uint global_work_offset[1] = {0}; uint global_work_size[1] = {WeightsConv.Total()}; ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_target, WeightsConv.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.WeightsConv.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; }
按类似的方式,我们在所有其它类别的神经层中创建方法,其中我们添加了具有优化参数的对象。我不会给出类方法的完整代码。您可以在附件中找到它们。
我们函数库的操作算法不提供用户对模型神经层的直接访问。用户始终操控神经网络模型的顶级类。因此,在将方法添加到神经层类后,我们将在 CNet::WeightsUpdate 模型类中创建一个同名的方法。在参数中,该方法接收指向已训练神经网络的指针,和更新系数。在方法的主体中,我们安排循环搜索遍历模型的所有神经网络,并调用更新神经层的方法。该算法非常简单。在文章中提供其代码是没有意义的。您可以在附件中找到它。
2.2. 扮演者和评论者之间的数据交换
安排好模型更新之后,我们直接开始安排模型的训练过程。我们的模型与带有先前曾研究方式的 DDPG 算法是一种共生关系。特别是,我决定针对两个神经网络(扮演者和评论者)使用单一的源数据初步处理模块。
扮演者基于所获环境状态决定最优动作。评论者接收环境状态和扮演者行为的描述作为输入。基于接收的数据,它预测预期奖励(评估扮演者的行动)。正如我们所见,扮演者和评论者都会收到环境描述。为了尽量减少重复操作,决定在扮演者主体中规划一个对源数据进行初步处理的模块。评论者应该从扮演者的隐含状态传达环境状态的浓缩表示。为了在主程序一端尽量减少扮演者和评论者之间的数据传输量,决定创建额外的向前和向后验算方法,传输的指针不是独立数据缓冲区,而是直接指向源数据模型,及含有源数据的层标识符。
将考虑安排 CNet::feedForward 前向验算方法。方法参数提供 2 个指向神经网络的指针(主数据源和附加源数据),以及在这些网络中 2 个神经层的标识符。
bool CNet::feedForward(CNet *inputNet, int inputLayer=-1, CNet *secondNet = NULL, int secondLayer = -1) { if(!inputNet || !opencl) return false;
默认值已添加到参数当中,这令我们在调用该方法时仅需传递一个指向主要源数据模型的指针。
在方法主体中,我们检查接收到的指向主要源数据模型的指针。如果没有数据,则退出该方法,结果为负数。
接下来,我们检查主要输入数据模型中神经层的 ID。如果因某种原因未指定,那么我们就取模型的最后一个神经层。
if(inputLayer<0) inputLayer=inputNet.layers.Total()-1;
在下一阶段,我们会安排访问附加数据的操作。我们创建一个指向数据缓冲区对象的空指针。检查指针与附加源数据模型的相关性。
CBufferFloat *second = NULL; bool del_second = false; if(!!secondNet) { if(secondLayer < 0) secondLayer = secondNet.layers.Total() - 1; if(secondNet.GetOpenCL() != opencl) { secondNet.GetLayerOutput(secondLayer, second); if(!!second) { if(!second.BufferCreate(opencl)) { delete second; return false; } del_second = true; } } else { if(secondNet.layers.Total() <= secondLayer) return false; CLayer *layer = secondNet.layers.At(secondLayer); CNeuronBaseOCL *neuron = layer.At(0); second = neuron.getOutput(); } }
如果我们有一个指向附加源数据模型的有效指针,我们针对事件的发展有 2 个选项:
- 如果附加源数据模型和当前模型被加载到不同的 OpenCL 关联环境中,那么我们就需在各自情况下重新加载数据。我们将数据从相应的数据模型层复制到新的缓冲区当中,并在所需的关联环境中创建一个缓冲区。
- 这两个模型都位于相同的 OpenCL 关联环境中。数据已存储在关联环境内存之中。我们只需将指针复制指向所需神经层结果缓冲区的指针。
在收到包含附加源数据的缓冲区后,我们转入研究主要源数据的模型。如上所述,我们检查模型是否加载到同一 OpenCL 关联环境的内存当中。如果没有,那么我们只需将原始数据复制到缓冲区,并调用之前开发的前向验算方法。
if(inputNet.opencl != opencl) { CBufferFloat *inputs; if(!inputNet.GetLayerOutput(inputLayer, inputs)) { if(del_second) delete second; return false; } bool result = feedForward(inputs, 1, false, second); if(del_second) delete second; return result; }
如果两个模型都位于同一个 OpenCL 关联环境之中,则我们将源数据层替换为源数据模型中指定的神经层。
CLayer *layer = inputNet.layers.At(inputLayer); if(!layer) { if(del_second) delete second; return false; } CNeuronBaseOCL *neuron = layer.At(0); layer = layers.At(0); if(!layer) { if(del_second) delete second; return false; } if(layer.At(0) != neuron) if(!layer.Update(0, neuron)) { if(del_second) delete second; return false; }
之后,我们安排循环列举所有神经层,然后调用前向验算方法。
for(int l = 1; l < layers.Total(); l++) { layer = layers.At(l); neuron = layer.At(0); layer = layers.At(l - 1); if(!neuron.FeedForward(layer.At(0), second)) { if(del_second) delete second; return false; } } //--- if(del_second) delete second; return true; }
循环迭代完成后,以正面结果退出该方法。
我们以类似的方式创建 CNet::backProp 方法。其完整代码可在附件中找到。
在训练评论者时,我们会调用这两种方法。但对于训练扮演者,我们需要另一种反向验算方法。事实上,在后向验算方法中,在将误差梯度传递到神经层之前,我们首先判定前向验算结果与目标值的偏差。对于扮演者,DDPG 方法消除了该过程。为了实现此算法,创建了 CNet::backPropGradient 方法。
在方法参数中,我们传递 2 个指向数据缓冲区的指针:附加源数据,和它们的误差梯度。两个缓冲区都有默认值,这令我们在不指定参数的情况下运行该方法。
bool CNet::backPropGradient(CBufferFloat *SecondInput = NULL, CBufferFloat *SecondGradient = NULL) { if( ! layers || ! opencl) return false; CLayer *currentLayer = layers.At(layers.Total() - 1); CNeuronBaseOCL *neuron = NULL; if(CheckPointer(currentLayer) == POINTER_INVALID) return false;
在方法的主体中,我们首先检查指向神经层动态数组对象的指针与 OpenCL 关联环境的相关性。我们声明必要的局部变量。
然后,我们安排循环,将误差梯度分派到模型的所有神经层上。
//--- Calc Hidden Gradients int total = layers.Total(); for(int layerNum = total - 2; layerNum >= 0; layerNum--) { CLayer *nextLayer = currentLayer; currentLayer = layers.At(layerNum); if(CheckPointer(currentLayer) == POINTER_INVALID) return false; neuron = currentLayer.At(0); if(!neuron || !neuron.calcHiddenGradients(nextLayer.At(0), SecondInput, SecondGradient)) return false; }
请注意,在安排该过程时,我们假设误差梯度已经处于最后一个神经层的缓冲区当中。这是由 DDPG 算法(基于扮演者动作的评论者误差梯度)提供的。至于误差梯度的存在,没有控制。该方法如何应用是用户的责任。
在分派误差梯度后,我们将更新权重系数矩阵。
CLayer *prevLayer = layers.At(total - 1); for(int layerNum = total - 1; layerNum > 0; layerNum--) { currentLayer = prevLayer; prevLayer = layers.At(layerNum - 1); neuron = currentLayer.At(0); if(!neuron.UpdateInputWeights(prevLayer.At(0), SecondInput)) return false; }
在此,我们应当记住,在神经层方法中,我们只把内核放入执行队列当中。但在执行后续的正向验算之前,我们需要确保反向验算操作已完成。为了获得这种置信度,我们要加载权重矩阵最后一次内核更新的结果。
bool result=false; for(int layerNum = 0; layerNum < total; layerNum++) { currentLayer = layers.At(layerNum); CNeuronBaseOCL *temp = currentLayer.At(0); if(!temp) continue; if(!temp.TrainMode() || !temp.getWeights()) continue; if(!temp.getWeights().BufferRead()) continue; result=true; break; } //--- return result; }
更新函数库的类和方法的工作到此结束。它们的完整代码可以在附件中找到。
2.3. 创建模型训练 EA
接下来,我们将转入运用 DDPG 算法创建和训练模型。训练在 “DDPG\Study.mq5” EA 中实现。
正如我们所提,创建的模型将结合 DDPG 的元素和之前所讨论方法。这些都会反映在我们的模型架构之中。我们来创建描述体系结构的 CreateDescriptions 函数。
在参数中,该函数接收指向 2 个动态数组的指针,它们保存记录描述扮演者和评论者神经层架构的对象。在函数的主体中,我们检查接收指针的相关性,并在必要时创建新的数组对象。
bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; }
我们从扮演者架构的描述开始。此处,我们利用 GCRL 开发并构建一个包含 2 个源数据流的模型。扮演者的决策将基于环境的当前状态(历史数据)。我们将为其创建相应大小的源数据层。
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (HistoryBars * BarDescr); 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; }
原始数据由批量常规化层处理,并经由卷积层模块传递。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count - 1; descr.window = 2; descr.step = 1; descr.window_out = 8; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = 8; descr.step = 8; descr.window_out = 8; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
接下来,我们将数据压缩到 2 个全连接层。所有这些都可能会让您想起以前使用的编码器。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.optimization = ADAM; descr.activation = LReLU; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 128; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
对市场形势的评估也许足以判定交易方向和止损/止盈水平。不过,对于资金管理功能来说这还不够。在此阶段,我们将添加有关帐户状态的信息,就像陈述模型问题一样。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = 256; descr.window = prev_count; descr.step = AccountDescr; descr.optimization = ADAM; descr.activation = LReLU; if(!actor.Add(descr)) { delete descr; return false; }
记住该层的 ID,及其结果向量的大小。正是从这一层开始,我们将环境状态的隐含表示作为评论者的初始数据。
接下来是来自全连接层的决策模块。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
在扮演者的输出端,我们将有一个由 6 个元素组成的全连接层,这些元素代表交易量、止损和止盈(买入和卖出各 3 个元素)。
//--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 6; descr.optimization = ADAM; descr.activation = LReLU; if(!actor.Add(descr)) { delete descr; return false; }
在简化形式中,我们不会针对平仓和等待合适的入场/离场点的操作添加元素。我们假设持仓将以止损或止盈平仓。其中一笔业务指标发出的错误值,对应于没有交易操作。
评论者模型使用环境的当前状态和扮演者的动作来预测奖励。在我们的例子中,两种信息流都来自扮演者模型,尽管来自不同的神经层,且相应地来自不同的数据缓冲区。我们将使用神经数据连接层来组合两个数据流。这将在评论者模型的架构中有所反映,如下所示。我们将第一个数据流(当前状态的隐含表示)传输到源数据层。这个层的大小应与扮演者神经层的大小相对应,我们计划从中获取数据。
//--- Critic critic.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = 256; descr.window = 0; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
数据来自另一个模型的内部状态,我们可以跳过数据常规化层。
接下来,我们使用串联层来组合 2 个信息流。附加数据的大小等于扮演者结果层的大小。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = 128; descr.window = prev_count; descr.step = 6; descr.optimization = ADAM; descr.activation = LReLU; if(!critic.Add(descr)) { delete descr; return false; }
然后是由 2 个全连接层组成的决策模块。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 128; descr.activation = LReLU; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 128; descr.activation = LReLU; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
在评论者输出端使用含有 1 个元素,其不带激活函数的全连接层。此处,我们期望获得预测的奖励。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 1; descr.optimization = ADAM; descr.activation = None; if(!critic.Add(descr)) { delete descr; return false; } //--- return true; }
为了将来不与环境状态隐含表示层的标识符混淆,我们将以宏替换的形式定义一个常量。
#define LatentLayer 6
现在我们已经决定了模型的架构,我们将转入 EA 算法工作。首先,我们将创建 EA 的初始化 OnInit 方法。在方法伊始,如前,我们初始化指标和交易操作的对象。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- 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(HistoryBars) || !CCI.BufferResize(HistoryBars) || !ATR.BufferResize(HistoryBars) || !MACD.BufferResize(HistoryBars)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); return INIT_FAILED; } //--- if(!Trade.SetTypeFillingBySymbol(Symb.Name())) return INIT_FAILED;
然后,我们尝试加载预训练的模型。如果它们不存在,那么我们开始创建模型。
在此,我们应当注意一个细微差别。虽然我们之前创建了一个训练模型,并将其完全复制到目标模型之中,但现在我们使用随机参数初始化训练模型和目标模型。此外,两种模型都使用相同的架构。
//--- load models float temp; if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) || !Critic.Load(FileName + "Crt.nnw", temp, temp, temp, dtStudied, true) || !TargetActor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) || !TargetCritic.Load(FileName + "Crt.nnw", temp, temp, temp, dtStudied, true)) { CArrayObj *actor = new CArrayObj(); CArrayObj *critic = new CArrayObj(); if(!CreateDescriptions(actor, critic)) { delete actor; delete critic; return INIT_FAILED; } if(!Actor.Create(actor) || !Critic.Create(critic) || !TargetActor.Create(actor) || !TargetCritic.Create(critic)) { delete actor; delete critic; return INIT_FAILED; } delete actor; delete critic; //--- }
接下来,我们将所有模型转移到单个 OpenCL 关联环境中。这令我们在模型之间传输信息时能使用指向数据缓冲区的指针进行操作,而无需进行物理复制。
COpenCLMy *opencl = Actor.GetOpenCL(); Critic.SetOpenCL(opencl); TargetActor.SetOpenCL(opencl); TargetCritic.SetOpenCL(opencl);
接下来是一个监视模型架构一致性的模块。
Actor.getResults(Result); if(Result.Total() != 6) { PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", 6, Result.Total()); return INIT_FAILED; } ActorResult = vector<float>::Zeros(6); //--- Actor.GetLayerOutput(0, Result); if(Result.Total() != (HistoryBars * BarDescr)) { PrintFormat("Input size of Actor doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr)); return INIT_FAILED; } //--- Actor.GetLayerOutput(LatentLayer, Result); int latent_state = Result.Total(); Critic.GetLayerOutput(0, Result); if(Result.Total() != latent_state) { PrintFormat("Input size of Critic doesn't match latent state Actor (%d <> %d)", Result.Total(), latent_state); return INIT_FAILED; }
初始化全局变量,并终止该方法。
PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE); PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY); FirstBar = true; Gradient.BufferInit(AccountDescr, 0); Gradient.BufferCreate(opencl); //--- return(INIT_SUCCEEDED); }
我们判定目标模型将在每个世代更新。因此,此寒素包含在 EA 逆初始化方法之中。我们首先更新目标模型。然后我们保存它们。注意,我们保存的是目标模型,而不是经过训练的模型。因此,我们希望把单个世代的模型重新训练最小化。
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- TargetActor.WeightsUpdate(GetPointer(Actor), Tau); TargetCritic.WeightsUpdate(GetPointer(Critic), Tau); TargetActor.Save(FileName + "Act.nnw", Actor.getRecentAverageError(), 0, 0, TimeCurrent(), true); TargetCritic.Save(FileName + "Crt.nnw", Critic.getRecentAverageError(), 0, 0, TimeCurrent(), true); delete Result; }
训练模型的实际过程是在动作流中运转的。在我们的例子中,我们将在策略测试器的历史演练模式下训练模型。我们不会创建经验回放缓冲区。它的角色将由策略测试者本身执行。因此,整个学习过程都安排在 OnTick 函数当中。
在函数开始时,我们检查新的蜡烛开盘事件。之后,我们更新在缓冲区中的指标数据、和有关金融产品价格走势的历史数据。
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- if(!IsNewBar()) return; //--- int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates); if(!ArraySetAsSeries(Rates, true)) return; //--- RSI.Refresh(); CCI.Refresh(); ATR.Refresh(); MACD.Refresh(); Symb.Refresh(); Symb.RefreshRates();
数据准备过程已完全从前面讨论过的 EA 转移过来。此处有一点要在这里讲述。在附件中找到完整的 EA 代码及其所有函数。
在准备好初始数据后,我们检查训练好的模型之前是否进行了前向验算。如果有正向验算,则进行反向验算。为了评估当前状态,我们将执行目标模型的前向验算。注意,我们首先执行目标扮演者模型的前向验算。我们参考形成的动作,运行一次评论者目标模型的直接验算。将系统的实际奖励以账户余额变化的形式添加到结果值之中。此外,如果没有持仓,我们将增加惩罚,以便鼓励扮演者积极交易,先调用评论者的反向验算,然后再是扮演者。
if(!FirstBar) { if(!TargetActor.feedForward(GetPointer(State), 1, false, GetPointer(Account))) return; if(!TargetCritic.feedForward(GetPointer(TargetActor), LatentLayer, GetPointer(TargetActor))) return; TargetCritic.getResults(Result); float reward = (float)(account[0] - PrevBalance + Result[0]); if(account[0] == PrevBalance) if((buy_value + sell_value) == 0) reward -= 1; Result.Update(0, reward); if(!Critic.backProp(Result, GetPointer(Actor)) || !Actor.backPropGradient(GetPointer(PrevAccount), GetPointer(Gradient))) return; }
注意,对于评论者反向验算,我们使用更新的 backProp 方法传递目标值的缓冲区,和指向扮演者模型的指针。同时,我们没有指示隐含层的标识符,因为我们之前(在直接验算期间)替换了对象。
至于扮演者的反向验算,我们使用 backPropGradient 方法,其中来自评论者反向验算的梯度经模型传播。
执行评论者和扮演者的反向验算令我们能优化模型的 Q-函数。
接下来,我们将基于已训练模型执行前向验算。
if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account))) return; if(!Critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor))) return;
此处值得关注的是:在训练 Q-函数的过程中,我们只提高对预期奖励的预测品质。不过,我们不会训练扮演者来提升其行为的盈利能力。为此目的,DDPG 算法提供了顺增加预测奖励的方向更新扮演者参数。值得注意的是,此时我们通过评论者传递误差梯度,但不更新其参数。因此,我们通过将 TrainMode 标志设置为 “false” 来禁止更新评论者权重矩阵。在扮演者的反向验算之后,我们再把标志位置返回为 'true'。
if(!FirstBar) { Critic.getResults(Result); Result.Update(0, Result.At(0) + MathAbs(Result.At(0) * 0.0001f)); Critic.TrainMode(false); if(!Critic.backProp(Result, GetPointer(Actor)) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient))) return; Critic.TrainMode(true); }
将下一根柱线上的操作值保存到全局变量之中。
FirstBar = false; PrevAccount.AssignArray(GetPointer(Account)); PrevAccount.BufferCreate(Actor.GetOpenCL()); PrevBalance = account[0]; PrevEquity = account[1];
然后我们只需要破译扮演者的操作结果,并采取交易操作。在此示例中,我们训练扮演者来提供交易量和交易价位的绝对值。我们只对数据进行常规化,并将价位转换为特定的价格值。
vector<float> temp; Actor.getResults(temp); float delta = MathAbs(ActorResult - temp).Sum(); ActorResult = temp; //--- double min_lot = Symb.LotsMin(); double stops = MathMax(Symb.StopsLevel(), 1) * Symb.Point(); double buy_lot = MathRound((double)ActorResult[0] / min_lot) * min_lot; double sell_lot = MathRound((double)ActorResult[3] / min_lot) * min_lot; double buy_tp = NormalizeDouble(Symb.Ask() + ActorResult[1], Symb.Digits()); double buy_sl = NormalizeDouble(Symb.Ask() - ActorResult[2], Symb.Digits()); double sell_tp = NormalizeDouble(Symb.Bid() - ActorResult[4], Symb.Digits()); double sell_sl = NormalizeDouble(Symb.Bid() + ActorResult[5], Symb.Digits()); //--- if(ActorResult[0] > min_lot && ActorResult[1] > stops && ActorResult[2] > stops && buy_sl > 0) Trade.Buy(buy_lot, Symb.Name(), Symb.Ask(), buy_sl, buy_tp); if(ActorResult[3] > min_lot && ActorResult[4] > stops && ActorResult[5] > stops && sell_tp > 0) Trade.Sell(sell_lot, Symb.Name(), Symb.Bid(), sell_sl, sell_tp);
我要提醒您,我们没有针对等待合适形势提供单独的扮演者行动。代之,我们使用无效的交易参数值。因此,我们在发送交易请求之前,要检查接收到的参数的正确性。
值得注意的是,多出的这一点并非由所研究的算法提供的,而是由我添加的。它与所研究的方法并不矛盾。它只在扮演者的训练策略中引入了一些限制。以这种方式,我想在持仓的交易量和交易水平的大小中引入一些框架。
当收到不正确或夸张的交易参数时,我在指定范围内形成一个随机目标值的向量,并执行类似于监督学习方法的扮演者反向验算。以我的观点,这应将扮演者的操作结果返回到指定的限制。
if(temp.Min() < 0 || MathMax(temp[0], temp[3]) > 1.0f || MathMax(temp[1], temp[4]) > (Symb.Point() * 5000) || MathMax(temp[2], temp[5]) > (Symb.Point() * 2000)) { temp[0] = (float)(Symb.LotsMin() * (1 + MathRand() / 32767.0 * 5)); temp[3] = (float)(Symb.LotsMin() * (1 + MathRand() / 32767.0 * 5)); temp[1] = (float)(Symb.Point() * (MathRand() / 32767.0 * 500.0 + Symb.StopsLevel())); temp[4] = (float)(Symb.Point() * (MathRand() / 32767.0 * 500.0 + Symb.StopsLevel())); temp[2] = (float)(Symb.Point() * (MathRand() / 32767.0 * 200.0 + Symb.StopsLevel())); temp[5] = (float)(Symb.Point() * (MathRand() / 32767.0 * 200.0 + Symb.StopsLevel())); Result.AssignArray(temp); Actor.backProp(Result, GetPointer(PrevAccount), GetPointer(Gradient)); } }
当然,我们可以使用约束激活函数(例如 sigmoid)作为替代。但之后我们要严格限制可能的数值范围。此外,在训练期间,我们可以快速触及极限值,从而减慢模型的进一步训练速度。
所有操作完成后,我们进入等待模式,等待下一次跳价。
附件中提供了 EA 的完整代码和本文中用到的所有程序。
3. 测试
在完成模型训练 EA 的工作后,我们转入验证所完成工作结果的阶段。如前,该模型是依据 2023 年初的 EURUSD H1 的历史数据进行训练的。所有指标和模型训练参数均采用默认值。
实时训练模型会自行调整,并防止使用若干个并行代理者。因此,EA 算法正确性操作的首次检查,是在单独运行模式下运转。然后选择慢速优化模式,仅激活 1 个局部优化代理者。
为了调节训练迭代次数,添加了一个外部参数 Agent,该参数在 EA 算法中并未用到。
经过大约 3000 次验算后,我得到一个能够在训练集上产生盈利的模型。在 5 个月的训练期间,该模型进行了 334 笔交易。其中超过 84% 是盈利的。结果利润达到初始资本的 33%。同时,余额的回撤不到 1%,按净值则为 7.6%。盈利因子超过 26,恢复因子为 3.16。下图展示了余额的上升趋势。余额线几乎总是低于净值线,这表明开仓方向正确。同时,资金的负载约为 20%。这是一个相当高的数字,但不超过累计利润。
不幸的是,EA 的操作结果摆明在训练集之外比较温和。
结束语
在本文中,我们探讨了强化学习在连续动作空间背景下的应用,并讲述了深度判定性策略梯度(DDPG)方法。该方式为训练代理者管理资本和风险开辟了新的机会,这是成功交易的一个重要方面。
我们已经开发并测试了训练模型的 EA。它不仅可以预测交易方向,还可判定交易量、止损和止盈价位。这令代理者能更有效地管理投资。
在测试期间,我们设法训练模型,从容在训练集上产生利润。不幸的是,所提供的训练不足以在训练集之外获得类似的结果。我们的实现瓶颈是模型的在线训练,其不允许并行使用若干个代理者来提高环境研究水平,减少模型训练时间。
所获结果令我们希望有办法训练模型在训练集之外能稳定运行。
参考文献列表
本文中用到的程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Study.mq5 | 智能交易系统 | 代理者训练 EA |
2 | Test.mq5 | 智能交易系统 | 模型测试 EA |
3 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
4 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/12853


