English Русский Español Deutsch 日本語 Português
preview
交易中的神经网络:具有预测编码的混合交易框架(StockFormer)

交易中的神经网络:具有预测编码的混合交易框架(StockFormer)

MetaTrader 5交易系统 |
468 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

强化学习(RL)正在金融领域越来越多地应用于复杂问题,包括交易策略的开发、以及投资组合管理。训练模型用于分析资产价格走势、交易量、和技术指标的历史数据。然而,大多数现有方法假设所分析数据完全捕捉到资产之间的所有相互依赖关系。但实际上,这种情况很罕有,尤其是在嘈杂、且高度波动的市场环境之中。

传统方式往往未能同时考虑短期和长期回报预测,以及资产间的相关性。然而,成功的投资策略通常依赖于对这些因素的深刻理解。为此,论文《StockFormer:搭配预测编码的学习混合交易机器》讲述了 StockFormer,这是一种结合预测编码与强化智代灵活性的混合交易系统。预测编码广泛应用于自然语言处理、和计算机视觉,能够从嘈杂的输入数据中提取具有信息价值的隐藏状态,这一能力在金融应用中尤为宝贵。

StockFormer 整合了三个修改过的变换器分支,分别负责捕捉市场动态的不同方面:

  • 长期趋势
  • 短期趋势
  • 跨资产依赖性

每个分支都集成了多元化多头注意力DMH-Attn)机制,以多个并行模块替换单个前馈模块来扩展雏形变换器。这令模型能够在子空间中捕捉多元化的时态形态,同时保留重要信息。

为了优化交易策略,这三个分支产生的潜在状态通过多头注意力自适应融合到统一状态空间,随后供强化学习智代所用。

政策学习采用参与者–评论者方法运作。关键是,来自评论者的梯度反馈会被反向传播,以改进预测编码模块,确保预测建模与政策优化间的紧密集成。

在三个公开数据集上的实验表明,StockFormer 在预测准确性和投资回报方面均远超现有方法。


StockFormer 算法

StockFormer 定位于通过强化学习(RL)预测金融市场,并制定交易决策。传统方法的一个关键局限在于它们无法有效为资产与其未来趋势之间的动态依赖关系建模。这在条件变化迅速、且不可预测的市场中尤为重要。StockFormer 经由两个核心阶段解决这一挑战:预测编码交易策略学习

在第一阶段,StockFormer 利用自我监督学习从嘈杂的市场数据中提取隐藏形态。这令模型能够捕捉短期和长期的动态,以及交叉资产间的依赖关系。通过这种方式,模型提取出重要的隐藏状态,随后在下一步骤中用于制定交易决策。

金融市场在多种资产间展现出高度多元化的时态模式,令从原产数据中提取有效表述变得复杂。为解决这一点,StockFormer 修改了雏形变换器的多头注意力机制,将单一的前馈网络(FFN)替换为一组并行 FFN。无需增加参数的数量,该设计增强了多头注意力分解特征的能力,从而改善跨子空间异构时态模式的建模。

该强化模块称为多头注意力多元化DMH-Attn)。对于 d-模型查询主键、和数值实体,过程始于将多头注意力的输出特征 Z 按通道维度划分为 h 组,其中 h 是注意力头的数量。然后针对 Z 中的每个组应用专门的 FFN

此处 MH-Attn 表示多头注意力。𝑓𝑖 是每个 FFN 头的输出特征,包含两条线性投影,两者间有 ReLU 激活。

StockFormer 中,现代化变换器中的每条分支都切分为两个模块:编码器和解码器。两者均会在预测编码训练期间用到,但策略优化期间仅用到编码器。该模型由 L 个编码层和 M 个解码器层组成。最终编码输出 XLenc 则被投喂至每个解码器层。第 l 个编码器层,和第 m 个解码器层的计算过程可书写如下:

  • 编码层:

  • 解码层:

此处 Xl,encXm,dec 分别是编码器和解码器的输出。第一编码器和解码层的输入由带有位置嵌入的原产数据组成。最终的解码器输出会经由一个投影层,以便生成预测编码结果。

交叉资产间依赖性模块识别跨时间序列间的动态相关性。在每个时间步 t,它处理编码器和解码器的雷同输入。对于股市数据,框架作者用到了 MACDRSI、和 SMA 这样的技术指标。

在训练期间,数据被切分为两部分:

  1. 协方差矩阵。协方差矩阵是在时间 t 之前,所有资产在固定时间窗口内的每日收盘价计算的。
  2. 掩码统计。 该部分包含一半的时间序列随机用零掩码,而其余部分作为可见特征。测试时,我们使用完整(未掩码)数据。

