English Русский Español Deutsch 日本語 Português
preview
神经网络变得简单(第 84 部分):可逆归一化(RevIN)

神经网络变得简单(第 84 部分):可逆归一化(RevIN)

MetaTrader 5交易系统 | 6 一月 2025, 14:53
263 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

在上一篇文章中,我们讨论了构象方法,其初衷是为天气预报而开发的。这是一个非常有趣的方法。在测试已训练模型时,我们得到了一个相当不错的结果。但我们所做的每件事情都正确吗?是否可能获得更好的结果?我们来看看学习过程。很容易看出,我们显然并未使用该模型来预测后续最有可能的时间序列数值,来实现其预期目的。把时间序列投喂模型输入数据,我们用预测结果降低模型的传播误差梯度来训练它。我们从评论者的结果开始。

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

然后是扮演者的结果。

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

当为了运营盈利能力而调整其政策时,数据再次取自扮演者。

         Critic.TrainMode(false);
         if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder)) ||
            !Actor.backPropGradient((CNet *)GetPointer(Encoder), -1, -1, false) ||
            !Encoder.backPropGradient((CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

当然,这并无错误。它在训练各种模型实践中广泛运用。然而,在这种情况下,在训练初始环境状态的编码器模型时,我们关注的不是预测后续状态,而是识别允许我们优化后续模型操作的单独特征。

当然,我们的主要任务是找到扮演者的最优政策。如此这般,初看,令编码器适配扮演者的目标并无错误。但在这种情况下,编码器解决的问题略有不同。在实践中,它变成后续模型的模块。它的架构也许并非解决所需任务的最优选择。

进而,当用 3 个不同任务的误差梯度训练编码器时,我们也许会遇到单独任务的梯度方向不同的问题。在这种情况下,模型将寻找最能满足所有任务集的 “中庸之道”。这样的解决方案或许远非最优。

我认为这很明显,使用模型的结构化逻辑也应当在学习过程中实现。在这样的范式中,我们需要训练编码器来预测环境的后续状态。构象方式已正式运用在编码器之中。然后,我们训练扮演者的政策,同时考虑所预测的环境状态。

理论如此,非常清晰。然而,在实际实现中,我们在描述环境状态各个特征的分布时面临着巨大的差距。在模型输入处接收描述环境状态的“原始”数据后,我们将其归一化,将其带至一种可比较的形式。但我们如何在模型输出处得到不同的数值呢?

当训练各种自动编码器模型时,我们就遇到了类似的问题。在这些情况下,我们找到了一种解决方案,即采用归一化后的原始数据作为目标。不过,在这种情况下,我们需要不同于输入数据的,描述环境后续状态的数据。论文《可逆实例归一化抵消精确时间序列预测中的分布移位》中提出了解决此问题的方法之一。

论文作者提议一种简单而有效的归一化和逆归一化方法:可逆实例归一化(RevIN)。该算法首先归一化输入序列,然后在模型的输出序列进行逆归一化,以便解决时间序列预测当中的分布移位相关问题。RevIN 是对称结构,按归一化层中输入数据的伸缩和移位相等量值,在逆归一化层中缩放和移位输出,从而将原始分布信息返回到模型输出。

RevIN 是一个灵活的、可训练的层,能够应用于所有任意选择的层,有效地抑制一层中的非稳态信息(实例的平均值和方差),并在近乎对称位置另一层将其恢复,例如输入和输出层。 


1. RevIN 算法

为了领略 RevIN 算法,我们来研究离散时间内,时间序列的多元预测问题,针对一组输入数据 X = {xi}i=[1..N],和相应的目标 Y = {yi}i=[1..N],其中 N 表示序列中的元素数量。

设 K、Tx 和 Ty 分别表示变量的数量、输入序列的长度、和模型预测的长度。给定输入序列 Xi∈ RK*Tx,我们的意向是解决时间序列预测问题,即预测后续值 Yi∈ RK*Ty。在 RevIN 中,输入序列 Tx 的长度、和预测长度 Ty 也许会有所不同,因为观测值沿时间维度进行了归一化和逆归一化。所提议方法 RevIN,由对称结构的归一化和逆归一化层组成。首先,我们用其均值和标准差归一化输入 Xi,作为即时归一化,这已被广泛接受。每个输入实例 Xi 的均值和标准差计算如下:

归一化序列可以具有更一致的均值和标准差,其中非稳态信息会减少。结果就是,归一化层允许模型准确预测序列内的局部动态,同时接收以均值和偏差形式的一致分布输入。

该模型接收转换后的数据作为输入,并预测未来值。不过,与原始分布相比,输入数据的统计数据不同,并且仅通过观察归一化输入,很难捕捉到输入数据的原始分布。因此,为了令这项任务更容易应对模型,我们通过在对称位置(输出层)逆转归一化,把已从输入数据中删除的非稳态特征显式返回到模型输出。逆非归一化步骤能够把模型输出返回到原始时间序列值。相应地,我们通过应用逆归一化操作对模型输出进行逆归一化:

归一化步骤中用到的相同统计数据,也用于缩放和移位。现在 ŷi 是模型的最终预测。

简单地加到网络中差不多对称的位置,RevIN 就可以有效降低分布偏差,在时间序列数据中作为可训练的归一化层,通常可适用于任意深度的神经网络。事实上,所提议方法是一个灵活的、可学习的层,能够应用于所有任意选择的层,甚至多个层。该方法的作者将其添加到各种模型的中间层,以此来确认其作为柔性层的有效性。无论如何,RevIN 在应用于编码器-解码器结构的差不多对称层时最有效。在典型的时间序列预测模型中,编码器和解码器之间的界限往往不明确。因此,该方法的作者将 RevIN 应用到模型的输入和输出层,因为它们可以被视为编码器-解码器结构,即基于输入数据生成后续数值。

RevIN 方法的原始可视化表述如下。


2. 利用 MQL5 实现

我们已研究过该方法的理论层面。现在我们可以转到利用 MQL5 实际实现所提议的方式。

从上面讲述的方法的理论中,您可以看到,由该方法的作者提议的初始数据归一化完全重复了我们之前实现的 CNeuronBatchNormOCL 批量归一化层的算法。因此,我们能用现有的类来归一化数据。但为了逆归一化数据,我们需要创建一个新的神经层 CNeuronRevINDenormOCL

2.1创建新的逆归一化层

显然,数据逆归一化过程将使用数据归一化所用的对象。这就是为什么新的 CNeuronRevINDenormOCL 层是从 CNeuronBatchNormOCL 归一化层派生而来的。

class CNeuronRevINDenormOCL   :  public CNeuronBatchNormOCL
  {
protected:
   int               iBatchNormLayer;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)   {  return true;   }

public:
                     CNeuronRevINDenormOCL(void)   :  iBatchNormLayer(-1) {};
                    ~CNeuronRevINDenormOCL(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, int NormLayer, 
                          CNeuronBatchNormOCL *normLayer);
   virtual int       GetNormLayer(void)   {  return iBatchNormLayer; }
   //---
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual int       Type(void)        const                      {  return defNeuronRevInDenormOCL;    }
   virtual CLayerDescription* GetLayerInfo(void);
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau)   {  return true;   }
  };

