Русский
preview
Redes neurais em trading: Previsão de séries temporais com o auxílio da decomposição modal adaptativa (ACEFormer)

Redes neurais em trading: Previsão de séries temporais com o auxílio da decomposição modal adaptativa (ACEFormer)

MetaTrader 5Sistemas de negociação |
149 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introdução

O mercado financeiro é um sistema complexo e dinâmico, no qual cada movimento de preço representa o resultado de uma interação complexa de muitos fatores. Nele é possível encontrar reflexos de praticamente tudo, desde fluxos de informações macroeconômicas e notícias corporativas internas até explosões emocionais de investidores e cálculos frios de estratégias algorítmicas de trading. Nessa diversidade de sinais, ruídos e distorções, a tarefa de extrair informações úteis e reconhecer o verdadeiro rótulo de tendência deixa de ser apenas interessante e passa a ser estrategicamente importante.

A capacidade de prever com precisão a direção do mercado pode proporcionar uma vantagem consistente. Uma dificuldade particular é representada pelo chamado ruído informacional, que são microflutuações frequentes e, muitas vezes, sem sentido dos preços, causadas por operações de curto prazo, manchetes de notícias ou ações algorítmicas aleatórias. São justamente elas que frequentemente impedem os modelos analíticos de captar a essência do que está acontecendo.

Tentativas de construir modelos de previsão já eram realizadas no final do século XX. As arquiteturas mais simples de redes neurais mostraram que, em princípio, é possível treinar modelos para prever movimentos de mercado. No entanto, essas abordagens sofriam com a incapacidade de reter informações em intervalos temporais longos e rapidamente esqueciam o que havia ocorrido pouco antes.

Com o surgimento da arquitetura LSTM, a situação melhorou. Esses modelos, por possuírem mecanismos de memória, conseguiam manter padrões importantes ao longo de períodos de tempo mais extensos. Eles se difundiram amplamente em tarefas de previsão de séries temporais. Mas também aqui nem tudo se mostrou simples. As séries financeiras não são sequências temporais comuns. Elas são irregulares. Nelas, frequentemente não há uniformidade entre os ticks. E nelas existe uma quantidade extremamente grande de picos de curto prazo que não carregam informações significativas sobre a direção do rótulo de tendência.

Um problema especialmente sério é representado pelo trading de alta frequência. Ele cria o chamado ruído de mercado, que são oscilações múltiplas das cotações dentro de intervalos de tempo muito curtos. Essas oscilações mascaram os verdadeiros rótulos de tendência, tornam os dados instáveis e sobrecarregam o modelo com eventos irrelevantes. Como resultado, até mesmo arquiteturas complexas começam a se concentrar não no que é importante, mas no que apenas distrai.

Para resolver as tarefas mencionadas, no trabalho "An End-to-End Structure with Novel Position Mechanism and Improved EMD for Stock Forecasting", foi proposto o framework ACEFormer, que representa um algoritmo integrado para análise de séries temporais de bolsa, especialmente adaptado às condições de trading de alta frequência. Isso não é apenas um modelo, mas um sistema de componentes que se complementam mutuamente, em que cada um resolve uma tarefa específica, como filtragem de ruído, consideração dos intervalos de tempo e focalização da atenção em mudanças-chave.

A primeira etapa da arquitetura ACEFormer é a limpeza dos dados de ruídos. Aqui é utilizado o algoritmo ACEEMD modificado (Alias Complete Ensemble Empirical Mode Decomposition with Adaptive Noise). O método é baseado nas abordagens de decomposição modal empírica (EMD), porém implementado em uma forma aprimorada. Isso permite eliminar duas limitações principais do EMD, que são o efeito de borda e a mistura de componentes. Graças à remoção da primeira função modal intrínseca (IMF), que contém a maior quantidade de oscilações de alta frequência, o ACEEMD remove efetivamente o ruído e preserva pontos de reversão importantes do rótulo de tendência.

Após a filtragem preliminar, os dados limpos são direcionados para o módulo de consciência temporal. Os eventos de bolsa ocorrem em intervalos de tempo irregulares, e isso impõe restrições ao funcionamento dos mecanismos clássicos de atenção. Para levar essas diferenças em consideração, os autores do framework integraram o mecanismo Time-Aware, que é um módulo que analisa os valores das características levando em conta os intervalos de tempo entre elas. Isso permite que o modelo compreenda melhor a sequência dos eventos e identifique relações de causa e efeito.

