English Русский Español Deutsch 日本語 Português
preview

神经网络变得简单(第 91 部分):频域预测(FreDF)

MetaTrader 5示例 |
253 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

在各种金融市场情景中,预测时间序列的未来价格至关重要。当前存在的大多数方法都是基于数据中的某些自相关性。换言之,我们利用了时间步骤之间输入数据和预测值两者都存在的相关性。

日益博得追捧的模型当中,那些基于变换器架构的模型会用到自关注机制进行动态自相关评测。还有,我们还看到在预测模型中运用频域分析的兴趣正在提升。输入数据序列以频域表示有助于避免自相关性描述的复杂性,并提升各种模型的成效。

另一个重要层面是预测值序列中的自相关性。显然,预测值是大型时间序列的一部分,其中包括所分析和预测的序列。因此,预测值保留了所分析数据的相关性。但这种现象在现代预测方法中往往被忽视。特别是,现代方法的主流采用直接预测DF)范式,其会同步生成多阶段预测。这隐式地假定预测值序列中步骤的独立性。模型假设和数据特征之间的这种错位,其结果就是预测品质欠佳。

该问题的解决方案之一已于论文《FreDF:在频域中学习预测》中提出。该论文的作者提出了一种频率增益(FreDF) 的直接预测方法。它通过在频域中对齐预测值和标签序列来阐明 DF 范式。当转至频域时,其中基级相互正交且独立,自相关性的影响被有效降低。因此,FreDF 防止了有关 DF 假设、与标签自相关存在之间的不一致,同时维护了 DF 的优势。

该方法的作者在一连串实验中测试了其有效性,证明所提议方式具有碾压现代方法的显著优势。


1. FreDF 算法

DF 范式使用多输出模型 ɡθ 来生成步骤 T 的预测 Ŷ = ɡθ(X)。设 YtY 的第 t 步,且 Yt(n) 是第 n-个观测样本。模型参数 θ 以最小化均方误差(MSE)进行优化:

DF 范式在每个步骤独立计算预测误差,处置序列的每个元素时均作为单独任务。然而,这种方式忽视了 Y 中存在的自相关性,这与标签的自相关性的存在相矛盾。后果是,模型训练期间出产偏态似然,并偏离最大似然原则。

克服这一限制的策略之一,是在正交基级形成的变换域中表示标签序列。特别是,这能运用傅里叶变换有效实现,其可将序列投影到与不同频率相关的正交基级上。通过将标签序列转换为正交频域,可以有效减少对标签自相关性的依赖。

其中 i 是虚数单元,定义为 √(-1),
exp(•) 是与频率 k 相关的傅里叶基级,其是不同 k 值的正交。

由于基级的正交性,标签序列的频域表示绕过了时域中自相关性的依赖。这凸显了频域预测学习的潜能。

配以 DF 方式的经典用法,在给定的时间戳 n 处,历史序列 Xn 被输入到模型当中,以便生成 T-步的预测,表示为 Ŷn=ɡθ(Xn)。计算时域 Ltmp 中的预测误差。

除了经典方式之外,FreDF 方法的作者还提议将预测值和标签序列转换到频域。然后按以下公式计算频域中的预测误差:

此处,求和的每一项都是复数 A 的矩阵;|A| 表示计算矩阵中每个元素的模数、并求和运算。在这种情况下,复数 a = ar + i ai 的模数计算为 √(ar^2 + ai^2)。

请注意,由于频域中标签序列的数值特性不同,FreDF 方法的作者并未采用平方损失形式(MSE),这在时域损失误差计算中却很典型。具体来说,不同的频率分量往往具有非常不同的幅度,与高频相比,较低频率的音量高出若干个数量级,这令平方损耗方法不稳定。

时域和频域中的预测误差采用数值范围内 [0,1] 的系数 α 进行合并,该系数控制频域均衡的相对强度:

FreDF 通过在频域中对齐生成的预测值和标签序列来避开目标值的自相关效应。它还保留了 D.F. 的优势,诸如高效输出,和多任务能力。FreDF 的瞩目特点是它与各种预测模型和变换的兼容性。这种灵活性显著扩展了 FreDF 的潜在应用纵深。