目标是基于协方差矩阵、及其余特征重建掩码的统计数据。这种预测编码任务迫使变换器编码器学习跨资产的相互依赖关系。

StockFormer 中的短期和长期预测模块,旨在预测不同时间横向范围内各资产的回报率。

短期预测模块预测资产的次日回报(H = 1)。为此,我们分析后的 T-日统计数据投喂至编码器。解码器接收相同的统计数据,但针对所分析的时刻。

长期模块的运作类似,但返回更长横向范围的预测。这鼓励模型捕捉更广泛的市场动态。

在训练短期和长期预测模块时,采用了一个组合损失函数,即回归误差和股票排名误差共事。回归误差将预测收益与实际收益之间的间隙最小化,而排名误差确保了收益更高资产的优先级。

因此,模型的两个分支令 StockForformer 能够从不同时间视角捕捉市场动态,允许强化学习智代制定更准确、更明智的交易决策。

在训练的第二阶段,Stockformer 通过多头注意力模块级联,将三种潜在表示类型:srelat,tslong,t、和 sshort,t 整合到统一状态空间 St。这一过程始于短期和长期预测的融合。此处,长期预测表示作为查询,因其受短期噪音影响较少。这一步的成果随后与资产相互依赖的潜在表示并齐,作为后续注意力模块中的主键数值

随后,训练模型以便判定采用参与者–评论者方式的最优交易策略。StockForformer 的一个主要优势是整合了预测编码,以及政策优化阶段。评论者的评估有助于精化潜在表示提取的品质,令模型能够更有效地分析资产间关系,并更好地处理输入数据中的噪音。

下面提供了 StockFormer 框架的原始可视化。


实现 MQL5 版本

在介绍过 StockFormer 的理论基础后,我们转向所提议方法的 MQL5 实现。正如理论阐述中所强调,关键架构修改在于引入多头 FeedForward 模块。实现该模块是我们工作的第一步。

StockFormer 框架作者提出的多头 FeedForward 模块实现中,针对每个序列元素的多头自注意力模块输出被切分为 h 个相等组,每组由各自拥有唯一可训练参数的的 MLP 处理。

重点要注意,此处形成“头”的方式与传统多头注意力模块不同。在多头自注意力中,查询、主键、和数值实体的多个版本由单一序列元素嵌入生成。然而,在这种情况下,StockForformer 的作者提议将序列元素的表示向量直接拆分为若干个相等的组。每组随后由各自的 MLP 进行处理。当然,该方式允许在无需增加可训练参数数量的情况下创建多个头。甚至,输出张量保持相同的维度,无需如多头自关注力那样需要投影层。然而,因此我们无法像以前那样使用现有卷积层。这意味着我们必须找到替代方案。

一方面,我们可以考虑置换三维张量,以便适配卷积层的解,并对一维序列进行独立分析。但 StockForformer 包含了大量这样的层。因此,在每层的 FeedForward 模块前后转置数据,将显著提升训练和推断时间。因此,下定决心设计一个卷积层的多头变体。然而,在主程序中实现这一新组件之前,OpenCL 端需要做一些调整。

扩展 OpenCL 程序


我们从构造新的多头卷积 FeedForwardMHConv 层的前馈通验内核开始。应当注意的是,参数结构和算法的一部分是从现有卷积层的内核中借用的。卷积头标识符和头总数作为任务空间中的一个额外维度被引入。