根据 RevIN 方法算法,我们应该复用归一化阶段训练的参数来进行逆归一化。此处逻辑是,在归一化阶段,我们研究输入数据的分布。之后,我们将输入数据转换为可比较的形式,消除“间隙”。然后,模型搭配归一化数据操作。在模型的输出中,我们对数据进行逆归一化,返回输入数据的分布参数。因此,我们期待模型输出包含基于输入数据“自然”分布的预测数据。

显然,在逆归一化步骤中,模型参数不会更新。因此,在类结构中,我们以“空存根”覆盖更新模型参数的方法。无论如何,我们将不得不实现前馈验算算法,和误差梯度分布。但首事先行。

在这个类中,我们没有声明任何其它内部对象。因此,类构造函数和析构函数保持为空。不过,我们在模型中创建一个变量来存储归一化层标识符:iBatchNormLayer。于此,我们还创建了一个公开方法来获取该变量的值:GetNormLayer(void)

我们新类的对象在 CNeuronRevINDenormOCL::Init 方法中初始化。在参数中,为了成功初始化内部对象和变量,该方法接收所需的所有信息。此处应提到的是,与之前研究的类似神经层方法存在非常显着的差异。在方法的参数中,除了常量之外,我们还将传递一个指向 CNeuronBatchNormOCL 批量归一化层对象的指针。

