
交易中的神经网络:多智代自适应模型(终篇)
概述
在上一篇文章中,我们讲述了 MASA 框架 — 建立在交互智代的独特集成之上的一个多智代系统。在 MASA 架构中,基于强化学习(RL)的 RL 智代优化了投资组合的整体回报。同时,一种基于算法的平替智代试图优化 RL 智代提出的投资组合,专注于最小化潜在风险。
得益于智代之间明确的职责划分,该模型不断学习,并适配底层金融市场环境。MASA 多智代制程达成了更加平衡的投资组合,包括盈利能力和风险敞口两方面。
MASA 框架的原始可视化提供如下。
在上一篇文章的实践章节,我们验证了实现单个 MASA 框架智代功能的算法,每个都作为单独的对象开发。今天,我们继续这项工作。
1. MASA 复合层
在上一篇文章中,我们创建了三个独立的智代,每个智代在 MASA 框架内都有特定的功能。现在,我们将它们合并到单一系统之中。为此,我们将创建一个新对象 CNeuronMASA,其结构如下所示。
class CNeuronMASA : public CNeuronBaseSAMOCL { protected: CNeuronMarketObserver cMarketObserver; CNeuronRevINDenormOCL cRevIN; CNeuronRLAgent cRLAgent; CNeuronControlAgent cControlAgent; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronMASA(void) {}; ~CNeuronMASA(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint units_count, uint heads, uint layers_mo, uint forecast, uint segments_rl, float rho, uint layers_rl, uint n_actions, uint heads_contr, uint layers_contr, int NormLayer, CNeuronBatchNormOCL *normLayer, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronMASA; } //--- virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; virtual void SetOpenCL(COpenCLMy *obj) override; virtual void SetActivationFunction(ENUM_ACTIVATION value) override; //--- virtual int GetNormLayer(void) { return cRevIN.GetNormLayer(); } virtual bool SetNormLayer(int NormLayer, CNeuronBatchNormOCL *normLayer); };
这个新物体的结构有若干方面值得特别关注。
首先,人们会立即注意到初始化方法 Init 中的参数数量相对较多。这是因为需要容纳所有三个智代,每个都有自己的架构细节。
另一个细微差别违背了我们代码库的总体理念。前馈通验方法具有单一输入源,与 MASA 框架一致。RL 智代和市场观察器智代都接收当前市场状态作为输入。然而,在梯度分布方法中,我们引入了第二个数据源 — 在最初描述的前馈通验和 MASA 框架中都不存在的一个数据源。
采用这种非常规解决方案是为了启用替代市场观察器智代的训练过程。为此目的,我们还添加了一个用于数据逆向归一化的内部对象。我们将在构建类方法时更详细地讨论该决定。
新类的所有内部对象都声明为静态,允许我们将构造函数和析构函数保持为空。新类实例的初始化由 Init 方法专门处理。如上所述,该方法采用许多参数,尽管它们本质上复制了先前创建智代的初始化参数。
bool CNeuronMASA::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint units_count, uint heads_mo, uint layers_mo, uint forecast, uint segments_rl, float rho, uint layers_rl, uint n_actions, uint heads_contr, uint layers_contr, int NormLayer, CNeuronBatchNormOCL *normLayer, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseSAMOCL::Init(numOutputs, myIndex, open_cl, n_actions, rho, optimization_type, batch)) return false;
在方法内,我们首先调用父类的同名方法。在这种情况下,父类是配以 SAM 优化的全连接神经层。
回想一下,MASA 框架的最终输出是由控制器智代生成的,作为一个动作张量。相应地,在父类初始化中,我们将层大小设置为与参与者动作空间相匹配。
接下来,我们按顺序初始化我们的智代。第一个是市场观察器。
它接收当前市场状态张量作为输入,并以相同的多模态序列格式返回指定规划横向范围的预测值。
//--- Market Observation if(!cMarketObserver.Init(0, 0, OpenCL, window, window_key, units_count, heads_mo, layers_mo, forecast, optimization, iBatch)) return false; if(!cRevIN.Init(0, 1, OpenCL, cMarketObserver.Neurons(), NormLayer, normLayer)) return false;
紧接着,我们初始化逆向归一化层,其大小与市场观察器智代的输出相匹配。
然后,我们初始化 RL 智代,它也接收市场状态张量作为输入。但它会根据学到的政策返回参与者动作张量。
//--- RL Agent if(!cRLAgent.Init(0, 2, OpenCL, window, units_count, segments_rl, fRho, layers_rl, n_actions, optimization, iBatch)) return false;
最后,我们初始化控制器智代,它取前两个智代的输出,并在调整后生成参与者动作张量。
if(!cControlAgent.Init(0, 3, OpenCL, 3, window_key, n_actions / 3, heads_contr, window, forecast, layers_contr, optimization, iBatch)) return false;
重点要注意,在我们的实现中,RL 智代和控制器智代对于参与者动作张量的解释不同。这种区别不仅仅是功能上的。
RL 智代的输出使用一个全连接层,即基于市场分析、和学到的政策独立生成动作张量的每个元素。然而,我们事先知道相反的行为(买入与卖出同一资产)是互斥的。甚至,每个方向的交易参数在动作向量中占据三个元素。
考虑到这一点,我们指示控制器智代将动作张量解释为多模态序列,其中每个元素代表由 3 元素向量描述的交易。以此方式,控制器智代就可以单独评估每个交易方向的风险。
在初始化方法结束时,我们重新分配指向外部接口缓冲区的指针,并将 sigmoid 激活函数设置为默认值。
if(!SetOutput(cControlAgent.getOutput(), true) || !SetGradient(cControlAgent.getGradient(), true)) return false; SetActivationFunction(SIGMOID); //--- return true; }
然后,该方法返回一个逻辑标志,指示成功执行。
关于激活函数应当说几句话。我们类的输出是参与者动作张量 — 首先由 RL 智代生成,然后经控制器智代调整。显然,输出空间必须在智代和类本身之间保持一致。据此理由,我们重写激活函数方法,以便确保跨所有组件的同步。
void CNeuronMASA::SetActivationFunction(ENUM_ACTIVATION value) { cControlAgent.SetActivationFunction(value); cRLAgent.SetActivationFunction((ENUM_ACTIVATION)cControlAgent.Activation()); CNeuronBaseSAMOCL::SetActivationFunction((ENUM_ACTIVATION)cControlAgent.Activation()); }
初始化完成后,我们转到前馈通验算法。这部分直接了当。我们简单地按顺序调用智代的前馈方法。首先,我们获取市场分析结果、及初步的参与者动作张量。
bool CNeuronMASA::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cMarketObserver.FeedForward(NeuronOCL.AsObject())) return false; if(!cRLAgent.FeedForward(NeuronOCL.AsObject())) return false;
接下来,我们将这些结果传递给控制器智代,以便产生最终决策。
if(!cControlAgent.FeedForward(cRLAgent.AsObject(), cMarketObserver.getOutput())) return false; //--- return true; }
该方法结束时返回逻辑执行结果。
再次查看早前的 MASA 可视化效果,您或许会注意到最终动作向量被描述为 RL 智代和控制器智代输出的汇总。然而,在我们的实现中,我们将控制器智代的输出视为最终结果,未与 RL 智代的残余连接。您还记得我们控制器智代的架构吗?
我们的控制器智代是作为变换器解码器实现的。如您所知,变换器架构已在注意力模块和 FeedForward 模块中包含了残差连接。因此,来自 RL 智代的残余信息流本质上内置于控制器智代当中,没必要额外连接。
我们现在转向反向传播过程。具体到误差梯度分布算法(calcInputGradients)。这就是我们在 CNeuronMASA 中开始的一些早期非标准决定发挥作用的地方。
首先,我们看看智代的预期输出。我们的两个智代返回参与者动作张量。在训练期间,使用一组最优动作作为目标(在监督学习中),或将其投影到奖励(在强化学习中)是合乎逻辑的。
不过,市场观察器智代会为所分析金融产品输出多模态时间序列的预测值。这就浮现出智代的训练目标问题。我们能经由控制器智代传递梯度,间接影响市场观察器调整 RL 智代输出的决定。然而,这样的方式与其预测目标不符。
更应景的解决方案应当是单独训练市场观察器智代预测时间序列,如同我们之前针对账户状态编码器所做的那样。然而,挑战在于观察器现已集成到我们的复合模型当中。这令单独训练变得不切实际。我由此想到了在层级提供两个训练目标的思路。这就是针对我们函数库工作流程的根本性改变。这会需要大规模的重新设计。
为了避免这种情况,我们可采用非标准方案。我们使用第二个输入源机制来发送一组额外的目标值。故此,我们使用两个输入数据源重新定义误差梯度分布方法,只是这次将使用第二个对象的缓冲区将目标值张量传递给市场观察器智代。
bool CNeuronMASA::calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = -1) { if(!NeuronOCL) return false;
然而,这种方式有其缺陷,主要是数据可比性。通常,我们接收来自终端的生料、未处理的输入数据,并投喂给模型。来自终端的这些数据在预处理中会被归一化,所有神经层都与归一化值打交道。这也应用于我们正在创建的 CNeuronMASA 对象。因此,观察器智代的输出也会被归一化,以便控制器智代更容易处理。对比之,所分析多模态时间序列(我们的目标)的未来实际值,仅以生料形式提供。为了解决这一点,我们引入了逆归一化层,我们在前馈通验中并未用到它。但它会在梯度分布期间用到。它在观察器的预测,会重新应用输入数据的统计参数。
if(!cRevIN.FeedForward(cMarketObserver.AsObject())) return false;
这就可与生料目标进行有效比较,并实现正确的梯度传播。
if(!cRevIN.FeedForward(cMarketObserver.AsObject())) return false; float error = 1.0f; if(!cRevIN.calcOutputGradients(SecondGradient, error)) return false; if(!cMarketObserver.calcHiddenGradients(cRevIN.AsObject())) return false;
之后,我们在 RL 智代和市场观察器之间分配控制器智代的梯度。为了保留先前累积的梯度,我们将观察器的误差存储在缓冲区之中,然后汇总来自两个信息流的贡献。
if(!cRLAgent.calcHiddenGradients(cControlAgent.AsObject(), cMarketObserver.getOutput(), cMarketObserver.getPrevOutput(), (ENUM_ACTIVATION)cMarketObserver.Activation()) || !SumAndNormilize(cMarketObserver.getGradient(), cMarketObserver.getPrevOutput(), cMarketObserver.getGradient(), 1, false, 0, 0, 0, 1)) return false;
另一个关键点:RL 智代和控制器智代都返回参与者动作张量。前者基于自身对当前市场状况的分析结果返回它。而后者 — 在评估所提供动作张量的风险之后,考虑到接收自市场观察器智代的即将到来的价格走势的预测值。理想的话,它们的输出应当一致。因此,我们为 RL 智代引入了一个误差项,表示与控制器智代结果的偏差。
CBufferFloat *temp = cRLAgent.getGradient(); if(!cRLAgent.SetGradient(cRLAgent.getPrevOutput(), false) || !cRLAgent.calcOutputGradients(cControlAgent.getOutput(), error) || !SumAndNormilize(temp, cRLAgent.getPrevOutput(), temp, 1, false, 0, 0, 0, 1) || !cRLAgent.SetGradient(temp, false)) return false;
再一次,这些误差操作不得擦除先前累积的梯度。为了确保这一点,我们用两个缓冲区来做数据流替换、并汇总。
梯度分布沿所有内部智代,下一步是将它们传递回输入级别。在此,我们也必须聚合来自两个来源的梯度:RL 智代,和市场观察器智代。如前,我们首先传播观察器的梯度。
if(!NeuronOCL.calcHiddenGradients(cMarketObserver.AsObject())) return false;
然后我们替换缓冲区,并传播 RL 智代的梯度。
temp = NeuronOCL.getGradient(); if(!NeuronOCL.SetGradient(NeuronOCL.getPrevOutput(), false) || !NeuronOCL.calcOutputGradients(cRLAgent.getOutput(), error) || !SumAndNormilize(temp, NeuronOCL.getPrevOutput(), temp, 1, false, 0, 0, 0, 1) || !NeuronOCL.SetGradient(temp, false)) return false; //--- return true; }
我们将这两个贡献相加,并恢复原始缓冲区状态。
在该阶段,误差梯度已根据它们对模型性能的贡献分布在所有组件当中。最后一步是更新模型参数,以便误差最小化。该功能在 updateInputWeights 方法中执行。该方法算法十分简单。我们简单地按顺序调用智代的前馈方法。我们在此就不赘述了。我们必须记住,所有智代都用到 SAM 优化。因此,这些更新必须按前馈通验的逆顺序执行。
据此,我们针对新 CNeuronMASA 类方法背后算法的讨论结束。附件中提供了该对象、及其所有方法的完整代码,供进一步研究。
2. 模型架构
现在我们已完成了新对象的构造,我们转到可训练模型架构。在此,我们也引入了一些变化,及一些非常规的解决方案。
首先,我们放弃了使用单独的环境状态编码器。这并非巧合。在我们的 CNeuronMASA 类中,两个智代针对当前环境并行执行分析。
第二个修改关注包含帐户状态信息。之前,我们将该数据作为第二个输入源投喂到参与者模型之中。然而,现在,这个输入通道被市场观察器智代的目标值占据。为了解决这一点,我们简单地将账户状态信息附加到环境状态张量的末尾。
因此,参与者现在接收一个组合输入张量,其由环境状态描述和账户状态信息组成。
bool CreateDescriptions(CArrayObj *&actor, CArrayObj *&critic) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; } //--- Actor actor.Clear(); //--- if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (HistoryBars * BarDescr + AccountDescr); descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
这些生料输入数据首先由批量归一化层处理。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
在该阶段,我们必须注意到,账户状态向量破坏了环境状态张量的结构。它的长度可能与所分析时间序列的多模态序列中单个元素的描述大小不同,这与我们所用的注意力模块的结构不兼容。为了解决这一点,我们使用可训练的嵌入层将输入数据转换为矩阵形式。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronEmbeddingOCL; descr.count = 1; descr.window_out = BarDescr; { int temp[HistoryBars + 1]; if(ArrayInitialize(temp, BarDescr) < (HistoryBars + 1)) return false; temp[HistoryBars] = AccountDescr; if(ArrayCopy(descr.windows, temp) < (HistoryBars + 1)) return false; } descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
该层将输入向量划分为固定长度的板块,并将每个板块投影到预定义维度的子空间当中,且与板块的原始大小无关。每个板块都有自己独立可训练的投影矩阵。
我们知道,大多数输入张量由描述单个环境状态(柱线)的同次向量组成,只有最终元素(账户状态)不同。因此,我们采用固定长度的分析窗口初始化序列,然后仅调整最后一个元素的大小。
重点是,我们将每个嵌入序列元素的输出大小设置为等于单根柱线描述的大小。这是一件非常重要的事情。这一点至关重要:理论上我们可以为嵌入输出选择任何维度。但市场观察器智代返回的预测是按原始输入维度。因此,预测的多模态时间序列必须与训练期间所用目标值匹配,其与原始输入相同。圆环闭合了。
使用可训练嵌入隐式来提供位置编码。正如我们早前所提,每个序列元素都有自己独特的投影矩阵。因此,不同位置的雷同向量被投影到不同的子空间表示之中,确保它们在分析过程中维持可区分。
然后将生成的嵌入传递到我们的 MASA 框架对象之中。此处,有几个重要的细节值得关注。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMASA; //--- Windows { int temp[] = {BarDescr, NForecast, 2 * NActions}; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.window_out = 32; descr.count = HistoryBars+1; //--- Heads { int temp[] = {4, 4}; if(ArrayCopy(descr.heads, temp) < (int)temp.Size()) return false; } //--- Layers { int temp[] = {3, 3, 3}; if(ArrayCopy(descr.units, temp) < (int)temp.Size()) return false; } descr.window = BarDescr; descr.probability = Rho; descr.step = 1; // Normalization layer descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
在动态数组 descr.windows 中,我们按内部智代分析的序列指定关键参数。此处,我们按顺序指示一个输入序列元素的维度、后续时间序列的预测范围、以及参与者的动作空间。
应特别注意最后一个参数。在设计内部智代架构时,我们最初描述了环境状态和所生成动作之间的直接依赖关系,排除了参与者行为中的随机性。然而,在实践中,我们实现了随机参与者政策。为了达成这一点,我们将 MASA 框架的输出动作空间的维度加倍。这与我们早前在组织随机政策时所用方式相对应。出品的动作向量在逻辑上分为两个相等的部分,代表所分析环境状态下参与者的动作空间的均值和方差。据此理由,禁用了该层的激活函数。
我们用到的每个注意力模块都采用四个头。每个智代包含三个编码器/解码器层。
如早前所述,MASA 框架的输出被传递到变分自动编码器的潜在状态层,其根据指定的分布生成随机参与者动作。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
使用卷积层和 sigmoid 激活函数,将生成的动作向量投影到所需的范围。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvSAMOCL; descr.count = NActions / 3; descr.window = 3; descr.step = 3; descr.window_out = 3; descr.activation = SIGMOID; descr.optimization = ADAM; descr.probability = Rho; if(!actor.Add(descr)) { delete descr; return false; }
在最终模型输出中,我们应用频率对齐层,以便将模型的结果与目标值相匹配。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFreDFOCL; descr.window = NActions; descr.count = 1; descr.step = int(false); descr.probability = 0.7f; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
下一步是构造评论者架构。总体而言,它仍类似于早期设计。尽管有一个重大变化:删除单独的环境状态编码器,需要我们将环境分析模块直接添加到评论者模型之中。为此,我们使用 PSformer 框架来分析当前状态。值得注意的是,评论者的输入数据不包括账户状态信息。在我看来,这些信息对于评论者价值不大。交易成果主要取决于市场条件,而非入场时的账户状态。
有人会争辩说,过大或过小的交易量都可能导致执行错误,结果就是未能开仓。不过,交易量的判定是参与者的责任。评论者应当处理这样的边缘情况吗?这根本上是模型之间的功能分离问题。
另一个考虑因素是持仓和累计损益,即过去交易的结果。评论者评估的是当前的交易(或政策),而非以前的成果。即使我们假设评论者按整体评估政策,其评估也会延伸到局次的末端,不可追溯。
因此,评论者仅接收当前环境状态作为输入。
//--- Critic critic.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
如前,生料输入数据在批量归一化层中进行处理。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
然后它被投喂到 PSformer 框架的 3 个连续层。
//--- layer 2 - 4 for(int i = 0; i < 3; i++) { if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronPSformer; descr.window = BarDescr; descr.count = HistoryBars; descr.window_out = Segments; descr.probability = Rho; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } }
接下来,我们使用连续卷积层、和全连通层来降低所得张量的维度。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvSAMOCL; descr.count = HistoryBars; descr.window = BarDescr; descr.step = BarDescr; descr.window_out = int(LatentCount / descr.count); descr.probability = Rho; descr.activation = GELU; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } //--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseSAMOCL; descr.count = LatentCount; descr.probability = Rho; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
我们把所分析的环境结果与智代的行为,在数据级联层中相结合。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = LatentCount; descr.window = LatentCount; descr.step = NActions; descr.activation = GELU; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
之后到来的是决策模块,它由 4 个全连接层组成。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseSAMOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; descr.probability = Rho; if(!critic.Add(descr)) { delete descr; return false; } //--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseSAMOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; descr.probability = Rho; if(!critic.Add(descr)) { delete descr; return false; } //--- layer 10 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseSAMOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; descr.probability = Rho; if(!critic.Add(descr)) { delete descr; return false; } //--- layer 11 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseSAMOCL; descr.count = NRewards; descr.activation = None; descr.optimization = ADAM; descr.probability = Rho; if(!critic.Add(descr)) { delete descr; return false; }
在输出阶段,我们使用频率对齐层来协调模型的结果与目标值。
//--- layer 12 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFreDFOCL; descr.window = NRewards; descr.count = 1; descr.step = int(false); descr.probability = 0.7f; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } //--- return true; }
在成功定义两个可训练模型的架构之后,我们向调用程序返回逻辑结果,并完成该方法。
3. 模型训练程序
我们现在自信正逼近我们工作的合乎逻辑的结论,并转向模型训练计划的构造。当然,删除其中一个可训练模型,会在训练算法上留下印记。甚至,在设计 MASA 框架模块时,我们同意使用来自第二个数据源的信息流,作为目标值的附加流。有介于此,我们直接进入训练算法,在 Train 方法中实现。
如前,我们从一些准备工作开始。我们从经验回放缓冲区形成的轨迹里选择概率向量,并按过去运行的有效性进行加权。
void Train(void) { //--- vector<float> probability = GetProbTrajectories(Buffer, 0.9); //--- vector<float> result, target, state; bool Stop = false; //--- uint ticks = GetTickCount();
然后我们声明必要的局部变量。
接下来,我们设置训练循环,其中迭代次数由我们的智能系统外部参数判定。
for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++) { int tr = SampleTrajectory(probability); int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - NForecast)); if(i <= 0) { iter --; continue; } if(!state.Assign(Buffer[tr].States[i].state) || MathAbs(state).Sum() == 0 || !bState.AssignArray(state)) { iter --; continue; } if(!state.Assign(Buffer[tr].States[i+NForecast].state) || !state.Resize(NForecast*BarDescr) || MathAbs(state).Sum() == 0 || !bForecast.AssignArray(state)) { iter --; continue; }
在循环内,我们针对一条轨迹及其环境状态进行采样。在此刻,我们还验证了在所需的分析深度、和预测横向范围内,是否存在历史和未来数据。如果在任何时刻检查失败,我们会据新的轨迹和状态重新采样。
一旦有了必要的数据,我们就会将它们转移到相应的缓冲区之中。然后,我们将帐户状态信息追加到所分析环境状态描述当中。
//--- Account float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0]; float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1]; bState.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance); bState.Add(Buffer[tr].States[i].account[1] / PrevBalance); bState.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity); bState.Add(Buffer[tr].States[i].account[2]); bState.Add(Buffer[tr].States[i].account[3]); bState.Add(Buffer[tr].States[i].account[4] / PrevBalance); bState.Add(Buffer[tr].States[i].account[5] / PrevBalance); bState.Add(Buffer[tr].States[i].account[6] / PrevBalance);
我们还为所分析环境状态添加时间戳。
//--- double time = (double)Buffer[tr].States[i].account[7]; double x = time / (double)(D'2024.01.01' - D'2023.01.01'); bState.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_MN1); bState.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_W1); bState.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_D1); bState.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
输入数据准备好之后,我们开始模型训练过程。第一步是训练评论者。评论者接收所分析环境状态、和参与者在收集训练样本时实际执行的动作向量作为输入。我们使用这些行动,是因为我们已知道环境为它们提供的真正回报。我们执行前馈通验,评估参与者过去的动作。
//--- Critic bActions.AssignArray(Buffer[tr].States[i].action); Critic.TrainMode(true); if(!Critic.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)GetPointer(bActions))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
正如预期,我们希望评论者的前馈通验输出是一个接近观察到的实际奖励的张量。因此,我们从回放缓冲区中提取事实奖励,并运行评论者的反向传播过程,从而最大限度地减少抗拒该目标的误差。
result.Assign(Buffer[tr].States[i + 1].rewards); target.Assign(Buffer[tr].States[i + 2].rewards); result = result - target * DiscFactor; Result.AssignArray(result); if(!Critic.backProp(Result, (CBufferFloat *)GetPointer(bActions), (CBufferFloat *)GetPointer(bGradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
接下来是参与者政策训练,我们分两个阶段进行。首先,参与者执行前向通验,生成动作张量。
//--- Actor Policy if(!Actor.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
接下来是评论者的前馈通验,这次评估参与者生成的动作。
Critic.TrainMode(false); if(!Critic.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CNet*)GetPointer(Actor), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
在该阶段,评论者训练被禁用。这将防止不正确的数值影响参与者的奖励政策学习。
然后我们评估所分析轨迹的结果。如果参与者的政策产生了积极的结果,我们会把监督学习的风格朝向参与者当前政策的积极轨迹。这是参与者训练的第一阶段。
if(Buffer[tr].States[0].rewards[0] > 0) if(!Actor.backProp(GetPointer(bActions),(CBufferFloat*)GetPointer(bForecast),GetPointer(bGradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
在第二阶段,我们为评论者分配奖励最大化的任务,并将误差梯度向下传播到参与者的动作级别。
Critic.getResults(Result); for(int c = 0; c < Result.Total(); c++) { float value = Result.At(c); if(value >= 0) Result.Update(c, value * 1.01f); else Result.Update(c, value * 0.99f); } if(!Critic.backProp(Result, (CNet *)GetPointer(Actor), LatentLayer) || !Actor.backPropGradient((CBufferFloat*)GetPointer(bForecast),GetPointer(bGradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
这令评论者能够指出调整参与者政策的方向,即增加整体回报。然后,参与者政策将被相应地更新。
我们将继续通知用户有关训练过程的信息,并继续进行训练周期的下一次迭代。
//--- if(GetTickCount() - ticks > 500) { double percent = double(iter) * 100.0 / (Iterations); string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Actor", percent, Actor.getRecentAverageError()); str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Critic", percent, Critic.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } }
所有迭代成功完成之后,我们清除金融产品图表上的注释字段(显示用户信息)。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic", Critic.getRecentAverageError()); ExpertRemove(); //--- }
将模型训练结果输出到日志中,并启动 EA 终止过程。
此刻,我们结束了对 MASA 框架算法、和模型训练程序实现的讨论。完整的源代码可在附件中找到。
4. 测试
故此,我们利用 MQL5 实现 MASA 框架作者所提议方式的工作已经得到合乎逻辑的结论。我们现在已到达工作的最后阶段 — 依据真实历史数据评估已实现的方式。
重点要强调,我们正在评估已实现方式的有效性,不光是提议的这些,在于我们的实现包括对原始 MASA 框架的多项修改。
这些模型是依据 EURUSD 的 2023 年 H1 数据进行训练的。所有指标参数均按其默认值设置。
对于初始训练,我们采用了早期工作中编译的数据集,并在整个训练过程中定期更新,从而令其与参与者不断革新的政策保持一致。
经过若干周期的模型训练、及数据集更新,我们获得了一项政策,其在训练集和测试集上都展现出盈利能力。
训练有素的政策依据 2024 年 1 月的历史数据上进行了测试,所有其它参数保持不变。结果如下:
在测试期间,该模型执行了 29 笔交易,其中一半以盈利了结。由于平均盈利交易规模是平均亏损交易规模的两倍多,该模型实现了账户余额的明显上升趋势。这些结果指明了所实现框架的潜力。
结束语
我们探索了一种在不稳定的金融市场中进行投资组合管理的创新方法 — MASA 多智代自适应系统。该框架有效地结合了 RL 算法、及自适应优化方法的优势,令模型能够同时提高盈利能力,并降低风险。
在实践章节,我们实现了所提议方式的 MQL5 解释。我们依据真实历史数据训练模型,并测试了所生成政策。结果表明潜力巨大。然而,在部署到实时交易之前,有必要对更具代表性的数据集进行深入训练,并在各种条件下进行广泛的测试。
参考
文章中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | 智能系统 | 收集样本的智能系统 |
2 | ResearchRealORL.mq5 | 智能系统 | 利用 Real-ORL 方法收集样本的智能系统 |
3 | Study.mq5 | 智能系统 | 模型训练智能系统 |
4 | Test.mq5 | 智能系统 | 模型测试智能系统 |
5 | Trajectory.mqh | 类库 | 系统状态描述结构 |
6 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
7 | NeuroNet.cl | 函数库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/16570
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。



