English Русский Español Deutsch 日本語
preview
Redes neurais de maneira fácil (Parte 78): Detecção de objetos baseada em Transformador (DFFT)

Redes neurais de maneira fácil (Parte 78): Detecção de objetos baseada em Transformador (DFFT)

MetaTrader 5Sistemas de negociação | 25 julho 2024, 10:44
90 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introdução

Nos artigos anteriores, dedicamos muito tempo à questão da previsão do movimento futuro dos preços. Analisamos os dados históricos. E com base nessa análise, buscamos diferentes maneiras de prever o movimento futuro mais provável dos preços. Alguns construíram um espectro completo de possíveis movimentos futuros dos preços e tentaram avaliar a probabilidade de cada uma das previsões feitas. Naturalmente, o treinamento e a utilização desses modelos exigem recursos computacionais significativos.

Mas precisamos realmente prever o movimento futuro dos preços? Ainda mais quando a precisão das previsões obtidas está longe do desejado.

Nosso objetivo final é obter lucro, que planejamos alcançar através do desempenho bem-sucedido do nosso Agente. Este, por sua vez, seleciona as melhores ações com base nas trajetórias de preços previstas que recebe.

Portanto, um erro na previsão das trajetórias de preços pode resultar em um erro ainda mais significativo na seleção das ações pelo Agente. Uso a expressão "pode resultar" porque, no processo de treinamento, o Ator pode se ajustar aos erros das previsões e reduzir um pouco o erro. No entanto, essa situação é possível com um erro relativamente constante nas previsões. No caso de um erro estocástico nas previsões, o erro das ações do Agente apenas se intensificará.

Quando isso acontece, procuramos maneiras de minimizar o erro. E se excluirmos a etapa intermediária de previsão da trajetória do movimento futuro dos preços. Voltemos à abordagem clássica de aprendizado por reforço. E permitamos que o Ator escolha ações com base na análise dos dados históricos. Mas ao fazer isso, não daremos um passo para trás, mas sim para o lado.

Proponho que você conheça um método interessante, que foi apresentado para resolver problemas na área de visão computacional. Este é o método Decoder-Free Fully Transformer-based (DFFT), que foi apresentado no artigo "Efficient Decoder-free Object Detection with Transformers".

O método DFFT proposto no artigo oferece alta eficiência tanto na etapa de treinamento quanto na de utilização. Os autores do método simplificam a detecção de objetos para uma tarefa unificada de previsão densa, utilizando apenas o codificador. E concentram seus esforços na resolução de 2 tarefas:

  1. Eliminação do decodificador ineficaz e utilização de 2 codificadores poderosos para manter a precisão da previsão de um mapa unificado de características;
  2. Exploração de características semânticas de baixo nível para a tarefa de detecção com recursos computacionais limitados.

Em particular, os autores do método propõem uma nova arquitetura leve de transformador, orientada para a detecção, que captura eficientemente características de baixo nível com rica semântica. Os experimentos apresentados no artigo mostram a redução dos custos computacionais e um menor número de épocas de treinamento.


1. Algoritmo DFFT

O método Decoder-Free Fully Transformer-based (DFFT) é um detector de objetos eficiente, totalmente baseado em Transformadores sem decodificador. A estrutura do Transformador é orientada para a detecção de objetos. Ele extrai objetos em quatro escalas e os envia para o próximo módulo de previsão densa, destinado apenas ao codificador. O módulo de previsão primeiro agrega o objeto multiescalar em um único mapa de objetos usando o codificador Scale-Aggregated Encoder.

Em seguida, os autores do método propõem o uso do codificador Task-Aligned Encoder para alinhar simultaneamente as funções para tarefas de classificação e regressão.

A estrutura do transformador orientada para a detecção (Detection-Oriented Transformer — DOT) é projetada para extrair características multiescalares com semântica rigorosa. Ela organiza hierarquicamente um módulo de Embeeding e quatro estágios de DOT. O novo módulo de atenção semanticamente expandido agrega informações semânticas de baixo nível de cada dois estágios consecutivos de DOT. 

Ao processar mapas de características de alta resolução para previsão densa, os blocos transformadores comuns reduzem os custos computacionais substituindo a camada de Self-Attention com múltiplas cabeças (MSA) pela camada de atenção espacial local e Self-Attention com múltiplas cabeças de janela deslocada (SW-MSA). No entanto, tal estrutura reduz a performance de detecção, pois extrai apenas objetos multiescalares com semântica de baixo nível limitada.

Para mitigar essa limitação, os autores do método DFFT adicionam ao bloco DOT vários blocos de SW-MSA e um bloco global de atenção por canais. Observe que cada bloco de atenção contém uma camada de atenção e uma camada de FFN.

Os autores do método descobriram que colocar uma camada leve de atenção por canais após camadas sucessivas de atenção espacial local pode ajudar na extração da semântica do objeto em cada escala.

Enquanto o bloco DOT melhora as informações semânticas em características de baixo nível por meio da atenção global por canais, a semântica pode ser ainda mais aprimorada para melhorar a tarefa de detecção. Para isso, os autores do método propõem um novo módulo de atenção semanticamente aumentada (Semantic-Augmented Attention — SAA), que troca informações semânticas entre dois níveis consecutivos de DOT e complementa suas características. SAA consiste em uma camada de upsampling e um bloco global de atenção por canais. Os autores do método adicionam SAA a cada dois blocos consecutivos de DOT. Formalmente, SAA aceita os resultados do trabalho do bloco atual de DOT e da etapa anterior de DOT, e depois retorna uma função semanticamente expandida, que é enviada para a próxima etapa de DOT e também contribui para as características multiescalares finais.

Em geral, a etapa orientada para a detecção consiste em quatro camadas de DOT, onde cada etapa inclui um bloco de DOT e um módulo de SAA (exceto a primeira etapa). Em particular, a primeira etapa contém um bloco DOT e não contém o módulo SAA, pois as entradas do módulo SAA vêm de duas etapas consecutivas DOT. Em seguida, há uma camada de amostragem reduzida para restaurar a dimensão de entrada.

O próximo módulo é projetado para melhorar tanto a inferência quanto a eficiência do treinamento do modelo DFFT. Primeiro, é utilizado o codificador de escala agregada (Scale-Aggregated Encoder — SAE) para agregar objetos multiescalares da coluna vertebral DOT em um único mapa de objetos Ssae.

Em seguida, é utilizado o codificador alinhado por tarefa (Task-Aligned Encoder — TAE) para criar uma função de classificação alinhada 𝒕cls e uma função de regressão 𝒕reg simultaneamente em uma única cabeça.

O codificador de escala agregada é construído a partir de 3 blocos SAE. Cada bloco SAE aceita como dados de entrada dois objetos e os agrega passo a passo em todos os blocos SAE. Os autores do método usam a escala de agregação final dos objetos para equilibrar a precisão da detecção com os custos computacionais.

Normalmente, os detectores realizam a classificação e a localização dos objetos independentemente, usando dois ramos separados (cabeças não relacionadas). Essa estrutura de dois ramos não considera a interação entre as duas tarefas e leva a previsões inconsistentes. Enquanto isso, ao estudar características para as duas tarefas em uma cabeça conjunta, geralmente existem conflitos. Os autores do DFFT propõem o uso de um codificador orientado por tarefa, que proporciona um melhor equilíbrio entre o aprendizado de funções interativas e funções específicas da tarefa, combinando blocos de atenção de grupo por canais em uma cabeça conjunta.

Esse codificador é composto por dois tipos de blocos de atenção por canais. Primeiro, blocos de atenção de grupo multinível por canais alinham e dividem os objetos agregados Ssae em 2 partes. Em segundo lugar, os blocos de atenção globais por canais codificam um dos dois objetos divididos para a tarefa de regressão seguinte. 

Em particular, as diferenças entre o bloco de atenção de grupo por canais e o bloco de atenção global por canais são que todas as projeções lineares, exceto as projeções de inserções Query/Key/Value no bloco de atenção de grupo por canais, são realizadas em dois grupos. Assim, as características interagem nas operações de atenção, enquanto são projetadas separadamente nas projeções de saída.

A visualização do método pelos autores pode ser encontrada aqui: visualização.

2. Implementação com MQL5

