
神经网络变得简单(第 59 部分):控制二分法(DoC)
概述
金融市场行业是一个复杂而多诱因的环境。每一个事件和动作都植根于经济的基本面过程。某些事件的诱因可以在新闻、地缘政治事件、各种技术方面、和许多其它因素中发现。很多时候,我们会在它们发生后观察到这种依赖关系。在分析市场状况时,我们只观察到这些因素的一小部分。通常这令金融市场成为一个相当难以分析的环境。但我们仍然强调了一些可以检测主要趋势的最重要工具。其它因素则归因于环境随机性。
在如此复杂的环境中,强化学习是制定金融市场策略的强力工具。不过,现有方法(如决策转换器)在高度随机环境中的自适应性也许有所不足。这是我们在上一篇文章的实践部分观察到的。
您也许还记得,与传统方法不同,决策转换器针对操作序列进行建模,是在所需奖励的自回归模型的上下文当中。在训练模型时,构建状态序列、动作、期望奖励、及从环境中获得的实际结果之间的关系。然而,大量的随机因素会导致已训练策略与预期的未来结果之间存在差异。
许多强化学习方法都面临着类似的问题。2022 年 10 月,谷歌团队提出了控制二分法作为解决此问题的选项之一。
1. DoC 方法基础
控制二分法是斯多葛学派(Stoicism)的逻辑基础。它意味着一种思辨,即我们周围的一切存在都可以分为两部分。第一个受制于我们,完全在我们的控制之下。我们无法全面控制第二个,无论我们采取什么动作,事件都会发生。
我们正在操控第一个领域,同时认为第二个是理所当然的。
“控制二分法”方法的作者在他们的算法中实现了类似的假设。DoC 允许我们区分策略控制下的内容(动作政策),以及超出其控制的内容(环境随机性)。
但在转到研究该方法之前,我建议记住我们如何在 DT 中表示轨迹。
这里的 R1(“在途回报”)表示我们的愿望,与初始 S0 状态无关。我们的已训练模型选择在训练集上已产生所需结果的动作。但从当前状态获得所需奖励的概率也许太小了,以至于智能体的动作距最优状态甚远。
现在我们睁大眼睛看看世界。在这种境况下,“在途回报”是对智能体选择行为策略的指导。您不认为它类似于 GCRL 中的目标设定,或层次模型的技能吗?大概,DoC 方法的作者曾探访过类似的想法,他们建议使用某种隐藏状态 z(τ)。但是,如您所知,替换概念并不能改变本质。引入一个训练模型来表示 z(τ) 潜在状态。
方法作者的主要观察结果是,z不应包含与环境随机性相关的信息。它不应包含有关未来 Rt 和 St+1 的信息,这在以前的历史记录中是未知的。相应地,在目标中增加了 z 与未来每个 Rt 和 St+1 对之间的互动信息条件限制。我们将使用对比训练方法来满足这种互动信息约束。
接下来,我们引入由 f 能量函数参数化的条件分布 ω(rt|τ0:t-1,st,at)。
通过拉格朗日(Lagrange)比率将其结合起来,我们可以经由最小化 DoC 最终目标来训练 π 和 z(τ):
当应用于决策变换器方法时,经过 DoC 训练的策略需要一个合适的 z 条件。为了选择与高期望奖励相关的所需 z,该方法的作者建议:
- 选择潜在 z 值较大者;
- 为这些 z 值的每一个估算预期奖励;
- 选择具有最高预期奖励的 z,并将其传递给政策。
为了在操作阶段确保这样的过程,在方法配方中添加了两个额外的部件。首先,从先验分布 p(z|s0) 中选择 z 值较大者。第二个是 V(z) 值函数,其用于为潜在 z 值进行排位。这些部件是依据最小化以下目标来训练的:
注意,在训练 p 时,使用 q(z|τ) 的停止梯度,以避免相对于先验分布的 q 正则化。
《控制二分法:将您能控制的东西与你不能控制的东西分开》一文中列举了相当多的例子,验证了所提出的方法在各种随机环境中的明显优势。
这是一个相当有趣的观点,我提议在实践中测试采用这种方法来为我们解决问题的可能性。
2. 利用 MQL5 实现
在本文的实践部分,我们将研究利用 MQL5 实现控制二分法的算法。我想即刻提请您注意一个事实,即所讨论的实现是对拟议方法的个人解释。在某些时刻,它会与原始解决方案相去甚远。
首先,该实现是上一篇文章中的程序逻辑延续。我们在先前创建的 DT 代码中实现所提出的机制,以便优化模型性能,并提高其效率。
甚至,我们将尝试在保持基本思想的同时稍微简化 DoC 算法。
如上所述,该方法的作者引入了一些潜在状态,替代了在途回报。在操作过程中,从先验分布 p(z|s0) 中采样了此类潜在状态的某个数据包。随后使用 V(z) 值函数估算这些潜在状态。在实践中,这意味着我们从训练集中提取最相似的状态,并选择具有最高期望奖励的潜在表示。与控制二分法的思路一致,我们不仅考虑奖励的绝对值,还有获取它的概率。
当然了,我们不会每次都遍历整个训练集。取而代之,我们将使用预训练模型来近似训练集中的相应特征。但无论如何,对大量潜在表示进行采样,然后对它们进行估计是一项相当劳动密集型的任务。我们能以某种方式简化它吗?
我们来看看这些实体的本质。决策转换器关联环境中的 z 潜在表示是预期奖励。因此,值函数 V(z) 也许是 z 潜在状态本身的反映。我们可以考虑将值函数作为一类排除,并直接将潜在状态相互比较,但我们不会采取这样的步骤。
进一步思考这一点后,先验分布 p(z|s0) 可以表示为在特定环境状态下使用特定潜在表示的概率分布。我们回想一下完全参数化的分位数函数 (FQF)。它允许您结合概率分布和定量分布。这就是我们将在潜在表示生成模型中使用的内容。
该方案允许我们将先验分布和成本函数结合起来。甚至,以这种方式,我们可以避免对一批潜在状态进行采样,然后估算它们。
我们针对由 f 能量函数参数化的 ω(rt|τ0:t-1,st,at) 条件分布做同样的事情。
注意,在这两种情况下,我们都会生成一个潜在表示。为了节省资源,我们将创建两个模型,并在两种情况下使用一个模型。在此我们应该记住,ω(rt|τ0:t-1,st,at) 依赖于轨迹。因此,在构造模型时,我们应该考虑到其自回归性质类似于 DT 扮演者模型。
CreateDescriptions 方法中讲述了这两种模型的架构。在方法参数中,我们将指向两个动态数组的指针传递给描述的模型架构。模型架构的差异不会很大。但它们仍然存在。这就是为什么我们创建两个独立的架构,而不是一个共用的架构。首先,我们创建扮演者模型的架构。与上一篇文章一样,源数据层仅包含环境状态的可变组件(一根柱线数据)。
bool CreateDescriptions(CArrayObj *agent, CArrayObj *rtg) { //--- 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 = (NRewards + BarDescr*NBarInPattern + AccountDescr + TimeDescription + NActions); 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; }
常规化的数据经由嵌入层传递,并添加到堆栈中。
//--- 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; }
堆栈包含整个分析期间的数据嵌入。我们经由一个多头稀疏关注度模块来传递它们。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHSparseAttentionOCL; prev_count = descr.count = prev_count*5; descr.window = prev_wout; descr.step = 4; descr.window_out = 32; descr.layers = 8; descr.probability = Sparse; descr.optimization = ADAM; if(!agent.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(!rtg.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(!agent.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(!agent.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(!agent.Add(descr)) { delete descr; return false; }
在模型的输出端,我们使用 VAE 潜在层令智能体的政策拥有随机性。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 2 * NActions; descr.activation = SIGMOID; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; }
以下是对潜在表示模型的架构的讲述。如上所述,该模型的架构与之前的模型非常相似。但它分析的数据量较小。从理论部分的讲述中可以看出,条件分布函数 ω(rt|τ0:t-1,st,at) 基于当前状态、智能体动作、和先前轨迹生成潜在表示。随后,我们将生成的潜在状态提交到智能体的输入端。我们将据潜在状态的大小,往第二个模型的输入端提供更少的数据。
//--- RTG if(!rtg) { rtg = new CArrayObj(); if(!rtg) return false; } //--- rtg.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(!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; }
接着来到数据嵌入。在此,我们还观察到源数据结构的变化。
//--- 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); } prev_wout = descr.window_out = EmbeddingSize; if(!rtg.Add(descr)) { delete descr; return false; }
下面我们重温稀疏关注度模块的结构。注意序列中所分析元素数量的减少。虽然智能体在每根柱线上分析了 5 个实例,但此模型中只有 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 = 4; descr.window_out = 32; descr.layers = 8; descr.probability = Sparse; descr.optimization = ADAM; if(!rtg.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(!rtg.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(!rtg.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(!rtg.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(!rtg.Add(descr)) { delete descr; return false; }
现在,在决策模块的输出端,我们使用一个完全参数化的分位数函数层,如上所述。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFQF; descr.count = NRewards; descr.window_out = 32; descr.optimization = ADAM; if(!rtg.Add(descr)) { delete descr; return false; } //--- return true; }
在讲述了模型架构之后,我们把工作转到与环境交互的 EA,以及为训练 “\DoC\Research.mq5” 模型收集主要数据。即使在收集训练数据时,使用控制二分法的特点也很引人注目。在以前类似的 EA 中,我们只用到智能体模型,而其它模型仅在训练阶段才被连接,现在我们从收集原始数据到测试训练模型的所有阶段都会用到这两个模型。毕竟,第二个模型生成的潜在状态是我们智能体初始数据的一部分。
我们不会在这里详细研究 EA 的整个代码。其大部分方法的运作比之之前的文章没有变化。我们只讨论在 OnTick 跳价处理方法里安排的主要数据收集过程。
在方法伊始,我们如常检查新柱线开盘事件的发生,并在必要时更新价格走势的历史数据和所分析指标的读数。
我要提醒您,我们 EA 的所有操作仅在新柱线开盘时执行。我们模型的算法无法控制每次跳价的变化。所有已训练模型操作都依据 H1 时间帧的历史数据。然而,时间帧的选择是一个纯粹的主观决定,不受模型架构的限制。我们只需要遵守在相同的时间帧、和相同的金融产品上进行模型训练和操作的需求。使用据另一个时间帧和/或其它金融产品上训练的模型之前,应针对目标时间帧和金融工具额外进行训练。
//+------------------------------------------------------------------+ //| 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);
接下来,我们应当以“在途回报”的形式为智能体添加目标设定。但在 DoC 算法中,我们仍然需要生成潜在状态。然而,收集到的数据足以让潜在状态生成模型工作,我们运作一次前向验算。
//--- Return to go if(!RTG.feedForward(GetPointer(bState))) return;
执行模型的前向验算成功后,我们加载生成的潜在表示,并将其添加到源数据缓冲区之中。
RTG.getResults(Result); bState.AddArray(Result);
在此,我们已为智能体模型生成了一个完整的输入数据包,我们可以调用前向验算方法,据之前学习的政策生成最优动作。如常,不要忘记控制操作的执行。
if(!Agent.feedForward(GetPointer(bState), 1, false, (CBufferFloat*)NULL)) return;
在此,模型在当前柱线上的工作结束,开始与环境交互。首先,我们将对智能体的工作结果进行预处理和解密。在之前的文章中,我们只定义了存在一个方向的持仓。因此,我们要做的第一件事是从智能体的结果中判定交易量增量。我们将保存具有最大交易量的方向的差值。在另一个方向上,我们重置操作交易量。
//--- PrevBalance = sState.account[0]; PrevEquity = sState.account[1]; //--- vector<float> temp; Agent.getResults(temp); //--- 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; } 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]; if(!Base.Add(sState)) ExpertRemove(); }
我们针对与环境交互和收集训练样本数据的 EA 的工作到此结束。您可以在附件中找到 EA 的完整代码,,及其所有函数。
我们转到模型训练 EA “\DoC\Study.mq5”。在 EA 的 OnInit 初始化方法中,我们首先尝试加载训练集。由于我们离线训练模型,因此此训练集是我们唯一的数据来源。故此,如果在加载训练数据时出现任何错误,则 EA 的进一步工作就没有意义,我们返回程序初始化错误的结果。首先,往日志里发送一条包含错误 ID 的消息。
//+------------------------------------------------------------------+ //| 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) || !RTG.Load(FileName + "RTG.nnw", dtStudied, true)) { Print("Init new models"); CArrayObj *agent = new CArrayObj(); CArrayObj *rtg = new CArrayObj(); if(!CreateDescriptions(agent,rtg)) { delete agent; delete rtg; return INIT_FAILED; } if(!Agent.Create(agent) || !RTG.Create(rtg)) { delete agent; delete rtg; return INIT_FAILED; } delete agent; delete rtg; }
请注意,如果读取模型其一时出错,则将创建并初始化全部两个模型。这样做是为了保持模型兼容性。
接着来到检查模型架构的模块。此处,我们检查原始层的大小,与两个模型结果的一致性。首先,检查智能体架构。
//--- Agent.getResults(Result); if(Result.Total() != NActions) { PrintFormat("The scope of the agent does not match the actions count (%d <> %d)", NActions, Result.Total()); return INIT_FAILED; } //--- Agent.GetLayerOutput(0, Result); if(Result.Total() != (NRewards + BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions)) { PrintFormat("Input size of Agent doesn't match state description (%d <> %d)", Result.Total(), (NRewards + BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions)); return INIT_FAILED; }
然后,我们重复潜在表示模型的步骤。
RTG.getResults(Result); if(Result.Total() != NRewards) { PrintFormat("The scope of the RTG does not match the rewards count (%d <> %d)", NRewards, Result.Total()); return INIT_FAILED; } //--- RTG.GetLayerOutput(0, Result); if(Result.Total() != (BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions)) { PrintFormat("Input size of RTG doesn't match state description (%d <> %d)", Result.Total(), (BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions)); return INIT_FAILED; } RTG.SetUpdateTarget(1000000);
此处还值得注意的是,在训练潜在表示模型的过程中,我们未计划由 FQF 架构提供的目标模型。因此,我们立即将目标模型的更新周期设置为相当大。这种方法令我们能够在训练模型的过程中剔除不必要的操作。
以上所有操作成功完成后,我们要做的就是生成训练过程的启动事件,并完成 EA 初始化方法。
if(!EventChartCustom(ChartID(), 1, 0, 0, "Init")) { PrintFormat("Error of create study event: %d", GetLastError()); return INIT_FAILED; } //--- return(INIT_SUCCEEDED); }
在 EA 的 OnDeinit 逆初始化方法中,我们应当添加保存潜在表示模型。不像奥林匹克的口号“重要的不是胜利,而是参与”,我们只看重结果,而非训练过程。
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- Agent.Save(FileName + "Act.nnw", 0, 0, 0, TimeCurrent(), true); RTG.Save(FileName + "RTG.nnw", TimeCurrent(), true); delete Result; }
让我们转到训练模型 Train 方法。在方法的主体中,我们判定经验回放缓冲区中加载轨迹的数量,并将跳价计数器的当前状态保存到局部变量当中,以便控制模型训练过程的时间。
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { int total_tr = ArraySize(Buffer); uint ticks = GetTickCount();
继续深化,与上一篇文章一样,我们安排了一个循环系统。外循环计算模型训练批次的数量。在其主体中,我们从经验回放缓冲区中随机选择一条轨迹,并在该轨迹上选择一个状态作为训练的起点。我们立即清除两个模型的堆栈,并重置智能体最后动作向量。这些操作在训练自回归模型时是必不可少的,且必须在每次转换到训练模型轨迹的新区段之前执行。
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); Agent.Clear(); RTG.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);
创建帐户状态和时间戳的定义,它几乎完全重复了训练数据收集 EA 中的类似过程。
//--- 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); //--- Return to go if(!RTG.feedForward(GetPointer(State))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
执行潜在生成模型的前向验算方法成功后,我们可以立即更新其参数。我们将训练模型来预测未来的奖励。这种方式与 DT 算法一致,与 DoC 算法不矛盾。
Result.AssignArray(Buffer[tr].States[state+1].rewards); if(!RTG.backProp(Result)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
在这个阶段,我们放弃了使用 CAGrad 方法来调整结果向量中误差梯度的方向。这在于一个事实,即除了奖励的绝对值之外,我们还努力学习它们在 FQF 层深处的概率分布。调整目标值以优化误差梯度的方向,可能会令所需的分布失真。
潜在表示模型的参数优化之后,我们转到训练智能体政策模型。我们将移至下一个状态所获得的实际奖励添加到初始数据缓冲区之中。这正是我们在训练决策转换器智能体政策时所做的。甚至,在训练智能体的政策方面,我们完全重复了决策转换器算法。毕竟,我们必须训练智能体去比较来自独立状态的已完成操作和预期奖励,这与决策转换器算法正好一样。控制二分法算法的主要贡献是以潜在表示的形式创建正确的目标设定,其由第二个模型形成。
//--- Policy Feed Forward State.AddArray(Buffer[tr].States[state+1].rewards); if(!Agent.feedForward(GetPointer(State), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
下一步是更新智能体模型的参数,从而生成实际动作,而在智能体输入数据中指定的实际奖励的结果作为目标。
//--- Policy study Actions.Assign(Buffer[tr].States[state].action); vector<float> result; Agent.getResults(result); Result.AssignArray(CAGrad(Actions - result) + result); if(!Agent.backProp(Result, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
这次,我们已用 CAGrad 方法优化了误差梯度向量的方向,并提高了模型的收敛速度。
更新两个模型的参数成功后,我们所要做的就是通知用户训练进度,并转到下一次训练迭代。
//--- 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", "RTG", iter * 100.0 / (double)(Iterations), RTG.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__, "RTG", RTG.getRecentAverageError()); ExpertRemove(); //--- }
我们针对 “\DoC\Study.mq5” 模型训练 EA 的回顾到此结束。 在附件中找到文章中用到的所有程序的完整代码。在那里,您还可以找到测试训练模型的 “\DoC\Test.mq5” EA。它的代码几乎完全复制了与环境交互和收集训练数据 EA。因此,我们现在不详述其方法。我很乐意在与本文相对应的论坛线索中回答您所有可能的问题。
3. 测试
在完成创建 EA 的工作后,我们已实现了控制二分法算法的愿景,我们正在转入测试已完成工作的阶段。在该阶段,我们将收集训练数据,训练模型,并在训练样本区间之外验证它们的工作成果。使用新数据来测试模型,我们就能够令模型测试尽可能接近真实条件。毕竟,我们的目标是在可预见的未来获得一个能够在金融市场上产生实际利润的模型。
如常,模型依据 2023 年前 7 个月的历史数据进行训练。对于所有测试,我们选用一个波动性最大的金融产品 — EURUSD H1。自本系列文章开始以来,所有分析指标的参数都没有改变,均使用默认。
我们的模型训练过程是迭代的,由若干次连续迭代收集训练数据和训练模型组成。
我想再次强调,需要重复收集训练数据和训练模型的顺序操作。当然,我们可以先收集一个庞大的训练样本数据库,然后据其长时间训练模型。但我们的资源是有限的。我们实际上无法收集完全涵盖行动空间和互惠奖励的样本数据库。甚至,我们是在一个连续动作空间中工作。此外,我们还应该加上所研究环境的巨大随机性。这意味着在训练过程中,模型很有可能最终会进入一个未探索的空间。为了完善我们对环境的知识,我们将需要额外的交叉迭代。
另一个相当重要的一点是,在训练数据的初始收集过程中,每个智能体都采用随机政策。这令我们能够尽可能充分地探索环境。如您所知,强化学习的主要挑战之一是在探索和开发之间找到平衡。显然,我们在这里看到了 100% 的研究。在与环境重新交互并收集训练数据时,智能体选用已预先训练的政策。研究范围收窄到训练政策的随机性程度。
我们执行与环境交互的迭代次数越多,模型随机区域的收窄就越平滑。及时的反馈可以调整训练的方向。这增加了我们实现全局最大预期回报的机会。
在离线训练间隔较长的情况下,我们立即冒着尽可能降低模型动作随机性的风险,到达某个局部极值,而无能力调整模型的训练方向。
还应该注意的是,在我们的模型中,我们用到了稀疏关注度模块,其训练是一个双重复杂和漫长的过程。首先,有一个具有复杂结构的自关注模块。复杂的结构,依理,需要长期而仔细的训练。
第二点是稀疏关注度的使用。因此,与辍弃(Dropout)一样,并非所有神经元在每次训练迭代中都会得到充分利用。结果就是,在某些时刻,梯度没有到达神经元,它们从训练中辍弃。训练中神经元的损失是相当随机的。若要完全训练模型,需要额外的迭代次数。
同时,使用稀疏关注度模块可以减少每次训练迭代的时间,并令模型更加灵活。
但是,我们回到训练结果,并测试模型。为了测试已训练模型,我们用到了 2023 年 8 月的历史数据。EURUSD H1。八月是紧随训练区间之后的月份。如上所述,以这种方式,我们创造了条件,以便测试模型时尽可能接近模型的日常操作。基于模型测试的结果,我们仍然设法获得了一些利润。您也许还记得,在上一篇文章中,在类似条件下,使用决策转换器算法训练的模型并不能盈利。添加 DoC 方法令我们能够将几乎相同的模型提升到本质差别。
但是,尽管获得了利润,模型结果仍不完美。如果我们在测试训练模型时查看余额图,我们可以注意到以下趋势:
- 在本月的前十天,我们观察到余额急剧增加约 20%。
- 在第二个十天中,我们观察到所达成果区域的余额水平有所波动。无盈利区间之后是相当急剧的上涨。波动幅度达到余额的 10%。
- 在第三个十天中,出现了一连串无盈利交易。
结果就是,在整个训练期间,我们拥有大约 43% 的盈利仓位。在这种情况下,最大盈利交易大于最大亏损 2 倍以上。平均盈利交易比平均亏损高 1/3。结果就是,盈利因子固定为 1.01,而恢复因子为 0.03。
比较采用和不采用 DoC 原则测试模型的结果,可以注意到在这两种情况下,每月前十天的余额急剧增加。DoC 方式的运用令本月的后十天保持所取得的成果成为可能。在不运用 DoC 的情况下,一连串无盈利交易立即开始。
这导致了我的主观观点,即自回归方法可以令人们获得相当好的结果,尽管只是在很短的时间内。同时,DoC 的运用表明,可以通过对方法进行一些修改,来增加有益成效的时间。这意味着其有潜力和创造力的空间。
结束语
在本文中,我们结识了一种非常有趣、且具有巨大潜力的算法 — 控制二分法(DoC)。该算法是由谷歌团队引入的,作为在处理随机环境时提高模型效率的一种手段。DoC 的主要原则是将所有可观察的因素和结果划分为依赖和独立于智能体政策。因此,在训练模型时,我们不关注依赖于智能体动作的因素,而是构建旨在最大化结果的政策,同时考虑到环境的随机影响。
作为本文的一部分,我们将 DoC 原则添加到之前创建的决策转换器模型当中。结果就是,我们观察到模型在测试样本上的性能有所提高。所取得的结果还远非完美。但积极的偏移是显而易见的,我们可以注意到实现控制二分法原则的效率。
链接
- 决策转换器:通过序列建模进行强化学习
- 控制的二分法:将你能控制的东西和你不能控制的东西分开
- 神经网络变得简单(第 34 部分):完全参数化的分位数函数
- 神经网络变得简单(第 58 部分):决策转换器(DT)
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
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/13551