bool CNeuronRevINDenormOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                                 int NormLayer, CNeuronBatchNormOCL *normLayer)
  {
   if(NormLayer > 0)
     {
      if(!normLayer)
         return false;
      if(normLayer.Type() != defNeuronBatchNormOCL)
         return false;
      if(BatchOptions == normLayer.BatchOptions)
         BatchOptions = NULL;
      if(!CNeuronBatchNormOCL::Init(numOutputs, myIndex, open_cl, numNeurons, normLayer.iBatchSize, normLayer.Optimization()))
         return false;
      if(!!BatchOptions)
         delete BatchOptions;
      BatchOptions = normLayer.BatchOptions;
     }

另一个根本区别在于方法的主体。此处,我们根据接收到的批量归一化层标识符创建分支算法。如果它大于 0,那么我们检查接收到的指向批量归一化层的指针。我们还检查所接收对象的类型。之后,我们调用父类的相同方法。只有在成功通过所有指定的控制点后,我们才会替换优化参数缓冲区。

请注意,我们不会复制数据。取而代之,我们完全更改了指向缓冲区对象的指针。因此,在模型训练过程中,我们将始终依据相关的归一化参数操作。

该算法的第二个分支设计用于在加载以前保存的模型的过程中,初始化空的类对象。此处我们仅调用参数最少的父类相同方法

   else
      if(!CNeuronBatchNormOCL::Init(numOutputs, myIndex, open_cl, numNeurons, 0, ADAM))
         return false;

接下来,无论选择哪种路径,我们都会保存获取的批量归一化层标识符,并结束该方法。

   iBatchNormLayer = NormLayer;
//---
   return true;
  }

2.2组织前馈验算

我们开始前馈验算算法的实现,先在 OpenCL 程序端创建 RevInFeedForward 内核。类似于批量归一化层算法的实现,我们将在一维任务空间中启动这个内核。

在内核参数中,我们将传递指向 3 个数据缓冲区的指针:源数据、归一化参数、和结果。我们还将传递 2 个常量:带有归一化批量参数的缓冲区大小、和参数优化类型。  

__kernel void RevInFeedForward(__global float *inputs, __global float *options,
                               __global float *output, int options_size,
                               int optimization)
  {
   int n = get_global_id(0);

我提醒您,归一化参数缓冲区的大小取决于所选的参数优化算法。该缓冲区具有以下结构。


在内核主体中,我们识别任务空间中的线程。我们还判定所分析数据在缓冲区的偏移量。在源数据和结果缓冲区中,偏移量等于线程标识符。优化参数缓冲区的偏移量是根据给定的缓冲区结构,和指定的参数优化方法判定的。

int shift = (n * optimization == 0 ? 7 : 9) % options_size;

此外,于此我们必须考虑到,所分析环境状态的数量可能与我们预测的深度不同。在这种情况下,我们应该保持环境和预测环境状态的结构。换言之,在预测后续状态时,要完全保留一个环境状态描述的分析参数数量和顺序。因此,为了判定归一化参数缓冲区中的偏移量,我们将基于所分析线程,和缓冲区结构,用计算出的偏移量除以归一参数缓冲区大小之后取余数。

下一步是将全局缓冲区中的数据提取到局部变量之中。

   float mean = options[shift];
   float variance = options[shift + 1];
   float k = options[shift + 3];

计算预测参数的逆归一化数值。

   float res = 0;
   if(k != 0)
      res = sqrt(variance) * (inputs[n] - options[shift + 4]) / k + mean;
   if(isnan(res))
      res = 0;

操作的结果将写入结果缓冲区的相应元素。

   output[n] = res;
  }

在 OpenCL 程序端实现数据逆归一化算法之后,我们需要从主程序实现调用所创建内核。为此,我们需要覆盖 CNeuronRevINDenormOCL::feedForward 方法。 

bool CNeuronRevINDenormOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!OpenCL || !NeuronOCL)
      return false;
//---
   PrevLayer = NeuronOCL;
//---
   if(!BatchOptions)
      iBatchSize = 0;
   if(iBatchSize <= 1)
     {
      activation = (ENUM_ACTIVATION)NeuronOCL.Activation();
      return true;
     }

