English Русский Español Deutsch 日本語 Português
preview
交易中的神经网络:时空神经网络(STNN)

交易中的神经网络:时空神经网络(STNN)

MetaTrader 5交易系统 |
486 1
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

时间序列预测于包括金融在内的各个领域都扮演着重要角色。我们已经习惯了这样一个事实,即许多现实世界的系统允许我们衡量多维数据,其中包含有关目标变量动态的丰富信息。然而,对多元时间序列的有效分析和预测,经常受到“维度诅咒”的阻碍。这令选择所分析历史数据窗口成为一个关键因素。很多时候,当采用了不充分的分析数据窗口时,预测模型会展现出不尽人意的性能、并失败。 

为了解决多元数据的复杂性,基于延迟嵌入定理开发了时空信息(STI)变换方程。STI 方程将多元空间信息变换到目标变量的时态。这有效地提升了样本量,并缓解了短期数据招致的挑战。

基于变换器的模型,均已精熟处理数据序列,它运用自注意力机制来分析变量之间的关系,同时忽略它们的相对距离。这些注意力机制捕获全局信息,并专注于最相关的特征,从而减轻维度的诅咒。

在研究《时间序列预测的时空变换器神经网络》时,提出了一种时空变换器神经网络(STNN),实现了多元短期时间序列的高效多步骤预测。这种方式利用了 STI 方程和变换器框架的优势。

作者强调他们提议方法的若干主要益处:

  1. STNN 使用 STI 方程将多元空间信息转换为目标变量的时态演变,从而有效地增加样本量。
  2. 提议一种持续注意力机制来提高数值预测的准确性。
  3. STNN 中的空间自注意力结构从多变量中收集有效的空间信息,而时态自注意力结构收集有关时态演变的信息。变换器结构结合了空间和时态信息。
  4. STNN 模型能够针对时间序列预测重造动态系统的相空间。


1. STNN 算法

STNN 模型的目的是通过变换器训练有效地求解非线性变换方程 STI

STNN 模型利用变换方程 STI,并包括 2 个专用的注意力模块来执行多步提前预测。从上面的方程式中您可看出,时间 t (Xt) 处的 D-维输入数据被投喂到编码器之中,其会从输入变量中提取有效的空间信息。

此后,有效的空间信息被传输到解码器,其会从目标变量 Y (𝐘t) 中引入长度为 L-1 的时间序列。解码器提取有关目标变量的时间演变信息。然后,它把输入变量的空间信息 (𝐗t),和目标变量的时间信息 (𝐘t) 组合到一起,来预测目标变量的未来值。

注意,目标变量是多元输入数据 X 中的变量之一。

STI 的非线性变换由编码器-解码器对来求解。编码器由 2 层组成。第一个是全连接层,第二个是连续空间自注意力层。STNN 方法的作者使用一个连续空间自注意力层从多元输入数据 𝐗t 中提取有效的空间信息。

全连接层平滑输入多元时间序列数据 𝐗t,并过滤输入噪声。单层神经网络如下图所示。

其中 WFFN 是一个系数矩阵
      bFFN 是偏置
      ELU 是激活函数。

连续空间自注意力层接受 𝐗t,FFN 作为输入数据。由于自注意力层接受多元时间序列,因此编码器能够从输入数据中提取空间信息。为了获得有效的空间信息(SSAt),提出一种空间自注意力层的连续注意力机制。其操作可描述如下。

首先,它生成 3 个可训练参数矩阵(WQE\、WKEWVE),用于连续空间自注意力层。

然后,输入数据 𝐗t,FFN 乘以上述权重矩阵,生成连续空间自注意力层的 QueryKeyValue 实体。

执行矩阵标量积,我们得到输入数据 𝐗t 处的关键空间信息(SSAt)的表达式。

其中 dEQueryKeyValue 矩阵的维度。

STNN方法的作者强调,与经典的离散概率注意力机制不同,所提议连续注意力机制能够保证编码器数据的不间断传输。

编码器输出处,我们把关键空间信息张量、与已平滑输入数据相加,随后数据归一化,这可防止梯度快速消失,并加速模型收敛率。

解码器结合了有效的空间信息和时态演变目标变量。它的架构包括 2 个全连接层,一个连续的时态自注意力层,和一个变换注意力层。

我们将目标变量的历史序列输入数据投喂给解码器。如编码器的情况一样,在用全连接层过滤掉噪声后,可以获得输入数据(𝐘t,FFN)的有效表示。

然后,接收到的数据被发送到连续时态自注意力层,该层侧重于有关目标变量不同时间步骤之间的时态演变的历史信息。由于时间的影响是不可逆的,故我们使用历史信息来判定时间序列的当前状态,而非未来信息。因此,连续时态注意力层使用掩码注意力机制来筛选未来信息。我们仔细查看这个操作。

