English Русский Español Português
preview
交易中的神经网络:解耦结构化分量——编码器实现

交易中的神经网络:解耦结构化分量——编码器实现

MetaTrader 5 — 交易系统 |
29 0
Dmitriy Gizlyk
Dmitriy Gizlyk

引言

我们将继续落地 SCNN 框架作者提出的相关思路,实现我们自己的方案。回顾一下,SCNN(Structured Component Neural Network,结构化分量神经网络)采用一套概念完全不同的思路:它不试图用单一通用机制对整条时间序列建模,而是将时间序列拆分为五大核心分量 —— 长期分量、季节分量、短期分量、耦合分量以及残差分量。每一个分量都会被独立建模与外推预测。这不仅带来更高的灵活性,还能在每一步都生成可解释的结果。

该架构最核心的优势是透明性。不同于传统黑盒模型,SCNN 可以让分析师与交易者清晰看到:模型的哪一部分负责预测的某一段区间,以及模型在当前市场环境下的置信度。对于金融数据研究而言这一点尤为关键,模型的可信度必须依靠可解释性与可控性作为支撑。除此之外,对每个分量分别采用定制化延拓方法,既可以使用统计启发式算法(用于长期模式与季节模式),也可以使用可训练神经网络模块(用来建模短期异常或是资产间的相互关联)。

该框架同时能够处理复杂的时间依赖关系,包括自相关关系;而简化模型往往会忽略这类关系。这对于日内交易、或是交易时段衔接处的预测任务尤其有价值。SCNN 可以动态适应实时统计特征偏移,还能够识别预测结果不可靠的异常数据点。

下方是作者绘制的 SCNN 框架可视化图。

在上一篇文章的实操章节中,我们开发了 CNeuronPeriodNorm 对象,用于从时间序列中提取按周期窗口归一化处理的分量。该组件是在 MQL5 环境落地 SCNN 框架的第一步,后续会用它提取长期分量与短期分量。借助 OpenCL 内核,可以实现高效的并行数据处理,并支持反向传播机制,因此该模块可以嵌入可训练神经网络架构内使用。

后文我们会演示:只需少量简单的数据变换,CNeuronPeriodNorm 也可改造用于提取季节分量,进一步提升它的通用性。今天我们进入下一步:开始构建用于提取耦合分量的对象。耦合分量代表时间序列里多个变量之间的联动变化。该模块将在同步波动、异常协同走势的建模中起到关键作用,这对多变量市场分析场景意义重大。



耦合分量提取

实操经验表明,仅依靠时序分析不足以正确实现该模型。我们还必须考虑空间依赖 —— 也就是同一时刻下,不同变量之间的相互关系。而耦合分量提取机制正是用来完成这件事:它能够检测多模态时间序列里,识别各分量序列信号之间同向或反向的变动关系。这类协同演化关系既可以保持稳定,也可以动态变化,因此需要采用自适应归一化方案。

在 SCNN 框架内,该问题通过基于注意力机制的空间加权归一化实现。接下来我们进入下一个实现环节,结合金融建模的实际需求,给出我们对这套算法的自定义实现方案。一如既往,我们把绝大部分计算(加权平均、自适应标准化等)交由 OpenCL 上下文处理。这不仅加速运算,还保留架构配置的灵活性。

为此,我们新建一个名为 AdaptSpatialNorm 的内核。该内核算法对输入数据执行自适应归一化,计算时纳入变量间的注意力,从而更精准、灵敏地处理空间关联关系。其核心思路:基于注意力掩码做加权,为每一个时间点计算均值与标准差。这样就不是简单对全部变量取平均,而是纳入变量的相对重要性 —— 也就是在时空上下文里,每个变量对特定数据点的影响权重。

