English Русский Español Deutsch 日本語 Português
preview
神经网络变得简单(第 85 部分):多变元时间序列预测

神经网络变得简单(第 85 部分):多变元时间序列预测

MetaTrader 5交易系统 | 7 一月 2025, 09:22
208 1
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

预测时间序列是构建有效交易策略的最重要元素之一。当在一个或另一个方向执行交易操作时,我们是据自我对即将到来价格走势的愿景(预测)行事。深度学习模型的最新成就,尤其是基于变换器架构的模型,在这一领域展现出重大进展,为解决与长期时间序列预测相关的多方面问题提供了巨大的潜力。

不过,浮现出一个问题,即出于预测时间序列的目的,而使用变换器架构的效率。我们之前研究的大多数基于变换器的模型,都运用自关注度机制来记录所分析序列中各个时间步的长期依赖关系。然而,一些研究认为,大多数现有的基于跨期关注度的变换器模型无法充分研究跨期依赖性。有时,简单的线性模型更胜于某些变换器模型。

论文《客户端:用于多元长期时间序列预测的交叉变量线性集成增强型变换器》的作者非常建设性地解决了这个问题。为了评估问题的规模,他们进行了一项复杂的实验,取历史序列的一段掩蔽部分序列,即用 “0” 随机替换单独数据。对时间依赖性更敏感的模型,在没有正确历史数据的情况下会展现出较大的性能下滑。因此,性能下滑表明模型能够捕获时间形态的能力。实验发现,随着数据掩码规模的增加,基于交叉关注度的变换器模型的性能并没有显著降低。一些该类模型展现出预测性能几乎没有变化,即使高达 80% 的历史数据被随机替换为 “0” 亦如此。这也许表明该类模型的预测结果对于所分析时间序列的变化不敏感。

我个人对所呈现分析结果的态度是模棱两可。当然,委婉地说,对所分析时间序列的变化缺乏敏感性是令人担忧的。甚至,鉴于模型被视为“黑匣子”,因此很难理解模型参照数据的哪一部分,且忽略了哪一部分。

另一方面,在金融市场的随机环境中,所分析时间序列会包含相当多的噪音,最好对其进行过滤。在这种境况下,忽略参照环境下非典型的微小波动或异常值,也许有助于您识别所分析时间序列的最明显部分。

此外,该论文的作者注意到,在一些多元时间序列中,不同的变量会随着时间的推移表现出相关的形态。这表明更可能性运用关注度机制来学习变量之间的依赖关系,而非时间步。这个假设允许改变所应用的自我关注度机制的范式。

尽管由论文作者提出的变换器可以很好地模拟非线性,并捕获变量之间的依赖关系,但自所分析序列中提取趋势时,它也许作用不佳。而线性模型则能很好地执行该任务。为了两全其美,论文作者提出了《客户端:用于多元长期时间序列预测的交叉变量线性集成增强型变换器》。所提议算法结合了线性模型提取趋势的能力,以及强化变换器的表达能力。


1. “客户端”算法

客户端的主要思路是从关注随时间变化,转移到分析变量之间的依赖关系,并将线性模块集成到模型中,从而更好地分别利用变量依赖关系、和趋势信息。

客户端方法的作者以创造性的方式解决了时间序列预测问题。一方面,所提议算法并入了已熟悉的方式。另一方面,它摒弃了一些久负盛名的方法。在算法中包含或排除的每个单独模块,都要伴随着一连串测试。这些测试从模型有效性的视角证明了所做决策的可行性。

为了解决分布乖离问题,该方法的作者运用了对称结构的可逆归一化(RevIN),其在上一篇文章中已讨论过。RevIN 首先用来从原始数据中删除有关时间序列的统计信息。在模型处理数据、并生成预测值之后,原始时间序列的统计信息将在预测中恢复,这通常可以提高模型训练的稳定性,及时间序列预测值的品质。

为了能够在进一步分析中根据变量项,而非时间步长,该方法的作者提议转置初始数据。

变量项的关注度(作者的可视化)

以这种方式筹备的数据被馈送到变换器编码器,其由多头自关注度(MHA)前馈(FFN)模块的若干层组成。