Após considerar os aspectos teóricos do método Decoder-Free Fully Transformer-based (DFFT), passamos à implementação prática dos métodos propostos utilizando MQL5. No entanto, nosso modelo será ligeiramente diferente do original. Isso ocorre porque, ao desenvolvê-lo, consideramos as diferenças entre as tarefas específicas de visão computacional para as quais o método foi projetado e o trabalho nos mercados financeiros, para o qual estamos criando nosso modelo.

2.1 Construção do bloco DOT

E, ao iniciar, é importante observar que os métodos propostos são bastante distintos dos modelos que desenvolvemos anteriormente. Por exemplo, o bloco DOT difere dos blocos de atenção que examinamos antes. Assim, começamos nosso trabalho com a criação de uma nova camada neural chamada CNeuronDOTOCL. Nossa nova camada será criada como herdeira da nossa classe base de camadas neurais CNeuronBaseOCL.

Semelhante a outros blocos de atenção, adicionaremos variáveis para armazenar os parâmetros-chave:

  • iWindowSize — tamanho da janela de um elemento da sequência;
  • iPrevWindowSize — tamanho da janela de um elemento da sequência da camada anterior;
  • iDimension — tamanho do vetor de entidades internas Query, Key e Value;
  • iUnits — número de elementos na sequência;
  • iHeads — número de cabeças de atenção.

Acredito que você notou a variável iPrevWindowSize. Sua adição permitirá implementar a capacidade de compressão de dados de camada para camada, conforme previsto pelo método DFFT.

Além disso, para minimizar o trabalho diretamente na nova classe e maximizar o uso dos trabalhos anteriores, parte da funcionalidade será implementada utilizando camadas neurais aninhadas da nossa biblioteca. Nos familiarizaremos com essa funcionalidade durante a implementação dos métodos de propagação para frente e reversa.

class CNeuronDOTOCL     :  public CNeuronBaseOCL
  {
protected:
   uint              iWindowSize;
   uint              iPrevWindowSize;
   uint              iDimension;
   uint              iUnits;
   uint              iHeads;
   //---
   CNeuronConvOCL    cProjInput;
   CNeuronConvOCL    cQKV;
   int               iScoreBuffer;
   CNeuronBaseOCL    cRelativePositionsBias;
   CNeuronBaseOCL    MHAttentionOut;
   CNeuronConvOCL    cProj;
   CNeuronBaseOCL    AttentionOut;
   CNeuronConvOCL    cFF1;
   CNeuronConvOCL    cFF2;
   CNeuronBaseOCL    SAttenOut;
   CNeuronXCiTOCL    cCAtten;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   virtual bool      DOT(void);
   //---
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);
   virtual bool      updateRelativePositionsBias(void);
   virtual bool      DOTInsideGradients(void);

public:
                     CNeuronDOTOCL(void) {};
                    ~CNeuronDOTOCL(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint dimension, uint heads,
                          uint units_count, uint prev_window,
                          ENUM_OPTIMIZATION optimization_type,
                          uint batch);
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer);
   //---
   virtual int       Type(void)   const   {  return defNeuronDOTOCL;   }
   //--- methods for working with files
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   virtual CLayerDescription* GetLayerInfo(void);
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

Em geral, a lista de métodos sobrescritos é bastante padrão.

No corpo da classe, usaremos objetos estáticos. Isso nos permite deixar o construtor e o destrutor da classe vazios.

A inicialização da classe é realizada no método Init. Nos parâmetros do método, transmitiremos todas as informações necessárias. O controle mínimo necessário é realizado no método correspondente da classe pai. É também nesse método que ocorre a inicialização dos objetos herdados.

bool CNeuronDOTOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, 
                         uint dimension, uint heads, uint units_count, uint prev_window, 
                         ENUM_OPTIMIZATION optimization_type, uint batch)
  {
//---
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, 
                                                      optimization_type, batch))
      return false;

Em seguida, verificamos se o tamanho dos dados de entrada e os parâmetros da camada atual estão certos. Se necessário, inicializamos a camada de escalonamento de dados.

   if(prev_window != window)
     {
      if(!cProjInput.Init(0, 0, OpenCL, prev_window, prev_window, window, units_count, 
                                                              optimization_type, batch))
         return false;
     }

Depois, salvamos as constantes principais recebidas do programa chamador, que definem a arquitetura da camada, em variáveis internas da classe.

   iWindowSize = window;
   iPrevWindowSize = prev_window;
   iDimension = dimension;
   iHeads = heads;
   iUnits = units_count;

Após isso, inicializamos sequencialmente todos os objetos internos. Primeiro, inicializamos a camada de geração de entidades Query, Key e Value. Todas as 3 entidades serão geradas paralelamente no corpo de uma única camada neural cQKV.

   if(!cQKV.Init(0, 1, OpenCL, window, window, dimension * heads, units_count, 
                                                      optimization_type, batch))
      return false;

Depois, criaremos um buffer para gravar os coeficientes de dependência dos objetos iScoreBuffer. Aqui vale mencionar que no bloco DOT, primeiro analisamos a semântica local. Para isso, verificaremos a dependência entre o objeto e seus 2 vizinhos mais próximos. Portanto, o tamanho do buffer Score será definido como iUnits * iHeads * 3.

Além disso, os coeficientes salvos no buffer são recalculados a cada propagação para frente. E são usados apenas na próxima propagação reversa. Por isso, os dados do buffer não serão salvos no arquivo de salvamento do modelo. Além disso, não criaremos o buffer na memória do programa principal. Será suficiente criar o buffer na memória do contexto OpenCL. No lado do programa principal, manteremos apenas o ponteiro para o buffer.

//---
   iScoreBuffer = OpenCL.AddBuffer(sizeof(float) * iUnits * iHeads * 3, CL_MEM_READ_WRITE);
   if(iScoreBuffer < 0)
      return false;

No mecanismo de Self-Attention com janelas, ao contrário do transformador clássico, cada token interage apenas com tokens dentro de uma janela específica. Isso reduz significativamente a complexidade computacional. No entanto, essa limitação também significa que os modelos devem levar em conta as posições relativas dos tokens dentro da janela. Para implementar essa funcionalidade, são introduzidos parâmetros treináveis cRelativePositionsBias. Para cada par de tokens (i, j) dentro da janela iWindowSize, cRelativePositionsBias contém um peso que determina a importância da interação entre esses tokens com base em sua posição relativa.

É fácil deduzir que o tamanho desse buffer é igual ao tamanho do buffer de coeficientes Score. No entanto, para o treinamento dos parâmetros, além do buffer dos próprios valores, também precisaremos de buffers adicionais. Com o objetivo de reduzir o número de objetos internos e a legibilidade do código, para cRelativePositionsBias declararemos um objeto de camada neural que contém todos os buffers adicionais.

   if(!cRelativePositionsBias.Init(1, 2, OpenCL, iUnits * iHeads * 3, optimization_type, batch))
      return false;

Da mesma forma, adicionaremos os outros elementos do mecanismo Self-Attention.

   if(!MHAttentionOut.Init(0, 3, OpenCL, iUnits * iHeads * iDimension, optimization_type, batch))
      return false;
   if(!cProj.Init(0, 4, OpenCL, iHeads * iDimension, iHeads * iDimension, window, iUnits, 
                                                                       optimization_type, batch))
      return false;
   if(!AttentionOut.Init(0, 5, OpenCL, iUnits * window, optimization_type, batch))
      return false;
   if(!cFF1.Init(0, 6, OpenCL, window, window, 4 * window, units_count, optimization_type,batch))
      return false;
   if(!cFF2.Init(0, 7, OpenCL, window * 4, window * 4, window, units_count, optimization_type, 
                                                                                          batch))
      return false;
   if(!SAttenOut.Init(0, 8, OpenCL, iUnits * window, optimization_type, batch))
      return false;

Como bloco de atenção global, utilizaremos a camada CNeuronXCiTOCL.

   if(!cCAtten.Init(0, 9, OpenCL, window, MathMax(window / 2, 3), 8, iUnits, 1, 
                                                       optimization_type, batch))
      return false;

Para minimizar as operações de cópia de dados entre os buffers, realizaremos a substituição de objetos e buffers.

   if(!!Output)
      delete Output;
   Output = cCAtten.getOutput();
   if(!!Gradient)
      delete Gradient;
   Gradient = cCAtten.getGradient();
   SAttenOut.SetGradientIndex(cFF2.getGradientIndex());
//---
   return true;
  }

E concluímos o trabalho do método.

