English Русский Español Português
preview
交易中的神经网络:层次化双塔变换器(Hidformer)

交易中的神经网络:层次化双塔变换器(Hidformer)

MetaTrader 5交易系统 |
58 3
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

具备捕捉数据时态结构、并识别隐藏形态能力的神经网络模型,在金融预测中变得尤为重要。然而,传统神经网络方式面临诸多局限,即高计算复杂性、以及结果解释性不足。由此,近年来基于注意力机制的架构越来越受到研究人员的关注,在于它们能提供更准确的时间序列和财务数据分析。

基于变换器架构及其修订版模型获得了最广泛的欢迎。修订版之一,在论文《Hidformer:股票价格预测中的变压器式神经网络》中阐述,称为 Hidformer。该模型专为时间序列分析而设计,专注于依靠优化注意力机制,高效识别长期依赖关系,以及适应金融数据特性来提升预测准确性。Hidformer 的主要优势在于能够参考复杂的时态关系,这在股票市场分析中尤为重要,其中资产价格依赖于众多因素。

该框架作者提议改进时态依赖处理、降低计算复杂度、并强化预测准确性。这令 Hidformer 成为一款颇具前景的财务分析和预测工具。


Hidformer 算法

Hidformer 的一个关键特点是由两个编码器并行处理数据。第一个分析时态特征,识别随时间出现的趋势和形态。第二个在频域操作,令模型能够更深度识别依赖关系,并剔除市场噪声。该方式有助于揭示数据中的隐藏形态。这在预测股票价格时至关重要,其中信号可能会被噪声遮掩。输入数据被切分为子序列,然后在每个处理阶段合并,从而提升对重要形态的检测。

该方法在分析波动性资产,如科技股或加密货币时尤其实用,在于它有助于分离基本面趋势与短期波动。Hidformer 的作者提议,取代变换器架构中所用的标准多头注意力,在时态编码器中使用递归注意力机制、以及一个线性注意力机制来识别频谱中的依赖关系。这就降低了计算资源消耗,提升了预测稳定性,令模型在处置大体量市场数据时更加高效。

该模型的解码器基于多层感知器,能够在单步内预测整个价格序列。如是结果,在逐步预测期间积累的误差能被剔除。该架构对财务预测尤为有利,因为它降低了长期预测中积累的不准确度。

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


实现 MQL5 版本

在简要回顾了 Hidformer 框架的理论层面后,我们现在转到按我们对所提议方式的解释实现 MQL5 版本。我们将从实现修订版注意力算法开始。

首先,我们来看看递归注意力算法。原本提议是解决视觉对话问题,递归注意力机制有助于基于之前的对话历史,判定当前查询的正确上下文。显然,递归处理数据相比于多头注意力的并行计算,只会令我们的任务更加复杂。另一方面,递归方式允许我们在包含所需上下文的最近相关元素处停止,从而避免处理整个历史。

这些考量致使我们构造一个多尺度注意力算法。之前,我们讨论过通过调整注意力窗口来捕捉局部和全局特征的各种方式。但早期,在不同的组件里所用注意力级别不同。现在,我提议修改早期的多头注意力算法,如此每个头都能接收自己的上下文窗口。甚至,我们提议将上下文窗口定义为不围绕所分析元素,而是从序列的起始处开始。最新数据存储在序列的开头。该方式允许我们在当前市场形势的背景下评估所分析的历史。

OpenCL中的注意力修改


起先,我们将在 OpenCL 端实现上述变化。为此目的,我们将创建一个新的内核 MultiScaleRelativeAttentionOut,从亲缘内核 MHRelativeAttentionOut 复制其大部分代码。内核的参数清单保持不变。