请注意,i输入被馈送到编码器,绕过通常存在嵌入层。该方法作者所做的测试表明其作用无效,因为额外的数据级别转换会令时间信息失真,并导致模型性能下降。此外,由于不同变量之间没有时间序列,因此位置编码模块被移除。

在编码器中提取特征之后,时间序列被传递到预测层,其为每个变量生成预测值。

提议的预测层取代了经典的变换器解码器。在他们的论文中,客户端的作者发现,添加解码器会导致模型的整体性能下降。

与关注度模块并行,客户端模型包括并集成了线性模块,用于研究有关独立通道和单个变量中时间序列趋势的信息。

关注度模块和线性模块的预测值相加,同时考虑到应用于线性模块结果的可学习权重。

在模型的输出端,结果再次转置,令它们与原始数据的顺序排列。时间序列的统计信息被恢复。

因此,客户端方法使用线性模块来收集趋势信息,以及高级的变换器模块来收集非线性信息、及变量之间的依赖关系。作者对该方法的可视化如下表示。

作者对客户端方法的可视化


2. 利用 MQL5 实现

在研究了客户端方法的理论层面之后,我们转到本文的实践部分,其中我们利用 MQL5 来实现我们所提议方式的愿景。

2.1创建一个新神经层

首先,我们来创建一个新类 CNeuronClientOCL,其将结合大多数提议方法。就像我们之前创建的大多数类一样,我们创建的这个类将继承自我们的基础神经层类 CNeuronBaseOCL

class CNeuronClientOCL  :  public CNeuronBaseOCL
  {
protected:
   //--- Attention
   CNeuronMLMHAttentionOCL cTransformerEncoder;
   CNeuronConvOCL    cProjection;
   //--- Linear model
   CNeuronConvOCL    cLinearModel[];
   //---
   CNeuronBaseOCL    cInput;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer);

public:
                     CNeuronClientOCL(void) {};
                    ~CNeuronClientOCL(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key, uint heads,
                          uint at_layers, uint count, uint &mlp[],
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const   {  return defNeuronClientOCL;   }
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual void      SetOpenCL(COpenCLMy *obj);
   virtual void      TrainMode(bool flag);
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
  };

关注度模块将据 2 个对象创建:

  • cTransformerEncoderCNeuronMLMHAttentionOCL 类的对象,允许从给定数量的连续层创建多头变换器的编码器模块。
  • cProjection — 预测层。此处,我们用卷积层依据单个变量进行独立预测。预测深度决定了层中的过滤器数量。

为了创建一个线性模块,我们将创建一个卷积层的动态数组 cLinearModel[],这就允许我们为各个变量生成独立的预测。

注意,在该实现中,我决定将可逆归一化和数据转置层移到类之外。这是因为客户端模块可以集成到更复杂的架构当中。因此,可远离该模块删除和恢复统计信息。

此外,还可以在远离客户端模块的位置执行数据转置。此外,在某些情况下,可能会在数据筹备阶段创建所需的源数据序列。

我们的新类有一套非常标准的方法集。

所有内部对象我们都声明为静态,这允许我们将类构造函数和析构函数留空。以这种方式,我们可以减少对内存清理问题的关注,将此功能委托给系统。

所有内部对象的初始化都在 Init 方法中执行。在该方法的参数中,我们把所有必要信息传递给类对象,以便规划所需的架构。

此处应当注意的是,在类主体中,我们创建了 2 个并行流:

  • 变换器模块
  • 线性模组

这两个模块都具有复杂、且截然不同的独立架构,尽管它们都配合相同的数据集操作。因此,我们需要一种机制,将这两个模块传递到架构对象之中。对于变换器模块,我们将用到之前开发的 5 变量方式:

  • window:序列的 1 个元素的向量大小
  • window_key:序列的 1 个元素的内部表述的向量大小
  • heads:关注度头的数量
  • count:序列中的元素数量
  • at_layers:编码器模块中的层数

为了描述线性模块的架构,我们用到一个数值数组 mlp[]。数组中的元素数量指示要创建的层数。每个元素的数值代表在层输出端序列的描述一个元素的向量大小。线性模块操作时配以关注度模块相同的数据集。因此,序列中的元素数量相同。

