English Русский Español Deutsch 日本語 Português
preview
交易中的神经网络:统一轨迹生成模型(UniTraj)

交易中的神经网络:统一轨迹生成模型(UniTraj)

MetaTrader 5交易系统 | 16 五月 2025, 07:45
329 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

针对多个体行为的分析在各个领域都扮演着至关重要的角色,包括金融、智驾、和监控系统。理解个体动作需要解决若干关键任务:对象跟踪、识别、轨迹建模、和动作识别。这些当中,轨迹建模在分析个体走势的过程中尤为重要。尽管环境动态学与个体微妙互动的相关性极其复杂,但近期在解决这个问题方面取得了重大进展。主要成就集中在三个关键领域:轨迹预测、缺失数据恢复、和时空建模。

不过,大多数方式仍然专门针对特定任务。这令它们很难普适到其它问题。有些任务需要前向和后向时空依赖性,这在面向预测的模型中往往被忽视。而一些算法已经成功地解决了多个体轨迹的条件计算,但在审计未来的轨迹时它们频繁失误。这种限制降低了它们在充分理解走势方面的实际适用性,其中预测未来轨迹对于规划后续行动至关重要,而非仅仅重造过去的轨迹。

论文《“破译走势:多个体统一轨迹生成模型》 中提出了统一轨迹生成UniTraj)模型,这是一个将各种与轨迹相关的任务集成到一个统一制程之中的通用框架。具体来说,作者将不同类型的输入数据合并为单一的统一格式:一条任意不完整轨迹,带有一个蒙版,指示每名个体在每个时间步的可见性。该模型将所有任务输入统筹为蒙版轨迹,旨在基于不完整轨迹生成完整的轨迹。

为能按不同轨迹表现中的时空依赖关系建模,作者引入了幽灵空间蒙版GSM)模块,其嵌入在基于变换器的编码器之中。利用现今流行的状态空间模型(SSM),特别是曼巴(Mamba)模型的能力,作者将其改编并增强为双向时态编码器曼巴,用来生成长期的多个体轨迹。此外,他们还提出了一种简单而有效的双向时态缩放BTS)模块,该模块可以全面扫描轨迹,同时保留序列内的时态关系。论文中阐述的实验结果确认了拟议方法的稳健性和卓越性能。


1. UniTraj 算法

为了在单一框架内处理各种初始条件,作者提出了一个统一轨迹生成模型,即将任何任意输入视为一条蒙版轨迹序列。轨迹的可见区域用作约束、或输入数据,而缺失区域则成为生成式任务的目标。该方式导致以下问题定义:

有必要判定完整轨迹 X[N, T, D],其中 N 是个体数量,T 代表轨迹的长度,D 是个体状态的维度。个体 i 在时间步 t 的状态表示为 xi,t[D]。此外,该算法用到二元蒙版矩阵 M[N, T]。如果知道个体 i在时间 t 的位置,则变量 mi,t 等于 1,否则为 0。因此,轨迹按蒙版切分为两段:可见区域,定义为 Xv=X⊙M,缺失区域,定义为 Xm=X⊙(1−M)。意图是创建一条完整轨迹 Y'={X'v,X'm},其中 X'v 是重造的轨迹,而 X'm 是新生成的轨迹。出于一致性,作者将原始轨迹称为地面实况 Y=X={Xv, Xm}。

更正式地说,目标是训练一个参数为 θ 的生成式模型 f(⋅),以便输出完整的轨迹 Y'

估算计模型参数 θ 的常用方式包括分解联合轨迹分布,和最大化对数似然。

考虑个体 i 在时间步 t处的位置为 xi,t。首先,通过减去相邻时间步的坐标来计算相对速度 𝒗i,t,。至于缺失的位置,数值填充为零,并按蒙版进行元素相乘。此外,还定义了一个单类别向量 𝒄i,t 来表示个体类别。这种类别在玩家于运动场景中或许采用的特定进攻或防御策略至关重要。个体特征被投影到高维特征向量 𝒇i,xt 当中。源特征向量的计算如下:

其中 φx(⋅) 是权重为 𝐖x 的投影函数,⊙ 表示元素相乘,⊕表示级联。

该方法作者使用 MLP 实现了 φx(⋅) 。该方式协同了有关位置、速度、可见性、和类别的信息,以此提取空间特征以供后续分析。

不像是其它顺序建模任务,其着重参考密集的社交互动。关于人类互动的研究,主要运用注意力机制,诸如交叉注意力、和基于图论的注意力,以此捕捉这种动态。然而,鉴于 UniTraj 定位于具有任意不完整输入数据的统一任务,故所提议模型必须探索时空缺失形态。作者引入了 幽灵空间蒙版GSM) 模块来抽象和泛化缺失数据的空间结构。该模块无缝集成到变换器架构之中,而不会增加模型复杂性。