__kernel void AdaptSpatialNorm(__global const float* inputs,
                               __global const float* attention,
                               __global float2* mean_stdevs,
                               __global float* outputs
                              )
  {
   const size_t i = get_global_id(0);
   const size_t a = get_local_id(1);
   const size_t v = get_global_id(2);
   const size_t total_inputs = get_global_size(0);
   const size_t total_local = get_local_size(1);
   const size_t variables = get_global_size(2);

在这个计算内核中,计算逻辑划分在三个维度:时间、变量、局部工作项。每一个工作项负责处理一个特定数值:某个时间点下的单个变量。首先计算索引偏移量,保证每个工作项能够正确访问所需内存区域。

   __local float Temp[LOCAL_ARRAY_SIZE];
   const int shift_v = v * total_inputs;
   const int shift_out = shift_v + i;

随后进入核心计算流程:读取全部变量对应的输入数组数值,以及对应的注意力权重。每个数值乘上自身权重,之后执行局部求和 —— 先计算均值,再计算方差。

   float mean = 0, stdev = 0;
   for(uint l = 0; l < variables; l += total_local)
     {
      const int shift_at = v * variables + (a + l);
      float val = IsNaNOrInf(inputs[(a + l) * total_inputs + i], 0);
      float att = IsNaNOrInf(attention[shift_at], 0);
      mean += LocalSum(val * att, 1, Temp);
      BarrierLoc;
      stdev += LocalSum(val * val * att, 1, Temp);
      BarrierLoc;
     }

为保障跨工作项并行计算的正确性,这里使用同步屏障,避免共享内存访问冲突。

这里需要重点关注计算组织的技术细节。为在不同工作项之间完成数值求和运算,OpenCL 内部会创建工作组。同一工作组内的工作项可以借助局部内存交换数据。局部内存相比全局内存速度快得多,能够高效实现规约、求和这类集合运算。

但是该架构存在天然限制:工作组大小不能超过显卡或其他计算设备设定的硬件上限。实际工程中,这代表同一组内并行处理变量的工作项数量存在上限,不一定匹配待处理输入数组的维度。

为规避这个限制,内核主体内实现了一个循环。即便变量数量远大于工作组尺寸,也可以分步遍历全部变量。整体特征空间会被分块、分批处理,依次聚合数值。这套方案既维持计算效率,又保证无论模型涉及多少变量,所有数学运算都能正确执行。

该技术让模型在扩容时更加稳健,能够在多种不同规格的设备上运行;同时也提升了模型的灵活性和可移植性。

所有数值处理完毕后,每个局部工作组里仅一个工作项负责最终归一化计算。它通过平方和减去均值平方算出方差,再开平方根得到标准差。增加除零保护机制:如果计算得到的标准差过小或非正,则将标准差设为 1。

   if(a == 0)
     {
      stdev -= mean * mean;
      stdev = IsNaNOrInf(sqrt(stdev), 1);
      if(stdev <= 0)
         stdev = 1;
      mean_stdevs[shift_out] = (float2)(mean, stdev);
      outputs[shift_out] = IsNaNOrInf((inputs[shift_out] - mean) / stdev, 0);
     }
  }

之后计算归一化后的输入值,并写入输出数组。同时保存算出的均值和标准差 —— 后续步骤会用到这两个值。

这套实现逻辑融合了数据空间分布信息聚合,以及基于该分布的归一化操作。这使得模型能够灵敏捕捉变量之间隐藏的关联关系,从而更精准刻画复杂时序过程的动态特征,这对金融预测至关重要。

想要完整训练模型,不仅需要执行前向传播算出归一化结果,还必须正确实现反向传播,保证误差梯度回传到输入数据与模型参数。针对我们这套用于空间加权归一化的 AdaptSpatialNorm 内核,下一步自然就是实现反向传播内核 AdaptSpatialNormGrad,该内核负责将误差梯度分配到输入数据与注意力权重上。

