English Русский Español Português
preview
神经网络在交易中的应用:基于自适应模态分解(ACEFormer)的时间序列预测

神经网络在交易中的应用:基于自适应模态分解(ACEFormer)的时间序列预测

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

概述

金融市场是一个复杂且动态的系统,其中每一个价格变动都是众多因素错综复杂相互作用的结果。它几乎反映了所有内容,从宏观经济信息流和公司特定新闻,到投资者的情绪波动和算法交易策略的理性计算。在如此庞杂的信号、噪声和失真中,提取有意义的信息并识别真正的市场趋势,不仅是一个有趣的挑战,更是一种策略上的必需。

准确预测市场走向的能力可以带来可持续的竞争优势。其中最大的挑战之一是信息噪声 — 即由短期交易、新闻头条或随机算法活动产生的频繁且往往毫无意义的微小价格波动。这些波动往往使分析模型无法捕捉到潜在趋势。

开发预测模型的努力可以追溯到二十世纪后期。早期的神经网络架构表明,原则上可以通过训练模型来预测市场走势。然而,这些方法无法在较长的时间范围内保留信息,并且很快就会忘记不久前发生的事件。

LSTM 网络的引入改善了这种情况。得益于其记忆机制,LSTM 模型能够在较长时期内保留重要模式。它们很快被广泛应用于时间序列预测。然而,事情并非那么简单。金融时间序列不同于传统的序列数据。它们是不规则的,tick(逐笔报价)之间的间隔往往不均匀。它们包含大量短暂出现的峰值,这些峰值几乎无法提供有关潜在市场趋势的有意义信息。

高频交易带来了尤为严峻的挑战。它产生了通常所称的市场噪声 — 即在极短的时间间隔内反复出现的报价波动。这些波动掩盖了真实的趋势,增加了数据的不稳定性,并使预测模型被无关紧要的事件所淹没。因此,即使是复杂的神经架构也可能开始关注令人分心的短期变化,而非真正重要的信息。

为了应对这些挑战,论文“An End-to-End Structure with Novel Position Mechanism and Improved EMD for Stock Forecasting”(一种具有新型定位机制和改进 EMD 的端到端结构用于股票预测)介绍了 ACEFormer 框架 — 一种专为高频交易环境设计的金融时间序列分析集成架构。ACEFormer 并非仅代表一个单一的预测模型,而是融合了多个互补的组件,每个组件分别承担不同的任务:噪声过滤、不规则时间间隔建模以及对最具信息量的市场动态的选择性关注。

ACEFormer 架构的第一阶段执行数据去噪。这里使用了改进的 ACEEMDAlias Complete Ensemble Empirical Mode Decomposition with Adaptive Noise)算法。该方法基于经验模态分解( EMD ),但融合了多项改进。这有助于消除传统 EMD(经验模态分解)的两大主要局限性:端点效应和模态混叠。通过移除包含大部分高频振荡的第一固有模态函数(IMF),ACEEMD 在有效抑制市场噪声的同时,保留了表征潜在趋势的关键转折点。

经过预滤波处理后,去噪后的数据被传递至时间感知模块。由于金融市场事件发生的时间间隔不规则,传统的注意力机制并不适合对其进行建模。为了解决这一问题,作者们引入了一个时间感知模块,该模块在处理特征值的同时,会明确考虑观测值之间的时间间隔。这使模型能够更好地捕捉事件序列的时序结构,并识别其中的关联关系。

然后,生成的特征会被一个增强注意力模块处理。与标准的注意力机制不同,该模块专为金融数据设计,在金融数据中,识别关键变化点并忽略无关紧要的波动至关重要。通过更加重视时间序列中的信息区域,该模型不会将注意力分散在噪声元素上,而是专注于潜在的相关信息。

在最后阶段,使用了一个全连接神经网络。它汇总提取的特征,并生成对未来价格走势的最终预测。因此,ACEFormer 架构涵盖了整个预测流程 — 从降噪和时间建模,到基于注意力的特征提取和最终预测。