Em seguida, os dados são processados por um bloco de atenção aprimorado. Diferentemente do Attention padrão, o módulo proposto é adaptado às particularidades dos dados de mercado, nos quais é importante saber destacar pontos-chave de mudança, ignorando flutuações insignificantes. Graças ao foco reforçado em trechos relevantes da série temporal, o modelo não se dispersa em elementos de ruído e se concentra em informações potencialmente importantes.

Na etapa final, é utilizada uma rede neural totalmente conectada. Ela agrega as características extraídas e forma a previsão final sobre a direção do movimento do preço. Dessa forma, a arquitetura ACEFormer cobre todo o ciclo de processamento de dados, desde a supressão de ruído e a consideração da estrutura temporal, até a focalização da atenção e a previsão.


Algoritmo ACEFormer

O algoritmo ACEFormer representa um sistema multietapas de processamento de séries temporais com o objetivo de prever com precisão as direções do movimento do preço nos mercados financeiros. A essência de seu funcionamento consiste na eliminação sequencial e adaptativa dos ruídos de mercado, na extração de características importantes e, em seguida, na construção da previsão levando em conta tendências de longo prazo. Essa abordagem é especialmente útil em condições de trading de alta frequência, quando o sinal útil está oculto em meio a inúmeras flutuações aleatórias e ruídos.

O processo começa com a alimentação dos dados brutos na forma de uma série temporal 𝑆={𝑠1,𝑠2,…,𝑠𝑛}, em que cada vetor 𝑠𝑖 inclui o preço, o volume e outros indicadores no instante de tempo 𝑖. Para preparar os dados para o treinamento do modelo, é adicionada à sequência uma almofada adicional de zeros com comprimento 𝑝, criando uma estrutura para a previsão de passos futuros. Isso permite que o modelo construa previsões para os próximos 𝑝 passos, apesar da ausência de informações explícitas sobre o futuro nos dados originais 𝐷=[𝑠1,𝑠2,…,𝑠𝑛,0,0,…,0] ∈ 𝑅(𝑛+𝑝)×𝑑, onde 𝑑 é o número de características. Esses zeros ajudam o modelo a perceber a estrutura e a prever valores futuros, mesmo quando os dados históricos não contêm um quadro completo.

A etapa seguinte inclui o suavizamento do sinal com o auxílio de filtros convolucionais. Dois filtros convolucionais 𝑓 e 𝑔 são aplicados de forma sequencial, reduzindo a quantidade de flutuações aleatórias nos dados e estabilizando a série. Esse suavizamento ajuda o modelo a se livrar de picos aleatórios e a melhorar a qualidade dos dados de entrada para as etapas subsequentes de processamento.

Após o suavizamento preliminar, entra em ação o método adaptado de decomposição modal empírica (ACEEMD), que ajuda a eliminar de forma eficiente o ruído de alta frequência. O processo começa com a adição e subtração de ruído gaussiano 𝑛𝑖(𝑡) a cada elemento da série temporal, o que cria duas novas séries 𝑝𝑒𝑖(𝑡)=𝑥(𝑡)+𝑛𝑖(𝑡) e 𝑝𝑚𝑖(𝑡)=𝑥(𝑡)−𝑛𝑖(𝑡).

Em seguida, cada uma dessas séries é processada pelo método de decomposição modal empírica (EMD), extraindo a primeira função modal intrínseca (IMF).

Após a extração da IMF, os componentes médios de ambas as séries são somados. O componente obtido é então subtraído da série original, resultando em uma sequência temporal limpa 𝑟1(𝑡)=𝑥(𝑡)−IMF1(𝑡). A série temporal limpa é encaminhada para as próximas etapas de processamento.

Para que o modelo consiga reconhecer a ordem dos eventos no tempo, é adicionada a codificação posicional, que preserva a informação sobre a localização temporal dos dados na série. Também é realizada a projeção linear dos dados.

