English Русский Español Deutsch 日本語 Português
preview
神经网络在交易中的应用:基于频域的异常检测 (CATCH)

神经网络在交易中的应用:基于频域的异常检测 (CATCH)

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

概述

现代金融市场实时运作,每秒处理海量数据。股票价格、汇率、交易量、利率 — 所有这些都构成了复杂的高维时间序列。分析此类数据对交易者和投资者至关重要。这有助于他们预测市场动向,并发现隐藏的模式。

时间序列分析的核心挑战之一是异常检测。价格突然飙升、流动性急剧变化或可疑交易活动可能表明存在市场操纵或内幕交易。如果这些信号被忽视,后果可能十分严重 — 从重大损失到整个金融机构的崩溃。

异常通常分为两类:点异常和子序列异常。点异常是明显的异常值,例如某只股票交易量的突然激增。使用标准方法,这些相对容易检测。子序列异常更为微妙 — 乍一看它们似乎正常,但与既定的市场模式存在偏差。例子包括资产之间相关性的长期变化,或在市场波动期间价格异常平稳的上涨。这些异常情况尤为重要,因为它们往往揭示了隐藏的风险。

检测此类模式的最有效方法之一是将数据转换到频域。在这种表示方法中,不同类型的异常会出现在特定的频率范围内。例如,短期波动性激增会影响高频成分,而更广泛的趋势变化则体现在低频频段。然而,传统方法往往会丢失重要细节,尤其是在高频范围内,因为那里可能存在微妙但至关重要的信号。

此外,还必须考虑不同市场工具之间的关系。例如,如果石油期货价格大幅下跌,而石油公司股票却保持稳定,这可能表明市场存在不一致性。经典模型要么忽视这些依赖关系,要么施加过于严格的假设,从而降低了预测准确性。

论文"CATCH:Channel-Aware Multivariate Time Series Anomaly Detection via Frequency Patching"(CATCH:基于频率分块的通道感知多变量时间序列异常检测)中提出了解决这些问题的一种可能方案。作者介绍了 CATCH 框架,该框架利用傅里叶变换在频域中分析市场数据。为了改进复杂异常的检测,他们提出了一种频率分块机制,该机制能够以高精度对正常资产行为进行建模。自适应关系模块能够自动识别市场工具之间的有意义关联,同时过滤掉噪声。


CATCH 算法

CATCH 架构由三个关键模块组成:

  • 前向模块
  • 通道融合模块CFM ),
  • 时频重建模块TFRM )。
这种设计能够详细分析时域和频域特性,同时捕捉通道间隐藏的依赖关系。因此,该模型即使在复杂的多变量时间序列中也能有效检测异常情况。

第一阶段是前向模块。它包括数据归一化、使用快速傅里叶变换 ( FFT ) 将时间序列转换到频域,以及将结果分割成频率块。傅里叶变换将时间序列表示为一组正交三角函数,同时保留了频谱的实部和虚部。

接下来,将频谱分成 L 个大小为 P 的频率块,步长为 S。实部和虚部都使用相同的参数进行分块,然后将它们连接成一个统一的张量。

然后,使用投影层将这些分块投影到潜在空间中:

这一步骤至关重要,因为它在降低维度的同时保留了最具信息量的特征。这提高了模型的泛化能力和异常检测的准确性。

第二个组件是通道融合模块CFM ),它捕获每个频段内通道之间的依赖关系。这是通过使用带通道掩码的 TransformerCMT)来实现的。通道掩码 M掩码生成器( MG ) 生成。MG 构建概率矩阵 D ,并通过伯努利重采样将其二值化。D 中的高值与 M 中的高值相对应,表明通道之间存在依赖关系。

CMT 使用掩码注意力来处理分块,可以用以下表达式来描述:

在掩码生成和注意力机制调整的背景下,为了实现有效的优化,明确界定优化目标至关重要,这有助于提高所生成掩码的质量。关键思想是明确增加掩码所识别的相关通道之间的注意力权重。这样可以将注意力机制与最有意义的相关性联系起来,从而提高模型的整体性能。

这种方法的一个主要优点是,它避免了在注意力过程中引入无关通道所带来的负面影响。通过只关注信息量最大的通道,掩码注意力机制可以减少噪声和失真。这种方法使我们能够实现注意力机制的稳定性,从而使模型在动态环境中更加稳健和准确。