__kernel void FeedForwardMHConv(__global float *matrix_w,
                                __global float *matrix_i,
                                __global float *matrix_o,
                                const int inputs,
                                const int step,
                                const int window_in,
                                const int window_out,
                                const int activation
                               )
  {
   const size_t i = get_global_id(0);
   const size_t h = get_global_id(1);
   const size_t v = get_global_id(2);
   const size_t total = get_global_size(0);
   const size_t heads = get_global_size(1);

在内核主体中,我们识别跨任务空间所有维度上的线程。然后判定每个卷积头的输入和输出维度,以及对应所分析元素的全局数据缓冲区中的偏移量。

   const int window_in_h = (window_in + heads - 1) / heads;
   const int window_out_h = (window_out + heads - 1) / heads;
   const int shift_out = window_out * i + window_out_h * h;
   const int shift_in = step * i + window_in_h * h;
   const int shift_var_in = v * inputs;
   const int shift_var_out = v * window_out * total;
   const int shift_var_w = v * window_out * (window_in_h + 1);
   const int shift_w_h = h * window_out_h * (window_in_h + 1);

一旦准备工作完成后,我们转到构造输入数据与可训练滤波器之间的卷积运算。在单线程内,我们对输入数据的一个头、及其对应的滤波器进行卷积。为达成这一点,我们规划了一个嵌套环路系统。外环迭代覆盖对应给定卷积头的输出层元素。

   float sum = 0;
   float4 inp, weight;
   int stop = (window_in_h <= (inputs - shift_in) ? window_in_h : (inputs - shift_in));
//---
   for(int out = 0; (out < window_out_h && (window_out_h * h + out) < window_out); out++)
     {
      int shift = (window_in_h + 1) * out + shift_w_h;

在外环内,我们首先计算在可训练参数缓冲区的偏移量。然后我们初始化一个内部环路,贯穿应用于输入数据的卷积窗口元素。

      for(int k = 0; k <= stop; k += 4)
        {
         switch(stop - k)
           {
            case 0:
               inp = (float4)(1, 0, 0, 0);
               weight = (float4)(matrix_w[shift_var_w + shift + window_in_h], 0, 0, 0);
               break;
            case 1:
               inp = (float4)(matrix_i[shift_var_in + shift_in + k], 1, 0, 0);
               weight = (float4)(matrix_w[shift_var_w + shift + k], matrix_w[shift_var_w + shift + window_in_h], 0, 0);
               break;
            case 2:
               inp = (float4)(matrix_i[shift_var_in + shift_in + k],
                              matrix_i[shift_var_in + shift_in + k + 1], 1, 0);
               weight = (float4)(matrix_w[shift_var_w + shift + k], matrix_w[shift_var_w + shift + k + 1],
                                 matrix_w[shift_var_w + shift + window_in_h], 0);
               break;
            case 3:
               inp = (float4)(matrix_i[shift_var_in + shift_in + k], matrix_i[shift_var_in + shift_in + k + 1],
                              matrix_i[shift_var_in + shift_in + k + 2], 1);
               weight = (float4)(matrix_w[shift_var_w + shift + k], matrix_w[shift_var_w + shift + k + 1],
                                 matrix_w[shift_var_w + shift + k + 2], matrix_w[shift_var_w + shift + shift_w_h]);
               break;
            default:
               inp = (float4)(matrix_i[shift_var_in + shift_in + k], matrix_i[shift_var_in + shift_in + k + 1],
                              matrix_i[shift_var_in + shift_in + k + 2], matrix_i[shift_var_in + shift_in + k + 3]);
               weight = (float4)(matrix_w[shift_var_w + shift + k], matrix_w[shift_var_w + shift + k + 1],
                                 matrix_w[shift_var_w + shift + k + 2], matrix_w[shift_var_w + shift + k + 3]);
               break;
           }

为了优化计算,我们使用内置的向量乘法函数,其能够更高效地利用处理器资源。相应地,我们首先将外部缓冲区的必要数值加载到局部向量变量当中,之后在进入内环的下一次迭代前,执行向量乘法。

         sum += IsNaNOrInf(dot(inp, weight), 0);
        }

内环的所有迭代完成后,我们应用激活函数,并将结果存储在相应的输出缓冲区元素之中。然后,该过程继续进行外环的下一次迭代。

      sum = IsNaNOrInf(sum, 0);
      //---
      matrix_o[shift_var_out + out + shift_out] = Activation(sum, activation);;
     }
  }

到所有环路迭代结束时,输出缓冲区包含全部所需数值,内核执行完成。

现在我们继续构造反向传播算法。此处必须注意,与前馈通验不同,我们不能将卷积头标识符作为任务空间中的一个维度引入。

回想在梯度分布期间,我们会累计每个输入元素对输出的影响值。当卷积窗口的步幅小于其大小时,单个输入元素可能会影响多个卷积头的结果张量元素。

出于该原因,在此情况下,我们引入注意力头数量作为 CalcHiddenGradientMHConv 内核的额外外部参数。在累计误差梯度的期间,判定特定卷积头的标识符。