__kernel void AdaptSpatialNormGrad(__global const float* inputs,
                                   __global float* inputs_gr,
                                   __global const float* attention,
                                   __global float* attention_gr,
                                   __global const float2* mean_stdevs,
                                   __global const float2* mean_stdevs_gr,
                                   __global const float* outputs_gr,
                                   const uint total_inputs
                                  )
  {
   const size_t i = get_global_id(0);              // main
   const size_t loc = get_local_id(1);             // local to sum
   const size_t v = get_global_id(2);              // variable
   const size_t total_main = get_global_size(0);   // total
   const size_t total_loc = get_local_size(1);     // local dimension
   const size_t variables = get_global_size(2);    // total variables
//---
   __local float Temp[LOCAL_ARRAY_SIZE];

该算法将计算负载分配到各个工作项,每个工作项处理一组索引组合,索引对应一个时间切片与一个变量。局部内存用于存储中间值,从而加快工作组内部的数据求和与聚合过程。

首先,计算关于输入数据的梯度。读取输入数组每个元素对应的注意力参数以及输出梯度。接着计算梯度,计算过程需要考虑归一化参数(均值和标准差)的偏导数,这些参数同样参与反向传播。累加所有部分和,最后存入输入梯度数组。

//--- Input gradient
     {
      if(i < total_inputs)
        {
         float grad = 0;
         int shift_in = v * total_inputs + i;
         float x = IsNaNOrInf(inputs[shift_in], 0);
         for(int l = 0; l < variables; l += total_loc)
           {
            if((l + loc) >= variables)
               break;
            int shift_out = i + (l + loc) * total_inputs;
            float att = IsNaNOrInf(attention[(l + loc) * variables + v], 0);
            float out_gr = IsNaNOrInf(outputs_gr[shift_out], 0);
            float2 ms = mean_stdevs[shift_out];
            float2 ms_gr = mean_stdevs_gr[shift_out];
            float dy = (1 - att) * (1 / ms.y - (x - ms.x) * att * x / pow(ms.y, 3.0f));
            float dmean = IsNaNOrInf(ms_gr.x * att, 0);
            float dstd = IsNaNOrInf(ms_gr.y * x * (att - att * att) / ms.y, 0);
            grad += IsNaNOrInf(dy * out_gr + dmean + dstd, 0);
           }
         grad = LocalSum(grad, 1, Temp);
         if(loc == 0)
            inputs_gr[shift_in] = grad;
        }
      BarrierLoc;
     }

提前说明一个重要细节:我们保存的归一化参数(均值与标准差)并非计算的副产品。恰恰相反,它们会在 SCNN 框架的后续运算中主动参与计算,形成一条辅助数据处理分支。因此,在前向传播后续阶段为这些参数累积得到的误差梯度,也必须反向传播至输入数据层。

这代表在反向传播阶段,我们不只处理主信息流,同时还要计入存储的归一化统计量对应的导数贡献,并叠加到最终输入梯度上。该方案保证计算图的完整性,使模型能够有效调整所有会影响结果的参数,包括那些经由归一化机制间接关联到输入的参数。

接下来,计算注意力参数对应的梯度。针对每一个注意力权重元素,算法遍历全部对应的时间点,使用输入数据值与已经算出的输出梯度。计算过程同时考虑注意力通过均值、标准差对归一化带来的影响。最终结果借助局部内存累加,存入对应的注意力梯度数组。

//--- Attention gradient
     {
      if(i < variables)
        {
         float grad = 0;
         int shift_att = v * variables + i;
         float att = IsNaNOrInf(attention[shift_att], 0);
         for(int l = 0; l < total_inputs; l += total_loc)
           {
            if((l + loc) >= total_inputs)
               break;
            int shift_out = (l + loc) + v * total_inputs;
            int shift_in = (l + loc) + i * total_inputs;
            float x = IsNaNOrInf(inputs[shift_in], 0);
            float out_gr = IsNaNOrInf(outputs_gr[shift_out], 0);
            float2 ms = mean_stdevs[shift_out];
            float2 ms_gr = mean_stdevs_gr[shift_out];
            float dy = -x / ms.y - (x - ms.x) * x * x * (1 - 2 * att) / (2 * pow(ms.y, 3.0f));
            float dmean = IsNaNOrInf(ms_gr.x * x, 0);
            float dstd = IsNaNOrInf(ms_gr.y * x * x * (1 - 2 * att) / (2 * ms.y), 0);
            grad += IsNaNOrInf(dy * out_gr + dmean + dstd, 0);
           }
         grad = LocalSum(grad, 1, Temp);
         if(loc == 0)
            attention_gr[shift_att] = grad;
        }
     }
  }

需要重点说明:该实现考虑了 NaN、无穷大这类无效数值的可能性,阻止无效值继续传播,以此提升算法稳健性。

整套方案采用循环式数据处理并使用局部内存,保证可扩展性与运算效率;即便变量数量、时间步长超出工作组维度,也能完成大规模数据处理。

由此,AdaptSpatialNormGrad 内核能够在纳入空间注意力权重的前提下,精准、高效地计算关键归一化参数的梯度,使该机制可以嵌入依靠反向传播训练的复杂模型。

为把上述空间加权归一化算法整合进整体架构,我们在主程序中创建专用对象CNeuronAdaptSpatialNorm 。该类继承自 CNeuronBaseOCL的基础接口,可无缝接入神经网络组件的层级体系。该对象的核心作用是合理组织前向传播与反向传播的计算,同时正确处理注意力机制相关的全部辅助组件。

新类的结构如下所示。