__kernel void MultiScaleRelativeAttentionOut(__global const float * q,        ///<[in] Matrix of Querys
                                             __global const float * k,        ///<[in] Matrix of Keys
                                             __global const float * v,        ///<[in] Matrix of Values
                                             __global const float * bk,       ///<[in] Matrix of Positional Bias Keys
                                             __global const float * bv,       ///<[in] Matrix of Positional Bias Values
                                             __global const float * gc,       ///<[in] Global content bias vector
                                             __global const float * gp,       ///<[in] Global positional bias vector
                                             __global float * score,          ///<[out] Matrix of Scores
                                             __global float * out,            ///<[out] Matrix of attention
                                             const int dimension              ///< Dimension of Key
                                            )
  {
//--- init
   const uint q_id = get_global_id(0);
   const uint k_id = get_local_id(1);
   const uint h = get_global_id(2);
   const uint qunits = get_global_size(0);
   const uint kunits = get_local_size(1);
   const uint heads = get_global_size(2);
   const int shift_q = dimension * (q_id * heads + h);
   const int shift_kv = dimension * (heads * k_id + h);
   const int shift_gc = dimension * h;
   const int shift_s = kunits * (q_id *  heads + h) + k_id;
   const int shift_pb = q_id * kunits + k_id;
   const uint ls = min((uint)get_local_size(1), (uint)LOCAL_ARRAY_SIZE);
   const uint window = fmax((kunits + h) / (h + 1), fmin(3, kunits));
   float koef = sqrt((float)dimension);

在该方法内,我们首先实现准备工作。此处我们定义了所有必要常量,包括上下文窗口。

注意,我们并未创建单独的缓冲区,为每个注意力头传递上下文大小。取而代之,我们简单地将所分析序列的长度除以注意力头 ID 再加一(因为 ID 从零开始)。因此,第一个头分析整个序列,后续头则在逐渐缩小的上下文窗口内操作。

接下来,我们判定注意力系数。每条执行线程计算特定元素的一个系数。然而,操作仅在上下文窗口内执行。窗外的元素自动获得零注意权重。

   __local float temp[LOCAL_ARRAY_SIZE];
//--- score
   float sc = 0;
   if(k_id < window)
     {
      for(int d = 0; d < dimension; d++)
        {
         float val_q = q[shift_q + d];
         float val_k = k[shift_kv + d];
         float val_bk = bk[shift_kv + d];
         sc += val_q * val_k + val_q * val_bk + val_k * val_bk + gc[shift_q + d] * val_k + 
                                                                 gp[shift_q + d] * val_bk;
        }
      sc = sc / koef;
     }

为了提升系数稳定性,我们将数值移至数值稳定区。为此,我们查找已计算数值中的最大系数,排除超出上下文窗口的元素。

//--- max value
   for(int cur_k = 0; cur_k < kunits; cur_k += ls)
     {
      if(k_id < window)
         if(k_id >= cur_k && k_id < (cur_k + ls))
           {
            int shift_local = k_id % ls;
            temp[shift_local] = (cur_k == 0 ? sc : fmax(temp[shift_local], sc));
           }
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   uint count = min(ls, kunits);
//---
   do
     {
      count = (count + 1) / 2;
      if(k_id < (window + 1) / 2)
         if(k_id < ls)
            temp[k_id] = (k_id < count && (k_id + count) < kunits ? fmax(temp[k_id + count],
                                                                  temp[k_id]) : temp[k_id]);
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);

只有这样,我们才能计算每个系数减去最大值的指数。

   if(k_id < window)
      sc = IsNaNOrInf(exp(fmax(sc - temp[0], -120)), 0);
   barrier(CLK_LOCAL_MEM_FENCE);

然而,必须特别关注上下文窗口内的操作。通过将最大值移至零,我们令最大指数等于 1。其它系数均介于 0 到 1 之间。这久提升了 SoftMax 函数的稳定性。但由于上下文窗口外的系数会被自动设为零,计算其指数会得到最大权重,这极不可取。因此,我们必须预留其值为零。

然后我们汇总工作组内的系数。

//--- sum of exp
   for(int cur_k = 0; cur_k < kunits; cur_k += ls)
     {
      if(k_id >= cur_k && k_id < (cur_k + ls))
        {
         int shift_local = k_id % ls;
         temp[shift_local] = (cur_k == 0 ? 0 : temp[shift_local]) + sc;
        }
      barrier(CLK_LOCAL_MEM_FENCE);
     }
//---
   count = min(ls, (uint)kunits);
   do
     {
      count = (count + 1) / 2;
      if(k_id < count && k_id < (window + 1) / 2)
         temp[k_id] += ((k_id + count) < kunits ? temp[k_id + count] : 0);
      if(k_id + count < ls)
         temp[k_id + count] = 0;
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);

接下来,我们归一化每个系数,即除以计算出的合计。

//--- score
   float sum = IsNaNOrInf(temp[0], 1);
   if(sum <= 1.2e-7f)
      sum = 1;
   sc /= sum;
   score[shift_s] = sc;
   barrier(CLK_LOCAL_MEM_FENCE);

归一化后的系数随后写入对应的数据缓冲区。

在获得序列元素的归一化注意力权重之后,我们能够计算当前元素的调整值。为此,我们遍历序列,数值张量乘以每个系数,然后汇总结果。

//--- out
   int shift_local = k_id % ls;
   for(int d = 0; d < dimension; d++)
     {
      float val_v = v[shift_kv + d];
      float val_bv = bv[shift_kv + d];
      float val = IsNaNOrInf(sc * (val_v + val_bv), 0);
      //--- sum of value
      for(int cur_v = 0; cur_v < kunits; cur_v += ls)
        {
         if(k_id >= cur_v && k_id < (cur_v + ls))
            temp[shift_local] = (cur_v == 0 ? 0 : temp[shift_local]) + val;
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      //---
      count = min(ls, (uint)kunits);
      do
        {
         count = (count + 1) / 2;
         if(k_id < count && (k_id + count) < kunits)
            temp[k_id] += temp[k_id + count];
         if(k_id + count < ls)
            temp[k_id + count] = 0;
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      while(count > 1);
      //---
      if(k_id == 0)
         out[shift_q + d] = IsNaNOrInf(temp[0], 0);
      barrier(CLK_LOCAL_MEM_FENCE);
     }
  }

输出存储在设计好的数据缓冲区之中。

预留零权重允许我们利用现有工具来实现反向传播算法。我们在 OpenCL 端的工作至此完毕。附件中提供了完整的源代码。

创建多尺度注意力对象


接下来,我们需要在主程序中创建多尺度注意力对象。为了最大化对象继承的益处,我们简单地基于现有方法创建了自注意力对象和交叉注意力对象,仅覆盖了调用新创建内核的方法。新对象的结构如下所示。

class CNeuronMultiScaleRelativeSelfAttention   :  public CNeuronRelativeSelfAttention
  {
protected:
   //---
   virtual bool      AttentionOut(void);

public:
                     CNeuronMultiScaleRelativeSelfAttention(void) {};
                    ~CNeuronMultiScaleRelativeSelfAttention(void) {};
   //---
   virtual int       Type(void) override   const   {  return defNeuronMultiScaleRelativeSelfAttention; }
  };
class CNeuronMultiScaleRelativeCrossAttention   :  public CNeuronRelativeCrossAttention
  {
protected:
   virtual bool      AttentionOut(void);

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

我们采用了经典的内核排队执行方法。我们已多次回顾类似的方法。我相信您在理解它们时不会有困难。这些方法的完整代码见附录。

递归注意力对象


上述实现的多尺度注意力对象令我们能够分析多种上下文窗口大小的数据,但这并非 Hidformer 作者所提议的递归注意力机制。我们才刚完成准备阶段。

下一步是构建一个递归注意力对象,具备在先前观察到的历史背景下分析当前数据的能力。为此,我们将用到一些记忆模块设计技术。具体而言,我们将存储观察到的状态上下文,并设定一个历史深度,然后用来评估当前状态。我们将在 CNeuronRecursiveAttention 方法中实现该算法,其结构如下所示。

class CNeuronRecursiveAttention  :  public CNeuronMultiScaleRelativeCrossAttention
  {
protected:
   CNeuronMultiScaleRelativeSelfAttention cSelfAttention;
   CNeuronTransposeOCL  cTransposeSA;
   CNeuronConvOCL       cConvolution;
   CNeuronEmbeddingOCL  cHistory;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput)
   override  { return false; }
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput,
                                        CBufferFloat *SecondGradient,
                                        ENUM_ACTIVATION SecondActivation = None)
   override  { return false; }
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput)
   override  { return false; }

public:
                     CNeuronRecursiveAttention(void) {};
                    ~CNeuronRecursiveAttention(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key, uint units_count,
                          uint heads, uint history_size,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override   const   {  return defNeuronRecursiveAttention; }
   //---
   virtual bool      Save(int const file_handle) override;
   virtual bool      Load(int const file_handle) override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      SetOpenCL(COpenCLMy *obj) override;
   //---
   virtual bool      Clear(void) override;
  };

在此情况下,父类是之前实现的多尺度交叉注意力对象。

在方法内部,我们见到一组熟悉的被覆写虚拟方法,和若干内部对象,我们将在前馈和反向传播的实现期间探讨其功能。

所有内部对象都声明为静态,允许我们保持类构造和解构函数为空。所有继承和新声明对象的初始化均在 Init 方法中完成。

bool CNeuronRecursiveAttention::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                                        uint window, uint window_key, uint units_count,
                                                         uint heads, uint history_size,
                                       ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronMultiScaleRelativeCrossAttention::Init(numOutputs, myIndex, open_cl, window,
                                               window_key, units_count, heads, window_key,
                                                  history_size, optimization_type, batch))
      return false;

该方法参数包含一定数量的常量,明确定义了所创建对象的架构。重点要注意,尽管继承自交叉注意力类,我们的对象仍只用到一条输入数据流。父类正确运行所需的第二条数据流是内部生成的。这第二条流的序列长度由历史深度参数 history_size 定义。

如常,我们立即调用父类的同名方法,并把必要参数传递给它。回想父方法已经包含继承对象的所有控制点和初始化过程,包括基接口。

接着,我们初始化新声明的内部对象。第一个是多尺度的自我注意力模块。

   int index = 0;
   if(!cSelfAttention.Init(0, index, OpenCL, iWindow, iWindowKey, iUnits, iHeads, 
                                                           optimization, iBatch))
      return false;

利用该对象,我们可以判定原始数据中哪些元素对分析金融产品当前状态影响最大。

然后,我们要将当前环境状态的上下文添加到递归注意力模块的记忆之中。我们打算预留独立单变量序列的上下文。因此,我们首先转置输入数据。

   index++;
   if(!cTransposeSA.Init(0, index, OpenCL, iUnits, iWindow, optimization, iBatch))
      return false;

然后我们利用卷积层提取单变量序列的上下文。

   index++;
   if(!cConvolution.Init(0, index, OpenCL, iUnits, iUnits, iWindowKey, 1, iWindow,
                                                            optimization, iBatch))
      return false;

注意,卷积层参数指定所分析序列中的单个元素,而单变量序列的数量作为自变量传递。这令每个单元序列可用自己的可训练参数集,从而实现完全独立的分析。这就能对原始多模态序列进行更深度分析。

接下来,我们使用嵌入生成层捕捉所分析环境状态的上下文,并将其添加到历史记忆栈之中。

   index++;
   uint windows[] = { iWindowKey * iWindow };
   if(!cHistory.Init(0, index, OpenCL, iUnitsKV, iWindowKey, windows))
      return false;
//---
   return true;
  }