首先,我们为时空自注意力层生成 3 个可训练参数矩阵(WQDWKDWVD)。然后我们将计算相应的 QueryKeyValue 实体矩阵。

我们执行矩阵标量积来获取有关目标变量在s所分析历史周期内的时态演变信息。

编码器不同,此处我们添加了一个掩码,可以去除所分析数据中后续元素的影响。按该方式,我们不允许模型在构造目标变量的时态演变函数时“展望未来”。

接下来,我们使用残差连接,并归一化有关目标变量的时态演变信息。

预测目标变量未来值的连续变换注意力层,把空间依赖性信息(SSAt),与目标变量的时态演变数据(TSAt) 集成到一起。

此处还使用了残差关系和数据归一化。

解码器的输出处,该方法的作者用到第二个全连接层,来预测目标变量的数值

在训练 STNN 模型时,该方法作者使用 MSE 作为损失函数,以及 L2 参数正则化。

作者对该方法的可视化如下所示。


2. 利用 MQL5 实现

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

本文将介绍我们自己对实现的愿景,这也许与作者对该方法的实现不同。甚至,在该实现框架内,我们尝试最大限度地利用我们现有的开发,这也影响了结果。我们将在实现所提议方式时谈及这一点。

从上面所示的 STNN 算法的理论描述中,您也许已注意到,它包括 2 个主要模块:编码器解码器。我们还将我们的工作切分为实现 2 个对应类。我们从编码器的实现开始。

2.1STNN 编码器

我们将在 CNeuronSTNNEncoder 类中实现编码器算法。该方法作者对自注意力算法进行了一些调整。不过,它仍然十分容易辨别,并且包括经典方式的基础组件。因此,为了实现一个新类,我们将利用现有的开发,并从 CNeuronMLMHAttentionMLKV 类继承基本自注意力算法的主要功能。新类的一般结构如下所示。

class CNeuronSTNNEncoder  :  public CNeuronMLMHAttentionMLKV
  {
protected:
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      AttentionOut(CBufferFloat *q, CBufferFloat *kv, CBufferFloat *scores, CBufferFloat *out) override;
   //---
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronSTNNEncoder(void)  {};
                    ~CNeuronSTNNEncoder(void)  {};
   //---
   virtual int       Type(void)   override const   {  return defNeuronSTNNEncoder;   }
  };

如您所见,新类中没有新变量和对象的声明。甚至,所呈现的结构也不包含覆盖的对象初始化方法。这是有原因的。如上所述,我们最大限度地利用了我们现有的开发。

首先,我们看看所提议方式与我们之前已实现的之间差异。首先,STNN 方法作者在自注意力模块之前放置了一个全连接层。技术上,对象声明没有问题,因为它只影响前馈和后馈通验算法的实现。这意味着此刻的实现不会影响初始化方法算法。

第二点是 STNN 方法作者仅提供了一个全连接层。不过,在经典方式中,会创建一个由 2 个全连接层组成的模块。我个人的观点是,使用一个由 2 个全连接层组成的模块,肯定会增加计算成本,但不会降低模型的品质。作为实验,为了最大限度地保留现有开发,我们可用 2 层来替代 1 层。

此外,该方法作者已从注意力系数归一化步骤中删除了 SoftMax 函数。取而代之,它们使用简单的 QueryKey 矩阵乘积指数。以我观点,SoftMax 的区别仅在于数据归一化、及更复杂的计算。故此,在我的实现中,我将采用之前实现的搭配 SoftMax 方式。   

接下来,我们转到前馈算法的实现。我于此注意到,该方法作者仅在解码器中实现了后续元素的掩码。我们记得目标变量也许包含在编码器的初始数据集当中。我认为这有些不合逻辑。但在仔细研究了作者对该方法的可视化之后,一切都变得清晰起来。

编码器的输入所在与所分析状态尚有一定距离。我无法判断该方法作者选择这样实现的原因。但我个人的观点是,在分析数据时使用数据可用的全部信息,将为我们提供更多信息,并有提高我们的预测品质的潜力。故此,在我的实现中,我将编码器的输入转移到当前时刻,并为初始数据添加掩码,这将允许我们仅分析与先前数据的依赖关系。

为了实现数据掩码,我们需要对 OpenCL 程序进行修改。在此,我们只对 MH2AttentionOut 内核进行细微的修改。我们不会用到额外的掩码缓冲区。我们将以更简单的方式行事。我们只添加 1 个常量,它将判定我们是否需要使用掩码。掩码将直接在内核算法中组织。 