Após a inicialização da classe, passamos para a construção do algoritmo de propagação para frente. E aqui começaremos a trabalhar com a organização do mecanismo Self-Attention com janelas no lado OpenCL do programa. Para isso, criaremos um kernel DOTFeedForward. Nos parâmetros do kernel, passaremos ponteiros para 4 buffers de dados:

  • qkv — buffer de entidades Query, Key e Value,
  • score — buffer de coeficientes de dependência,
  • rpb — buffer de deslocamentos posicionais,
  • out — buffer dos resultados do Self-Attention com janelas multiconectadas.

__kernel void DOTFeedForward(__global float *qkv,
                             __global float *score,
                             __global float *rpb,
                             __global float *out)
  {
   const size_t d = get_local_id(0);
   const size_t dimension = get_local_size(0);
   const size_t u = get_global_id(1);
   const size_t units = get_global_size(1);
   const size_t h = get_global_id(2);
   const size_t heads = get_global_size(2);

Planejamos executar o kernel em um espaço tridimensional de tarefas. E no corpo do kernel, identificamos imediatamente o fluxo em todas as 3 dimensões. Aqui é importante notar que na primeira dimensão das entidades Query, Key e Value criamos um grupo de trabalho com uso conjunto do buffer na memória local.

Em seguida, definimos os deslocamentos nos buffers de dados até o início dos objetos analisados.

   uint step = 3 * dimension * heads;
   uint start = max((int)u - 1, 0);
   uint stop = min((int)u + 1, (int)units - 1);
   uint shift_q = u * step + h * dimension;
   uint shift_k = start * step + dimension * (heads + h);
   uint shift_score = u * 3 * heads;

E também criamos um buffer local para troca de dados entre os fluxos de um grupo de trabalho.

   const uint ls_d = min((uint)dimension, (uint)LOCAL_ARRAY_SIZE);
   __local float temp[LOCAL_ARRAY_SIZE][3];

Como mencionado anteriormente, a semântica local é determinada pelos 2 vizinhos mais próximos do objeto. Primeiro, determinamos a influência dos vizinhos no objeto analisado. Calculamos os coeficientes de dependência dentro do grupo de trabalho. Primeiro, multiplicamos os elementos das entidades Query e Key em fluxos paralelos.

//--- Score
   if(d < ls_d)
     {
      for(uint pos = start; pos <= stop; pos++)
        {
         temp[d][pos - start] = 0;
        }
      for(uint dim = d; dim < dimension; dim += ls_d)
        {
         float q = qkv[shift_q + dim];
         for(uint pos = start; pos <= stop; pos++)
           {
            uint i = pos - start;
            temp[d][i] = temp[d][i] + q * qkv[shift_k + i * step + dim];
           }
        }
      barrier(CLK_LOCAL_MEM_FENCE);

Em seguida, somamos os produtos obtidos.

      int count = ls_d;
      do
        {
         count = (count + 1) / 2;
         if(d < count && (d + count) < dimension)
            for(uint i = 0; i <= (stop - start); i++)
              {
               temp[d][i] += temp[d + count][i];
               temp[d + count][i] = 0;
              }
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      while(count > 1);
     }

Aos valores obtidos, adicionamos os parâmetros de deslocamento e normalizamos com a função SoftMax.

   if(d == 0)
     {
      float sum = 0;
      for(uint i = 0; i <= (stop - start); i++)
        {
         temp[0][i] = exp(temp[0][i] + rpb[shift_score + i]);
         sum += temp[0][i];
        }
      for(uint i = 0; i <= (stop - start); i++)
        {
         temp[0][i] = temp[0][i] / sum;
         score[shift_score + i] = temp[0][i];
        }
     }
   barrier(CLK_LOCAL_MEM_FENCE);

Salvamos o resultado no buffer de coeficientes de dependência.

Agora podemos multiplicar os coeficientes obtidos pelos elementos correspondentes da entidade Value para determinar os resultados do bloco de Self-Attention com janelas.

   int shift_out = dimension * (u * heads + h) + d;
   int shift_v = dimension * (heads * (u * 3 + 2) + h);
   float sum = 0;
   for(uint i = 0; i <= (stop - start); i++)
      sum += qkv[shift_v + i] * temp[0][i];
   out[shift_out] = sum;
  }

Os valores obtidos são salvos nos elementos correspondentes do buffer de resultados e o kernel é concluído.

Após criar o kernel, voltamos ao nosso programa principal, onde criamos os métodos da nossa nova classe CNeuronDOTOCL. Primeiro, criaremos o método DOT, que coloca o kernel recém-criado na fila de execução.

O algoritmo do método é bastante simples. Apenas transmitimos os parâmetros externos ao kernel.

bool CNeuronDOTOCL::DOT(void)
  {
   if(!OpenCL)
      return false;
//---
   uint global_work_offset[3] = {0, 0, 0};
   uint global_work_size[3] = {iDimension, iUnits, iHeads};
   uint local_work_size[3] = {iDimension, 1, 1};
   if(!OpenCL.SetArgumentBuffer(def_k_DOTFeedForward, def_k_dot_qkv, cQKV.getOutputIndex()))
      return false;
   if(!OpenCL.SetArgumentBuffer(def_k_DOTFeedForward, def_k_dot_score, iScoreBuffer))
      return false;
   if(!OpenCL.SetArgumentBuffer(def_k_DOTFeedForward, def_k_dot_rpb, 
                                                    cRelativePositionsBias.getOutputIndex()))
      return false;
   if(!OpenCL.SetArgumentBuffer(def_k_DOTFeedForward, def_k_dot_out,
                                                            MHAttentionOut.getOutputIndex()))
      return false;

Depois, enviamos o kernel para a fila de execução.

   ResetLastError();
   if(!OpenCL.Execute(def_k_DOTFeedForward, 3, global_work_offset, global_work_size,
                                                                     local_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }
//---
   return true;
  }

Lembrando de controlar o processo de execução das operações em cada etapa.

Após concluir o trabalho preparatório, passamos a criar o método CNeuronDOTOCL::feedForward, no qual definiremos em alto nível o algoritmo de propagação para frente da nossa camada.

Nos parâmetros do método, recebemos um ponteiro para a camada anterior da rede neural. Para conveniência, salvamos o ponteiro recebido em uma variável local.

bool CNeuronDOTOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   CNeuronBaseOCL* inputs = NeuronOCL;

Em seguida, verificamos se o tamanho dos dados de entrada difere dos parâmetros da camada atual. Se necessário, escalonamos os dados de entrada e calculamos as entidades Query, Key e Value.

Se os buffers de dados forem iguais, omitimos a etapa de escalonamento e geramos imediatamente as entidades Query, Key e Value.

   if(iPrevWindowSize != iWindowSize)
     {
      if(!cProjInput.FeedForward(inputs) ||
         !cQKV.FeedForward(GetPointer(cProjInput)))
         return false;
      inputs = GetPointer(cProjInput);
     }
   else
      if(!cQKV.FeedForward(inputs))
         return false;

Na etapa seguinte, chamamos o método criado anteriormente de Self-Attention com janelas.

   if(!DOT())
      return false;

Reduzimos a dimensionalidade dos dados.

   if(!cProj.FeedForward(GetPointer(MHAttentionOut)))
      return false;

E somamos o resultado com o buffer dos dados de entrada.

   if(!SumAndNormilize(inputs.getOutput(), cProj.getOutput(), AttentionOut.getOutput(), 
                                                                     iWindowSize, true))
      return false;

Passamos o resultado pelo bloco FeedForward.

   if(!cFF1.FeedForward(GetPointer(AttentionOut)))
      return false;
   if(!cFF2.FeedForward(GetPointer(cFF1)))
      return false;

E novamente somamos os buffers de resultados. Desta vez com a saída do bloco de Self-Attention com janelas.

   if(!SumAndNormilize(AttentionOut.getOutput(), cFF2.getOutput(), SAttenOut.getOutput(),
                                                                        iWindowSize, true))
      return false;

No final do bloco, há uma etapa de Self-Attention global. Para isso, utilizamos a camada CNeuronXCiTOCL.

   if(!cCAtten.FeedForward(GetPointer(SAttenOut)))
      return false;
//---
   return true;
  }

Verificamos os resultados das operações e concluímos o método.

Com isso, finalizamos a implementação da propagação para frente da nossa classe e passamos a trabalhar nos métodos de para propagação reversa. Aqui, começamos criando o kernel de para propagação reversa do bloco Self-Attention com janelas DOTInsideGradients. Assim como o kernel de propagação para frente, o novo kernel será executado em um espaço tridimensional de tarefas. Desta vez, sem a criação de grupos locais.

Nos parâmetros, o kernel recebe ponteiros para todos os buffers de dados necessários.

__kernel void DOTInsideGradients(__global float *qkv, __global float *qkv_g,
                                 __global float *scores,
                                 __global float *rpb, __global float *rpb_g,
                                 __global float *gradient)
  {
//--- init
   const uint u = get_global_id(0);
   const uint d = get_global_id(1);
   const uint h = get_global_id(2);
   const uint units = get_global_size(0);
   const uint dimension = get_global_size(1);
   const uint heads = get_global_size(2);

No corpo do kernel, identificamos imediatamente o fluxo em todas as 3 dimensões. E definimos o espaço de tarefas, que nos indicará a dimensionalidade dos buffers recebidos.

Aqui também definimos os deslocamentos nos buffers de dados.

   uint step = 3 * dimension * heads;
   uint start = max((int)u - 1, 0);
   uint stop = min((int)u + 1, (int)units - 1);
   const uint shift_q = u * step + dimension * h + d;
   const uint shift_k = u * step + dimension * (heads + h) + d;
   const uint shift_v = u * step + dimension * (2 * heads + h) + d;

Depois disso, passamos à distribuição do gradiente. Primeiro, determinamos o gradiente do erro para o elemento Value. Para isso, multiplicamos o gradiente recebido pelo coeficiente de influência correspondente.

//--- Calculating Value's gradients
   float sum = 0;
   for(uint i = start; i <= stop; i ++)
     {
      int shift_score = i * 3 * heads;
      if(u == i)
        {
         shift_score += (uint)(u > 0);
        }
      else
        {
         if(u > i)
            shift_score += (uint)(start > 0) + 1;
        }
      uint shift_g = dimension * (i * heads + h) + d;
      sum += gradient[shift_g] * scores[shift_score];
     }
   qkv_g[shift_v] = sum;

Na próxima etapa, determinamos o gradiente do erro para a entidade Query. Aqui o algoritmo é um pouco mais complexo. Primeiro, precisamos determinar o gradiente do erro para o vetor de coeficientes de dependência correspondente. Ajustar o gradiente recebido pela derivada da função SoftMax. E só depois disso, podemos multiplicar o gradiente de erro dos coeficientes de dependência pelo elemento correspondente do tensor de entidades Key.

Aqui é importante notar que antes da normalização dos coeficientes de dependência, somamos esses coeficientes com os elementos do deslocamento posicional da atenção. Como você sabe, ao somar, o gradiente é transmitido integralmente em ambas as direções. A dupla contagem do erro é facilmente neutralizada por um pequeno coeficiente de aprendizado. Portanto, transferimos o gradiente de erro no nível da matriz de coeficientes de dependência para o buffer de gradientes de erro do deslocamento posicional.

//--- Calculating Query's gradients
   float grad = 0;
   uint shift_score = u * heads * 3;
   for(int k = start; k <= stop; k++)
     {
      float sc_g = 0;
      float sc = scores[shift_score + k - start];
      for(int v = start; v <= stop; v++)
         for(int dim=0;dim<dimension;dim++)
            sc_g += scores[shift_score + v - start] * 
                    qkv[v * step + dimension * (2 * heads + h) + dim] * 
                    gradient[dimension * (u * heads + h) + dim] * 
                    ((float)(k == v) - sc);
      grad += sc_g * qkv[k * step + dimension * (heads + h) + d];
      if(d == 0)
         rpb_g[shift_score + k - start] = sc_g;
     }
   qkv_g[shift_q] = grad;

Em seguida, precisamos determinar da mesma forma o gradiente de erro para a entidade Key. O algoritmo é semelhante ao Query, mas em outra dimensão da matriz de coeficientes.

//--- Calculating Key's gradients
   grad = 0;
   for(int q = start; q <= stop; q++)
     {
      float sc_g = 0;
      shift_score = q * heads * 3;
      if(u == q)
        {
         shift_score += (uint)(u > 0);
        }
      else
        {
         if(u > q)
            shift_score += (uint)(start > 0) + 1;
        }
      float sc = scores[shift_score];
      for(int v = start; v <= stop; v++)
        {
         shift_score = v * heads * 3;
         if(u == v)
           {
            shift_score += (uint)(u > 0);
           }
         else
           {
            if(u > v)
               shift_score += (uint)(start > 0) + 1;
           }
         for(int dim=0;dim<dimension;dim++)
            sc_g += scores[shift_score] * qkv[shift_v-d+dim] * 
                    gradient[dimension * (v * heads + h) + d] * ((float)(d == v) - sc);
        }
      grad += sc_g * qkv[q * step + dimension * h + d];
     }
   qkv_g[shift_k] = grad;
  }

Com isso, concluímos o trabalho com o kernel e retornamos ao trabalho com nossa classe CNeuronDOTOCL, na qual criaremos o método DOTInsideGradients para chamar o kernel criado anteriormente. O algoritmo permanece o mesmo:

  • definimos o espaço de tarefas

bool CNeuronDOTOCL::DOTInsideGradients(void)
  {
   if(!OpenCL)
      return false;
//---
   uint global_work_offset[3] = {0, 0, 0};
   uint global_work_size[3] = {iUnits, iDimension, iHeads};

  • passamos os parâmetros

   if(!OpenCL.SetArgumentBuffer(def_k_DOTInsideGradients, def_k_dotg_qkv, 
                                                                   cQKV.getOutputIndex()))
      return false;
   if(!OpenCL.SetArgumentBuffer(def_k_DOTInsideGradients, def_k_dotg_qkv_g, 
                                                                  cQKV.getGradientIndex()))
      return false;
   if(!OpenCL.SetArgumentBuffer(def_k_DOTInsideGradients, def_k_dotg_scores, iScoreBuffer))
      return false;
   if(!OpenCL.SetArgumentBuffer(def_k_DOTInsideGradients, def_k_dotg_rpb, 
                                                  cRelativePositionsBias.getOutputIndex()))
      return false;
   if(!OpenCL.SetArgumentBuffer(def_k_DOTInsideGradients, def_k_dotg_rpb_g, 
                                                cRelativePositionsBias.getGradientIndex()))
      return false;
   if(!OpenCL.SetArgumentBuffer(def_k_DOTInsideGradients, def_k_dotg_gradient,
                                                        MHAttentionOut.getGradientIndex()))
      return false;

  • colocamos na fila de execução

   ResetLastError();
   if(!OpenCL.Execute(def_k_DOTInsideGradients, 3, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }
//---
   return true;
  }

  • verificamos o resultado das operações e concluímos o método.

Descrevemos o algoritmo de para propagação reversa de forma abrangente no método calcInputGradients. Nos parâmetros, esse método recebe um ponteiro para o objeto da camada anterior, para a qual passaremos o gradiente de erro. No corpo do método, verificamos imediatamente a validade do ponteiro recebido. Se o ponteiro for inválido, não temos para onde passar o gradiente de erro. E o sentido lógico de todas as operações é próximo de "0".

bool CNeuronDOTOCL::calcInputGradients(CNeuronBaseOCL *prevLayer)
  {
   if(!prevLayer)
      return false;

Em seguida, repetimos as operações do feedforward em ordem inversa. Na inicialização da nossa classe CNeuronDOTOCL, fizemos a substituição dos buffers. Agora, ao receber o gradiente de erro da camada neural subsequente, ele é recebido diretamente na camada de atenção global. Portanto, pulamos as operações desnecessárias de cópia de dados e chamamos imediatamente o método homônimo da camada interna de atenção global.

   if(!cCAtten.calcInputGradients(GetPointer(SAttenOut)))
      return false;

Aqui também utilizamos a substituição dos buffers e passamos imediatamente o gradiente de erro pelo bloco FeedForward.

   if(!cFF2.calcInputGradients(GetPointer(cFF1)))
      return false;
   if(!cFF1.calcInputGradients(GetPointer(AttentionOut)))
      return false;

Depois, somamos o gradiente de erro de dois fluxos.

   if(!SumAndNormilize(AttentionOut.getGradient(), SAttenOut.getGradient(), 
                                   cProj.getGradient(), iWindowSize, false))
      return false;

E distribuímos entre as cabeças de atenção.

   if(!cProj.calcInputGradients(GetPointer(MHAttentionOut)))
      return false;

Chamamos nosso método de distribuição do gradiente de erro através do bloco Self-Attention com janelas.

   if(!DOTInsideGradients())
      return false;

Após isso, verificamos a correspondência dos tamanhos das camadas anterior e atual. Se necessário, escalonamos os dados de erro para a camada de escalonamento. Somamos os gradientes de erro de dois fluxos. E só então escalonamos o gradiente de erro para a camada anterior.

   if(iPrevWindowSize != iWindowSize)
     {
      if(!cQKV.calcInputGradients(GetPointer(cProjInput)))
         return false;
      if(!SumAndNormilize(cProjInput.getGradient(), cProj.getGradient(),
                          cProjInput.getGradient(), iWindowSize, false))
         return false;
      if(!cProjInput.calcInputGradients(prevLayer))
         return false;
     }

No caso de igualdade das camadas neurais, passamos imediatamente o gradiente de erro para a camada anterior. E depois complementamos com o gradiente de erro do segundo fluxo.

   else
     {
      if(!cQKV.calcInputGradients(prevLayer))
         return false;
      if(!SumAndNormilize(prevLayer.getGradient(), cProj.getGradient(),
                          prevLayer.getGradient(), iWindowSize, false))
         return false;
     }
//---
   return true;
  }

Após distribuir o gradiente de erro entre todas as camadas neurais, precisamos atualizar os parâmetros do modelo para minimizar o erro. E isso seria trivial, se não fosse por um detalhe. Lembra do buffer dos parâmetros de influência posicional dos elementos? Precisamos atualizar seus parâmetros. Para realizar essa funcionalidade, criamos o kernel RPBUpdateAdam. Nos parâmetros, passamos ao kernel ponteiros para o buffer dos parâmetros atuais, gradiente de erro. Bem como tensores auxiliares e constantes do método Adam.

__kernel void RPBUpdateAdam(__global float *target,
                             __global const float *gradient,
                             __global float *matrix_m,   ///<[in,out] Matrix of first momentum
                             __global float *matrix_v,   ///<[in,out] Matrix of seconfd momentum
                             const float b1,             ///< First momentum multiplier
                             const float b2              ///< Second momentum multiplier
                            )
  {
   const int i = get_global_id(0);

No corpo do kernel, identificamos o fluxo, que nos indica o deslocamento nos buffers de dados.

Em seguida, declaramos variáveis locais e salvamos nelas os valores necessários dos buffers globais.

   float m, v, weight;
   m = matrix_m[i];
   v = matrix_v[i];
   weight = target[i];
   float g = gradient[i];

De acordo com o método Adam, primeiro determinamos os momentos.

   m = b1 * m + (1 - b1) * g;
   v = b2 * v + (1 - b2) * pow(g, 2);

Com base nos momentos obtidos, calculamos a correção necessária do parâmetro.

   float delta = m / (v != 0.0f ? sqrt(v) : 1.0f);

E salvamos todos os dados nos elementos correspondentes dos buffers globais.

   target[i] = clamp(weight + delta, -MAX_WEIGHT, MAX_WEIGHT);
   matrix_m[i] = m;
   matrix_v[i] = v;
  }

Voltamos à nossa classe CNeuronDOTOCL e criamos o método updateRelativePositionsBias para chamar o kernel, seguindo o esquema clássico. Aqui utilizamos um espaço de tarefas unidimensional.

bool CNeuronDOTOCL::updateRelativePositionsBias(void)
  {
   if(!OpenCL)
      return false;
//---
   uint global_work_offset[1] = {0};
   uint global_work_size[1] = {cRelativePositionsBias.Neurons()};
   if(!OpenCL.SetArgumentBuffer(def_k_RPBUpdateAdam, def_k_rpbw_rpb, 
                                         cRelativePositionsBias.getOutputIndex()))
      return false;
   if(!OpenCL.SetArgumentBuffer(def_k_RPBUpdateAdam, def_k_rpbw_gradient, 
                                         cRelativePositionsBias.getGradientIndex()))
      return false;
   if(!OpenCL.SetArgumentBuffer(def_k_RPBUpdateAdam, def_k_rpbw_matrix_m, 
                                    cRelativePositionsBias.getFirstMomentumIndex()))
      return false;
   if(!OpenCL.SetArgumentBuffer(def_k_RPBUpdateAdam, def_k_rpbw_matrix_v, 
                                   cRelativePositionsBias.getSecondMomentumIndex()))
      return false;
   if(!OpenCL.SetArgument(def_k_RPBUpdateAdam, def_k_rpbw_b1, b1))
      return false;
   if(!OpenCL.SetArgument(def_k_RPBUpdateAdam, def_k_rpbw_b2, b2))
      return false;
   ResetLastError();
   if(!OpenCL.Execute(def_k_RPBUpdateAdam, 1, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }
//---
   return true;
  }

O trabalho preparatório está concluído. E passamos à criação do método de alto nível para atualização dos parâmetros do bloco updateInputWeights. Nos parâmetros, o método recebe um ponteiro para o objeto da camada anterior. Nesse caso, pulamos a verificação do ponteiro recebido, pois ela será realizada nos métodos das camadas internas.

Primeiro, verificamos a necessidade de atualizar os parâmetros da camada de escalonamento. E, se necessário, chamamos o método homônimo da camada especificada.

   if(iWindowSize != iPrevWindowSize)
     {
      if(!cProjInput.UpdateInputWeights(NeuronOCL))
         return false;
      if(!cQKV.UpdateInputWeights(GetPointer(cProjInput)))
         return false;
     }
   else
     {
      if(!cQKV.UpdateInputWeights(NeuronOCL))
         return false;
     }

Depois, atualizamos os parâmetros da camada de geração de entidades Query, Key e Value.

Da mesma forma, atualizamos os parâmetros de todas as camadas internas.

   if(!cProj.UpdateInputWeights(GetPointer(MHAttentionOut)))
      return false;
   if(!cFF1.UpdateInputWeights(GetPointer(AttentionOut)))
      return false;
   if(!cFF2.UpdateInputWeights(GetPointer(cFF1)))
      return false;
   if(!cCAtten.UpdateInputWeights(GetPointer(SAttenOut)))
      return false;

E, ao final do método, atualizamos os parâmetros do deslocamento posicional.

   if(!updateRelativePositionsBias())
      return false;
//---
   return true;
  }

Ao fazer isso, não esquecemos de controlar o processo de execução das operações em cada etapa.

Com isso, concluímos a revisão dos métodos da nova camada neural CNeuronDOTOCL. Você pode encontrar o código completo da classe e todos os seus métodos, incluindo os que não foram descritos neste artigo, no anexo.

Agora, seguimos adiante e passamos à construção da arquitetura do nosso novo modelo.

2.2 Arquitetura do modelo

Como de costume, a arquitetura do nosso modelo será descrita no método CreateDescriptions. Nos parâmetros, o método recebe ponteiros para 3 arrays dinâmicos para salvar a descrição dos modelos. No corpo do método, verificamos imediatamente a validade dos ponteiros recebidos e, se necessário, criamos novas instâncias dos arrays.

bool CreateDescriptions(CArrayObj *dot, CArrayObj *actor, CArrayObj *critic)
  {
//---
   CLayerDescription *descr;
//---
   if(!dot)
     {
      dot = new CArrayObj();
      if(!dot)
         return false;
     }
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
   if(!critic)
     {
      critic = new CArrayObj();
      if(!critic)
         return false;
     }

Como você pode ter notado, criaremos 3 modelos:

  • DOT
  • Actor
  • Critic.

O bloco DOT está previsto na arquitetura DFFT, o que não pode ser dito do Actor e do Critic. Mas quero lembrar que o método DFFT propõe a criação do bloco TAE com saídas de classificação e regressão. O uso sequencial de Actor e Critic deve emular o bloco TAE. O Actor é um classificador de ações, e o Critic é uma regressão de recompensas.

Alimentamos o modelo DOT com a descrição do estado atual do ambiente.

//--- DOT
   dot.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!dot.Add(descr))
     {
      delete descr;
      return false;
     }

Os dados "brutos" são processados na camada de normalização por lote.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = MathMax(1000, GPTBars);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!dot.Add(descr))
     {
      delete descr;
      return false;
     }

Depois, criamos um embedding dos últimos dados e os adicionamos à pilha.

   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronEmbeddingOCL;
     {
      int temp[] = {prev_count};
      ArrayCopy(descr.windows, temp);
     }
   prev_count = descr.count = GPTBars;
   int prev_wout = descr.window_out = EmbeddingSize;
   if(!dot.Add(descr))
     {
      delete descr;
      return false;
     }

Em seguida, adicionamos a codificação posicional dos dados.

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronPEOCL;
   descr.count = prev_count;
   descr.window = prev_wout;
   if(!dot.Add(descr))
     {
      delete descr;
      return false;
     }

Até esse ponto, repetimos a arquitetura de embeddings de trabalhos anteriores. Mas a partir daí começam as mudanças. Adicionamos o primeiro bloco DOT, onde é realizada a análise de estados individuais.

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronDOTOCL;
   descr.count = prev_count;
   descr.window = prev_wout;
   descr.step  =  4;
   descr.window_out = prev_wout / descr.step;
   if(!dot.Add(descr))
     {
      delete descr;
      return false;
     }

No próximo bloco, comprimimos os dados pela metade, mas continuamos a análise de estados individuais.

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronDOTOCL;
   descr.count = prev_count;
   prev_wout = descr.window = prev_wout / 2;
   descr.step  =  4;
   descr.window_out = prev_wout / descr.step;
   if(!dot.Add(descr))
     {
      delete descr;
      return false;
     }

Em seguida, agrupamos os dados para análise em grupos de 2 estados consecutivos.

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronDOTOCL;
   prev_count = descr.count = prev_count / 2;
   prev_wout = descr.window = prev_wout * 2;
   descr.step  =  4;
   descr.window_out = prev_wout / descr.step;
   if(!dot.Add(descr))
     {
      delete descr;
      return false;
     }

E comprimimos os dados mais uma vez.

//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronDOTOCL;
   descr.count = prev_count;
   prev_wout = descr.window = prev_wout / 2;
   descr.step  =  4;
   descr.window_out = prev_wout / descr.step;
   if(!dot.Add(descr))
     {
      delete descr;
      return false;
     }

A última camada do modelo DOT vai além do método DFFT. Aqui, adicionei uma camada de cross-attention.

//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMH2AttentionOCL;
   descr.count = prev_wout;
   descr.window = prev_count;
   descr.step = 4;
   descr.window_out = prev_wout / descr.step;
   descr.optimization = ADAM;
   if(!dot.Add(descr))
     {
      delete descr;
      return false;
     }

O modelo Actor recebe como entrada os estados do ambiente processados no modelo DOT.

//--- Actor
   actor.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = prev_count*prev_wout;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Os dados obtidos são combinados com o estado atual da conta.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count * prev_wout;
   descr.step = AccountDescr;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Os dados são processados por 2 camadas totalmente conectadas.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NActions;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

E na saída, geramos a política estocástica do Actor.

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

O Critic também usa os estados do ambiente processados como dados de entrada.

//--- Critic
   critic.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.Copy(actor.At(0));
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }

A descrição do estado do ambiente é complementada pelas ações do Agente.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.Copy(actor.At(0));
   descr.step = NActions;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }

Os dados são processados por 2 camadas totalmente conectadas, com um vetor de recompensas na saída.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = NRewards;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

2.3 EA de interação com o ambiente

Após compor a arquitetura dos modelos, passamos a criar o EA de interação com o ambiente em "...\Experts\DFFT\Research.mq5". Este EA é destinado à coleta da amostra inicial de treinamento e à atualização subsequente do buffer de reprodução. O EA também pode ser usado para testar o modelo treinado. Embora para essa funcionalidade haja o EA "...\Experts\DFFT\Test.mq5". Ambos os EAs têm um algoritmo semelhante. A única diferença é que o último não salva dados no buffer de reprodução para treinamento subsequente. Isso é feito para um teste "justo" do modelo treinado.

Desde já, digo que ambos os EAs são amplamente copiados de trabalhos anteriores. Neste artigo, abordaremos apenas as mudanças específicas dos modelos.

Dentro da funcionalidade de coleta de dados, não usaremos o modelo Critic. 

CNet                 DOT;
CNet                 Actor;

No método de inicialização do EA, primeiro conectamos os indicadores necessários.

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   if(!Symb.Name(_Symbol))
      return INIT_FAILED;
   Symb.Refresh();