下一步是对掩码生成器进行迭代优化,以完善通道间的相关性。这包括在通道的上下文中微调掩码 Transformer 层内的注意力机制,以更好地捕获所有相关的通道间关系。

为了优化掩蔽,作者引入了 ClusteringLoss 函数。

最后,时频重建模块TFRM )应用逆傅里叶变换( iFFT )来重建时间序列。

根据重建误差检测异常情况。

CATCH 模型采用结合时域和频域分析的复杂分析方法,提供稳健可靠的异常检测。

下面展示了 CATCH 框架的示意图。



MQL5 中的实现

在回顾了 CATCH 方法的理论方面之后,我们现在进入文章的实践部分,我们将用 MQL5 实现我们对这些方法的一个具体版本。

首先,需要指出的是,此框架中的几乎所有操作都是在频域中执行的。这是一个决定性的特征,它既决定了数据处理的方法,也决定了数学工具的选择。

众所周知,在频域中表示信号需要用到复数。因此,高效处理复杂数据(包括算术运算)对于系统的正确运行至关重要。

我们在开发 ATFNet 框架时已经遇到过类似的挑战。当时,我们为光谱数据处理制定了原则,并开发了现在可以重复使用的方法论。之前的这些实现方式大大简化了后续的实现。

复杂卷积层


我们首先设计了一个能够处理复数值的卷积层。在实践中,卷积层是处理多元序列最有效的工具之一。这就是我们优先开发这个组件的原因。

和往常一样,我们首先在 OpenCL 端实现核心算法。在 GPU 级别实现关键操作可以让我们实现最大程度的并行性,这对于处理多元数据至关重要。与 CPU 的顺序计算不同, GPU 核心可以同时处理任务的不同部分,从而显著提高速度 — 无论是在训练期间还是在生产过程中。

前馈传递是在 FeedForwardComplexConv 内核中实现的。这个内核接收指向三个数据缓冲区的指针,以及几个定义数据结构的常量。

需要注意的是,所有缓冲区都使用向量类型 float2 。这一选择是基于高效处理复数的需求,其中每个复数值都由实部和虚部组成。

使用 float2具 有以下几个主要优势:

  • 优化的内存访问:向量表示法允许同时读取和写入两个值,从而减少内存操作。
  • 硬件加速OpenCL 在硬件层面支持向量类型,从而加快算术运算速度。
  • 更清晰的数据表示float2 使代码更直观,因为每个变量都直接对应于一个复数。

__kernel void FeedForwardComplexConv(__global const float2 *matrix_w,
                                     __global const float2 *matrix_i,
                                     __global float2 *matrix_o,
                                     const int inputs,
                                     const int step,
                                     const int window_in,
                                     const int activation
                                    )
  {
   const size_t i = get_global_id(0);
   const size_t units = get_global_size(0);
   const size_t out = get_global_id(1);
   const size_t w_out = get_global_size(1);
   const size_t var = get_global_id(2);
   const size_t variables = get_global_size(2);

内核设计为在三维执行空间中运行。第一个维度对应于序列中的元素个数。第二个维度对应于卷积核的数量,而第三个维度表示整个输入张量中独立单元序列的数量。在内核启动时,我们会识别该空间所有维度上的当前执行线程。我们将得到的索引保存在局部常量中。

接下来,基于这些索引,计算数据缓冲区内的偏移量。这一步骤反映了之前为实值数据实现的卷积层中所使用的逻辑,该逻辑是通过使用向量数据类型来表示复数来实现的。

int w_in = window_in;
int shift_out = w_out * (i + units * var);
int shift_in = step * i + inputs * var;
int shift = (w_in + 1) * (out + var * w_out);
int stop = (w_in <= (inputs - shift_in) ? w_in : (inputs - shift_in)) + inputs * var;

至此,准备阶段完成。现在,我们继续进行卷积运算。在这种情况下,输入数据和卷积核参数都是复数值。计算结果也是一个复数。所有数学运算均使用先前实现的基本复杂算术函数执行。

首先,我们声明一个局部变量来存储中间结果,并用卷积核的偏置项对其进行初始化。

   float2 sum = ComplexMul((float2)(1, 0), matrix_w[shift + w_in]);
#pragma unroll
   for(int k = 0; k <= stop; k ++)
      sum += IsNaNOrInf2(ComplexMul(matrix_i[shift_in + k], matrix_w[shift + k]), (float2)0);

然后,我们执行一个循环,在该循环中,输入数据向量与相应的卷积核向量进行逐元素相乘,并将结果累加到局部变量中。

之后,应用相应的激活函数,并将最终值写入输出缓冲区。

   switch(activation)
     {
      case 0:
         sum = ComplexTanh(sum);
         break;
      case 1:
         sum = ComplexDiv((float2)(1, 0), (float2)(1, 0) + ComplexExp(-sum));
         break;
      case 2:
         if(sum.x < 0)
           {
            sum.x *= 0.01f;
            sum.y *= 0.01f;
           }
         break;
      default:
         break;
     }
   matrix_o[out + shift_out] = sum;
  }