__kernel void MH2AttentionOut(__global float *q,
                              __global float *kv,
                              __global float *score,
                              __global float *out,
                              int dimension,
                              int heads_kv,
                              int mask ///< 1 - calc only previous units, 0 - calc all
                             )
  {
//--- init
   const int q_id = get_global_id(0);
   const int k = get_global_id(1);
   const int h = get_global_id(2);
   const int qunits = get_global_size(0);
   const int kunits = get_global_size(1);
   const int heads = get_global_size(2);
   const int h_kv = h % heads_kv;
   const int shift_q = dimension * (q_id * heads + h);
   const int shift_k = dimension * (2 *  heads_kv * k + h_kv);
   const int shift_v = dimension * (2 *  heads_kv * k + heads_kv + h_kv);
   const int shift_s = kunits * (q_id *  heads + h) + k;
   const uint ls = min((uint)get_local_size(1), (uint)LOCAL_ARRAY_SIZE);
   float koef = sqrt((float)dimension);
   if(koef < 1)
      koef = 1;
   __local float temp[LOCAL_ARRAY_SIZE];

在内核主体中,当计算指数总和时,我们仅进行了略微调整。 

//--- sum of exp
   uint count = 0;
   if(k < ls)
     {
      temp[k] = 0;
      do
        {
         if(mask == 0 || q_id <= (count * ls + k))
            if((count * ls) < (kunits - k))
              {
               float sum = 0;
               int sh_k = 2 * dimension * heads_kv * count * ls;
               for(int d = 0; d < dimension; d++)
                  sum = q[shift_q + d] * kv[shift_k + d + sh_k];
               sum = exp(sum / koef);
               if(isnan(sum))
                  sum = 0;
               temp[k] = temp[k] + sum;
              }
         count++;
        }
      while((count * ls + k) < kunits);
     }
   barrier(CLK_LOCAL_MEM_FENCE);
   count = min(ls, (uint)kunits);

在此,我们将添加条件,并仅计算前面元素的指数。请注意,在为模型创建输入时,我们从历史价格数据、和指标的时间序列中生成它们。在时间序列中,当前柱线的索引为“0”。因此,为了掩盖历史纪年中的元素,我们把索引小于所分析 Query 的所有元素的依赖系数重置。我们在计算指数和依赖系数之和时会看到这一点(在代码中下划线部分)。

//---
   do
     {
      count = (count + 1) / 2;
      if(k < ls)
         temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : 0);
      if(k + count < ls)
         temp[k + count] = 0;
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
//--- score
   float sum = temp[0];
   float sc = 0;
   if(mask == 0 || q_id >= (count * ls + k))
      if(sum != 0)
        {
         for(int d = 0; d < dimension; d++)
            sc = q[shift_q + d] * kv[shift_k + d];
         sc = exp(sc / koef) / sum;
         if(isnan(sc))
            sc = 0;
        }
   score[shift_s] = sc;
   barrier(CLK_LOCAL_MEM_FENCE);

内核代码的其余部分保持不变。

//--- out
   for(int d = 0; d < dimension; d++)
     {
      uint count = 0;
      if(k < ls)
         do
           {
            if((count * ls) < (kunits - k))
              {
               float sum =
                  kv[shift_v + d] * (count == 0 ? sc : score[shift_s + count * ls]);
               if(isnan(sum))
                  sum = 0;
               temp[k] = (count > 0 ? temp[k] : 0) + sum;
              }
            count++;
           }
         while((count * ls + k) < kunits);
      barrier(CLK_LOCAL_MEM_FENCE);
      //---
      count = min(ls, (uint)kunits);
      do
        {
         count = (count + 1) / 2;
         if(k < ls)
            temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : 0);
         if(k + count < ls)
            temp[k + count] = 0;
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      while(count > 1);
      //---
      out[shift_q + d] = temp[0];
     }
  }

注意,按该实现,我们简单地把下一个元素的依赖系数归零。这令我们能够以在最小编辑量的情况下实现前馈通验内核的掩码。甚至,该方法无需调整反向传播内核。由于依赖系数中的 “0” 是简单地将序列中该类元素的误差梯度归零。

这需要在 OpenCL 程序端完成调整。现在,我们可以转到主程序的工作。

此处我们首先在 CNeuronSTNNEncoder::AttentionOut 方法中添加上述内核的调用。将内核放入执行队列的方法算法无变化。您可在附件中自行学习其代码。我只想提请注意 def_k_mh2ao_mask 参数中 “1” 指示执行数据掩码。

接下来,我们转到实现新类的前馈通验方法。我们必须重写该方法,将前馈模块移动到自注意力之前。还应当注意的是,与传统的变换器不同,前馈模块没有残差关系、和数据归一化。

在实现算法之前,重要的是要回顾,为了在父类初始化期间避免不必要的数据复制,我们将层的结果和误差梯度缓冲区指针替换为来自前馈模块最后一层的类似缓冲区。这种方式利用了注意力模块,与前馈模块的结果缓冲区大小相同的事实。因此,我们可在访问相应的数据缓冲区时,简单地调整索引。

现在,我们看看我们的实现。如前,方法参数包括一个指向前一层对象的指针,其中提供输入数据。