Uma característica da arquitetura ACEFormer é o uso do módulo de atenção probabilística, que desempenha um papel importante no aumento da capacidade de generalização do modelo. A atenção probabilística representa uma modificação do Self-Attention clássico, voltada para o aumento da eficiência computacional e para a eliminação de conexões irrelevantes. A ideia central consiste em não calcular a atenção para todas as posições da sequência, mas focar apenas nos passos temporais mais significativos. Para isso, é previamente avaliada uma medida de importância de cada posição. No ACEFormer essa medida é definida como o valor máximo da projeção dos Querys sobre uma amostra aleatória dos Keys. Os valores são normalizados e, em seguida, são selecionadas as posições mais informativas. É exatamente para elas que o cálculo do Self-Attention é realizado. Dessa forma, a atenção não opera sobre toda a sequência, mas sobre um subconjunto comprimido dela, que com alta probabilidade contém os pontos-chave.

O uso do módulo de atenção probabilística no ACEFormer não é apenas um recurso técnico, mas um movimento estratégico que permite ao modelo se adaptar de forma mais flexível às condições dinâmicas do mercado, nas quais a importância de cada dependência individual pode mudar ao longo do tempo. Essa abordagem contribui para a criação de previsões mais confiáveis e fundamentadas em condições de dados instáveis.

Como resultado, a atenção probabilística ajuda o modelo ACEFormer a se concentrar em padrões realmente significativos nos dados, excluindo conexões irrelevantes e variações de ruído. Esse módulo reforça a capacidade do modelo de extrair regularidades importantes e construir previsões precisas, o que é especialmente relevante em tarefas de previsão da direção do movimento futuro de preços nos mercados financeiros.

Após a aplicação da atenção probabilística, o resultado passa por uma camada de convolução e por um procedimento de subamostragem (max-pooling), o que reforça ainda mais as características locais e melhora a representação do modelo sobre padrões importantes nos dados. A operação de convolução aprimora a extração de características locais, fortalecendo aquelas partes da série temporal que contêm as informações mais relevantes para a previsão posterior.

A etapa final do processamento dos dados é o mecanismo clássico de Self-Attention. Ele permite que cada elemento da série temporal leve em consideração o contexto global, além de identificar dependências entre eventos separados por intervalos de tempo significativos.

Para a obtenção dos valores de previsão em um horizonte de planejamento definido, é utilizada uma rede totalmente conectada.

Assim, o algoritmo ACEFormer atua em várias etapas, começando pela remoção de ruído e terminando com a construção de uma previsão precisa. Cada uma dessas etapas ajuda o modelo a trabalhar de forma mais eficiente com dados de mercado instáveis, identificando rótulos de tendência-chave de longo prazo e prevendo movimentos de preço com alta precisão.

A visualização autoral do framework ACEFormer é apresentada abaixo.



Implementação por meio de MQL5

Após o estudo detalhado dos aspectos teóricos do framework ACEFormer, passamos para sua implementação prática por meio de MQL5. Começaremos pelo módulo de atenção probabilística. Este é um dos blocos-chave da arquitetura, garantindo alta eficiência computacional sem perder a qualidade da representação dos dados.

Antes de nos aprofundarmos nos detalhes da implementação, vale reforçar mais uma vez a vantagem conceitual da atenção probabilística. Esse mecanismo representa um compromisso entre precisão e economia computacional. Diferentemente da atenção clássica, que analisa toda a sequência por completo, aqui é aplicada a seleção dos elementos mais informativos. Essa abordagem permite reduzir a carga sobre a memória e os recursos computacionais sem perda de qualidade, especialmente em sequências longas.

A implementação apresentada neste artigo é dividida em três kernels executados sequencialmente. Cada um deles resolve sua própria tarefa, desde o cálculo de significância, passando pela seleção das melhores consultas, até o cálculo final da representação contextual. Vamos considerar todo o processo passo a passo.

Primeiro, é determinada a significância de cada consulta. Isso acontece no kernel ProbAttentionQeuryImp. Nos parâmetros do kernel, recebemos:

  • matriz de Consultas (querys),
  • matriz combinada de Chaves e Valores (keys_values),
  • array de índices index_keys, no qual estão indicados os números de ordem amostrados das Chaves, associados a cada Consulta.