下一步是实现反向传播过程。让我们考虑负责传播误差梯度的内核 CalcHiddenGradientComplexConv 。这里,我们再次使用基于向量的复数表示法。该方法参数包括相应阶段的误差梯度缓冲区。

__kernel void CalcHiddenGradientComplexConv(__global const float2 * matrix_w,
                                            __global const float2 * matrix_g,
                                            __global const float2 * matrix_o,
                                            __global float2 * matrix_ig,
                                            const int outputs,
                                            const int step,
                                            const int window_in,
                                            const int window_out,
                                            const int activation,
                                            const int shift_out
                                           )
  {
   const size_t i = get_global_id(0);
   const size_t inputs = get_global_size(0);
   const size_t var = get_global_id(1);
   const size_t variables = get_global_size(1);

需要指出的是,此操作的目的是根据输入数据对模型输出的贡献程度,按比例将误差梯度反向传播回输入数据。这一要求导致内核执行空间发生变化。在这个实现过程中,使用了二维空间。第一维度对应于输入序列的元素,第二维度对应于多元序列中的单变量序列。

与之前一样,内核首先从各个维度识别当前的执行线程。获得的索引存储在本地常量中。

接下来,计算数据缓冲区内的偏移量。这里的一个关键细节是,根据卷积窗口的步长,单个输入元素可能会参与多次卷积操作。因此,必须在所有此类操作中聚合梯度。为了处理这个问题,我们定义了梯度累积的范围。

float2 sum = (float2)0;
float2 out = matrix_o[i];
int start = i - window_in + step;
start = max((start - start % step) / step, 0) + var * inputs;
int stop = (i + step - 1) / step;
if(stop > (outputs / window_out))
   stop = outputs / window_out;
stop += var * outputs;

准备工作完成后,一个循环以指定的步长遍历这些范围,在考虑相应卷积核权重的同时对梯度进行求和。

#pragma unroll
   for(int h = 0; h < window_out; h ++)
     {
      for(int k = start; k < stop; k++)
        {
         int shift_g = k * window_out + h;
         int shift_w = (stop - k - 1) * step + i % step + h * (window_in + 1);
         if(shift_g >= outputs || shift_w >= (window_in + 1) * window_out)
            break;
         sum += ComplexMul(matrix_g[shift_out + shift_g], matrix_w[shift_w]);
        }
     }
   sum = IsNaNOrInf2(sum, (float2)0);

然后,根据应用于输入数据的激活函数的导数来调整所得值。

   switch(activation)
     {
      case 0:
         sum = ComplexMul(sum, (float2)1.0f - ComplexMul(out, out));
         break;
      case 1:
         sum = ComplexMul(sum, ComplexMul(out, (float2)1.0f - out));
         break;
      case 2:
         if(out.x < 0.0f)
           {
            sum.x *= 0.01f;
            sum.y *= 0.01f;
           }
         break;
      default:
         break;
     }
   matrix_ig[i] = sum;
  }

结果存储在全局数据缓冲区的相应元素中。

您可以在附件中找到上述内核的完整代码。附件中还包含了用于优化可训练卷积核参数的核函数的完整代码,我建议你独立研究。接下来,我们将着手组织主程序端的流程。

在此阶段,我们引入一个新的对象 CNeuronComplexConvOCL 。其结构如下所示。

class CNeuronComplexConvOCL    :  public CNeuronConvOCL
  {
protected:
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);