作者对该方法的可视化呈现如下。


2. 利用 MQL5 实现

在研究了所提议 FreDF 方法的理论层面之后,我们转到本文的实践部分,在其中我们将实现对该方式的愿景。从上面讲述的理论描述可以得出结论,所提议方式并没有在模型架构中引入任何特殊的设计特征。甚至,它不会影响模型的实际操作。它的效果只能在模型训练期间看到。大概所提议 FreDF 方法能够与一些复杂的损失函数相比较。故此,我们将用它来训练模型,根据我们的先验知识,其目标标签具有自相关依赖关系。

在我们开始构建一个新对象来实现所提议方式之前,值得注意的是,该方法的作者使用了傅里叶变换将数据从时间序列转换到频域。必须说 FreDF 方法非常灵活。它还可以很好地与其它方法结合工作,将数据转换至正交域。该方法的作者进行了一连串实验,以证明在使用其它转换时它的有效性。这些实验的结果如下所示。

正如所见,使用傅里叶变换的模型展现出更佳结果。

我想提请您注意系数 α。基于实验结果,看似它的值约为 0.8 是最佳的。应当注意的是,如果仅在频域中进行预测(令 α 等于 1),根据相同实验的结果,模型的准确性会有所降低。

因此,我们可以得出结论,为了获得最优时间序列预测模型,训练过程应包括所研究信号的时域和频域。不同的表示令我们能够获得有关信号的更多信息,结果就是训练出更高效的模型。

但我们回到我们的实现。根据方法作者进行的实验结果,傅里叶变换能够训练出较小预测误差的模型。在上一篇文章中,我们已经实现了直接和逆快速傅里叶变换。我们在新的实现中能够使用这些已开发成果。

为了实现 FreDF 方式,我们将创建一个新类 CNeuronFreDFOCL,它的主要功能将继承自神经层基类 CNeuronBaseOCL。新类的结构如下所示。

class CNeuronFreDFOCL   :  public CNeuronBaseOCL
  {
protected:
   uint              iWindow;
   uint              iCount;
   uint              iFFTin;
   bool              bTranspose;
   float             fAlpha;
   //---
   CBufferFloat      cForecastFreRe;
   CBufferFloat      cForecastFreIm;
   CBufferFloat      cTargetFreRe;
   CBufferFloat      cTargetFreIm;
   CBufferFloat      cLossFreRe;
   CBufferFloat      cLossFreIm;
   CBufferFloat      cGradientFreRe;
   CBufferFloat      cGradientFreIm;
   CBufferFloat      cTranspose;
   //---
   virtual bool      FFT(CBufferFloat *inp_re, CBufferFloat *inp_im, 
                         CBufferFloat *out_re, CBufferFloat *out_im, bool reverse = false);
   virtual bool      Transpose(CBufferFloat *inputs, CBufferFloat *outputs, uint rows, uint cols);
   virtual bool      FreqMSA(CBufferFloat *target, CBufferFloat *forecast, CBufferFloat *gradient);
   virtual bool      CumulativeGradient(CBufferFloat *gradient1, CBufferFloat *gradient2, 
                                        CBufferFloat *cummulative, float alpha);
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)   {  return true;   }
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);

public:
                     CNeuronFreDFOCL(void)   {};
                    ~CNeuronFreDFOCL(void)   {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                          uint window, uint count, float alpha, bool need_transpose = true, 
                          ENUM_OPTIMIZATION optimization_type = ADAM, uint batch = 1);
   virtual bool      calcOutputGradients(CArrayFloat *Target, float &error);
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual int       Type(void)        const                      {  return defNeuronFreDFOCL; }
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

新类的结构表述有两个瞩目特征:

  • 内部对象仅由数据缓冲区表示,且没有内部层
  • calcOutputGradients 方法被覆盖