Neste contexto, trata-se de Chaves selecionadas aleatoriamente, e com base nelas é feita a avaliação da importância das Consultas. A amostragem é usada não para construir a atenção final, mas exclusivamente para uma avaliação estatística, isto é, calcular o quanto cada Consulta responde à subamostra de Chaves fornecida.

__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);

Planejamos a execução desse kernel em um espaço tridimensional de tarefas, em que cada dimensão desempenha um papel específico na organização do processamento paralelo. A primeira dimensão cobre a sequência de Consultas. A segunda dimensão corresponde à quantidade de Chaves amostradas vinculadas a cada Consulta específica. Esse número pode variar, dependendo dos parâmetros do modelo e da profundidade da análise. A terceira dimensão define o número de cabeças de atenção, que são submódulos independentes que, simultaneamente e em paralelo, analisam diferentes aspectos dos dados brutos.

Uma atenção especial deve ser dada ao mecanismo de trabalho com as cabeças de atenção. Cada uma delas opera com seu próprio conjunto individual de Chaves amostradas. Essa abordagem garante diversidade de perspectivas sobre a mesma sequência: cada cabeça se concentra em sua própria subamostra, identificando regularidades e relações únicas. Essa distribuição permite aumentar a robustez de toda a arquitetura, pois mesmo que uma das cabeças subestime um fragmento importante, outra pode compensar isso. Em conjunto, todas as cabeças formam uma representação mais rica e representativa do sinal original, o que melhora significativamente a qualidade da atenção e aumenta a informatividade do contexto final.

Os fluxos de operações são agrupados em grupos de trabalho ao longo da segunda dimensão do espaço de tarefas. Para a troca de informações entre os fluxos de operações paralelos dentro de um grupo de trabalho, criamos um array de dados na memória local do dispositivo OpenCL.

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

No passo seguinte, determinamos os deslocamentos nos arrays de dados correspondentes ao fluxo de operações atual. Nesse processo, para definir o deslocamento no buffer de Consultas, utilizamos o identificador do fluxo na primeira dimensão. Já para definir o deslocamento no buffer de Chaves, primeiro obtemos do buffer de indexação correspondente o número ordinal da Chave amostrada e, em seguida, determinamos o deslocamento no buffer.

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);

Para cada par Consulta-Chave, é calculado o produto escalar, que reflete o grau de correspondência entre eles. No laço, é realizada a multiplicação elemento a elemento e o acúmulo do resultado.

   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);

Em seguida, utilizando o array na memória local, são calculados em paralelo a soma e o valor máximo desses produtos dentro do grupo de trabalho. Isso permite obter, com alta performance, uma característica agregada para cada subamostra.

   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);

Depois disso, é determinada a diferença entre o valor máximo e o valor médio dos produtos escalares, que constitui a medida de importância da Consulta atual. Quanto maior esse valor, mais informativa é considerada essa Consulta. A significância final é armazenada no buffer de resultados 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);
}

A etapa seguinte é a seleção das Consultas mais significativas. Essa tarefa é executada pelo kernel TopKImportanceToIndex. Em vez de utilizar algoritmos complexos de ordenação, aqui é implementado um método simples e confiável de ranqueamento.

Para cada Consulta, é organizado um cálculo paralelo da quantidade de Consultas mais significativas. Se esse número for menor que o limiar definido top_k, a Consulta atual é incluída na lista final de índices. Esse método, apesar de sua simplicidade direta, é extremamente adequado para execução em GPU, pois requer um mínimo de sincronizações e não necessita de estruturas de dados adicionais.

__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;
  }

E, por fim, a terceira etapa-chave é o cálculo direto da atenção. Aqui entra em ação o kernel QIndexAttention. Sua tarefa é formar a representação contextual final para cada Consulta selecionada.

Na entrada desse kernel é fornecido o conjunto completo das entidades de Consultas (Query), Chaves (Key) e Valores (Value). Como já discutido anteriormente, uma decisão fundamental é a recusa em criar cópias adicionais de dados para o subconjunto selecionado, o que é criticamente importante para a economia de memória e para a aceleração dos cálculos. Em vez disso, é utilizado um buffer de índices que contém ponteiros para as Consultas mais significativas, selecionadas nas etapas anteriores.