public:
                     CNeuronComplexConvOCL(void)   {  activation = None;   }
                    ~CNeuronComplexConvOCL(void) {};
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint step, uint window_out, 
                          uint units_count, uint variables, 
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const   {  return defNeuronComplexConvOCL;   }
  };

这个类继承自用于实数值的卷积层对象。这使我们能够重用现有的基础设施,包括内部对象和接口。然而,仍需对继承的方法进行一些调整。

首先,这适用于前馈和反向传播方法。这些已被重载,以便与上述新的 OpenCL 内核一起使用。将这些内核排队等待执行的过程遵循标准程序。所以我们这里就不详细赘述了。完整的实现代码请见附件。

话虽如此,还是值得仔细研究一下新的对象初始化方法,因为处理复数会影响数据缓冲区的处理方式。虽然 MQL5 本身就支持复数,但我们选择不引入新的缓冲区类型。相反,我们增加了现有缓冲区的大小。这种方法使解决方案更具通用性,并避免了需要对现有方法进行大量更改。

初始化方法参数的结构完全继承自父类。

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

在该方法中,我们首先调用全连接层的对应方法,该层作为我们库中所有神经网络层(包括卷积层)的基类。由于缓冲区大小的不同,我们无法直接使用直接父类中的方法。

请注意,在指定通过父方法创建的对象的大小时,我们将其设置为计算大小的两倍。正如预期的那样,这是存储复数值的实部和虚部所必需的。

接下来,我们将对象的架构常量存储在内部变量中。

iWindow = (int)window;
iStep = MathMax(step, 1);
activation = None;
iWindowOut = window_out;
iVariables = variables;

然后我们开始初始化继承的数据缓冲区。首先,我们会检查可训练参数的缓冲区是否有效,如果需要,则创建一个新的缓冲区。

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

在确定此缓冲区的大小时,我们再次考虑了复数。我们相应地将预期大小翻倍。

int count = (int)(2 * (iWindow + 1) * iWindowOut * iVariables);
if(!WeightsConv.Reserve(count))
   return false;

然后使用随机值初始化缓冲区。

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

最后,根据所选的优化方法,我们分配所需数量的缓冲区来存储优化器状态动量项。这些缓冲区初始状态为零。

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

最后,该方法返回一个布尔结果,指示操作是否成功执行。

以上就是我们关于复值数据卷积层算法的讨论。附件中提供了该类及其所有方法的完整实现。

复值掩码注意力模块


我们将构建的下一个主要组件是针对复值数据的掩码注意力模块,该模块构成了通道融合模块的核心。

我们之前已经为实值数据实现了掩码注意力机制。现在,我们的任务是将这种方法扩展到复数,同时融入 CATCH 框架特有的几个关键特性。

与往常一样,开发工作从 OpenCL 端开始。前馈传递是在 MaskAttentionComplex 内核中实现的。该内核接受指向五个数据缓冲区的指针和定义输入数据结构的两个常量。由于我们处理的是复数,因此输入和输出数据的缓冲区使用 float2 向量类型。同时,掩码矩阵和注意力系数缓冲区仍然包含实数,因为它们代表概率分布。