UniTraj 中的变换器编码器初衷设计用来针对序列数据中的时间依赖性建模,在空间维度上应用了多头自注意力设计。在每个时间步,N 名个体每位的嵌入都按变换器编码器的输入进行处理。这种方式提取个体的顺序不变空间特征,考虑到实际场景中任何可能的个体排列顺序。因此,最好用完全可训练编码替换正弦位置编码。

如此这般,变换器编码器针对所有个体在每个时间步 t 上输出空间特征 Fs,xt。然后,这些特征沿时间维度级联,以此获得整条轨迹的空间表示。

鉴于曼巴有能力捕获长期时间依赖关系,UniTraj 的作者对其适配,以便集成到拟议的框架之中。然而,由于缺乏特定于轨迹的架构,适配曼巴作为统一轨迹生成颇具挑战性。有效的轨迹建模需要在处理缺失数据的同时捕获时空特征,这会令过程复杂化。

为了强化时态特征提取,同时保留缺失的关系,引入了双向时态曼巴。这般改编协同多个残差曼巴模块,以及双向时态缩放BTS)模块。

最初,蒙版 M用于整个轨迹的处理。它沿时间维度无折叠,从而生成 M',通过在 BTS 模块中使用原始和反向蒙版来促进时态缺失关系的学习。该过程生成一个伸缩矩阵 S,及其逆 S'。具体来说,对于个体 i在时间步 tsi,t 的计算方式如下:

随后,伸缩矩阵 S 及其逆 S' 被投影到特征矩阵之中:

其中 φs(⋅) 表示权重为 𝐖s 的投影函数。

作者实现 φs(⋅) 之时,利用 MLPReLU 激活函数。所提议伸缩矩阵计算自上次观测到当前时间步的距离,量化时态缝隙的影响,尤其是在与复杂缺失形态打交道时。关键的见解是,当变量缺失一段时间后,它的影响会随着时间的推移而减弱。利用一个负指数函数和 ReLU 可确保影响在 0 到 1 之间的合理范围内单调衰减。

编码过程旨在判定近似后验的高斯分布参数。具体来说,后验高斯分布的均值 μqσq 的标准差计算如下:

我们从先前的高斯分布 𝒩(0, I) 中对潜在变量 𝒁 进行采样。

为了强化模型生成合理轨迹的能力,我们将这个函数 Fz,x 与潜在变量 𝒁 结合起来,然后再将其输入解码器。然后,轨迹生成过程的计算如下:

其中 φdec 是利用 MLP 实现的解码器函数。

若存在任意不完整轨迹的情况下,UniTraj 模型会生成一条完整轨迹。在训练期间,将计算可见区域的重造误差和蒙版数据的恢复误差。

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



2. 利用 MQL5 实现

在研究了 UniTraj 方法的理论层面之后,我们转到本文的实践部分,其中我们利用 MQL5 实现我们对所提议方式的愿景。重点要注意,所提议算法在结构上与我们之前研究的方法有所不同。

第一个瞩目区别是蒙版过程。在将输入数据传递给模型时,作者建议准备一个额外的蒙版,以此判定模型能看到哪些数据,以及必须生成哪些数据。这会给工作流增加一个额外的步骤,并增加决策时间,这是不可取的。因此,我们意在将蒙版生成合并到模型本身当中。

第二个层面是将完整轨迹传输到模型。虽然在测试期间可以获得完整轨迹,但在现世部署中不可用。该模型允许蒙盖缺失数据,并随后重造,但我们仍必须为模型提供一个更大的张量。这会导致内存消耗增加,以及数据传输的额外开销,终极影响处理速度。一个潜在的解决方案是限制传输

仅在训练和部署期间访问历史数据。不过,如此行事会损害该方法的很大一部分功能。

为了权衡效率和准确性,我决定将数据传输分为两部分:历史数据和未来轨迹。后者仅在训练阶段提供,从而提取时空依赖关系。在实时执行期间,未来轨迹张量被省略,模型按预测模式运行。

此外,该实现需要在 OpenCL 端进行某些修改。

2.1OpenCL 计划的强化功能


作为我们实现的第一步,我们在 OpenCL 程序中准备新的内核函数。主要添加的是 UniTrajPrepare 内核,负责数据预处理。该内核将历史数据与已知的未来轨迹级联起来,同时应用相应的蒙版。

内核参数包括指向 5 个数据缓冲区的指针:4 个作为输入数据,1 个作为输出结果。它还需要定义历史数据分析深度,和计划横向范围的参数。