所有操作成功完成后,我们返回一个逻辑成功值给调用程序,并结束该方法。

下一步是实现 feedForward 方法,其算法相当线性。

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

该方法接收指向包含多模时间序列的输入数据对象的指针。我们立即将该指针传递给自注意力模块,以便分析当前环境状态中的依赖关系。结果随后被转置,以便进一步处理。

   if(!cTransposeSA.FeedForward(cSelfAttention.AsObject()))
      return false;

我们利用卷积层提取单变量序列的上下文。

   if(!cConvolution.FeedForward(cTransposeSA.AsObject()))
      return false;

我们将准备好的数据传递给嵌入生成器,其提取所分析状态的上下文,并将其添加到记忆栈之中。

   if(!cHistory.FeedForward(cConvolution.AsObject()))
      return false;

现在,我们需要依据历史序列的背景,来丰富之前获得的自注意力结果。为此目的,我们调用父类的相应方法,把必要的信息传递给它。

   return CNeuronMultiScaleRelativeCrossAttention::feedForward(cSelfAttention.AsObject(),
                                                                   cHistory.getOutput());
  }

值得注意的是,为了依据先前观察到的状态分析当前状态,我们用到之前创建的多尺度注意力对象。该方式赋予最近观测数据更高的权重,同时降低了久远信息的影响。无论如何,我们仍然保留着从“记忆深处”提取关键点的能力。