__kernel void CalcHiddenGradientMHConv(__global float *matrix_w,
                                       __global float *matrix_g,
                                       __global float *matrix_o,
                                       __global float *matrix_ig,
                                       const int outputs,
                                       const int step,
                                       const int window_in,
                                       const int window_out,
                                       const int activation,
                                       const int shift_out,
                                       const int heads
                                      )
  {
   const size_t i = get_global_id(0);
   const size_t inputs = get_global_size(0);
   const size_t v = get_global_id(1);

在内核主体中,我们识别二维任务空间中的当前线程,其指向输入数据元素和单变量序列标识符。之后,我们判定常数值,包括数据缓冲区的偏移,以及窗口维度、和单个卷积头的滤波器数量。 

   const int shift_var_in = v * inputs;
   const int shift_var_out = v * outputs;
   const int shift_var_w = v * window_out * (window_in + 1);
   const int window_in_h = (window_in + heads - 1) / heads;
   const int window_out_h = (window_out + heads - 1) / heads;

此处我们还定义了受到所分析数据元素影响的输出窗口范围。

   float sum = 0;
   float out = matrix_o[shift_var_in + i];
   const int w_start = i % step;
   const int start = max((int)((i - window_in + step) / step), 0);
   int stop = (w_start + step - 1) / step;
   stop = min((int)((i + step - 1) / step + 1), stop) + start;
   if(stop > (outputs / window_out))
      stop = outputs / window_out;

准备工作结束后,我们开始收集来自所有相关张量的误差梯度。为达成这一点,我们规划了一个环路系统。外部环路会在先前定义的窗口内迭代覆盖依赖元素。

   for(int k = start; k < stop; k++)
     {
      int head = (k % window_out) / window_out_h;

在外环的主体中,我们首先定义结果张量单一元素的卷积头,然后规划一个嵌套环路迭代覆盖滤波器。

      for(int h = 0; h < window_out_h; h ++)
        {
         int shift_g = k * window_out + head * window_out_h + h;
         int shift_w = (stop - k - 1) * step + (i % step) / window_in_h +
                       head * (window_in_h + 1) + h * (window_in_h + 1);
         if(shift_g >= outputs || shift_w >= (window_in_h + 1) * window_out)
            break;
         float grad = matrix_g[shift_out + shift_g + shift_var_out];
         sum += grad * matrix_w[shift_w + shift_var_w];
        }
     }

在嵌套环路主体内,我们在转到环路系统的下一次迭代之前,会积累单个卷积头的所有滤波器间的误差梯度。

一旦所有依赖元素的梯度收集完毕,累积值会通过激活函数的导数进行调整,结果则存储在数据缓冲区对应的元素之中:

   matrix_ig[shift_var_in + i] = Deactivation(sum, out, activation);
  }

此刻,梯度分布内核的操作完成。至于参数更新内核,我建议您独立复查。完整的 OpenCL 程序代码见本文附录。现在我们将转到下一阶段的工作:在主程序中实现多头卷积神经层。

多头卷积层


为了在主程序端实现卷积功能,我们引入了一个新对象 CNeuronMHConvOCL。正如预期的那样,现有卷积层被当作父类。新对象的结构如下所示。

class CNeuronMHConvOCL  :  public CNeuronConvOCL
  {
protected:
   uint              iHeads;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronMHConvOCL(void)  :  iHeads(1)   {};
                    ~CNeuronMHConvOCL(void)  {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                          uint window, uint step, uint window_out, 
                          uint units_count, uint variables, uint heads, 
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const override   {  return defNeuronMHConvOCL;   }
   //--- methods for working with files
   virtual bool      Save(int const file_handle) override;
   virtual bool      Load(int const file_handle) override;
  };

在该结构中,仅引入一个内部变量,存储指定的卷积头数量。处理所需的所有其它对象和变量都继承自父类。此外,前向和后向通验方法被覆盖,作为调用前述核心的包装器。内核调度算法保持不变,因此无需进一步解释。本文将专注于几乎完全从零实现的初始化方法 Init

在方法的参数结构内,仅需添加一个新元素,令调用程序能够传递卷积头的数量。

bool CNeuronMHConvOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                            uint window, uint step, uint window_out, 
                            uint units_count, uint variables, uint heads, 
                            ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronProofOCL::Init(numOutputs, myIndex, open_cl, window, step, 
                             units_count * window_out * variables, ADAM, batch))
      return false;

在方法内部,我们首先调用父采样层的对应初始化方法,其在本例中作为祖先对象。然后我们将外部参数的数值赋值到局部变量。

   iWindowOut = window_out;
   iVariables = variables;
   iHeads = MathMax(MathMin(heads, window), 1);

接下来,我们必须以随机值初始化可训练参数的张量。在如此行事之前,我们先定义该张量的维度。其大小取决于所分析多模序列中单变量级数的数量、滤波器的总数、以及单个头的卷积窗口大小。

   const int window_h = int((iWindow + heads - 1) / heads);
   const int count = int((window_h + 1) * iWindowOut * iVariables);

注意,我们指的是所有卷积头的滤波器总数,同时仅用单个卷积头的卷积窗口。可以直接推断,单个卷积头的可训练参数数量等于每个卷积头的滤波器数量、与其输入窗口大小的乘积,加上一个偏置项(Fi * (W+ 1))。为了得到单个单变量序列的参数总数,我们简单地将该值乘以头数(Fi * (Wi + 1) * H)。显而易见,每个头的滤波器数量乘以头的数量,即可得到用户指定的总滤波器数量。