__kernel void UniTrajPrepare(__global const float *history,
                             __global const float *h_mask,
                             __global const float *future,
                             __global const float *f_mask,
                             __global float *output,
                             const int h_total,
                             const int f_total
                            )
  {
   const size_t i = get_global_id(0);
   const size_t v = get_global_id(1);
   const size_t variables = get_global_size(1);

我们计划内核执行在二维任务空间之中。第一个维度是两个时间段(历史深度和规划横向范围)中较大者的尺寸。第二个维度将指示所分析参数的数量。

在内核主体中,我们首先标识给定任务空间中的线程。我们还判定数据缓冲区中的偏移量。

   const int shift_in = i * variables + v;
   const int shift_out = 3 * shift_in;
   const int shift_f_out = 3 * (h_total * variables + v);

接下来,我们处理历史数据。在此,我们首先判定参考蒙版的参数变化率。然后我们将参数值保存在结果缓冲区之中,同时参考最大值、先前计算的速度和蒙版本身。

//--- history
   if(i < h_total)
     {
      float mask = h_mask[shift_in];
      float h = history[shift_in];
      float v = (i < (h_total - 1) && mask != 0 ? (history[shift_in + variables] - h) * mask : 0);
      if(isnan(v) || isinf(v))
         v = h = mask = 0;
      output[shift_out] = h * mask;
      output[shift_out + 1] = v;
      output[shift_out + 2] = mask;
     }

我们计算未来数值的类似参数。

//--- future
   if(i < f_total)
     {
      float mask = f_mask[shift_in];
      float f = future[shift_in];
      float v = (i < (f_total - 1) && mask != 0 ? (future[shift_in + variables] - f) * mask : 0);
      if(isnan(v) || isinf(v))
         v = f = mask = 0;
      output[shift_f_out + shift_out] = f * mask;
      output[shift_f_out + shift_out + 1] = v;
      output[shift_f_out + shift_out + 2] = mask;
     }
  }

接下来我们保存上述运算的逆向通验内核:UniTrajPrepareGrad

__kernel void UniTrajPrepareGrad(__global float *history_gr,
                                 __global float *future_gr,
                                 __global const float *output,
                                 __global const float *output_gr,
                                 const int h_total,
                                 const int f_total
                                )
  {
   const size_t i = get_global_id(0);
   const size_t v = get_global_id(1);
   const size_t variables = get_global_size(1);

注意,我们没有在后向通验方法的参数中指定指向源数据和蒙版缓冲区的指针。代之,我们用到 UniTrajPrepare 前馈内核的结果缓冲区,它存储了指定的数据。此外,我们不会将误差梯度传递至掩码层,因为这没有意义。

反向传播内核的任务空间与上面所讨论前馈内核的任务空间相同。

在内核主体中,我们识别任务空间中的当前线程,并判定数据缓冲区的偏移量。

   const int shift_in = i * variables + v;
   const int shift_out = 3 * shift_in;
   const int shift_f_out = 3 * (h_total * variables + v);

类似于前馈内核,我们把工规划为 2 个阶段。首先,我们将误差梯度分派给历史数据层。

//--- history
   if(i < h_total)
     {
      float mask = output[shift_out + 2];
      float grad = 0;
      if(mask > 0)
        {
         grad = output_gr[shift_out] * mask;
         grad -= (i < (h_total - 1) && mask != 0 ? (output_gr[shift_out + 1]) * mask : 0);
         grad += (i > 0 ? output[shift_out + 1 - 3 * variables] * output[shift_out + 2 - 3 * variables] : 0);
         if(isnan(grad) || isinf(grad))
            grad = 0;
         //---
        }
      history_gr[shift_in] = grad;
     }

然后我们将误差梯度传播给已知的预测值。

//--- future
   if(i < f_total)
     {
      float mask = output[shift_f_out + shift_out + 2];
      float grad = 0;
      if(mask > 0)
        {
         grad = output_gr[shift_f_out + shift_out] * mask;
         grad -= (i < (h_total - 1) && mask != 0 ? (output_gr[shift_f_out + shift_out + 1]) * mask : 0);
         grad += (i > 0 ? output[shift_f_out + shift_out + 1 - 3 * variables] * 
                          output[shift_f_out + shift_out + 2 - 3 * variables] : 0);
         if(isnan(grad) || isinf(grad))
            grad = 0;
         //---
        }
      future_gr[shift_in] = grad;
     }
  }

我们需要在 OpenCL 端实现的另一个算法是创建伸缩矩阵。在 UniTrajBTS内核中,我们计算正向和逆向伸缩矩阵。

在此,我们还用到数据准备内核的前馈结果作为输入。基于其数据,我们计算与最后一个无蒙板的数值在正向和反向上的偏移量,并将其保存在相应的数据缓冲区之中。 

__kernel void UniTrajBTS(__global const float * concat_inp,
                         __global float * d_forw,
                         __global float * d_bakw,
                         const int total
                        )
  {
   const size_t i = get_global_id(0);
   const size_t v = get_global_id(1);
   const size_t variables = get_global_size(1);

我们使用一个二维任务空间。但在第一维中,我们只有 2 个线程,分别对应于正向和逆向伸缩矩阵的计算。在第二个维度中,和如前,我们将指示正在分析的变量数量。

在标识了任务空间中的线程后,我们根据第一维的数值来拆分内核算法。

   if(i == 0)
     {
      const int step = variables * 3;
      const int start = v * 3 + 2;
      float last = 0;
      d_forw[v] = 0;
      for(int p = 1; p < total; p++)
        {
         float m = concat_inp[start + p * step];
         d_forw[p * variables + v] = last = 1 + (1 - m) * last;
        }
     }

在计算直接伸缩矩阵时,我们判定所分析变量的第一个元素蒙版的偏移量,和下一个元素的步长。然后,我们按顺序迭代所分析元素的蒙板,根据给定的公式计算伸缩系数。

对于逆伸缩矩阵,算法保持不变。除了我们判定到最后一个元素的偏移量,并以相反的顺序迭代。

   else
     {
      const int step = -(variables * 3);
      const int start = (total - 1) * variables + v * 3 + 2;
      float last = 0;
      d_bakw[(total - 1) + v] = 0;
      for(int p = 1; p < total; p++)
        {
         float m = concat_inp[start + p * step];
         d_bakw[(total - 1 - p) * variables + v] = last = 1 + (1 - m) * last;
        }
     }
  }