请注意,客户端方法的作者提议分析变量之间的依赖关系。因此,在这种情况下,描述序列 1 个元素的向量大小将等于所分析历史的深度。序列中的元素数量等于正在分析的变量的数量。在将输入数据馈送到我们的新 CNeuronClientOCL 类对象之前,必须相应地转置输入数据。

以这种方式,我们将在 mlp[] 数组的最后一个元素中指示数据预测的深度。

这就是数据传输逻辑。我们在代码中实现提议的方式。在 Init 方法参数中,我们指定上面表述的变量,并用基类的元素补充它们。

bool CNeuronClientOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                            uint window, uint window_key, uint heads,
                            uint at_layers, uint count, uint &mlp[],
                            ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   uint mlp_layers = mlp.Size();
   if(mlp_layers == 0)
      return false;

在方法体中,我们首先检查线性模块架构描述数组 mlp[] 的大小。它必须至少包含一个元素,指示数据预测的深度。如果数组为空,则我们以 false 结果终止方法。

在下一步中,我们初始化类对象。首先,我们修改线性模块的动态数组。

   if(ArrayResize(cLinearModel, mlp_layers + 1) != (mlp_layers + 1))
      return false;

请注意,数组大小必须大于所生成线性层架构 1 个元素。我们稍后会讨论该步骤的原因。

接下来,我们调用父类的相同方法,于其内初始化所有继承的对象。

   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, mlp[mlp_layers - 1] * count, optimization_type, batch))
      return false;

之后,我们调用变换器编码器初始化方法。

   if(!cTransformerEncoder.Init(0, 0, OpenCL, window, window_key, heads, count, at_layers, optimization, iBatch))
      return false;

临时存储输入数据的辅助层。

   if(!cInput.Init(0, 1, open_cl, window * count, optimization_type, batch))
      return false;

下一步是创建一个循环,我们在其中初始化线性模块的层。

   uint w = window;
   for(uint i = 0; i < mlp_layers; i++)
     {
      if(!cLinearModel[i].Init(0, i + 2, OpenCL, w, w, mlp[i], count, optimization, iBatch))
         return false;
      cLinearModel[i].SetActivationFunction(LReLU);
      w = mlp[i];
     }

于此应当记住,客户端方法的作者提议将学习系数应用于线性模块的结果。他们发现了一种相当不寻常的方法来创建可学习的乘数。我决定将它们替换为卷积层,其过滤器数量、窗口大小、和卷积步幅等于 1。我们将其添加到线性模块数组的最后一个元素(我们之前添加的)之中。

   if(!cLinearModel[mlp_layers].Init(0, mlp_layers + 2, OpenCL, 1, 1, 1, w * count, optimization, iBatch))
      return false;

这里还有一件事。在对输入数据进行归一化的过程中,我们将它们转换为等于 “0” 的平均值和方差 “1”。因此,预测值也应与该分布相对应。为了约束预测值,我们使用双曲正切(tanh)作为激活函数。

以类似的方式,我们启动关注度模块的预测层。

   cLinearModel[mlp_layers].SetActivationFunction(TANH);
   if(!cProjection.Init(0, mlp_layers + 3, OpenCL, window, window, w, count, optimization, iBatch))
      return false;
   cProjection.SetActivationFunction(TANH);

如您所见,两个输出数据预测模块都由双曲正切激活。为了确保误差梯度的正确传送,我们为整个层指定了类似的激活函数。

   SetActivationFunction(TANH);

由于我们计划简单地将两个模块的数值相加,然后在逆验算期间,我们可跨两个模块之间完全分派误差梯度。为了消除不必要的数据复制操作,我们将在内部层中替换存储误差梯度的数据缓冲区。

   if(!SetGradient(cProjection.getGradient()))
      return false;
   if(!cLinearModel[mlp_layers].SetGradient(Gradient))
      return false;
//---
   return true;
  }

不要忘记控制每个阶段的操作。所有嵌套对象初始化成功之后,我们将操作的逻辑结果返回给调用方。