//---
   if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice))
      return INIT_FAILED;
//---
   if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice))
      return INIT_FAILED;
//---
   if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod))
      return INIT_FAILED;
//---
   if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice))
      return INIT_FAILED;
   if(!RSI.BufferResize(HistoryBars) || !CCI.BufferResize(HistoryBars) ||
      !ATR.BufferResize(HistoryBars) || !MACD.BufferResize(HistoryBars))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return INIT_FAILED;
     }
//---
   if(!Trade.SetTypeFillingBySymbol(Symb.Name()))
      return INIT_FAILED;
//--- load models
   float temp;

Depois tentamos carregar os modelos previamente treinados.

   if(!DOT.Load(FileName + "DOT.nnw", temp, temp, temp, dtStudied, true) ||
      !Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *dot = new CArrayObj();
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      if(!CreateDescriptions(dot, actor, critic))
        {
         delete dot;
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      if(!DOT.Create(dot) ||
         !Actor.Create(actor))
        {
         delete dot;
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      delete dot;
      delete actor;
      delete critic;
     }

Se não for possível carregar os modelos, inicializamos novos modelos com parâmetros aleatórios. Em seguida, transferimos ambos os modelos para um único contexto OpenCL.

   Actor.SetOpenCL(DOT.GetOpenCL());

E realizamos uma verificação mínima da arquitetura do modelo.

   Actor.getResults(Result);
   if(Result.Total() != NActions)
     {
      PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", 
                                                               NActions, Result.Total());
      return INIT_FAILED;
     }
//---
   DOT.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Encoder doesn't match state description (%d <> %d)",
                                              Result.Total(), (HistoryBars * BarDescr));
      return INIT_FAILED;
     }