注意,所提议算法仅配以蒙版工作,误差梯度的分布没有意义。出于该原因,我们没有为该算法创建反向传播内核。我们在 OpenCL 程序端的操作到此完毕。您可在附件中找到其完整代码。

2.2实现 UniTraj 算法


OpenCL 程序端的准备操作之后,我们转到主程序端实现所提议方式。UniTraj 算法将在 CNeuronUniTraj 类中实现。其结构如下所示。

class CNeuronUniTraj    :  public CNeuronBaseOCL
  {
protected:
   uint              iVariables;
   float             fDropout;
   //---
   CBufferFloat      cHistoryMask;
   CBufferFloat      cFutureMask;
   CNeuronBaseOCL    cData;
   CNeuronLearnabledPE cPE;
   CNeuronMVMHAttentionMLKV   cEncoder;
   CNeuronBaseOCL    cDForw;
   CNeuronBaseOCL    cDBakw;
   CNeuronConvOCL    cProjDForw;
   CNeuronConvOCL    cProjDBakw;
   CNeuronBaseOCL    cDataDForw;
   CNeuronBaseOCL    cDataDBakw;
   CNeuronBaseOCL    cConcatDataDForwBakw;
   CNeuronMambaBlockOCL cSSM[4];
   CNeuronConvOCL    cStat;
   CNeuronTransposeOCL cTranspStat;
   CVAE              cVAE;
   CNeuronTransposeOCL cTranspVAE;
   CNeuronConvOCL    cDecoder[2];
   CNeuronTransposeOCL cTranspResult;
   //---
   virtual bool      Prepare(const CBufferFloat* history, const CBufferFloat* future);
   virtual bool      PrepareGrad(CBufferFloat* history_gr, CBufferFloat* future_gr);
   virtual bool      BTS(void);
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override { return feedForward(NeuronOCL, NULL); }
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override;
   //---
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override 
                                       { return calcInputGradients(NeuronOCL, NULL, NULL, None); }
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override 
                                       { return updateInputWeights(NeuronOCL, NULL); }
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, 
                                                                   CBufferFloat *SecondGradient, 
                                                            ENUM_ACTIVATION SecondActivation = None) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *second) override;
   //---

public:
                     CNeuronUniTraj(void) {};
                    ~CNeuronUniTraj(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                          uint window, uint window_key, uint heads, uint units_count, 
                          uint forecast, float dropout, ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const   {  return defNeuronUniTrajOCL; }
   //---
   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);
  };

如您所见,类结构声明了大量内部对象,我们将随方法实现的进度逐步探索其功能。所有对象都声明为静态。这允许我们将类构造函数和析构函数留空,而内存作将委托给系统。

所有内部对象都在 Init 方法中初始化。在其参数中,我们获得了允许我们唯一标识对象架构的主要常量。

bool CNeuronUniTraj::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                          uint window, uint window_key, uint heads, uint units_count, 
                          uint forecast, float dropout, ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * (units_count + forecast), 
                                                                   optimization_type, batch))
      return false;

在方法主体内,遵照既定的约定,我们首先调用父类的同名方法,其已实现了继承对象的基本初始化控制。

父类操作成功执行之后,我们存储从外部程序得到的常量。这些指标包括输入数据中所分析变量的数量,以及训练过程中被蒙盖的元素的比例。

   iVariables = window;
   fDropout = MathMax(MathMin(dropout, 1), 0);

接下来,我们转到初始化声明的对象。于此,我们首先创建缓冲区来蒙盖历史和预测数据。

   if(!cHistoryMask.BufferInit(iVariables * units_count, 1) ||
      !cHistoryMask.BufferCreate(OpenCL))
      return false;
   if(!cFutureMask.BufferInit(iVariables * forecast, 1) ||
      !cFutureMask.BufferCreate(OpenCL))
      return false;

然后我们初始化所级联源数据的内层。

   if(!cData.Init(0, 0, OpenCL, 3 * iVariables * (units_count + forecast), optimization, iBatch))
      return false;

并创建一个大小相似的可学习定位编码层。

   if(!cPE.Init(0, 1, OpenCL, cData.Neurons(), optimization, iBatch))
      return false;

