
神经网络变得简单(第 56 部分):利用核范数推动研究
概述
强化学习基于由智能体对环境进行独立探索的范式。智能体会影响环境,从而致其变化。作为回报,智能体会获得某种奖励。
强化学习的两个主要问题就此得以突出:环境探索和奖励函数。正确结构的奖励函数鼓励智能体探索环境,并寻找最优行为策略。
然而,在解决大多数实际问题时,我们面临着稀疏的外部奖励。为了克服这一障碍,提出了使用所谓的内部奖励。它们允许智能体掌握新技能,这样也许有助于将来获得外部奖励。然而,由于环境随机性,内部奖励可能会很嘈杂。将嘈杂的预测值直接应用于观测值,可能会对智能体政策训练的效率产生负面影响。甚至,许多方法使用 L2 范数,或方差来衡量研究的新颖性,这会由于平方操作而增加产生的噪声。
为了解决这个问题,《基于核范数最大化的好奇心驱动学习》一文章中提出了一种基于核范数最大化(NNM)激发智能体好奇心的新算法。这样的内部奖励能够更准确地评估环境探索的新颖性。同时,它对于噪音和尖峰具有很高的抗扰度。
1. 核规范及其应用
矩阵范数,包括核范数,广泛用于线性代数的分析和计算方法。核范数在矩阵性质研究、优化问题、条件评估、以及数学和应用科学的许多其它领域中发挥着重要作用。
矩阵的核范数是决定矩阵“大小”的数值特征。它是 Schatten 范数的特例,等于矩阵奇异值的总和。
其中 σi 表示 A 矩阵的奇异值向量的元素。
在其核心,核范数是具有相同谱范数的一组矩阵的秩函数的凸包。这令其可用于解决各种优化问题。
核范数最大化(NNM)方法的主要思路是在访问一个状态时,使用矩阵的核范数准确评估新颖性,同时减轻噪声和各种尖峰的影响。n*m 大小的矩阵包含环境的 n 个编码状态。每个状态都有 m 维度。该矩阵结合了 s 的当前状态,及其 (n - 1) 最近的相邻状态。此处 s 代表自原始的高维观察映射到低维抽象空间的抽象状态。由于 S 矩阵的每一行都代表编码状态,因此 rank(S) 可用于表示矩阵中的多样性。较高的 S 矩阵秩意味着编码状态之间的线性距离越大。该方法的作者采用了创造性的方式来解决问题,并用矩阵秩最大化来增加研究多样性。这会鼓励我们模型的智能体去参观更多具有高度多样性的不同状态。
有两种方式可以使用矩阵的最大秩。我们可以将其当作损失函数或奖励。直接最大化矩阵秩,对于非凸函数是一个相当困难的问题。因此,我们不会将其当作损失函数。不过,矩阵的秩值是离散的,不能准确反映状态的新颖性。因此,使用原始矩阵秩值作为奖励来指导模型训练也是低效的。
在数学上,矩阵秩的计算通常被其核范数所取代。因此,可以通过近似最大化核范数来维护新颖性。相较秩数,核范数具有若干优良的属性。首先,核范数的凸性允许开发快速和收敛的优化算法。其次,核范数是一个连续函数,这对于许多训练任务很重要。
NNM 方法的作者建议使用方程判定内部奖励
其中:
λ 是用于设置核范数值范围的权重;
‖S‖⋆ 是状态矩阵的核范数;
‖S‖F 是状态矩阵的弗罗贝纽斯(Frobenius)范数。
我们已经熟悉了矩阵的核范数,而弗罗贝纽斯范数计算为所有矩阵元素的平方和的平方根。
柯西-布尼亚科夫斯基(Cauchy-Bunyakovsky)不等式允许我们进行以下变换。
显然,数值平方和的平方根将始终小于或等于数值本身的总和。因此,矩阵的核范数将始终大于或等于同一矩阵的弗罗贝纽斯范数。因此,我们可以推导出以下不等式。
该不等式表明,核范数和弗罗贝纽斯范数是相互制约的。如果核范数增加,那么弗罗贝纽斯范数也趋于增加。
此外,弗罗贝纽斯范数还有另一个对我们有用的性质 — 它与单调性的熵完全相反。它递增相当于熵递减。结果就是,核范数的影响可以切分为两部分:
- 种类繁多。
- 低熵。
我们需要鼓励智能体去参观较新的状态,我们的目标是多样性。然而,熵的减少意味着状态收敛的增加。这意味着各状态之间有很大的相似性。因此,我们的标的是鼓励第一种效应,并减少第二种的影响。为此,我们将矩阵的核范数除以其弗罗贝纽斯范数。
将上述不等式除以弗罗贝纽斯范数,我们得到以下结果:
显然,直接使用这样的奖励标尺可能不利于模型训练。此外,在不同的环境或训练模型的不同架构下状态矩阵最小维数的根可能会有所不同。因此,期待重新规范我们的奖励标尺。由于 min(m, n) ≤ max(m, n),我们得到:
上述数学计算允许我们自动判定 λ 矩阵的核范数值范围的调整因子为
故此,内部奖励方程将采用以下形式:
以下是作者对核范数最大化方法的可视化。
在作者文章中呈现的测试结果证明了所拟议的方法优于其它环境研究算法,包括先前温习的内在好奇心模块和依据分歧进行自我监督探索。此外,值得注意的是,当把噪声加入原始数据时,该方法展现出更佳的结果。我们转入本文的实践部分,并评估该方法在解决问题方面的能力。
2. 利用 MQL5 实现
在我们开始实现核范数最大化(NNM)方法之前,我们重点讲解一下它的主要创新 — 新的内部奖励方程。因此,这种方式可作为几乎任何之前研究过的强化学习算法的补充来实现。
还应当注意的是,该算法使用编码器将环境状态转换为某种压缩表示。k-最近邻算法也可用于形成环境状态的压缩表示矩阵。
我的观点是,最简单的解决方案看似是将拟议的内部奖励引入 RE3 算法。它还用编码器将环境状态转换为压缩表示形式。出于该目的,我们在 RE3 中使用随机卷积编码器。这令我们能够降低编码器的训练成本。
此外,RE3 还应用 k-最近环境条件来形成内部奖励。不过,这种奖励的形成方式不同。
我们的动作方向很明确,故到了开始工作的时间了。首先,我们将所有文件从 “...\Experts\RE3\” 复制到 “...\Experts\NNM\” 目录。您也许还记得,它包含四个文件:
- Trajectory.mqh — 常用常量、结构和方法的函数库。
- Research.mq5 — 与环境交互和收集训练样本的 EA。
- Study.mq5 — 直接模型训练的 EA。
- Test.mq5 — 测试已训练模型的 EA。
我们还将使用分解的奖励。奖励向量的结构如下所示。
//+------------------------------------------------------------------+ //| Rewards structure | //| 0 - Delta Balance | //| 1 - Delta Equity ( "-" Drawdown / "+" Profit) | //| 2 - Penalty for no open positions | //| 3 - NNM | //+------------------------------------------------------------------+
在 “...\NNM\Trajectory.mqh” 文件中,我们把环境状态的压缩表示和模型内部全连接层的大小都增加了。
#define EmbeddingSize 16 #define LatentCount 512
该文件还包含定义所用模型架构的 CreateDescriptions 方法。在此,我们将用到三个神经网络模型:Actor、Critic 和 Encoder。至于后者,我们将选用随机卷积编码器。
bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic, CArrayObj *convolution) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; } if(!convolution) { convolution = new CArrayObj(); if(!convolution) return false; }
在该方法的主体中,我们创建一个局部变量,存储指向一个定义 CLayerDescription 神经层的对象指针,并在必要时初始化定义所用模型架构方案的动态数组。
首先,我们将创建一个扮演者架构的定义,它由两个模块组成:源数据初步处理,和决策。
我们将所分析金融产品的价格走势和指标读数的历史数据提交给初始数据初步处理模块的输入端。如您所见,不同的指标具有不同的参数范围。这对模型训练效率有负面影响。因此,我们使用 CNeuronBatchNormOCL 批量归一化层对接收到的数据进行常规化。
//--- Actor actor.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(!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 = BarDescr; descr.window = HistoryBars; descr.step = HistoryBars; int prev_wout=descr.window_out = HistoryBars/2; 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 = prev_wout; descr.step = prev_wout; descr.window_out = 8; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
获得的数据由全连接神经层处理。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; 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; prev_count = descr.count = LatentCount; 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 = LatentCount; descr.window = prev_count; descr.step = AccountDescr; descr.optimization = ADAM; descr.activation = SIGMOID; if(!actor.Add(descr)) { delete descr; return false; }
接下来是全连接层的决策模块,最后是变分自动编码器的潜在表示的随机层。如前,我们在模型的输出中使用这种类型的层来实现随机扮演者政策。
//--- layer 7 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 8 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 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 2 * NActions; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 10 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
我们已经完整地讲述了扮演者架构。同时,我们构建了一个实现随机政策的模型,从而强调使用核范数最大化方法进行此类决策的可能性。此外,我们的扮演者将在连续动作空间中操作。不过,这并未限制使用 NNM 方法的范围。
下一步是创建评论者架构的定义。在此,我们将使用一种已经验证的技术,并剔除数据预处理模块。我们将取用金融产品的历史数据状态,以及来自扮演者内部神经层的帐户状态的潜在表示作为初始数据。同时,我们将环境状态的内部表示和扮演者生成的动作张量结合起来。
//--- 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; }
我们已讲述了两个模型的架构。现在我们需要创建编码器架构。此处,我们回到理论部分,并注意到 NNM 方法提供了过渡到 St+1 之后环境状态的比较。显然,该方法是为在线训练而开发的。但我们稍后会谈及这个问题。在模型架构形成的阶段,我们必须了解编码器将要处理的金融产品的历史数据和账户状态读数。我们创建一个足够大小的源数据层。
//--- Convolution convolution.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = (HistoryBars * BarDescr) + AccountDescr; descr.activation = None; descr.optimization = ADAM; if(!convolution.Add(descr)) { delete descr; return false; }
注意,我们既不使用数据归一化层,也不会用两个数据张量的并集。这是因为事实上我们并无计划去训练这个模型。它仅用于将环境的多维表示转换到一些随机压缩空间,我们将在其中衡量状态之间的距离。但我们将使用一个完全连接层,这将令我们能够以某种可比的形式呈现数据。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 1024; descr.window = prev_count; descr.step = NActions; descr.optimization = ADAM; descr.activation = SIGMOID; if(!convolution.Add(descr)) { delete descr; return false; }
接下来是卷积层模块,以便降低数据维数。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = 1024 / 16; descr.window = 16; descr.step = 16; prev_wout = descr.window_out = 4; descr.activation = LReLU; descr.optimization = ADAM; if(!convolution.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = (prev_count * prev_wout) / 8; descr.window = 8; descr.step = 8; prev_wout = descr.window_out = 4; descr.activation = LReLU; descr.optimization = ADAM; if(!convolution.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = (prev_count * prev_wout) / 4; descr.window = 4; descr.step = 4; prev_wout = descr.window_out = 2; descr.activation = LReLU; descr.optimization = ADAM; if(!convolution.Add(descr)) { delete descr; return false; }
最后,我们使用全连接层来降低给定大小的数据维度。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = EmbeddingSize; descr.activation = LReLU; descr.optimization = ADAM; if(!convolution.Add(descr)) { delete descr; return false; } //--- return true; }
在编码器的输入和输出端使用全连接层,令我们能够自定义卷积层的架构,而不受源数据和压缩表示嵌入维度的束缚。
模型架构的工作到此结束。我们回过头来讲解未来状态。在线训练的情况下,我们不会经历困难。但在线训练有其缺点。当使用经验回放缓冲区时,我们对所分析金融产品和指标的价格走势的历史数据没有疑问。扮演者行为对它们的影响可以忽略不计。而帐户状态是另一回事。它直接取决于持仓的方向和交易量。我们必须由扮演者基于当前状态的分析结果生成动作向量来预测帐户状态。我们在 ForecastAccount 函数中实现此功能。
在方法参数中,我们将传递:
- prev_account ― 当前账户状态的描述数组(先于智能体动作);
- actions ― 扮演者动作向量;
- prof_1l ― 多头持仓每手获利;
- time_label ― 预测柱线的时间戳。
您可以注意到参数类型的多样性。这与数据源有关。我们从经验回放缓冲区获取当前帐户状态和预测柱线时间戳的描述,其中数据存储在浮点类型的动态数组之中。
贯穿模型的直接验算结果以向量形式保存,扮演者从中获取动作。
vector<float> ForecastAccount(float &prev_account[], vector<float> &actions, double prof_1l, float time_label ) { vector<float> account; double min_lot = SymbolInfoDouble(_Symbol,SYMBOL_VOLUME_MIN); double step_lot = SymbolInfoDouble(_Symbol,SYMBOL_VOLUME_STEP); double stops = MathMax(SymbolInfoInteger(_Symbol,SYMBOL_TRADE_STOPS_LEVEL), 1) * Point(); double margin_buy,margin_sell; if(!OrderCalcMargin(ORDER_TYPE_BUY,_Symbol,1.0,SymbolInfoDouble(_Symbol,SYMBOL_ASK),margin_buy) || !OrderCalcMargin(ORDER_TYPE_SELL,_Symbol,1.0,SymbolInfoDouble(_Symbol,SYMBOL_BID),margin_sell)) return vector<float>::Zeros(prev_account.Size());
我们在函数主体中做了一些准备工作。我们判定金融产品的最小手数,以及改变持仓交易量的步骤。请求当前止损水平,以及每笔交易的保证金大小。注意,我们没有引入额外的参数来辨别所分析的金融产品。我们使用启动程序的图表所对应金融产品。因此,在训练模型时,重点是坚持使用从训练样本中收集初始数据的金融产品,以及模型训练程序所附着的图表。
接下来,我们调整扮演者动作向量,参照成交量差值选择仅按一个方向成交。在 EA 中执行类似的操作,以便与环境进行交互。模型训练过程中所有程序中遵守统一规则对于实现预期结果非常重要。
我们还要立即检查账户中是否有足够的资金来开仓。
account.Assign(prev_account); //--- if(actions[0] >= actions[3]) { actions[0] -= actions[3]; actions[3] = 0; if(actions[0]*margin_buy >= MathMin(account[0],account[1])) actions[0] = 0; } else { actions[3] -= actions[0]; actions[0] = 0; if(actions[3]*margin_sell >= MathMin(account[0],account[1])) actions[3] = 0; }
基于调整后的动作向量预测帐户状态。首先,我们检查多头持仓。如果需要平仓,我们会将累积盈利转换到账户余额。然后我们重置开仓交易量和累计盈利。
当有持仓时,我们会检查是否需要部分平仓或加仓。当部分平仓时,我们将累积盈利按被平仓和剩余部分的比例分配。已平仓的份额从累计盈利转换到账户余额。
如有必要,我们会调整开仓交易量,并根据持仓量按比例更改累计盈亏金额。
//--- buy control if(actions[0] < min_lot || (actions[1] * MaxTP * Point()) <= stops || (actions[2] * MaxSL * Point()) <= stops) { account[0] += account[4]; account[2] = 0; account[4] = 0; } else { double buy_lot = min_lot + MathRound((double)(actions[0] - min_lot) / step_lot) * step_lot; if(account[2] > buy_lot) { float koef = (float)buy_lot / account[2]; account[0] += account[4] * (1 - koef); account[4] *= koef; } account[2] = (float)buy_lot; account[4] += float(buy_lot * prof_1l); }
对空头持仓重复类似的操作。
//--- sell control if(actions[3] < min_lot || (actions[4] * MaxTP * Point()) <= stops || (actions[5] * MaxSL * Point()) <= stops) { account[0] += account[5]; account[3] = 0; account[5] = 0; } else { double sell_lot = min_lot + MathRound((double)(actions[3] - min_lot) / step_lot) * step_lot; if(account[3] > sell_lot) { float koef = float(sell_lot / account[3]); account[0] += account[5] * (1 - koef); account[5] *= koef; } account[3] = float(sell_lot); account[5] -= float(sell_lot * prof_1l); }
接下来,我们调整两个方向的累计盈亏总量和账户净值。
account[6] = account[4] + account[5]; account[1] = account[0] + account[6];
根据获得的预测值,我们将准备一个描述账户状态的向量,按格式为模型提供数据。操作结果将返回到调用程序。
vector<float> result = vector<float>::Zeros(AccountDescr); result[0] = (account[0] - prev_account[0]) / prev_account[0]; result[1] = account[1] / prev_account[0]; result[2] = (account[1] - prev_account[1]) / prev_account[1]; result[3] = account[2]; result[4] = account[3]; result[5] = account[4] / prev_account[0]; result[6] = account[5] / prev_account[0]; result[7] = account[6] / prev_account[0]; double x = (double)time_label / (double)(D'2024.01.01' - D'2023.01.01'); result[8] = (float)MathSin(2.0 * M_PI * x); x = (double)time_label / (double)PeriodSeconds(PERIOD_MN1); result[9] = (float)MathCos(2.0 * M_PI * x); x = (double)time_label / (double)PeriodSeconds(PERIOD_W1); result[10] = (float)MathSin(2.0 * M_PI * x); x = (double)time_label / (double)PeriodSeconds(PERIOD_D1); result[11] = (float)MathSin(2.0 * M_PI * x); //--- return result return result; }
所有准备工作均已完成。我们转入更新与环境和训练模型交互的程序。我要提醒您,NNM 方法对内部奖励函数进行了修改。此功能不会影响与环境的交互。因此,“...\NNM\Research.mq5” 和 “...\NNM\Test.mq5” EA 则保持不变。代码附在下面。算法本身在前面的文章中曾讲解过。
我们把注意力集中在 “...\NNM\Study.mq5” 模型训练 EA。首先,必须说 NNM 方法主要是为了在线训练而开发的。通过对后续状态的比较来指明这一点。当然,我们可以在相当长的一段时间内生成预测状态。但它们在状态比较数据库中的缺失可能会对整体训练产生负面影响。在缺失它们的情况下,该模型会将状态视为新状态,并鼓励它们在训练期间重复之前曾参观过但结果不明的步骤。从理论上讲,有两种选项可以解决该问题:
- 将预测状态添加到样本数据库。
- 减少训练循环迭代。
这两种方法都有其缺点。在向样本数据库添加预测状态时,我们在填充它时会有不可靠和不完整的数据。当然,我们已经基于我们的先验知识和一些假设运作数学计算。只是,我们承认其中存在一定数量的误差。此外,我们没有这些动作的真实奖励值来训练模型。因此,我们选择了第二种方法,尽管它涉及到手工劳动增加,事关收集训练数据和训练模型的运行次数会更多。
我们减少了训练循环的迭代次数。
//+------------------------------------------------------------------+ //| Input parameters | //+------------------------------------------------------------------+ input int Iterations = 10000; input float Tau = 0.001f;
在训练期间,我们将用到一个扮演者、2 个评论者和它们的目标模型,以及一个随机卷积编码器。所有评论者模型都拥有相同的架构,但在训练期间中形成的参数不同。
CNet Actor; CNet Critic1; CNet Critic2; CNet TargetCritic1; CNet TargetCritic2; CNet Convolution;
我们将训练一名扮演者和两名评论者。我们将用 Tau 参数从相应评论者参数中软性更新目标评论者模型。编码器未经过训练。
在 EA 的 OnInit 初始化方法中,我们加载初步收集的初始数据。如果无法加载预训练模型,我们会根据给定的架构初始化新模型。此过程保持不变,您可以在附件中领略它。我们直接转入 Train 模型训练方法。
在该方法中,我们首先判定存储在经验回放缓冲区中的轨迹数量,并计算其中的状态总数。
准备记录状态嵌入和相应外部奖励的矩阵。
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { int total_tr = ArraySize(Buffer); uint ticks = GetTickCount(); //--- int total_states = Buffer[0].Total; for(int i = 1; i < total_tr; i++) total_states += Buffer[i].Total; vector<float> temp, next; Convolution.getResults(temp); matrix<float> state_embedding = matrix<float>::Zeros(total_states,temp.Size()); matrix<float> rewards = matrix<float>::Zeros(total_states,NRewards);
接下来,我们安排一个嵌套循环系统。在循环实体中,我们为来自经验回放缓冲区的所有状态安排编码。获得的数据用于填充状态的嵌入矩阵和相应的奖励。请注意,我们会为独立转移到新状态而保存奖励,不考虑累积值,直到验算结束。因此,我们希望带入可比形式状态,其与将底层思路相似,但在时间上分离。
int state = 0; for(int tr = 0; tr < total_tr; tr++) { for(int st = 0; st < Buffer[tr].Total; st++) { State.AssignArray(Buffer[tr].States[st].state); float PrevBalance = Buffer[tr].States[MathMax(st,0)].account[0]; float PrevEquity = Buffer[tr].States[MathMax(st,0)].account[1]; State.Add((Buffer[tr].States[st].account[0] - PrevBalance) / PrevBalance); State.Add(Buffer[tr].States[st].account[1] / PrevBalance); State.Add((Buffer[tr].States[st].account[1] - PrevEquity) / PrevEquity); State.Add(Buffer[tr].States[st].account[2]); State.Add(Buffer[tr].States[st].account[3]); State.Add(Buffer[tr].States[st].account[4] / PrevBalance); State.Add(Buffer[tr].States[st].account[5] / PrevBalance); State.Add(Buffer[tr].States[st].account[6] / PrevBalance); double x = (double)Buffer[tr].States[st].account[7] / (double)(D'2024.01.01' - D'2023.01.01'); State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_MN1); State.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_W1); State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_D1); State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); if(!Convolution.feedForward(GetPointer(State),1,false,NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); return; } Convolution.getResults(temp); state_embedding.Row(temp,state); temp.Assign(Buffer[tr].States[st].rewards); next.Assign(Buffer[tr].States[st + 1].rewards); rewards.Row(temp - next * DiscFactor,state); state++; if(GetTickCount() - ticks > 500) { string str = StringFormat("%-15s %6.2f%%", "Embedding ", state * 100.0 / (double)(total_states)); Comment(str); ticks = GetTickCount(); } } } if(state != total_states) { rewards.Resize(state,NRewards); state_embedding.Reshape(state,state_embedding.Cols()); total_states = state; }
在准备好状态的嵌入之后,我们直接开始安排模型训练循环。如常,循环迭代次数由外部参数设置,我们添加了对用户终止事件的检查。
在循环实体中,我们从经验回放缓冲区中随机选择一条轨迹及其单独状态。
vector<float> rewards1, rewards2; int bar = (HistoryBars - 1) * BarDescr; for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++) { int tr = (int)((MathRand() / 32767.0) * (total_tr - 1)); int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2)); if(i < 0) { iter--; continue; } vector<float> reward, target_reward = vector<float>::Zeros(NRewards); reward.Assign(Buffer[tr].States[i].rewards);
准备记录奖励的向量。
接下来,准备下一个状态的描述。请注意,无论是否需要用到目标模型,我们都要准备它。毕竟,无论如何,我们都需要它来调用 NNM 方法生成内部奖励。
//--- Target TargetState.AssignArray(Buffer[tr].States[i + 1].state);
与之对比,仅在必要时才执行后续账户状态描述向量和目标模型的直接验算。
if(iter >= StartTargetIter) { float PrevBalance = Buffer[tr].States[i].account[0]; float PrevEquity = Buffer[tr].States[i].account[1]; Account.Clear(); Account.Add((Buffer[tr].States[i + 1].account[0] - PrevBalance) / PrevBalance); Account.Add(Buffer[tr].States[i + 1].account[1] / PrevBalance); Account.Add((Buffer[tr].States[i + 1].account[1] - PrevEquity) / PrevEquity); Account.Add(Buffer[tr].States[i + 1].account[2]); Account.Add(Buffer[tr].States[i + 1].account[3]); Account.Add(Buffer[tr].States[i + 1].account[4] / PrevBalance); Account.Add(Buffer[tr].States[i + 1].account[5] / PrevBalance); Account.Add(Buffer[tr].States[i + 1].account[6] / PrevBalance); double x = (double)Buffer[tr].States[i + 1].account[7] / (double)(D'2024.01.01' - D'2023.01.01'); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_MN1); Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_W1); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_D1); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); //--- if(Account.GetIndex() >= 0) Account.BufferWrite(); if(!Actor.feedForward(GetPointer(TargetState), 1, false, GetPointer(Account))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } //--- if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) || !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } TargetCritic1.getResults(rewards1); TargetCritic2.getResults(rewards2); if(rewards1.Sum() <= rewards2.Sum()) target_reward = rewards1; else target_reward = rewards2; for(ulong r = 0; r < target_reward.Size(); r++) target_reward -= Buffer[tr].States[i + 1].rewards[r]; target_reward *= DiscFactor; }
接下来,我们训练评论者。在此模块中,我们首先为环境当前状态准备数据。
//--- 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 x = (double)Buffer[tr].States[i].account[7] / (double)(D'2024.01.01' - D'2023.01.01'); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_MN1); Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_W1); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_D1); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); if(Account.GetIndex() >= 0) Account.BufferWrite();
接下来,我们运作扮演者前向验算。
if(!Actor.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(Actor), LatentLayer, GetPointer(Actions)) || !Critic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
前向验算之后,我们需要执行反向验算,并更新模型参数。您也许还记得,我们使用分解的奖励函数。CAGrad 方法用于优化梯度。尽管目标相同,但每个评论者的误差梯度显然会有所不同。我们按顺序更新模型。首先,我们调整误差梯度,并执行 Critic 1 的逆向验算。
Critic1.getResults(rewards1); Result.AssignArray(CAGrad(reward + target_reward - rewards1) + rewards1); if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
然后我们针对 Critic 2 重复操作。当然,我们控制着每一步的操作。
Critic2.getResults(rewards2); Result.AssignArray(CAGrad(reward + target_reward - rewards2) + rewards2); if(!Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
评论者模型经过训练,可以正确评估特定环境状态下的扮演者动作。我们期望获得正确的预测奖励,作为评论者模型操作的结果。这只是冰山一角。但还有水下部分。在训练时,评论者近似于 Q-函数,并在扮演者行为和奖励之间建立确定关系。
我们的目标是最大化外部奖励。但这并不直接取决于评论者的训练品质。与之对比,奖励是通过扮演者行为达成的。为了调整扮演者动作,我们将使用近似 Q-函数。评论者对扮演者行为的评估与所获奖励之间的误差梯度,将指明扮演者行为的调整方向。高估动作的可能性将降低,而低估动作的可能性将增加。
为了训练扮演者,我们将使用具有最小平均移动预测误差的评论者,潜在地这为我们提供扮演者动作的更准确评估。
CNet *critic = NULL; if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError()) critic = GetPointer(Critic1); else critic = GetPointer(Critic2);
我们已运作了一次扮演者直接验算。为了评估选定的动作,我们需要运作一次选定评论者的直接验算。但首先,我们准备一个目标奖励值的向量。这项任务并非微不足道。我们需要以某种方式预测来自环境的外部奖励,并用内在奖励来补充它们,从而激发扮演者的探索潜力。
尽管看似很奇怪,但我们将从内部奖励开始,我们将调用 NNM 方法判定内部奖励。如前所述,为了判定内部奖励,我们需要获得后续状态的编码表示。后续状态历史数据已添加到 TargetState 缓冲区。我们调用前面讲述的 ForecastAccount 函数获取预测帐户状态。
Actor.getResults(rewards1); double cl_op = Buffer[tr].States[i + 1].state[bar]; double prof_1l = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE_PROFIT) * cl_op / SymbolInfoDouble(_Symbol, SYMBOL_POINT); vector<float> forecast = ForecastAccount(Buffer[tr].States[i].account,rewards1, prof_1l,Buffer[tr].States[i + 1].account[7]); TargetState.AddArray(forecast);
我们连接 2 个张量,执行贯穿 2 个评论者模型的直接验算,来评估扮演者和编码器的动作,从而得到预测状态的压缩表示。
if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) || !Convolution.feedForward(GetPointer(TargetState))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; }
接下来,我们转入奖励向量的形成。我要提醒您,target_reward 向量包含的是目标评论者对扮演者动作的评估,距来自环境交互时获得的实际累积奖励的偏差。本质上,这个向量代表了政策变化对整体结果的影响。
我们将采用向量之间的距离经 k-最近邻调整后的实际奖励,作为扮演者当前动作的目标外部奖励。在此,我们假设一个动作的奖励与至其相应邻居距离成反比。
选择 k-最近邻并形成内部奖励是在 KNNReward 函数中运作的。我稍后会讲到它。
但在此我们需要注意一点。我们将外部奖励保存在编码状态的奖励矩阵当中,仅用于最后一次转换,无需累积总数。因此,为了获得可比的目标,我们需要在当前验算完成前,把来自经验回放缓冲区中累积奖励加入 target_reward。
next.Assign(Buffer[tr].States[i + 1].rewards); target_reward+=next; Convolution.getResults(rewards1); target_reward=KNNReward(7,rewards1,state_embedding,rewards) + next * DiscFactor; if(forecast[3] == 0.0f && forecast[4] == 0.0f) target_reward[2] -= (Buffer[tr].States[i + 1].state[bar + 6] / PrevBalance) / DiscFactor; critic.getResults(reward); reward += CAGrad(target_reward - reward);
我们将调用冲突厌恶梯度下降方法调整目标奖励值与评论者评估间的偏差,并将结果值添加到评论者的预测值之中。因此,我们得到了一个目标值的向量,该向量在考虑奖励分解的情况下进行了调整。我们将用它返回,并更新扮演者参数。我们首先禁用评论者的训练模式,如此就不会将其参数拟合到调整后的目标里。
Result.AssignArray(reward); critic.TrainMode(false); if(!critic.backProp(Result, GetPointer(Actor)) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); critic.TrainMode(true); break; } critic.TrainMode(true);
成功更新扮演者参数后,我们返回到评论者模型训练模式,并更新两个评论者目标模型。
//--- Update Target Nets TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau); TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
模型训练循环迭代至此完毕。我们所要做的就是通知用户有关已执行操作的信息。
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(); //--- }
现在我们视察一下 KNNReward 奖励生成函数,以便充分理解模型训练算法的操作。此函数包含核范数最大化方法的主要特征。
在其参数中,该函数接收所分析最近邻的数量、所分析状态的嵌入、状态嵌入矩阵、以及来自经验回放缓冲区的相应奖励。
vector<float> KNNReward(ulong k, vector<float> &embedding, matrix<float> &state_embedding, matrix<float> &rewards ) { if(embedding.Size() != state_embedding.Cols()) { PrintFormat("%s -> %d Inconsistent embedding size", __FUNCTION__, __LINE__); return vector<float>::Zeros(0); }
在方法实体中,我们检查当前状态的嵌入维度,以及来自经验回放缓冲区的状态。在当前的实现中,该检查看似有点多余。毕竟,我们在此 EA 中用到一个编码器接收所有嵌入。但是,如果您决定在与环境交互时生成状态嵌入,并将其存储在经验回放缓冲区中,这样做会非常实用,正如 RE3 方法原始文章中所建议的那样。
接下来,我们来做一些准备工作,定义一些常量作为局部变量。如有必要,我们还会将最近邻的数量减少到经验回放缓冲区中的状态数量。这种需求的可能性很小。但这个特性令我们的代码更加通用,并保护它免受运行时错误的影响。
ulong size = embedding.Size(); ulong states = state_embedding.Rows(); k = MathMin(k,states); ulong rew_size = rewards.Cols(); matrix<float> temp = matrix<float>::Zeros(states,size);
下一步是判定所分析状态的向量与经验回放缓冲区中的状态之间的距离。获得的数值将被保存到 distance 向量之中。
for(ulong i = 0; i < size; i++) temp.Col(MathPow(state_embedding.Col(i) - embedding[i],2.0f),i); vector<float> distance = MathSqrt(temp.Sum(1));
现在我们必须判定 k-最近邻。我们将它们的参数保存在 k_embeding 和 k_rewards 矩阵当中。注意,我们正在 k_embeding 矩阵中多创建一行。我们将已分析状态的嵌入写入其中。
我们将根据我们正在寻找的向量数量,在循环中将数据传输到矩阵当中。在循环实体中,我们使用 ArgMin 向量运算来判定距离向量中最小值的位置。这就是我们最近的邻居。我们将其数据传输到矩阵的相应行,而在距离向量中,我们将设置距这个位置的最大可能常数。因此,我们在复制数据后将最小距离更改为最大值。在下一次循环迭代中,ArgMin 运算为我们提供了下一个邻居的位置。
注意,在传输奖励向量时,我们按照状态向量间距离反比因子调整其数值。
matrix<float> k_rewards = matrix<float>::Zeros(k,rew_size); matrix<float> k_embeding = matrix<float>::Zeros(k + 1,size); for(ulong i = 0; i < k; i++) { ulong pos = distance.ArgMin(); k_rewards.Row(rewards.Row(pos) * (1 - MathLog(distance[pos] + 1)),i); k_embeding.Row(state_embedding.Row(pos),i); distance[pos] = FLT_MAX; } k_embeding.Row(embedding,k);
该算法具有许多优点:
- 迭代次数不取决于经验回放缓冲区的大小,这在数据库很庞大时很方便;
- 无需对数据进行排序,这通常需要大量资源;
- 我们只需每个邻居复制一次数据,我们不会复制其它数据。
在所有必要邻居的数据传输完毕后,我们将当前状态加到 k_embedding 矩阵的最后一行。
接下来,我们需要找到矩阵的奇异值来判定 k_embeding 矩阵的核范数,并实现 NNM 方法。为此,我们将使用 SVD 矩阵运算。
matrix<float> U,V; vector<float> S; k_embeding.SVD(U,V,S);
现在矩阵的奇异值已存储在 S 向量之中。我们只需要求其值总和即可判定核范数。但首先,我们将按所选奖励的 k_rewards 矩阵的列数生成一个外部奖励向量作为平均值向量。
我们调用 NNM 方法将内部奖励定义为状态嵌入矩阵的核范数与其弗罗贝纽斯范数的比率,并通过核范数的比例因子对其进行调整。将结果值写入奖励向量的相应元素之中,并把奖励向量返回给调用程序。
vector<float> result = k_rewards.Mean(0); result[rew_size - 1] = S.Sum() / (MathSqrt(MathPow(k_embeding,2.0f).Sum() * MathMax(k + 1,size))); //--- return (result); }
我们利用 MQL5 实现核范数最大化方法的工作到此结束。本文中用到的所有程序的完整代码可在附件中找到。
3. 测试
我们在实现核范数最大化方法与 RE3 算法的集成方面已经做了相当多的工作。现在到了测试的时间了。一如既往,这些模型依据 EURUSD H1 的 2023 年 1-5 个月历史数据进行了训练和测试。所有指标采用默认参数。
我已经提到过该方法的特性,以及在创建 “...\NNM\Study.mq5” 训练 EA 时经验回放缓冲区中缺少生成的状态。然后我们决定降低一个训练循环的迭代次数。当然,这在整个训练中留下了印记。
我们没有降低整体经验回放缓冲区。但同时,不需要一个包含一百三十万个状态的数据库来执行一万次更新模型参数的迭代。当然,更大的数据库令我们能够更好地优调模型。但当每次更新迭代超过 100 个状态时,我们无法完成贯穿所有状态。因此,我们将逐步填充经验回放缓冲区。在第一次迭代中,我们启动训练数据收集 EA 仅执行 50 次验算。这就为我们在指定历史时段训练模型提供了大约十二万个状态。
模型训练的第一次迭代之后,我们用另外 50 次验算来补充样本数据库。因此,我们用训练政策框架内相应扮演者行为的新状态逐渐填充经验回放缓冲区。
这种方式明显增加了启动 EA 所涉及的手工劳动。但这允许我们能够令样本数据库保持相对最新。生成的内部奖励将引导扮演者探索新的环境状态。
在训练模型时,我们设法获得了一个能够在训练样本上产生利润,并将所获得的知识推广到后续环境状态的模型。例如,在策略测试器中,我们训练的模型能够在训练样本之后的一个月内产生 1% 的利润。在测试期间,该模型执行了 133 次交易操作,其中 42% 以盈利了结。每笔交易的最大盈利几乎是最大亏损交易的 2 倍。每笔交易的平均盈利比平均亏损高出 40%。所有这些加在一起令我们能够获得 1.02 的盈利因子。
结束语
在本文中,我讲述了一种鼓励探索基于核范数最大化的强化学习的新方法。这种方法令我们能够有效地评估环境研究的新颖性,同时考虑到历史信息,并确保对噪音和排放的高度容忍度。
在本文的实践部分,我们将核范数最大化方法集成到 RE3 算法之中。我们训练了模型,并在 MetaTrader 5 策略测试器中对其进行了测试。根据测试结果,我们可以说,与使用纯 RE3 方法训练模型的结果相比,所提出的方法明显令扮演者动作更加多样化。然而,我们最终遭遇到更混乱的交易。这也许表明需要通过在奖励函数中引入额外的影响比率,来平衡探索和开发之间的平衡。
链接
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | 智能交易系统 | 样本收集 EA |
2 | Study.mq5 | 智能交易系统 | 智能体训练 EA |
3 | Test.mq5 | 智能交易系统 | 模型测试 EA |
4 | Trajectory.mqh | 类库 | 系统状态定义结构 |
5 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
6 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/13242