下一步是检查指向包含可训练参数缓冲区对象的指针有效性,并在必要时创建新对象。

   if(!WeightsConv)
     {
      WeightsConv = new CBufferFloat();
      if(!WeightsConv)
         return false;
     }

我们预留缓冲区中所需的元素数量,并规划一个环路来填充随机值。

   if(!WeightsConv.Reserve(count))
      return false;
   float k = (float)(1 / sqrt(window_h + 1));
   for(int i = 0; i < count; i++)
     {
      if(!WeightsConv.Add((GenerateWeight() * 2 * k - k) * WeightsMultiplier))
         return false;
     }
   if(!WeightsConv.BufferCreate(OpenCL))
      return false;

在成功用随机值填充缓冲区之后,我们会将其传送到 OpenCL 关联环境内存。接下来,我们创建动量缓冲区,填充零值。

   if(!FirstMomentumConv)
     {
      FirstMomentumConv = new CBufferFloat();
      if(!FirstMomentumConv)
         return false;
     }
   if(!FirstMomentumConv.BufferInit(count, 0.0))
      return false;
   if(!FirstMomentumConv.BufferCreate(OpenCL))
      return false;
//---
   if(!SecondMomentumConv)
     {
      SecondMomentumConv = new CBufferFloat();
      if(!SecondMomentumConv)
         return false;
     }
   if(!SecondMomentumConv.BufferInit(count, 0.0))
      return false;
   if(!SecondMomentumConv.BufferCreate(OpenCL))
      return false;
   if(!!DeltaWeightsConv)
      delete DeltaWeightsConv;
//---
   return true;
  }

此刻,我们针对多头卷积层对象 CNeuronMHConvOCL 方法的探讨完结。该类及其所有方法的完整实现可见附录。

多头 FeedForward 模块


我们现在已创建了构造 StockForformer 框架的第一个构建模块。接下来,我们将利用它在新对象 CNeuronMHFeedForward 中实现多头 FeedForward 模块,其结构如下所示。

class CNeuronMHFeedForward   :  public CNeuronBaseOCL
  {
protected:
   CNeuronMHConvOCL  acConvolutions[2];
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronMHFeedForward(void) {};
                    ~CNeuronMHFeedForward(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_out,
                          uint units_count, uint variables, uint heads,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const override   {  return defNeuronMHFeedForward;   }
   //--- methods for working with files
   virtual bool      Save(int const file_handle) override;
   virtual bool      Load(int const file_handle) override;
   //---
   virtual void      SetOpenCL(COpenCLMy *obj) override;
  };

在新对象的结构中,我们声明一个由两个内部多头卷积层组成的数组,并覆盖熟悉的虚拟方法集合。这些内部对象都声明为静态,这允许我们保持构造和析构函数为空。所有声明和继承的对象,都在 Init 方法中执行初始化。

bool CNeuronMHFeedForward::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                                uint window, uint window_out,
                                uint units_count, uint variables, uint heads,
                                ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count * variables, optimization_type, batch))
      return false;

初始化方法接收定义将被创建对象的架构常量。其中一些参数会立即传递给父类对应的初始化方法,以便设置继承的基接口。

然后我们初始化第一个卷积层,指定 GELU 作为其激活函数。

   if(!acConvolutions[0].Init(0, 0, OpenCL, window, window, window_out, units_count, variables, heads, 
                                                                                  optimization, iBatch))
      return false;
   acConvolutions[0].SetActivationFunction(GELU);

之后,我们初始化第二卷积层,这次没有激活函数。

   if(!acConvolutions[1].Init(0, 1, OpenCL, window_out, window_out, window, units_count, variables, heads, 
                                                                                     optimization, iBatch))
      return false;
   acConvolutions[1].SetActivationFunction(None);

应当注意的是,在调用第二卷积层初始化方法时,我们会交换相应的参数,如滤波器数量和输入窗口大小。

在 FeedForward 模块的输出处,会应用含有归一化的残差连接。就此原因,我们不会覆盖模块接口的结果缓冲区。不过,我们会覆盖误差梯度缓冲区,刻令梯度直接从接口传送到第二卷积层对应的缓冲区。

   if(!SetGradient(acConvolutions[1].getGradient(), true))
      return false;
   SetActivationFunction(None);
//---
   return true;
  }

我们还禁用了模块本身的激活函数,并返回执行逻辑的结果给调用程序,完成初始化方法。