初始化嵌套类对象之后,我们继续在 CNeuronClientOCL::feedForward 方法中组织前馈验算算法。我们讨论了初始化对象时数据传送的基本原则。现在,我们看看所提议方式的实现。

在参数中,该方法接收指向前一个神经层对象的指针。在方法的主体中,我们立即调用多层关注度模块的前馈方法。

bool CNeuronClientOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cTransformerEncoder.FeedForward(NeuronOCL))
      return false;

之后,我们预测值是在所需的规划深度上进行预测。

   if(!cProjection.FeedForward(GetPointer(cTransformerEncoder)))
      return false;

为避免将整个输入数据的体量复制到内层,我们仅复制相应数据缓冲区的指针。

   if(cInput.getOutputIndex() != NeuronOCL.getOutputIndex())
      cInput.getOutput().BufferSet(NeuronOCL.getOutputIndex());

我们为线性模块规划了一个前馈验算循环。

   uint total = cLinearModel.Size();
   CNeuronBaseOCL *neuron = NeuronOCL;
   for(uint i = 0; i < total; i++)
     {
      if(!cLinearModel[i].FeedForward(neuron))
         return false;
      neuron = GetPointer(cLinearModel[i]);
     }

在这个阶段,两个模块的预测值我们都已完成预测。线性模块预测已针对训练系数进行了调整。现在我们只需要对来自两个线程的数据进行求和。

   if(!SumAndNormilize(neuron.getOutput(), cProjection.getOutput(), Output, 1, false, 0, 0, 0, 
0.5 ))
      return false;
//---
   return true;
  }

与此类似,但顺序相反,根据嵌套对象对最终结果的影响,我们实现遍历前一层的嵌套对象传播误差梯度。这是在 CNeuronClientOCL::calcInputGradients 方法中完成的。

由于我们用的是数据缓冲区替身,因此来自下一层的误差梯度被直接写入两个模块的对象缓冲区之中。因此,我们省略了在变换器和线性模块之间分派误差梯度的不必要操作。我们立即转到遍历指定模块分派误差梯度。首先,我们经由关注度模块验算误差梯度。

bool CNeuronClientOCL::calcInputGradients(CNeuronBaseOCL *prevLayer)
  {
   if(!cTransformerEncoder.calcHiddenGradients(cProjection.AsObject()))
      return false;
   if(!prevLayer.calcHiddenGradients(cTransformerEncoder.AsObject()))
      return false;

然后我们在反向传播循环中经由线性模块验算它。

   CNeuronBaseOCL *neuron = NULL;
   int total = (int)cLinearModel.Size() - 1;
   for(int i = total; i >= 0; i--)
     {
      neuron = (i > 0 ? cLinearModel[i - 1] : cInput).AsObject();
      if(!neuron.calcHiddenGradients(cLinearModel[i].AsObject()))
         return false;
     }

注意,变换器将误差梯度写入前一层的缓冲区。线性模型将其写入内层的缓冲区。

在该方法结束之前,我们添加两个流的误差梯度。

   if(!SumAndNormilize(neuron.getGradient(), prevLayer.getGradient(), prevLayer.getGradient(), 1, false))
      return false;
//---
   return true;
  }

该类的其它方法均以大致相同的方式构造。我们逐一调用内部对象的相关方法。在本文的框架内,我们不再赘述它们的算法。我建议您自行熟悉一下它们。您可在附件中找到该类及其所有方法的完整代码。附件还包含本文中用到的所有程序的完整代码。

2.2模型架构

我们创建了一个新类 CNeuronClientOCL,它实现了客户端方法作者所提议方法的主要部分。不过,该方法的一些需求需要直接在模型架构中实现。

提出客户端方法来解决时间序列预测问题。我们将在编码器中用到它。

在我们的模型结构中,编码器用于准备环境状态的浓缩表示。扮演者模型会用到该表示形式,基于学到的行为政策按给定状态生成最优动作。显然,为了学习最可能的行为政策,我们需要一个正确、且信息量丰富的环境状态浓缩表示。

