神经网络变得简单(第 62 部分):在层次化模型中运用决策转换器
概述
在解决实际问题时,我们经常会遇到环境随机和动态变化的问题,这迫使我们寻找新的自适应算法。近几十年来,人们致力于开发强化学习(RL)技术,其可训练智能体适应各种环境和任务。然而,RL在现实世界中的应用面临着诸多挑战,包括可变和随机环境中的离线学习,以及在状态和动作的高维空间中难于规划和控制。
往往在解决复杂问题时,最有效的方式是将一个问题切分为其组成子任务。在研究层次化方法时,我们谈论过这种方式的优点。这种集成方式允许创建更具适应性的模型。
以前,我们曾研究过运用马尔可夫(Markov)过程经典方法来解决问题的层次化模型。然而,运用层次化方式的优点也适用于序列分析问题。其中一种算法是控制转换器,在文章《控制转换器:在未知环境中机器人导航 — 通过 PRM 引导的返回条件序列建模》中讲述。该方法的作者将其定位为一种新的架构,旨在解决基于强化学习的复杂控制和导航问题。这种方法融合了当今强化学习、规划和机器学习的多种方法,令我们能够在各种环境中创建自适应控制策略。
控制转换器为解决机器人、自动驾驶等领域的复杂控制问题开辟了新的视角。我提议展望一下运用这种方法解决我们交易问题的前景。
1. 控制转换器算法
控制转换器算法是一种相当复杂的方法,包括若干个独立的模块,这实际上是层次化模型的特征。还应该说,该算法是为了导航和控制机器人行为而开发的。因此,故算法描述会体现在上下文之中。
为了解决覆盖长期规划界限的控制问题,该方法的作者提议将其分解为更小的子任务,其形式是有限距离的某些片段。该方法的作者使用概率路线图来构建 G,其中顶点是目标点,边表示使用局部调度器在连接点之间移动的能力。该图形是基于环境中 n 个随机目标点样本构建的,这些目标点随后以不超过 d(超参数)的距离与相邻点连接,并在图中形成一条边,前提是目标点之间有一条路径。
因此,在生成的 G 图形中,我们可以从任意 X0 起点抵达任意 Xg 目标点。这是搜索图形,查找起点和目标点的最近邻居来达成的。然后,我们利用最短路径搜索算法获得一系列站点(轨迹)。之后,机器人可以从初始状态移至目标,执行 πc 局部控制器政略的动作。随着机器人的进展,可以修复或更新指导 πc 政策的一系列站点或计划。
为了训练 πc 局部政策,方法作者使用了旨在达成目标的强化学习方法(GCRL)。在本例中,采用马尔可夫决策过程对问题进行建模,配以直指目标的条件。建议使用基于样本的计划来设定目标和训练策略。
为此,我们率先使用概率路线图来获得如上所述的 G 图形。接下来,针对每个学习局面,从图形中选择一条边。边是该局面的开始和目标。该过程与任何基于目标的学习算法兼容。作者在他们的实验中采用了软性扮演者-评论者,其密集的奖励与迈进目标的进展成正比。低级策略可以有效地训练,因为策略的状态空间只包含关于它们自身位置的信息,它们不需要学习来规避约束。
局部 πc 政策训练之后,我们需要安排一个过程来指导它达成全局目标。换言之,我们必须训练一个生成计划轨迹的模型。该模型的目标和奖励是相对于最终目标设定的,且不会后随 πc 站点。显然,为了达成模型的全局目标,需要更多的初始数据。将高维观测值和其它可用信息添加到低维局部状态数据当中。例如,它也许是局部映射图。
运用采样设计收集而来的数据,为了据其训练模型,我们研究一个序列建模问题,包括达成目标的朝向。在它们的工作中,方法作者还参考了一个部分可观测的多任务环境,其中策略可以在具有相同导航任务的若干个环境中工作,但每个环境的结构都有区别。虽然可以依据这个序列学习自回归动作预测,但我们遇到了一些问题。与 DT 一样,假设最优 RTG 是恒定的,因为我们不知道最优初始预测奖励,这取决于环境的未知结构。它也许在不同的局面中会有所变化。它还取决于初始状态和目标位置。因此,我们需要探索一些变化,这可令 DT 普适到未知环境,从任何起点到任何目标均能工作。
一种方式是依据离线数据训练全部的 RTG 分布。然后我们需要在操作期间从这个分布中选择条件。然而,在以目标为导向的任务中训练 RTG 的整个分布是很困难的,如此就可以在未知环境中推广和预测 RTG。代之,该方法的作者提议训练该分布的平均值函数。该函数估算 T 轨迹内给定目标 g 在 S 站点处的预期奖励。该函数也不是基于过去的历史记录,因为在开始操作的那一刻,我们预测初始预期奖励 R̃0。接下来,我们将 RTG 调整未为来自环境的实际奖励。值函数已被参数化,作为一个单独的神经网络,并使用 MSE 进行训练。
为了获得更优的行为,我们可以将训练值调整为一定的常数比率。此外,可能只会在最佳轨迹或满足某些预定义条件的轨迹上训练值函数。
下面呈现的是作者对控制转换器方法的可视化。
离线学习的常见问题之一是当已训练策略投入实践时,轨迹的实际分布与训练集的分布不匹配,会有分布偏移。这也许会导致误差累积,并导致策略变成次优的状况。为了解决这个问题,方法作者提议在离线训练阶段后,使用当前的模型政策扩展训练集,然后离线对模型进行优调。
2. 利用 MQL5 实现
在研究了控制转换器方法的理论层面之后,我们转到利用 MQL5 实现它。如早前所述,该算法很复杂。因此,在实施期间,我们将用到一些之前文章中的开发成果。我们开始研究该方法的第一件事就是构建可能的走势图形。
2.1. 训练集的集合
在随机环境和连续动作空间的情况下,构建这样的图形也许是一项艰巨的任务。我们决定从其它方面来解决这个问题,并借助在开发 出去探索(Go-Explore) 方法时获得的经验。我们针对 “...\CT\Faza1.mq5” EA 进行了细微的调整,并收集了训练期间交易操作的可能轨迹。在这样做的同时,我们选择了具有最大盈利能力的轨迹。
为此,我们在 EA 外部参数中添加了最大采样动作数量和最小轨迹长度。这些参数的出现是由于在一次验算中,覆盖整个训练间隔的可接受轨迹采样概率相当低(接近 “0”)。它更有可能逐渐采样具有盈利交易的小型区域,然后将其收集到一个共用的盈利动作序列之中。
input int MaxSteps = 48; input int MinBars = 20;
我要立即提醒你,该 EA 未用到神经网络模型。所有动作都是从均匀分布中采样的。
在 EA 初始化方法中,我们首先初始化指标和交易操作类对象。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- if(!Symb.Name(_Symbol)) return INIT_FAILED; Symb.Refresh(); //--- if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice)) return INIT_FAILED; //--- if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice)) return INIT_FAILED; //--- if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod)) return INIT_FAILED; //--- if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice)) return INIT_FAILED; if(!RSI.BufferResize(NBarInPattern) || !CCI.BufferResize(NBarInPattern) || !ATR.BufferResize(NBarInPattern) || !MACD.BufferResize(NBarInPattern)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); return INIT_FAILED; } //--- if(!Trade.SetTypeFillingBySymbol(Symb.Name())) return INIT_FAILED;
我们初始化必要的变量,并对轨迹和初始状态进行采样,以便继续先前保存的轨迹。当然,仅当先前有轨迹保存的情况下,才有可能如此采样。
PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE); PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY); AgentResult = vector<float>::Zeros(NActions); //--- int error_code; if(Buffer.Size() > 0 || LoadTotalBase()) { int tr = int(MathRand() / 32767.0 * Buffer.Size()); Loaded = Buffer[tr]; StartBar = MathMax(0,Loaded.Total - int(MathMax(Math::MathRandomNormal(0.5, 0.5, error_code), 0) * MaxSteps)); } //--- return(INIT_SUCCEEDED); }
如果先前没有验算过的轨迹,则 EA 开始从第一根柱线开始对动作进行采样。
直接数据收集是在 OnTick 跳价处理函数中运作的。在此,如前,我们检查新柱线开盘事件的发生,并在必要时加载有关金融产品走势的历史数据,及指标参数 。
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- if(!IsNewBar()) return; //--- CurrentBar++; 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; }
添加有关帐户状态、和来自环境的奖励信息。
//--- 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; //--- sState.rewards[0] = float((sState.account[0] - PrevBalance) / PrevBalance); sState.rewards[1] = float(sState.account[1] / PrevBalance - 1.0);
重新定义内部变量。
PrevBalance = sState.account[0]; PrevEquity = sState.account[1];
接下来,我们必须选择智能体的动作。如上所述,我们在此未使用模型。代之,我们检查采样阶段的开始。当重复先前保存的轨迹时,我们采取的行动来自轨迹。如果采样期已经到来,那么我们从均匀分布中生成一个动作向量。
vector<float> temp = vector<float>::Zeros(NActions); if((CurrentBar - StartBar) < MaxSteps) if(CurrentBar < StartBar) temp.Assign(Loaded.States[CurrentBar].action); else temp = SampleAction(NActions);
生成的动作将于环境中执行。
//--- 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); 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] = temp[i]; if(!Base.Add(sState) || (CurrentBar - StartBar) >= MaxSteps) ExpertRemove(); //--- }
在此,我们检查是否已达到最大采样步骤数,并在必要时启动程序的终止。
需要说几句话关于往经验回放缓冲区里添加轨迹方法的变化。虽然以前使用 FIFO(先进先出)方法添加轨迹,但现在我们保存最有盈利前景的验算结果。因此,在完成下一次验算后,我们首先检查经验回放缓冲区的大小。
//+------------------------------------------------------------------+ //| TesterPass function | //+------------------------------------------------------------------+ void OnTesterPass() { //--- ulong pass; string name; long id; double value; STrajectory array[]; while(FrameNext(pass, name, id, value, array)) { int total = ArraySize(Buffer); if(name != MQLInfoString(MQL_PROGRAM_NAME)) continue; if(id <= 0) continue; if(total >= MaxReplayBuffer) {
当触及缓冲区大小限制时,我们首先从先前保存的验算中搜索盈利能力最小的那一个。
for(int a = 0; a < id; a++) { float min = FLT_MAX; int min_tr = 0; for(int i = 0; i < total; i++) { float prof = Buffer[i].States[Buffer[i].Total - 1].account[1]; if(prof < min) { min = MathMin(prof, min); min_tr = i; } }
我们将新验算的盈利能力与经验回放缓冲区中的最小验算比较,并在必要时设置新验算取代最小那个。
float prof = array[a].States[array[a].Total - 1].account[1]; if(min <= prof) { Buffer[min_tr] = array[a]; PrintFormat("Replace %.2f to %.2f -> bars %d", min, prof, array[a].Total); } } }
这令我们能够剔除昂贵的缓冲区数据排序。在一次验算中,我们判定了保存新轨迹的最小值和可行性。
如果尚未达到经验回放缓冲区的大小限制,那么我们只需添加一个新验算,并完成方法操作即可。
else { if(ArrayResize(Buffer, total + (int)id, 10) < 0) return; ArrayCopy(Buffer, array, total, 0, (int)id); } } }
我们针对环境交互 EA 的讲述到此结束。您可在附件中找到其完整代码。
2.2. 技能训练
下一步是创建局部政策训练 EA。本地政策扮演执行者的角色,运作更高级别调度器的指令。为了简化局部政策模型本身,并本着层次化系统的精神,我们决定不提供环境的当前状态作为模型的输入。在我们的愿景中,它将是一个拥有一定技能的模型。所用的技能选择取决于调度器。同时,局部政策模型本身并不会分析环境状态。
为了训练技能,我们将使用自动编码器的架构,和前面讨论的层次化模型的发展。在训练期间,我们会将一项技能随机输入到我们局部政策模型的输入中。鉴别器将尝试识别正在使用的技能。
于此,我们必须判定要训练的技能数量。此处,我们也参考了我们以前的工作。在研究聚类方法时,我们曾判定了 100-500 范围内的最优聚类数量。为了避免任何技能短缺,我们将局部政策输入向量的大小指定为 512 个元素。
#define WorkerInput 512
下面表述的是局部政策和鉴别器模型的架构。我们不曾令这些模型过于复杂。我们期望收到一个单热向量,或由 SoftMax 函数常规化的数据向量,作为局部政策模型的输入。因此,我们没有在源数据层之后添加批量常规化层。
bool CreateWorkerDescriptions(CArrayObj *worker, CArrayObj *descriminator) { //--- CLayerDescription *descr; //--- if(!worker) { worker = new CArrayObj(); if(!worker) return false; } //--- Worker worker.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = WorkerInput; descr.activation = None; descr.optimization = ADAM; if(!worker.Add(descr)) { delete descr; return false; }
接下来是两个带有不同激活函数的全连接神经层。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!worker.Add(descr)) { delete descr; return false; } //--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = TANH; descr.optimization = ADAM; if(!worker.Add(descr)) { delete descr; return false; }
之后,我们降低层的维度,并在智能体的动作空间上下文中调用 SoftMax 函数对数据进行常规化。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = NActions * EmbeddingSize; descr.activation = None; descr.optimization = ADAM; if(!worker.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = EmbeddingSize; descr.step = NActions; descr.activation = None; descr.optimization = ADAM; if(!descriminator.Add(descr)) { delete descr; return false; }
局部政策的输出是一个完全连接神经层,其大小等于智能体的动作向量。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = NActions; descr.activation = SIGMOID; descr.optimization = ADAM; if(!worker.Add(descr)) { delete descr; return false; }
鉴别器模型具有类似于解码器的逆向架构。模型输入接收由局部政策模型生成的智能体动作向量。此处我们也不会用到批量常规化层。
//--- Descriminator if(!descriminator) { descriminator = new CArrayObj(); if(!descriminator) return false; } //--- descriminator.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = NActions; descr.activation = None; descr.optimization = ADAM; if(!descriminator.Add(descr)) { delete descr; return false; }
接下来我们在局部政策中使用相同的全连接层。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!descriminator.Add(descr)) { delete descr; return false; } //--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = TANH; descr.optimization = ADAM; if(!descriminator.Add(descr)) { delete descr; return false; }
然后,我们将维度更改为所用的技能数量,并调用 SoftMax 函数对所用技能的概率进行常规化。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = WorkerInput; descr.activation = None; descr.optimization = ADAM; if(!descriminator.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = WorkerInput; descr.activation = None; descr.optimization = ADAM; if(!descriminator.Add(descr)) { delete descr; return false; } //--- return true; }
我们开发的模型尽可能简单。这将令我们能够在训练和操作期间尽可能加快它们的工作。
为了训练技能,我们将创建 “...\CT\StudyWorker.mq5” EA。我们不会花太多时间详细研究所有 EA 方法。我们仅研究模型直接训练 Train 方法。
该方法的主体根据 EA 外部参数中指定的迭代次数安排训练模型的循环。在循环中,我们首先生成一个随机的单热向量,其大小等于技能的数量。
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { uint ticks = GetTickCount(); //--- bool StopFlag = false; for(int iter = 0; (iter < Iterations && !IsStopped() && !StopFlag); iter ++) { Data.BufferInit(WorkerInput, 0); int pos = int(MathRand() / 32767.0 * (WorkerInput - 1)); Data.Update(pos, 1.0f);
局部政策模型输入端接收向量,并执行前向验算。获得的结果被传递到鉴别器输入。
//--- Study if(!Worker.feedForward(Data,1,false) || !Descrimitator.feedForward(GetPointer(Worker),-1,(CBufferFloat *)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
记住要控制操作。
两个模型前向验算均成功后,我们执行模型的向后验算,以便把实际技能与判别器判定的技能之间的偏差最小化。
if(!Descrimitator.backProp(Data,(CBufferFloat *)NULL, (CBufferFloat *)NULL) || !Worker.backPropGradient((CBufferFloat *)NULL, (CBufferFloat *)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
所要我们做的就是通知用户训练进度,然后转入下一次训练迭代。
//--- if(GetTickCount() - ticks > 500) { string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Desciminator", iter * 100.0 / (double)(Iterations), Descrimitator.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } }
完成循环的所有迭代后,我们清除注释字段。显示训练结果。启动程序关闭。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Descriminator", Descrimitator.getRecentAverageError()); ExpertRemove(); //--- }
这种相当简单的方法令我们能够训练所需数量的不同技能。在构建层次化模型时,基于所执行动过的技能区分非常重要。这有助于令模型的行为多样化,并促进调度器在特定环境状态下选择正确技能的工作。
2.3. 成本函数训练
接下来,我们转到研究成本函数。期待经过训练的模型将能够在分析环境的当前状态后预测可能的盈利能力。从本质上讲,这是对标准 RL 中未来状态的估算,我们在几乎所有模型中都以一种或另一种形式对其进行研究。不过,该方法的作者提议在没有折扣因子的情况下考虑它。
我决定在局面结束之前进行成本估算实验,但只在短期的计划界限内进行。我的逻辑是,我们未计划开仓,并持有它“直到时间的尽头”。在随机市场中,这种达至远期的预测不太可能。否则,这种方式定非常容易辨别。同样,我没有令模型过于复杂。该模型的架构呈现如下。
我们向模型提供少量描述环境状态的历史数据。在该模型中,我们仅估测市场状况,以便评估主要的可能潜力。请注意,在这种情况下,我们对趋势不感兴趣。取而代之,我们专注于市场强度。由于我们取用原始数据,因此我们已在该模型中应用了批量常规化层。
//+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ bool CreateValueDescriptions(CArrayObj *value) { //--- CLayerDescription *descr; //--- if(!value) { value = new CArrayObj(); if(!value) return false; } //--- Value value.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(!value.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(!value.Add(descr)) { delete descr; return false; }
常规化数据在蜡烛上下文中的卷积层处理,这令我们能够辨别主要形态。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = ValueBars; descr.window = BarDescr; descr.step = BarDescr; descr.window_out = 4; descr.optimization = ADAM; descr.activation = LReLU; if(!value.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(!value.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(!value.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = NRewards; descr.activation = None; descr.optimization = ADAM; if(!value.Add(descr)) { delete descr; return false; } //--- return true; }
为了训练值函数,我们创建了 “...\CT\StudyValue.mq5” EA。在此,我们还将专门于模型训练 Train 方法。为了训练这个模型,我们需要一个训练样本。因此,在训练循环的主体中,我们对轨迹和状态进行采样。
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { int total_tr = ArraySize(Buffer); uint ticks = GetTickCount(); int check = 0; //--- 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)) * (Buffer[tr].Total - 2 * ValueBars)); if(i < 0) { iter--; continue; check++; if(check >= total_tr) break; }
请注意,在对轨迹进行采样时,我们会将可能状态的范围降至 ValueBars 值的两倍。这是因为在经验回放缓冲区中,每个状态只包含最后一跟柱线(由于在 DT 中采用了 GPT 架构),我们需要若干根柱线的历史数据来评估潜力。此外,我们将从总累积奖励中提取超出计划界限的奖励,直到局面结束。
接下来,我们填充源数据缓冲区。
check = 0; //--- History data State.AssignArray(Buffer[tr].States[i].state); for(int state = 1; state < ValueBars; state++) State.AddArray(Buffer[tr].States[i + state].state);
执行模型的直接验算。
//--- Study if(!Value.feedForward(GetPointer(State))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
接下来,我们要准备用于训练模型的目标数据。我们从状态估算时的经验回放缓冲区中获取累积奖励,并在计划界限之外减去累积奖励。然后,我们加载模型直接验算的结果,并调用 CAGrad 方法校正目标值向量。
vector<float> target, result; target.Assign(Buffer[tr].States[i + ValueBars - 1].rewards); result.Assign(Buffer[tr].States[i + 2 * ValueBars - 1].rewards); target = target - result*MathPow(DiscFactor,ValueBars); Value.getResults(result); Result.AssignArray(CAGrad(target - result) + result);
将准备好的目标值向量传递给模型,并执行逆向验算。切记要控制操作的执行。
if(!Value.backProp(Result, (CBufferFloat *)NULL, (CBufferFloat *)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
接下来,我们通知用户有关模型训练进度,并转到训练循环的下一次迭代。
//--- if(GetTickCount() - ticks > 500) { string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Value", iter * 100.0 / (double)(Iterations), Value.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } }
循环的所有迭代成功完成之后,清除金融产品图表上的注释字段。在日志中显示模型训练结果。启动 EA 终止。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Value", Value.getRecentAverageError()); ExpertRemove(); //--- }
EA 的完整代码和本文中用到的所有程序都可在附件中找到。
2.4. 调度器训练
我们转到工作的下一阶段,即为我们的层次化模型开发一个调度器。在这种情况下,决策转换器扮演调度器的角色,调度器将分析访问状态的顺序,以及在这些状态中执行的动作。在调度器的输出中,我们期望收到局部政策模型用来生成动作的技能。
我们将从模型架构开始。作为初始数据,我们会用一个向量来描述我们轨迹中的一个状态,其中包括所有可能的信息。数据以原始状态提供,故我们用批量数据常规化层对其进行预处理。
//+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ 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; }
此外,源数据向量中的数据是从不同的来源收集到的。相应地,它们具有不同的维度和分布。会用到一个嵌入层,方便它们进一步使用,并把它们转换为可比较的形式。
//--- 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 = 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 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = prev_count; descr.window = EmbeddingSize; descr.step = EmbeddingSize; descr.window_out = 16; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.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; }
在输出中,我们将数据的维度降至所用技能的数量,并调用 SoftMax 函数对其概率进行常规化。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = WorkerInput; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = WorkerInput; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; } //--- return true; }
在研究了模型的架构之后,我们转到构建调度器训练 “...\CT\Study.mq5” EA。如常,我们只专注模型训练 Train 方法。
DT 的训练方法几乎没有变化。在模型中,我们在源数据(包括 RTG)和智能体所执行动作之间建立依赖关系。但是,这与构造算法的相关原则有细微差别:
- RTG 不应该到达局面的结尾,而只应在计划界限内;
- 我们在 DT 输出中有一个技能,而非一个动作。局部政策模型用于传达误差梯度。
在模型训练过程中,所有这些细微差别都会有所反映。
在 Train 方法的主体中,我们和以前一样组织了一个模型训练循环系统。在外环体中,我们对轨迹和初始状态进行采样,以便训练模型。
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { int total_tr = ArraySize(Buffer); uint ticks = GetTickCount(); float err=0; int err_count=0; //--- 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-ValueBars,MathMin(Buffer[tr].Total,20+ValueBars))); if(i < 0) { iter--; continue; }
训练过程本身是在嵌套循环体中运作的。您也许还记得,由于 GPT 架构的特殊性,我们在使用历史数据时,需要严格按照它们在训练期间相应的接收顺序。
我们按顺序将价格走势的历史记录和指标加载到源数据缓冲区当中。
Actions = vector<float>::Zeros(NActions); for(int state = i; state < MathMin(Buffer[tr].Total - 2 - 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 State.AddArray(Actions);
接下来,我们必须指定 RTG。此处,我们使用实际的累积奖励。但首先,我们依据计划界限对其进行调整。
//--- Return-To-Go vector<float> rtg; rtg.Assign(Buffer[tr].States[state+1].rewards); Actions.Assign(Buffer[tr].States[state+ValueBars].rewards); rtg=rtg-Actions*MathPow(DiscFactor,ValueBars); State.AddArray(rtg);
以这种方式收集的数据馈送到调度器的输入,并调用向前验算方法。将生成的预测技能传递给局部政策模型的输入,并运作直接验算,预测智能体动作。
//--- Policy Feed Forward if(!Agent.feedForward(GetPointer(State), 1, false, (CBufferFloat *)NULL) || !Worker.feedForward((CNet *)GetPointer(Agent),-1,(CBufferFloat *)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
以这种方式预测的智能体动作与来自经验回放缓冲区中的实际动作进行比较,后者给出源数据中指定的奖励。为了训练模型,我们需要两个值向量之间的偏差最小化。我们将目标动作向量馈送到局部政策模型的输出,并在两个模型中执行一连串逆向验算。
//--- Policy study Actions.Assign(Buffer[tr].States[state].action); Worker.getResults(rtg); if(err_count==0) err=rtg.Loss(Actions,LOSS_MSE); else err=(err*err_count + rtg.Loss(Actions,LOSS_MSE))/(err_count+1); if(err_count<1000) err_count++; Result.AssignArray(CAGrad(Actions - rtg) + rtg); if(!Worker.backProp(Result,NULL,NULL) || !Agent.backPropGradient((CBufferFloat *)NULL, (CBufferFloat *)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
在本例中,我们使用已训练的局部政策模型。在向后验算期间,我们只更新调度器参数。为此,我们需要将局部政策模型训练标志设置为 false (Worker.TrainMode(false))。在所呈现的实现中,我在 EA 初始化方法中做这些事,以免在每次迭代时重复该操作。
所要我们做的就是通知用户训练进度,然后转入下一次训练迭代。
if(GetTickCount() - ticks > 500) { string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Agent", iter * 100.0 / (double)(Iterations), err); Comment(str); ticks = GetTickCount(); } } }
循环系统的所有迭代完成后,重复终止 EA 的操作,上面已讲过两次。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Agent", err); ExpertRemove(); //--- }
模型训练算法的主题到此结束。在本文中,我们创建了三个模型训练 EA,替代之前所用的。这种方式允许我们并行化训练模型。如您所见,技能训练 EA 不需要训练样本。我们可以在收集训练样本的同时训练技能。在训练调度器和成本函数时,我们用到经验回放缓冲区。同时,这些过程不会重叠,甚至可以在不同的机器上并行启动。
2.5. 模型测试 EA
训练模型之后,我们需要评估在交易中获得的成果。当然,我们将在策略测试器中测试模型。但我们需要一个 EA,它将上面讨论的所有模型组合成一个单一的决策复合体。我们将在 “...\CT\Test.mq5” EA 中实现此功能。我们不会考虑所有 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(!Value.feedForward(GetPointer(bState), 1, false)) return;
然后,我们用调度器的数据填充缓冲区。请注意,在训练模型时,数据序列应完全重复其呈现顺序。首先,我们传输有关价格走势和指标值的历史数据。
for(int b = 0; b < 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);
在缓冲区的末尾添加 RTG。我们从成本函数结果缓冲区中获取此数值。
//--- Return to go
Value.getResults(Result);
bState.AddArray(Result);
数据准备完成后,我们按顺序执行调度器,和局部政策模型的前向验算。同时,我们确保监控已执行的操作。
if(!Agent.feedForward(GetPointer(bState), 1, false, (CBufferFloat*)NULL) || !Worker.feedForward((CNet *)GetPointer(Agent), -1, (CBufferFloat *)NULL)) return;
以这种方式预测的智能体动作将在环境中处理和执行。
PrevBalance = sState.account[0]; PrevEquity = sState.account[1]; //--- vector<float> temp; Worker.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; } 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]; if(!Base.Add(sState)) ExpertRemove(); }
应当注意的是,以这种方式收集的数据既可用于优调模型,也可用于在操作过程中对模型进行后续的额外训练。这将令我们能够持续适应变化的环境条件。
3. 测试
我们在创建数据收集和模型训练 EA 方面做了大量工作。如上所述,我们将整个过程划分到单独的 EA,以便并行执行多项任务。第一步是启动技能训练 EA “StudyWorker.mq5”,它可以自主工作,不需要训练样本。同时,我们收集一个训练样本。
事实证明,收集 2023 年前 7 个月历史区间的训练样本是相当劳累的。我遇到了一个问题,即便智能体动作采样的界限很小,大多数验算也无法满足余额正增长需求。
为了在优化模式下选择最优计划界限,将每次验算的迭代次数调整为优化参数。
在收集训练集,并训练局部政策模型后,我并行运行了调度器和成本函数模型训练。这种方式令我能够显著减少训练模型所花费的时间。
经历了漫长且相当复杂的训练过程,我们设法获得了一个能够在训练集之外产生盈利的模型。训练后的模型依据 2023 年 8 月的历史数据进行了测试。根据测试结果,盈利因子为 1.13。盈利和无盈利的仓位比例接近 1:1。所有盈利都是由于平均盈利交易超过平均亏损而达成的。
结束语
在本文中,我们讲述了控制转换器方法,其在复杂和动态变化的环境中训练控制策略提供了一种创新的架构。控制转换器结合了先进的强化学习、调度和机器学习技术,可创建灵活、且自适应的控制策略。
控制转换器为各种自主系统和机器人的开发开辟了新的前景。它能够适应不同的环境,参考动态条件和离线训练,令其成为创建能解决复杂控制和导航问题的智能、自主系统的强大工具。
在本文的实践部分,我们利用 MQL5 实现了我们所提方法的愿景。在该实现中,我们使用了一种新的方式,将模型训练划分到单独的不相关 EA,这令我们能够并行执行若干个任务。这令我们能够显著减少模型的整体训练时间。
在训练和测试模型时,我们设法创建了一个能够产生盈利的模型。因此,该方式可被认为是有效的。它可用于构建交易解决方案。
我要再次提醒您,本文中讲述的所有程序信息丰富,旨在演示所提议的算法。它们不适合在实际市场条件下使用。
链接
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Faza1.mq5 | 智能交易系统 | 样本收集 EA |
2 | Study.mq5 | 智能交易系统 | 调度器训练 EA |
3 | StudyWorker.mq5 | 智能交易系统 | 局部政策模型训练 EA |
4 | StudyValue.mq5 | 智能交易系统 | 成本函数训练 EA |
5 | Test.mq5 | 智能交易系统 | 模型测试 EA |
6 | Trajectory.mqh | 类库 | 系统状态定义结构 |
7 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
8 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/13674