接下来是变换器编码器,用于提取时空依赖关系。

   if(!cEncoder.Init(0, 2, OpenCL, 3, window_key, heads, (heads + 1) / 2, iVariables, 1, 1, 
                                                  (units_count + forecast), optimization, iBatch))
      return false;

值得注意的是,作者进行了一系列实验,并得出结论,当使用单个变换器编码器模块,和四个曼巴模块时,该方法达成了最优性能。因此,在这种情况下,我们仅用一个编码器层。

此外,注意输入窗口大小设置为 “3”,对应于每个时间步单个指标的三个参数(数值、速度、和蒙版)。序列长度由所分析变量的数量决定,而独立通道的数量设置为所分析历史和预测横向范围的总深度。这种设置令我们能够在单个时间步内估算所分析指标之间的依赖关系。

接下来,我们进入 BTS 模块,在其中我们要创建正向和逆向伸缩矩阵。

   if(!cDForw.Init(0, 3, OpenCL, iVariables * (units_count + forecast), optimization, iBatch))
      return false;;
   if(!cDBakw.Init(0, 4, OpenCL, iVariables * (units_count + forecast), optimization, iBatch))
      return false;

然后,我们添加卷积层来投影这些矩阵。

   if(!cProjDForw.Init(0, 5, OpenCL, 1, 1, 3, iVariables, (units_count + forecast), optimization, iBatch))
      return false;
   cProjDForw.SetActivationFunction(SIGMOID);
   if(!cProjDBakw.Init(0, 6, OpenCL, 1, 1, 3, iVariables, (units_count + forecast), optimization, iBatch))
      return false;
   cProjDBakw.SetActivationFunction(SIGMOID);

生成的投影将逐个元素乘以编码器的工作成果,并且运算的结果将写入以下对象。

   if(!cDataDForw.Init(0, 7, OpenCL, cData.Neurons(), optimization, iBatch))
      return false;
   if(!cDataDBakw.Init(0, 8, OpenCL, cData.Neurons(), optimization, iBatch))
      return false;

然后,我们计划将结果数据级联到单个张量之中。

   if(!cConcatDataDForwBakw.Init(0, 9, OpenCL, 2 * cData.Neurons(), optimization, iBatch))
      return false;

我们将该张量传递给 SSM 模块。如前所述,在这个模块中,我们初始化了 4 个连续的曼巴层。

   for(uint i = 0; i < cSSM.Size(); i++)
     {
      if(!cSSM[i].Init(0, 10 + i, OpenCL, 6 * iVariables, 12 * iVariables, 
                                 (units_count + forecast), optimization, iBatch))
         return false;
     }

于此,该方法作者提议使用与曼巴层的残差连接。我们将深入迈进,并用到我们在搭配 TrajLLM 方法工作时创建的 CNeuronMambaBlockOCL 类。

我们将获得的结果投影到目标分布的统计变量上。

   uint id = 10 + cSSM.Size();
   if(!cStat.Init(0, id, OpenCL, 6, 6, 12, iVariables * (units_count + forecast), optimization, iBatch))
      return false;

但在对数值进行采样和重新参数化之前,我们需要重新排列数据。为此,我们用到转置层。

   id++;
   if(!cTranspStat.Init(0, id, OpenCL, iVariables * (units_count + forecast), 12, optimization, iBatch))
      return false;
   id++;
   if(!cVAE.Init(0, id, OpenCL, cTranspStat.Neurons() / 2, optimization, iBatch))
      return false;

我们将采样值转换至独立信息通道的维度。

   id++;
   if(!cTranspVAE.Init(0, id, OpenCL, cVAE.Neurons() / iVariables, iVariables, optimization, iBatch))
      return false;

然后我们经由解码器传递数据,并在输出处获取生成的目标序列。

   id++;
   uint w = cTranspVAE.Neurons() / iVariables;
   if(!cDecoder[0].Init(0, id, OpenCL, w, w, 2 * (units_count + forecast), iVariables, optimization, iBatch))
      return false;
   cDecoder[0].SetActivationFunction(LReLU);
   id++;
   if(!cDecoder[1].Init(0, id, OpenCL, 2 * (units_count + forecast), 2 * (units_count + forecast), 
                                                 (units_count + forecast), iVariables, optimization, iBatch))
      return false;
   cDecoder[1].SetActivationFunction(TANH);

现在我们只需将得到的结果转换为原始数据的维度。

   id++;
   if(!cTranspResult.Init(0, id, OpenCL, iVariables, (units_count + forecast), optimization, iBatch))
      return false;

为了避免不必要的数据复制操作,我们替换指向数据缓冲区的指针。

   if(!SetOutput(cTranspResult.getOutput(), true) ||
      !SetGradient(cTranspResult.getGradient(), true))
      return false;
   SetActivationFunction((ENUM_ACTIVATION)cDecoder[1].Activation());
//---
   return true;
  }

在每个阶段,我们确保对执行过程进行相应监控,在方法完成后,我们向调用程序返回一个布尔值,表明方法成功。

