Русский
preview
Redes neurais em trading: Segmentação periódica adaptativa (Criação de tokens)

Redes neurais em trading: Segmentação periódica adaptativa (Criação de tokens)

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

Introdução

No artigo anterior, examinamos detalhadamente os fundamentos teóricos do framework LightGTS (Lightweight General Time Series Forecasting) — um dos enfoques mais progressivos e tecnicamente bem elaborados para previsão de séries temporais na atualidade, apresentado no trabalho "LightGTS: A Lightweight General Time Series Forecasting Model". Seu conceito baseia-se em uma compreensão profunda da natureza da periodicidade característica dos dados financeiros e econômicos, bem como em uma releitura cuidadosa da arquitetura Transformer para tarefas específicas de processamento de estruturas temporais. A principal atenção foi dedicada a como o LightGTS trabalha com padrões periódicos, minimizando os custos de treinamento e garantindo uma generalização estável mesmo em dados heterogêneos e ruidosos.

O framework começa com o chamado Period Patching, um mecanismo no qual a série temporal é dividida em segmentos que correspondem à frequência interna do sinal analisado. Essa frequência não é definida manualmente, mas determinada pelo modelo com base na análise do espectro de frequência obtido por meio da transformada rápida de Fourier. Cada patch identificado representa um ciclo que contém padrões locais relevantes. É justamente esse fragmento que é transformado em um token por meio de uma projeção. Aqui surge a primeira inovação arquitetural, a Flex Projection Layer, que permite processar patches de comprimento variável por meio de uma transformação linear flexível de pesos. Essa projeção não apenas escala os dados, mas também preserva a equivalência dos tokens ao transitar entre diferentes escalas e frequências.

No bloco do Codificador é utilizado o Rotary Positional Encoding (RoPE), que fornece uma representação compacta e estável das posições relativas dos tokens. Isso é particularmente importante para séries financeiras, nas quais a posição absoluta frequentemente é muito menos significativa do que a disposição relativa dos elementos dentro da sequência. Após isso, os tokens são processados por uma pilha clássica de blocos Transformer, cada um composto por Self-Attention multicanais e pelo módulo Feed-Forward.

Uma das soluções mais originais propostas pelos autores do LightGTS foi o mecanismo Periodical Parallel Decoding, conceitualmente oposto às estratégias autorregressivas. Em vez de prever a sequência de forma etapa por etapa, o modelo utiliza o último token da representação oculta, que acumula toda a informação sobre a série anterior, e com base nele gera simultaneamente toda a sequência de saída, replicando-o e aplicando posteriormente um ponderamento posicional. Esse enfoque permite não apenas acelerar a previsão, mas também preservar a coerência periódica na estrutura temporal na saída do modelo.

Por fim, o framework aplica Flex-resize à camada de projeção do decodificador, garantindo assim a correspondência entre as previsões e o comprimento real do sinal previsto. Todo o treinamento do modelo se resume à minimização da função clássica MSE entre os valores previstos e a série alvo.

Dessa forma, LightGTS não é apenas um Transformer modificado. Trata-se de uma arquitetura cuidadosamente projetada na qual cada componente foi adaptado às características das séries temporais, desde o processamento da periodicidade até a rejeição da autorregressão em favor da geração totalmente paralela. É justamente graças a essa adaptação profunda que o framework demonstra alta precisão com baixo custo computacional.

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

Visualização autoral do framework LightGTS

Na parte prática do artigo anterior, começamos a construir o algoritmo de patching periódico adaptativo, um dos elementos centrais da arquitetura LightGTS. Foram analisadas em detalhes as limitações relacionadas à impossibilidade de utilizar alocação dinâmica de memória no ambiente de execução característico do MQL5 e do OpenCL. Essas limitações nos obrigaram a abandonar a ideia de um número arbitrário de tokens na saída.

Em vez disso, adotamos uma decisão estrategicamente equilibrada: fixar a quantidade de patches e utilizar a sobreposição entre eles como instrumento para compensar a variação no comprimento de segmentos individuais. Assim, conseguimos encontrar um compromisso entre adaptabilidade, no qual o modelo permanece sensível à periodicidade real da série temporal, e estabilidade computacional, necessária para o uso eficiente dos recursos de hardware. Essa abordagem permitiu preservar tanto a precisão na representação das estruturas cíclicas dos dados analisados quanto a previsibilidade no gerenciamento de memória, algo crítico para modelos de trading de alta frequência e para implementação em ambientes com contexto de execução limitado.