Vale observar que os tokens de Chaves e Valores estão combinados em um único buffer de dados. Isso simplifica a organização do acesso aos dados e aumenta a eficiência do cache. Neste caso, é utilizado o tipo vetorial float2, em que o primeiro elemento corresponde à Chave e o segundo ao Valor. Essa estrutura permite processar pares "Chave-Valor" como uma única entidade lógica, reduzindo a sobrecarga de acesso à memória e contribuindo para uma implementação mais compacta e clara das operações computacionais.

__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);

Neste kernel, voltamos a utilizar um espaço de tarefas tridimensional. Apenas a primeira dimensão das Consultas opera com uma amostra limitada dos tokens mais significativos. Já a segunda dimensão das Chaves, ao contrário, corresponde à sequência completa. E, da mesma forma, agrupamos os fluxos de operações em grupos de trabalho ao longo da segunda dimensão.

No corpo do kernel, identificamos o fluxo de operações atual em todas as dimensões do espaço de tarefas. Em seguida, com base nos valores obtidos, determinamos o deslocamento nos buffers de dados.

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;

Observe que, antes de definir o deslocamento no buffer de dados das Consultas, primeiro extraímos o ponteiro para o token necessário a partir do buffer de índices dos elementos mais importantes.

Imediatamente após, criamos um array de dados na memória local para a transferência de informações entre os fluxos do grupo de trabalho.

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

Na primeira etapa, é realizado o cálculo dos produtos escalares entre os tokens de Consultas e Chaves, formando um array de valores intermediários, os chamados Raw Score. Essas estimativas refletem o grau de relevância mútua do par "Consulta-Chave" e se tornam a base para o processamento subsequente.

//--- 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);
     }

Com o objetivo de estabilizar os cálculos e aumentar a robustez numérica, a normalização é realizada utilizando o mecanismo SoftMax em uma forma modificada. No âmbito de cada grupo de trabalho (work group), inicialmente é determinado o valor máximo entre todos os Score.

//--- 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);

Em seguida, cada Score é ajustado por meio da subtração do máximo encontrado. Isso permite evitar o estouro da exponencial e garante que os valores sob a exponente sejam menores ou iguais a zero. Consequentemente, o resultado da função exponencial fica dentro do intervalo de 0 a 1.

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

Depois disso, é realizada a soma das exponenciais e a divisão de cada uma delas por essa soma, transformando o score no peso final.

//--- 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;

Em seguida, os pesos obtidos são aplicados ao tensor de Valores. Todos esses vetores ponderados são acumulados e combinados em um único vetor contextual, que descreve a essência semântica da informação original do ponto de vista da Consulta em questão.

//--- 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);
     }
  }

Todo o mecanismo descrito implementa um esquema coerente e altamente eficiente de atenção probabilística. Primeiro, avaliamos de forma rápida e aproximada a importância das Consultas. Em seguida, selecionamos as mais promissoras e, por fim, realizamos cálculos completos e precisos sobre um subconjunto limitado e informativo. Essa abordagem não apenas acelera o processamento de sequências longas, como também preserva um alto nível de precisão do modelo. Ao mesmo tempo, alcança-se uma redução significativa no volume de dados intermediários e nos acessos à memória global.

No entanto, o processo descrito acima abrange apenas a propagação para frente, isto é, a fase em que o modelo forma suas previsões com base nos dados brutos. Para que o modelo seja capaz de aprender, é necessário organizar a propagação reversa do erro, que é o processo de ajuste dos parâmetros treináveis de todos os componentes, levando em conta sua influência no resultado final do funcionamento do modelo.

Neste caso, adotamos uma decisão arquitetural consciente e fundamentada: propagar o gradiente do erro exclusivamente por meio do mecanismo de atenção, excluindo da cadeia do processo reverso a etapa de seleção das Consultas mais significativas. À primeira vista, isso pode parecer uma simplificação, porém por trás disso há um cálculo preciso e uma compreensão clara da estrutura interna dos cálculos.