__kernel void MaskAttentionComplex(__global const float2 *q,
                                   __global const float2 *kv,
                                   __global float2 *scores,
                                   __global const float *masks,
                                   __global float2 *out,
                                   const int dimension,
                                   const int heads_kv
                                  )
  {
//--- init
   const int q_id = get_global_id(0);
   const int k = get_local_id(1);
   const int h = get_global_id(2);
   const int qunits = get_global_size(0);
   const int kunits = get_local_size(1);
   const int heads = get_global_size(2);

内核在三维执行空间中运行。第一个维度对应于 Query 张量的大小,并指定要分析的元素数量。第二个维度对应于 Key 张量,其元素数量用于计算依赖关系。在这个维度上,线程被分组到工作组中。第三维度代表注意力头的数量。在执行开始时,每个线程都会确定自身在这个空间中的位置,并将索引存储在局部常量中。

利用这些索引,计算所有数据缓冲区的偏移量。

   const int h_kv = h % heads_kv;
   const int shift_q = dimension * (q_id * heads + h);
   const int shift_k = dimension * (2 *  heads_kv * k + h_kv);
   const int shift_v = dimension * (2 *  heads_kv * k + heads_kv + h_kv);
   const int shift_s = kunits * (q_id *  heads + h) + k;

然后将掩码值加载到局部变量中。

   const float mask = IsNaNOrInf(masks[shift_s], 0);

需要注意的是,内核期望接收的掩码张量已经考虑了注意力头。换言之,每个注意力头都有自己的通道掩码矩阵。

接下来,我们在局部 OpenCL 上下文内存中声明一个数组,我们将使用该数组在工作组内进行数据交换。

   const uint ls = min((uint)kunits, (uint)LOCAL_ARRAY_SIZE);
   float2 koef = (float2)(fmax((float)sqrt((float)dimension), (float)1), 0);
   __local float2 temp[LOCAL_ARRAY_SIZE];

至此,准备工作阶段完成,我们直接进入计算操作。首先,我们需要计算注意力系数。为此,我们对相应的 QueryKey 向量执行循环。它们乘积的指数乘以掩码。

//--- Score
   float score = 0;
   float2 score2 = (float2)0;
   if(ComplexAbs(mask) >= 0.01)
     {
      for(int d = 0; d < dimension; d++)
         score2 = IsNaNOrInf2(ComplexMul(q[shift_q + d], kv[shift_k + d]), (float2)0);
      score = IsNaNOrInf(ComplexAbs(ComplexExp(ComplexDiv(score, koef))) * mask, 0);
     }

请注意,此操作仅在掩码值超过预定义阈值时执行。这有效地消除了无关通道的影响。

然后必须使用 SoftMax 函数对所得值进行归一化,以获得正确的概率分布。为此,我们将工作组内的所有值相加。首先,计算局部数组元素的部分和。

//--- sum of exp
#pragma unroll
   for(int i = 0; i < kunits; i += ls)
     {
      if(k >= i && k < (i + ls))
         temp[k % ls].x = (i == 0 ? 0 : temp[k % ls].x) + score;
      barrier(CLK_LOCAL_MEM_FENCE);
     }

然后,将这些部分和汇总成最终总数。

   uint count = ls;
#pragma unroll
   do
     {
      count = (count + 1) / 2;
      if(k < ls)
         temp[k].x += (k < count && (k + count) < kunits ? temp[k + count].x : 0);
      if(k + count < ls)
         temp[k + count].x = 0;
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);

经过所有迭代后,本地数组的第一个元素保存了整个工作组的总和。然后,每个线程将其注意力系数除以该总和,以获得归一化值。结果写入全局输出缓冲区。

//--- score
   if(temp[0].x > 0)
      score = score / temp[0].x;
   scores[shift_s] = score;

接下来,我们计算每个元素的最终表示,同时考虑来自其他通道的贡献。这涉及到将注意力系数向量乘以 Value 矩阵。由于需要在并行工作组线程中执行操作,且每个线程仅包含一个注意力系数,因此这一过程变得复杂。因此,该过程需要嵌套循环结构。外层循环遍历 Value 矩阵中对应行的元素。

//--- out
#pragma unroll
   for(int d = 0; d < dimension; d++)
     {
      float2 val = (score > 0 ? ComplexMul(kv[shift_v + d], (float2)(score,0)) : (float2)0);

在循环内部,每个线程从全局内存中加载相关值,并将其乘以各自的注意力系数,然后将结果存储在局部变量中。为了减少代价高昂的全局内存访问,此步骤仅在注意力系数大于零时执行。否则,该变量将被安全地初始化为零,而无需访问全局内存。

下一步是在工作组内跨线程对这些中间结果进行求和。这里我们使用了一个类似于对注意力系数求和的过程。首先,我们将局部数组中的部分结果相加。

#pragma unroll
      for(int i = 0; i < kunits; i += ls)
        {
         if(k >= i && k < (i + ls))
            temp[k % ls] = (i == 0 ? (float2)0 : temp[k % ls]) + val;
         barrier(CLK_LOCAL_MEM_FENCE);
        }

然后,我们将局部数组中各个元素的值相加。

      uint count = ls;
#pragma unroll
      do
        {
         count = (count + 1) / 2;
         if(k < ls)
            temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : (float2)0);
         if((k + count) < ls)
            temp[k + count] = (float2)0;
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      while(count > 1);

只需要一个线程即可将最终结果写入全局缓冲区。

      //---
      if(k == 0)
         out[shift_q + d] = temp[0];
      barrier(CLK_LOCAL_MEM_FENCE);
     }
  }