O algoritmo de seleção da frequência dominante já foi implementado, e ele extrai de forma eficiente a periodicidade principal para cada sequência unitária da série temporal de entrada. Isso nos permitirá indicar a escala base para a segmentação posterior dos dados. Hoje continuaremos o trabalho iniciado e daremos o próximo passo, implementar o algoritmo de geração de tokens no lado do contexto OpenCL.

Nossa tarefa consiste em dividir cada sequência temporal em uma quantidade fixa de fragmentos, na qual o tamanho do segmento é definido de acordo com a frequência dominante identificada, enquanto a sobreposição regula a adaptação ao comprimento da janela. Tudo isso deve ser executado sob condições de paralelismo rigoroso, utilizando código compatível com GPU, no qual cada thread será responsável pela formação de um token em um dos componentes unitários da sequência analisada.


Construção de kernels OpenCL

Após identificar a frequência dominante com base na transformada rápida de Fourier, chegamos a uma etapa central, a construção do mecanismo de geração de tokens, isto é, fragmentos da série temporal que correspondem à periodicidade identificada. Recordemos que, na parte anterior, concentramos nossa atenção em uma tarefa importante, conciliar a adaptação do modelo à frequência atual do mercado com a necessidade de um número fixo de patches de saída, algo especialmente relevante para o funcionamento no ambiente MQL5, que possui limitações quanto à alocação dinâmica de memória.

Nossa abordagem baseia-se no seguinte princípio, o número de tokens (patches) permanece constante, enquanto seu comprimento se adapta à frequência atual, e a sobreposição é regulada de modo a garantir a cobertura completa da série temporal analisada. Isso permite combinar de forma eficiente flexibilidade e controle de recursos. No entanto, aqui surge uma dificuldade técnica, se a janela de convolução muda, então a matriz de pesos, logicamente, também deveria se adaptar, seja reconstruída a cada vez, seja projetada em um novo espaço.

No artigo original, os autores do framework LightGTS propuseram uma solução, a matriz de pesos é treinada em um tamanho fixo de janela, determinado pelas estatísticas do conjunto de treinamento, e depois, sempre que surge um novo tamanho de janela, ocorre uma projeção dos pesos utilizando a pseudo-inversade Moore–Penrose. Matematicamente elegante, porém, na prática, pesada.

Os mercados financeiros reais são implacáveis. O ciclo de hoje não é o ciclo de amanhã. A periodicidade varia, e a adaptação exige flexibilidade. Calcular a matriz pseudo-inversa em tempo de execução, especialmente em condições de processamento online ou em trading de alta frequência, significa sacrificar velocidade e eficiência de recursos em nome do rigor formal. E isso é um luxo inadmissível.

Nossa solução é muito mais prática. Em vez de reconstruir constantemente os pesos, seguimos outro caminho, utilizamos uma matriz com o tamanho máximo permitido, na qual simplesmente ignoramos os pesos desnecessários por meio de padding com zeros. Em outras palavras, se um fragmento de dados não entra na janela ativa, ele é multiplicado por zero, e o peso correspondente não influencia o resultado. Simples e eficiente.

Essa abordagem permite:

  • preservar uma estrutura fixa da matriz de pesos, evitando operações computacionalmente caras;
  • variar dinamicamente o tamanho da janela e o passo entre os patches;
  • adaptar-se às oscilações de frequência do mercado em tempo de execução, sem interromper o modelo nem recalcular parâmetros.

Tudo isso é implementado no lado do contexto OpenCL dentro do kernel FeedForwardAdaptConv, no qual cada thread é responsável por um par específico segmento–filtro de uma das sequências unitárias.