一旦类实例初始化完成后,我们转到实现前馈通验方法。最初,我们执行一个简短的准备步骤,将先前创建的内核的排队执行。此处,我们依赖于成熟的算法,您可在随附的素材中单独审查这些算法。不过,在本文中,我提议专注高层级 feedForward 方法,其中我们大致勾画出了整个算法轮廓。

bool CNeuronUniTraj::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput)
  {
   if(!NeuronOCL)
      return false;

在方法参数中,我们收到指向 2 个包含历史值和预测值的对象指针。在方法主体中,我们立即检查指针与历史数据的相关性。如您所知,根据我们的逻辑,历史数据始终存在。但可能没有任何预测值。

接下来,我们组织生成历史数据的随机蒙版张量的过程。

//--- Create History Mask
   int total = cHistoryMask.Total();
   if(!cHistoryMask.BufferInit(total, 1))
      return false;
   if(bTrain)
     {
      for(int i = 0; i < int(total * fDropout); i++)
         cHistoryMask.Update(RND(total), 0);
     }
   if(!cHistoryMask.BufferWrite())
      return false;

注意,蒙版仅在训练过程中应用。在部署设置中,我们会利用所有可用信息。

接下来,我们为预测值建立一个类似的过程。不过,有一个关键的细微差别。当预测值可用时,我们生成一个随机蒙版张量。在有关未来走势信息缺失的情况下,我们用零值填充整个蒙版张量。

//--- Create Future Mask
   total = cFutureMask.Total();
   if(!cFutureMask.BufferInit(total, (!SecondInput ? 0 : 1)))
      return false;
   if(bTrain && !!SecondInput)
     {
      for(int i = 0; i < int(total * fDropout); i++)
         cFutureMask.Update(RND(total), 0);
     }
   if(!cFutureMask.BufferWrite())
      return false;

生成蒙版张量后,我们可以执行数据准备和级联步骤。

//--- Prepare Data
   if(!Prepare(NeuronOCL.getOutput(), SecondInput))
      return false;

然后我们添加定位编码,并将其传递给变换器编码器。

//--- Encoder
   if(!cPE.FeedForward(cData.AsObject()))
      return false;
   if(!cEncoder.FeedForward(cPE.AsObject()))
      return false;

接下来,根据 UniTraj 算法,我们使用 BTS 模块。我们创建正向和逆向伸缩矩阵。

//--- BTS
   if(!BTS())
      return false;

我们制定它们的投影。

   if(!cProjDForw.FeedForward(cDForw.AsObject()))
      return false;
   if(!cProjDBakw.FeedForward(cDBakw.AsObject()))
      return false;

我们将它们乘以编码器的工作结果。

   if(!ElementMult(cEncoder.getOutput(), cProjDForw.getOutput(), cDataDForw.getOutput()))
      return false;
   if(!ElementMult(cEncoder.getOutput(), cProjDBakw.getOutput(), cDataDBakw.getOutput()))
      return false;

然后我们将获得的数值合并到一个张量。

   if(!Concat(cDataDForw.getOutput(), cDataDBakw.getOutput(), cConcatDataDForwBakw.getOutput(),
              3, 3, cData.Neurons() / 3))
      return false;

我们在状态空间模型中分析数据。

//--- SSM
   if(!cSSM[0].FeedForward(cConcatDataDForwBakw.AsObject()))
      return false;
   for(uint i = 1; i < cSSM.Size(); i++)
      if(!cSSM[i].FeedForward(cSSM[i - 1].AsObject()))
         return false;

之后,我们得到目标分布的统计指标的预测。

//--- VAE
   if(!cStat.FeedForward(cSSM[cSSM.Size() - 1].AsObject()))
      return false;

然后我们从给定的分布中抽取数值。

   if(!cTranspStat.FeedForward(cStat.AsObject()))
      return false;
   if(!cVAE.FeedForward(cTranspStat.AsObject()))
      return false;

解码器生成目标序列。

//--- Decoder
   if(!cTranspVAE.FeedForward(cVAE.AsObject()))
      return false;
   if(!cDecoder[0].FeedForward(cTranspVAE.AsObject()))
      return false;
   if(!cDecoder[1].FeedForward(cDecoder[0].AsObject()))
      return false;
   if(!cTranspResult.FeedForward(cDecoder[1].AsObject()))
      return false;
//---
   return true;
  }

然后,我们将其转置到输入数据维度。 

如您或许记得,在对象初始化方法期间,我们替换了指向数据缓冲区的指针,在该阶段无需将接收到的数值从内部对象复制到我们类的继承缓冲区。为了完成前馈方法,我们仅需将布尔执行结果返回给调用程序。

构造前馈算法后,下一步典型是规划反向传播过程。这些过程是前馈通验的镜像,但数据流是逆向的。不过,考虑到我们的工作纵深和文章的格式限制,我们不会在此涵盖反向传播通验的细节。代之,我把它留待独立研究。提醒一下,可在附件中找到该类,及其所有方法的完整代码。

2.3模型架构