在进入下一次迭代之前,工作组中的所有线程都会进行同步。

所有迭代完成后,内核执行结束。

下一步是通过复杂的掩码注意力机制实现反向传播。我们在 MaskAttentionGradientsComplex 内核中实现了这一点。

__kernel void MaskAttentionGradientsComplex(__global const float2 *q, __global float2 *q_g,
                                            __global const float2 *kv, __global float2 *kv_g,
                                            __global const float *scores,
                                            __global const float *mask, __global float *mask_g,
                                            __global const float2 *gradient,
                                            const int kunits, const int heads_kv
                                           )
  {
//--- init
   const int q_id = get_global_id(0);
   const int d = get_global_id(1);
   const int h = get_global_id(2);
   const int qunits = get_global_size(0);
   const int dimension = get_global_size(1);
   const int heads = get_global_size(2);

该内核的结构与前馈传递基本相似。我们只是添加了全局缓冲区来存储误差梯度。但是,执行空间略有改变。它仍然具有三维结构,但第二维现在表示内部向量的大小,且线程不再按工作组进行分组。

在内核体中,我们通过任务空间的所有维度来识别当前线程,并将获得的值存储在局部常量中。和之前一样,我们使用它们来确定全局数据缓冲区中的偏移量。

   const int h_kv = h % heads_kv;
   const int shift_q = dimension * (q_id * heads + h) + d;
   const int shift_s = (q_id * heads + h) * kunits;
   const int shift_g = h * dimension + d;
   float2 koef = (float2)(fmax(sqrt((float)dimension), (float)1), 0);

完成准备工作后,我们直接开始收集误差梯度。首先,我们在 Value 张量级别定义误差。

回想一下,Value 张量是通过与注意力矩阵相乘来生成输出序列的所有元素的。因此,注意力输出的梯度会根据相应的注意力系数加权,反向传播到值张量。这是通过循环系统实现的。

//--- Calculating Value's gradients
   int step_score = kunits * heads;
   if(h < heads_kv)
     {
#pragma unroll
      for(int v = q_id; v < kunits; v += qunits)
        {
         float2 grad = (float2)0;
         for(int hq = h; hq < heads; hq += heads_kv)
           {
            int shift_score = hq * kunits + v;
            for(int g = 0; g < qunits; g++)
              {
               float sc = IsNaNOrInf(scores[shift_score + g * step_score], 0);
               if(sc > 0)
                  grad += ComplexMul(gradient[shift_g + dimension * (hq - h + g  * heads)],
                                     (float2)(sc, 0));
              }
           }
         int shift_v = dimension * (2 *  heads_kv * v + heads_kv + h) + d;
         kv_g[shift_v] = grad;
        }
     }

接下来,我们将误差梯度传播到 Query 张量级别。Query 张量的每个元素仅影响单个输出元素。因此,可以将相应的梯度存储在局部变量中,以减少全局内存访问。

//--- Calculating Query's gradients
   float2 grad = 0;
   float2 out_g = IsNaNOrInf2(gradient[shift_g + q_id * dimension], (float2)0);
   int shift_val = (heads_kv + h_kv) * dimension + d;
   int shift_key = h_kv * dimension + d;
#pragma unroll
   for(int k = 0; (k < kunits && ComplexAbs(out_g) != 0); k++)
     {
      float2 sc_g = 0;
      float2 sc = (float2)(scores[shift_s + k], 0);
      for(int v = 0; v < kunits; v++)
         sc_g += IsNaNOrInf2(ComplexMul(
                                ComplexMul((float2)(scores[shift_s + v], 0),
                                           out_g * kv[shift_val + 2 * v * heads_kv * dimension]),
                                ((float2)(k == v, 0) - sc)), (float2)0);
      float m = mask[shift_s + k];
      mask_g[shift_s + k] = IsNaNOrInf(sc.x / m * sc_g.x + sc.y / m * sc_g.y, 0);
      grad += IsNaNOrInf2(ComplexMul(sc_g, kv[shift_key + 2*k*heads_kv*dimension]), (float2)0);
     }
   q_g[shift_q] = IsNaNOrInf2(ComplexDiv(grad, koef), (float2)0);

然而,在生成结果值时,我们会与一系列 KeyValue 张量值进行交互。为了获得所需的误差值,我们首先将梯度传播到注意力系数矩阵,然后才将其传递到 Query 张量。

请注意,这里我们也把误差梯度传播到通道掩码矩阵。

最后,将梯度传播到 Key 张量。该算法与 Query 情况非常相似,只是计算是沿着注意力矩阵的列进行的。

//--- Calculating Key's gradients
   if(h < heads_kv)
     {
#pragma unroll
      for(int k = q_id; k < kunits; k += qunits)
        {
         int shift_k = dimension * (2 *  heads_kv * k + h_kv) + d;
         grad = 0;
         for(int hq = h; hq < heads; hq++)
           {
            int shift_score = hq * kunits + k;
            float2 val = IsNaNOrInf2(kv[shift_k + heads_kv * dimension], (float2)0);
            for(int scr = 0; scr < qunits; scr++)
              {
               float2 sc_g = (float2)0;
               int shift_sc = scr * kunits * heads;
               float2 sc = (float2)(IsNaNOrInf(scores[shift_sc + k], 0), 0);
               if(ComplexAbs(sc) == 0)
                  continue;
               for(int v = 0; v < kunits; v++)
                  sc_g += IsNaNOrInf2(
                             ComplexMul(
                                ComplexMul((float2)(scores[shift_sc + v], 0),
                                           gradient[shift_g + scr * dimension]),
                                ComplexMul(val, ((float2)(k == v, 0) - sc))),
                             (float2)0);
               grad += IsNaNOrInf2(ComplexMul(sc_g, q[shift_q + scr * dimension]), (float2)0);
              }
           }
         kv_g[shift_k] = IsNaNOrInf2(ComplexDiv(grad, koef), (float2)0);
        }
     }
  }