__kernel void FeedForwardAdaptConv(__global const float *matrix_w,
                                   __global const float *matrix_i,
                                   __global float *matrix_o,
                                   __global const float *main_freq,
                                   const int inputs,
                                   const int window_in,
                                   const int activation
                                  )
  {
   const size_t u = get_global_id(0);
   const size_t f = get_global_id(1);
   const size_t v = get_global_id(2);
   const size_t units = get_global_size(0);
   const size_t filters = get_global_size(1);
   const size_t variables = get_global_size(2);

No corpo do kernel, primeiro determinamos os índices das operações das threads no espaço tridimensional de tarefas. Em seguida, pelo identificador da sequência unitária v, extraímos do buffer global main_freq a frequência dominante da variável analisada.

const int freq = main_freq[v];
int window = (inputs / variables + freq - 1) / freq;

Com base nela, calcula-se o tamanho da janela, considerando ajustes relacionados ao passo e aos limites da fragmentação. Nesse mesmo ponto também determinamos o tamanho do passo da janela de análise, com base no tamanho do tensor de dados brutos e na quantidade de tokens que serão criados. A tarefa consiste em cobrir toda a sequência de maneira uniforme, sem perdas.

const int step = (int)(inputs / variables + units + 1) / (units + 2);
if(window < step)
   window = (int)((step + window - 1) / window) * window;
if(window > window_in)
   window = window_in;

Em seguida vem um ponto crucial. É importante garantir que a janela não seja menor que o passo, caso contrário parte dos dados pode ficar sem cobertura. Portanto, se o período identificado for muito pequeno, aumentamos a janela em múltiplos até que ela alcance um valor não inferior ao passo, sem ultrapassar o máximo permitido.

Depois disso, são determinados os deslocamentos nos arrays de entrada e de saída, bem como na matriz de pesos. Isso é necessário para o endereçamento correto dos dados dentro dos buffers globais

const int shift_in = (u < (units - 1) ? u * step : inputs / variables - window);
const int shift_in_var =  v * inputs / variables;
const int shift_out = (u + v * units) * filters + f;
const int shift_weight = (v * filters + f) * (window_in + 1);

Aqui vale destacar um detalhe importante: precisamos cobrir toda a sequência de entrada, incluindo sua parte final. Se a segmentação for realizada com passo fixo e o comprimento de cada janela for definido dinamicamente, os últimos elementos podem ficar fora da análise. Para garantir a cobertura completa de toda a sequência de entrada, calculamos o ponto inicial do último segmento como a diferença entre o comprimento total da sequência analisada e o tamanho da janela. Isso permite deslocar a última janela de modo que ela necessariamente capture os dados finais da sequência, mesmo que seu tamanho tenha sido determinado dinamicamente e seja menor que o máximo permitido.

Em seguida começa a principal etapa de cálculo, a operação de convolução, na qual é formado o valor final para cada token. Na primeira etapa, tomamos o valor base correspondente ao componente bias, que é extraído da matriz de pesos utilizando o deslocamento previamente calculado. Esse elemento atua como ponto inicial para o acúmulo da contribuição de cada elemento da janela de análise.

float sum = matrix_w[shift_weight + window_in];
for(int i = 0; i < window; i++)
   if((shift_in + i) < (inputs / variables))
      sum += IsNaNOrInf(matrix_i[shift_in_var + shift_in + i], 0) *
             matrix_w[shift_weight + i];

Depois inicia-se a passagem por cada elemento da janela de análise. É exatamente nesse ponto que se manifesta a principal característica da nossa implementação, a estratégia de padding com zeros. Se um elemento da sequência estiver fora dos limites da janela efetivamente calculada, ele simplesmente é excluído dos cálculos. Isso permite evitar distorções do sinal que poderiam surgir ao incluir dados irrelevantes. Essa técnica garante estabilidade nos resultados e permite obter cálculos corretos independentemente do tamanho atual da janela. Além disso, o padding com zeros facilita a manutenção de uma dimensionalidade fixa da matriz de pesos, pois podemos garantir que todas as posições vazias serão compensadas por zeros que não influenciam a soma final.

Ao final aplicamos a função de ativação escolhida e salvamos o resultado no buffer de valores de saída.

 matrix_o[shift_out] = Activation(sum, activation);
}

Assim, obtemos incorporações adaptadas à frequência atual do mercado, com contexto local bem definido e prontas para serem alimentadas aos blocos Transformer. Tudo isso é feito sem operações custosas, com consumo mínimo de recursos e em plena conformidade com as limitações do ambiente MQL5.

Antes de lançarmos os tokens obtidos no turbilhão dos Transformer-blocos, precisamos garantir que o próprio modelo não falhe logo no primeiro passo e seja capaz de aprender. Para isso precisamos de uma propagação reversa completa (backpropagation), o estágio no qual são calculados os gradientes que nos permitem ajustar filtros adaptativos de convolução. Sem isso, tudo o que fizemos no processo de propagação para frente se transformará em estagnação: os filtros permanecerão congelados em estados aleatórios, e o modelo simplesmente não conseguirá se adaptar às novas oscilações do mercado, ficando preso aos próprios erros.

O início da propagação reversa é como dar à orquestra um retorno inverso: se um instrumento se desajusta, a onda sonora precisa voltar e indicar exatamente o que deve ser corrigido. No mundo da convolução adaptativa com janelas variáveis, essa tarefa está longe de ser simples. Cada valor original pode ter participado simultaneamente de vários tokens, e todos eles exigem que o erro seja redistribuído de volta às suas fontes.

Foi exatamente para esse propósito que criamos o kernel OpenCL CalcHiddenGradientAdaptConv. Ele opera em um espaço bidimensional: no eixo inp estão as posições nas sequências unitárias originais, e no eixo v estão os diferentes canais, ou seja, as sequências unitárias. Essa organização garante que cada elemento dos dados analisados receba seu gradiente exato.