E salvamos o estado do saldo em uma variável local.

   PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE);
   PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY);
//---
   return(INIT_SUCCEEDED);
  }

A interação direta com o ambiente e a coleta de dados são realizadas no método OnTick. No corpo do método, primeiro verificamos se ocorreu a abertura de um novo candle. Toda a análise é feita apenas na nova vela.

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
//---
   if(!IsNewBar())
      return;

Depois atualizamos os dados históricos.

   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1),
                                                               HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
      return;
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();
   Symb.Refresh();
   Symb.RefreshRates();

E preenchemos o buffer de descrição do estado do ambiente.

   float atr = 0;
   for(int b = 0; b < (int)HistoryBars; b++)
     {
      float open = (float)Rates[b].open;
      float rsi = (float)RSI.Main(b);
      float cci = (float)CCI.Main(b);
      atr = (float)ATR.Main(b);
      float macd = (float)MACD.Main(b);
      float sign = (float)MACD.Signal(b);
      if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || 
         macd == EMPTY_VALUE || sign == EMPTY_VALUE)
         continue;
      //---
      int shift = b * BarDescr;
      sState.state[shift] = (float)(Rates[b].close - open);
      sState.state[shift + 1] = (float)(Rates[b].high - open);
      sState.state[shift + 2] = (float)(Rates[b].low - open);
      sState.state[shift + 3] = (float)(Rates[b].tick_volume / 1000.0f);
      sState.state[shift + 4] = rsi;
      sState.state[shift + 5] = cci;
      sState.state[shift + 6] = atr;
      sState.state[shift + 7] = macd;
      sState.state[shift + 8] = sign;
     }
   bState.AssignArray(sState.state);

A próxima etapa é coletar dados sobre o estado atual da conta.

   sState.account[0] = (float)AccountInfoDouble(ACCOUNT_BALANCE);
   sState.account[1] = (float)AccountInfoDouble(ACCOUNT_EQUITY);
//---
   double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0;
   double position_discount = 0;
   double multiplyer = 1.0 / (60.0 * 60.0 * 10.0);
   int total = PositionsTotal();
   datetime current = TimeCurrent();
   for(int i = 0; i < total; i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      double profit = PositionGetDouble(POSITION_PROFIT);
      switch((int)PositionGetInteger(POSITION_TYPE))
        {
         case POSITION_TYPE_BUY:
            buy_value += PositionGetDouble(POSITION_VOLUME);
            buy_profit += profit;
            break;
         case POSITION_TYPE_SELL:
            sell_value += PositionGetDouble(POSITION_VOLUME);
            sell_profit += profit;
            break;
        }
      position_discount += profit - (current - PositionGetInteger(POSITION_TIME)) * 
                                     multiplyer * MathAbs(profit);
     }
   sState.account[2] = (float)buy_value;
   sState.account[3] = (float)sell_value;
   sState.account[4] = (float)buy_profit;
   sState.account[5] = (float)sell_profit;
   sState.account[6] = (float)position_discount;
   sState.account[7] = (float)Rates[0].time;