ACEFormer 算法

ACEFormer 算法是一个处理时间序列数据的分阶段框架,旨在准确预测金融市场的价格变动。其核心原则是依次且自适应地抑制市场噪声,随后提取有信息量的特征,并生成考虑长期市场趋势的预测。这种方法在高频交易环境中尤为有效,因为在这种环境中,有意义的信号往往被众多随机波动和市场噪声所掩盖。

该过程从输入时间序列 𝑆={𝑠 1,𝑠 2,…,𝑠 𝑛 } 开始,其中每个向量 𝑠𝑖 包含在时间步 𝑖 观察到的价格、交易量和其他市场指标。为了准备用于模型训练的数据,序列通过长度为 𝑝 的零填充段进行扩展,使模型能够预测未来的时间步。这使得模型能够预测未来的 p 个时间步长,尽管源数据中没有关于未来的明确信息 𝐷=[𝑠 1,𝑠 2,…,𝑠 𝑛,0,0,…,0] ∈ 𝑅 (𝑛+𝑝)×𝑑 ,其中 𝑑 是特征的数量。这些零值有助于模型保持序列结构并预测未来值。

下一阶段使用卷积滤波器进行信号平滑处理。顺序应用两个卷积滤波器 𝑓 和 𝑔,以减少随机波动并稳定输入序列。这一预处理步骤能够抑制瞬态尖峰,从而提高提供给模型后续阶段的数据质量。

在初始平滑阶段之后,应用自适应经验模态分解算法( ACEEMD )来抑制高频噪声。该过程首先对输入时间序列的每个元素添加和减去高斯噪声 𝑛𝑖 (𝑡),从而产生两个新序列:𝑝𝑒𝑖 (𝑡)=𝑥(𝑡)+𝑛𝑖 (𝑡) 和 𝑝𝑚𝑖 (𝑡)=𝑥(𝑡)−𝑛𝑖 (𝑡)。

随后使用经验模态分解 ( EMD ) 对每个序列进行分解,提取第一固有模态函数 ( IMF )。

随后将这两个分解结果中对应的 IMF 分量相加。从原始信号中减去该分量,得到去噪时间序列 𝑟1 (𝑡)=𝑥(𝑡)− IMF 1 (𝑡)。去噪后的时间序列随后被送入后续处理阶段。

为了使模型能够捕捉事件的时间顺序,我们在输入表示中加入了位置编码,以保留数据在序列中的位置信息。然后对数据进行线性投影。

ACEFormer 架构的一个显著特征是其概率注意力模块,该模块在提高模型的泛化能力方面发挥着核心作用。概率注意力是传统自注意力机制的一种计算效率更高的变体,它消除了许多无关的注意力连接。该机制不是在整个序列上计算注意力,而是仅关注信息量最大的时间步。为了实现这一目标,首先需要为每个位置估算一个重要性分数。在 ACEFormer 中,该分数定义为每个 查询 的随机采样子集上的最大投影值。经过归一化处理后,选择信息量最大的位置。并且,仅对该子集执行自注意力计算。因此,评估注意力时,并非针对整个序列,而是针对一个紧凑的子集,该子集极有可能包含最具信息量的时间事件。

ACEFormer 中的概率注意力模块不仅仅是一项技术优化,更是一个战略性的设计选择。它使模型能够更有效地适应动态市场条件,在这种条件下,各个依赖项的重要性会随时间不断变化。在处理嘈杂且高度波动的金融数据时,这种方法能产生更稳健、更可靠的预测结果。

因此,概率注意力机制使 ACEFormer 模型能够专注于真正有信息的模式,同时过滤掉无关的依赖关系和随机市场波动。这提高了模型提取有意义关系和生成准确预测的能力,尤其是在预测金融市场未来价格走势方面。