此处还可提到另一个隐含特性:这个对象不包含可训练的参数,而这非常罕见。所有这些特征都与该类的目的有关:我们正在创建一个复杂的损失函数类,而非一个可训练的神经层。且我们神经层架构中的 calcOutputGradients 方法负责计算预测值与目标值的偏差。在实现方法时,我们将领略内部对象和变量的用途。

所有对象都声明为静态,这样允许我们将类构造函数和析构函数保留为“空”。与释放内存相关的所有操作都将由系统自身执行。

类对象在 Init 方法中初始化。如常,在该方法的参数中,我们传递定义类架构的主要常量。此处我们有:

  • window 描述输入数据的一个元素的窗口,
  • count:序列中的元素数量
  • alpha 系数是频域和时域均衡的推力,
  • need_transpose 标志,指示为了频率转换。需要转置数据。

该对象将用在模型的输出。因此,输入是我们的模型生成的预测值。提供的数据必须与目标结果的格式一致。windowcount 参数对应于预测值和目标值两者。我们还为用户提供了在不同平面将数据转换至频域的能力。这就是为什么我们引入 need_transpose 标志。

我想在此引用该方法作者的一些其它实验的结果。他们测试了模型的性能,比较了多元序列(T)的单元时间序列、单独时间步骤(D)项、和聚集序列(2D)的频率特征。

模型的最佳结果是由正常聚集序列频率特性体现。事实证明,比较独立时间步骤的频率特性在实验中出局。分析单元时间序列的频率特性排名第二,略微落后于领先者。

在我们的实现中,我们为用户提供了频率转换测量方法选择的能力,即指定相应的 need_transpose 标志值。为了比较 2-维频率特性,在 window 参数中指定整个序列的大小,且其余参数采用以下数值:

  • count: 1,
  • need_transpose: false.

bool CNeuronFreDFOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                           uint window, uint count, float Alpha, bool need_transpose = true, 
                           ENUM_OPTIMIZATION optimization_type = ADAM, uint batch = 1)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * count, optimization_type, batch))
      return false;

在方法主体中,我们首先调用相关父类的同名方法,并检查作结果。再者,父类实现必要的控件集,包括正在创建的神经层的大小。对于层大小,我们指定变量 windowcount 的乘积。显然,如果您只在其一当中指定了一个零值,那么整个乘积将等于 “0”,且父类方法将失败。

父类方法成功执行之后,我们将得到的数值保存在局部变量当中。

   bTranspose = need_transpose;
   iWindow = window;
   iCount = count;
   fAlpha = MathMax(0, MathMin(Alpha, 1));
   activation = None;

正如我们早前所见,对于快速傅里叶变换,我们的缓冲区需要大小为 2 的幂。我们来计算数据缓冲区的大小:

//--- Calculate FFTsize
   uint size = (bTranspose ? count : window);
   int power = int(MathLog(size) / M_LN2);
   if(MathPow(2, power) != size)
      power++;
   iFFTin = uint(MathPow(2, power));

下一步是初始化内部数据缓冲区。首先,我们初始化预测值的频率响应缓冲区。我们采用 2 个数据缓冲区设计。一个缓冲区记录实部分量数据,第二个缓冲区则用于虚部。

//---
   uint n = (bTranspose ? iWindow : iCount);
   if(!cForecastFreRe.BufferInit(iFFTin * n, 0) || !cForecastFreRe.BufferCreate(OpenCL))
      return false;
   if(!cForecastFreIm.BufferInit(iFFTin * n, 0) || !cForecastFreIm.BufferCreate(OpenCL))
      return false;

接下来,我们创建相似的缓冲区,保存目标值的频率特性:

   if(!cTargetFreRe.BufferInit(iFFTin * n, 0) || !cTargetFreRe.BufferCreate(OpenCL))
      return false;
   if(!cTargetFreIm.BufferInit(iFFTin * n, 0) || !cTargetFreIm.BufferCreate(OpenCL))
      return false;

我们将预测误差写入缓冲区 cLossFreeRecLossFree 之中:

   if(!cLossFreRe.BufferInit(iFFTin * n, 0) || !cLossFreRe.BufferCreate(OpenCL))
      return false;
   if(!cLossFreIm.BufferInit(iFFTin * n, 0) || !cLossFreIm.BufferCreate(OpenCL))
      return false;