Ambas as etapas mencionadas acima, tanto a seleção das Consultas significativas quanto a própria atenção, utilizam a mesma operação básica: a correspondência entre os tokens de Consultas e Chaves. No primeiro caso, lidamos com a análise de um subconjunto de Chaves amostradas no contexto de toda a sequência de Consultas, avaliando a significância de cada elemento com base em sua resposta. No segundo caso, ocorre o inverso: focamos a atenção nas Consultas mais significativas já selecionadas e as avaliamos no contexto da sequência completa de Chaves. Em outras palavras, em ambos os casos é realizada a correspondência das mesmas entidades, porém a partir de projeções diferentes. Isso nos permite evitar a duplicação de cálculos e organizar uma retroalimentação eficiente, ajustando os parâmetros do modelo apenas por meio de um único fluxo de informação.

Essa abordagem permite alcançar vários objetivos simultaneamente. Em primeiro lugar, ela reduz a carga computacional, pois o gradiente é propagado apenas por um único caminho informacional. Em segundo lugar, aumenta a estabilidade numérica do modelo, já que é eliminada a possibilidade de conflitos entre duas fontes paralelas de gradientes. Em terceiro lugar, a arquitetura torna-se mais simples e elegante, pois o número de dependências é reduzido, e a implementação e os testes são simplificados. E, o mais importante, todas as informações necessárias sobre a significância já estão contidas nos sinais de gradiente. Com isso, é garantido o efeito de reutilização de conhecimento, no qual um único ajuste de parâmetros gera um ganho duplo: melhora tanto o mecanismo de atenção quanto o procedimento de seleção.

O kernel QIndexAttentionGradients implementa a propagação reversa do erro por meio do mecanismo de atenção, sendo responsável pela distribuição precisa dos gradientes entre três componentes-chave: Consultas (Query), Chaves (Key) e Valores (Value). O espaço de trabalho é organizado em três dimensões:

  • as Consultas mais significativas;
  • a dimensionalidade dos tokens;
  • as cabeças de atenção.
Isso garante um alto grau de paralelismo e o uso máximo dos recursos computacionais da 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);

Na etapa inicial, cada fluxo de execução identifica suas coordenadas no espaço de tarefas. A partir do array de índices indexes, é extraído o identificador real da Consulta, necessário para o mapeamento correto com os elementos nos arrays globais. Também são calculados todos os deslocamentos necessários para o acesso às regiões correspondentes da memória.

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;

Em seguida, inicia-se a primeira etapa dos cálculos, que é a propagação do gradiente pelos Valores (Value). Nesta implementação, está prevista a possibilidade de utilizar um número menor de cabeças de atenção para as Chaves e Valores (heads_kv), em comparação com o número total de cabeças (heads) que processam as Consultas. Isso permite reduzir o volume de memória e acelerar os cálculos, sem perda da flexibilidade estrutural do modelo. No entanto, essa decisão exige uma abordagem especial para a propagação reversa do gradiente do erro.

Como os Valores (Value) podem ser compartilhados simultaneamente por várias cabeças de atenção, é necessário agregar a contribuição de todas as cabeças cujas saídas foram influenciadas por esses valores. Isso garante a propagação correta das informações de erro de volta aos valores dos quais dependem múltiplos caminhos informacionais no cálculo da atenção.

Para cada posição dos Valores é realizada uma passagem por todas as cabeças às quais os elementos analisados estão potencialmente acessíveis. Dentro dessa passagem, é calculada a contribuição ponderada de cada cabeça, que corresponde ao produto do gradiente do erro no nível dos resultados pelo coeficiente de atenção normalizado (score), obtido no processo de propagação para frente. Os resultados dessas operações são acumulados e, ao final, armazenados no segundo componente da estrutura float2 do array de gradientes de erro kv_g.

Esse mecanismo garante consistência e precisão na propagação reversa em condições nas quais o número de cabeças de atenção para Chaves e Valores é menor do que para Consultas. Como resultado, o modelo é treinado corretamente, apesar da assimetria estrutural entre os componentes da atenção.

//--- 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);
        }
     }