在概率注意力机制之后,所得特征表示随后进入卷积层和 max-pooling 操作处理。这些组件进一步增强了局部特征提取,并提高了模型对重要模式的表征能力。卷积操作强调了时间序列中包含对后续预测最具信息量的信号的区域。

最终的数据处理阶段采用了传统的自注意力机制。该模块使序列中的每个元素都能访问全局上下文,从而使模型能够捕捉到由长时间间隔分隔的事件之间的依赖关系。

为了获得给定预测期的预测值,我们使用了全连接网络。

总体而言,ACEFormer 算法包括多个阶段,从去噪开始,到生成准确预测结束。每个阶段都有助于提升模型处理嘈杂且高度波动的金融时间序列、识别长期市场趋势以及高精度预测未来价格走势的能力。

下面展示了作者提供的 ACEFormer 框架示意图。



MQL5 中的实现

在研究了 ACEFormer 框架的理论基础后,我们现在可以开始探讨其在 MQL5 中的实际实现。首先介绍概率注意力模块的实现。它是该架构的核心组件之一,在保持数据表示质量的同时,提供了高计算效率。

在讨论实现细节之前,有必要重申概率注意力机制在概念上的优势。这种机制在预测精度与计算效率之间取得了平衡。与处理整个序列的传统注意力机制不同,概率注意力机制会选择性地关注信息量最大的元素。这一策略在不损失模型质量的前提下,显著降低了内存消耗和计算成本,尤其是在处理长序列时效果尤为显著。

本文中提出的实现分为三个按顺序执行的内核。每个内核执行一项特定任务:从估计重要性,到选择最佳查询,再到计算最终的上下文表示。让我们一步步地研究这个流程。

第一阶段是对每个查询的重要性进行估计。此操作由 ProbAttentionQueryImp 内核执行。内核接收以下输入:

  • 查询矩阵(querys);
  • 的组合矩阵(keys_values);
  • 索引数组 index_keys,用于存储与每个查询相关联的采样索引。

在此上下文中,指的是用于估计查询重要性的随机抽样元素。采样过程仅用于统计评估,而非用于计算最终的注意力分数。其目的是衡量每个查询中具有代表性的子集的响应强度。