请注意比较频率特性两个分量的重要性。为了正确预测时间序列,时间序列频率特征的幅度和相位两者都很重要。

还需要创建缓冲区,以便记录所预测时间序列数值之处的误差梯度:

   if(!cGradientFreRe.BufferInit(iFFTin * n, 0) || !cGradientFreRe.BufferCreate(OpenCL))
      return false;
   if(!cGradientFreIm.BufferInit(iFFTin * n, 0) || !cGradientFreIm.BufferCreate(OpenCL))
      return false;

为了节省内存,我们可以剔除缓冲区 cGradientFreeRecGradientFreeIm。例如,它们可以很容易地替换为缓冲区 cForecastFreeRecForecastFreeIm。但它们的存在令代码更具可读性。此外,在我们的例子中,它们占用的内存额度并不严重。

最后,如果需要,我们将创建一个临时缓冲区来写入转置数值:

      if(!cTranspose.BufferInit(iWindow * iCount, 0) || !cTranspose.BufferCreate(OpenCL))
         return false;
//---
   return true;
  }

数据初始化后,我们通常会创建一个前馈通验方法。上面曾说过,该类的对象在操作期间不会执行数据运算。如您所知,前馈方法描述了模型的操作模式。我们可以用“假体”重新定义前馈通验方法,但之后我们将如何传输数据呢?如常,我们希望数据复制过程最小化,因为数据量可能不同,并且过程规划会增加“成本开销”。在这种背景下,我们要令前馈通验方法尽可能简单。在该方法中,我们仅检查指针与当前层、和前一层结果缓冲区的对应关系。如有必要,我们将当前层中的指针替换为前一层的结果缓冲区。

bool CNeuronFreDFOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL || !NeuronOCL.getOutput())
      return false;
   if(NeuronOCL.getOutput() != Output)
     {
      Output.BufferFree();
      delete Output;
      Output = NeuronOCL.getOutput();
     }
//---
   return true;
  }

因此,我们将指针改为指向一个缓冲区,替代传输数据,这样就与其体量无关。请注意,每次通验都执行控制,并且仅在首次通验时替换数据缓冲区。

我们实现该类反向传播通验的主要功能。我们先做一些准备工作。为了完全实现所需功能,我们将在 OpenCL 程序端创建 2 个小内核。

FreDF 方法的作者建议在估算频域中的偏差时使用 MAE 作为损失函数。他们还注意到,当使用 MSE 时,训练稳定性会有所降低。我要提醒您,我们的基本神经层类 CNeuronBaseOCL 确定使用 MSE 来判定误差梯度。故此,我们需要创建一个内核,利用 MAE 来判定预测误差梯度。从数学视野来看,这非常简单:我们仅需从目标标签向量中减去预测值向量。

__kernel void GradientMSA(__global float *matrix_t,
                          __global float *matrix_o,
                          __global float *matrix_g
                         )
  {
   int i = get_global_id(0);
   matrix_g[i] = matrix_t[i] - matrix_o[i];
  }

判定频域和时域中的误差梯度之后,我们需要使用温度系数合并误差梯度。我们在 CumulativeGradient 内核中实现这个功能,我认为这应该很容易理解。

__kernel void CumulativeGradient(__global float *gradient_freq,
                                 __global float *gradient_tmp,
                                 __global float *gradient_out,
                                 float alpha
                                )
  {
   int i = get_global_id(0);
   gradient_out[i] = alpha * gradient_freq[i] + (1 - alpha) * gradient_tmp[i];
  }

我要提醒您,为了将数据从时域转换到频域、及返回,我们将用到我们在上一篇文章中实现的快速傅里叶变换算法。该文章提供了所用算法的描述,以及将内核放入执行队列的方法。

至于将内核放入执行队列的方法,我们现在不研究其算法。它们都遵循相同的过程,这些都已在本系列的文章中多次讲述,包括上一篇文章