一旦初始化完成,我们转到在 feedForward 方法中实现前向通验算法。在这种情况下,实现方式直截了当。我们简单地依次调用内部卷积层的前向通验方法即可。

bool CNeuronMHFeedForward::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   CObject *prev = NeuronOCL;
   for(uint i = 0; i < acConvolutions.Size(); i++)
     {
      if(!acConvolutions[i].FeedForward(prev))
         return false;
      prev = GetPointer(acConvolutions[i]);
     }

然后输出与原始输入汇总,随之在多模序列的元素内进行归一化。

   if(!SumAndNormilize(NeuronOCL.getOutput(), acConvolutions[acConvolutions.Size() - 1].getOutput(),
                       Output, acConvolutions[0].GetWindow(), true, 0, 0, 0, 1))
      return false;
//---
   return true;
  }

该方法完结时将操作的逻辑结果返回给调用程序。

误差梯度分布方法的 calcInputGradients 算法看起来稍微复杂一些,因为我们需要沿两条数据流传播梯度。

bool CNeuronMHFeedForward::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;

该方法的参数包括指向源数据对象的指针;我们会将误差梯度传递到缓冲区,该梯度的分布取决于输入数据对最终模型输出的影响。在方法主体中,我们立即检查所接收指针的相关性。

成功通过控制模块后,我们规划一个贯穿内部卷积层的反向迭代循环,同时顺序调用相关方法。

   for(int i = (int)acConvolutions.Size() - 2; i >= 0; i--)
     {
      if(!acConvolutions[i].calcHiddenGradients(acConvolutions[i + 1].AsObject()))
         return false;
     }

在误差梯度分派到内部对象流水线后,它之后会传播到源数据层。该操作结束了主要工作流程。

   if(!NeuronOCL.calcHiddenGradients(acConvolutions[0].AsObject()))
      return false;

接下来,我们需要沿第二条信息流传播误差梯度。此处的算法分为两条操作分支,取决于源数据激活函数的存在。由于我们没有激活函数,我们简单地将源数据层级的累积误差梯度与输出端的类似值汇总。

   if(NeuronOCL.Activation() == None)
     {
      if(!SumAndNormilize(NeuronOCL.getGradient(), Gradient, NeuronOCL.getGradient(),
                          acConvolutions[0].GetWindow(), false, 0, 0, 0, 1))
         return false;
     }
   else
     {
      if(!DeActivation(NeuronOCL.getOutput(), NeuronOCL.getPrevOutput(), Gradient, NeuronOCL.Activation()) ||
         !SumAndNormilize(NeuronOCL.getGradient(), NeuronOCL.getPrevOutput(), NeuronOCL.getGradient(),
                          acConvolutions[0].GetWindow(), false, 0, 0, 0, 1))
         return false;
     }
//---
   return true;
  }

否则,我们需要先通过源数据激活函数的导数调整模块输出级的误差梯度。只有这样,我们才能将两条信息流的数据汇总。

剩下的就是将操作的结果返回给调用程序,并退出该方法。

调整模块可训练参数,减少整体模型误差的 updateInputWeights 方法留待独立研究。其算法相当直接了当:我们简单地按顺序调用内部对象的对应方法。多头 FeedForward 模块对象 CNeuronMHFeedForward 的完整实现,及其所有方法可在本文附件中找到。

多头注意力的解码器


在构建多头 FeedForward 模块后,我们现在开始构造编码器和解码器对象,以实现多元化的多头注意力。为了实现这些模块的算法,我们引入了新的对象:CNeuronDMHAttentionCNeuronCrossDMHAttention。这些对象的构造结构大致相似。然而,后者的不同之处在于它包含内部交叉注意力模块,并用到两条输入数据来源。在本文范畴内,我提议关注解码器,在于其是更复杂的对象。一旦算法清晰,理解编码器就不会有太大困难。

我们使用 CNeuronRMAT,作为两个对象的父类,它提供了顺序模型的底层算法。

class CNeuronCrossDMHAttention   :  public CNeuronRMAT
  {
protected:
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override { return false; }
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer) override { return false; }
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput,
                                                                   CBufferFloat *SecondGradient, 
                                                        ENUM_ACTIVATION SecondActivation = None) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return false; }
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override;

public:
                     CNeuronCrossDMHAttention(void)  {};
                    ~CNeuronCrossDMHAttention(void)  {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key, uint units_count,
                          uint window_cross, uint units_cross,
                          uint heads, uint layers,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override   const   {  return defNeuronCrossDMHAttention; }
  };

在解码器对象的结构中,我们仅能观察到虚拟方法的覆盖。内部对象结构定义是在初始化方法 Init 当中,其参数包含决定对象架构的关键常量。

