
神经网络变得简单(第 64 部分):保守加权行为克隆(CWBC)方法
概述
我们在最近的文章中讨论的决策转换器,及其所有修改,都属于行为克隆(BC)方法。我们训练模型根据环境状态和目标结果,重复来自“专家”轨迹中的动作。因此,我们教导模型模仿专家在当前环境状态下的行为,从而达成目标。
然而,在真实条件下,不同专家对同一环境状态的评估差异很大。有时他们完全相悖。甚至,我想提醒您,在以前的工作中,我们未让专家参与创建我们的训练集。我们采用各种方法对智能体的动作进行采样,并选择最佳轨迹。这些轨迹并不总是最优的。
在连续的动作和场景空间中对轨迹进行采样的过程中,几乎不可能保存所有可能的选项。只有一小部分采样的轨迹才能至少部分满足我们的需求。这样的轨迹更像是模型在训练过程中可以简单地丢弃的异常值。
为了应对这种状况,我们采用的方式是出去探索方法。然后,用较小片段,我们陆续形成了一条成功的轨迹。这样的轨迹可谓之次优。它们接近我们的期望,但它们的最优性仍未得到实证。
当然,我们能据历史数据手工标记最优轨迹。这种方式令我们更接近监督学习,更贴合这种方式的所有优点和缺点。
同时,选择最优验算会助推模型处于理想条件,这可导致模型过度拟合。在这种情况下,模型在学习了训练样本的路线后,不能将获得的经验普适到新的环境状态。
行为克隆方法的第二个问题层面是为模型设定目标(在途回报,RTG)。我们在以前的工作中已讨论过这个问题。一些工作建议采用系数来吧训练集的结果最大化,这往往会产生更好的结果。但这种方式仅适用于解决静态问题。为每个任务分别选择这样的系数。控制二分法为这个问题提供了另一种方案。还有其它方法。
上面言及的问题由文章《离线强化学习的行为克隆可靠条件》的作者加以定位。为了解决这些问题,作者提出了一种相当有趣的方法,即保守加权行为克隆(CWBC),它不仅适用于决策转换器家族的模型。
1. 算法
为了辨别影响依赖于目标奖励的强化学习方法可靠性的因素,文章《离线强化学习的行为克隆可靠条件》的作者设计了两个阐述性实验。
在第一个实验中,他们在具有不同回报水平的轨迹数据集上运行不同架构的模型,从几乎随机级到专家级、和次优级。实验结果表明,模型的可靠性很大程度上取决于训练数据集的品质。当依据来自平均和专家返回轨迹的数据训练模型时,该模型在较高 RTG 条件下展现出可靠的结果。同时,当依据较低分数轨迹训练模型时,在 RTG 增加某个点后,其性能会迅速衰减。这是因为低品质的数据无法提供足够的信息来训练以高额奖励为条件的政策。这会对结果模型的可靠性产生负面影响。
数据品质并不是模型可靠性的唯一原因。模型架构也起着重要作用。在所进行的实验中,DT 在所有三个数据集中都展现出可靠性。假设 DT 可靠性是通过使用转换器架构实现的。由于智能体的下一个动作预测政策基于一系列环境状态,和 RTG 标记,因此关注度层可以忽略训练数据集分布之外的 RTG 标记。这也展示出良好的预测准确性。同时,基于 MLP 架构构建的模型接收当前状态,和 RTG 作为生成动作的输入数据,不能忽略有关所需奖励的信息。为了验证这一假设,作者的实验采用了略微修改的 DT 版本,其中环境和 RTG 向量在每个时间步骤都串联起来。因此,模型不能忽略序列中的 RTG 信息。实验结果表明,RTG 离开训练集分布后,该模型的可靠性迅速衰减。这确认了上述假设。
为了优化模型训练过程,并尽量减少上述因素的影响,文章作者建议采用“保守加权行为克隆(CWBC)”框架,这是一种相当简单,但又有效的方式,可提高现有方法训练行为克隆模型的可靠性。CWBC 由两部分组成:
- 轨迹加权
- 保守性 RTG 正则化
轨迹加权提供了一种系统性的途径,通过提升高回报轨迹的权重,把次优数据分布转换为更准确估值的最优分布。保守性损失正则化器鼓励政策保持接近原始数据分布,意向是大型目标。
1.1轨迹加权
我们知道,轨迹的最优离线分布就是由最优政策生成的演示分布。典型情况,轨迹的离线分布将相对于最优轨迹会有乖离。在训练期间,这会导致训练和测试之间的间隙,因为我们希望在评估和操作模型时,调节我们的智能体以便最大化其回报,但在训练期间强制把基于乖离数据分布的经验风险降至最低。
该方法的主要思路是将轨迹的训练样本转换为新的分布,以更好地估算最优轨迹。新的分布应该专注于高回报轨迹,其直觉上减轻了训练-测试的间隙。由于我们期待原始数据集包含很少的高回报轨迹,仅仅剔除低回报轨迹就会消除训练数据的大部分。这将导致数据效率低下。该方法的作者提议基于轨迹的回报对它们加权。
其中 λ、k 是判定变换分布形状的两个超参数。
平滑参数 k 控制如何基于轨迹的回报加权。直觉上,较小的 k 为高回报轨迹提供了更大的权重。随着参数值的增加,变换后的分布变得更加均匀。作者提议将 k 值设置为训练数据集中结果的最大值与第 z 个百分位数值之间的差值。
这令 k 的实际值能够适配不同的数据集。方法作者测试了来自集合 {99, 90, 50, 0} 中的四个 z 值,它们对应于四个递增的 k 值。根据每个数据集的实验结果,使用较小 k 值的变换分布高度集中在高奖励。随着 k 的递增,低回报轨迹的密度增加,分布变得更加均匀。基于来自集合 {99, 90, 50} 的百分位数,k 值相对较小,该模型在所有数据集上都表现出良好的性能。不过,基于百分位数 0 的较大 k 值会令专家轨迹数据集的性能降级。
参数 λ 也会影响变换分布。当 λ = 0 时,转换分布集中在高回报。随着 λ 的递增,变换后的分布趋向于本源,但由于指数项的影响,仍向高回报区域加权。具有不同 λ 值的模型的实际性能展现出相似的结果,比之在原始数据集上的训练结果更好或相当。
1.2保守性正则化
如上所述,架构在训练模型的可靠性方面也扮演着重要角色。理想化的场景很难、甚至不可能达成。但是 CWBC 方法的作者要求模型至少接近原始数据分布,从而避免 RTG 落在指定分布之外时发生灾难性故障。换句话说,政策必须是保守性的。然而,保守主义不一定来自架构,也可能来自适当的模型训练损失函数,就像通常基于状态和过渡成本估测的保守方法所做的那样。
该方法的作者提出了一种新的保守性正则化器,用于回报条件化的行为克隆方法,显式鼓励政策停留在接近原始数据分布。该思路是当条件化回报位于大量分布之外时,为了停留在接近分布内的动作,而强制执行预测动作。这是通过往拥有高回报轨迹里的 RTG 添加正值噪声,并惩罚预测动作与地面实况之间的 L2 距离来达成的。为了保证在分布之外产生较大的回报,我们生成噪音,如此这般调整后的 RTG 值不小于训练集中的最高回报。
作者提议将保守性正则化应用于回报率超过训练集中奖励的第 q 个百分位数的轨迹。这可确保当指定的 RTG 处于训练分布之外时,政策的行为类似于高回报轨迹,而非随机轨迹。我们在每个时间步骤添加噪声,并偏移 RTG。
方法作者进行的实验表明,采用第 95 个百分位数在各种环境和数据集中普遍效果很好。
该方法的作者指出,所提出的保守性正则化器与其它基于估测状态和转换成本的离线 RL 方法的保守性组件不同。虽然后者典型情况会尝试调整成本函数的估值,从而防止外推误差,但所拟议的方法扭曲了创建分布外条件、及调整动作预测的在途回报。
结合使用轨迹加权与保守性正则化器,我们得到了“保守加权行为克隆(CWBC)”,它结合了两全的优势。
2. 利用 MQL5 实现
在研究过保守加权行为克隆方法的理论层面之后,我们转到实现我们对所提议方法的解释。在这项工作中,我们将训练 2 个模型:
- 预测动作的决策转换器。
- 估算 RTG 生成环境当前状态成本的模型。
我们将在优化学习过程里添加轨迹加权,和保守性正则化。CWBC 方法的作者声称,所提议算法可以将 DT 训练的效率平均提高 8%。
注意,模型训练过程是独立的。可以组织它们的并行训练。这就是我们即将使用的。但首先,我们定义一下模型的架构。我们把架构定义过程划分为 2 个独立的方法。在 CreateDescriptions 方法中,我们将创建智能体架构的定义,其接收所分析序列的一个步骤作为输入,该序列由 5 个实体组成:
- 价格走势的历史数据,和指标的分析读数;
- 账户状态和持仓;
- 时间戳;
- 智能体的最后动作;
- RTG。
这反映在模型的源数据层之中。
bool CreateDescriptions(CArrayObj *agent) { //--- CLayerDescription *descr; //--- if(!agent) { agent = new CArrayObj(); if(!agent) return false; } //--- Agent agent.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions + NRewards); descr.activation = None; descr.optimization = ADAM; if(!agent.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(!agent.Add(descr)) { delete descr; return false; }
接下来,我们将所有实体转换为可比较的形式。为此,我们首先使用一个嵌入层,将所有内容转移到单个 N 维空间之中。我想提醒您,我们的嵌入层在内存中包含以前获得的数据,以便分析历史深度。新数据将添加到所收集序列之中。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronEmbeddingOCL; prev_count = descr.count = HistoryBars; { int temp[] = {BarDescr * NBarInPattern, AccountDescr, TimeDescription, NActions, NRewards}; ArrayCopy(descr.windows, temp); } int prev_wout = descr.window_out = EmbeddingSize; if(!agent.Add(descr)) { delete descr; return false; }
然后,我们调用 SoftMax 层将所有嵌入转换为可比较的分布。请注意,SoftMax 适用于每个独立的嵌入。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = EmbeddingSize; descr.step = prev_count * 5; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; }
把所有嵌入转换为可比较的形式之后,我们用一个关注度模块来分析结果序列。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; prev_count = descr.count = prev_count * 5; descr.window = EmbeddingSize; descr.step = 8; descr.window_out = 32; descr.layers = 4; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; }
接下来是 2 个卷积层模块,它在数据中搜索稳定的形态,同时将数据维度缩减 2 倍。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = EmbeddingSize; descr.step = EmbeddingSize; prev_wout = descr.window_out = EmbeddingSize; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 6 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 = prev_wout / 2; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.Add(descr)) { delete descr; return false; }
请注意,我们一直在单独的嵌入框架内处理数据。我们调用 SoftMax 函数将所有实体转换为可比较的形式来完成此阶段,我们还将其分别应用于序列中的每个实体。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = prev_wout; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; }
经过处理且完全可比的数据被传输到决策模块,其由完全连接层组成。在输出中,我们得到生成的预测智能体动作。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 10 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 11 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = NActions; descr.activation = SIGMOID; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; } //--- return true; }
下一步是在 CreateRTGDescriptions 方法中创建环境成本核算模型架构的定义。我们把一定序列的历史价格变化和所分析指标读数投喂到该模型之中。在本例中,我们谈论的是若干根柱线的序列。
bool CreateRTGDescriptions(CArrayObj *rtg) { //--- CLayerDescription *descr; //--- if(!rtg) { rtg = new CArrayObj(); if(!rtg) return false; } //--- RTG rtg.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = ValueBars * BarDescr; descr.activation = None; descr.optimization = ADAM; if(!rtg.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(!rtg.Add(descr)) { delete descr; return false; }
接下来,我们调用卷积层和 SoftMax 函数创建每根柱线的嵌入。在这种情况下,我们不使用嵌入层,因为每根柱线的数据结构是相同的,我们不需要累积接收到的数据。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = (prev_count + BarDescr - 1) / BarDescr; descr.window = BarDescr; descr.step = BarDescr; int prev_wout = descr.window_out = EmbeddingSize; descr.optimization = ADAM; descr.activation = LReLU; if(!rtg.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = EmbeddingSize; descr.activation = None; descr.optimization = ADAM; if(!rtg.Add(descr)) { delete descr; return false; }
处理后的数据被传输到关注度模块。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; descr.count = prev_count; descr.window = EmbeddingSize; descr.step = 8; descr.window_out = 32; descr.layers = 4; descr.optimization = ADAM; if(!rtg.Add(descr)) { delete descr; return false; }
然后数据进入卷积层模块,然后由 SoftMax 常规化,与上面讨论的模型相似。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = EmbeddingSize; descr.step = EmbeddingSize; prev_wout = descr.window_out = EmbeddingSize; descr.optimization = ADAM; descr.activation = LReLU; if(!rtg.Add(descr)) { delete descr; return false; } //--- layer 6 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 = prev_wout / 2; descr.optimization = ADAM; descr.activation = LReLU; if(!rtg.Add(descr)) { delete descr; return false; } //--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = prev_wout; descr.activation = None; descr.optimization = ADAM; if(!rtg.Add(descr)) { delete descr; return false; }
之后,我们自全连接层创建一个决策模块。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!rtg.Add(descr)) { delete descr; return false; } //--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = TANH; descr.optimization = ADAM; if(!rtg.Add(descr)) { delete descr; return false; }
在模型的输出端,我们使用变分自编码器模块在 RTG 生成政策中生成随机性。因此,我们模仿环境的随机性,及在学习分布框架内的可能转换成本。
//--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 2 * NRewards; descr.activation = None; descr.optimization = ADAM; if(!rtg.Add(descr)) { delete descr; return false; } //--- layer 10 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = NRewards; descr.activation = None; descr.optimization = ADAM; if(!rtg.Add(descr)) { delete descr; return false; } //--- return true; }
在创建模型架构的定义之后,我们转到模型训练 EA。对于初始训练样本集合,我们将使用 EA “...\CWBC\Faza1.mq5” 选择最优随机轨迹采样。该 EA 的算法和数据收集原理在《控制转换器》一文中进行了讲述。
接下来,我们创建一个 EA 来训练我们的智能体 “...\CWBC\StudyAgent.mq5”。必须要说的是,这个 EA 很大程度上继承自原始决策转换器训练 EA 的结构。此外,我们还用到来自 CWBC 方法的一些方式对其进行了补充。首先,我们将创建一个名为 GetProbTrajectories 的轨迹加权方法,该方法将返回采样轨迹的累积概率向量。在方法的主体中,我们立即判定经验回放缓冲区中的最大回报、所需分位数的等级、和回报标准偏差的向量。我们需要这些数据进行后续的保守性正则化。
在方法的参数中,我们传递经验回放缓冲区、和必要的变量。
vector<float> GetProbTrajectories(STrajectory &buffer[], float &max_reward, float &quantile, vector<float> &std, double quant, float lanbda) { ulong total = buffer.Size();
在方法的主体中,我们判定回放缓冲区中的轨迹数量,并准备一个矩阵,用来在验算中收集奖励。
matrix<float> rewards = matrix<float>::Zeros(total, NRewards); vector<float> result;
当把轨迹保存到回放缓冲区时,我们会重新计算累积奖励,直到验算结束。因此,整个验算的总奖励将存储在索引为 0 的元素当中。我们将组织一个循环,并将每次验算的总奖励复制到我们准备好的矩阵之中。
for(ulong i = 0; i < total; i++) { result.Assign(buffer[i].States[0].rewards); rewards.Row(result, i); }
使用矩阵运算,我们获得了奖励向量中每个元素的标准偏差。
std = rewards.Std(0);
每次验算的总奖励向量和最大奖励值。
result = rewards.Sum(1);
max_reward = result.Max();
注意,我在每次验算中都用到了一次简单地奖励向量求和。不过,分解奖励的平均值,以及额度或平均值的加权选项,可能会有所不同。该方法取决于具体任务。
接下来,我们判定所需分位数的等级。有关分位数向量运算的 MQL5 文档指出,正确计算需要已排序序列向量。我们创建总奖励向量的副本,并对其按升序排序。
vector<float> sorted = result; bool sort = true; int iter = 0; while(sort) { sort = false; for(ulong i = 0; i < sorted.Size() - 1; i++) if(sorted[i] > sorted[i + 1]) { float temp = sorted[i]; sorted[i] = sorted[i + 1]; sorted[i + 1] = temp; sort = true; } iter++; } quantile = sorted.Quantile(quant);
接下来,我们调用向量函数 Quantile,并保存结果。
在收集后续操作所需的数据之后,我们直接开始判定每个轨迹的权重。为了统一系数 λ 的使用,我们需要一种算法,将所有可能的奖励样本带到单个分布之中。为此,我们将所有奖励常规化到范围 (0, 1]。
请注意,我们不在常规化数值范围内包含 “0”,因为每个轨迹的概率必须与 “0” 不同。故此,我们将奖励最小值的范围拉低至奖励均方的 10%。
最大限度地使用相对值,令我们能够真正统一我们的计算。
float min = result.Min() - 0.1f * std.Sum();
不过,在所有验算中获得相同奖励值的概率很小。这可能有多种原因。尽管此类事件的概率很低,但我们要创建一个检查。在我们算法的主分支中,我们将首先计算指数分量。然后,我们对奖励进行常规化,并重新计算轨迹的权重。
if(max_reward > min) { vector<float> multipl=exp(MathAbs(result - max_reward) / (result.Percentile(90)-max_reward)); result = (result - min) / (max_reward - min); result = result / (result + lanbda) * multipl; result.ReplaceNan(0); }
对于奖励相等的特殊情况,我们将用一个常数值填充概率向量。
else result.Fill(1);
然后,我们将所有概率的总和降至 “1”,并计算累积和的向量。
result = result / result.Sum(); result = result.CumSum(); //--- return result; }
为了在每次迭代中对轨迹进行采样,我们调用 SampleTrajectory 方法,在该方法的参数中,我们传递上面获得的累积概率向量。迭代的结果是经验回放缓冲区中的轨迹索引。
int SampleTrajectory(vector<float> &probability) { //--- check ulong total = probability.Size(); if(total <= 0) return -1;
在方法的主体中,我们检查结果概率向量的大小,如果它为空,我们立即返回不正确的索引 “-1”。
接下来,我们从均匀分布中生成一个范围 [0, 1] 内的随机数,并寻找其选择概率范围落在结果随机值内的元素。
首先,我们检查极值(概率向量的第一个和最后一个元素)。
//--- randomize float rnd = float(MathRand() / 32767.0); //--- search if(rnd <= probability[0] || total == 1) return 0; if(rnd > probability[total - 2]) return int(total - 1);
如果采样值不在极值范围内,我们会遍历向量元素,从而搜索所需的值。
直觉上,人们可以假设轨迹的概率分布趋于均匀。从向量的中间开始遍历元素,同时沿所需方向移动,将比从头开始遍历整个数组快得多。如此,我们将采样值乘以向量的大小,得到元素的一些索引。我们检查所选元素相较采样值的概率。如果它的概率较低,那么在循环中,我们递增索引,直至找到所需的元素。否则,我们同样会递减索引。
int result = int(rnd * total); if(probability[result] < rnd) while(probability[result] < rnd) result++; else while(probability[result - 1] >= rnd) result--; //--- return result return result; }
结果将返回给调用程序。
实现 CWBC 方法所需的另一个辅助函数是噪音生成函数 “Noise”。在函数参数中,我们传递奖励向量元素的标准偏差向量,和判定最大噪音等级的标量系数。该函数返回噪音向量。
vector<float> Noise(vector<float> &std, float multiplyer) { //--- check ulong total = std.Size(); if(total <= 0) return vector<float>::Zeros(0);
在函数的主体中,我们首先检查标准偏差向量的大小。如果它是空的,那么我们返回一个空的噪音向量。
成功传递控制模块之后,我们创建一个零值的向量。接下来,在一个循环中,我们为奖励向量的每个元素生成一个单独的噪音值。
vector<float> result = vector<float>::Zeros(total); for(ulong i = 0; i < total; i++) { float rnd = float(MathRand() / 32767.0); result[i] = std[i] * rnd * multiplyer; } //--- return result return result; }
我们已创建了实现 CWBC 方法的单独模块,现在正在转到实现完整智能体模型训练算法,其在 Train 方法中实现。
在该方法的主体中,我们声明必要的局部变量,并调用 GetProbTrajectories 方法来进行轨迹加权。
void Train(void) { float max_reward = 0, quantile = 0; vector<float> std; vector<float> probability = GetProbTrajectories(Buffer, max_reward, quantile, std, 0.95, 0.1f); uint ticks = GetTickCount();
然后我们组织一个模型训练循环系统。在循环主体中,我们首先调用 SampleTrajectory 方法对轨迹进行采样,然后在所选轨迹上随机选择一个状态开始学习过程。
bool StopFlag = false; for(int iter = 0; (iter < Iterations && !IsStopped() && !StopFlag); iter ++) { int tr = SampleTrajectory(probability); int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * MathMax(Buffer[tr].Total - 2 * HistoryBars - ValueBars, MathMin(Buffer[tr].Total, 20))); if(i < 0) { iter--; continue; }
接下来,我们组织一个嵌套循环,其中模型依据连续环境状态进行训练。为了正确训练和运行决策转换器模型,我们需要严格按照事件的历史顺序使用事件。模型在收集接收到的数据时,会按数据到达内部缓冲区的顺序,并生成所分析历史序列。
Actions = vector<float>::Zeros(NActions); Agent.Clear(); for(int state = i; state < MathMin(Buffer[tr].Total - 1 - ValueBars, i + HistoryBars * 3); state++) { //--- History data State.AssignArray(Buffer[tr].States[state].state);
在循环主体中,我们将数据收集到源数据缓冲区当中。首先,我们下载历史价格走势数据,及分析指标值。
接下来是有关账户状态和持仓的信息。
//--- Account description float PrevBalance = (state == 0 ? Buffer[tr].States[state].account[0] : Buffer[tr].States[state - 1].account[0]); float PrevEquity = (state == 0 ? Buffer[tr].States[state].account[1] : Buffer[tr].States[state - 1].account[1]); State.Add((Buffer[tr].States[state].account[0] - PrevBalance) / PrevBalance); State.Add(Buffer[tr].States[state].account[1] / PrevBalance); State.Add((Buffer[tr].States[state].account[1] - PrevEquity) / PrevEquity); State.Add(Buffer[tr].States[state].account[2]); State.Add(Buffer[tr].States[state].account[3]); State.Add(Buffer[tr].States[state].account[4] / PrevBalance); State.Add(Buffer[tr].States[state].account[5] / PrevBalance); State.Add(Buffer[tr].States[state].account[6] / PrevBalance);
之后,我们生成一个时间戳。
//--- Time label double x = (double)Buffer[tr].States[state].account[7] / (double)(D'2024.01.01' - D'2023.01.01'); State.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_MN1); State.Add((float)MathCos(2.0 * M_PI * x)); x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_W1); State.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_D1); State.Add((float)MathSin(2.0 * M_PI * x));
我们在缓冲区中加入智能体最后动作的向量。
//--- Prev action if(state > 0) State.AddArray(Buffer[tr].States[state - 1].action); else State.AddArray(vector<float>::Zeros(NActions));
接下来,我们只需要在缓冲区中加入 RTG 形式的目标指定。在此模块中,在验算结束之前,我们不会使用目标指定,且仅针对一小部分局部片段。于此,我们还创建了一个保守性正则化过程。为此,我们首先检查所用轨迹的盈利能力,并在必要时生成噪音矢量。我要提醒您,根据 CWBC 方法,噪音只会添加到回报率最高的验算上。
//--- Return to go vector<float> target, result; vector<float> noise = vector<float>::Zeros(NRewards); target.Assign(Buffer[tr].States[0].rewards); if(target.Sum() >= quantile) noise = Noise(std, 100);
接下来,我们计算局部历史区间的实际回报。添加生成的噪音向量。将生成的值添加到源数据缓冲区。
target.Assign(Buffer[tr].States[state + 1].rewards); result.Assign(Buffer[tr].States[state + ValueBars].rewards); target = target - result * MathPow(DiscFactor, ValueBars) + noise; State.AddArray(target);
现在我们已经生成了一整套必要的数据,我们运行智能体的前馈验算,从而形成动作向量。
//--- Feed Forward if(!Agent.feedForward(GetPointer(State), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
前馈验算成功后,我们调用智能体的反向传播方法,将智能体的预测动作和实际动作之间的差异最小化。该过程类似于训练原始 DT。
//--- Policy study Result.AssignArray(Buffer[tr].States[state].action); if(!Agent.backProp(Result, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
最后,我们需要告知用户模型训练过程的进度,并转到我们的模型训练循环系统的下一次迭代。
//--- if(GetTickCount() - ticks > 500) { string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Agent", iter * 100.0 / (double)(Iterations), Agent.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } }
模型训练迭代循环全部完成后,清除图表上的注释字段。将训练结果输出到日志中,并启动 EA 关闭。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Agent", Agent.getRecentAverageError()); ExpertRemove(); //--- }
我们对智能体训练算法的讲述到此结束。训练用于评测环境状态的模型是在 EA “...\CWBC\StudyRTG.mq5” 中基于类似原则构建的。我建议您自行从附件中掌握它。附件还包含本文中用到的所有程序。
我还想多说一点。我们通过选择最佳采样轨迹形成了主要的训练数据集。它们可以有条件地归类为次优,因为它们满足我们的一些需求。接下来,我们想优化依据此类数据训练的智能体政策。为此,我们需要依据历史数据测试训练模型的性能,同时收集有关优化政策能力的信息。故此,在策略测试器里针对训练样本的历史区段进行下一次验算时,我们根据智能体预测的数据在某个置信区间内执行动作,并将此类验算的结果添加到我们的经验回放缓冲区之中。之后,我们迭代执行模型下游训练。
收集下游验算的函数将在 EA “...\CWBC\Research.mq5” 中实现。在本文的框架内,我们不会详细讨论 EA 的所有方法。我们只研究 OnTick 跳价处理方法,它实现了与环境的交互。
在方法的主体中,我们检查是否发生了新柱线开立事件,并在必要时加载历史数据。
void OnTick() { //--- if(!IsNewBar()) return; //--- int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), History, Rates); if(!ArraySetAsSeries(Rates, true)) return; //--- RSI.Refresh(); CCI.Refresh(); ATR.Refresh(); MACD.Refresh(); Symb.Refresh(); Symb.RefreshRates();
根据获得的数据,我们首先形成一个输入数据向量来估算状态,并调用相应模型的前馈验算。
//--- History data float atr = 0; bState.Clear(); for(int b = ValueBars - 1; b >= 0; b--) { float open = (float)Rates[b].open; float rsi = (float)RSI.Main(b); float cci = (float)CCI.Main(b); atr = (float)ATR.Main(b); float macd = (float)MACD.Main(b); float sign = (float)MACD.Signal(b); if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE) continue; //--- bState.Add((float)(Rates[b].close - open)); bState.Add((float)(Rates[b].high - open)); bState.Add((float)(Rates[b].low - open)); bState.Add((float)(Rates[b].tick_volume / 1000.0f)); bState.Add(rsi); bState.Add(cci); bState.Add(atr); bState.Add(macd); bState.Add(sign); } if(!RTG.feedForward(GetPointer(bState), 1, false)) return;
接下来,我们形成智能体初始数据的张量。确保遵循训练模型时所用的数据顺序。在此,我们使用来自环境的数据,而非经验回放缓冲区。
for(int b = 0; b < (int)NBarInPattern; b++) { float open = (float)Rates[b].open; float rsi = (float)RSI.Main(b); float cci = (float)CCI.Main(b); atr = (float)ATR.Main(b); float macd = (float)MACD.Main(b); float sign = (float)MACD.Signal(b); if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE) continue; //--- int shift = b * BarDescr; sState.state[shift] = (float)(Rates[b].close - open); sState.state[shift + 1] = (float)(Rates[b].high - open); sState.state[shift + 2] = (float)(Rates[b].low - open); sState.state[shift + 3] = (float)(Rates[b].tick_volume / 1000.0f); sState.state[shift + 4] = rsi; sState.state[shift + 5] = cci; sState.state[shift + 6] = atr; sState.state[shift + 7] = macd; sState.state[shift + 8] = sign; } bState.AssignArray(sState.state);
并行之,我们把收集到数据纳入结构,并存储在经验回放缓冲区。
我们还进行环境调查(向终端查询),以便收集有关账户状态和持仓的信息。
//--- Account description sState.account[0] = (float)AccountInfoDouble(ACCOUNT_BALANCE); sState.account[1] = (float)AccountInfoDouble(ACCOUNT_EQUITY); //--- double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0; double position_discount = 0; double multiplyer = 1.0 / (60.0 * 60.0 * 10.0); int total = PositionsTotal(); datetime current = TimeCurrent(); for(int i = 0; i < total; i++) { if(PositionGetSymbol(i) != Symb.Name()) continue; double profit = PositionGetDouble(POSITION_PROFIT); switch((int)PositionGetInteger(POSITION_TYPE)) { case POSITION_TYPE_BUY: buy_value += PositionGetDouble(POSITION_VOLUME); buy_profit += profit; break; case POSITION_TYPE_SELL: sell_value += PositionGetDouble(POSITION_VOLUME); sell_profit += profit; break; } position_discount += profit - (current - PositionGetInteger(POSITION_TIME)) * multiplyer * MathAbs(profit); } sState.account[2] = (float)buy_value; sState.account[3] = (float)sell_value; sState.account[4] = (float)buy_profit; sState.account[5] = (float)sell_profit; sState.account[6] = (float)position_discount; sState.account[7] = (float)Rates[0].time; //--- bState.Add((float)((sState.account[0] - PrevBalance) / PrevBalance)); bState.Add((float)(sState.account[1] / PrevBalance)); bState.Add((float)((sState.account[1] - PrevEquity) / PrevEquity)); bState.Add(sState.account[2]); bState.Add(sState.account[3]); bState.Add((float)(sState.account[4] / PrevBalance)); bState.Add((float)(sState.account[5] / PrevBalance)); bState.Add((float)(sState.account[6] / PrevBalance));
生成的时间戳完全遵从学习过程算法。
//--- Time label double x = (double)Rates[0].time / (double)(D'2024.01.01' - D'2023.01.01'); bState.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_MN1); bState.Add((float)MathCos(2.0 * M_PI * x)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_W1); bState.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_D1); bState.Add((float)MathSin(2.0 * M_PI * x));
在初始数据向量收集过程结束时,我们添加智能体最后动作,以及由我们的模型生成的在途回报。
//--- Prev action bState.AddArray(AgentResult); //--- Latent representation RTG.getResults(Result); bState.AddArray(Result);
收集到的数据被传输到我们的智能体前馈方法,以便形成后续动作的向量。
//--- if(!Agent.feedForward(GetPointer(bState), 1, false, (CBufferFloat *)NULL)) return;
我们通过添加随机噪音,稍微扭曲智能体预测动作向量。这样,我们鼓励在预测动作的特定环境中探索环境。
Agent.getResults(AgentResult); for(ulong i = 0; i < AgentResult.Size(); i++) { float rnd = ((float)MathRand() / 32767.0f - 0.5f) * 0.03f; float t = AgentResult[i] + rnd; if(t > 1 || t < 0) t = AgentResult[i] - rnd; AgentResult[i] = t; } AgentResult.Clip(0.0f, 1.0f);
之后,我们将后续烛条所需的数据保存到局部变量之中。
PrevBalance = sState.account[0]; PrevEquity = sState.account[1];
我们调整多向持仓的重叠交易量。
double min_lot = Symb.LotsMin(); double step_lot = Symb.LotsStep(); double stops = MathMax(Symb.StopsLevel(), 1) * Symb.Point(); if(AgentResult[0] >= AgentResult[3]) { AgentResult[0] -= AgentResult[3]; AgentResult[3] = 0; } else { AgentResult[3] -= AgentResult[0]; AgentResult[0] = 0; }
然后,我们解码智能体动作的结果向量。之后,我们在环境中实现它们。
//--- buy control if(AgentResult[0] < 0.9*min_lot || (AgentResult[1] * MaxTP * Symb.Point()) <= stops || (AgentResult[2] * MaxSL * Symb.Point()) <= stops) { if(buy_value > 0) CloseByDirection(POSITION_TYPE_BUY); } else { double buy_lot = min_lot + MathRound((double)(AgentResult[0] - min_lot) / step_lot) * step_lot; double buy_tp = Symb.NormalizePrice(Symb.Ask() + AgentResult[1] * MaxTP * Symb.Point()); double buy_sl = Symb.NormalizePrice(Symb.Ask() - AgentResult[2] * MaxSL * Symb.Point()); if(buy_value > 0) TrailPosition(POSITION_TYPE_BUY, buy_sl, buy_tp); if(buy_value != buy_lot) { if(buy_value > buy_lot) ClosePartial(POSITION_TYPE_BUY, buy_value - buy_lot); else Trade.Buy(buy_lot - buy_value, Symb.Name(), Symb.Ask(), buy_sl, buy_tp); } } //--- sell control if(AgentResult[3] < 0.9*min_lot || (AgentResult[4] * MaxTP * Symb.Point()) <= stops || (AgentResult[5] * MaxSL * Symb.Point()) <= stops) { if(sell_value > 0) CloseByDirection(POSITION_TYPE_SELL); } else { double sell_lot = min_lot + MathRound((double)(AgentResult[3] - min_lot) / step_lot) * step_lot;; double sell_tp = Symb.NormalizePrice(Symb.Bid() - AgentResult[4] * MaxTP * Symb.Point()); double sell_sl = Symb.NormalizePrice(Symb.Bid() + AgentResult[5] * MaxSL * Symb.Point()); if(sell_value > 0) TrailPosition(POSITION_TYPE_SELL, sell_sl, sell_tp); if(sell_value != sell_lot) { if(sell_value > sell_lot) ClosePartial(POSITION_TYPE_SELL, sell_value - sell_lot); else Trade.Sell(sell_lot - sell_value, Symb.Name(), Symb.Bid(), sell_sl, sell_tp); } }
接下来,我们需要从环境中接收变换到当前状态(自智能体先前动作)的奖励,并将收集到的数据传输到经验回放缓冲区。
int shift = BarDescr * (NBarInPattern - 1); sState.rewards[0] = bState[shift]; sState.rewards[1] = bState[shift + 1] - 1.0f; if((buy_value + sell_value) == 0) sState.rewards[2] -= (float)(atr / PrevBalance); else sState.rewards[2] = 0; for(ulong i = 0; i < NActions; i++) sState.action[i] = AgentResult[i]; if(!Base.Add(sState)) ExpertRemove(); }
您可在附件中找到 EA 及其所有方法的完整代码。
已训练模型测试 EA “...\CWBC\Test.mq5” 遵循类似的算法,只是预测的智能体动作向量的失真。其代码也包含在文章的附件当中。
在创建所有必要的程序之后,我们转入测试已完成的工作。
3. 测试
在本文的实践部分,我们做了大量工作来利用 MQL5 实现我们的保守加权行为克隆方法的愿景。现在,我们在实践中评估一下劳动成果。如常,我们将采用 EURUSD H1 历史数据训练和测试我们的模型。我们将采用 2023 年前 7 个月的历史时期作为训练数据。测试将使用 2023 年 8 月的数据进行。
如上所述,我们将使用《控制转换器》一文中的采样数据进行初始训练。因此,我们跳过此过程,立即转入模型训练过程。
在本文中,我们创建了两个 EA 来训练两个模型。这令我们能够并行训练 2 个模型。该过程能在不同的设备上独立执行。
在对模型进行初始训练后,我们依据训练数据集检查已训练模型的性能,并在策略测试器中依照训练数据集的历史周期运行 EA “...\CWBC\Research.mq5” 和 “...\CWBC\Test.mq5” 来收集其它轨迹。在这种情况下,EA 的启动顺序不会影响训练模型的过程。
然后,我们使用更新的经验回放缓冲区中的数据运行下游训练。
此处应该注意的是,在我的例子中,只有在下游学习的第一次迭代之后才能观察到模型性能的提高。旨在收集其它轨迹的深入迭代,以及据其重新训练模型,并未产生预期的结果。但这也许是一个特例。
在训练过程中,我设法获得了一个模型,其在训练样本的历史区段上产生了盈利。
在训练期间,该模型交易了 141 笔。其中约 40% 以盈利了结。最大盈利交易是最大亏损的 4 倍以上。平均盈利交易几乎是平均亏损的 2 倍。甚至,平均盈利交易比最大亏损高 13%。所有这些给出了 1.11 的盈利系数。在新数据中也观察到类似的结果。
但是,对于所获得的结果,也存在负面因素。该模型仅开立多头持仓,这通常与该历史区间内的全球趋势相对应。结果就是,余额曲线与金融产品图表非常相似。
详细的测试分析显示,2023 年 2 月和 5 月的亏损在随后的几个月中重叠。事实证明,三月份是最有利的月份。从周线来看,周三表现出最大的盈利能力。
结束语
在本文中,我们介绍了保守加权行为克隆(CWBC),它结合了轨迹加权和保守性正则化,从而提高学习策略的健壮性。我们利用 MQL5 实现了所提议的方法,并在真实的历史数据上对其进行了测试。
结果表明,CWBC 在离线模型训练中表现出相当高的稳定性。特别是,该方法成功地应对了高回报轨迹仅占训练数据集一小部分的情况。不过,请注意仔细选择必要的超参数的重要性,这对 CWBC 的有效性起着重要作用。
参考
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Faza1.mq5 | EA | 样本收集 EA |
2 | Research.mq5 | EA | 收集额外轨迹的智能系统 |
3 | StudyAgentmq5 | EA | 训练局部政策模型的智能系统 |
4 | StudyRTG.mq5 | EA | 训练成本函数的智能系统 |
5 | Test.mq5 | EA | 模型测试 EA |
6 | Trajectory.mqh | 类库 | 系统状态定义结构 |
7 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
8 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/13742


