
神经网络变得简单(第 70 部分):封闭式政策改进运算器(CFPI)
概述
约束智能体行为的情况下优化其政策的方式,事实证明,在解决离线强化学习问题方面很有前景。通过利用历史过渡,智能体政策经过训练,可以最大化所学习的数值函数。
行为约束政策有助于避免与智能体动作相关的重大分布偏移,这为评估动作成本提供了足够的信心。在上一篇文章中,我们领略了 SPOT 方法,它利用了这种方式。作为该主题的延续,我建议领略封闭式政策改进(CFPI)算法,其在论文 《依据封闭式政策改进运算器的离线强化学习》中提出。
1. 封闭式政策改进(CFPI)算法
封闭式表达式是使用有限数量的标准运算表示的数学函数。它可以包含常量、变量、标准运算符、和函数,但通常不包含限制、微分或积分表达式。因此,我们正在研究的 CFPI 方法将一些分析粒度引入智能体政策学习算法之中。
大多数现有的离线强化学习模型都采用随机梯度下降(SGD)来优化其策略,这可能导致训练过程不稳定,需要仔细调整学习率。此外,离线训练策略的性能也许取决于特定的估测点。这往往会导致学习的最后阶段出现重大变数。这种不稳定性在离线强化学习中带来了重大挑战,因为与环境交互的机会有限,因此很难调整超参数。除了不同估测点之间的差异外,使用 SGD 改进策略还可能导致在不同随机初始条件下的性能发生重大变数。
在他们的工作中,CFPI 方法的作者旨在减少上述离线 RL 学习的不稳定性。他们开发稳定的策略改进运算器。特别是,他们指出,限制分布偏移的需要促使使用一阶泰勒(Taylor)近似,从而导致智能体的政策意向函数的线性近似,在行为策略足够小的邻域内是准确的。基于这一关键观测,该方法的作者构造了策略改进运算器,可返回封闭式解。
通过将行为策略建模成单一高斯(Gaussian)分布,CFPI 作者提议的策略改进运算器判定性地将行为政策向提升数值的方向偏移。结果就是,所提议的封闭式政策Po改进方法避免了策略改进的学习不稳定性,因为它仅学习给定数据集的基本行为策略。
CFPI 方法的作者还提醒,实践数据集往往是使用异构策略收集的。这可能会导致智能体动作的多模态分布。单一高斯分布将无法捕获基础分布的众多模式,从而限制了策略改进的潜力。将行为策略建模为高斯分布的混合,可以提供更好的表现力,但会带来额外的优化困难。该方法的作者通过使用 LogSumExp 和 Jensen 不等式的下限来解决这个问题,这也导致了适用于多模态行为策略的封闭式政策改进运算器。
作者强调了封闭式策略改进方法的以下贡献:
- CFPI 运算器,与单模态和多模态行为策略兼容,并且可以改进据其它算法学习过的策略。
- 将行为策略建模为高斯分布混合的好处之经验证据。
- 在标准基准上,所提议算法的单步和迭代变体优于现有算法。
CFPI 的作者创建了一个无需训练即可分析策略改进的运算器,以避免离线场景中的不稳定。他们指出,意向函数的统筹优化会生成一个策略,其能够约束来自离线样本中的行为策略偏差。因此,它只会在训练期间查询行为附近的 Q-值。这自然而然地促进了一阶线性近似的使用。
同时,更新策略中对动作的估测,仅在训练样本分布的足够小的邻域中提供学习值函数的准确线性近似值。因此,从训练数据集中选择状态-动作配对,对于最终学习结果至关重要。
为了解决这个问题,作者提议针对任何状态 S 求解以下近似问题:
应当注意的是,D(•,•) 不一定是数学定义的发散函数。我们可以考虑任何一般的 D(•,•),它可以约束智能体的行为政策距训练数据集分布的偏差。
一般来说,上述问题并不总是有封闭式的解。CFPI 方法的作者分析了一个特殊情况:
- 使用高斯策略收集训练数据集。
- 然后,他们训练智能体的确定性行为政策。
- D(•,•) 是负似然函数。
在这种场景下,政策训练的合理选择集中在围绕训练数据集的分布。然后,提议的优化问题可以表示为封闭式表达式:
使用这种封闭式表达式来改进智能体政策可以带来有益的计算效率,并避免 SGD 导致的潜在不稳定性。不过,其适用性取决于训练数据集所收集策略的单一高斯假设。在实践中,所收集历史数据集通常含有不同专业知识水平的异构策略。一维高斯分布也许无法捕获整个分布全貌,故看似使用高斯分布的混合来表示数据收集政策是合理的。
然而,直接替换高斯的混合来训练数据收集策略违反了上述问题的适用性,因为它会导致非凸意向函数。在此,我们在解决优化问题时会面临两个主要挑战。
首先,目前尚不清楚如何从训练数据集中选择相应的动作。此处,还有必要确保目标政策的解位于所选动作的小邻域中。
其次,使用高斯分布的混合不允许凸形式,这会导致优化困难。
使用 LogSumExp 可以转换优化问题。
这可以表示为封闭式表达式。
使用 Jensen 不等式可令我们得到以下优化问题:
该问题的封闭式解如下所示:
与最初的优化问题相比,这两个提议的扩展都施加了更严格的置信区间约束。这是通过为高于某个阈值的高斯混合的对数似然提供下限来达成的。同时,参数 τ 控制置信间隔的大小。
这两个优化问题都有其优、缺点。当训练数据集分布表现出明显的多模态性时,由 Jensen 不等式构建的数据收集政策的对数下边界,由于其凹度而无法捕获不同的模式,失去了将数据收集政策建模为高斯混合的优势。在这种情况下,LogSumExp 优化问题可以作为原始优化问题的合理替代,因为 LogSumExp 的下边界保留了数据收集政策对数的多模态性。
当训练数据集的分布降低到单一高斯时,Jensen 不等式的近似值变为相等。因此,µjensen 精确求解给定的优化问题。不过,在这种情况下,LogSumExp 下边界的准确度在很大程度上取决于权重 λi=1...N。
幸运的是,我们可以结合这两种方式各自的最佳品质,并考虑到上述所有场景,获得一个 CFPI 运算器,该运算器返回一个行为政策,该策略从 µlse 和 µjensen 中选择排名较高的动作:
在原始论文中,您可以找到所有所呈现表达式的适用性计算和证据的详情。
CFPI 方法的作者指出,所提议方法也适用于训练数据集的非高斯分布。同时,所呈现 CFPI 运算器允许您创建用于离线学习的通用模板,并能够获得单步、多步和迭代方法。
使用预先训练的 Critic 模型来估测动作。它可在训练数据集上按任何已知方式进行训练。这实际上是模型训练算法的第一阶段。
接下来,从训练数据集中采样某个状态数据包。考虑到当前的智能体政策,为该数据包生成动作。然后,考虑到上述提议的 CFPI 运算器,估测结果动作。
根据该估测结果,选择最优状态,此时将更新智能体政策。
在构建多步骤和迭代方法时,将重复该过程。
尽管 CFPI 运算器的设计受到行为智能体政策约束范式的启发,但所提议方法与常见的基本强化学习方法兼容。作者在他们的论文中演示了 CFPI 运算器提高使用其它算法学习的策略效率的示例。
2. 利用 MQL5 实现
以上是有关封闭式政策改进方法的理论阐述。我同意所提议数学方程式也许看起来相当复杂。故此,我们尝试在实现所提议方法的过程中更详尽地了解它们。
应当马上注意到的是,本文作者提议的模型训练算法提供了评论者和扮演者的顺序训练。首先训练评论者模型。只有在那之后,我们才能开始训练扮演者政策。
按这种方式,当评论者使用扮演者模型对源数据进行初步处理时,我们的技术变得无关紧要。因为在训练评论者的阶段,扮演者模型尚未形成。当然,我们可以生成一个扮演者模型,并像以前那样使用它。但在这种情况下,我们遇到了以下问题:在政策训练阶段,CFPI 算法不能提供更新评论者模型。更改扮演者的参数必然会导致更改源数据初步处理的参数。在这种情况下,评论者输入处的分布会发生变化。这通常会导致扮演者动作的估测失真。
为了纠正所描述的状况,我们可以避免使用通用的初始状态编码器,或将其移到单独的模型之中。
我们无法将编码器转移到评论者模型,因为评论者的前馈验算需要扮演者生成的动作。此外,扮演者的前馈验算需要编码器的结果。圆环闭合了。
2.1模型架构
在我的实现中,我决定将环境状态编码器作为单独的模型创建。这反过来又影响了模型的架构。模型架构的描述在 CreateDescriptions 方法中给出。尽管对扮演者和评论者模型进行了一致的训练,但我并没有将模型架构的描述切分为 2 种方法。因此,在参数中,该方法接收指向记录模型架构的 3 个动态对象数组的指针。
在方法的主体中,我们检查所接收指针的相关性,并在必要时创建新的数组对象实例。
//+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic, CArrayObj *encoder) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) 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 = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!encoder.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(!encoder.Add(descr)) { delete descr; return false; }
成品 “原始” 数据在批量常规化层中进行预处理。
接下来是卷积模块,它允许我们降低数据维度,同时还可以识别数据中的稳定形态。
//--- layer 2 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 3 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 4 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 5 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; }
卷积模块的结果由 2 个完全连接神经层处理。
//--- layer 6 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 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
以这种方式处理的数据补充了有关帐户状态的信息,其中包括时间戳谐波。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = 2 * LatentCount; descr.window = prev_count; descr.step = AccountDescr; descr.optimization = ADAM; descr.activation = SIGMOID; if(!encoder.Add(descr)) { delete descr; return false; }
我们在编码器的输出处创建随机性。这令我们能够减少模型过拟合的概率,并提高模型在随机外部环境中的稳定性。
//--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = LatentCount; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
接下来是扮演者架构的描述。它接收上述环境编码器的结果作为输入。
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
如您所见,准备初始数据的所有准备工作都在编码器中执行。这允许我们令扮演者模型尽可能简单。此处,我们创建 3 个完全连接层。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; 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; }
评论者模型还使用编码器结果作为输入。但与扮演者模型不同的是,它用到计算出的 Actions 向量来补充结果。因此,在源数据层之后,我们使用一个结合了 2 个源数据张量的串联层。
//--- Critic critic.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = None; descr.optimization = ADAM; if(!critic.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 = NActions; descr.optimization = ADAM; descr.activation = LReLU; if(!critic.Add(descr)) { delete descr; return false; }
接下来是来自完全连接神经层的决策模块。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; 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 = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = NRewards; descr.optimization = ADAM; descr.activation = None; if(!critic.Add(descr)) { delete descr; return false; } //--- return true; }
如此这般,我们完成了模型架构的描述,并转到构造模型学习算法。
当然,在我们开始训练模型之前,我们需要收集一个训练数据集。现在请注意以下几点。这一次,我不能按不变形式使用以前作品中的环境交互智能系统。模型架构发生了变化,环境状态编码器已分离到外部模型当中。这影响到我们原来智能系统的算法。然而,这些修改仅针对某些点进行,您在文件 “...\Experts\CFPI\Research.mq5” 和 “...\Experts\CFPI\Test.mq5” 中可以领会这些点。附件中提供了这些文件。现在,我们转到为评论者1构建学习算法。
2.2 评论者训练
评论者模型训练算法在 EA “...\Experts\CFPI\StudyCritic.mq5” 中实现。在该 EA 中,我们有两个并行训练的评论者模型。如您所知,用两个评论者令我们能够提高扮演者行为政策后续训练的稳定性和效率。我们将配合评论者模型一起,为环境状态训练一个通用的编码器。
//+------------------------------------------------------------------+ //| Input parameters | //+------------------------------------------------------------------+ input int Iterations = 1e6; //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ STrajectory Buffer[]; CNet StateEncoder; CNet Critic1; CNet Critic2;
在 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(!StateEncoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true) || !Critic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) || !Critic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true)) { Print("Init new models"); CArrayObj *actor = new CArrayObj(); CArrayObj *critic = new CArrayObj(); CArrayObj *encoder = new CArrayObj(); if(!CreateDescriptions(actor, critic, encoder)) { delete actor; delete critic; delete encoder; return INIT_FAILED; } if(!Critic1.Create(critic) || !Critic2.Create(critic) || !StateEncoder.Create(encoder)) { delete actor; delete critic; delete encoder; return INIT_FAILED; } delete actor; delete critic; delete encoder; //--- }
我们将所有模型传输到单个 OpenCL 关联环境当中,从而实现模型之间的数据交换,且不会将不必要的信息传输到主程序内存,之后返回。
//---
OpenCL = Critic1.GetOpenCL();
Critic2.SetOpenCL(OpenCL);
StateEncoder.SetOpenCL(OpenCL);
为了消除模型之间数据传输中可能出现的错误,我们检查它们是否符合所用数据的统一布局。
//--- StateEncoder.getResults(Result); if(Result.Total() != LatentCount) { PrintFormat("The scope of the State Encoder does not match the latent size count (%d <> %d)", LatentCount, Result.Total()); return INIT_FAILED; } //--- StateEncoder.GetLayerOutput(0, Result); if(Result.Total() != (HistoryBars * BarDescr)) { PrintFormat("Input size of State Encoder doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr)); return INIT_FAILED; } //--- Critic1.GetLayerOutput(0, Result); if(Result.Total() != LatentCount) { PrintFormat("Input size of Critic1 doesn't match State Encoder output (%d <> %d)", Result.Total(), LatentCount); return INIT_FAILED; } //--- Critic2.GetLayerOutput(0, Result); if(Result.Total() != LatentCount) { PrintFormat("Input size of Critic2 doesn't match State Encoder output (%d <> %d)", Result.Total(), LatentCount); return INIT_FAILED; }
成功通过所有控制后,我们初始化辅助数据缓冲区。
//--- Gradient.BufferInit(AccountDescr, 0);
我们还初始化了一个自定义事件来启动模型训练过程。
//--- if(!EventChartCustom(ChartID(), 1, 0, 0, "Init")) { PrintFormat("Error of create study event: %d", GetLastError()); return INIT_FAILED; } //--- return(INIT_SUCCEEDED); }
之后,我们完成 EA 初始化方法的操作。
在 EA 逆初方法中,我们保存经过训练的模型,并清除内存。
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- if(!(reason == REASON_INITFAILED || reason == REASON_RECOMPILE)) { StateEncoder.Save(FileName + "Enc.nnw", 0, 0, 0, TimeCurrent(), true); Critic1.Save(FileName + "Crt1.nnw", Critic1.getRecentAverageError(), 0, 0, TimeCurrent(), true); Critic2.Save(FileName + "Crt2.nnw", Critic2.getRecentAverageError(), 0, 0, TimeCurrent(), true); } delete Result; delete OpenCL; }
训练模型的实际过程在 Train 方法中实现。在方法的主体中,我们首先计算从经验回放缓冲区中选择轨迹的加权概率。
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { //--- vector<float> probability = GetProbTrajectories(Buffer, 0.9);
然后我们声明局部变量,并创建一个训练循环,其迭代次数等于用户在 EA 的外部参数中指定的数值。
vector<float> rewards, rewards1, rewards2, target_reward; uint ticks = GetTickCount(); //--- for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++) {
在训练循环主体中,我们对轨迹及其状态进行采样。
int tr = SampleTrajectory(probability); int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 3)); if(i < 0) { iter--; continue; }
之后,我们填充源数据缓冲区。首先,我们取来自经验回放缓冲区的价格走势,和所分析指标的数值填充缓冲区,用来描述环境状态。
//--- Q-function study
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]; Account.Clear(); Account.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance); Account.Add(Buffer[tr].States[i].account[1] / PrevBalance); Account.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity); Account.Add(Buffer[tr].States[i].account[2]); Account.Add(Buffer[tr].States[i].account[3]); Account.Add(Buffer[tr].States[i].account[4] / PrevBalance); Account.Add(Buffer[tr].States[i].account[5] / PrevBalance); Account.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'); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_MN1); Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_W1); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_D1); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); if(Account.GetIndex() >= 0) Account.BufferWrite();
收集的数据足以进行环境状态编码器的前馈验算。
//--- if(!StateEncoder.feedForward(GetPointer(State), 1, false, GetPointer(Account))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
如上所述,在此阶段,我们不用扮演者模型。评论者通过监督学习方法进行训练,该方法取从环境中收到的实际动作和奖励的估值,这些动作和奖励以前存储在训练数据集之中。因此,对于两个评论者的前馈验算,我们用环境状态编码器的结果和训练数据集中的动作向量。
//--- Actions.AssignArray(Buffer[tr].States[i].action); if(Actions.GetIndex() >= 0) Actions.BufferWrite(); //--- if(!Critic1.feedForward(GetPointer(StateEncoder), -1, GetPointer(Actions)) || !Critic2.feedForward(GetPointer(StateEncoder), -1, GetPointer(Actions))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
我们检查操作的正确性,并加载两个评论者的前馈验算结果。
//---
Critic1.getResults(rewards1);
Critic2.getResults(rewards2);
下一步是生成训练模型的目标值。如上所述,我们将从训练数据集中训练实际值。在此阶段,我们用一个奖励来一次过渡到新状态。为了提高收敛性,我们调用 CAGrad 方法调整误差梯度向量的方向。
模型的参数是逐个调整的。首先,我们调整第一个评论者的参数,然后调用环境状态编码器的反向传播验算方法。
rewards.Assign(Buffer[tr].States[i + 1].rewards); target_reward.Assign(Buffer[tr].States[i + 2].rewards); rewards = rewards - target_reward * DiscFactor; Result.AssignArray(CAGrad(rewards - rewards1) + rewards1); if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) || !StateEncoder.backPropGradient(GetPointer(Account), GetPointer(Gradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
然后我们针对第二个评论者重复操作。
Result.AssignArray(CAGrad(rewards - rewards2) + rewards2); if(!Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) || !StateEncoder.backPropGradient(GetPointer(Account), GetPointer(Gradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
请注意,在更新每个评论者之后,编码器参数也会进行调整。因此,我们正在努力令环境嵌入尽可能地提供信息和准确。
成功更新模型参数后,我们只需要通知用户训练进度,并转到循环的下一次迭代。
//--- if(GetTickCount() - ticks > 500) { string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic1", iter * 100.0 / (double)(Iterations), Critic1.getRecentAverageError()); str += StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic2", iter * 100.0 / (double)(Iterations), Critic2.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } }
完成学习循环系统的所有迭代之后,我们清除图表上的注释字段。我们还将有关训练结果的信息输出到日志中,并启动 EA 终止。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic1", Critic1.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic2", Critic2.getRecentAverageError()); ExpertRemove(); //--- }
完整的智能系统代码可在附件中找到。
2.3行为政策训练
在训练评论者之后,我们转到下一个阶段 — 训练扮演者行为政策。我们在 EA “...\Experts\CFPI\Study.mq5” 中实现此功能。首先,对于外部参数,我们添加数据包的大小,我们将在其中选择最优点进行训练。
//+------------------------------------------------------------------+ //| Input parameters | //+------------------------------------------------------------------+ input int Iterations = 10000; input int BatchSize = 256;
在 EA 初始化方法中,我们首先加载训练集。
CNet Actor; CNet Critic1; CNet Critic2; CNet StateEncoder;
在 EA 初始化方法中,我们首先加载训练集。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- ResetLastError(); if(!LoadTotalBase()) { PrintFormat("Error of load study data: %d", GetLastError()); return INIT_FAILED; }
之后,我们加载模型。我们首先加载预先训练的环境状态编码器和评论者模型。如果这些模型不可用,我们就无法进一步运行学习过程。故此,如果在加载模型时发生错误,我们就终止 EA 运作。
//--- load models float temp; if(!StateEncoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true) || !Critic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) || !Critic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true)) { Print("Can't load Critic models"); return INIT_FAILED; }
如果没有预训练的扮演者,我们初始化一个填充了随机参数的新模型。
if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true)) { Print("Init new models"); CArrayObj *actor = new CArrayObj(); CArrayObj *critic = new CArrayObj(); if(!CreateDescriptions(actor, critic, critic)) { delete actor; delete critic; return INIT_FAILED; } if(!Actor.Create(actor)) { delete actor; delete critic; return INIT_FAILED; } delete actor; delete critic; }
我们将所有模型传输到一个 OpenCL 关联环境,并禁用编码器和评论者的训练模式。
OpenCL = Actor.GetOpenCL(); Critic1.SetOpenCL(OpenCL); Critic2.SetOpenCL(OpenCL); StateEncoder.SetOpenCL(OpenCL); //--- StateEncoder.TrainMode(false); Critic1.TrainMode(false); Critic2.TrainMode(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; }
StateEncoder.GetLayerOutput(0, Result); if(Result.Total() != (HistoryBars * BarDescr)) { PrintFormat("Input size of State Encoder doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr)); return INIT_FAILED; }
StateEncoder.getResults(Result); int latent_state = Result.Total(); Critic1.GetLayerOutput(0, Result); if(Result.Total() != latent_state) { PrintFormat("Input size of Critic1 doesn't match output State Encoder (%d <> %d)", Result.Total(), latent_state); return INIT_FAILED; }
Critic2.GetLayerOutput(0, Result); if(Result.Total() != latent_state) { PrintFormat("Input size of Critic2 doesn't match output State Encoder (%d <> %d)", Result.Total(), latent_state); return INIT_FAILED; }
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; }
如果检查模块成功完成,我们可以转到下一步。我们初始化辅助缓冲区,并生成自定义事件,以便启动学习过程。
Gradient.BufferInit(AccountDescr, 0); //--- if(!EventChartCustom(ChartID(), 1, 0, 0, "Init")) { PrintFormat("Error of create study event: %d", GetLastError()); return INIT_FAILED; } //--- return(INIT_SUCCEEDED); }
这样就完成了 EA 初始化方法的操作。在 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); delete Result; delete OpenCL; }
扮演者模型训练过程在 Train 方法中实现。在方法的主体中,我们首先确定从训练数据集中选择轨迹的概率。
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { //--- vector<float> probability = GetProbTrajectories(Buffer, 0.9);
之后,我们将创建必要的局部变量。
//--- vector<float> rewards, rewards1, rewards2, target_reward; vector<float> action, action_beta; float Improve = 0; int bar = (HistoryBars - 1) * BarDescr; uint ticks = GetTickCount();
接下来,我们取 EA 外部参数中指定的迭代次数创建一个模型训练循环。
//--- for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++) {
在训练扮演者行为政策的循环主体中,我们将采用的方式就是调用 CFPI 方法。首先,我们需要从训练数据集中采样一批数据。我们需要生成和估测当前扮演者政策在选定状态下的动作。为了执行这些操作,我们创建一个嵌套循环,其迭代次数等于正在分析的数据包的大小。我们将操作结果保存到局部 mBatch 矩阵当中。
matrix<float> mBatch = matrix<float>::Zeros(BatchSize, 4); for(int b = 0; b < BatchSize; b++) { int tr = SampleTrajectory(probability); int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2)); if(i < 0) { b--; continue; }
采样操作与我们早前执行的操作类似。
我们用来自每个选定状态的数据填充描述缓冲区的环境状态。
//--- State
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]; Account.Clear(); Account.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance); Account.Add(Buffer[tr].States[i].account[1] / PrevBalance); Account.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity); Account.Add(Buffer[tr].States[i].account[2]); Account.Add(Buffer[tr].States[i].account[3]); Account.Add(Buffer[tr].States[i].account[4] / PrevBalance); Account.Add(Buffer[tr].States[i].account[5] / PrevBalance); Account.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'); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_MN1); Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_W1); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_D1); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); if(Account.GetIndex() >= 0) Account.BufferWrite();
运行状态编码器前馈方法。
//--- State embedding if(!StateEncoder.feedForward(GetPointer(State), 1, false, GetPointer(Account))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
生成环境状态嵌入后,我们会根据当前政策生成智能体动作。
//--- Action if(!Actor.feedForward(GetPointer(StateEncoder), -1, NULL, 1)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
生成的动作由两个评论者进行评测。
//--- Cost if(!Critic1.feedForward(GetPointer(StateEncoder), -1, GetPointer(Actor)) || !Critic2.feedForward(GetPointer(StateEncoder), -1, GetPointer(Actor))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
成功完成所有操作后,我们将结果上传到局部向量。然后从训练数据集中形成类似的数据向量。
Critic1.getResults(rewards1); Critic2.getResults(rewards2); Actor.getResults(action); action_beta.Assign(Buffer[tr].States[i].action); rewards.Assign(Buffer[tr].States[i + 1].rewards); target_reward.Assign(Buffer[tr].States[i + 2].rewards);
分析状态的坐标,并按照轨迹和状态的索引保存到结果矩阵之中。我们还保存了动作向量的偏差,及其对结果的影响。
//--- Collect mBatch[b, 0] = float(tr); mBatch[b, 1] = float(i); mBatch[b, 2] = MathMin(rewards1.Sum(), rewards2.Sum()) - (rewards - target_reward * DiscFactor).Sum(); mBatch[b, 3] = MathSqrt(MathPow(action - action_beta, 2).Sum()); }
之后,我们转到采样和评测下一个状态。
来自整个数据包的数据处理和收集完毕之后,我们需要选择最优状态来优化扮演者行为政策。在这个阶段,我们需要选择一个具有可靠评论者估测,且对模型结果影响最大的状态。
关于动作评测的可靠性,我们已经说过,当与训练数据集分布的偏差最小时,评论者对动作的评测更准确。随着偏差的增加,评论者评测的准确性会降低。按照这个逻辑,动作评测准确性的标准可以是动作之间的距离,我们将其存储在分析矩阵索引为 3 的列中。
现在我们需要选择一个置信区间。在原始论文中,CFPI 方法的作者采用了分布的方差。不过,我们不能对动作偏差向量取方差。事实是,方差被认为是与分布中间的标准差。在我们的例子中,我们保留了偏差的绝对值。因此,评论者的估测最准确的零偏差只能是一个极端。分布的平均值与这一点相去甚远。因此,在这种情况下使用方差并不能保证动作估测的预期准确性。
但在这里,我们可以使用 “3 西格玛” 规则:在正态分布中,68% 的数据与数学期望的偏差不超过 1 个标准差。这意味着我们可以使用定量函数来判定置信区间。使用非常简单的数学运算,我们创建 weights 向量,对于偏差大于置信区间的操作,其值为零,对于其余的动作,其它值为 “1”。
action = mBatch.Col(3); float quant = action.Quantile(0.68); vector<float> weights = action - quant - FLT_EPSILON; weights.Clip(weights.Min(), 0); weights = weights / weights; weights.ReplaceNan(0);
我们已经确定了置信区间。现在,我们可以选择一个含有动作适当评测的状态数组。我们需要选择最优状态来优化扮演者行为政策。为了简化整个算法,并加快模型训练过程,我决定不采用 CFPI 算法作者提出的分析方法,而是采用更简单的方法替代。
显然,在我们的例子中,最优方向是智能体行为政策的盈利能力随着动作子空间的最小偏移而变化的方向。因为我们希望最大限度地提高政策的盈利能力,而最小的偏差表明对评论者行为进行了更准确的估测。当然,在我们的分析矩阵中,行动估测既有正偏差也有负偏差。整体盈利能力的增加同样受到盈利增加和亏损减少的影响。因此,为了计算最优选择准则,我们采用过渡奖励偏差的绝对值。
rewards = mBatch.Col(2); weights = MathAbs(rewards) * weights / action;
在结果向量中,我们选择具有最高值的元素。它的索引将指向要在模型优化算法中采用的最优状态。
ulong pos = weights.ArgMax(); int sign = (rewards[pos] >= 0 ? 1 : -1);
在此,我们将奖励偏差的符号保存到一个局部变量之中。
展望未来,我必须说,我们将采用来自评论者模型传递的误差梯度来更新扮演者行为政策。在这种学习模式下,我们无法计算扮演者预测中的误差。为了控制学习过程,我引入了所用状态的平均改进系数。
Improve = (Improve * iter + weights[pos]) / (iter + 1);
接着来到优化政策模型的熟悉算法。但这次我们没有采用随机状态,而是一个能最大限度提高模型性能的状态。
int tr = int(mBatch[pos, 0]); int i = int(mBatch[pos, 1]);
如前,我们填充缓冲区来描述环境状态和账户的状态。
//--- Policy study 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]; Account.Clear(); Account.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance); Account.Add(Buffer[tr].States[i].account[1] / PrevBalance); Account.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity); Account.Add(Buffer[tr].States[i].account[2]); Account.Add(Buffer[tr].States[i].account[3]); Account.Add(Buffer[tr].States[i].account[4] / PrevBalance); Account.Add(Buffer[tr].States[i].account[5] / PrevBalance); Account.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'); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_MN1); Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_W1); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_D1); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
生成环境状态嵌入。
//--- State if(Account.GetIndex() >= 0) Account.BufferWrite(); if(!StateEncoder.feedForward(GetPointer(State), 1, false, GetPointer(Account))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
智能体动作参考当前政策。
//--- Action if(!Actor.feedForward(GetPointer(StateEncoder), -1, NULL, 1)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
估算智能体动作的成本。
//--- Cost if(!Critic1.feedForward(GetPointer(StateEncoder), -1, GetPointer(Actor)) || !Critic2.feedForward(GetPointer(StateEncoder), -1, GetPointer(Actor))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
为了优化智能体行为政策,我们采用最低得分的评论者。为了提升收敛性,我们调用 CAGrad 方法调整梯度方向向量。
Critic1.getResults(rewards1); Critic2.getResults(rewards2); //--- rewards.Assign(Buffer[tr].States[i + 1].rewards); target_reward.Assign(Buffer[tr].States[i + 2].rewards); rewards = rewards - target_reward * DiscFactor; CNet *critic = NULL; if(rewards1.Sum() <= rewards2.Sum()) { Result.AssignArray(CAGrad((rewards1 - rewards)*sign) + rewards1); critic = GetPointer(Critic1); } else { Result.AssignArray(CAGrad((rewards2 - rewards)*sign) + rewards2); critic = GetPointer(Critic2); }
我们按顺序执行评论者和扮演者反向传播验算。
if(!critic.backProp(Result, GetPointer(Actor), -1) || !Actor.backPropGradient((CBufferFloat *)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
请注意,在此阶段,我们不会优化评论者模型。因此,不需要通过环境状态编码器进行反向传播验算。
至此更新智能体行为政策的一次迭代操作完毕。我们通知用户学习过程的进度,并转到循环的下一次迭代。
if(GetTickCount() - ticks > 500) { string str = StringFormat("%-15s %5.2f%% -> %15.8f\n", "Mean Improvement", iter * 100.0 / (double)(Iterations), Improve); Comment(str); ticks = GetTickCount(); } }
在完成训练循环的所有迭代后,我们清除图表上的注释字段,在日志中显示有关训练结果的信息,并启动 EA 关闭。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Mean Improvement", Improve); ExpertRemove(); //--- }
至此,我们完成了对本文中使用的算法的研究。所有程序的完整代码附在文后。我们转到检查已完成工作的结果。
3. 测试
于上,我们已经看过了封闭式政策改进方法,并在利用 MQL5 实现其方法方面做了大量工作。我们采用了方法作者提议的思路。然而,选择最优状态的分析方法与论文中提议的方法不同。此外,在我们的工作中,我们用到了来自以前经验的开发成果。因此,获得的结果可能与方法作者在其论文中介绍的结果有很大不同。当然,我们的测试环境与原始论文中描述的实验不同。
如常,这些模型使用 EURUSD H1 的历史数据进行训练和测试。该模型取 2023 年前 7 个月的数据进行训练。为了测试已训练模型,我们用到了 2023 年 8 月的历史数据。所有指标都采用默认参数。
CFPI 方法的实现需要对模型架构进行一些更改,但不会影响源数据的结构。因此,在训练的第一阶段,我们所用的训练数据集,是在测试之前讨论学习算法之一那时创建的。我所用的训练数据集来自前几篇文章。针对当前文章,我创建了一个名为 “CFPI.bd” 的文件副本。但您也可以使用在前面讨论方法之一,创建全新的训练数据集。在这部分,CFPI 方法未施加约束。
不过,架构的变化不允许我们再用以前训练过的模型。因此,整个学习过程都是“从头开始”实现的。
首先,我们使用 EA “...\Experts\CFPI\StudyCritic.mq5” 训练状态编码器和评论者模型。
训练数据集包括 500 条轨迹,每条轨迹有 3591 个环境状态。这总共相当于近 180 万个“状态-行动-奖励”集合。评论者模型的主要训练进行了 100 万次迭代,理论上允许我们分析几乎每一秒的状态。对于连续的轨迹,当并非每个新环境状态都会对市场状况产生根本性改变时,这是一个相当好的结果。鉴于对于具有最大盈利能力轨迹的重视,这将令评论者几乎可以完全研究这些轨迹,并将它们的“视野”扩展到盈利较低的验算。
下一步是训练扮演者行为政策,这是在 EA “...\Experts\CFPI\Study.mq5” 中完成。在此,我们依据 256 个状态的数据包执行 10,000 次训练迭代。总体上,这令我们能够分析超过 250 万个状态,大大超过我们的训练数据集。
我必须说,在测试验算的第一次训练迭代之后,您可以注意到创建盈利策略的一些先决条件。余额图表有一些可盈利的间隔。在额外收集训练轨迹的过程中,在 200 次验算中,有 3 次完成并获利。当然,这可能是我的主观意见,也可能是独立于方法的某些因素汇合的结果。例如,我们很幸运,模型的随机初始化产生了相当好的结果。无论如何,我们可以肯定地说,由于训练模型的后续迭代和收集额外的验算,有清楚的迹象表明验算的平均盈利能力和盈利因子增加。
经过若干次模型训练迭代,我们获得了一个扮演者行为政策,其能够在训练数据集的历史数据、以及未包含于训练数据集中的测试数据上均产生盈利。模型测试结果如下所示。
在余额图表上,您可以注意到测试区期开始处有一些回撤。但随后,该模型展现出相当均匀的余额增长趋势。这令我们能够挽回损失,并增加盈利。在测试期间,该模型总共进行了 125 笔业务,其中 45.6% 以盈利了结。最高盈利和平均盈利交易比相应的亏损指标高出 50%。这盈利因子结果为 1.23。
结束语
在本文中,我们领略了另一种模型训练算法:封闭式政策改进。这种方法的主要贡献可能是增加了选择训练模型优化方向的分析方式。好吧,该过程需要额外的计算成本。然而,奇怪的是,这种方式总体上降低了模型训练成本。这是因为我们并没有尝试完全重复所呈现轨迹中最好的。代之,我们专注于效率最高的领域,不会浪费时间寻找最优噪声现象。
在我们文章的实践部分,我们实现了 CFPI 方法作者提议的思路,尽管与作者的原始数学计算相比有一些变化。尽管如此,我们还是得到了积极的经验,和良好的测试结果。
我个人认为封闭式政策改进方法值得研究。我们可利用其方式来构建我们自己的交易策略。
参考
文中所用程序
# | 已发行 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | EA | 样本收集 EA |
2 | ResearchRealORL.mq5 | EA | 用于使用 Real-ORL 方法收集示例的 EA |
3 | Study.mq5 | EA | Actor 训练 EA |
4 | StudyCritic.mq5 | EA | Critics 训练 EA |
5 | Test.mq5 | EA | 模型测试 EA |
6 | Trajectory.mqh | 类库 | 系统状态定义结构 |
7 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
8 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/13982
注意: 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.