如同父类的相同方法,该方法将在参数中接收指向前一层对象的指针,其中包含输入数据。

在方法的主体中,我们检查接收到的指针,并将其保存在相应的变量之中。

然后我们检查归一化批量大小。如果它不超过 “1”,则我们认为这是逆归一化,并原封不动地传递来自上一层的数据。当然,我们不会复制所有数据。我们只复制激活函数标识符。当访问结果或梯度缓冲区时,我们返回指向上一层缓冲区的指针。该功能已在父类中实现。

接下来,我们实现将内核直接放入执行队列的算法。此处我们首先定义任务空间。

   uint global_work_offset[1] = {0};
   uint global_work_size[1] = {Neurons()};

之后,我们将必要的参数传递给内核。

   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_RevInFeedForward, def_k_revffinputs, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_RevInFeedForward, def_k_revffoptions, BatchOptions.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_RevInFeedForward, def_k_revffoutput, Output.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_RevInFeedForward, def_k_revffoptions_size, (int)BatchOptions.Total()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_RevInFeedForward, def_k_revffoptimization, (int)optimization))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

将内核发送到执行队列。

   if(!OpenCL.Execute(def_k_RevInFeedForward, 1, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }
//---
   return true;
  }

不要忘记在每个步骤控制操作。

2.3误差梯度传播算法

实现前馈验算后,我们需要实现反向传播算法。如上所述,该层不包含任何可学习的参数。它采用在归一化阶段的训练参数。因此,所有参数更新方法都替换为“存根”。

不过,该层参与反向传播算法,误差梯度经由它传播到前一神经层。如前,我们首先在 OpenCL 程序端创建 RevInHiddenGradient 内核。这一次,内核参数的数量增加了。我们传递 4 个数据缓冲区指针:前一层的结果和误差梯度的缓冲区、优化参数、和当前层结果层的误差梯度。我们还传递 3 个常量:归一化参数缓冲区的大小、参数优化类型、和上一层的激活函数。

__kernel void RevInHiddenGraddient(__global float *inputs, __global float *inputs_gr,
                                   __global float *options, __global float *output_gr,
                                   int options_size,
                                   int optimization,
                                   int activation)
  {
   int n = get_global_id(0);
   int shift = (n * optimization == 0 ? 7 : 9) % options_size;

在内核主体中,我们首先识别线程,并判定数据缓冲区中的偏移。判定缓冲区偏移的算法在上面与前馈内核相关的部分中进行了讲述。

接下来,我们将全局数据缓冲区中的数据加载到局部变量之中。

   float variance = options[shift + 1];
   float inp = inputs[n];
   float k = options[shift + 3];

然后我们通过逆归一化函数的导数来调整误差梯度。这里需要注意的是,在逆归一化阶段,所有归一化参数都是常数,而函数的导数则明显简化。

我们在代码中实现所述函数。

   float res = 0;
   if(k != 0)
      res = sqrt(variance) * output_gr[n] / k;
   if(isnan(res))
      res = 0;

之后,我们由前一个神经层的激活函数的导数来调整误差梯度。

   switch(activation)
     {
      case 0:
         res = clamp(res + inp, -1.0f, 1.0f) - inp;
         res = res * (1 - pow(inp == 1 || inp == -1 ? 0.99999999f : inp, 2));
         break;
      case 1:
         res= clamp(res + inp, 0.0f, 1.0f) - inp;
         res = res * (inp == 0 || inp == 1 ? 0.00000001f : (inp * (1 - inp)));
         break;
      case 2:
         if(inp < 0)
            res *= 0.01f;
         break;
      default:
         break;
     }

将操作结果保存在前一个神经层的误差梯度缓冲区的相应元素之中。

//---
   inputs_gr[n] = res;
  }

下一步是在主程序端实现调用内核。该功能在 CNeuronRevINDenormOCL::calcInputGradients 方法中实现。将内核放入执行队列的算法与上面讲述的前馈方法的算法相同。因此,我们现在不再赘述。

还有,我们不会研究该类的辅助方法。它们的算法非常简单,因此您可以据附件自行研究。此外,附件还包含新类和以前创建方法的所有完整代码。如此这般,您可以研究本文中用到的所有程序。

2.4在更高级别的类中现场编辑