我们来研究 CNeuronFreDFOCL::calcOutputGradients 方法,它实现了类的主要功能。如您所知,根据我们的模型结构,该方法判定预测值与目标标签的偏差。在方法参数中,我们收到一个指向含有目标值的缓冲区指针。方法操作完成之后,我们需要将误差梯度保存到当前层对应的缓冲区之中。

bool CNeuronFreDFOCL::calcOutputGradients(CArrayFloat *Target, float &error)
  {
   if(!Target)
      return false;
   if(Target.Total() < Output.Total())
      return false;

在方法主体中,我们检查接收到的目标值缓冲区的指针正确性。此外,其大小必须不小于模型的结果张量。

由于接收到的缓冲区在 OpenCL 关联环境端可能没有自己的副本,故我们必须在那里创建它,以供后续计算。然而,为了更经济地使用 OpenCL 关联环境资源,我们会将获取的数据传输到已创建的梯度缓冲区。

   if(Target.Total() == Output.Total())
     {
      if(!Gradient.AssignArray(Target))
         return false;
     }
   else
     {
      for(int i = 0; i < Output.Total(); i++)
        {
         if(!Gradient.Update(i, Target.At(i)))
            return false;
        }
     }
   if(!Gradient.BufferWrite())
      return false;

这里有 2 种可能的发展。如果目标标签和预测值缓冲区的大小相等,则我们使用现有的复制方法。否则,我们使用循环来传输所需数量的值。任何情况下,在复制数据之后,我们将其传输到 OpenCL 关联环境内存。

然后,使用获得的数据计算时域和频域中的偏差。请注意,在时域中计算偏差时,我们的层误差梯度缓冲区由计算出的偏差覆盖,而获得的目标值将彻底舍弃。因此,在计算时域偏差之前,我们至少需要把得到的目标标签的时间序列分解成频率分量。

时间序列可以分解成二维频率特征。哪个数值要用,则由 bTranspose 标志值判定。如果标志设置为 true,我们首先转置模型的结果缓冲区,然后将其分解成频率响应:

   if(bTranspose)
     {
      if(!Transpose(Output, GetPointer(cTranspose), iWindow, iCount))
         return false;
      if(!FFT(GetPointer(cTranspose), NULL, GetPointer(cForecastFreRe), GetPointer(cForecastFreIm), false))
         return false;

我们针对目标标签张量执行类似的运算:

      if(!Transpose(Gradient, GetPointer(cTranspose), iWindow, iCount))
         return false;
      if(!FFT(GetPointer(cTranspose), NULL, GetPointer(cTargetFreRe), GetPointer(cTargetFreIm), false))
         return false;
     }

如果 bTranspose 标志值为 false,则我们将目标值和预测值分解成相应的频率特性,无需初步转置:

   else
     {
      if(!FFT(Output, NULL, GetPointer(cForecastFreRe), GetPointer(cForecastFreIm), false))
         return false;
      if(!FFT(Gradient, NULL, GetPointer(cTargetFreRe), GetPointer(cTargetFreIm), false))
         return false;
     }

一旦判定了频率特性,我们就可以计算时域和频域的偏差,而不必担心丢失目标值。

   if(!FreqMSA(GetPointer(cTargetFreRe), GetPointer(cForecastFreRe), GetPointer(cLossFreRe)))
      return false;
   if(!FreqMSA(GetPointer(cTargetFreIm), GetPointer(cForecastFreIm), GetPointer(cLossFreIm)))
      return false;
   if(!FreqMSA(Gradient, Output, Gradient))
      return false;

注意,在频域中,频率响应的实部和虚部两者的偏差我们都要判定。因为相移的数值不亚于信号幅度。然而,我们不能直接估算时域和频域的误差梯度。显然,数据是不可比的。因此,我们首先需要将频率响应的误差梯度返回到时域。为此,我们将使用逆傅里叶变换。

   if(!FFT(GetPointer(cLossFreRe), GetPointer(cLossFreIm), GetPointer(cGradientFreRe), GetPointer(cGradientFreIm), true))
      return false;

时域和频域的误差梯度已形成可比的形式。现在,提取频率特性的测量值取决于 bTranspose 标志的值。因此,我们需要根据标志值变换频域误差梯度。只有这样,我们才能判定模型的累积误差梯度。

   if(bTranspose)
     {
      if(!Transpose(GetPointer(cGradientFreRe), GetPointer(cTranspose), iCount, iWindow))
         return false;
      if(!CumulativeGradient(GetPointer(cTranspose), Gradient, Gradient, fAlpha))
         return false;
     }
   else
      if(!CumulativeGradient(GetPointer(cGradientFreRe), Gradient, Gradient, fAlpha))
         return false;
//---
   return true;
  }