__kernel void CalcHiddenGradientAdaptConv(__global const float *matrix_w,
                                          __global const float *matrix_i,
                                          __global float *matrix_ig,
                                          __global const float *matrix_og,
                                          __global const float *main_freq,
                                          const int outputs,
                                          const int window_in,
                                          const int window_out,
                                          const int activation
                                         )
  {
   const size_t inp = get_global_id(0);
   const size_t v = get_global_id(1);
   const size_t inputs = get_global_size(0);
   const size_t variables = get_global_size(1);

Dentro do kernel, primeiro ativamos o radar, identificando a thread atual em todas as dimensões do espaço de tarefas. Em seguida, de maneira semelhante ao kernel da propagação para frente, determinamos o tamanho do segmento individualmente para cada sequência unitária e também o tamanho do seu passo.

const int units = outputs / (window_out * variables);
const int freq = main_freq[v];
int window = (inputs / variables + freq - 1) / freq;
const int step = (int)(inputs + units + 1) / (units + 2);
if(window < step)
   window = (int)((step + window - 1) / window) * window;
if(window > window_in)
   window = window_in;

Depois disso determinamos os deslocamentos nos buffers de dados até os elementos necessários.

const int shift_in = v * inputs + inp;
int u = inp / step;
int shift_out_var = v * (outputs / variables);
int shift_weight_var = (v * window_out) * (window_in + 1);

Em seguida passamos ao processo principal de distribuição do gradiente de erro. Nesse ponto, primeiro identificamos na formação de qual token foi utilizado o elemento atual do buffer de dados brutos e coletamos o gradiente de erro de todos os elementos do token resultante, considerando a contribuição do elemento analisado.

No entanto, é importante observar que o uso de segmentos sobrepostos leva à possibilidade de que um mesmo elemento dos dados brutos seja utilizado na formação de vários tokens em posições diferentes. Por isso, a operação de coleta dos gradientes de erro é envolvida em um laço.

float sum = 0;
while(u * step <= inp && u < (units - 1))
  {
   int pos = inp - u * step;
   if(pos >= window)
     {
      u++;
      continue;
     }
   int shift_out = u * window_out;
   int shift_weight = pos + shift_weight_var;
   for(int out = 0; out < window_out; out++)
     {
      if((shift_out + out) >= (outputs / variables))
         continue;
      sum += IsNaNOrInf(matrix_og[shift_out_var + shift_out + out] *
                        matrix_w[shift_weight + out * (window_in + 1)], 0);
     }
   u++;
  }

Também não devemos esquecer as particularidades da formação do último segmento. No algoritmo de distribuição do gradiente de erro, tratamos esse caso em um bloco separado.

if(inp >= (inputs - window))
  {
   int pos = inp + window - inputs;
   int shift_out = (units - 1) * window_out;
   int shift_weight = pos + shift_weight_var;
   for(int out = 0; out < window_out; out++)
     {
      if((shift_out + out) >= (outputs / variables))
         continue;
      sum += IsNaNOrInf(matrix_og[shift_out_var + shift_out + out] *
                        matrix_w[shift_weight + out * (window_in + 1)], 0);
     }
  }

Por fim, a soma acumulada dos gradientes de erro é corrigida pela derivada da função de ativação e armazenada no elemento correspondente do buffer global de dados.

 matrix_ig[shift_in] = Deactivation(sum, matrix_i[shift_in], activation);
}

Essa abordagem com um laço sobre os tokens garante que cada pedaço dos dados brutos receba seu respectivo gradiente de erro, mesmo que o seu sinal tenha contribuído simultaneamente para vários tokens. Isso permite que nossos filtros adaptativos aprendam não a partir de fragmentos isolados, mas a partir de conjuntos completos de dados de mercado.

Entretanto, a distribuição do gradiente de erro representa apenas o meio do caminho. A verdadeira magia ocorre quando utilizamos esses gradientes para atualizar os parâmetros do modelo. Imagine um jardineiro que, após a colheita, decide quais árvores devem ser podadas e quais precisam de mais nutrientes para que na próxima estação produzam ainda mais frutos. Da mesma forma, nossos algoritmos de otimização utilizam os gradientes calculados para ajustar os pesos na direção da minimização do erro total de previsão.

No nosso caso, a atualização dos parâmetros do modelo é implementada dentro do kernel OpenCL UpdateWeightsAdaptConvAdam. Isso não é apenas um passo técnico, mas a culminação de todo o processo de propagação reversa do erro, o momento em que o modelo tira conclusões dos seus erros e dá um passo rumo à melhoria.