在结束该方法之前,我们返回一个布尔值至调用者,指示初始化成功或失败。

由于自注意力结果会在前馈通验中被重复使用,这影响到 calcInputGradients 方法实现的反向传播算法。

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

反向传播方法接收指向同一输入数据对象的指针,但现在我们必须将对模型输出影响的误差梯度传递给它。

在方法内,我们会立即验证收到指针的有效性。否则,我们无法将数据传递给不存在的对象,后续操作也会失去意义。因此,只有当该控制点成功通过时,我们才继续。

正如您所知,前馈和反向传播通验的信息流在概念上完全对应,仅在方向上有所不同。前馈通验调用父类方法结束。相应地,反向传播通验从调用其继承方法开始。后者基于两条数据流对最终结果的贡献,将先前接收到的梯度在两条数据流之间分派。

   if(!CNeuronMultiScaleRelativeCrossAttention::calcInputGradients(cSelfAttention.AsObject(),
                                                                        cHistory.getOutput(),
                                                                      cHistory.getGradient(),
                                                     (ENUM_ACTIVATION)cHistory.Activation()))
      return false;

我们首先将梯度分派到对应对象记忆的辅助数据流之中。此处,我们将误差传播到卷积层,用于提取单变量序列的上下文。

   if(!cConvolution.calcHiddenGradients(cHistory.AsObject()))
      return false;