说几句有关更高级别类中进行特定编辑的方法,其是由我们的新 CNeuronRevINDenormOCL 类的特性导致的。这涉及该类对象的初始化和加载。

在描述初始化 CNeuronRevINDenormOCL 类对象的方法时,我们提到了将指针传递给数据归一化层对象的特殊性。注意,在描述模型架构时,我们没有指向该对象的指针,原因很简单 — 该对象尚未创建。我们只能指示层的序号,而这是我们从所描述模型架构中得知的。

不过,我们清晰地知道归一化层位于逆归一化层之前。甚至,它们之间可以有任意数量的神经层。这意味着,在创建逆归一化层对象时,必须已在模型中创建归一化层。我们可以访问它,但只能在模型内部访问。因为对单个神经层的访问对外部程序是封闭的。

因此,在 CNet::Create 方法中,我们创建一个单独的模块来初始化 CNeuronRevINDenormOCL 逆归一化层对象。

            case defNeuronRevInDenormOCL:
               if(desc.layers>=layers.Total())
                 {
                  delete temp;
                  return false;
                 }

于此,我们首先检查在模型中是否已创建配有指定标识符的层。

然后我们检查指定层的类型。它应该是一个批量归一化层。

               if(((CLayer *)layers.At(desc.layers)).At(0).Type()!=defNeuronBatchNormOCL)
                 {
                  delete temp;
                  return false;
                 }

仅在成功通过特定控制之后,我们才会创建一个新对象。

               revin = new CNeuronRevINDenormOCL();
               if(!revin)
                 {
                  delete temp;
                  return false;
                 }

初始化它。

               if(!revin.Init(outputs, 0, opencl, desc.count, desc.layers, 
                                  ((CLayer *)layers.At(desc.layers)).At(0)))
                 {
                  delete temp;
                  delete revin;
                  return false;
                 }

将其添加到对象数组之中。

               if(!temp.Add(revin))
                 {
                  delete temp;
                  delete revin;
                  return false;
                 }
               break;

此外,在加载之前已训练模型时也存在细微差别。如您所知,在我们的新类初始化方法中,我们根据归一化层的标识符创建了一个分支算法。这样做是为了能够加载预先训练的模型。要点是,在加载对象之前,我们需要创建一个“空白”的它。该功能在 CLayer::CreateElement 方法中执行。难处在于,在加载数据之前,我们尚不知道归一化层的标识符。这就是为什么我们指定 “-1” 作为标识符,指定 “NULL” 作为对象指针。

         case  defNeuronRevInDenormOCL:
            if(CheckPointer(OpenCL) == POINTER_INVALID)
               return false;
            revin = new CNeuronRevINDenormOCL();
            if(CheckPointer(revin) == POINTER_INVALID)
               result = false;
            if(revin.Init(iOutputs, index, OpenCL, 1, -1, NULL))
              {
               m_data[index] = revin;
               return true;
              }
            delete revin;
            break;

然后,在加载期间,所有数据都被加载到我们类的内部对象和变量之中。但此处也存在细微差别。在数据加载期间,我们会在预训练模型后保存归一化参数。但我们不需要这个。为了进一步训练和操作模型,我们需要在归一化层和逆归一化层之间同步参数。否则,输入数据的分布和预测之间就会出现落差。因此,我们转到 CNet::Load 方法,在加载下一个神经层后,我们检查其类型。

bool CNet::Load(const int file_handle)
  {
........
........
//--- read array length
   num = FileReadInteger(file_handle, INT_VALUE);
//--- read array
   if(num != 0)
     {
      for(i = 0; i < num; i++)
        {
         //--- create new element
         CLayer *Layer = new CLayer(0, file_handle, opencl);
         if(!Layer.Load(file_handle))
            break;
         if(Layer.At(0).Type() == defNeuronRevInDenormOCL)
           {
            CNeuronRevINDenormOCL *revin = Layer.At(0);
            int l = revin.GetNormLayer();
            if(!layers.At(l))
              {
               delete Layer;
               break;
              }

如果检测到 CNeuronRevINDenormOCL 逆归一化层,我们请求一个指向归一化层的指针,并检查是否加载了这样的层。

我们还检查该层的类型。

            CNeuronBaseOCL *neuron = ((CLayer *)layers.At(l)).At(0);
            if(neuron.Type() != defNeuronBatchNormOCL)
              {
               delete Layer;
               break;
              }

成功通过指定的控制之后,我们传递指向相应归一化层的指针来初始化层对象。

            if(!revin.Init(revin.getConnections(), 0, opencl, revin.Neurons(), l, neuron))
              {
               delete Layer;
               break;
              }
           }
         if(!layers.Add(Layer))
            break;
        }
     }
   FileClose(file_handle);
//--- result
   return (layers.Total() == num);
  }