“正确、且信息量丰富的环境状态浓缩表示”的概念听起来相当抽象和模糊。合乎逻辑地假设,由于我们正在训练扮演者政策,以便在即将到来的最可能价格走势条件下执行最优动作,以便产生最大可能的盈利,而浓缩应代表包含有关即将到来的最可能价格走势的最大可能信息。此外,我们需要评估风险,以及价格往对立方向移动的可能性。我们还需要评估这种走势的可能量级。在这样的范式中,训练编码器来预测未来价格走势看似是合适的。然后编码器的隐藏状态将包含有关即将到来价格走势的最大可能信息。因此,在编码器架构中,我们使用客户端方法。

编码器架构在 CreateEncoderDescriptions 方法中呈现。在参数中,该方法接收指向一个动态数组的指针,其中将把模型架构保存在该数组之中。

bool CreateEncoderDescriptions(CArrayObj *encoder)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }

在方法主体中,我们检查收到的指针,并在必要时创建动态数组对象的新实例。

如常,我们为模型投喂环境状态的“原始”描述。为了记录原始数据,我们创建了一个神经网络的基础层,其大小足够接受原始数据。

//--- Encoder
   encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

此处,如前,层大小由两个常数的乘积决定:

  • HistoryBars — 环境状态需分析的历史深度(柱线)
  • BarDescr — 环境状态描述一根柱线的向量大小

但有一件事。以前,在每次迭代中,我们只向模型投喂有关价格走势中最后一根收盘柱线的信息。所分析历史的所有必要深度。都以嵌入的形式积累在我们模型内层的堆栈当中。现在,客户端方法的作者假设额外的嵌入层会扭曲时间序列信息,因此他们建议剔除它。因此,我们扩展了模型的输入数据层,为其提供涵盖所分析历史的整个深度的数据。

如此这般,我们将 HistoryBars 常量的数值提升到 120。这令您可据 H1 时间帧上分析上周的历史数据。

#define        HistoryBars             120           //Depth of history

如前,下一层是批量归一化层,其中通过从时间序列中删除统计信息,将输入数据转换为可比较的形式。

//--- 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(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

我们记住该层的标识符。因为在模型的输出端,我们必须将时间序列的统计信息返回到预测值。

在准备输入数据时,我们可由单个指标的数据序列来形成它(客户端方法上下文中的变量)。另有选项,我们可按时间步长(柱线)描述的序列形式提供它,就像以前所做的那样。出于本文目的,我决定不去改变输入数据准备模块。因此,我们可用以前创建的环境交互 EA,只需最少修改。

但这样的实现需要安装一个数据转置层,我们将在下一步中添加它。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   prev_count = descr.count = HistoryBars;
   int prev_wout = descr.window = BarDescr;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

在转置层之后,我们添加新层的实例 — 客户端 模块。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronClientOCL;
   descr.count = prev_wout;
   descr.window = prev_count;
   descr.step = 4;
   descr.window_out = EmbeddingSize;
   descr.layers = 5;
     {
      int temp[] = {1024, 1024, 1024, NForecast};
      ArrayCopy(descr.windows, temp);
     }
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

此处,对于正在分析的序列大小,我们指定欲分析的变量的数量(BarDescr 常数)。描述序列中一个元素的向量大小等于我们正在分析的历史记录的深度(HistoryBars 常数)。在变换器模块中,我们使用 4 个关注度头,并创建 5 个这样的层。

我们将创建一个 4 层线性模块:3 个大小为 1024 的隐藏层,而最后一层等于规划层位(NForecast 常数)。

接下来,我们执行数据的逆转置。

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   prev_count = descr.count = BarDescr;
   prev_wout = descr.window = NForecast;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

我们在它们中回复统计信息。

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronRevInDenormOCL;
   prev_count = descr.count = prev_count * prev_wout;
   descr.activation = None;
   descr.optimization = ADAM;
   descr.layers = 1;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

我应当说几句关于扮演者的架构。它几乎完全是从上一篇文章复制而来的。不过,它有一个细节,稍后将解释。 

扮演者和评论者模型的架构在 CreateDescriptions 方法中呈现。在方法参数中,我们接收指向 2 个动态数组的指针,用于记录模型架构。

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();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = 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 = defNeuronBaseOCL;
   prev_count = descr.count = EmbeddingSize;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