然后我们进一步传播到自注意力模块的转置层。

   if(!cTransposeSA.calcHiddenGradients(cConvolution.AsObject()))
      return false;

接下来,我们必须将梯度传递到多尺度的自注意力层。但早前,我们已向它传播了主数据流的梯度,必须保持该梯度。为此,我们暂时交换数据缓冲区的指针。首先,我们给对象传递一个指向空闲缓冲区的指针,同时保存已有的缓冲区。

   CBufferFloat *temp = cSelfAttention.getGradient();
   if(!cSelfAttention.SetGradient(cTransposeSA.getPrevOutput(), false) ||
      !cSelfAttention.calcHiddenGradients(cTransposeSA.AsObject()) ||
      !SumAndNormilize(temp, cSelfAttention.getGradient(), temp, iWindow, false, 0, 0, 0, 1) ||
      !cSelfAttention.SetGradient(temp, false))
      return false;

然后我们传播误差梯度,并汇总两条数据流的数值。之后,我们会将缓冲指针恢复到原始状态。

最后,我们将梯度传播到输入数据的层级。

   if(!NeuronOCL.calcHiddenGradients(cSelfAttention.AsObject()))
      return false;
//---
   return true;
  }

在方法结束处,我们返回一个逻辑成功值。

附件中提供了该对象、及其所有方法的完整代码,供进一步研究。

线性注意力对象


除了已实现的递归注意力对象外,框架作者还提议在负责频谱分析的塔中使用线性注意力。

线性注意力是优化变换器传统注意力机制的方法之一。与依赖全连通矩阵运算、且复杂度为二次的经典自注意力不同,线性注意力降低了计算复杂度,令其在处理长序列时更高效。

线性注意力引入了分式 φ(Q)φ(K),令注意力计算可以表示为:

线性注意力的优势

  1. 线性复杂度:降低计算成本,令处理长序列成为可能。
  2. 降内存消耗:无需存储完整的依赖系数分数矩阵,从而减少内存需求。
  3. 在线任务的效率:线性注意力支持流式数据处理,因为更新是渐进式的。
  4. 内核选择的灵活性:不同的 φ(x) 函数令注意力机制能适应特定任务。

线性注意力算法的实现被封装在 CNeuronLinerAttention 对象中,其结构如下所示。