class CNeuronAdaptSpatialNorm :  public CNeuronBaseOCL
  {
protected:
   uint                    iVariables;
   uint                    iCount;
   //---
   CParams                 cEn;
   CNeuronTransposeOCL     cEnT;
   CNeuronBaseOCL          cEnEnT;
   CNeuronSoftMaxOCL       cAttan;
   CNeuronBaseOCL          cMeanSTDevs;
   //---
   virtual bool      AdaptSpatialNorm(CNeuronBaseOCL *NeuronOCL);
   virtual bool      AdaptSpatialNormGrad(CNeuronBaseOCL *NeuronOCL);
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronAdaptSpatialNorm(void) : iCount(0), iVariables(1) {};
                    ~CNeuronAdaptSpatialNorm(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint units_count, uint variables,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual bool      Save(const int file_handle) override;
   virtual bool      Load(const int file_handle) override;
   //---
   virtual int       Type(void) override const  {  return defNeuronAdaptSpatialNorm; }
   virtual void      SetOpenCL(COpenCLMy *obj) override;
   //---
   CNeuronBaseOCL*   GetMeanSTDevs(void) { return cMeanSTDevs.AsObject(); }
   virtual uint      GetVariables(void) const { return iVariables; }
   virtual uint      GetUnits(void) const { return iCount; }
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
  };

在类内部可以看到若干受保护成员,每一个都对组件运行起到重要作用。变量 iVariables 和 iCount 指定输入数据张量的维度,定义处理所涉及的空间维度。内部对象cEn,cEnT,cEnEnT和 cAttan依次构建并训练注意力矩阵。cMeanSTDevs 组件作为这条链路的收尾,用于存储、更新归一化参数;这些参数将根据算出的注意力权重对输入数据做缩放。

全部内部对象采用静态声明,大幅简化内存管理与初始化流程。这使得方案可靠性更高、行为更可预测:无需手动分配或释放资源。类的构造函数与析构函数保持为空,因为这些对象在类实例创建时就已存在,实例生命周期结束时会自动销毁。

该类所有内部组件统一在 Init 方法中完成初始化。该方法接收关键入参,能够唯一确定所创建对象的架构,包括输入数据维度。整个初始化流程顺序清晰、逻辑连贯,与模型内部结构紧密关联。

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

第一阶段调用父类 CNeuronBaseOCL 的同名方法,完成神经节点的基础初始化。结果缓冲区大小等于变量数量乘以序列长度。若底层初始化成功,则设置输入张量维度,存入局部变量iVariables与iCount。

   iVariables = variables;
   iCount = units_count;
//---
   uint dimension = (iVariables + 1) / 2;
   uint index = 0;
   if(!cEn.Init(0, index, OpenCL, iVariables * dimension, optimization, iBatch))
      return false;
   cEn.SetActivationFunction(None);

接下来依次配置用于构建注意力矩阵的内部组件。首先初始化 cEn 对象。它是一组可训练参数构成的张量。其维度等于变量数量乘以变量数量的一半。这种降维方式可以筛选出最重要的特征用于后续处理。此处显式禁用激活函数,因为该阶段只需要纯线性变换,不能扭曲输出信号。

然后初始化转置对象cEnT,用来生成可训练参数张量的转置副本。

   index++;
   if(!cEnT.Init(0, index, OpenCL, iVariables, dimension, optimization, iBatch))
      return false;
   cEnT.SetActivationFunction(None);
   index++;
   if(!cEnEnT.Init(0, index, OpenCL, iVariables * iVariables, optimization, iBatch))
      return false;
   cEnEnT.SetActivationFunction(None);

cEnEnT 对象在 SCNN 框架的注意力机制构建中起到核心作用。它用于存储可训练参数张量与其转置副本的矩阵乘法结果。该运算生成一个对称矩阵,反映单个时间步内各变量之间的相互依赖关系与相关强度。生成的结构能够显式识别出,在被分析特征中哪些特征相互影响最强。

cAttan 对象完成注意力机制的构建。它接收相关矩阵作为输入,并对其执行 SoftMax 归一化,将注意力权重分配到各个变量上。注意力头的数量等于相关矩阵的行数,能够灵活适配数据结构。

   index++;
   if(!cAttan.Init(0, index, OpenCL, iVariables * iVariables, optimization, iBatch))
      return false;
   cAttan.SetHeads(iVariables);

最后配置 cMeanSTDevs 对象,用于存储成对数值:经由空间加权归一化算出的均值与标准差。该对象维度是输出神经元数量的两倍,因为每个结果元素都需要保存两个参数。

   index++;
   if(!cMeanSTDevs.Init(0, index, OpenCL, 2 * Neurons(), optimization, iBatch))
      return false;
   cMeanSTDevs.SetActivationFunction(None);
//---
   return true;
  }

由此,Init 方法完成 SCNN 内部空间加权归一化机制所需全部组件的创建与配置。代码结构体现架构的严谨性与模块化:每个模块职责明确,按照指定顺序与其他模块交互。

所有内部组件初始化成功后,我们继续搭建在 feedForward 方法中实现的前向传播机制。这是该层运算的关键阶段:在此阶段生成注意力矩阵,并对输入数据执行空间加权归一化。

bool CNeuronAdaptSpatialNorm::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(bTrain)
     {
      if(!cEn.FeedForward())
         return false;
      if(!cEnT.FeedForward(cEn.AsObject()))
         return false;
      if(!MatMul(cEn.getOutput(), cEnT.getOutput(), cEnEnT.getOutput(),
                 iVariables, cEnT.GetWindow(), iVariables, 1, false))
         return false;
      if(!cAttan.FeedForward(cEnEnT.AsObject()))
         return false;
     }
//---
   return AdaptSpatialNorm(NeuronOCL);
  }

这里要强调一个重要架构设计点:注意力参数仅在训练阶段生成。这是有意为之,因为注意力矩阵本身属于模型的静态组件 —— 它在运行阶段并不会针对具体输入动态调整。换言之,我们训练一组通用权重,用来反映时间序列变量之间稳定存在的关联。该方案保证模型在新数据上行为稳定;推理阶段可跳过注意力计算,大幅提升运行速度,且不降低归一化质量。