添加 3 个连续的交叉关注度层,其中我们分析帐户的当前状态、与编码器形成的环境未来状态的浓缩表示之间的依赖关系。

//--- layer 2-4
   for(int i = 0; i < 3; i++)
     {
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronCrossAttenOCL;
        {
         int temp[] = {1, BarDescr};
         ArrayCopy(descr.units, temp);
        }
        {
         int temp[] = {EmbeddingSize, NForecast};
         ArrayCopy(descr.windows, temp);
        }
      descr.window_out = 16;
      descr.step = 4;
      descr.activation = None;
      descr.optimization = ADAM;
      if(!actor.Add(descr))
        {
         delete descr;
         return false;
        }
     }

根据客户端方法的思路,对于交叉分析,我们所用数据来自重新转置数据之前编码器隐藏状态。这允许我们分析当前账户状态、与单个变量的预测值的依赖关系。这反映在 desc.unitsdescr.windows 数组的新值中。

接下来,如前,是往扮演者政策里添加随机性的决策模块。

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NActions;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

类似的更改影响了评论者模型。如您所忆,扮演者和评论者模型具有相似的架构。区别在于,评论者模型的输入是一个动作向量,替代了账户状态描述。在模型的输出中,动作向量由奖励向量替换。您可从附件中找到用到的所有模型架构解决方案的完整描述。附件还包含本文中用到的所有程序的完整代码。

此外,我们还更改了指向编码器隐藏层的常量指针的值,以便提取数据。

#define        LatentLayer             3

由于我们已经做了大量工作来协调模型的架构解决方案,并更改用到的常量,因此我们可用以前创建的 EA 与环境交互,而无需更改。我们只需要重新编译它们,同时考虑到更改的常量和模型架构。不过,这并不是指模型训练 EA。

2.3预测模型训练 EA

预测环境条件的模型在 “...\Experts\Client\StudyEncoder.mq5” EA 中训练。一般来说,EA 的结构是从以前的作品中借来的。我们不会详细研究它的所有方法。我们只研究模型训练阶段在 Train 方法中的所作所为。

void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);
//---
   vector<float> result, target;
   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;
        }

在循环的主体中,我们从经验回放缓冲区中对轨迹、及其上的环境状态进行采样。

我们从经验回放缓存区中提取环境采样状态的描述,并将得到的数值传送到数据缓冲区。

      bState.AssignArray(Buffer[tr].States[i].state);

该信息足以运行编码器的前馈验算。

      //--- State Encoder
      if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

接下来,我们需要准备一个目标值的向量。在这项工作的上下文中,正规划的范围远小于所分析历史的深度。这极大简化了我们准备目标值的任务。我们只需从经验回放缓存区中提取环境状态的描述,并含有正规划范围的缩进。我们还从所需体量的张量中获取第一个元素。

      //--- Collect target data
      if(!bState.AssignArray(Buffer[tr].States[i + NForecast].state))
         continue;
      if(!bState.Resize(BarDescr * NForecast))
         continue;

如果您所用的规划范围大于所分析历史记录的深度,那么收集目标值就需要创建涵盖经验回放缓冲区中状态的循环,以便规划范围。

目标值张量准备好之后,我们执行编码器反向传播验算,以便优化训练模型的参数,从而最大限度地减少数据预测误差。

      if(!Encoder.backProp(GetPointer(bState), (CBufferFloat*)NULL))
        {
         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", "Encoder", percent, 
                                                                       Encoder.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }

确保在过程的每一步都控制执行操作。模型训练的所有迭代成功完成后,我们清除图表上的注释字段。

   Comment("");
//---
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Encoder", Encoder.getRecentAverageError());
   ExpertRemove();
//---
  }

在日志中显示模型训练结果。启动 EA 终止。

2.4扮演者政策训练 EA

还对扮演者政策训练 EA “...\Experts\Client\Study.mq5” 进行了一些编辑。再次,我们只专注于模型训练方法。

void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);
//---
   vector<float> result, target;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

在方法的主体中,我们首先生成轨迹选择概率的向量,以及其它准备工作。在这部分,您能看到这是之前 EA 算法的精确重复。