至此,我们对在 OpenCL 环境中实现复值掩码注意力的算法进行了概述。完整的内核实现代码可在附件中找到。

下一步将在主程序端实现掩码注意力逻辑。我们将在下一篇文章中对此进行探讨。


结论

在本文中,我们探讨了 CATCH 框架的理论基础,该框架将傅里叶变换与频率分块相结合,以检测多元时间序列中的异常。其关键优势在于,它能够揭示在仅分析时间域数据时隐藏的复杂市场模式。

频域表示法的使用能更深入地洞察市场动态,而频率分块则使分析能够适应不断变化的条件。此外, CATCH 还能捕捉资产之间的关系,因此对系统性异常更为敏感。与传统方法不同,它不仅能检测出明显的峰值和异常值,还能识别出微妙的、隐藏的依赖关系,这些关系可能预示着市场趋势即将发生变化。

在实践部分,我们开始用 MQL5 实现我们自己的框架版本。在下一篇文章中,我们将继续这项工作,并最终根据真实的历史数据评估已实施解决方案的性能。


相关链接


本文中用到的程序

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

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

附加的文件 |
MQL5.zip (2596.13 KB)
基于Python的CFTC数据挖掘与AI预测模型构建 基于Python的CFTC数据挖掘与AI预测模型构建
让我们尝试挖掘CFTC数据,通过Python下载COT和TFF报告,将其与MetaTrader 5行情数据及AI模型相结合,并生成预测。外汇市场中的COT报告是什么?如何利用COT和TFF报告进行行情预测?
挖掘央行资产负债表数据,描绘全球流动性全貌 挖掘央行资产负债表数据,描绘全球流动性全貌
挖掘各国央行资产负债表数据,能够厘清外汇市场与主要币种的全球流动性现状。我们整合美联储、欧洲央行、日本央行、中国人民银行的数据构建综合指数,并借助机器学习挖掘潜藏规律。该方法融合基本面与技术分析,将原始数据转化为可落地的交易信号。
新手在交易中的10个基本错误 新手在交易中的10个基本错误
新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
神经网络在交易中的应用:市场异常的自适应检测(终篇) 神经网络在交易中的应用:市场异常的自适应检测(终篇)
我们继续构建构成 DADA 框架基础的算法,该框架是检测时间序列异常的高级工具。这种方法能够有效区分随机波动和显著偏差。与经典方法不同,DADA 能够动态适应不同的数据类型,在每种特定情况下选择最佳的压缩级别。