bool CNeuronCrossDMHAttention::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                                    uint window, uint window_key, uint units_count,
                                    uint window_cross, uint units_cross, uint heads,
                                    uint layers, ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type, batch))
      return false;

在方法主体中,我们首先调用父类的全连接层初始化方法,以便设置继承的接口。

接下来,清空存储指向模块内部对象指针的动态数组,并创建若干局部变量临时数据存储。

   cLayers.Clear();
   cLayers.SetOpenCL(OpenCL);
   CNeuronRelativeSelfAttention *attention = NULL;
   CNeuronRelativeCrossAttention *cross = NULL;
   CNeuronMHFeedForward *conv = NULL;
   bool use_self = units_count > 0;
   int layer = 0;

一旦准备阶段完成,我们规划一个环路,其迭代次数等于多元化多头注意力解码器指定的内层数量。

   for(uint i = 0; i < layers; i++)
     {
      if(use_self)
        {
         attention = new CNeuronRelativeSelfAttention();
         if(!attention ||
            !attention.Init(0, layer, OpenCL, window, window_key, units_count, heads, optimization, iBatch) ||
            !cLayers.Add(attention)
           )
           {
            delete attention;
            return false;
           }
         layer++;
        }

在环路内,我们首先创建一个相对自注意力模块,分析主输入数据流中的依赖关系。重点要注意,仅当主输入流的序列长度大于 “1” 时,自注意力模块才会实例化。否则,没有可供依赖性分析的数据。

然后我们加入一个相对交叉注意力模块。

      cross = new CNeuronRelativeCrossAttention();
      if(!cross ||
         !cross.Init(0, layer, OpenCL, window, window_key, units_count, heads, 
                              window_cross, units_cross, optimization, iBatch) ||
         !cLayers.Add(cross)
        )
        {
         delete cross;
         return false;
        }
      layer++;

解码器的每一层内部都包含一个多头 FeedForward 模块,之后我们转到环路的下一次迭代。

      conv = new CNeuronMHFeedForward();
      if(!conv ||
         !conv.Init(0, layer, OpenCL, window, 2 * window, units_count, 1, heads, optimization, iBatch) ||
         !cLayers.Add(conv)
        )
        {
         delete conv;
         return false;
        }
      layer++;
     }

内部对象集合全部初始化之后,我们将接口指针替换为这些对象的引用,并结束方法,返回逻辑结果给调用程序。

   SetOutput(conv.getOutput(), true);
   SetGradient(conv.getGradient(), true);
//---
   return true;
  }

feedForward 方法的算法由按顺序调用内部对象相应方法组成。我建议把研究留作独立的练习。取而代之,我们先关注误差梯度分布算法 calcInputGradients

bool CNeuronCrossDMHAttention::calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, 
                                   CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = -1)
  {
   if(!NeuronOCL || !SecondInput || !SecondGradient)
      return false;

该方法的参数包括指向输入数据对象及其对应的误差梯度的指针,操作结果将被写入其中。因此,在方法主体中,我们首先验证收到的指针。

重点要强调,在前馈通验过程中,第二输入数据源被所有解码层的交叉注意力模块平等共用。由此,我们必须聚集所有信息流的误差梯度。如常,在这种情况下,我们需要一个内部缓冲区来存储数据。由于新对象中没有定义这样的缓冲区,我们便用到一个继承自父类的未使用的缓冲区。

首先,我们检查所继承缓冲区的大小,并在必要时进行调整。

   if(PrevOutput.Total() != SecondGradient.Total())
     {
      PrevOutput.BufferFree();
      if(!PrevOutput.BufferInit(SecondGradient.Total(), 0) ||
         !PrevOutput.BufferCreate(OpenCL))
         return false;
     }

接下来,我们以零值初始化第二个数据源的误差梯度缓冲区。这一步确保当前通验的梯度不会与之前的梯度值叠加。

   if(!SecondGradient.Fill(0))
      return false;

然后我们创建局部变量,临时存储数据。

   CObject *next = cLayers[-1];
   CNeuronBaseOCL *current = NULL;

此刻,准备阶段完成,我们启动覆盖内部对象的一个逆向迭代环路。

   for(int i = cLayers.Total() - 2; i >= 0; i--)
     {
      current = cLayers[i];
      if(!current ||
         !current.calcHiddenGradients(next, SecondInput, PrevOutput, SecondActivation))
         return false;
      if(next.Type() == defNeuronCrossDMHAttention)
         if(!SumAndNormilize(SecondGradient, PrevOutput, SecondGradient, 1, false, 0, 0, 0, 1))
            return false;
      next = current;
     }

在该环路内,我们顺序调用内部对象的相应方法,同时持续检查负责分派误差梯度的对象类型。如果遇到交叉注意力模块,我们将第二个数据源的误差梯度加到之前累计的数值当中。

所有环路迭代成功完成之后,我们将误差梯度传播回主流的输入数据。

   if(!NeuronOCL.calcHiddenGradients(next, SecondInput, PrevOutput, SecondActivation))
      return false;
   if(next.Type() == defNeuronCrossDMHAttention)
      if(!SumAndNormilize(SecondGradient, PrevOutput, SecondGradient, 1, false, 0, 0, 0, 1))
         return false;
//---
   return true;
  }