__kernel void UpdateWeightsAdaptConvAdam(__global float *matrix_w,
                                         __global const float *matrix_og,
                                         __global const float *matrix_i,
                                         __global float *matrix_m,
                                         __global float *matrix_v,
                                         __global float *main_freq,
                                         const int inputs,
                                         const int outputs,
                                         const float l,
                                         const float b1,
                                         const float b2
                                        )
  {
   const size_t id_in = get_global_id(0);    // input shift
   const size_t id_out = get_global_id(1);   // filter shift
   const size_t id_v = get_global_id(2);     // variable
   const size_t window_in = get_global_size(0) - 1;
   const size_t window_out = get_global_size(1);
   const size_t variables = get_global_size(2);

O algoritmo de funcionamento do kernel é organizado como um cenário multinível com três eixos de coordenadas, cada um assumindo um papel claramente definido no processo computacional. O primeiro eixo corresponde à posição dentro da janela analisada, ou segmento, dos dados brutos (id_in), o segundo corresponde ao número do filtro, ou, em outras palavras, a uma posição específica no token na saída do objeto (id_out), e o terceiro corresponde ao índice da sequência unitária (id_v), o que representa um canal separado dos dados brutos.

Essa distribuição tridimensional dos cálculos não é apenas uma conveniência arquitetural, mas uma decisão estratégica. Ela garante a decomposição completa da tarefa: cada parâmetro treinável da matriz de pesos é aplicado estritamente no contexto do fragmento correspondente da sequência de entrada e está vinculado a um filtro e a um canal específicos. Imagine que cada filtro seja um analista separado, trabalhando com seu próprio gráfico e não confundindo documentos com os demais. Isso permite evitar a mistura de sinais entre séries temporais, prevenindo distorções cruzadas, algo especialmente crítico ao trabalhar com dados financeiros, nos quais ruído e instabilidade fazem parte da realidade cotidiana.

Essa abordagem cria uma espécie de microscópio com lentes independentes: cada filtro está focado em um fragmento único de dados, proporcionando um ajuste altamente preciso às menores oscilações do sinal. Se um canal contiver oscilações intensas e rápidas, enquanto outro apresentar uma tendência estável porém lenta, o algoritmo conseguirá lidar com cada um deles sem perder a nitidez da análise. Na prática, cada peso é treinado individualmente para sua tarefa local, como se fosse um modelo separado dentro de um sistema maior.

No corpo do kernel identificamos imediatamente o thread no espaço tridimensional de tarefas, o que nos permite selecionar um elemento da matriz de parâmetros treináveis para realizar as operações seguintes.

Logo depois, com base na frequência dominante main_freq[id_v], calculamos o tamanho atual do segmento window no qual esse peso será aplicado.

const int units = outputs / (window_out * variables);
const int freq = main_freq[id_v];
int window = (inputs / variables + freq - 1) / freq;
const int step = (int)(inputs / variables + units + 1) / (units + 2);
if(window < step)
   window = (int)((step + window - 1) / window) * window;
if(window > window_in)
   window = window_in;

Essa abordagem garante:

  • isolamento de parâmetros, no qual cada peso trabalha apenas com os dados brutos aos quais está associado;
  • paralelismo total, pois as threads não interferem entre si, já que trabalham com diferentes elementos da matriz de pesos;
  • precisão, pois o filtro está sincronizado com a frequência dos dados e processa apenas segmentos relevantes.

Não devemos esquecer que todos os cálculos são realizados sobre estrutura universal, a matriz de pesos dimensionada para a maior janela possível dos dados analisados. Porém, na prática, cada segmento específico frequentemente é menor do que esse tamanho máximo. Para não desperdiçar o precioso tempo e energia da GPU com trabalho inútil, no início de cada thread verificamos se o parâmetro pertence ao segmento atual e encerramos imediatamente a execução das threads desnecessários.

if(id_in != window_in &&
   id_in >= window)
   return;

Como resultado, o desempenho geral aumenta significativamente e o modelo passa a operar muito mais rápido, como um piloto focado no objetivo, que descarta imediatamente todas as curvas desnecessárias e segue pelo caminho mais direto até a linha de chegada.

O próximo passo é determinar os deslocamentos nos buffers globais de dados, pois sem isso nenhuma thread conseguirá localizar os elementos necessários.

const int shift_in_var = id_v * inputs / variables;
const int shift_out_var = id_v * outputs / variables;
const int shift_weight = (id_v * window_out + id_out) *
                         (window_in + 1) + id_in;
const bool bias = (id_in == window_in);

Essa técnica simples, mas extremamente importante, garante que cada parâmetro do filtro, em um determinado instante, trabalhe apenas com sua porção de dados, aumentando a eficiência e a previsibilidade de todo o modelo.

Em seguida passamos para uma das partes mais críticas, o cálculo do gradiente de erro para o parâmetro selecionado. O gradiente não é uma grandeza abstrata, mas um verdadeiro farol que indica em que direção e em que magnitude o peso deve ser ajustado para que o modelo produza previsões mais precisas.

Para obter a contribuição real do parâmetro analisado, percorremos toda a sequência unitária e acumulamos todas as suas respostas nos tokens de saída.

float grad = 0;
for(int u = 0; u < (units - 1); u++)
  {
   const int shift_in_loc = id_in + u * step;
   if(shift_in_loc >= (inputs / variables))
      continue;
   float inp = (bias ? 1 : IsNaNOrInf(matrix_i[shift_in_var + shift_in_loc], 0));
   grad += IsNaNOrInf(inp * matrix_og[shift_out_var + u * window_out + id_out], 0);
  }

Também levamos em conta as particularidades da formação do último segmento. Esse caso foi separado em um bloco específico.

{
 const int shift_in_loc = id_in + inputs / variables - window;
 if(shift_in_loc < (inputs / variables))
   {
    float inp = (bias ? 1 : IsNaNOrInf(matrix_i[shift_in_var + shift_in_loc], 0));
    grad += IsNaNOrInf(inp * matrix_og[shift_out_var + (units - 1) * window_out + id_out], 0);
   }
}

Esse método de coleta do gradiente é cuidadoso e completo. Não ignoramos nenhum retorno dos dados brutos e, consequentemente, obtemos o vetor de direção mais preciso possível para a correção do peso. É exatamente esse rastreamento reverso minucioso que garante que o modelo não fique preso em erros locais e consiga adaptar-se de forma estável e contínua ao ritmo mutável do mercado financeiro.

Em seguida entra em ação o algoritmo Adam, um método adaptativo de otimização que combina as vantagens do suavizamento de gradiente (Momentum) com a normalização da variância (RMSProp). Ele utiliza dois arrays auxiliares: matrix_m para o primeiro momento (gradiente acumulado) e matrix_v para o segundo momento (erro quadrático acumulado).

float mt = IsNaNOrInf(clamp(b1 * matrix_m[shift_weight] + (1 - b1) * grad, -1.0e5f, 1.0e5f), 0);
float vt = IsNaNOrInf(clamp(b2 * matrix_v[shift_weight] + (1 - b2) * pow(grad, 2), 1.0e-6f, 1.0e6f), 1.0e-6f);
float weight = clamp(matrix_w[shift_weight] + IsNaNOrInf(l * mt / sqrt(vt), 0), -MAX_WEIGHT, MAX_WEIGHT);

O valor do parâmetro é ajustado levando em consideração tanto a direção (mt) quanto a estabilidade (vt) da mudança. Os valores atualizados são então gravados novamente nos buffers globais de dados.

 matrix_w[shift_weight] = weight;
 matrix_m[shift_weight] = mt;
 matrix_v[shift_weight] = vt;
}