在遵照我们的解释实现了 UniTraj 算法之后,我们现在继续将其集成到我们的模型之中。就像应用于历史数据的其它轨迹分析方法,我们将把所提议算法合并到环境状态编码器模型当中。该模型架构是在 CreateEncoderDescriptions 方法中定义。

bool CreateEncoderDescriptions(CArrayObj *&encoder)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         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;
     }

我们立即把归一化数据传送到新的 UniTraj 模块。在此,我们将蒙版系数设置为所接收数据的 50%。 

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronUniTrajOCL;
   descr.window = BarDescr;                          //window
   descr.window_out = EmbeddingSize;                 //Inside Dimension
   descr.count = HistoryBars;                        //Units
   descr.layers = NForecast;                         //Forecast
   descr.step=4;                                     //Heads
   descr.probability=0.5f;                           //DropOut
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

在模块的输出处,我们得到一个任意的目标轨迹,其中包含给定规划横向范围的已复原历史数据和预测值。对于获得的数据,我们添加了输入数据的统计变量,其在数据归一化期间曾被删除。

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

然后,我们在频域中校准预测值。

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

得益于我们新 CNeuronUniTraj 模块的综合架构,所创建模型的描述保持简洁和结构化,而不会影响其功能。

应当注意的是,环境状态编码器模型增加的张量大小需要对参与者评论者模型进行略微调整。不过,这些修改很小,可在随附的素材中单独查看。然而,针对编码器模型训练程序的修改更为重大。

2.4模型训练程序


针对环境状态编码器模型架构的修改,以及 UniTraj 作者所提议训练方式,需要更新模型训练 EA “...\Experts\UniTraj\StudyEncoder.mq5”。

他的第一个调整涉及修改模型认证模块,以此检查输出层的大小。这是该 EA 初始化方法内的一次有针对性的更新。

   Encoder.getResults(Result);
   if(Result.Total() != (NForecast+HistoryBars) * BarDescr)
     {
      PrintFormat("The scope of the Encoder does not match the forecast state count (%d <> %d)",
                                             (NForecast+HistoryBars) * BarDescr, Result.Total());
      return INIT_FAILED;
     }

但正如您能猜到的那样,主要工作需求是在模型训练方法里— Train

void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);

该方法首先生成一个概率向量,在训练期间基于获得的回报选择轨迹。该操作的本质是盈利轨迹的优先级更频繁,允许模型学习更具盈利性的策略。

接下来,我们声明必要的变量。

   vector<float> result, target, state;
   bool Stop = false;
   const int Batch = 1000;
   int b = 0;
//---
   uint ticks = GetTickCount();

我们创建了一个模型训练循环系统。

   for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter += b)
     {
      int tr = SampleTrajectory(probability);
      int start = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 5 - NForecast));
      if(start <= 0)
         continue;

重点是要注意,曼巴模块具有递归性质,这会影响其训练过程。最初,我们从经验回放缓冲区中抽取单条轨迹,并选择开始状态进行训练。然后,我们创建一个嵌套循环,以沿所选轨迹顺序迭代状态。

      for(b = 0; (b < Batch && (iter + b) < Iterations); b++)
        {
         int i = start + b;
         if(i >= MathMin(Buffer[tr].Total, Buffer_Size) - NForecast)
            break;

我们首先从经验回放缓冲区加载已分析参数的历史数据。

         state.Assign(Buffer[tr].States[i].state);
         if(MathAbs(state).Sum() == 0)
            break;
         bState.AssignArray(state);

然后我们加载真实的后续数值。

         //--- Collect target data
         if(!Result.AssignArray(Buffer[tr].States[i + NForecast].state))
            continue;
         if(!Result.Resize(BarDescr * NForecast))
            continue;

之后,我们将学习过程随机分成 2 个线程,概率均为 50%。

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

在第一种情况下,如前,我们只将历史数据投喂模型,并执行前馈通验。在第二种情况下,我们还为模型提供了价格走势的实际未来值。这意味着该模型接收有关历史和未来系统状态的完整真实信息。

         else
           {
            if(Result.GetIndex()>=0)
              Result.BufferWrite();
            if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, Result))
              {
               PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
               Stop = true;
               break;
              }
           }

提醒一下,我们的算法在训练期间,在 50% 的输入数据上应用随机蒙版。该模式强制模型学习如何恢复蒙版值。