class CNeuronLinerAttention   :  public CNeuronBaseOCL
  {
protected:
   uint                 iWindow;
   uint                 iWindowKey;
   uint                 iUnits;
   uint                 iVariables;
   //---
   CNeuronConvOCL       cQuery;
   CNeuronConvOCL       cKey;
   CNeuronTransposeVRCOCL  cKeyT;
   CNeuronBaseOCL       cKeyValue;
   CNeuronBaseOCL       cAttentionOut;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronLinerAttention(void) {};
                    ~CNeuronLinerAttention(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key,
                          uint units_count, uint variables,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override   const   {  return defNeuronLinerAttention; }
   //---
   virtual bool      Save(int const file_handle) override;
   virtual bool      Load(int const file_handle) override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      SetOpenCL(COpenCLMy *obj) override;
  };

此处我们见到一组基本的覆写方法,及在我们构建的算法中扮演关键角色的若干内部对象。我们将在新类方法的实现期间详察它们的功能。

所有方法都声明为静态,允许构造和析构函数留空。所有继承和声明的对象都在 Init 方法里初始化。该方法的参数包括若干定义所创建对象架构的参数。

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

在方法主体中,会立即被调用父类的同名方法。在这种情况下,它是一个全连通层。

接下来,我们将关键架构参数保存在内部变量之中,并开始初始化内部对象。

   iWindow = window;
   iWindowKey = fmax(window_key, 1);
   iUnits = units_count;
   iVariables = variables;

我们首先初始化负责生成查询主键实体的卷积层。在形成查询时,我们调用 sigmoid 激活函数,其将指示每个元素影响对象的程度。

   int index = 0;
   if(!cQuery.Init(0, index, OpenCL, iWindow, iWindow, iWindowKey, iUnits, iVariables,
                                                                optimization, iBatch))
      return false;
   cQuery.SetActivationFunction(SIGMOID);
   index++;
   if(!cKey.Init(0, index, OpenCL, iWindow, iWindow, iWindowKey, iUnits, iVariables, 
                                                                optimization, iBatch))
      return false;
   cKey.SetActivationFunction(TANH);

对于主键实体,我们调用双曲切线作为激活函数,从而判定每个元素的影响是正面亦或负面。

然后我们为主键初始化矩阵转置对象:

   index++;
   if(!cKeyT.Init(0, index, OpenCL, iVariables, iUnits, iWindowKey, optimization, iBatch))
      return false;
   cKeyT.SetActivationFunction(TANH);

以及负责存储主键数值矩阵乘积的对象。

   index++;
   if(!cKeyValue.Init(0, index, OpenCL, iWindow * iWindowKey, optimization, iBatch))
      return false;
   cKeyValue.SetActivationFunction(None);

注意我们未用层来生成数值实体。取而代之,我们计划直接使用原产输入数据。

注意力的结果将存储在专门创建的内部对象之中。

   index++;
   if(!cAttentionOut.Init(0, index, OpenCL, Neurons(), optimization, iBatch))
      return false;
   cAttentionOut.SetActivationFunction(None);

我们将用到父类接口来创建残余连接。为避免不必要的数据复制,我们用指向梯度缓冲区的指针替代。

   if(!SetGradient(cAttentionOut.getGradient(), true))
      return false;
//---
   return true;
  }

在该方法完成之前,我们返回一个布尔值,告知调用程序操作成功。

初始化完成后,我们继续在 feedForward 方法中实现前向通验算法。

bool CNeuronLinerAttention::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cQuery.FeedForward(NeuronOCL))
      return false;
   if(!cKey.FeedForward(NeuronOCL) ||
      !cKeyT.FeedForward(cKey.AsObject()))
      return false;

该方法接收指向多维输入数据序列的指针,立即用于形成查询主键实体。

接下来,我们将转置的主键矩阵乘以输入数据,判定每个对象对分析序列的影响。

   if(!MatMul(cKeyT.getOutput(), NeuronOCL.getOutput(), cKeyValue.getOutput(),
                                     iWindowKey, iUnits, iWindow, iVariables))
      return false;