Assim, cada parâmetro do filtro se adapta utilizando o rico contexto de suas atualizações anteriores. Isso permite que o modelo não apenas responda rapidamente a erros locais, mas também evite oscilações bruscas nos parâmetros, garantindo uma adaptação suave e estável aos dados. Esse ajuste fino é especialmente importante em séries temporais financeiras instáveis, nas quais qualquer impulso excessivo pode levar ao sobreajuste ou à perda de capacidade de generalização.

Com isso encerramos o trabalho no lado do programa OpenCL. O código completo é apresentado no anexo do artigo.


Criação do objeto

Vimos como, nos kernels do programa OpenCL, são implementados passo a passo a convolução adaptativa com janela variável, a formação dinâmica de tokens, o cálculo de gradientes e a atualização de pesos. No entanto, apenas a lógica computacional representa apenas metade do trabalho. Para que essa arquitetura funcione em tempo real e dentro de um modelo completo, ela precisa ser integrada corretamente ao programa principal.

É exatamente nesse ponto que começa o mais interessante, a integração. Assim como em uma boa orquestra, não importa apenas o talento dos solistas, ou seja, dos (kernels), mas também o trabalho preciso do maestro, aquele que inicia os processos certos no momento correto, coordena sua interação e garante a integridade de toda a composição.