Na etapa seguinte, passamos ao cálculo dos gradientes em relação às Consultas. Aqui a situação é mais complexa, pois entra em jogo a derivada da função SoftMax, que exige cálculos adicionais. Para cada Consulta, é considerado o gradiente do erro no nível dos resultados correspondente à posição atual, após o que ocorre um duplo laço sobre as Chaves: primeiro, para calcular a contribuição de cada peso de atenção e, em seguida, para levar em conta a influência de cada Chave, do ponto de vista de seu valor normalizado. Dessa forma, obtém-se uma propagação cuidadosa do sinal de erro através dos pesos do SoftMax, considerando toda a estrutura probabilística da atenção. Ao final, o gradiente acumulado para um elemento específico da Consulta é gravado no array q_g, de acordo com o deslocamento previamente calculado.

//--- 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;

Em seguida, passamos ao cálculo dos gradientes em relação às Chaves, uma das etapas mais delicadas do processo reverso. Aqui é fundamental determinar com precisão como cada Chave influenciou o resultado final do modelo por meio do mecanismo de atenção.

Antes de tudo, é necessário considerar que, nesta implementação, pode ser utilizado um número diferente de cabeças de atenção para Consultas e para Chaves. Portanto, o gradiente em relação à Chave é formado levando em conta a contribuição de todas as cabeças de atenção para as quais essa Chave foi utilizada nos cálculos de atenção.

Durante a propagação para frente, cada par Consulta–Chave gera um valor escalar, que é normalizado pela função SoftMax. O resultado é armazenado no buffer scores. No entanto, para o cálculo correto do gradiente isso não é suficiente. O SoftMax é uma função não linear e, portanto, na propagação reversa do erro, é necessário considerar sua derivada. Mesmo que os valores do SoftMax já tenham sido armazenados, para cada logit de entrada é preciso calcular a sensibilidade de toda a função à sua variação. Isso é implementado por meio da fórmula da derivada do SoftMax, que inclui tanto os elementos diagonais quanto os elementos externos. Assim, no cálculo do gradiente da Chave, o algoritmo deve percorrer todas as Consultas com as quais essa Chave foi associada e acumular cuidadosamente suas contribuições.

O ponto-chave aqui é o uso dos índices das Consultas mais significativas para restaurar a cadeia correta de dependências. Sem isso, a distribuição do gradiente seria incorreta.

O algoritmo itera por todos os pares relevantes, calcula os elementos necessários da derivada do SoftMax e os multiplica pelo gradiente do erro no nível dos resultados. Os valores obtidos são então acumulados no gradiente da Chave correspondente e gravados no primeiro buffer kv_g, utilizado para o acúmulo dos gradientes de erro de Chaves e Valores.

//--- 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);
        }
     }
  }

Com isso, concluímos a análise dos algoritmos de construção dos processos de atenção probabilística no lado do programa OpenCL. Examinamos de forma sequencial todas as etapas principais, desde a avaliação da significância das Consultas e a seleção dos elementos mais informativos, até o cálculo da atenção e a organização da propagação reversa do erro. Cada kernel foi cuidadosamente adaptado às particularidades da arquitetura ACEFormer e otimizado para execução eficiente em dispositivos GPU.

O código-fonte completo da implementação, incluindo todos os kernels descritos, está disponível no anexo deste artigo.

A próxima etapa do nosso trabalho será a implementação dos algoritmos de atenção probabilística já no lado do programa principal. É exatamente aqui que ocorre a integração do programa OpenCL com a lógica do modelo, o gerenciamento de buffers e a sincronização dos cálculos. No entanto, o volume do artigo atual já atingiu limites razoáveis, portanto propomos fazer uma breve pausa e continuar o trabalho no próximo artigo.



Considerações finais

Neste artigo, conhecemos o conceito do framework ACEFormer, uma arquitetura voltada para o trabalho altamente eficiente com dados sequenciais em condições de recursos computacionais limitados. Seus pontos fortes, modularidade, adaptabilidade e economia computacional, tornaram-se a base de toda a implementação subsequente.

ACEFormer oferece uma solução elegante para o problema de escalabilidade da atenção ao trabalhar com sequências longas. Em vez de processar completamente todo o fluxo de dados brutos, é aplicado um mecanismo probabilístico de seleção dos elementos mais significativos, o que permite reduzir substancialmente a carga computacional sem uma perda significativa de qualidade. Isso é especialmente relevante em ambientes onde cada microssegundo e cada megabyte de memória contam, por exemplo, em plataformas de trading.