然后我们遵循之前创建的算法。

您可以在附件中找到所有类、及其方法的完整代码,以及准备本文时用到的所有程序。

2.5进行训练的模型架构

我们已经利用 MQL5 实现了由 RevIN 方法作者提议的方式。现在是时候将它们包含在我们的模型架构之中了。如早前所讨论的,我们将在编码器模型中使用逆归一化,从而实现训练它、并预测环境后续状态的能力。我们将在 NForecast 常量中定义环境的预测状态数量(在我们的例子中,后续蜡烛)。

#define        NForecast               6             //Number of forecast

由于我们计划分开训练扮演者和评论者的编码器,故我们还会将编码器架构的描述移动到单独的方法 CreateEncoderDescriptions 之中。在该方法的参数中,我们仅传递一个指向动态数组的指针,以便保存所创建模型架构。此处应注意的是,我们实现的 CNeuronRevINDenormOCL 类不允许将解码器作为单独的模型分配。

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;
     }

接收到的数据在批量归一化层中进行初级处理。我们需要保存层的序号。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = MathMax(1000, GPTBars);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

归一化输入数据之后,我们创建数据嵌入,并将其添加到内部堆栈当中。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronEmbeddingOCL;
     {
      int temp[] = {4, 1, 1, 1, 2};
      ArrayCopy(descr.windows, temp);
     }
   prev_count = descr.count = GPTBars;
   int prev_wout = descr.window_out = EmbeddingSize / 2;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = prev_count * 5;
   descr.step = descr.window = prev_wout;
   prev_wout = descr.window_out = EmbeddingSize;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

然后我们添加数据的位置编码。

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

我们将准备好的数据馈送到 5 层 CNeuronConformer 模块之中。

//--- layer 5-10
   for(int i = 0; i < 5; i++)
     {
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronConformerOCL;
      descr.count = prev_count;
      descr.window = prev_wout;
      descr.step = 4;
      descr.window_out = EmbeddingSize;
      descr.layers = 5;
      if(!encoder.Add(descr))
        {
         delete descr;
         return false;
        }
     }

为了测试该方法,我们使用具有相应数量元素的全连接层作为解码器。不过,为了提高预测品质,建议使用架构更复杂的解码器。

//--- layer 11
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = NForecast*BarDescr;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

我们正在搭配归一化数据操作,并假设其偏差接近 1,其均值接近 0。因此,我们使用双曲正切(tanh)作为解码器输出的激活函数。如您所知,其值范围是从 “-1” 到 “1”。

最后,我们对预测值进行逆归一化。

//--- layer 12
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronRevInDenormOCL;
   prev_count = descr.count = prev_count;
   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;
     }

然后来到交叉关注度模块,它根据环境的预测状态分析账户的当前状态。

//--- layer 2-4
   for(int i = 0; i < 3; i++)
     {
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronCrossAttenOCL;
        {
         int temp[] = {1, NForecast};
         ArrayCopy(descr.units, temp);
        }
        {
         int temp[] = {EmbeddingSize, BarDescr};
         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;
        }
     }

在扮演者模型的末尾,有一个含有随机政策的决策制定模块。

//--- 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;
     }

评论者模型的构造方式与此类似。仅替代了描述帐户状态,评论者依据环境预测状态的上下文分析扮演者的动作。