在模型的输出中,我们得到完整的轨迹作为单个张量,故我们将 2 个源数据缓冲区合并至一个张量,并用其作为模型的反向传播通验。在反向通验期间,我们调整模型的训练参数,以便把数据恢复和预测中的总体误差最小化。

         //--- Collect target data
         if(!bState.AddArray(Result))
            continue;
         if(!Encoder.backProp((CBufferFloat*)GetPointer(bState), (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

现在我们只需要告知用户训练过程的进度,并转到循环系统的下一次迭代。

         //---
         if(GetTickCount() - ticks > 500)
           {
            double percent = double(iter + b) * 100.0 / (Iterations);
            string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Encoder", 
                                          percent, Encoder.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

模型训练循环的所有迭代成功完成后,我们清除品种图表上的注释字段。我们将训练结果输出到终端日志,并启动模型结束。

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

在模型部署期间,我们未计划提供预测值作为输入。因此,参与者政策的训练计划保持不变。您可在附件中找到此处所用的所有程序的完整代码。


3. 测试

在前面的部分中,我们探讨了 UniTraj 方法依据多模态时间序列工作的理论基础。我们利用 MQL5 实现我们的解释。现在,我们转到最后阶段,评估这些方式对我们的特定任务的有效性。

尽管修改了环境状态编码器模型的架构和训练程序,但训练数据集的结构保持不变。这令我们能够使用以前收集的数据集启动训练。

再次,为了训练模型,我们采用 EURUSD 金融产品整个 2023 年的真实历史数据,以及 H1 时间帧。所有指标参数均按其默认值设置。

在该阶段,我们训练编码器模型。如前所述,在编码器训练期间无需更新训练数据集。模型经受训练,直至达到所需的性能。该模型不能被描述为轻量级。因此,它的训练需要时间。不过,该过程进行得很平滑。如此这般,我们得到了未来价格走势的可视合理预测。

也就是说,预测的轨迹明显平滑。重建造的轨迹亦如此。这表明原始数据中的噪声有相当大的降低。模型训练的下一阶段将判定这种平滑是否有益于开发可盈利的参与者政策。

第二阶段涉及迭代训练参与者评论者模型。在该阶段,我们需要根据环境状态编码器生成的预测价格走势,找到一个可盈利的参与者政策。编码器输出预测和重建的历史价格走势。

为了测试经过训练的模型,我们采用 2024 年 1 月的历史数据,同时保持所有其它参数不变。

在测试期间,我们已训练的参与者模型产生了超过 40% 的盈利,最大回撤略高于 24%。EA 总共执行了 65 笔交易,其中 33 笔以盈利了结。鉴于最大和平均获胜交易值超过相关的亏损变量,因此盈利因子记录为 1.51。当然,一个月的测试期和 65 笔交易不足以保证长期的稳定性。然而,结果超过了 Traj-LLM 方法所达成的结果。


结束语

本次研究中阐述的 UniTraj 方法,展示了它在横跨各种境况下,作为处置个体轨迹多功能工具的潜力。这种方式解决了一个关键问题:令模型适配多种任务,与传统方法相比,这提高性能。UniTraj 对蒙版输入数据的统一处理,令其成为灵活高效的解决方案。

在实践章节,我们利用 MQL5 实现了所提议方法,并将它们集成到环境状态编码器模型当中。我们在真实历史数据上训练并测试了该模型。获得的结果展示出所提议方式的有效性,这令它们能够用于构建真实世界的交易策略。


参考

  • 破译走势:多个体的统一轨迹生成模型
  • 本系列的其它文章


  • 文章中所用程序

    # 发行 类型 说明
    1 Research.mq5 EA 样本收集 EA
    2 ResearchRealORL.mq5
    EA
    利用 Real ORL方法收集样本的 EA
    3 Study.mq5 EA 模型训练 EA
    4 StudyEncoder.mq5 EA
    编码器训练 EA
    5 Test.mq5 EA 模型测试 EA
    6 Trajectory.mqh 类库 系统状态描述结构
    7 NeuroNet.mqh 类库 创建神经网络的类库
    8 NeuroNet.cl 代码库 OpenCL 程序代码库

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

    附加的文件 |
    MQL5.zip (1667.85 KB)
    重构MQL5中的经典策略(第三部分):富时100指数预测 重构MQL5中的经典策略(第三部分):富时100指数预测
    在本系列文章中,我们将重新审视一些知名的交易策略,以探究是否可以利用AI来改进这些策略。在今天的文章中,我们将研究富时100指数,并尝试使用构成该指数的部分个股来预测该指数。
    使用MQL5和Python构建自优化的EA(第四部分):模型堆叠 使用MQL5和Python构建自优化的EA(第四部分):模型堆叠
    今天,我们将展示如何构建能够从自身错误中学习的AI驱动的交易应用程序。我们将展示一种称为堆叠(stacking)的技术,我们使用2个模型来做出1个预测。第一个模型通常是较弱的学习器,而第二个模型通常是更强大的模型,它学习较弱学习器的残差。我们的目标是创建一个模型集成,以期获得更高的准确性。
    掌握 MQL5:从入门到精通(第五部分):基本控制流操作符 掌握 MQL5:从入门到精通(第五部分):基本控制流操作符
    本文探讨了用于修改程序执行流程的关键操作符:条件语句、循环和 switch 语句。利用这些操作符将使我们创建的函数表现得更加“智能”。
    开发多币种 EA 交易(第 17 部分):为真实交易做进一步准备 开发多币种 EA 交易(第 17 部分):为真实交易做进一步准备
    目前,我们的 EA 使用数据库来获取交易策略单个实例的初始化字符串。然而,这个数据库相当大,包含许多实际 EA 操作不需要的信息。让我们尝试在不强制连接到数据库的情况下确保 EA 的功能。