为了获得线性注意力结果,我们将查询张量乘以前一操作的输出。

   if(!MatMul(cQuery.getOutput(), cKeyValue.getOutput(), cAttentionOut.getOutput(),
                                          iUnits, iWindowKey, iWindow, iVariables))
      return false;

然后我们加入残余连接,并对操作结果进行归一化。

   if(!SumAndNormilize(NeuronOCL.getOutput(), cAttentionOut.getOutput(), Output,
                                                     iWindow, true, 0, 0, 0, 1))
      return false;
//---
   return true;
  }

然后我们将操作的逻辑结果返回至调用者,结束方法的执行。

接下来,我们需要根据误差梯度对模型输出的贡献,在所有内部对象和输入数据之间分派误差梯度。如常,这些操作在 calcInputGradients 方法中执行,其接收输入数据对象的指针。这次它用于写入结果。

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

在方法主体中,我们立即检查接收指针的相关性。我们之前已提到过,这个控制点很重要。

由于缓冲指针替换,来自下一神经层接收的误差梯度自动进入内部线性注意力结果对象。然后会跨信息流分派。

   if(!MatMulGrad(cQuery.getOutput(), cQuery.getGradient(),
                  cKeyValue.getOutput(), cKeyValue.getGradient(),
                  cAttentionOut.getGradient(),
                  iUnits, iWindowKey, iWindow, iVariables))
      return false;
   if(!MatMulGrad(cKeyT.getOutput(), cKeyT.getGradient(),
                  NeuronOCL.getOutput(), cAttentionOut.getPrevOutput(),
                  cKeyValue.getGradient(),
                  iWindowKey, iUnits, iWindow, iVariables))
      return false;

重点要注意,传播到输入数据的梯度由 4 条信息流组成:

  • 查询实体
  • 主键实体
  • 主键*数值乘积
  • 残余连接

在之前的操作中,我们将来自主键*数值乘积的梯度保存在一个空闲缓冲区中。全部传播的残余梯度来自当前对象的输出。这些梯度尚未按输入对象的激活函数导数调整。然而,当梯度经由卷积查询/主键层传播时,会由相应激活导数进行调整。为了确保所有流的一致性,我们汇总梯度,并应用输入对象激活函数的导数。结果被存储在一个自由缓冲区中。

   if(!SumAndNormilize(Gradient, cAttentionOut.getPrevOutput(), cAttentionOut.getPrevOutput(),
                       iWindow, false, 0, 0, 0, 1))
      return false;
//---
   if(NeuronOCL.Activation() != None)
      if(!DeActivation(NeuronOCL.getOutput(), cAttentionOut.getPrevOutput(),
                       cAttentionOut.getPrevOutput(), NeuronOCL.Activation()))
         return false;

我们还由各自的激活导数调整其它流的梯度。

   if(cKeyT.Activation() != None)
      if(!DeActivation(cKeyT.getOutput(), cKeyT.getGradient(),
                       cKeyT.getGradient(), cKeyT.Activation()))
         return false;
   if(cQuery.Activation() != None)
      if(!DeActivation(cQuery.getOutput(), cQuery.getGradient(),
                       cQuery.getGradient(), cQuery.Activation()))
         return false;

接下来,我们将梯度传播到主键信息流中,并积累结果。

   if(!cKey.calcHiddenGradients(cKeyT.AsObject()) ||
      !NeuronOCL.calcHiddenGradients(cKey.AsObject()) ||
      !SumAndNormilize(NeuronOCL.getGradient(), cAttentionOut.getPrevOutput(),
                       cAttentionOut.getPrevOutput(), iWindow, false, 0, 0, 0, 1))
      return false;

查询流也同样行事,然后将合并的梯度传递给输入对象。

   if(!NeuronOCL.calcHiddenGradients(cQuery.AsObject()) ||
      !SumAndNormilize(NeuronOCL.getGradient(), cAttentionOut.getPrevOutput(),
                       NeuronOCL.getGradient(), iWindow, false, 0, 0, 0, 1))
      return false;
//---
   return true;
  }

在方法结束处,返回布尔成功值。

我们对线性注意力对象方法的探讨至此完毕。您可在附录中查看类及其所有方法的完整的代码。