//--- Critic
   critic.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = NActions;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!critic.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(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2-4
   for(int i = 0; i < 3; i++)
     {
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronCrossAttenOCL;
        {
         int temp[] = {1, NForecast};
         ArrayCopy(descr.units, temp);
        }
        {
         int temp[] = {EmbeddingSize, BarDescr};
         ArrayCopy(descr.windows, temp);
        }
      descr.window_out = 16;
      descr.step = 4;
      descr.activation = None;
      descr.optimization = ADAM;
      if(!critic.Add(descr))
        {
         delete descr;
         return false;
        }
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = NRewards;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

在评论者的输出中,我们得到了该个体动行的清晰、非随机的评估。

2.6模型训练程序

在描述了已训练模型的架构之后,我们转到创建程序来训练它们。为了训练编码器,我们将创建一个智能交易系统:“...\Experts\RevIN\StudyEncoder.mq5”。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 batch = GPTBars + 48;
      int state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - NForecast - batch));
      if(state <= 0)
        {
         iter--;
         continue;
        }
      Encoder.Clear();
      int end = MathMin(state + batch, Buffer[tr].Total - NForecast);

在外部循环的主体中,我们从来自经验回放缓冲区中的轨迹进行采样,及其学习开始状态。然后,我们判定训练批量的最终状态,并清除内部模型堆栈。之后,我们针对选定的历史数据段运行嵌套学习循环。

      for(int i = state; i < end && !IsStopped() && !Stop; i++)
        {
         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
         bState.Clear();
         for(int fst = 1; fst <= NForecast; fst++)
           {
            if(!bState.AddArray(Buffer[tr].States[i + fst].state))
               break;
           }

收集目标值后,我们可以运行编码器反向传播验算,从而把预测值和目标值之间的误差最小化。

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

然后,我们将训练进度通知用户,并转到下一次训练迭代。

         if(GetTickCount() - ticks > 500)
           {
            double percent = (double(i - state) / ((end - state)) + 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 终止。

训练模型来预测环境的未来状态非常实用。不过,我们的目标是训练扮演者政策。在下一步中,我们创建扮演者和评论者训练 EA “...\Experts\RevIN\Study.mq5”。智能系统是基于相同的架构构造的,故此我们只涉及特定的更改。

首先,在 EA 初始化期间,如果没有预先训练的编码器,我们会生成程序初始化不正确的错误。

int OnInit()
  {
//---
........
........
//--- load models
   float temp;
   if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true))
     {
      PrintFormat("Error of load Encoder: %d", GetLastError());
      return INIT_FAILED;
     }
........
........
//---
   return(INIT_SUCCEEDED);
  }

其次,该模型中的编码器未经过训练,因此不应保存。

void OnDeinit(const int reason)
  {
//---
   if(!(reason == REASON_INITFAILED || reason == REASON_RECOMPILE))
     {
      Actor.Save(FileName + "Act.nnw", 0, 0, 0, TimeCurrent(), true);
      Critic.Save(FileName + "Crt.nnw", 0, 0, 0, TimeCurrent(), true);
     }
   delete Result;
   delete OpenCL;
  }

此外,在使用编码器作为扮演者和评论者的输入数据源时,还有一件事。在本文的开头,我们讨论过使用归一化数据来训练和操作模型的重要性。编码器输出处的逆归一化层,另一方面,把我们的预测返回到原始数据分布,令它们不可比。

不过,我们很久以前就实现了访问模型隐藏层的功能,以便提取数据。故此,我们将使用此功能从编码器的倒数第二层获取归一化预测数据。我们将用该数据作为扮演者和评论者的初始数据。我们在 LatentLayer 常量中指定指向所需层的指针。

#define        LatentLayer             11

调用评论者的前馈如下所示:

         if(!Critic.feedForward((CBufferFloat*)GetPointer(bActions), 1, false, 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;
           }

相应地,我们编写调用扮演者前馈

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

在调用我们模型的反向传播方法时,不要忘记指定层标识符。

         if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder),LatentLayer))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         if(!Actor.backProp(GetPointer(bActions), 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;
           }

我对环境交互 EA 进行了类似的有针对性的更改。您可自行据附件中的代码检查它们。附件包含所有程序的完整代码。


3. 测试

所有必要的程序创建之后,我们终于可以训练和测试模型了。这将令我们能够评估所提议解决方案的有效性。

我们采用 EURUSD、H1 的历史数据来训练和测试模型。

时间不会停滞不前。随着时间的推移,我们的历史数据库不断增长。故此,在准备本文时,我决定扩展训练数据集的历史间隔,从而包括 2023 全年。2024 年 1 月的数据将用来测试经过训练的模型。

为了创建主要训练数据集,我用到了 Real-ORL 框架。您可在此链接中找到其详细说明。我从 20 个真实信号中下载了交易数据。然后,我在完全优化模式下运行 EA “...\Experts\RevIN\ResearchRealORL.mq5”。

训练集的集合 训练集的集合

结果就是,我得到了 20 条轨迹。它们并非全都是有利可图的。

训练集的集合

在该步骤中,我们首先开始训练编码器。编码器训练完成后,我们将执行扮演者和评论者的初步训练。这主要是因为 20 条轨迹太少,无法获得扮演者的最优政策。

在下一步中,我们将扩展我们的训练数据集。为此,在慢速完全优化模式下,我们运行 EA “...\Experts\RevIN\Research.mq5”。它在训练期间依据真实历史数据测试当前的扮演者政策,并将验算添加到我们的训练数据集之中。

在该阶段,您不应该期望任何出色的结果。负面结果也是结果。这也为进一步的模型训练实验提供了很好的经验。进而,此类迭代有助于了解扮演者当前政策的行动区域中的环境。

经过若干次扮演者政策训练迭代,并将额外数据收集到训练数据集当中,我设法训练了一个能够在训练和测试数据集上都产生利润的模型。

测试结果 测试结果

在测试期间,EA 进行了 424 笔交易,其中 210 笔以盈利结束。这是 49.53%。然而,由于最大和平均盈利的交易超过无盈利交易,因此测试区间最终获利。最大余额和净值回撤显示出接近的结果(分别为 9.14% 和 10.36%)。测试期间的盈利因子为 1.25。锋锐比率达到 3.38。


结束语

在本文中,我们领略了 RevIN 方法,它代表了归一化和逆归一化技术发展的重要一步。它与时间序列预测背景下的深度学习模型尤其相关。它允许我们保存和检索有关时间序列的统计信息,这对于准确预测至关重要。RevIN 展现出数据动态随时间变化的稳健性。这令其成为与时间序列中分布偏移问题打交道的有效工具。

RevIN 的重要优势之一是其灵活性,和对各种深度学习模型的适用性。它可以轻松实现到各种神经网络架构中,甚至可以应用于多层,提供稳定的预测品质。

在本文的实践部分,我们利用 MQL5 实现了所提议方法。我们依据真实历史数据训练模型,并采用未包含在训练数据集中的新数据对其进行测试。

测试结果显示,已训练模型具有普适训练数据的能力,并可在历史训练集上、以及超出部分上产生盈利。

不过,应当记住,本文中讲述的所有程序都具有演示性质,仅用于测试所提议的方式。

参考

  • 可逆实例归一化,用于针对分布偏移进行准确的时间序列预测
  • 本系列的其它文章


  • 文中所用程序

    # 名称 类型 说明
    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/14673

    附加的文件 |
    MQL5.zip (1071.34 KB)
    您应当知道的 MQL5 向导技术(第 15 部分):协同牛顿多项式的支持向量机 您应当知道的 MQL5 向导技术(第 15 部分):协同牛顿多项式的支持向量机
    支持向量机基于预定义的类,按探索增加数据维度的效果进行数据分类。这是一种监督学习方法,鉴于其与多维数据打交道的潜力,它相当复杂。至于本文,我们会研究进行价格行为分类时,如何运用牛顿多项式更有效地做到非常基本的 2-维数据实现。
    神经网络变得简单(第 83 部分):“构象”时空连续关注度转换器算法 神经网络变得简单(第 83 部分):“构象”时空连续关注度转换器算法
    本文介绍了最初是为天气预报而开发的“构象(Conformer)”算法,其变化多端之处可与金融市场相提并论。“构象(Conformer)”是一种复杂的方法。它结合了关注度模型和常微分方程的优点。
    神经网络变得简单(第 85 部分):多变元时间序列预测 神经网络变得简单(第 85 部分):多变元时间序列预测
    在本文中,我愿向您介绍一种新的复杂时间序列预测方法,它和谐地结合了线性模型和转换器的优点。
    神经网络变得简单(第 82 部分):常微分方程模型(NeuralODE) 神经网络变得简单(第 82 部分):常微分方程模型(NeuralODE)
    在本文中,我们将讨论另一种模型类型,它们旨在研究环境状态的动态。