在前向传播的后续流程中,调用 AdaptSpatialNorm 方法,它是同名内核的包装器。该步骤将计算交给 OpenCL 上下文,在这里执行核心运算:基于注意力权重对输入数据归一化。

下面简单介绍 OpenCL 内核包装方法的组织方式。算法整体逻辑保持不变,但实现层面做了结构性优化,提升代码可读性与可靠性。主要体现在 OpenCL 函数调用的写法上。

引入替换宏,简化并标准化内核参数设置与内核启动流程。例如 setBuffer 宏封装 OpenCL.SetArgumentBuffer 调用,自带错误处理与调试输出,包含内核名称、错误码与出错行号。setArgument 宏功能类似,用于设置标量参数。

#define setBuffer(kernel, id, buffer)  if(!OpenCL.SetArgumentBuffer(kernel, id, buffer)) { \
                                          printf("Error of set parameter kernel %s: %d; line %d",
                                                 OpenCL.GetKernelName(kernel), GetLastError(), __LINE__); \
                                          return false; }
#define setArgument(kernel, id, value) if(!OpenCL.SetArgument(kernel, id, value)) { \
                                          printf("Error of set parameter kernel %s: %d; line %d",
                                                 OpenCL.GetKernelName(kernel), GetLastError(), __LINE__); \
                                          return false; }

内核通过kernelExecute与kernelExecuteLoc宏启动。这些宏隐藏了初始化工作项网格的全部底层样板代码;一旦执行失败,会自动输出详细的错误文本描述,并关联到被调用函数名称。

#define kernelExecute(kernel,offset,global)  if(!OpenCL.Execute(kernel, global.Size(), offset, global)) { \
                                                string error; \
                                                CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error); \
                                                printf("Error of execution kernel %s %s: %s", __FUNCSIG__,
                                                        OpenCL.GetKernelName(kernel), error); \
                                                return false; }
#define kernelExecuteLoc(kernel,offset,global,local)  if(!OpenCL.Execute(kernel, global.Size(), offset,
                                                                         global, local)) {  string error; \
                                                         CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION,
                                                                                                          error); \
                                                         printf("Error of execution kernel %s %s: %s", __FUNCSIG__,
                                                                 OpenCL.GetKernelName(kernel), error); \
                                                         return false; }

得益于这种代码结构,方法内代码更加精简、逻辑更加清晰;新增内核或参数时,扩展维护也更加方便。

另外,正如在内核说明部分提到的,在内核运行前,必须考虑计算设备的硬件限制 —— 尤其是 OpenCL 平台所支持的工作组最大尺寸。这类参数随显卡型号变化,会显著影响代码能否正确执行。

在AdaptSpatialNorm方法内部,通过调用CLGetDeviceInfo实现该逻辑,该函数用于查询各个维度可用的工作组尺寸上限。查询得到的值存入union sizes结构体,我们可以像访问整型数组一样便捷读取,无需关心底层数据存储的细节。

bool CNeuronAdaptSpatialNorm::AdaptSpatialNorm(CNeuronBaseOCL *NeuronOCL)
  {
   if(!OpenCL || !NeuronOCL)
      return false;
   uint global_work_offset[3] = { 0 };
   union sizes
     {
      long data[3];
      uchar cdata[24];
     } max_workgroup_size;
   uint size = 0;
   if(!CLGetDeviceInfo(OpenCL.GetContext(), CL_DEVICE_MAX_WORK_ITEM_SIZES, max_workgroup_size.cdata, size))
      return false;

获取上述参数之后,我们构造global_work_size数组,用来定义整体计算网格。此外,对于决定局部工作组宽度的第二维,我们显式将其取值限制为变量数量iVariables,与设备支持最大值二者中的较小值。这就保证:即便处理海量数据,内核内部的循环也可以正确执行。

   uint global_work_size[] = { iCount, MathMin(iVariables, uint(max_workgroup_size.data[1])), iVariables};
   uint local_work_size[] = { 1, global_work_size[1], 1};
//---
   uint kernel = def_k_AdaptSpatialNorm;
   setBuffer(kernel, def_k_asn_inputs, NeuronOCL.getOutputIndex())
   setBuffer(kernel, def_k_asn_mean_stdevs, cMeanSTDevs.getOutputIndex())
   setBuffer(kernel, def_k_asn_attention, cAttan.getOutputIndex())
   setBuffer(kernel, def_k_asn_outputs, getOutputIndex())
   kernelExecuteLoc(kernel, global_work_offset, global_work_size, local_work_size)
//---
   return true;
  }

与此同时,创建local_work_size数组,第二维上的工作项数量被显式固定。这种方案在处理特征向量时可以高效利用局部内存。

接下来,使用前面定义好的setBuffer宏依次设置内核的缓冲区参数,保证参数初始化过程统一且安全。最后调用kernelExecuteLoc宏,启动AdaptSpatialNorm 内核;启动时会带入所有预先设置的约束条件与计算网格结构。