不要忘记在每个步骤控制结果。操作的逻辑值将返回给调用方。

在模型输出处判定误差梯度之后,我们需要将其传递给前一层。我们在 CNeuronFreDFOCL::calcInputGradients 方法中实现此功能,在其参数中接收指向前一个神经层对象的指针。

记住,我们的层不包含可训练参数。在前馈通验期间,我们替换了数据缓冲区,并显示来自上一层的数值作为结果。这种方法的目的是什么?这很简单。我们只需要调整上面前一层激活函数计算出的累积误差梯度。

bool CNeuronFreDFOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;
//---
   return DeActivation(NeuronOCL.getOutput(), NeuronOCL.getGradient(), Gradient, NeuronOCL.Activation());
  }

由于我们的类不包含可训练参数,故我们使用“空存根”来重新定义 updateInputWeights 方法。

类中缺少可训练参数也会影响文件操作方法。因为我们不需要存储不相关的内部对象。因此,在保存数据时,我们仅调用父类同名方法即可。

bool CNeuronFreDFOCL::Save(const int file_handle)
  {
   if(!CNeuronBaseOCL::Save(file_handle))
      return false;

我们保存描述对象设计特征的变量值:

   if(FileWriteInteger(file_handle, int(iWindow)) < INT_VALUE)
      return false;
   if(FileWriteInteger(file_handle, int(iCount)) < INT_VALUE)
      return false;
   if(FileWriteInteger(file_handle, int(iFFTin)) < INT_VALUE)
      return false;
   if(FileWriteInteger(file_handle, int(bTranspose)) < INT_VALUE)
      return false;
   if(FileWriteFloat(file_handle, fAlpha) < sizeof(float))
      return false;
//---
   return true;
  }

从数据文件还原对象的 Load 算法看起来稍微复杂一些。于此我们首先恢复父类的元素:

bool CNeuronFreDFOCL::Load(const int file_handle)
  {
   if(!CNeuronBaseOCL::Load(file_handle))
      return false;

然后我们按照变量数据的保存顺序加载它们,记住要检查何时到达数据文件的末尾:

   if(FileIsEnding(file_handle))
      return false;
   iWindow = uint(FileReadInteger(file_handle));
   if(FileIsEnding(file_handle))
      return false;
   iCount = uint(FileReadInteger(file_handle));
   if(FileIsEnding(file_handle))
      return false;
   iFFTin = uint(FileReadInteger(file_handle));
   if(FileIsEnding(file_handle))
      return false;
   bTranspose = bool(FileReadInteger(file_handle));
   if(FileIsEnding(file_handle))
      return false;
   fAlpha = FileReadFloat(file_handle);

然后我们需要根据类架构的加载参数来初始化嵌套对象。对象的初始化方式与初始化新类实例的算法类似:

   uint n = (bTranspose ? iWindow : iCount);
   if(!cForecastFreRe.BufferInit(iFFTin * n, 0) || !cForecastFreRe.BufferCreate(OpenCL))
      return false;
   if(!cForecastFreIm.BufferInit(iFFTin * n, 0) || !cForecastFreIm.BufferCreate(OpenCL))
      return false;
   if(!cTargetFreRe.BufferInit(iFFTin * n, 0) || !cTargetFreRe.BufferCreate(OpenCL))
      return false;
   if(!cTargetFreIm.BufferInit(iFFTin * n, 0) || !cTargetFreIm.BufferCreate(OpenCL))
      return false;
   if(!cLossFreRe.BufferInit(iFFTin * n, 0) || !cLossFreRe.BufferCreate(OpenCL))
      return false;
   if(!cLossFreIm.BufferInit(iFFTin * n, 0) || !cLossFreIm.BufferCreate(OpenCL))
      return false;
   if(!cGradientFreRe.BufferInit(iFFTin * n, 0) || !cGradientFreRe.BufferCreate(OpenCL))
      return false;
   if(!cGradientFreIm.BufferInit(iFFTin * n, 0) || !cGradientFreIm.BufferCreate(OpenCL))
      return false;
   if(bTranspose)
     {
      if(!cTranspose.BufferInit(iWindow * iCount, 0) || !cTranspose.BufferCreate(OpenCL))
         return false;
     }
   else
     {
      cTranspose.BufferFree();
      cTranspose.Clear();
     }
//---
   return true;
  }