Consolidamos os dados coletados no buffer de descrição do estado da conta.

   bAccount.Clear();
   bAccount.Add((float)((sState.account[0] - PrevBalance) / PrevBalance));
   bAccount.Add((float)(sState.account[1] / PrevBalance));
   bAccount.Add((float)((sState.account[1] - PrevEquity) / PrevEquity));
   bAccount.Add(sState.account[2]);
   bAccount.Add(sState.account[3]);
   bAccount.Add((float)(sState.account[4] / PrevBalance));
   bAccount.Add((float)(sState.account[5] / PrevBalance));
   bAccount.Add((float)(sState.account[6] / PrevBalance));

Aqui também adicionamos um carimbo de tempo do estado atual.

   double x = (double)Rates[0].time / (double)(D'2024.01.01' - D'2023.01.01');
   bAccount.Add((float)MathSin(2.0 * M_PI * x));
   x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_MN1);
   bAccount.Add((float)MathCos(2.0 * M_PI * x));
   x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_W1);
   bAccount.Add((float)MathSin(2.0 * M_PI * x));
   x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_D1);
   bAccount.Add((float)MathSin(2.0 * M_PI * x));
//---
   if(bAccount.GetIndex() >= 0)
      if(!bAccount.BufferWrite())
         return;

Após coletar os dados iniciais, realizamos a propagação para frente do codificador.

   if(!DOT.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }

E imediatamente realizamos a propagação para frente do Actor.

//--- Actor
   if(!Actor.feedForward((CNet *)GetPointer(DOT), -1, (CBufferFloat*)GetPointer(bAccount)))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }

Obtemos os resultados do modelo.

   PrevBalance = sState.account[0];
   PrevEquity = sState.account[1];
//---
   vector<float> temp;
   Actor.getResults(temp);
   if(temp.Size() < NActions)
      temp = vector<float>::Zeros(NActions);

E os decodificamos executando operações comerciais.

   double min_lot = Symb.LotsMin();
   double step_lot = Symb.LotsStep();
   double stops = MathMax(Symb.StopsLevel(), 1) * Symb.Point();
   if(temp[0] >= temp[3])
     {
      temp[0] -= temp[3];
      temp[3] = 0;
     }
   else
     {
      temp[3] -= temp[0];
      temp[0] = 0;
     }
//--- buy control
   if(temp[0] < min_lot || (temp[1] * MaxTP * Symb.Point()) <= stops || 
     (temp[2] * MaxSL * Symb.Point()) <= stops)
     {
      if(buy_value > 0)
         CloseByDirection(POSITION_TYPE_BUY);
     }
   else
     {
      double buy_lot = min_lot + MathRound((double)(temp[0] - min_lot) / step_lot) * step_lot;
      double buy_tp = NormalizeDouble(Symb.Ask() + temp[1] * MaxTP * Symb.Point(),
                                                                     Symb.Digits());
      double buy_sl = NormalizeDouble(Symb.Ask() - temp[2] * MaxSL * Symb.Point(), 
                                                                     Symb.Digits());
      if(buy_value > 0)
         TrailPosition(POSITION_TYPE_BUY, buy_sl, buy_tp);
      if(buy_value != buy_lot)
        {
         if(buy_value > buy_lot)
            ClosePartial(POSITION_TYPE_BUY, buy_value - buy_lot);
         else
            Trade.Buy(buy_lot - buy_value, Symb.Name(), Symb.Ask(), buy_sl, buy_tp);
        }
     }
//--- sell control
   if(temp[3] < min_lot || (temp[4] * MaxTP * Symb.Point()) <= stops || 
     (temp[5] * MaxSL * Symb.Point()) <= stops)
     {
      if(sell_value > 0)
         CloseByDirection(POSITION_TYPE_SELL);
     }
   else
     {
      double sell_lot = min_lot + MathRound((double)(temp[3] - min_lot) / step_lot) * step_lot;
      double sell_tp = NormalizeDouble(Symb.Bid() - temp[4] * MaxTP * Symb.Point(), 
                                                                                 Symb.Digits());
      double sell_sl = NormalizeDouble(Symb.Bid() + temp[5] * MaxSL * Symb.Point(), 
                                                                                 Symb.Digits());
      if(sell_value > 0)
         TrailPosition(POSITION_TYPE_SELL, sell_sl, sell_tp);
      if(sell_value != sell_lot)
        {
         if(sell_value > sell_lot)
            ClosePartial(POSITION_TYPE_SELL, sell_value - sell_lot);
         else
            Trade.Sell(sell_lot - sell_value, Symb.Name(), Symb.Bid(), sell_sl, sell_tp);
        }
     }

Os dados recebidos do ambiente são salvos no buffer de reprodução.

   sState.rewards[0] = bAccount[0];
   sState.rewards[1] = 1.0f - bAccount[1];
   if((buy_value + sell_value) == 0)
      sState.rewards[2] -= (float)(atr / PrevBalance);
   else
      sState.rewards[2] = 0;
   for(ulong i = 0; i < NActions; i++)
      sState.action[i] = temp[i];
   if(!Base.Add(sState))
      ExpertRemove();
  }

Os demais métodos do EA foram transferidos sem alterações. Você pode consultá-los no anexo.

2.4 EA de treinamento dos modelos

Após coletar a amostra de treinamento, passamos a construir o EA de treinamento dos modelos "...\Experts\DFFT\Study.mq5". Como os EAs de interação com o ambiente, seu algoritmo foi amplamente fundamentado em pesquisas anteriores. Portanto, neste artigo, proponho considerar apenas o método de treinamento direto dos modelos Train.

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

No corpo do método, primeiramente geramos um vetor de probabilidades para a escolha de trajetórias a partir da amostra de treinamento, com base em sua rentabilidade. As passagens mais lucrativas serão usadas com mais frequência para o treinamento dos modelos.

Em seguida, declararemos as variáveis locais necessárias.

   vector<float> result, target;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

Após concluir o trabalho preparatório, realizamos um sistema de ciclos de treinamento dos modelos. Lembro que no modelo do Codificador usamos uma pilha de dados históricos. Esse modelo é altamente sensível à ordem cronológica dos dados fornecidos. Portanto, no ciclo externo, amostraremos a trajetória do buffer de reprodução de experiência e o estado inicial para o treinamento nessa trajetória.

   for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++)
     {
      int tr = SampleTrajectory(probability);
      int batch = GPTBars + 48;
      int state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * 
                        (Buffer[tr].Total - 2 - PrecoderBars - batch));
      if(state <= 0)
        {
         iter--;
         continue;
        }

Depois, limpamos a pilha interna do modelo.

      DOT.Clear();

E executamos um ciclo aninhado de extração de estados históricos sequenciais do buffer de reprodução de experiência para o treinamento do modelo. O pacote de treinamento do modelo foi definido para 2 dias a mais que a profundidade da pilha interna do modelo.

      int end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars);
      for(int i = state; i < end; i++)
        {
         bState.AssignArray(Buffer[tr].States[i].state);

No corpo do ciclo aninhado, extraímos um estado do ambiente do buffer de reprodução de experiência e o usamos para a propagação para frente do Codificador.

         //--- Trajectory
         if(!DOT.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Para treinar a política do Ator, primeiro precisamos preencher o buffer de descrição do estado da conta, como fizemos no EA de interação com o ambiente. Só que agora não interrogamos o ambiente, mas extraímos dados do buffer de reprodução de experiência.

         //--- Policy
         float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
         float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
         bAccount.Clear();
         bAccount.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance);
         bAccount.Add(Buffer[tr].States[i].account[1] / PrevBalance);
         bAccount.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity);
         bAccount.Add(Buffer[tr].States[i].account[2]);
         bAccount.Add(Buffer[tr].States[i].account[3]);
         bAccount.Add(Buffer[tr].States[i].account[4] / PrevBalance);
         bAccount.Add(Buffer[tr].States[i].account[5] / PrevBalance);
         bAccount.Add(Buffer[tr].States[i].account[6] / PrevBalance);