通过这种方式,我们不仅实现了内核调用的包装器,还搭建了一套灵活机制,可让算法适配目标硬件的具体性能,保证在各类设备上都具备兼容性与稳定运行能力。

下一个关键步骤是误差梯度的分发,在 calcInputGradients 方法中实现。误差信号在此处沿着内部组件反向传播。累积的误差经过逐步变换,反向传递到输入层的可训练参数。算法的这一部分至关重要,模型可以基于预测值与真实值之间的偏差,完成权重更新。

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

该过程首先调用 AdaptSpatialNormGrad 方法。和前向传播对应的接口一样,它是对应 OpenCL 内核的包装器。它负责将梯度从归一化模块的输出向下传播到输入数据与注意力矩阵的可训练参数。

AdaptSpatialNormGrad 方法的实现,整体结构几乎和前向传播内核的调度逻辑一致。同样会定义全局与局部工作组尺寸,借助宏配置缓冲区,并按正确顺序向内核传入参数。但是,基于误差反向传播本身的特性,这里存在一处重要差异。

前向传播阶段,每个元素独立运算;而梯度计算阶段则完全不同:对于输入数据层的每一个元素,必须借助注意力权重,聚合所有单变量序列带来的梯度贡献。这就需要同时访问输出张量的大量不同分量,意味着求和逻辑更加复杂。

注意力权重部分也需要特殊处理。这里需要沿着时间维度仔细整合梯度,也就是在整个序列所有使用该权重的位置上汇总信息。这是计算开销相当大的运算,尤其是输入张量规模很大的时候,因此工作组尺寸的选择需要格外留意。

因此,为正确、高效地完成计算任务,工作组尺寸会选取梯度聚合所在各轴的最大值,同时不能超过对应 OpenCL 设备允许的上限。该方案既满足硬件约束,还能避免内存溢出与内存访问冲突,从而优化性能。

OpenCL 端的全部计算完成后,方法继续执行后续常规逻辑。我们沿着构成注意力矩阵的各个组件,逐层反向传递误差梯度。

   if(!cEnEnT.CalcHiddenGradients(cAttan.AsObject()))
      return false;
   if(!MatMulGrad(cEn.getOutput(), cEn.getPrevOutput(),
                  cEnT.getOutput(), cEnT.getGradient(),
                  cEnEnT.getGradient(), iVariables,
                  cEnT.GetWindow(), iVariables, 1, false))
      return false;
   if(!cEn.CalcHiddenGradients(cEnT.AsObject()) ||
      !SumAndNormilize(cEn.getGradient(), cEn.getPrevOutput(), cEn.getGradient(),
                       cEnT.GetWindow(), false, 0, 0, 0, 1))
      return false;
   if(cEn.Activation() != None)
      if(!DeActivation(cEn.getOutput(), cEn.getGradient(), cEn.getGradient(), cEn.Activation()))
         return false;
//---
   return true;
  }

因此,整个 calcInputGradients 方法是一套结构清晰的步骤序列,能够确保误差在网络中稳定、正确地反向传播。

参数优化方法在此处以尽可能简洁的方式实现,但目标十分明确。整个处理流程的核心是将控制权转交至单个内部对象cEn,该对象保存了可训练的注意力参数。此对象代表参数模块的核心,相关的相关矩阵正是基于它构建。该对象独自承担模块中所有可训练部分的全部处理工作。

bool CNeuronAdaptSpatialNorm::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   return cEn.UpdateInputWeights();
  }

CNeuronAdaptSpatialNorm 类及其全部方法的完整代码已附在文末,可供进一步研究。



SCNN 编码器

在构建好用于提取时间序列各个独立分量的对象之后,顺理成章的下一步,就是将它们整合为一个统一连贯的结构:SCNN Encoder。在这一阶段,预先搭建好的模块被组装成一套定义明确的执行序列,以此保障对待分析数据进行实现既可靠稳定又足够灵活的数据处理。

SCNN Encoder 编码器的核心特点是模块化架构。它基于四条并行分支,每条分支负责对应一个分析尺度。一条分支捕捉长期趋势,一条捕捉季节模式,第三条捕捉短期波动,第四条则对数据执行自适应归一化,强化各个特征之间的关联关系。

所有这些算法都在专用对象 CNeuronSCNNEncoder 内精心实现。该对象的结构体现了上述逻辑,并为形成序列的统一表示奠定基础 —— 这种表征经过压缩、富含有效信息,适用于后续预测任务。