bool CNeuronSTNNEncoder::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;

在方法伊始,我们验证所接收指针的有效性。一旦完成,我们直接开始构造前馈算法。于此,重点是要强调我们实现中的另一个区别。STNN 方法作者没有指定编码器层的数量、或注意力头的数量。基于前面提供的可视化和方法描述,我们可以预期在单个编码器层中只存在一个注意力头。然而,在我们的实现中,我们遵循经典方式,在多层架构中搭配多头注意力。然后,我们组织一个循环来迭代嵌套的编码器层。

如前所述,在循环中,输入数据首先经由前馈模块,在该模块中执行数据平滑和筛选。

   CBufferFloat *kv = NULL;
   for(uint i = 0; (i < iLayers && !IsStopped()); i++)
     {
      //--- Feed Forward
      CBufferFloat *inputs = (i == 0 ? NeuronOCL.getOutput() : FF_Tensors.At(6 * i - 4));
      CBufferFloat *temp = FF_Tensors.At(i * 6 + 1);
      if(IsStopped() || 
         !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 1), inputs, temp, 
                                                                      iWindow, 4 * iWindow, LReLU))
         return false;
      inputs = FF_Tensors.At(i * 6);
      if(IsStopped() ||
         !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 2), temp, inputs, 
                                                                       4 * iWindow, iWindow, None))
         return false;

之后,我们定义 QueryKeyValue 实体的矩阵。

      //--- Calculate Queries, Keys, Values
      CBufferFloat *q = QKV_Tensors.At(i * 2);
      if(IsStopped() || 
        !ConvolutionForward(QKV_Weights.At(i * (optimization == SGD ? 2 : 3)), inputs, q, 
                                                          iWindow, iWindowKey * iHeads, None))
         return false;
      if((i % iLayersToOneKV) == 0)
        {
         uint i_kv = i / iLayersToOneKV;
         kv = KV_Tensors.At(i_kv * 2);
         if(IsStopped() || 
           !ConvolutionForward(KV_Weights.At(i_kv * (optimization == SGD ? 2 : 3)), inputs, kv, 
                                                      iWindow, 2 * iWindowKey * iHeadsKV, None))
            return false;
        }

请注意,在这种情况下,我们采用的方式来自父类继承的 MLKV 方法。这允许我们针对多个注意力头、和自注意力层使用一个键-值缓冲区。

基于获得的实体,我们将判定参考数据掩码的依赖系数。

      //--- Score calculation and Multi-heads attention calculation
      temp = S_Tensors.At(i * 2);
      CBufferFloat *out = AO_Tensors.At(i * 2);
      if(IsStopped() || !AttentionOut(q, kv, temp, out))
         return false;

然后,我们将计算注意力层的结果,同时参考残差连接、和数据归一化。

      //--- Attention out calculation
      temp = FF_Tensors.At(i * 6 + 2);
      if(IsStopped() || 
        !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9)), out, temp, 
                                                      iWindowKey * iHeads, iWindow, None))
         return false;
      //--- Sum and normilize attention
      if(IsStopped() || !SumAndNormilize(temp, inputs, temp, iWindow, true))
         return false;
     }
//---
   return true;
  }

然后我们转到下一个嵌套层。所有层处理完毕,随即我们就会终止该方法。

以类似方式,但按相反的顺序,我们为误差梯度分派方法 CNeuronSTNNEncoder::calcInputGradients 构造算法。在参数中,该方法仍接收指向上一层对象的指针。然而,这一次,我们必须为其传递在模型输出处与输入影响相对应的误差梯度。