接下来,我们还规划了一个模型训练循环,在该循环中,我们从经验回放缓冲区中抽取轨迹、及其环境状态。

我们加载选定的环境状态描述,并执行编码器的前馈验算。

      bState.AssignArray(Buffer[tr].States[i].state);
      //--- State Encoder
      if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

如此就完成了对前一个 EA 算法的“复制”。在生成环境的浓缩表述后,我们首先优化评论者参数。于此,我们首先加载扮演者在给定状态下与环境交互时执行的动作,并执行评论者前馈验算。

      //--- Critic
      bActions.AssignArray(Buffer[tr].States[i].action);
      if(bActions.GetIndex() >= 0)
         bActions.BufferWrite();
      if(!Critic.feedForward((CBufferFloat*)GetPointer(bActions), 1, false, GetPointer(Encoder), LatentLayer))
        {
         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);

 我们优化评论者的参数,是为了最大限度地减少评估扮演者动作的错误。

      Critic.TrainMode(true);
      if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

接下来是两步扮演者政策训练模块。此处我们首先提取所选环境状态对应的账户状态描述,并将其传送到数据缓冲区。

      //--- Policy
      float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
      float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
      bAccount.Clear();
      bAccount.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance);
      bAccount.Add(Buffer[tr].States[i].account[1] / PrevBalance);
      bAccount.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity);
      bAccount.Add(Buffer[tr].States[i].account[2]);
      bAccount.Add(Buffer[tr].States[i].account[3]);
      bAccount.Add(Buffer[tr].States[i].account[4] / PrevBalance);
      bAccount.Add(Buffer[tr].States[i].account[5] / PrevBalance);
      bAccount.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');
      bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = time / (double)PeriodSeconds(PERIOD_MN1);
      bAccount.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
      x = time / (double)PeriodSeconds(PERIOD_W1);
      bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = time / (double)PeriodSeconds(PERIOD_D1);
      bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      if(bAccount.GetIndex() >= 0)
         bAccount.BufferWrite();