class CNeuronSCNNEncoder   :  public CNeuronTransposeOCL
  {
protected:
   CNeuronPeriodNorm       cLongNorm;
   CNeuronTransposeVRCOCL  cSeasonTransp;
   CNeuronPeriodNorm       cSeasonNorm;
   CNeuronTransposeVRCOCL  cUnSeasonTransp;
   CNeuronPeriodNorm       cShortNorm;
   CNeuronAdaptSpatialNorm cAdaptSpatNorm;
   CNeuronBaseOCL          cConcatenated;
   CNeuronSwiGLUOCL        cProjection;
   CNeuronTransposeOCL     cTranspose;
   CNeuronConvOCL          caFusion[2];
   CNeuronBaseOCL          cFusionOut;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)  override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL)  override;

public:
                     CNeuronSCNNEncoder(void) {};
                    ~CNeuronSCNNEncoder(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint units_count, uint variables, uint forecast,
                          uint season_period, uint short_period,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual bool      Save(const int file_handle) override;
   virtual bool      Load(const int file_handle) override;
   //---
   virtual int       Type(void) override const  {  return defNeuronGinAR; }
   virtual void      TrainMode(bool flag) override;
   virtual void      SetOpenCL(COpenCLMy *obj);
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
  };

这个新类的内部包含若干成员对象,每一个对象在编码器内部都承担严格定义的功能。我们会在后续讲解算法逻辑时介绍它们各自的用途。这里需要重点说明一项关键架构细节:所有这些组件都是静态声明的。这意味着内存会提前为它们分配,其生命周期严格和 CNeuronSCNNEncoder 对象本身保持一致。这种方案省去额外的资源处理逻辑,类的构造函数与析构函数因此可以保持为空。

所有已声明和继承而来的内部对象,都在 Init 方法中完成初始化;该方法的入参提供了定义对象架构所需的全部常量。

bool CNeuronSCNNEncoder::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                              uint units_count, uint variables, uint forecast,
                              uint season_period, uint short_period,
                              ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronTransposeOCL::Init(numOutputs, myIndex, open_cl, units_count + forecast, variables,
                                                                       optimization_type, batch))
      return false;
   SetActivationFunction(None);

初始化操作首先调用父类中同名的基类方法,在其中指定架构的核心参数。初始化成功后,会设置为无激活函数,避免在这一阶段引入非线性畸变。

接下来,负责时间序列预处理的模块会依次创建。首先初始化用于长期分量的归一化模块。

   uint index = 0;
   if(!cLongNorm.Init(0, index, OpenCL, 1, units_count, variables, optimization, iBatch))
      return false;
   cLongNorm.SetActivationFunction(None);

下一个重要步骤是分离季节分量。按照 SCNN 框架作者提出的原始思路,该步骤理解为:以等于季节周期的步长对数据做归一化。乍一看,这似乎需要某种专用模块。但我们遵循经典的系统工程原则,采用了更简洁的实现思路。

我们开发了一个通用归一化对象,用于在固定周期内处理数据。该对象本身并不包含任何季节结构信息;只要改变待分析数组的排布方式,季节结构就会显现。只需按照季节周期长度对时间步序列做转置,就能自动将数据按所需时间间隔分组。新生成的这些序列,刚好符合周期归一化模块所要求的数据格式。

因此,我们只需少量开发工作,就得到一套强大的季节分量提取机制,而无需对归一化对象本身的结构做额外修改。只需要对输入数据做合理重组,之后即可交由这套经过验证、充分调优的算法执行处理。

   index++;
   if(!cSeasonTransp.Init(0, index, OpenCL, variables, units_count / season_period, season_period,
                                                                            optimization, iBatch))
      return false;
   cSeasonTransp.SetActivationFunction(None);
   index++;
   if(!cSeasonNorm.Init(0, index, OpenCL, cSeasonTransp.GetCount(), season_period, variables,
                                                                       optimization, iBatch))
      return false;
   cSeasonNorm.SetActivationFunction(None);
   index++;
   if(!cUnSeasonTransp.Init(0, index, OpenCL, variables, season_period, cSeasonTransp.GetCount(),
                                                                           optimization, iBatch))
      return false;
   index++;

完成季节分量分离后,我们通过反向转置将数据恢复为原始表达形式,再启用短期归一化模块。

   if(!cShortNorm.Init(0, index, OpenCL, units_count / short_period, short_period, variables, optimization, iBatch))
      return false;
   cSeasonNorm.SetActivationFunction(None);
   index++;
   if(!cAdaptSpatNorm.Init(0, index, OpenCL, units_count, variables, optimization, iBatch))
      return false;
   cAdaptSpatNorm.SetActivationFunction(None);

接下来是自适应空间归一化模块,该模块会考虑输入序列内各个变量之间的关联关系。