bool CNeuronSTNNEncoder::calcInputGradients(CNeuronBaseOCL *prevLayer)
  {
   if(CheckPointer(prevLayer) == POINTER_INVALID)
      return false;
//---
   CBufferFloat *out_grad = Gradient;
   CBufferFloat *kv_g = KV_Tensors.At(KV_Tensors.Total() - 1);

在方法主体中,如前,我们检查所接收指针的正确性。我们还声明了局部变量,临时存储指向数据缓冲区对象的指针。

接下来,我们声明一个循环来迭代编码器的嵌套层。

   for(int i = int(iLayers - 1); (i >= 0 && !IsStopped()); i--)
     {
      if(i == int(iLayers - 1) || (i + 1) % iLayersToOneKV == 0)
         kv_g = KV_Tensors.At((i / iLayersToOneKV) * 2 + 1);
      //--- Split gradient to multi-heads
      if(IsStopped() ||
        !ConvolutionInputGradients(FF_Weights.At(i * (optimization == SGD ? 6 : 9)), out_grad, 
                                   AO_Tensors.At(i * 2), AO_Tensors.At(i * 2 + 1), iWindowKey * iHeads, iWindow, None))
         return false;

在循环主体中,我们首先在注意力头之间分派从后续层获得的误差梯度。之后,我们将在 QueryKeyValue 实体级别检测误差。

      //--- Passing gradient to query, key and value
      if(i == int(iLayers - 1) || (i + 1) % iLayersToOneKV == 0)
        {
         if(IsStopped() ||
           !AttentionInsideGradients(QKV_Tensors.At(i * 2), QKV_Tensors.At(i * 2 + 1), 
                                     KV_Tensors.At((i / iLayersToOneKV) * 2), kv_g, 
                                     S_Tensors.At(i * 2), AO_Tensors.At(i * 2 + 1)))
            return false;
        }
      else
        {
         if(IsStopped() || 
           !AttentionInsideGradients(QKV_Tensors.At(i * 2), QKV_Tensors.At(i * 2 + 1), 
                                     KV_Tensors.At((i / iLayersToOneKV) * 2), GetPointer(Temp), 
                                     S_Tensors.At(i * 2), AO_Tensors.At(i * 2 + 1)))
            return false;
         if(IsStopped() || !SumAndNormilize(kv_g, GetPointer(Temp), kv_g, iWindowKey, false, 0, 0, 0, 1))
            return false;
        }

注意算法的分支,它与根据当前层将误差梯度分派到 键-值 张量的不同方式相关联。

接下来,我们将误差梯度从 Query 实体传播到前馈模块,同时参考残差连接。

      CBufferFloat *inp = FF_Tensors.At(i * 6);
      CBufferFloat *temp = FF_Tensors.At(i * 6 + 3);
      if(IsStopped() || 
        !ConvolutionInputGradients(QKV_Weights.At(i * (optimization == SGD ? 2 : 3)), QKV_Tensors.At(i * 2 + 1), 
                                                                  inp, temp, iWindow, iWindowKey * iHeads, None))
         return false;
      //--- Sum and normilize gradients
      if(IsStopped() || !SumAndNormilize(out_grad, temp, temp, iWindow, false, 0, 0, 0, 1))
         return false;

如有必要,我们会在 KeyValue 实体上添加影响度误差。

      if((i % iLayersToOneKV) == 0)
        {
         if(IsStopped() || 
           !ConvolutionInputGradients(KV_Weights.At(i / iLayersToOneKV * (optimization == SGD ? 2 : 3)), kv_g, inp, 
                                                         GetPointer(Temp), iWindow, 2 * iWindowKey * iHeadsKV, None))
            return false;
         if(IsStopped() || !SumAndNormilize(GetPointer(Temp), temp, temp, iWindow, false, 0, 0, 0, 1))
            return false;
        }

然后,我们经由前馈模块传播误差梯度。

      //--- Passing gradient through feed forward layers
      if(IsStopped() ||
        !ConvolutionInputGradients(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 2), out_grad, 
                                   FF_Tensors.At(i * 6 + 1), FF_Tensors.At(i * 6 + 4), 4 * iWindow, iWindow, None))
         return false;
      inp = (i > 0 ? FF_Tensors.At(i * 6 - 4) : prevLayer.getOutput());
      temp = (i > 0 ? FF_Tensors.At(i * 6 - 1) : prevLayer.getGradient());
      if(IsStopped() || 
        !ConvolutionInputGradients(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 1), FF_Tensors.At(i * 6 + 4), 
                                                                           inp, temp, iWindow, 4 * iWindow, LReLU))
         return false;
      out_grad = temp;
     }
//---
   return true;
  }

继续循环迭代,直到所有嵌套层处理完毕,最后将误差梯度传播到前一层。

一旦误差梯度分派完毕,下一步就是优化模型参数,从而把总体预测误差最小化。这些操作在 CNeuronSTNNEncoder::updateInputWeights 方法中实现。它的算法完全复制自父类的类似方法,唯一的区别是数据缓冲区的规范。因此,我们于此不再深究其细节,我鼓励您独立查看随附的材料。编码器类及其所有方法的完整代码也可以在那里找到。

2.2STNN 解码器

实现编码器之后,我们转到工作的第二部分,其中涉及为 STNN 方法开发解码器算法。在此,我们将遵循构造编码器时所用的相同原则。具体来说,我们将尝试尽可能多地使用以前开发的代码。

当我们开始实现解码器算法时,重点是要注意相较于编码器的一个关键区别:新类将继承自交叉注意力对象。这是必需的,因为该层将映射时空信息。新类的完整结构如下所示。

class CNeuronSTNNDecoder   :  public CNeuronMLCrossAttentionMLKV
  {
protected:
   CNeuronSTNNEncoder      cEncoder;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Context) override;
   virtual bool      AttentionOut(CBufferFloat *q, CBufferFloat *kv, CBufferFloat *scores, CBufferFloat *out) override;
   //---
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, 
                                       CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Context) override;