然后,我们执行扮演者的前馈验算,以便生成操作向量。

      //--- Actor
      if(!Actor.feedForward((CBufferFloat*)GetPointer(bAccount), 1, false, GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

如上所述,扮演者的政策在 2 个步骤中训练。我们首先调整扮演者的政策,令其操作保持在训练集的分布范围内。为此,我们将扮演者生成的动作向量,与经验回放缓冲区中的实际操作之间的误差最小化。

      if(!Actor.backProp(GetPointer(bActions), GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

在第二步中,我们根据所生成动作的评论者评估,来调整扮演者的政策。于此,我们首先需要评估动作。

      if(!Critic.feedForward((CNet *)GetPointer(Actor), -1, (CNet*)GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

然后我们关闭评论者学习模式,并通过它传播动作评估与给定状态下实际可能情况的偏差梯度。

      Critic.TrainMode(false);
      if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder), LatentLayer) ||
         !Actor.backPropGradient((CNet *)GetPointer(Encoder), LatentLayer, -1, true))
        {
         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 终止。

同样,您可以在附件中找到所有程序的完整代码。


3. 测试

在本文中,我们讨论了多元时间序列预测客户端方法,并利用 MQL5 实现了我们对所提议方法的愿景。现在我们进入工作的最后阶段 — 测试结果。在该阶段,我们将采用 EURUSD 金融产品、2023 年、H1 时间帧的真实历史数据训练模型。之后,我们将采用 2024 年 1 月的历史数据在 MetaTrader 5 策略测试器中测试已训练模型的结果,且采用我们训练模型时的相同品种、和时间帧。

注意,消除嵌入层,并提升描述环境 1 种状态的柱线数,令我们无法复用上一篇文章中的训练数据集。因此,我们必须收集一个新的数据集。反正这个过程完全重复了上一篇文章中描述的算法,如此我现在就不提供细节了。

收集初始训练数据之后,我们首先训练时间序列预测模型。此处遇到第一个令人不快的惊讶:预测的品质竟然相当低下。或许是输入数据中的大量噪声、以及模型对时间序列细节的关注提升,令结果恶化。

但我们不会放弃,并继续实验。我们看看扮演者模型是否可以适应这样的预测。我们执行若干次迭代来训练扮演者,并更新训练数据集。但一言难尽。我们无法得到一个有能力在训练中产生盈利的模型,显然是测试数据集。余额线向下移动。盈利因子值约为 0.5。

大概,只针对我们的实现这是个典型结果。但事实仍然存在。所实现模型无法在高度随机的环境中提供期待的时间序列预测品质。


结束语

在本文中,我们讨论了一种相当有趣、且复杂的算法,称为客户端,它结合了研究线性趋势的线性模型、和分析各个变量之间依赖关系的变换器模型,以便研究非线性信息。该方法的作者从他们的模型中排除了由时间分离的环境独立状态之间的关注度。提议的改进变换器模型还简化了嵌入和位置编码级别。解码器模块被预测层取代,据该方法的作者称,它显著提高了预测效率。进而,引用论文中提出的实验结果证明,对于时间序列预测任务,分析变换器中变量之间依赖关系,比分析由时间分离的环境独立状态之间的依赖关系更重要。

然而,我们的工作结果表明,所提议方法在金融市场的高度随机条件下无效。

请注意,本文展示了我们独立实现所提议方法的测试结果。因此,获得的结果可能仅与该实现相关。在其它条件下,可能会获得完全相反的结果。

本文的目的只是让读者熟悉客户端方法,并演示实现所提议方法的选项之一。我们无意评估作者提议的算法。我们仅是尝试应用提议方法来解决我们的问题。


参考

  • 客户:用于多变量长期时间序列预测的交叉变量线性集成增强型转换器
  • 本系列的其它文章

  • 文中所用程序

    # 名称 类型 说明
    1 Research.mq5 EA 样本收集 EA
    2 ResearchRealORL.mq5
    EA
    运用 Real-ORL 方法收集示例的 EA
    3 Study.mq5  EA 模型训练 EA
    4 StudyEncoder.mq5 EA
    编码训练 EA
    5 Test.mq5 EA 模型测试 EA
    6 Trajectory.mqh 类库 系统状态定义结构
    7 NeuroNet.mqh 类库 创建神经网络的类库
    8 NeuroNet.cl 代码库 OpenCL 程序代码库

    本文由MetaQuotes Ltd译自俄文
    原文地址: https://www.mql5.com/ru/articles/14721

    附加的文件 |
    MQL5.zip (1106.26 KB)
    最近评论 | 前往讨论 (1)
    Zhi Jin
    Zhi Jin | 9 1月 2025 在 02:17
    非常棒的思路
    神经网络实践:伪逆(I) 神经网络实践:伪逆(I)
    今天,我们将开始探讨如何在纯MQL5语言中实现伪逆的计算。即将展示的代码对于初学者来说可能比我预期的要复杂得多,我还在思考如何以简单的方式解释它。所以,现在请将其视为学习一些不寻常代码的机会。请保持冷静和专注。虽然它并不旨在高效或快速应用,但其目标是尽可能具有教育意义。
    您应当知道的 MQL5 向导技术(第 15 部分):协同牛顿多项式的支持向量机 您应当知道的 MQL5 向导技术(第 15 部分):协同牛顿多项式的支持向量机
    支持向量机基于预定义的类,按探索增加数据维度的效果进行数据分类。这是一种监督学习方法,鉴于其与多维数据打交道的潜力,它相当复杂。至于本文,我们会研究进行价格行为分类时,如何运用牛顿多项式更有效地做到非常基本的 2-维数据实现。
    让新闻交易轻松上手(第二部分):风险管理 让新闻交易轻松上手(第二部分):风险管理
    在本文,我们将把继承引入到我们之前的代码和新代码中。我们将引入一种新的数据库设计以提高效率。此外,还将创建一个风险管理类来处理容量计算。
    神经网络变得简单(第 84 部分):可逆归一化(RevIN) 神经网络变得简单(第 84 部分):可逆归一化(RevIN)
    我们已经知晓,输入数据的预处理对于模型训练的稳定性扮演重要角色。为了在线处理 “原始” 输入数据,我们往往会用到批量归一化层。但有时我们需要一个逆过程。在本文中,我们将讨论解决该问题的可能方式之一。