在该阶段,我们再次验证执行梯度分派对象的类型,并在必要时将第二信息流的误差梯度加到累积值当中。最后,我们返回一个逻辑结果给调用程序,结束该方法。

我们针对多头注意力解码器构建算法的综述至此完毕。该对象及其所有方法的完整实现可在附件中找到。在那里您还会找到本文中所有其它对象的完整代码。

我们现已实现 StockFormer 框架的核心架构单元 — 变换器架构中编码器和解码器的多元化多头注意力模块。然而,StockFormer 的作者也提出了一个两级训练过程,具有可训练模型之间复杂的交互机制。这会在即将发布的文章中探讨。


结束语

我们已领略了 StockFormer 框架,其作者提出了一种创新的方式,在金融市场训练交易策略。StockFormer 结合了预测编码方法与深度强化学习。其主要优势在于能够灵活训练策略,考虑多个资产之间的动态依赖关系,同时预测其在短期和长期横向范围的行为。 

三条分支预测编码机制提取与短期趋势、长期动态、及资产间依赖性相关的潜在表征。级联多头注意力机制令这些多元表示能够高效整合到统一的状态空间之中。

在本文的实施章节,我们按照作者提出的雏形变换器算法实现了 MQL5 修订版 ,并将其整合进多元化多头注意力编码器和解码器模块当中。在下一篇文章中,我们将继续探讨可训练模型的架构及其训练过程。


参考


文章中所用程序

# 名称 类型 说明
1 Research.mq5 智能系统 收集样本的智能系统
2 ResearchRealORL.mq5
智能系统
利用 Real-ORL 方法收集样本的智能系统
3 Study1.mq5  智能系统 预测学习智能系统
4 Study2.mq5  智能系统 政策训练智能系统
5 Test.mq5 智能系统 模型测试智能系统
6 Trajectory.mqh 类库 系统状态描述结构
7 NeuroNet.mqh 类库 创建神经网络的类库
8 NeuroNet.cl 代码库 OpenCL 程序代码库

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

附加的文件 |
MQL5.zip (2253.87 KB)
市场模拟(第六部分):将信息从 MetaTrader 5 传输到 Excel 市场模拟(第六部分):将信息从 MetaTrader 5 传输到 Excel
许多人,尤其是非程序员,发现在 MetaTrader 5 和其他程序之间传输信息非常困难。其中一个程序就是 Excel。许多人使用 Excel 作为管理和维护风险控制的一种方式。这是一个优秀的程序,易于学习,即使对于那些不是 VBA 程序员的人来说也是如此。在这里,我们将看看如何在 MetaTrader 5 和 Excel 之间建立连接(一种非常简单的方法)。
让手动回测变得简单:为MQL5策略测试器构建自定义工具包 让手动回测变得简单:为MQL5策略测试器构建自定义工具包
在本文中,我们设计了一个自定义的MQL5工具包,用于在策略测试器中轻松进行手动回测。我们将解释其设计与实现方案,重点介绍交互式交易控制功能。然后,我们将展示如何使用它来有效地测试交易策略。
使用Python和MQL5进行特征工程(第四部分):基于UMAP回归的K线模式识别 使用Python和MQL5进行特征工程(第四部分):基于UMAP回归的K线模式识别
降维技术被广泛用于提升机器学习模型的性能。让我们来讨论一项被称为“统一流形逼近与投影”的相对较新的技术(UMAP)。这项新技术的开发旨在针对性地克服传统方法在数据中产生伪影和失真的局限性。UMAP是一种强大的降维技术,它能以一种新颖而有效的方式帮助我们将相似的K线进行分组,从而降低在样本外数据上的错误率,并提升我们的交易表现。
市场模拟(第五部分):创建 C_Orders 类(二) 市场模拟(第五部分):创建 C_Orders 类(二)
在本文中,我将解释 Chart Trade 如何与 EA 交易一起处理平仓请求,以关闭用户的所有未平仓头寸。这听起来简单,但你需要知道如何应对一些复杂情况。