Também adicionamos uma marca temporal.

         double time = (double)Buffer[tr].States[i].account[7];
         double x = time / (double)(D'2024.01.01' - D'2023.01.01');
         bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = time / (double)PeriodSeconds(PERIOD_MN1);
         bAccount.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = time / (double)PeriodSeconds(PERIOD_W1);
         bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = time / (double)PeriodSeconds(PERIOD_D1);
         bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         if(bAccount.GetIndex() >= 0)
            bAccount.BufferWrite();

Depois, realizamos a propagação para frente do Ator e do Crítico.

         //--- Actor
         if(!Actor.feedForward((CNet *)GetPointer(DOT), -1, 
                               (CBufferFloat*)GetPointer(bAccount)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         //--- Critic
         if(!Critic.feedForward((CNet *)GetPointer(DOT), -1, (CNet*)GetPointer(Actor)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Em seguida, treinamos o Ator com ações do buffer de reprodução de experiência, transmitindo o gradiente para o modelo do Codificador. Classificação de objetos no bloco TAE, conforme proposto pelo método DFFT.

         Result.AssignArray(Buffer[tr].States[i].action);
         if(!Actor.backProp(Result, (CBufferFloat *)GetPointer(bAccount), 
                           (CBufferFloat *)GetPointer(bGradient)) ||
            !DOT.backPropGradient((CBufferFloat*)NULL)
           )
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Depois, determinamos a recompensa para a próxima transição para um novo estado do ambiente.

         result.Assign(Buffer[tr].States[i+1].rewards);
         target.Assign(Buffer[tr].States[i+2].rewards);
         result=result-target*DiscFactor;

E treinamos o modelo do Crítico, transmitindo o gradiente de erro para ambos os modelos.

         Result.AssignArray(result);
         if(!Critic.backProp(Result, (CNet *)GetPointer(Actor)) ||
            !DOT.backPropGradient((CBufferFloat*)NULL) ||
            !Actor.backPropGradient((CBufferFloat *)GetPointer(bAccount), 
                                    (CBufferFloat *)GetPointer(bGradient)) ||
            !DOT.backPropGradient((CBufferFloat*)NULL)
           )
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Aqui vale notar que em muitos algoritmos anteriores tentávamos evitar a adaptação mútua dos modelos. Com receio de obter resultados indesejados. Os autores do método DFFT, por outro lado, afirmam que essa abordagem permitirá ajustar melhor os parâmetros do Codificador para extrair o máximo de informações.

Após o treinamento dos modelos, informamos o usuário sobre o progresso do processo de treinamento e passamos para a próxima iteração do ciclo.

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

Após concluir com sucesso todas as iterações do processo de treinamento, limpamos o campo de comentários no gráfico. Exibimos os resultados do treinamento no diário. E iniciamos a finalização do trabalho do EA.

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

Com isso, concluímos a análise dos métodos do EA de treinamento dos modelos. Você pode encontrar o código completo do EA no anexo. Além disso, todos os programas utilizados na preparação deste artigo também estão incluídos lá.

3. Teste

Foi realizado um trabalho bastante extenso na implementação do método Decoder-Free Fully Transformer-based (DFFT) usando MQL5. E agora é hora da 3ª parte do nosso artigo — testar o trabalho realizado. Como sempre, o treinamento e teste do novo modelo são realizados com dados históricos do instrumento EURUSD no timeframe H1. Os parâmetros de todos os indicadores são utilizados no padrão.

Para o treinamento do modelo, foram coletadas 500 trajetórias aleatórias no período dos primeiros 7 meses de 2023. O teste do modelo treinado foi realizado com dados históricos de agosto de 2023. Como pode ser observado, o intervalo de teste não fazia parte da amostra de treinamento. Isso permite avaliar o desempenho do modelo com novos dados.

Devo admitir que o modelo ficou bastante "leve" em termos de consumo de recursos computacionais tanto no processo de treinamento quanto na operação em modo de teste.

O processo de treinamento foi bastante estável, com uma redução suave do erro tanto do Ator quanto do Crítico. Durante o treinamento, foi obtido um modelo capaz de gerar um lucro pequeno tanto nos dados de treinamento quanto nos dados de teste. No entanto, gostaria de obter um nível de rentabilidade mais alto e uma linha de saldo mais estável. 

Considerações finais

Neste artigo, conhecemos o método DFFT — um detector de objetos eficiente baseado em transformador sem uso de decodificador, que foi apresentado para resolver problemas de visão computacional. As principais características dessa abordagem incluem o uso do transformador para extrair características e a previsão densa em um único mapa de características. O método propõe novos módulos para melhorar a eficiência do treinamento e da operação do modelo.

Os autores do método demonstraram que DFFT oferece alta precisão na detecção de objetos com custos computacionais relativamente baixos.

Na parte prática deste artigo, implementamos as abordagens propostas usando MQL5. Realizamos o treinamento e o teste do modelo construído com dados históricos reais. Os resultados obtidos confirmam a eficácia dos algoritmos propostos e merecem uma investigação prática mais detalhada.

Gostaria de lembrar que todos os programas apresentados no artigo são exclusivamente para fins informativos. E foram criados apenas para demonstrar as abordagens propostas, bem como suas capacidades. Antes de usar nos mercados financeiros reais, os programas devem ser aprimorados e testados cuidadosamente.


Links

  • Efficient Decoder-free Object Detection with Transformers
  • Outros artigos da série

  • Programas utilizados no artigo

    # Nome Tipo Descrição
    1 Research.mq5 EA EA de coleta de exemplos
    2 ResearchRealORL.mq5
    EA
    EA de coleta de exemplos com o método Real-ORL
    3 Study.mq5  EA EA de treinamento de modelos
    4 Test.mq5 EA EA para teste de modelo
    5 Trajectory.mqh Biblioteca de classe Estrutura de descrição do estado do sistema
    6 NeuroNet.mqh Biblioteca de classe Biblioteca de classes para criação de rede neural
    7 NeuroNet.cl Biblioteca Biblioteca de código do programa OpenCL



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

    Arquivos anexados |
    MQL5.zip (4712.2 KB)
    Desenvolvendo um sistema de Replay (Parte 58): Voltando a trabalhar no serviço Desenvolvendo um sistema de Replay (Parte 58): Voltando a trabalhar no serviço
    Depois de ter dado um tempo no desenvolvimento e aperfeiçoamento do serviço usado no replay / simulação. Iremos voltar a trabalhar nele. Mas como já não iremos mais usar alguns recursos, como as variáveis globais de terminal, se torna necessário uma completa reestruturação de algumas partes do mesmo. Mas não fiquem aflitos, tal processo será adequadamente explicado, para que todos consigam de fato acompanhar o desenrolar do desenvolvimento do serviço.
    Ciência de dados e aprendizado de máquina (Parte 20): Escolha entre LDA e PCA em tarefas de algotrading no MQL5 Ciência de dados e aprendizado de máquina (Parte 20): Escolha entre LDA e PCA em tarefas de algotrading no MQL5
    Neste artigo, vamos considerar métodos de redução de dimensionalidade e sua aplicação no ambiente de trading MQL5. Especificamente, vamos estudar as nuances da Análise Discriminante Linear (LDA) e da Análise de Componentes Principais (PCA), bem como analisar sua influência no desenvolvimento de estratégias e na análise de mercado.
    Classe base de algoritmos populacionais como alicerce para otimização eficiente Classe base de algoritmos populacionais como alicerce para otimização eficiente
    Uma tentativa única de pesquisa para combinar uma série de algoritmos populacionais em uma única classe com o objetivo de simplificar a aplicação dos métodos de otimização. Essa abordagem não apenas abre possibilidades para o desenvolvimento de novos algoritmos, incluindo variantes híbridas, mas também estabelece um banco de testes básico universal. Este banco se torna uma ferramenta chave para a escolha do algoritmo ideal, dependendo da tarefa específica em questão.
    Vantagens do Assistente MQL5 que você precisa saber (Parte 12): Polinômio de Newton Vantagens do Assistente MQL5 que você precisa saber (Parte 12): Polinômio de Newton
    O polinômio de Newton, que cria equações quadráticas a partir de um conjunto de vários pontos, é uma abordagem arcaica, mas interessante para a análise de séries temporais. Neste artigo, tentaremos explorar quais aspectos dessa abordagem podem ser úteis para os traders, bem como eliminar suas limitações.