__kernel void ProbAttentionQeuryImp(__global const float* querys,
                                    __global const float2* __attribute__((aligned(8))) keys_values,
                                    __global const float* index_keys,
                                    __global float* querys_imp,
                                    const int dimension
                                   )
  {
   const size_t id_q = get_global_id(0);
   const size_t total_q = get_global_size(0);
   const size_t ind_k = get_local_id(1);
   const size_t total_ind = get_local_size(1);
   const size_t id_h = get_global_id(2);
   const size_t total_h = get_global_size(2);

内核在三维任务空间上执行,每个维度在组织并行计算中都有其独特的作用。第一个维度对应于查询序列。第二个对应于与每个查询关联的采样的数量。这个数量可能会因模型配置和分析深度的不同而有所变化。第三维表示注意力头 — 同时分析输入序列不同方面的独立处理单元。

应特别关注注意力头的行为。每个头都独立处理其从中采样到的子集。这种设计能够呈现同一序列的多个互补视图,使每个头部都能发现不同的关系和结构模式。因此,整体架构变得更为稳健:如果一个头部低估了序列中的一个重要区域,另一个头部仍可能捕捉到它。注意力头共同作用,为原始信号生成了更丰富、更具表现力的表征,显著提升了注意力机制的质量以及所得上下文的信息量。

执行线程在第二维度上被组织成工作组。为了方便各工作组内并行线程之间的通信,在 OpenCL 设备的本地内存中分配了一个共享数组。

__local float temp[LOCAL_ARRAY_SIZE][2];
const int ls = min((int)total_ind, (int)LOCAL_ARRAY_SIZE);

下一步是计算当前执行线程对应的输入缓冲区内的偏移量。查询缓冲区中的偏移量直接由第一维中的线程标识符确定。缓冲区的偏移量是通过首先从索引缓冲区中检索采样的的索引,然后将该索引转换为相应的缓冲区偏移量来计算的。

const int shift_q = dimension * (id_q * total_h + id_h);
const int id_k = index_keys[total_ind * id_q * total_h + ind_k * total_h + id_h];
const int shift_k = dimension * (id_k * total_h + id_h);

对于每个查询-键对,内核计算它们的点积,以衡量它们的相似度。此操作包括逐元素相乘,然后累加所得乘积。

   float sum = 0;
#pragma unroll
   for(int d = 0; d < dimension; d++)
      sum += IsNaNOrInf(querys[shift_q + d] * keys_values[shift_k + d].s0, 0);

接下来,利用共享的本地内存数组,线程在每个工作组内协同计算这些点积的和与最大值。这种并行归约方法能够高效地为每个采样子集生成聚合统计数据。

   int id_t = ind_k % ls;
#pragma unroll
   for(int i = 0; i < total_ind; i += ls)
     {
      if(i <= ind_k || (i + ls) > ind_k)
        {
         temp[id_t][0] = IsNaNOrInf((i == 0 ? 0 : temp[id_t][0]) + sum, 0);
         temp[id_t][1] = (i == 0 ? IsNaNOrInf(sum, MIN_VALUE) : fmax(temp[id_t][1], IsNaNOrInf(sum, MIN_VALUE)));
         barrier(CLK_LOCAL_MEM_FENCE);
        }
     }
   int count = ls;
#pragma unroll
   do
     {
      count = (count + 1) / 2;
      if(ind_k < count && (ind_k + count) < ls)
        {
         temp[ind_k][0] += temp[ind_k + count][0];
         temp[ind_k + count][0] = 0;
         temp[ind_k][1] = fmax(temp[ind_k + count][1], temp[ind_k][1]);
        }
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);

然后,将当前查询的重要性得分计算为最大点积与平均点积之间的差值。该值越大,查询被认为越具有信息量。最终得到的重要性得分存储在输出缓冲区 querys_imp 中。

 if(ind_k == 0)
    querys_imp[id_q * total_h + id_h] = IsNaNOrInf(temp[0][1] - temp[0][0] / total_ind, MIN_VALUE);
}

下一阶段会选择信息量最大的查询。此任务由 TopKImportanceToIndex 内核执行。该实现没有依赖计算成本高昂的排序算法,而是采用了一种简单而稳健的排名策略。

对于每个查询,内核并行计算重要性得分更高的查询的数量。如果此计数小于指定的阈值 top_k,则当前查询将包含在最终索引列表中。虽然方法简单直接,但这种方法特别适合 GPU 执行,因为它需要的同步最少,而且不依赖于辅助数据结构。

__kernel void TopKImportanceToIndex(__global const float* importance,
                                   __global float* indexes,
                                   const int top_k
                                  )
  {
   const size_t id_q = get_global_id(0);
   const size_t total_q = get_global_size(0);
   const size_t id_h = get_global_id(1);
   const size_t total_h = get_global_size(1);
//---
   float imp = importance[id_q * total_h + id_h];
   int pos = 0;
#pragma unroll
   for(int i = 0; i < total_q; i++)
     {
      if(i == id_q)
         continue;
      float val = importance[i * total_h + id_h];
      if(val > imp || (i < id_q && val >= imp))
         pos++;
      if(pos >= top_k)
         break;
     }
//---
   if(pos < top_k)
      indexes[pos * total_h + id_h] = (float)id_q;
  }

第三阶段也是最后一个阶段,计算注意力机制本身。此操作由 QIndexAttention 内核实现。它的目的是为每个选定的查询生成最终的上下文表示。

内核接收完整的查询向量、向量和向量作为输入 。如前所述,一个关键的设计决策是避免为所选查询子集创建额外的副本。这对于减少内存使用和提高计算效率至关重要。相反,内核通过索引缓冲区进行操作,该缓冲区包含对上一阶段识别出的最具信息量的查询的引用。

