
神经网络变得简单(第 61 部分):离线强化学习中的乐观情绪问题
概述
最近,离线强化学习方法已广泛普及,其在解决不同复杂度的问题方面具有许多前景。然而,研究人员面临的主要问题之一是当学习时可能浮现的乐观情绪。智能体基于训练集中的数据优化其策略,并获得对其动作的信心。但是训练集往往无法涵盖环境的所有可能状态和转变。在随机环境中,这种信心被揭示是不完全正当的。在这种情况下,智能体的乐观情绪策略可能会导致风险增加,以及不良后果。
为了搜索这个问题的解决方案,值得关注的是自主驾驶领域的研究。很明显,该领域的算法旨在降低风险(提高用户安全性),并最大限度地减少在线训练。其中一种方法是分离潜在轨迹(SPLT)转换器,其在文章《定位强化学习序列建模中的优化乖离》(2022 年 7 月)中阐述。
1. SPLT-转换器方法
与决策转换器类似,SPLT-转换器是利用转换器架构的序列生成模型。但与上述 DT 不同的是,它用两个独立的信息流来对扮演者政策和环境进行建模。
该方法的作者试图解决 2 个主要问题:
- 模型应有助于在任何情况下为智能体的行为创建各种候选者;
- 模型应涵盖向新环境状态转变的各种潜在模式的大多数。
为了达成该目标,我们基于扮演者政略和环境模型转换器,训练了 2 个独立的 VAE。方法作者为两个流程生成随机潜在变量,并在覆盖整个规划界限内使用它们。这令我们能够枚举所有可能的候选轨迹,而分支不会呈指数级增加,并在测试期间提供对行为选项的有效搜索。
这一思路是,潜在的政策变量应当对应于不同高度的意图,类似于层次化算法的技能。同时,环境模型的潜在变量应当对应于各种可能的趋势、及其状态下最可能的变化。
政策和环境编码器所用的架构与转换器相同。它们按先前轨迹的形式接收相同的初始数据。但与前面讨论的算法不同,轨迹仅包括一组扮演者状态和动作。在编码器的输出端,我们获得离散的潜在变量,每个维度的值数量有限。
该方法的作者建议使用转换器所有输出元素的平均值,以便将整个轨迹合并为一个向量表示。
接下来,这些输出中的每一个都经由一个小型多层感知器处理,其输出潜在表示的独立分类分布。
政策解码器接收与输入相同的原始轨迹,并辅以相应的潜在表示。政策解码器的目标是估算概率,并预测轨迹中下一个最有可能的动作。该方法的作者提出了一个使用转换器模型的解码器。
如上所述,我们从序列中删除了奖励,但添加了一个潜在表示。不过,潜在表示并不会在每个步骤中取代奖励作为序列元素。该方法的作者推介了一种由单个嵌入向量转换的潜在表示,类似于转换器架构下的其它一些工件中所用的位置编码。
环境模型解码器具有类似于政策解码器的架构。仅在输出端,环境模型解码器具有“三个头”来预测最可能的后续状态及其成本,以及转变奖励。
与 DT 一样,训练模型使用监督学习方法,并依据训练集中的数据。模型经过训练,可以将轨迹与后续动作(扮演者)、转换到新状态及其成本(环境模型)进行比较。
在测试和操作时,基于在给定规划界限上候选预测轨迹的评估来选择最优动作。为了编制一个规划的候选轨迹,生成含有奖励的行动和状态序列要涵盖规划界限运作。然后选择最优轨迹,且其第一个动作开始运作。在转变到环境的新状态后,重复整个算法。
如您所见,该算法规划了多个候选轨迹,但只执行最优轨迹的一个动作。尽管这种方式也许看似效率低下,但它能通过提前规划若干步骤来把风险最小化。同时,由于重新估测每个访问的状态,可以及时调整轨迹。
作者对该方法的可视化如下表示。
2. 利用 MQL5 实现
在研究 SPLT-转换器方法的理论层面之后,我们转到利用 MQL5 实现所提出的方法。我想立即言明,我们的实现将比以往任何时候都更远离作者的算法。原因是我的主观感知。本系列文章的整个经验演绎了为金融市场创建环境模型的复杂性。我们所有的尝试结果有点惭愧。预测的准确性非常低,只有 1-2 步。随着规划界限的增长,它趋于 0。因此,我决定不构建候选轨迹,而是将自己限制在仅从当前状态生成几个候选动作选项。
但这种方式在动作和估值之间产生了差距。如上面的可视化效果所示,扮演者政策和环境模型接收相同的输入数据。但随后数据以并行流的形式流动。因此,在预测后续状态和预期奖励时,环境模型对智能体将选择的动作一无所知。在此,我们只能基于来自训练样本的先前经验来谈论具有一定概率的某个假设。应该注意的是,训练样本是基于与当前所用扮演者政策不同的一个创建的。
在作者的版本中,通过将智能体的动作和预测状态添加到下一步的轨迹中来取得平衡。然而,在我们的案例中,考虑到对后续环境状态进行低质量规划的经验,我们有可能在轨迹中添加完全不协调的状态和动作。这将导致预测轨迹中下一步的规划品质愈加下降。以我的观点,这种规划和估算这种轨迹的效率是非常值得怀疑的。因此,我们不会将资源浪费在预测候选轨迹上。
同时,我们需要一种能够比较智能体动作和预期奖励的机制。一方面,我们可以使用评论者模型,但这从根本上破坏了算法,并彻底排除了环境模型。当然不能如此,我们把它当作评论者。
不过,我决定尝试的一种不同方式更接近原始算法。我决定开始就对两个流使用一个编码器。由此产生的潜在状态被添加到轨迹中,并馈送到 2 个解码器的输入端。扮演者基于初始数据生成预测动作,环境模型返回未来折扣奖励的数额。
该思路在于,给定相同的输入数据,模型返回一致的结果。为此,我们排除了扮演者和环境模型中的随机性。如此这般,我们在潜在表示中创建了随机性,这令我们能够生成多个候选操作和相关的预测状态估值。根据这些估值,我们将对候选动作进行排位,以便选择最优权重步骤。
为了优化所执行操作的数量,我们应该注意另一点。通过将相同的轨迹馈送到编码器输入,我们将以数学精度重复其所有内层的结果。仅当从给定分布中采样时,才会在变分自动编码器层中形成差异。因此,为了生成候选动作,建议我们将指定的层移动到编码器之外。这将允许我们在每次迭代中只执行一次编码器验算。经过一番思索,我将变分自动编码器层移到了环境模型之中。
我在优化工作流的道路上走得更远了。我们的三个模型都使用与输入数据相同的轨迹。如您所知,轨迹元素并不均匀。在处理之前,它们会经过嵌入层。这给了我一个灵感,即只在一个模型中嵌入数据,然后在剩下的两个模型中使用结果数据。因此,我只将嵌入层留在编码器当中。
还有一件事。环境模型和扮演者均用轨迹和潜在表示的串联向量作为输入。我们已经判定,为了形成随机潜在表示,变分自动编码器层已转移到环境模型之中。在此,我们将运作向量的组合,并将已经获得的结果传递到扮演者的输入端。
现在我们将上述思路转移到代码中。我们创建模型的定义。如常,它是在 CreateDescriptions 方法中形成的。在参数中,该方法接收指向定义模型的三个对象指针。
bool CreateDescriptions(CArrayObj *agent, CArrayObj *latent, CArrayObj *world) { //--- CLayerDescription *descr;
架构的定义应当从编码器的模型开始,其输入提供的是未处理的序列数据。
//--- latent.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = (BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions); descr.activation = None; descr.optimization = ADAM; if(!latent.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(!latent.Add(descr)) { delete descr; return false; }
将常规化之后的数据传递到嵌入层。记住这一层。然后,我们把从中获取的数据放到环境模型之中。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronEmbeddingOCL; prev_count = descr.count = HistoryBars; { int temp[] = {BarDescr * NBarInPattern, AccountDescr, TimeDescription, NActions}; ArrayCopy(descr.windows, temp); } int prev_wout = descr.window_out = EmbeddingSize; if(!latent.Add(descr)) { delete descr; return false; }
接下来,我们通过 Transformer 模块据生成的轨迹运作。我使用了一个稀疏关注度模块,有 8 个自关注者,每个模块有 4 层。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHSparseAttentionOCL; prev_count = descr.count = prev_count * 4; descr.window = prev_wout; descr.step = 8; descr.window_out = 32; descr.layers = 4; descr.probability = Sparse; descr.optimization = ADAM; if(!latent.Add(descr)) { delete descr; return false; }
在关注度模块之后,我们将略微降低卷积层的维数,并把数据从全连接层传递到决策模块。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = prev_wout; descr.window_out = 4; descr.optimization = ADAM; descr.activation = LReLU; if(!latent.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!latent.Add(descr)) { delete descr; return false; } //--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = TANH; descr.optimization = ADAM; if(!latent.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(!latent.Add(descr)) { delete descr; return false; } //--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 2 * EmbeddingSize; descr.activation = None; descr.optimization = ADAM; if(!latent.Add(descr)) { delete descr; return false; }
在编码器模型的输出端,我们使用一个没有激活函数的全连接神经层,其规模比一个轨迹元素的嵌入规模大两倍。这是潜在表示分布的均值和方差,允许我们在下一步中从给定分布中对潜在表示进行采样。
接下来,我们转入定义环境模型。它的源数据层等于编码器模型的结果层,后随变分自动编码器层,这令我们能够立即对潜在表示进行采样。
//--- World if(!world) { world = new CArrayObj(); if(!world) return false; } //--- world.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = 2 * EmbeddingSize; descr.activation = None; descr.optimization = ADAM; if(!world.Add(descr)) { delete descr; return false; } //--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; prev_count = descr.count = prev_count / 2; descr.activation = None; descr.optimization = ADAM; if(!world.Add(descr)) { delete descr; return false; }
接下来,我们必须添加轨迹嵌入张量。为此,我们将使用一个串联层。在该层的输出端,我们接收处理后初始数据,用之环境模型和扮演者。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.step = 4 * EmbeddingSize * HistoryBars; prev_count = descr.count = descr.step + prev_count; descr.activation = LReLU; descr.optimization = ADAM; if(!world.Add(descr)) { delete descr; return false; }
我们把数据传递给履行职责的自关注模块。与编码器一样,我们使用 8 头和 4 层。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHSparseAttentionOCL; prev_count = descr.count = prev_count / EmbeddingSize; descr.window = EmbeddingSize; descr.step = 8; descr.window_out = 32; descr.layers = 4; descr.probability = Sparse; descr.optimization = ADAM; if(!world.Add(descr)) { delete descr; return false; }
使用卷积层降低数据维度。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = prev_wout; descr.window_out = 4; descr.optimization = ADAM; descr.activation = LReLU; if(!world.Add(descr)) { delete descr; return false; }
使用决策模块的完全连接感知器处理接收到的数据。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!world.Add(descr)) { delete descr; return false; } //--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = TANH; descr.optimization = ADAM; if(!world.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(!world.Add(descr)) { delete descr; return false; } //--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = NRewards; descr.activation = None; descr.optimization = ADAM; if(!world.Add(descr)) { delete descr; return false; }
在模型的输出中,我们得到一个分解的奖励向量。
在本模块的最后,我们将查看扮演者模型的结构。如上所述,模型从环境模型的隐藏状态接收其初始数据。相应地,源数据层应具有足够的大小。
//--- 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 = EmbeddingSize * (4 * HistoryBars + 1); descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; }
获得的数据是模型的结果,不需要额外的处理。因此,我们立即使用稀疏关注度模块。模块参数与上述模型中所用的参数类似。因此,所有三种模型均用相同的转换器架构。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHSparseAttentionOCL; prev_count = descr.count = prev_count / EmbeddingSize; descr.window = EmbeddingSize; descr.step = 8; descr.window_out = 32; descr.layers = 4; descr.probability = Sparse; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; }
与环境模型类似,我们降低了维度,并在完全连接决策感知器中处理数据。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = prev_count; descr.window = EmbeddingSize; descr.step = EmbeddingSize; descr.window_out = 4; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 3 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 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = TANH; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 5 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 6 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; }
在模型的输出中,将形成智能体动作向量。
我们还需要注意,为了实现该方法,我们需要以潜在表示分布的形式向经验回放缓冲区添加一个附加实体,该实体是在编码器的输出处形成的。为此,我们将在定义环境状态的结构中创建一个额外的数组。
struct SState { ....... ....... float latent[2 * EmbeddingSize]; ....... ....... }
新数组的大小等于两个嵌入,因为它包括分布的平均值和方差。
除了声明该数组之外,我们还需要在所有结构方法中添加其维护操作:
- 以初始值进行初始化
SState::SState(void) { ....... ....... ArrayInitialize(latent, 0); }
- 清理结构
void Clear(void) { ....... ....... ArrayInitialize(latent, 0); }
- 复制结构
void operator=(const SState &obj) { ....... ....... ArrayCopy(latent, obj.latent); }
- 保存结构
bool SState::Save(int file_handle) { ....... ....... //--- total = ArraySize(latent); if(FileWriteInteger(file_handle, total) < sizeof(int)) return false; for(int i = 0; i < total; i++) if(FileWriteFloat(file_handle, latent[i]) < sizeof(float)) return false; //--- return true; }
- 从文件加载结构
bool SState::Load(int file_handle) { ....... ....... //--- total = FileReadInteger(file_handle); if(total != ArraySize(latent)) return false; //--- for(int i = 0; i < total; i++) { if(FileIsEnding(file_handle)) return false; latent[i] = FileReadFloat(file_handle); } //--- return true; }
我们熟悉了训练模型的架构,并更新了数据结构。下一步是为它们的训练收集数据。此函数在 “...\SPLT\Research.mq5” EA 中执行。SPLT-转换器方法提供了候选轨迹的生成(或我们实现中的候选动作)。此类候选者的数量是模型的超参数之一,我们将其包含在 EA 外部参数之中。
input int Agents = 5;
您可能还记得,之前我们使用智能体外部参数作为辅助参数,在策略测试器优化模式下指示环境研究智能体的并行数量。现在我们将重命名 EA 服务参数。
input int OptimizationAgents = 1;
今后,我们将不会详解收集训练样本的所有 EA 方法。它们的算法在本系列中已经讲述过多次了。本文中用到的所有程序的完整代码可在附件中找到。我们只研究与环境直接交互的 OnTick 方法,它包含所实现算法的关键功能。
在方法伊始,我们如常检查新柱线开盘事件的发生,并在必要时更新价格走势的历史数据和所分析指标的读数。
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- if(!IsNewBar()) return; //--- int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), NBarInPattern, Rates); if(!ArraySetAsSeries(Rates, true)) return; //--- RSI.Refresh(); CCI.Refresh(); ATR.Refresh(); MACD.Refresh(); Symb.Refresh(); Symb.RefreshRates();
之后,我们为模型创建源数据缓冲区。首先,我们输入有关价格走势的历史数据和所分析指标的数值。
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- if(!IsNewBar()) return; //--- int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), NBarInPattern, Rates); if(!ArraySetAsSeries(Rates, true)) return; //--- RSI.Refresh(); CCI.Refresh(); ATR.Refresh(); MACD.Refresh(); Symb.Refresh(); Symb.RefreshRates(); //--- History data float atr = 0; 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 ResetLastError(); if(!Latent.feedForward(GetPointer(bState), 1, false)) { PrintFormat("Error of Latent model feed forward: %d",GetLastError()); return; }
成功创建潜在表示后,我们转到解码器。
我要提醒您,在这个阶段,我们必须生成候选动作。我们将把它们归入一个循环。其迭代次数将等于所需候选者的数量,并将在 EA 外部参数中指示。
为了保存有关生成的候选动作的信息,我们将创建 actions 和 values 矩阵。在第一个中,我们将记录动作向量。第二个是包含正在应用政策的预期奖励。
如上所述,在编码器模型中,我们只生成有关潜在表示分布的数据。在环境模型中对潜在表示向量进行采样。因此,在循环的主体中,我们首先对环境模型进行前向验算。然后我们调用智能体的前向验算方法,其使用环境模型的隐藏状态作为输入。
模型的直接验算结果被保存到先前准备好的矩阵当中。
matrix<float> actions = matrix<float>::Zeros(Agents, NActions); matrix<float> values = matrix<float>::Zeros(Agents, NRewards); for(ulong i = 0; i < (ulong)Agents; i++) { if(!World.feedForward(GetPointer(Latent), -1, GetPointer(Latent), LatentLayer) || !Agent.feedForward(GetPointer(World), 2,(CBufferFloat *)NULL)) return; vector<float> result; Agent.getResults(result); actions.Row(result, i); World.getResults(result); values.Row(result, i); }
采用的随机政策基于以下假设:在学习分布之内,所有事件中每一个发生的概率相等。因此,每个采样的候选动作在环境中获得预期奖励的概率相等。我们的目标是获得最大的盈利能力。这意味着在概率相等的条件下,我们选择具有最大预期回报的动作。
如您所知,我们的矩阵是行相关的。我们正在 values 矩阵中查找具有最大预期奖励的行,并从 actions 矩阵的相应行中选择一个动作。
vector<float> temp = values.Sum(1); temp = actions.Row(temp.ArgMax());
所选动作在环境中行事。
//--- 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(temp[0] >= temp[3]) { temp[0] -= temp[3]; temp[3] = 0; } else { temp[3] -= temp[0]; temp[0] = 0; } float delta = MathAbs(AgentResult - temp).Sum(); AgentResult = temp; //--- buy control if(temp[0] < min_lot || (temp[1] * MaxTP * Symb.Point()) <= stops || (temp[2] * MaxSL * Symb.Point()) <= stops) { if(buy_value > 0) CloseByDirection(POSITION_TYPE_BUY); } else { double buy_lot = min_lot + MathRound((double)(temp[0] - min_lot) / step_lot) * step_lot; double buy_tp = Symb.NormalizePrice(Symb.Ask() + temp[1] * MaxTP * Symb.Point()); double buy_sl = Symb.NormalizePrice(Symb.Ask() - temp[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(temp[3] < min_lot || (temp[4] * MaxTP * Symb.Point()) <= stops || (temp[5] * MaxSL * Symb.Point()) <= stops) { if(sell_value > 0) CloseByDirection(POSITION_TYPE_SELL); } else { double sell_lot = min_lot + MathRound((double)(temp[3] - min_lot) / step_lot) * step_lot;; double sell_tp = Symb.NormalizePrice(Symb.Bid() - temp[4] * MaxTP * Symb.Point()); double sell_sl = Symb.NormalizePrice(Symb.Bid() + temp[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]; Latent.getResults(sState.latent); if(!Base.Add(sState)) ExpertRemove(); }
我们有关 EA 与环境交互并收集训练样本数据的讲述到此结束。您可以在附件中找到其完整代码。在那里,您还可以找到本文中用到的所有程序的完整代码。我们将转入离线模型训练 EA “...\SPLT\Study.mq5”。
在 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(!Agent.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) || !World.Load(FileName + "Wld.nnw", temp, temp, temp, dtStudied, true) || !Latent.Load(FileName + "Lat.nnw", temp, temp, temp, dtStudied, true)) { CArrayObj *agent = new CArrayObj(); CArrayObj *latent = new CArrayObj(); CArrayObj *world = new CArrayObj(); if(!CreateDescriptions(agent, latent, world)) { delete agent; delete latent; delete world; return INIT_FAILED; } if(!Agent.Create(agent) || !World.Create(world) || !Latent.Create(latent)) { delete agent; delete latent; delete world; return INIT_FAILED; } delete agent; delete latent; delete world; //--- }
您也许已经注意到,用来收集训练样本的 EA 算法,所用数据通常在训练模型之间传输。在训练期间,传输的数据量会增加,因为数据流是在两个方向上运作的:正向和反向验算。为了消除 OpenCL 关联环境和主存储器之间不必要的数据复制操作,我们将所有模型传输到单个 OpenCL 关联环境之中。
COpenCL *opcl = Agent.GetOpenCL(); Latent.SetOpenCL(opcl); World.SetOpenCL(opcl);
接下来,我们检查训练模型架构的一致性。
Agent.getResults(Result); if(Result.Total() != NActions) { PrintFormat("The scope of the Agent does not match the actions count (%d <> %d)", 6, Result.Total()); return INIT_FAILED; } //--- Latent.GetLayerOutput(0, Result); if(Result.Total() != (BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions)) { PrintFormat("Input size of Latent model doesn't match state description (%d <> %d)", Result.Total(), (BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions)); return INIT_FAILED; } Latent.Clear();
所有控制成功完成后,我们生成一个模型训练开始事件,并完成 EA 初始化方法的操作。
//--- if(!EventChartCustom(ChartID(), 1, 0, 0, "Init")) { PrintFormat("Error of create study event: %d", GetLastError()); return INIT_FAILED; } //--- return(INIT_SUCCEEDED); }
训练模型的实际过程安排在 Train 方法当中。在方法的主体中,我们判定经验回放缓冲区中的轨迹数量,并在局部变量中记录训练的开始时间。它将作为我们定期通知用户模型训练进度的指南。
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { int total_tr = ArraySize(Buffer); uint ticks = GetTickCount();
我要提醒您,我们的模型使用 GPT 架构,其对源数据序列很敏感。如前,在类似的情况下,我们将使用嵌套循环系统来训练模型。在外部循环中,我们从经验回放缓冲区和环境的初始状态中对轨迹进行采样。
bool StopFlag = false; for(int iter = 0; (iter < Iterations && !IsStopped() && !StopFlag); iter ++) { int tr = (int)((MathRand() / 32767.0) * (total_tr - 1)); int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * MathMax(Buffer[tr].Total - 2 * HistoryBars,MathMin(Buffer[tr].Total,20))); if(i < 0) { iter--; continue; }
然后,我们初始化模型缓冲区,并创建一个嵌套循环,在该循环中,我们按顺序提供单独的历史数据片段作为模型输入。
Actions = vector<float>::Zeros(NActions); Latent.Clear(); for(int state = i; state < MathMin(Buffer[tr].Total - 2,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
State.AddArray(Actions);
我想再次强调严格遵守一致性。缓冲区数据未命名。模型根据数据在缓冲区中的位置估算数据。序列的变化会被模型感知为完全不同的状态。决策结果将是完全不同和不可预测的。因此,为了不令模型混淆,并始终获得足够的解,我们需要在训练和操作模型的所有阶段严格遵守数据序列。
原始数据缓冲区收集之后,我们首先执行编码器和环境模型的前向验算。
//--- Latent and Wordl if(!Latent.feedForward(GetPointer(State)) || !World.feedForward(GetPointer(Latent), -1, GetPointer(Latent), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
注意,我们不会在训练期间生成候选动作。甚至,环境模型和扮演者政策的训练也是分开运作的。这是由于模型训练的特殊性。
环境模型经过训练,可基于先前的轨迹估算智能体的政策,并预测未来收到的奖励情况,同时参考环境的当前状态和所用的政策。同时,我们调整了潜在表示的分布。为此,在前向验算成功之后,我们对环境模型和编码器执行后向验算,旨在把环境模型预测值和来自经验回放缓冲区的实际奖励之间的误差最小化。
Actions.Assign(Buffer[tr].States[state].rewards); vector<float> result; World.getResults(result); Result.AssignArray(CAGrad(Actions - result) + result); if(!World.backProp(Result,GetPointer(Latent),LatentLayer) || !Latent.backPropGradient((CBufferFloat *)NULL,(CBufferFloat *)NULL,LatentLayer) || !Latent.backPropGradient((CBufferFloat *)NULL,(CBufferFloat *)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
请注意,在环境模型后向验算之后,我们首先执行部分编码器后向验算,优化嵌入参数,从而满足环境模型的要求。然后,我们对编码器进行完全逆向验算,在此期间优化潜在表示的分布。
我们优化了扮演者政策,匹配潜在状态和已执行动作。因此,我们从经验回放缓冲区中提取潜在表示分布,并将其输入到环境模型的输入端,以便对潜在表示重新采样。接下来,我们运作环境模型和扮演者的直接验算。
//--- Policy Feed Forward Result.AssignArray(Buffer[tr].States[state+1].latent); Latent.GetLayerOutput(LatentLayer,Result2); if(Result2.GetIndex()>=0) Result2.BufferWrite(); if(!World.feedForward(Result, 1, false, Result2) || !Agent.feedForward(GetPointer(World),2,(CBufferFloat *)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
然后,我们执行扮演者的逆向验算,以便最大程度地减少预测动作与从经验回放缓冲区实际执行之间的误差。
//--- Policy study Actions.Assign(Buffer[tr].States[state].action); Agent.getResults(result); Result.AssignArray(CAGrad(Actions - result) + result); if(!Agent.backProp(Result,NULL,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()); str += StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "World", iter * 100.0 / (double)(Iterations), World.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } }
一旦循环系统的所有迭代都完成,我们就会清除注释字段。模型训练结果显示在日志之中。启动 EA 终止。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Agent", Agent.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "World", World.getRecentAverageError()); ExpertRemove(); //--- }
针对 SPLT-转换器方法的解释,我们进行的模型训练 EA 的研究到此结。附件中提供了 EA 和本文中用到的所有程序的完整代码。在那里,您还可以找到 “...\SPLT\Test.mq5” 模型测试 EA 的代码。我们不会在本文中赘述其方法。EA 结构重复了之前文章中讨论过的类似 EA。OnTick 函数中所呈现的算法实现功能,完全重复了训练样本的数据收集 EA 中类似方法的实现。我建议您在附件中熟悉此 EA。
我们正在转入下一阶段 — 在 MetaTrader 5 策略测试器中依据历史数据测试模型。
3. 测试
这些模型依据 EURUSD H1 前 7 个月的历史数据进行训练。所有指标采用默认参数,无需任何额外优化。
首先,我们在策略测试器的慢速优化模式下启动训练样本收集 EA。这令我们能够从多个测试智能体并行收集数据。因此,我们增加了经验回放缓冲区中的轨迹数量,同时最大限度地减少了数据收集所花费的时间。
所研究的算法假定模型仅在离线训练。因此,为了测试其性能,我建议最大化经验回放缓冲区,并用各种轨迹填充它。但值得注意的是,生成候选动作是一个相当昂贵的过程。随着候选者数量的增加,数据收集的成本也在增加。
收集数据之后,我训练了模型,而没有额外收集轨迹,就像之前所做。如常,训练模型是一个漫长的过程。由于我未规划额外收集轨迹,所以我增加了轨迹的数量,并留给我的电脑进行长期训练。
接下来,在 2023 年 8 月的历史数据上测试训练后的模型,其未包含在训练集当中。
基于测试结果,该模型显示出微薄的盈利,和相当准确的交易。我要提醒您,SPLT-转换器方法是为自动驾驶而开发的,可以最大限度地降低风险。
在测试图形上,我们看到余额几乎在整个月中都有增长的趋势。仅在本月的最后一周观察到一系列无盈利的交易。不过,先前累积的盈利足以弥补亏损。总体而言,月底录得小幅盈利。
在整个测试期间,该模型仅以最小的交易量开立了 16 笔仓位。盈利交易的份额仅为 37.5%。然而,平均赚钱交易几乎比平均亏钱大 70%。因此,根据测试结果,盈利因子为 1.02。
结束语
在本文中,我们讲述了 SPLT-转换器,这是一种创新方法,旨在解决与乐观智能体行为相关的离线强化学习问题。借助代表政策和世界的两个独立模型,构建了可靠有效的智能体政策。
SPLT-转换器的核心组件,包括候选轨迹生成算法,令我们能够模拟各种场景,并在考虑各种可能的未来结果的情况下做出决策。这令所提出的方法在各种随机环境中具有高度的适应性和安全性。方法作者提供了自动驾驶领域的实验结果,确认 SPLT-转换器与现有方法相比具有优越的性能。
在本文的实践部分,我们针对所讨论的方法创建了自己的、略微简化的解释。我们训练并测试了生成的模型。测试结果表明,该模型能够根据局势表现出谨慎和乐观的行为。这令其成为关键任务系统的理想选择。
总体而言,该方法值得进一步发展。在我看来,对模型进行更彻底的训练可以产生更好的结果。
我再次提醒您,本系列文章中讲述的所有程序都只是为了演示和测试相关算法而创建的。它们不适合在真实账户上进行交易。在真实交易中运用特定模型之前,建议对其进行彻底的训练和测试。
链接
- 解决强化学习序列建模中的乐观偏差
- 神经网络变得简单(第 58 部分):决策转换器(DT)
- 神经网络变得简单(第 59 部分):控制二分法(DoC)
- 神经网络变得简单(第 60 部分):在线决策转换器(ODT)
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
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/13639