public:
                     CNeuronSTNNDecoder(void) {};
                    ~CNeuronSTNNDecoder(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, 
                          uint window_kv, uint heads_kv, uint units_count, uint units_count_kv, uint layers, 
                          uint layers_to_one_kv, ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const   {  return defNeuronSTNNDecoder;   }
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

注意,在该类中,我们声明了一个之前创建的编码器嵌套对象。不过,我应当澄清一下,在这种情况下,它的作用略有不同。

回顾本文第 1 部分中所讲述方法的理论描述,您可以看到负责识别时空依赖关系的模块之间的相似之处。区别在于所分析输入数据的类型。在空间依赖关系模块中,将在短时间内分析大量参数,而在时间依赖关系模块中,将针对特定历史区段分析目标变量。尽管存在这些差异,但算法非常相似。因此,在这种情况下,我们使用嵌套的编码器来识别目标变量的时间依赖关系。

我们回到我们的方法算法描述。附加嵌套对象的声明,即使是静态的,也要求我们覆盖类初始化方法 Init。尽管如此,我们重申复用以前开发的组件的承诺已经取得了成果:新的初始化方法非常简单。

bool CNeuronSTNNDecoder::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, 
                              uint heads, uint window_kv, uint heads_kv, uint units_count, uint units_count_kv, 
                              uint layers, uint layers_to_one_kv, ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!cEncoder.Init(0, 0, open_cl, window, window_key, heads, heads_kv, units_count, layers, layers_to_one_kv, 
                                                                                        optimization_type, batch))
      return false;
   if(!CNeuronMLCrossAttentionMLKV::Init(numOutputs, myIndex, open_cl, window, window_key, heads, window_kv, heads_kv, 
                                        units_count, units_count_kv, layers, layers_to_one_kv, optimization_type, batch))
      return false;
//---
   return true;
  }

在此,我们简单地调用嵌套的编码器和父类的方法,相应参数搭配相同的参数值。然后,我们的任务仅限于验证操作结果,并将获取的布尔值返回给调用程序。

在前馈反向传播方法中观察到类似的方式。举例,在前向通验方法中,我们首先调用相关的编码器方法来识别目标变量值之间的时间依赖关系。然后,我们将这些识别出的时间依赖关系,与通过该方法的上下文参数从 STNN 模型的编码器获得的空间依赖关系保持一致。此操作是由继承自父类的前馈机制执行。

bool CNeuronSTNNDecoder::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Context)
  {
   if(!cEncoder.FeedForward(NeuronOCL, Context))
      return false;
   if(!CNeuronMLCrossAttentionMLKV::feedForward(cEncoder.AsObject(), Context))
      return false;
//---
   return true;
  }

有几处值得强调,我们偏离了由 STNN 方法作者提议的算法。虽然我们保留了整体概念,但我们在如何实现所提议方式时采取了很大的自由度。

我们保留的部分:

  • 识别时间依赖性。
  • 预测目标变量值的时间和空间依赖关系的对齐。

不过,与编码器一样,我们用到由两个全连接层组成的前馈模块,替代了作者提议的单一层。这既适用于识别时间依赖关系之前的数据筛选,也适用于在解码器输出中预测目标变量值。

此外,为了实现交叉注意力,我们用到了父类的前馈通验,它实现了经典的多层交叉注意力算法,且注意力模块和前馈之间存在残差连接。这与 STNN 方法作者提议的交叉注意力算法不同。

尽管如此,我相信这种实现是合理的,特别是考虑到我们的实验目标是最大限度地复用以前开发的组件。

我还想提请注意这样一个事实,即尽管在时间依赖、和交叉注意力模块中用到了多层结构,但解码器的整体架构仍然是单层的。换言之,我们首先判定多层嵌套编码器中的时态依赖关系。然后,多层交叉注意力模块在预测目标变量值之前,比较时间和空间依赖关系。

逆向通验方法的构造方式按类似途径。但我们现在不再赘述。我建议您使用附件中提供的代码熟悉它们。

我们对新对象的架构和算法的讨论到此结束。附件中提供了这些组件的完整代码。

2.3模型架构

在探索了所提议 STNN 方法的算法实现之后,我们现在转去讨论它们在可训练模型中的实际应用。重点注意的是,所提议算法中的编码器解码器针对不同的输入数据进行操作。这种区别促使我们将它们作为单独的模型实现,其架构在 CreateStateDescriptions 方法中定义。

该方法的参数包括两个指向动态数组的指针,于其中定义相应模型的架构。