值得注意的是, token 一起存储在同一个数据缓冲区中。这种安排简化了内存访问模式,提高了缓存利用率。具体来说,该实现使用了 float2 向量类型,其中第一个分量表示,第二个分量表示相应的。将每个键-值对视为单个逻辑实体,可以减少内存访问开销,同时实现更紧凑、更高效的架构。

__kernel void QIndexAttention(__global const float *q,
                              __global const float2* kv,
                              __global float *scores,
                              __global const float *indexes,
                              __global float *out,
                              const int dimension,
                              const int heads_kv
                             )
  {
//--- init
   const int ind_q = get_global_id(0);
   const int k = get_local_id(1);
   const int h = get_global_id(2);
   const int total_q = get_global_size(0);
   const int total_k = get_local_size(1);
   const int heads = get_global_size(2);

该内核还可在三维任务空间中运行。然而,第一个查询维度仅对选定的最具信息量的 token 子集进行操作。第二维度涵盖了整个序列。与之前一样,执行线程沿第二维度被分组到工作组中。

在内核中,当前执行线程在任务空间的所有维度上都被识别出来。然后,这些标识符被用来计算输入缓冲区中的适当偏移量。

const int h_kv = h % heads_kv;
const int q_id = (int)(indexes[ind_q * heads + h] + 0.001f);
const int shift_q = dimension * (q_id * heads + h);
const int shift_kv = dimension * (heads_kv * k + h_kv);
const int shift_s = total_k * (ind_q *  heads + h) + k;

请注意,在计算查询缓冲区中的偏移量之前,内核首先从包含所选高重要性元素的缓冲区中检索相应的查询索引。

然后,在本地内存中分配一个共享数组,以实现属于同一工作组的线程之间的数据交换。

__local float temp[LOCAL_ARRAY_SIZE];
const uint ls = min((uint)total_k, (uint)LOCAL_ARRAY_SIZE);

第一个计算阶段评估查询向量和向量之间的点积,生成一个中间值数组,通常称为原始分数。这些分数量化了每个查询-键对的相关性,并作为后续注意力计算的基础。

//--- Score
   float score = 0;
   if(q_id >= 0)
     {
#pragma unroll
      for(int d = 0; d < dimension; d++)
         score += IsNaNOrInf(q[shift_q + d] * kv[shift_kv + d].s0, 0);
     }

为了稳定计算并提高数值稳定性, Softmax 归一化以改进的形式实现。在每个工作组中,首先确定所有分数中的最大值。

//--- max of score
#pragma unroll
   for(int i = 0; i < total_k; i += ls)
     {
      if(k >= i && k < (i + ls))
         temp[k % ls] = (i == 0 ? score : fmax(temp[k % ls], score));
      barrier(CLK_LOCAL_MEM_FENCE);
     }
//---
   uint count = ls;
#pragma unroll
   do
     {
      count = (count + 1) / 2;
      if(k < count && (k + count) < ls)
         temp[k] = fmax(temp[k + count], temp[k]);
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);

然后,每个分数减去这个最大值进行平移。这种转换通过确保每个指数都小于或等于零,从而防止指数溢出。因此,指数值保持在 0 到 1 的区间内。

score = IsNaNOrInf(exp(score - temp[0]), 0);

然后对指数进行求和,并将每个值除以总和,从而将原始分数转换为最终权重。

//--- sum of exp
#pragma unroll
   for(int i = 0; i < total_k; i += ls)
     {
      if(k >= i && k < (i + ls))
         temp[k % ls] = (i == 0 ? 0 : temp[k % ls]) + score;
      barrier(CLK_LOCAL_MEM_FENCE);
     }
//---
   count = ls;
#pragma unroll
   do
     {
      count = (count + 1) / 2;
      if(k < count && (k + count) < ls)
        {
         temp[k] += temp[k + count];
         temp[k + count] = 0;
        }
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
//--- score
   if(temp[0] > 0)
      score /= temp[0];
   scores[shift_s] = score;

最后,将这些注意力权重应用于张量。加权后的值向量被累积起来,形成一个单一的上下文向量,该向量从当前查询的角度捕捉输入序列的语义表示。

//--- out
#pragma unroll
   for(int d = 0; d < dimension; d++)
     {
      float val = kv[shift_kv + d].s1 * score;
#pragma unroll
      for(int i = 0; i < total_k; i += ls)
        {
         if(k >= i && k < (i + ls))
            temp[k % ls] = (i == 0 ? 0 : temp[k % ls]) + val;
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      //---
      uint count = ls;
#pragma unroll
      do
        {
         count = (count + 1) / 2;
         if(k < count && (k + count) < ls)
           {
            temp[k] += temp[k + count];
            temp[k + count] = 0;
           }
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      while(count > 1);
      //---
      if(k == 0)
         out[dimension * (ind_q * heads + h) + d] = temp[0];
      barrier(CLK_LOCAL_MEM_FENCE);
     }
  }

所描述的整个机制实现了一种连贯且高效的概率注意力方案。它首先对查询重要性进行快速、近似的估计,然后选择最有前景的查询,最后仅对有限但信息量丰富的子集进行全面计算。这种方法不仅加快了长序列的处理速度,还保持了较高的预测准确率。同时,它显著减少了中间数据的数量以及对全局内存的访问次数。

然而,到目前为止所描述的过程仅涵盖了前向传播 — 即模型根据输入数据生成预测的阶段。为了实现学习,还需要实施反向传播 — 使每个组件的可训练参数能够根据其对模型最终输出的贡献进行更新。

在此实现中,我们特意做出了一个架构决策,即仅通过注意力机制传播梯度,而在反向传播过程中排除查询选择阶段。乍一看,这可能显得有些简化,但实际上,这是基于底层计算结构而做出的深思熟虑的设计选择。

上述两个阶段 — 信息性查询的选择和注意力计算本身 — 都依赖于相同的基本操作:将查询 token 与 token 进行匹配。在查询选择阶段,会从中抽取一个子集,并将其与查询的完整集合进行对比,以根据每个元素的响应来估计其重要性。在注意力阶段,视角发生了逆转:先前选择的查询将针对完整的序列进行评估。换句话说,这两个阶段都是作用于相同的实体,但视角不同。这种对称性使我们能够消除冗余计算,并通过单一计算路径更新模型参数,从而建立高效的梯度流。

这种设计具有几个重要的优势。首先,它降低了计算成本,因为梯度仅通过一条信息路径传播。其次,它通过消除两个并行梯度源之间的潜在冲突,提高了数值稳定性。第三,它使得架构更为简洁优雅,减少了依赖,从而简化了实现和测试过程。最重要的是,关于重要性的所有信息都已经包含在梯度信号中。因此,通过一次参数更新,可以同时改进注意力机制和查询选择过程,从而有效地复用训练过程中学到的信息。

QIndexAttentionGradients 内核通过注意力机制实现误差反向传播,负责在三个关键组件之间准确地分布梯度:查询。计算域沿三个维度进行组织:

  • 最重要的查询;
  • 令牌维度;
  • 注意力头。
这提供了高度的并行性,并能够有效地利用 GPU 计算资源。

__kernel void QIndexAttentionGradients(__global const float* q,
                                       __global float* q_g,
                                       __global const float2* kv,
                                       __global float2* kv_g,
                                       __global const float* indexes,
                                       __global const float* scores,
                                       __global const float* gradient,
                                       const int kunits, const int heads_kv
                                      )
  {
//--- init
   const int ind_q = 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 q_id = (int)(indexes[ind_q * heads + h] + 0.001f);
const int shift_q = dimension * (q_id * heads + h) + d;
const int shift_s = (ind_q * heads + h) * kunits;
const int shift_g = h * dimension + d;

第一阶段计算关于向量的梯度。此实现支持在处理查询时,对heads_kv)使用比所有更少的注意力头。这既减少了内存消耗和计算成本,又保留了架构的灵活性。然而,在反向传播过程中,它也需要特殊的方法。

由于向量可能在多个注意力头上共享,因此其梯度必须汇总每个输出依赖于这些的头的贡献。这确保了错误信息能够正确传播到参与多个注意力路径的数值。

对于每个位置,该算法会遍历所有可能引用该值的注意力头。在这个循环中,每个头的加权贡献是通过输出梯度与在前馈过程中获得的归一化注意力权重(score)的乘积来计算的。这些贡献值会被累积起来,并最终存储在 kv_g 梯度缓冲区中 float2 结构的第二个组成部分中。

即使当的头数与查询的头数不同时,此过程也能保证梯度传播的一致性和准确性。因此,尽管注意力组件之间存在结构不对称,该模型仍能被正确训练。

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

下一阶段计算关于查询的梯度。这一步骤稍微复杂一些,因为它需要计算 Softmax 函数的导数。对于每个查询,检索与当前位置对应的输出梯度,之后算法对执行两个嵌套循环。第一个循环计算每个注意力权重的贡献,而第二个循环则通过其归一化值来考虑每个的影响。该过程通过 Softmax 分布准确地传播误差信号,同时保持注意力机制的概率结构。最后,将累积的查询梯度写入先前计算的偏移量处的 q_g 缓冲区。

//--- Calculating Query's gradients
   float grad = 0;
   float out_g = IsNaNOrInf(gradient[shift_g + ind_q * dimension], 0);
   int shift_kv = h_kv * dimension + d;
#pragma unroll
   for(int k = 0; (k < kunits && out_g != 0); k++)
     {
      float sc_g = 0;
      float sc = scores[shift_s + k];
      if(sc == 0)
         continue;
      for(int v = 0; v < kunits; v++)
         sc_g += scores[shift_s + v] * out_g * kv[shift_kv + v * heads_kv * dimension].s1 *
                 ((float)(k == v) - sc);
      grad += sc_g * kv[shift_kv + k * heads_kv * dimension].s0;
     }
   q_g[shift_q] = grad;

后续阶段计算关于的梯度,这是反向传播过程中最精细的部分之一。此处的目的是通过注意力机制,精确确定每个键对模型最终预测的贡献程度。

如前所述,实现时可能会为查询使用不同数量的注意力头。因此,每个键的梯度必须累积来自每个有参与的注意力头的贡献。

在前馈过程中,每个查询-键对都会产生一个标量注意力分数,该分数通过 Softmax 函数进行归一化。归一化值存储在 scores 缓冲区中。然而,仅凭这些值不足以计算梯度。由于 Softmax 是一种非线性变换,反向传播需要计算其导数。尽管 Softmax 输出已经计算并存储,但整个函数对每个输入 logit 的敏感性仍然需要计算。该导数由 Softmax 公式的对角项和非对角项组成。因此,在计算特定的梯度时,该算法必须遍历与该键关联的每个查询,并累加它们的贡献。

此过程的一个关键方面是使用所选的查询索引来重建正确的依赖链。如果没有这些指标,梯度分布将不正确。

该算法遍历每一对相关项,计算所需的 Softmax 导数项,并将其与相应的输出误差梯度相乘。然后将结果累加到该键的梯度中,并写入 kv_g 缓冲区的第一个组件,该缓冲区同时存储的梯度。

//--- Calculating Key's gradients
   if(h < heads_kv)
     {
#pragma unroll
      for(int k = ind_q; k < kunits; k += qunits)
        {
         int shift_k = dimension * (heads_kv * k + h_kv) + d;
         grad = 0;
         for(int hq = h; hq < heads; hq++)
           {
            int shift_score = hq * kunits + k;
            float val = kv[shift_k + heads_kv * dimension].s1;
            for(int scr = 0; scr < qunits; scr++)
              {
               float sc_g = 0;
               int shift_sc = scr * kunits * heads;
               float sc = scores[shift_sc + k];
               if(sc == 0)
                  continue;
               for(int v = 0; v < kunits; v++)
                  sc_g += scores[shift_sc + v] * gradient[shift_g + scr * dimension] *
                          val * ((float)(k == v) - sc);
               grad += IsNaNOrInf(sc_g * 
                                  q[(hq + (int)(indexes[scr * heads + hq] + 0.001f) * heads) * dimension + d], 0);
              }
           }
         kv_g[shift_k].s0 = IsNaNOrInf(grad, 0);
        }
     }
  }

以上就是我们关于在 OpenCL 程序中实现的概率注意力算法的讨论。我们按顺序检查了每个主要阶段 — 从评估查询的重要性、选择最具信息量的元素,到计算注意力机制和执行反向传播过程。每个内核都经过精心适配,以适应 ACEFormer 架构,并针对 GPU 设备上的高效执行进行了优化。

附件中提供了完整的实现,包括上述所有内核的源代码。

我们下一阶段的工作将聚焦于在主应用中实现概率注意力算法。在这一阶段, OpenCL 程序与模型逻辑、缓冲区管理和计算同步集成在一起。然而,本文的篇幅已达到合理限度,因此我们将在下一篇文章中继续此讨论。



结论

在本文中,我们介绍了 ACEFormer 框架 — 一种旨在计算资源受限的情况下高效处理顺序数据的架构。其关键优势 — 模块化、适应性和计算效率 — 构成了实现的基础。

ACEFormer 为解决长序列注意力机制的扩展问题提供了一个优雅的解决方案。它没有对整个输入序列进行全量计算,而是采用了一种概率选择机制来识别最具信息量的元素。这显著降低了计算开销,同时保持了几乎相同的质量水平。在算法交易平台等对执行时间精确到微秒、内存空间寸土必争的环境中,这种方法尤其宝贵。

在本工作的实践部分,我们详细研究了 OpenCL 程序中概率注意力机制所有主要组成部分的实现。下一步将在主应用层面实现概率注意力算法。然而,为了使当前文章内容更加集中且易于理解,我们将在此暂停,并在下一篇文章中继续讨论这一实现。在那里,该项目的一个同样有趣的阶段正等着我们。


参考


本文中用到的程序

# 名称 类型 描述
1 Research.mq5 EA 样本采集 EA
2 ResearchRealORL.mq5
EA
使用 Real-ORL 方法采集样本的 EA
3 Study.mq5 EA 离线模型训练的 EA
4 StudyOnline.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/18004

附加的文件 |
MQL5.zip (2720.44 KB)
交易策略 交易策略
各种交易策略的分类都是任意的,下面这种分类强调从交易的基本概念上分类。
MQL5 MVC范式下的表格视图与控制器组件:可调整尺寸的元素 MQL5 MVC范式下的表格视图与控制器组件:可调整尺寸的元素
在本文中,我们将实现通过鼠标拖动控件的边缘和角点来调整控件大小的功能。
新手在交易中的10个基本错误 新手在交易中的10个基本错误
新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
价格行为分析工具包开发(第34部分):借助高级数据采集管道将原始市场数据转化为预测模型 价格行为分析工具包开发(第34部分):借助高级数据采集管道将原始市场数据转化为预测模型
你是否曾错过市场突然的尖峰行情,或者在行情发生时措手不及?预判实时行情的最佳方法是从历史形态中学习。为了训练一个机器学习模型,本文首先向您展示如何在 MetaTrader 5 中创建一个脚本,用于摄取历史数据并将其发送到 Python 进行存储 —— 为您的尖峰检测系统奠定基础。继续阅读,了解每个步骤的实际操作。