Na parte prática deste trabalho, examinamos em detalhe a implementação de todos os componentes-chave da atenção probabilística no lado do programa OpenCL. O próximo passo será a implementação dos algoritmos de atenção probabilística no nível do programa principal. No entanto, para não sobrecarregar o material atual, faremos uma pequena pausa e continuaremos o trabalho no próximo artigo. À frente nos espera uma etapa de integração não menos interessante e rica.


Referências


Programas utilizados no artigo

#NomeTipoDescrição
1Research.mq5EAEA de coleta de exemplos
2ResearchRealORL.mq5
EA
EA de coleta de exemplos pelo método Real-ORL
3Study.mq5EAEA de treinamento offline de modelos
4StudyOnline.mq5
EA
EA de treinamento online de modelos
4Test.mq5EAEA para teste de modelo
5Trajectory.mqhBiblioteca de classeEstrutura de descrição do estado do sistema e da arquitetura dos modelos
6NeuroNet.mqhBiblioteca de classeBiblioteca de classes para criação de redes neurais
7NeuroNet.clBibliotecaBiblioteca de código do programa OpenCL

Traduzido do russo pela MetaQuotes Ltd.
Artigo original: https://www.mql5.com/ru/articles/18004

Arquivos anexados |
MQL5.zip (2720.44 KB)
Desenvolvimento do Kit de Ferramentas de Análise de Price Action (Parte 9): Fluxo Externo Desenvolvimento do Kit de Ferramentas de Análise de Price Action (Parte 9): Fluxo Externo
Este artigo explora uma nova dimensão de análise utilizando bibliotecas externas especificamente projetadas para análises avançadas. Essas bibliotecas, como o pandas, fornecem ferramentas poderosas para processar e interpretar dados complexos, permitindo que os traders obtenham percepções mais profundas sobre a dinâmica do mercado. Ao integrar essas tecnologias, podemos reduzir a lacuna entre dados brutos e estratégias acionáveis. Junte-se a nós enquanto estabelecemos as bases dessa abordagem inovadora e desbloqueamos o potencial de combinar tecnologia com expertise em trading.
MQL5 Trading Toolkit (Parte 7): Expandindo a Biblioteca EX5 de Gerenciamento de Histórico com as Funções da Última Ordem Pendente Cancelada MQL5 Trading Toolkit (Parte 7): Expandindo a Biblioteca EX5 de Gerenciamento de Histórico com as Funções da Última Ordem Pendente Cancelada
Aprenda como concluir a criação do módulo final na biblioteca History Manager EX5, com foco nas funções responsáveis por lidar com a ordem pendente cancelada mais recente. Isso fornecerá a você as ferramentas para recuperar e armazenar de forma eficiente os principais detalhes relacionados às ordens pendentes canceladas com MQL5.
Redefinindo os Indicadores MQL5 e MetaTrader 5 Redefinindo os Indicadores MQL5 e MetaTrader 5
Uma abordagem inovadora para coletar informações de indicadores em MQL5 permite uma análise de dados mais flexível e simplificada, ao possibilitar que os desenvolvedores passem entradas personalizadas para os indicadores para cálculos imediatos. Essa abordagem é particularmente útil para o trading algorítmico, pois fornece maior controle sobre as informações processadas pelos indicadores, indo além das restrições tradicionais.
Implementação do mecanismo de breakeven em MQL5 (Parte 1): Classe base e modo de breakeven por pontos fixos Implementação do mecanismo de breakeven em MQL5 (Parte 1): Classe base e modo de breakeven por pontos fixos
Neste artigo, analisamos a aplicação do mecanismo de breakeven (ponto de equilíbrio) em estratégias automatizadas na linguagem MQL5. Começaremos com uma explicação simples do que é o modo de breakeven, como ele é implementado e quais são suas possíveis variações. Em seguida, essa funcionalidade será integrada ao EA Order Blocks, criado por nós no último artigo sobre gerenciamento de riscos. Para avaliar a eficácia, faremos dois backtests sob determinadas condições: um com a aplicação do mecanismo de breakeven e outro, sem.