我们工作太努力了,抵达本文的结尾。但我们的工作尚未完成。我们稍事休息,在下一篇文章中继续,我们将为此做出合乎逻辑的结论。


结束语

我们探讨了 Hidformer 框架,其在时间序列预测(包括财务数据)方面表现出色。其显著特点是采用双塔编码器,并对原产数据按时间序列和频率特征分别加以分析。这赋予 Hidformer 高度灵活性和适应力,适应多变的市场条件。

在文章的实践部分,我们实现了 Hidformer 框架作者提出的几个组件。然而,我们的工作尚未完成,未来将继续推进。


参考


文章中所用程序

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

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

附加的文件 |
MQL5.zip (2406.43 KB)
最近评论 | 前往讨论 (3)
Evgeniy Chernish
Evgeniy Chernish | 31 1月 2025 在 09:08
您能告诉我如何用这个神经网络获得一组趋势吗?

据我所知,这是它的特点之一

"首先分析时间特征,确定时间尺度上的趋势和模式"。


Andreas Alois Aigner
Andreas Alois Aigner | 2 4月 2025 在 15:44

你好,德米特里、

根据 OnTesterDeinit(),代码应在测试模式下(即在 StrategyTester 中)保存 NN 文件。

//+------------------------------------------------------------------+
//| TesterDeinit 函数|
//+------------------------------------------------------------------+
void OnTesterDeinit()
  {
//---
   int total = ArraySize(Buffer);
   printf("total %d", MathMin(total, MaxReplayBuffer));
   Print("Saving...");
   SaveTotalBase();
   Print("Saved");
  }
//+------------------------------------------------------------------+

但这并没有发生。而且 OnTesterDeinit() 似乎也没有被调用。因为我没有看到任何打印 语句。

这是因为 MQL5 的更新吗?或者为什么您的代码不再保存文件?

Dmitriy Gizlyk
Dmitriy Gizlyk | 6 4月 2025 在 13:48
Andreas Alois Aigner 打印 语句。

这是因为 MQL5 的更新吗?或者为什么您的代码不再保存文件?

亲爱的 Andreas

OnTesterDeinit 只在优化模式下运行。请参阅https://www.mql5.com/en/docs/event_handlers/ontesterdeinit。
我们不在测试器中保存模型,因为该 EA 不研究它们。有必要检查之前研究过的模型的有效性。

致以最崇高的敬意,
Dmitriy。

交易中的资本管理和带有数据库的交易者家庭会计程序 交易中的资本管理和带有数据库的交易者家庭会计程序
交易者如何管理资金?交易者和投资者如何跟踪支出、收入、资产和负债?我不仅要向你介绍会计软件;我将向您展示一个工具,它可能会成为您在波涛汹涌的交易海洋中可靠的金融导航器。
重构经典策略(第十四部分):高胜率交易形态 重构经典策略(第十四部分):高胜率交易形态
高胜率交易形态在交易圈内广为人知,但遗憾的是,其定义始终缺乏明确标准。本文将通过实证研究与算法建模,为高胜率形态构建量化定义框架,并探索其识别与运用方法。借助梯度提升树模型,我们演示如何系统性优化任意交易策略的性能,同时以更精准、可解释的方式向计算机传达交易指令的核心逻辑。
纯 MQL5 货币对强弱指标 纯 MQL5 货币对强弱指标
我们将在 MQL5 中开发货币强势分析的专业指标。这本分步指南将向你展示如何为 MetaTrader 5 开发一款功能强大的交易工具,该工具带有可视化仪表板。您将学习如何计算多个时间周期(H1、H4、D1)内货币对的强度,实现动态数据更新,并创建用户友好的界面。
使用MQL5经济日历进行交易(第七部分):基于资源型新闻事件分析的策略测试准备 使用MQL5经济日历进行交易(第七部分):基于资源型新闻事件分析的策略测试准备
在本文中,我们通过将经济日历数据作为非实盘分析资源嵌入到MQL5交易系统中,为策略测试做好准备。我们实现了按时间、货币和影响程度加载和筛选事件的功能,并在策略测试器中验证其有效性。这使得基于新闻事件的策略能够进行高效的回测。