新类 CNeuronFreDFOCL 的方法描述到此结束。您可在附件中看到该类的完整代码。

在新类的方法构造完毕之后,我们通常会转到描述可训练模型架构。但在本文中,我们构建了一个相当不寻常的神经层。我们曾以神经层的形式实现了一个复杂的损失函数。故此,我们可将上面创建的对象加到我们之前训练的模型之一当中,重新训练它,并查看结果如何变化。至于我的实验,我选择了 FEDformer 模型;其架构是在此处讲述。我们往其中添加一个新层。

bool CreateEncoderDescriptions(CArrayObj *encoder)
  {
//---
........
........
//--- layer 17
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFreDFOCL;
   descr.window = BarDescr;
   descr.count =  NForecast;
   descr.step = int(true);
   descr.probability = 0.8f;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

深思一番,我决定扩大实验。FreDF 方法的作者提出了自己的算法,可在预测的结果中使用依赖性。实际上,扮演者结果的各个参数之间也存在依赖关系。例如,买入和卖出交易量是相互排斥的,因为在任何给定时间,我们只有一个方向的持仓。止损和止盈参数决定了即将到来的最有可能走势的强度。因此,多头持仓的止盈应该在一定程度上与空头持仓的止损相关,反之亦然。类似的推理能用于建议预测的评论者数值中的依赖关系。那么,为什么不将实验扩展到上述模型呢?讷言敏行。往扮演者和评论者模型里添加新层:

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
.........
.........
//--- layer 17
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFreDFOCL;
   descr.window = NActions;
   descr.count =  1;
   descr.step = int(false);
   descr.probability = 0.8f;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- Critic
.........
.........
//--- layer 17
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFreDFOCL;
   descr.window = NRewards;
   descr.count =  1;
   descr.step = int(false);
   descr.probability = 0.8f;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

请注意,在这种情况下,我们分析的是整个结果序列的频率特性,而非独立单元序列的频率特性。

我们实现的所提议 FreDF 方式,不需要对之前模型训练、及与环境交互的智能系统进行任何调整。这意味着,为了测试获得的结果,我们可以使用之前准备好的智能系统,和训练数据集。   


3. 测试

为了利用 MQL5 实现 FreDF 作者提议的方式,我们已经做了相当多的工作。现在我们进入工作的最后阶段:训练和测试。

如上所述,我们将用之前创建的智能系统,及预先收集的训练数据来训练模型。在我们的文章中,我们采用 EURUSD 金融产品、2023 年、H1 时间帧历史数据训练模型。

首先,我们训练环境状态编码器模型。经训练的模型,可按 NForecast 常数判定的计划横向范围内预测环境的未来状态。在我的实验中,我采用 12 根后续蜡烛。生成的预测是按描述环境状态的所有分析参数的上下文进行。

#define        NForecast               12            //Number of forecast

编码器训练过程中,我们可以看到,比之不用 FreDF 方式的类似模型相比,预测误差有所减少。不过,我们没有执行预测结果的图形化比较。因此,很难判断预测值的实际品质。此处应当注意的是,也许看似奇怪,但我们的目标并不是获得所有分析指标的最准确预测。扮演者模型使用编码器的潜在空间来决定最优动作。第一阶段训练目标是获得编码器信息量最丰富的潜在空间,它将为即将到来的最有可能的价格走势编码。

如前,编码器模型只分析价格走势,故此在训练的第一阶段,我们不需要更新训练集。

在学习过程的第二阶段,我们寻找最优的扮演者动作政策。此处,我们运行扮演者和评论者模型的训练迭代,其与更新训练数据集交替进行。

扮演者政策经若干次迭代训练的结果,我们设法获得了一个可以产生利润的模型。我们用 2024 年 1 月的真实历史数据,在 MetaTrader 5 策略测试器中测试了已训练模型的性能。测试参数与训练数据集的参数完全对应,包括金融产品、时间帧、及所分析指标的参数。测试结果显示在下面的屏幕截图之中。 

基于测试结果,我们可以注意到账户余额明显朝向增加趋势。在测试期间,该模型执行了 49 笔交易,其中 21 笔以盈利了结。是的,盈利仓位不到一半。然而,平均盈利交易几乎是平均亏损交易的 2 倍。结果就是,模型在测试数据集上的盈利因子为 1.43,当月总收入约为 19%。


结束语

在本文中,我们讨论了旨在改进时间序列预测的 FreDF 方法。该方法的作者实证证实,按当前的 DF 范式,忽略标记序列中的自相关性会导致似然性乖离、及预测品质的恶化。他们针对当前 DF 范式,提出了一种简单而有效的修改,其考虑到自相关性,即在频域中对齐预测和标签序列。FreDF 方法与各种预测模型和变换兼容,令其相当灵活、且通用。

在本文的实践部分,我们利用 MQL5 语言实现了我们对所提议方法的愿景。我们按提议方式补充了之前创建的 FEDformer 模型,并进行了训练。然后我们测试了经过训练的模型。测试结果表明所提议方式的有效性,因为在其它条件相同的情况下,FreDF 的加入提升了模型效率。

我想提请注意,FreDF 方法的灵活性,这令它可以有效地广泛用于各种现有模型。

参考


文中所用程序

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

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

附加的文件 |
MQL5.zip (1240.5 KB)
在MetaTrader 5中实现基于EMA交叉的级联订单交易策略 在MetaTrader 5中实现基于EMA交叉的级联订单交易策略
本文介绍一个基于EMA交叉信号的自动交易算法,该算法适用于MetaTrader 5平台。文章详细阐述了在MQL5中开发一个EA所需的方方面面,以及在MetaTrader 5中进行测试的过程——从分析价格区间行为到风险管理。
交易中的混沌理论(第一部分):简介、在金融市场中的应用和李亚普诺夫指数 交易中的混沌理论(第一部分):简介、在金融市场中的应用和李亚普诺夫指数
混沌理论可以应用于金融市场吗?在这篇文章中,我们将探讨传统混沌理论和混沌系统与比尔·威廉姆斯提出的概念有何不同。
MQL5 交易工具包(第 2 部分):扩展和实现仓位管理 EX5 库 MQL5 交易工具包(第 2 部分):扩展和实现仓位管理 EX5 库
了解如何在 MQL5 代码或项目中导入和使用 EX5 库。在这篇续文中,我们将通过向现有库中添加更多仓位管理功能并创建两个 EA 交易系统来扩展 EX5 库。第一个例子将使用可变指数动态平均(Variable Index Dynamic Average,VIDYA)技术指标来开发追踪止损交易策略 EA 交易,而第二个例子将利用交易面板来监控、开仓、平仓和修改仓位。这两个例子将演示如何使用和实现升级后的 EX5 仓位管理库。
您应当知道的 MQL5 向导技术(第 20 部分):符号回归 您应当知道的 MQL5 向导技术(第 20 部分):符号回归
符号回归是一种回归形式,它从最小、甚或没有假设开始,而底层模型看起来应当映射所研究数据集。尽管它可以通过贝叶斯(Bayesian)方法、或神经网络来实现,但我们看看如何使用遗传算法实现,从而有助于在 MQL5 向导中使用自定义的智能信号类。
该网站使用cookies。了解有关我们Cookies政策的更多信息。