当来自各个分支的数据都预处理完毕后,会创建一个拼接模块,用来汇总所有归一化模块的输出,合并成单一特征矩阵。该模块的尺寸会动态计算,同时纳入各归一化模块的主输出以及对应的统计量。

   index++;
   uint concatSize = units_count * variables;                                         //inputs
   concatSize += cLongNorm.Neurons() + cLongNorm.GetMeanSTDevs().Neurons();           // long term
   concatSize += cSeasonNorm.Neurons() + cSeasonNorm.GetMeanSTDevs().Neurons();       // seasons
   concatSize += cShortNorm.Neurons() + cShortNorm.GetMeanSTDevs().Neurons();         // short term
   concatSize += cAdaptSpatNorm.Neurons() + cAdaptSpatNorm.GetMeanSTDevs().Neurons(); // spatial
   if(!cConcatenated.Init(0, index, OpenCL, concatSize, optimization, iBatch))
      return false;
   cConcatenated.SetActivationFunction(None);

下一个逻辑组件是投影模块,用于对高维特征空间做高效压缩并重组高维特征空间。

   index++;
   if(!cProjection.Init(0, index, OpenCL, concatSize / variables, concatSize / variables, 
                             units_count + forecast, 1, variables, optimization, iBatch))
      return false;

投影模块的输出送入转置模块,该模块可根据后续处理需求,在时间维度表达与特征维度表达之间转换数据。

   index++;
   if(!cTranspose.Init(0, index, OpenCL, variables, units_count + forecast, optimization, iBatch))
      return false;
   index++;
   if(!caFusion[0].Init(0, index, OpenCL, variables, variables, variables, units_count + forecast,
                                                                            optimization, iBatch))
      return false;
   caFusion[0].SetActivationFunction(TANH);
   index++;
   if(!caFusion[1].Init(0, index, OpenCL, variables, variables, variables, units_count + forecast,
                                                                            optimization, iBatch))
      return false;
   caFusion[1].SetActivationFunction(SIGMOID);
   index++;
   if(!cFusionOut.Init(0, index, OpenCL, caFusion[0].Neurons(), optimization, iBatch))
      return false;
//---
   return true;
  }

最后阶段包含两条并行卷积层,二者实现不同的信号滤波机制。其中一层采用双曲正切激活函数,使输出平滑饱和;另一层使用 Sigmoid 函数,对数值施加逻辑边界约束。两层的输出在最终融合卷积模块中汇合,至此完成整个网络架构的搭建。

我们已经完成了大量工作,本文篇幅也随之大幅增加。接下来将要讲解编码器前向传播与反向传播这套复杂算法,这一部分需要重点理解、仔细研读。因此不妨稍作停顿,留出时间消化、复盘前面介绍的内容。在下一篇文章中,我们将继续推进,把整套方案完善落地,并使用真实历史数据评估这套实现方案的效果。



结论

本文详细探讨了 SCNN 框架实现的下一阶段:搭建并集成自适应空间归一化模块,同时将各个核心组件整合为统一的编码器。我们展示了如何依靠精心设计的架构,结合现代 OpenCL 并行计算技术,有效识别时间序列中的结构化分量,为后续预测任务完成高质量的数据预处理。

在下一篇文章中,我们将继续开展研究,重点在真实数据集上测试并评估 SCNN 框架的性能,以此验证所实现方法的实际价值,以及在金融预测任务中的有效性。


链接


文中使用的程序

# 名称 类型 说明
1 Study.mq5 EA 用于离线模型训练的EA
2 StudyOnline.mq5 EA 用于在线模型训练的EA
3 Test.mq5 EA 模型测试EA
4 Trajectory.mqh 类库 用于描述系统状态与模型架构的结构体
5 NeuroNet.mqh 类库 用于构建神经网络的类库
6 NeuroNet.cl 库文件 OpenCL程序的代码库文件


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

附加的文件 |
MQL5.zip (2931.75 KB)
神经网络在交易中的应用:一种端到端多变量时间序列预测模型(GinAR) 神经网络在交易中的应用:一种端到端多变量时间序列预测模型(GinAR)
我们诚邀您探索一种使用 GinAR 框架预测缺失数据时间序列的创新方法。本文展示了使用 OpenCL 实现关键组件的过程,确保了高性能。在我们的下一篇文章中,我们将详细介绍如何将这些解决方案集成到 MQL5 中。这将有助于理解如何在交易实践中应用该方法。
交易中的神经网络:结构化分量解耦(SCNN) 交易中的神经网络:结构化分量解耦(SCNN)
我们诚邀您探索创新的SCNN框架。该框架可将数据清晰拆分为长期分量、季节分量、短期分量与残差分量,把时间序列分析提升至全新层次。该方法使模型能够适配复杂且不断变化的市场动态,从而显著提升预测精度。
新手在交易中的10个基本错误 新手在交易中的10个基本错误
新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
神经网络在交易中的应用:时间序列的概率预测(结论) 神经网络在交易中的应用:时间序列的概率预测(结论)
我们诚邀您了解 K²VAE 框架以及如何将所提出的方法集成到交易系统中。您将了解到混合 Koopman-Kalman-VAE 方法如何助力构建自适应且可解释的模型。本文最后将展示使用所实现方案的实际应用结果。