bool CreateStateDescriptions(CArrayObj *&encoder, CArrayObj *&decoder)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
//---
   if(!decoder)
     {
      decoder = new CArrayObj();
      if(!decoder)
         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 = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

接下来是 STNN 编码器层。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSTNNEncoder;
   descr.count = HistoryBars;
   descr.window = BarDescr;
   descr.window_out = 32;
   descr.layers   =  4;
   descr.step = 2;
     {
      int ar[] = {8, 4};
      if(ArrayCopy(descr.heads, ar) < (int)ar.Size())
         return false;
     }
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

在此,我们用到 4 个嵌套的编码器层,每层使用 Query 实体的 8 个注意力头,以及 4 个用于键-值张量。此外,一个 键-值 张量用于 2 个嵌套的编码器层。

编码器模型的架构至此结束。我们将在解码器中使用其输出。

我们将目标变量的历史值投喂到解码器。所分析历史深度与我们规划的横向范围相对应。

//--- Decoder
   decoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = (NForecast * ForecastBarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.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(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

接下来是 STNN 解码器层。它的架构还包括 4 个嵌套的时态层、和交叉注意力层。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSTNNDecoder;
     {
      int ar[] = {NForecast, HistoryBars};
      if(ArrayCopy(descr.units, ar) < (int)ar.Size())
         return false;
     }
     {
      int ar[] = {ForecastBarDescr, BarDescr};
      if(ArrayCopy(descr.windows, ar) < (int)ar.Size())
         return false;
     }
     {
      int ar[] = {8, 4};
      if(ArrayCopy(descr.heads, ar) < (int)ar.Size())
         return false;
     }
   descr.window_out = 32;
   descr.layers   =  4;
   descr.step = 2;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

解码器输出处,我们期待获得目标变量的预测值。我们将批量归一化层中提取的统计变量加到它们当中。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronRevInDenormOCL;
   descr.count = ForecastBarDescr * NForecast;
   descr.activation = None;
   descr.optimization = ADAM;
   descr.layers = 1;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

然后,我们对齐预测时间序列的频域特征。

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFreDFOCL;
   descr.window = ForecastBarDescr;
   descr.count =  NForecast;
   descr.step = int(true);
   descr.probability = 0.7f;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

参与者评论者模型的架构与之前的文章相同,并在 CreateDescriptions 方法中呈现,其可在本文附件中找到(文件 “...\Experts\STNN\Trajectory.mqh”)。

2.4模型训练程序

将环境状态编码器拆分成两个模型,需要修改这些模型的训练程序。除了将算法拆分为两个模型之外,还对输入数据和目标值的准备工作进行了修改。这些调整将举环境状态编码器 “...\Experts\STNN\StudyEncoder.mq5” 的训练 EA 示例来讨论。

在该 EA 框架内,我们训练一个模型来预测某个规划好横向范围内即将到来的价格走势,足以在特定时刻做出交易决策。

在本文内,我们不会详细讲述该程序的所有过程,而仅研究模型训练方法 Train。此处我们首先判定从经验回放缓冲区中选择出的轨迹概率,在真实历史数据上的实际表现。

void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);
//---
   vector<float> result, target, state;
   matrix<float> mstate = matrix<float>::Zeros(1, NForecast * ForecastBarDescr);
   bool Stop = false;

我们还为局部变量声明了必要的最小值。之后,我们组织一个循环来训练模型。循环迭代的次数由用户在 EA 的外部参数中定义。

   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;
        }
      state.Assign(Buffer[tr].States[i].state);
      if(MathAbs(state).Sum() == 0)
        {
         iter--;
         continue;
        }

在循环主体中,我们对轨迹及其状态进行采样,以便执行模型优化迭代。我们首先调用编码器的前馈通验方法,检测所分析变量之间的空间依赖关系。

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

接下来,我们为解码器准备输入。通常,我们假设规划的横向范围小于所分析历史的深度。因此,我们首先将所分析环境状态的历史数据传送到矩阵之中。我们调整它的大小,如此这般矩阵的每一行都表示来自一个历史柱线的数据。然后我们修剪矩阵。结果矩阵的行数应与规划横向范围相对应,而列数应与目标变量匹配。

      mstate.Assign(state);
      mstate.Reshape(HistoryBars, BarDescr);
      mstate.Resize(NForecast, ForecastBarDescr);
      bStateD.AssignArray(mstate);

此处需要注意的是,在准备训练数据集时,我们首先记录了每根柱线的价格走势参数。这些是我们将规划的数值。因此,我们取矩阵的第一列。

然后,将结果矩阵中的数值传送到数据缓冲区之中,并经由解码器执行前馈通验。

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

执行前馈通验之后,我们需要优化模型参数。为此,我们需要准备预测变量的目标值。此操作类似于为解码器准备输入。不过,这些操作是依据后续历史数值上实现的。

      //--- Collect target data
      mstate.Assign(Buffer[tr].States[i + NForecast].state);
      mstate.Reshape(HistoryBars, BarDescr);
      mstate.Resize(NForecast, ForecastBarDescr);
      if(!Result.AssignArray(mstate))
         continue;

执行解码器反向传播通验。在此,我们优化解码器参数,并将误差梯度传递至编码器。

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

之后,我们优化编码器参数。

      if(!Encoder.backPropGradient((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", "Decoder", 
                                       percent, Decoder.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }

成功完成所有训练迭代之后,我们将记录模型训练的结果,并初始化程序关闭过程。

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

模型训练算法的主题到此结束。您可在附件中找到此处所用的所有程序的完整代码。


3. 测试

在本文中,我们概述了一种基于时空信息 STNN 预测时间序列的新方法。我们利用 MQL5 实现了我们提议方法的愿景。现在是时候评估我们的努力成果了。

如常,我们依据来自 EURUSD 金融产品 2023 年全年的 H1 时间帧历史数据,训练我们的模型。然后,我们采用 2024 年 1 月的数据在 MetaTrader 5 策略测试器中测试已训练模型。很容易注意到,测试区段紧随训练区间之后。这种方式紧密模拟了模型操作的真实条件。

为了训练模型来预测后续的价格走势,我们采用了在本系列前几篇文章的准备过程中收集的训练数据集。如您所知,训练该模型仅依赖于历史价格走势数据、和正在分析的指标数据。个体动作不会影响所分析数据,故我们可以训练环境状态编码器模型,而无需定期更新训练数据集。

我们继续训练过程,直到预测误差稳定下来。不幸的是,在该阶段,我们遭遇到极大失望。我们的模型为即将到来的价格走势提供期待预测时失手了,仅指示出趋势的大致方向。

尽管预测的走势看起来是线性的,但数字化值仍然展现出微小的波动。然而,这些波动如此之微小,以至于它们无法在图表上可视化。这就浮现出一个问题:这些波动是否足够为我们的参与者构建可盈利的策略?

我们通过定期更新训练数据集来迭代训练参与者评论者模型。如您所知,定期更新对于更准确地评估训练期间政策偏移时参与者的动作是必要的。

不幸的是,我们无法训练参与者政策,从而在测试数据集上产生持续盈利。

尽管如此,我们承认在工作期间,我们的实现与原始方法存在重大偏差,这可能会对获取结果产生影响。


结束语

在本文中,我们探讨了另一种基于时空变换器神经网络(STNN)的时间序列预测方法。该模型结合了时空信息(STI)变换方程,和变换器结构的优点,可有效地执行短期时间序列的多步骤预测。

STNN 使用 STI 方程,其将多维变量的空间信息转换为目标变量的时态信息。这等同于增加了样本量,有助于解决短期数据不足的问题。

为了提高数值预测的准确性,STNN 包括一个持续注意力机制,令模型能够更好地关注数据的重要层面。

在本文的实践部分,我们利用 MQL5 语言实现了我们对所提议方法的愿景。不过,我们制作的版本与原始算法有重大偏差,这可能会影响我们的实验结果。


参考

  • 时间序列预测的时空转换器神经网络
  • 运用预期学习机依据短期时间序列预测未来动态
  • 本系列的其它文章

  • 文章中所用程序

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

    附加的文件 |
    MQL5.zip (1473.08 KB)
    最近评论 | 前往讨论 (1)
    Hao Qing Feng
    Hao Qing Feng | 27 3月 2025 在 05:00
    量化收益很小,无法跟人的交易比较
    重塑经典策略(第四部分):标普500指数与美国国债 重塑经典策略(第四部分):标普500指数与美国国债
    在本系列文章中,我们使用现代算法分析经典交易策略,以确定是否可以利用人工智能改进这些策略。在今天的文章中,我们将重新审视一种利用标普500指数与美国国债之间关系的经典交易方法。
    创建 MQL5-Telegram 集成 EA 交易 (第一部分):从 MQL5 发送消息到 Telegram 创建 MQL5-Telegram 集成 EA 交易 (第一部分):从 MQL5 发送消息到 Telegram
    在本文中,我们在 MQL5 中创建一个 EA 交易,以使用机器人向 Telegram 发送消息。我们设置必要的参数,包括机器人的 API 令牌和聊天 ID,然后通过执行 HTTP POST 请求来传递消息。之后,我们将处理响应以确保成功传达,并排除故障时出现的任何问题。这确保我们能够通过创建的机器人将消息从 MQL5 发送到 Telegram。
    重构经典策略(第五部分):基于USDZAR的多品种分析 重构经典策略(第五部分):基于USDZAR的多品种分析
    在本系列文章中,我们重新审视经典策略,看看是否可以使用人工智能来改进这些策略。在今天的文章中,我们将研究一种使用一篮子具有相关性的金融产品来进行多品种分析的流行策略,我们将重点关注货币对 USDZAR。
    交易中的神经网络:基于双注意力的趋势预测模型 交易中的神经网络:基于双注意力的趋势预测模型
    我们继续讨论时间序列的分段线性表示的运用,这在前一篇文章中已经开始。今天,我们要看看如何将该方法与其它时间序列分析方法相结合,从而提高价格趋势预测品质。