No nosso caso, o maestro é a classe CNeuronAdaptConv. É ela que coordena tudo: desde a análise das frequências dominantes até o início da convolução adaptativa, desde a propagação reversa do erro até a atualização dos pesos pelo otimizador Adam. Não se trata apenas de um invólucro sobre o programa OpenCL, mas de um módulo completo de controle que toma decisões, conecta entre si as etapas de cálculo e garante a preservação do estado entre as iterações.

A estrutura do novo objeto é apresentada abaixo.

class CNeuronAdaptConv    :  public CNeuronConvOCL
  {
protected:
   CBufferFloat      bMainFreq;
   //---
   virtual bool      FFT(CBufferFloat *inp_re, CBufferFloat *inp_im,
                         CBufferFloat *out_re, CBufferFloat *out_im, 
                         uint variables, bool reverse = false);
   virtual bool      PeriodsFinding(CBufferFloat *inp_re, CBufferFloat *inp_im, 
                                    CBufferFloat *main_freq, uint variables);
   virtual bool      AdaptiveConvolution(CNeuronBaseOCL *NeuronOCL, CBufferFloat *main_freq);
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronAdaptConv(void) {};
                    ~CNeuronAdaptConv(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window,
                          uint window_out, uint units_count, uint variables,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override const  {  return defNeuronAdaptConv;   }
   //--- methods for working with files
   virtual bool      Save(int const file_handle) override;
   virtual bool      Load(int const file_handle) override;
   //---
   virtual void      SetOpenCL(COpenCLMy *obj)   override;
  };

Como se pode observar, dentro de CNeuronAdaptConv é declarado apenas um único buffer próprio, bMainFreq, destinado ao armazenamento das frequências dominantes. Todos os demais objetos necessários para o funcionamento são herdados da classe pai da camada de convolução CNeuronConvOCL, o que garante o reaproveitamento da lógica comum e reduz a duplicação de código.

O buffer bMainFreq é declarado de forma estática, o que nos permite deixar vazios tanto o construtor quanto o destrutor da classe. O processo de inicialização desse buffer e de todos os objetos herdados é organizado no método Init, em cujos parâmetros recebemos uma série de constantes que permitem interpretar de forma inequívoca a arquitetura do objeto criado.

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

O algoritmo do método é bastante simples. Primeiro delegamos toda a verificação e inicialização à lógica básica da classe pai, como se confiássemos a um mentor que já sabe com quais parâmetros e buffers deve trabalhar. Isso libera nosso código de rotinas desnecessárias. Em seguida resta apenas inicializar o buffer das frequências dominantes.

   bMainFreq.BufferFree();
   if(!bMainFreq.BufferInit(iVariables, 1) ||
      !bMainFreq.BufferCreate(OpenCL))
      return false;
//---
   return true;
  }

Depois retornamos o resultado lógico da execução do método para o programa chamador.

Vale dizer que a maior parte dos métodos do novo objeto é apenas invólucros para configurar o enfileiramento para execução dos kernels correspondentes, construídos com base no algoritmo que você já conhece:

  • FFT — decomposição da série temporal em componentes de frequência por meio da transformada rápida de Fourier;
  • PeriodsFinding — busca da frequência dominante;
  • AdaptiveConvolution — propagação para frente da convolução adaptativa.

Como a transformada rápida de Fourier e o algoritmo de busca da frequência dominante não utilizam parâmetros treináveis e não exigem distribuição do gradiente de erro, os métodos de propagação reversa tornam-se apenas invólucros correspondentes. Nesse contexto, destaca-se claramente o método de propagação para frente feedForward, que reúne várias iterações.

bool CNeuronAdaptConv::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;

Nos parâmetros do método recebemos um ponteiro para o objeto de dados brutos, cuja validade verificamos imediatamente. Em seguida decompomos os dados obtidos em componentes de frequência por meio da chamada do método FFT.

if(!FFT(NeuronOCL.getOutput(), NULL, Output, PrevOutput, iVariables, false))
   return false;

Do espectro obtido extraímos as frequências dominantes para cada sequência unitária.

if(!PeriodsFinding(Output, PrevOutput, GetPointer(bMainFreq), iVariables))
   return false;

E por fim chamamos o método de convolução adaptativa, que gerará os tokens de que precisamos.

 return AdaptiveConvolution(NeuronOCL, GetPointer(bMainFreq));
}

O resultado lógico da execução das operações é retornado ao programa chamador.

Assim, a classe CNeuronAdaptConv atua como um verdadeiro “maestro” do pipeline computacional: ela não possui implementações pesadas, mas demonstra uma capacidade perfeita de conduzir e sincronizar todas as etapas do trabalho, transferindo o controle exatamente para onde ele realmente é necessário.


Considerações finais

Neste artigo concluímos a formação de um pipeline completo de processamento de séries temporais, que combina análise espectral e convolução adaptativa em um único algoritmo integrado. Mostramos como, com o auxílio da FFT e da busca da frequência dominante, é possível determinar o ritmo de cada canal de dados e, em seguida, com base nessa informação, ajustar de forma flexível a largura do segmento, mantendo ao mesmo tempo um número fixo de tokens na saída do objeto.

Foi dada atenção especial à implementação prática no ambiente MQL5 e OpenCL. Percorremos todas as etapas, desde a decomposição do espectro e a segmentação em patches até a propagação reversa do erro e a atualização dos pesos pelo otimizador Adam. Cada fase foi organizada na forma de um pequeno kernel independente, enquanto a classe de controle CNeuronAdaptConv disciplina e sincroniza seu funcionamento, atuando como o maestro de toda a orquestra computacional.

Graças a uma arquitetura cuidadosamente planejada, na qual cada thread da GPU processa apenas seu próprio peso e seu próprio fragmento de dados, conseguimos alcançar um nível impressionante de paralelismo sem bloqueios entre processos. O padding com zeros e o sistema rígido de deslocamentos garantem que nenhuma informação seja perdida ou misturada entre os canais. Já o otimizador adaptativo Adam, levando em consideração de forma precisa os momentos de primeira e segunda ordem, assegura um treinamento suave e estável do modelo.

No próximo artigo falaremos sobre o uso dos tokens obtidos em uma pilha modificada de Transformer.


Links

Programas utilizados no artigo

# Nome Tipo Descrição
1 Study.mq5 Expert Advisor EA de treinamento offline dos modelos
2 StudyOnline.mq5 Expert Advisor EA de treinamento online dos modelos
3 Test.mq5 Expert Advisor EA para testar o modelo
4 Trajectory.mqh Biblioteca de classe Estrutura de descrição do estado do sistema e da arquitetura dos modelos
5 NeuroNet.mqh Biblioteca de classe Biblioteca de classes para criação de redes neurais
6 NeuroNet.cl Biblioteca Biblioteca de código do programa OpenCL

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

Arquivos anexados |
MQL5.zip (2881.34 KB)
Operando com o Calendário Econômico do MQL5 (Parte 6): Automatizando a Entrada de Trades com Análise de Eventos de Notícias e Temporizadores de Contagem Regressiva Operando com o Calendário Econômico do MQL5 (Parte 6): Automatizando a Entrada de Trades com Análise de Eventos de Notícias e Temporizadores de Contagem Regressiva
Neste artigo, implementamos a entrada automática de trades utilizando o Calendário Econômico do MQL5, aplicando filtros definidos pelo usuário e deslocamentos de tempo para identificar eventos de notícias qualificados. Comparamos os valores de previsão e valores anteriores para determinar se devemos abrir uma operação BUY ou SELL. Temporizadores dinâmicos de contagem regressiva exibem o tempo restante até a divulgação da notícia e são redefinidos automaticamente após a execução de um trade.
Algoritmo de Busca com Retrocesso — Backtracking Search Algorithm (BSA) Algoritmo de Busca com Retrocesso — Backtracking Search Algorithm (BSA)
E se um algoritmo de otimização pudesse lembrar suas viagens passadas e usar essa memória para buscar soluções melhores? O BSA faz exatamente isso, equilibrando a exploração do novo e o retorno ao que já foi testado. No artigo, revelamos os segredos do algoritmo. Ideia simples, mínimo de parâmetros e resultado estável.
Está chegando o novo MetaTrader 5 e MQL5 Está chegando o novo MetaTrader 5 e MQL5
Esta é apenas uma breve resenha do MetaTrader 5. Eu não posso descrever todos os novos recursos do sistema por um período tão curto de tempo - os testes começaram em 09.09.2009. Esta é uma data simbólica, e tenho certeza que será um número de sorte. Alguns dias passaram-se desde que eu obtive a versão beta do terminal MetaTrader 5 e MQL5. Eu ainda não consegui testar todos os seus recursos, mas já estou impressionado.
Redes neurais em trading: Segmentação periódica adaptativa (LightGTS) Redes neurais em trading: Segmentação periódica adaptativa (LightGTS)
Propomos conhecer uma técnica inovadora de patching adaptativo, um método de segmentar séries temporais de forma flexível considerando sua periodicidade interna. Além disso, apresentamos uma técnica de codificação eficiente que permite preservar características semânticas importantes ao trabalhar com dados de diferentes escalas. Esses métodos abrem novas possibilidades para o processamento preciso de dados complexos multiescalares, característicos dos mercados financeiros, e aumentam significativamente a estabilidade e a fundamentação das previsões.