English Русский 中文 Español Deutsch 日本語
preview
Redes neurais em trading: Transformer vetorial hierárquico (Conclusão)

Redes neurais em trading: Transformer vetorial hierárquico (Conclusão)

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

Introdução

No artigo anterior, vimos a descrição teórica do algoritmo Transformer Vetorial Hierárquico (HiVT), proposto para prever o movimento de múltiplos agentes no campo da direção autônoma de veículos. Esse método oferece uma abordagem eficiente para resolver o problema de previsão de séries temporais, decompondo a tarefa principal em etapas de extração local de contexto e modelagem global de interações.

Relembrando, a previsão de séries temporais é abordada pelos autores do método HiVT em três etapas. Na primeira, o modelo extrai características contextuais locais dos objetos. Toda a cena é dividida em um conjunto de áreas locais, cada uma delas centrada em um agente específico.

Na segunda etapa, dependências globais de longo alcance são capturadas na cena, transmitindo informações entre as áreas locais centradas nos agentes.

As representações locais e globais obtidas permitem que o decodificador preveja as trajetórias futuras de todos os agentes em uma única propagação para frente do modelo.

A visualização original do método está apresentada abaixo.

Além disso, no artigo anterior, realizamos um extenso trabalho preparatório, no qual implementamos blocos individuais do algoritmo proposto. Agora, devemos concluir esse trabalho inicial, integrando esses blocos separados em uma estrutura unificada e completa.


1. Montando o HiVT

Nossa visão da implementação das abordagens propostas pelos autores do HiVT será realizada dentro da classe CNeuronHiVTOCL. A funcionalidade básica dessa nova classe será herdada da camada totalmente conectada CNeuronBaseOCL. Sua estrutura completa está apresentada abaixo.

class CNeuronHiVTOCL    :  public CNeuronBaseOCL
  {
protected:
   uint              iHistory;
   uint              iVariables;
   uint              iForecast;
   uint              iNumTraj;
   //---
   CNeuronBaseOCL               cDataTAD;
   CNeuronConvOCL               cEmbeddingTAD;
   CNeuronTransposeRCDOCL       cTransposeATD;
   CNeuronHiVTAAEncoder         cAAEncoder;
   CNeuronTransposeRCDOCL       cTransposeTAD;
   CNeuronLearnabledPE          cPosEmbeddingTAD;
   CNeuronMVMHAttentionMLKV     cTemporalEncoder;
   CNeuronLearnabledPE          cPosLineEmbeddingTAD;
   CNeuronPatching              cLineEmbeddibg;
   CNeuronMVCrossAttentionMLKV  cALEncoder;
   CNeuronMLMHAttentionMLKV     cGlobalEncoder;
   CNeuronTransposeOCL          cTransposeADT;
   CNeuronConvOCL               cDecoder[3]; // Agent * Traj * Forecast
   CNeuronConvOCL               cProbProj;
   CNeuronSoftMaxOCL            cProbability; // Agent * Traj
   CNeuronBaseOCL               cForecast;
   CNeuronTransposeOCL          cTransposeTA;
   //---
   virtual bool      Prepare(const CNeuronBaseOCL *history);
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override ;
   //---
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronHiVTOCL(void) {};
                    ~CNeuronHiVTOCL(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                          uint window, uint window_key, uint heads, uint units_count, 
                          uint forecast, uint num_traj, 
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const   {  return defNeuronHiVTOCL; }
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

Na estrutura do objeto CNeuronHiVTOCL, podemos ver a declaração da já conhecida lista de métodos sobrepostos, além de uma série de objetos internos, cujas funções serão exploradas ao longo da implementação dos algoritmos dos métodos sobrepostos.

Todos os objetos internos são declarados estaticamente, o que nos permite manter o construtor e o destrutor da classe "vazios". A inicialização direta de todos os objetos aninhados e variáveis é realizada no método Init.

bool CNeuronHiVTOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,

                          uint window, uint window_key, uint heads, uint units_count, 
                          uint forecast, uint num_traj, 
                          ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(units_count < 2 ||
      !CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * forecast, optimization_type, batch))
      return false;

Nos parâmetros do método, recebemos as constantes principais que permitem identificar de forma única a arquitetura do objeto que está sendo inicializado. No corpo do método, chamamos imediatamente o método homônimo da classe pai. Como você sabe, esse método inicializa todos os objetos e variáveis herdados.

Aqui, vale destacar que, além dos controles já implementados no método da classe pai, adicionamos uma verificação direta do número de elementos na sequência analisada. Neste caso, ele deve ser no mínimo 2, pois, no processo de vetorização do estado inicial, previsto pelo algoritmo HiVT, operamos com a dinâmica dos indicadores. Para calcular a variação de um indicador, precisamos de dois valores: o do momento atual e o do passo de tempo anterior.

Após a passagem bem-sucedida pelo bloco de controle do nosso método de inicialização, armazenamos os parâmetros da arquitetura do bloco em variáveis internas.

   iVariables = window;
   iHistory = units_count - 1;
   iForecast = forecast;
   iNumTraj = MathMax(num_traj, 1);

Em seguida, inicializamos os objetos internos. A ordem de inicialização desses objetos seguirá a sequência de uso dentro do algoritmo de propagação para frente analisado. Essa abordagem nos permitirá revisar novamente o algoritmo que estamos estruturando, além de garantir tanto a suficiência quanto a necessidade da criação dos objetos.

Primeiramente, criamos um objeto de camada interna para registrar a representação vetorial do estado analisado do ambiente.

Lembrando que, nesse contexto, o vetor que descreve cada elemento de uma sequência individual em um determinado passo de tempo é igual ao dobro do número de sequências analisadas. Isso ocorre porque cada elemento da sequência é caracterizado pelo deslocamento no espaço bidimensional e pela mudança na posição dos demais agentes em relação ao elemento analisado.

Esse vetor de descrição é gerado para cada elemento de todas as sequências individuais analisadas em cada passo de tempo.

   if(!cDataTAD.Init(0, 0, OpenCL, 2 * iVariables * iVariables * iHistory, optimization, iBatch))
      return false;

Vale notar que, ao implementar o algoritmo HiVT, trabalhamos com tensores tridimensionais, cuja estrutura é armazenada em um buffer unidimensional de dados. Para indicar a dimensionalidade atual nos nomes dos objetos, adicionamos um sufixo de três caracteres:

  • T (Time) — representa a dimensão dos passos temporais;
  • A (Agent) — representa a dimensão do agente (série temporal individual), que, neste caso, corresponde ao parâmetro analisado;
  • D (Dimension) — representa a dimensão do vetor que descreve um elemento da sequência individual.

Em seguida, utilizamos uma camada convolucional para criar embeddings a partir das representações vetoriais geradas.

   if(!cEmbeddingTAD.Init(0, 1, OpenCL, 2 * iVariables, 2 * iVariables, window_key, iVariables * iHistory,
                                                                                  1, optimization, iBatch))
      return false;

Nesse caso, para a geração dos embeddings, usamos uma única matriz de parâmetros aplicada a todos os elementos da sequência multimodal. Por isso, o número de blocos analisados nessa camada é especificado como o produto do número de sequências individuais pela profundidade do histórico analisado.

Após a geração dos embeddings, o algoritmo HiVT analisa as dependências locais entre os agentes em um único passo de tempo. Como discutido no artigo anterior, antes de realizar essa etapa, é necessário transpor os dados de entrada.

   if(!cTransposeATD.Init(0, 2, OpenCL, iHistory, iVariables, window_key, optimization, iBatch))
      return false;

Somente depois desse processo podemos utilizar nossas classes de atenção para identificar as dependências entre os agentes do grupo local.

   if(!cAAEncoder.Init(0, 3, OpenCL, window_key, window_key, heads, (heads + 1) / 2, iVariables, 2, 1, 
                                                                       iHistory, optimization, iBatch))
      return false;

Aqui você deve prestar atenção a 2 situações. Primeiro, após a transposição dos dados, alteramos a sequência de caracteres no sufixo do nome do objeto para ATD, o que corresponde à dimensionalidade do tensor tridimensional na saída da camada de transposição dos dados.

Em segundo lugar, vale observar a funcionalidade dos nossos blocos de atenção. Inicialmente, eles foram projetados para trabalhar com tensores bidimensionais, em que cada linha representa o vetor de descrição de um elemento da sequência. Dessa forma, identificamos dependências entre as linhas da matriz analisada, o que podemos chamar de "atenção vertical". Posteriormente, acrescentamos a análise de dependências dentro de sequências individuais individuais da série temporal multimodal. Na prática, isso significou dividir a matriz original em várias matrizes menores, contendo um número reduzido de sequências individuals analisadas. As novas matrizes herdaram da matriz original a quantidade de linhas, mas suas colunas foram distribuídas de forma equitativa entre si. Em essência, essa organização corresponde à dimensionalidade do nosso tensor tridimensional. O primeiro eixo representa o número de linhas da matriz original dos dados analisados. O segundo eixo define a quantidade de matrizes menores que serão usadas para análise independente. O terceiro eixo representa a dimensionalidade do vetor que descreve um elemento da sequência analisada. Considerando a transposição prévia do tensor de embeddings dos dados de entrada, especificamos o número de sequências individuals como o tamanho da sequência analisada pelo bloco de atenção atual. Enquanto isso, a profundidade do histórico analisado nos dados de entrada é definida pelo parâmetro referente à quantidade de variáveis. Dessa maneira, alcançamos o efeito de analisar as dependências entre variáveis individuais dentro de um único passo de tempo.

Na implementação deste bloco de análise de dependências Agente-Agente, utilizei duas camadas de atenção, gerando um tensor Key-Value para cada camada interna. Além disso, o número de cabeças de atenção no tensor Key-Value é duas vezes menor do que o mesmo parâmetro para o tensor Query.

E vale destacar que, neste caso, utilizamos um bloco de atenção com a funcionalidade de controle da fusão de características, o CNeuronHiVTAAEncoder.

Após enriquecer os embeddings dos elementos da sequência com as dependências entre os agentes do grupo local, o algoritmo HiVT prevê a análise das dependências temporais dentro dos elementos individuais das sequências individuais. Nesta etapa, devemos retornar os dados à sua representação original.

   if(!cTransposeTAD.Init(0, 4, OpenCL, iVariables, iHistory, window_key, optimization, iBatch))
      return false;

Em seguida, adicionamos um codificador posicional totalmente treinável.

   if(!cPosEmbeddingTAD.Init(0, 5, OpenCL, iVariables * iHistory * window_key, optimization, iBatch))
      return false;

Utilizamos o bloco de atenção CNeuronMVMHAttentionMLKV para identificar as dependências temporais.

   if(!cTemporalEncoder.Init(0, 6, OpenCL, window_key, window_key, heads, (heads + 1) / 2, iHistory, 2, 1, 
                                                                          iVariables, optimization, iBatch))
      return false;

Apesar das diferenças na arquitetura entre os blocos de atenção das dependências locais e temporais, usamos os mesmos parâmetros para inicializá-los.

O próximo passo sugerido pelos autores do HiVT é enriquecer os embeddings dos Agentes com informações sobre o mapa viário. Creio que não há dúvidas de que o estado da estrada, sua sinalização e curvas influenciam as ações do agente. No nosso caso, não há restrições explícitas sobre as mudanças nos valores dos parâmetros analisados. Claro, existem faixas aceitáveis para certos osciladores. Por exemplo, o RSI só pode assumir valores entre 0 e 100. Mas isso é um caso específico.

Sendo assim, utilizamos os dados históricos disponíveis para determinar as variações mais prováveis. Em vez de um mapa tradicional, representamos esse contexto por meio de embeddings de pequenos segmentos reais de trajetória, criados utilizando uma camada de "patching" de dados.

   if(!cLineEmbeddibg.Init(0, 7, OpenCL, 3, 1, 8, iHistory - 1, iVariables, optimization, iBatch))
      return false;

Vale lembrar que, ao vetorizarmos o estado atual, usamos a variação do parâmetro em um único passo de tempo. Já ao criar os embeddings dos pequenos segmentos de trajetória, utilizamos blocos de três elementos com um deslocamento de um passo. Assim, buscamos identificar as relações entre a dinâmica do indicador em um instante específico e a possível continuação da trajetória.

Adicionamos um codificador posicional totalmente treinável aos embeddings obtidos.

   if(!cPosLineEmbeddingTAD.Init(0, 8, OpenCL, cLineEmbeddibg.Neurons(), optimization, iBatch))
      return false;

Depois, enriquecemos os embeddings atuais dos Agentes com informações sobre trajetórias. Para isso, utilizamos o bloco de cross-attention CNeuronMVCrossAttentionMLKV, que contém duas camadas internas.

   if(!cALEncoder.Init(0, 9, OpenCL, window_key, window_key, heads, 8, (heads + 1) / 2, 
                       iHistory, iHistory - 1, 2, 1, iVariables, iVariables, optimization, iBatch))
      return false;

À primeira vista, pode parecer que estamos realizando duas operações idênticas em sequência: a identificação das dependências temporais e a análise das relações entre os agentes e as trajetórias. Em ambos os casos, analisamos as conexões entre o estado atual do agente e os parâmetros do mesmo indicador em diferentes períodos temporais. No entanto, há uma distinção sutil. No primeiro caso, comparamos estados semelhantes do agente em diferentes passos de tempo. No segundo, lidamos com padrões de trajetória que abrangem um intervalo de tempo um pouco maior.

Com isso, concluímos o bloco de análise das dependências locais, que, em essência, enriquece de forma abrangente os embeddings do estado do Agente. O próximo estágio do algoritmo HiVT é a análise das dependências de longo prazo da cena no bloco de interação global.

   if(!cGlobalEncoder.Init(0, 10, OpenCL, window_key*iVariables, window_key*iVariables, heads, (heads+1)/2, 
                                                                      iHistory, 4, 2, optimization, iBatch))
      return false;

Aqui, utilizamos um bloco de atenção com quatro camadas internas. Neste caso, a análise das dependências não é feita considerando Agentes individuais, mas sim toda a cena.

O próximo passo é modelar a sequência futura de valores previstos. Assim como nas etapas anteriores, a previsão da sequência futura ocorre dentro de cada sequência. Para isso, primeiro precisamos transpor os dados atuais.

   if(!cTransposeADT.Init(0, 11, OpenCL, iHistory, window_key * iVariables, optimization, iBatch))
      return false;

Para prever os valores futuros ao longo de todo o horizonte de planejamento, os autores do HiVT sugerem o uso de um MLP. No nosso caso, essa tarefa é realizada por um bloco de três camadas convolucionais sequenciais, em que cada camada possui uma janela única de dados analisados e uma função de ativação específica.

   if(!cDecoder[0].Init(0, 12, OpenCL, iHistory, iHistory, iForecast, window_key * iVariables, 
                                                                                         optimization, iBatch))
      return false;
   cDecoder[0].SetActivationFunction(SIGMOID);
   if(!cDecoder[1].Init(0, 13, OpenCL, iForecast * window_key, iForecast * window_key, iForecast * window_key,
                                                                             iVariables, optimization, iBatch))
      return false;
   cDecoder[1].SetActivationFunction(LReLU);
   if(!cDecoder[2].Init(0, 14, OpenCL, iForecast * window_key, iForecast * window_key, iForecast * iNumTraj, 
                                                                             iVariables, optimization, iBatch))
      return false;
   cDecoder[2].SetActivationFunction(TANH);

Na primeira etapa, trabalhamos com os elementos individuais do embedding, que descreve o estado de cada Agente, alterando o tamanho da sequência da profundidade do histórico analisado para o horizonte de planejamento.

Em seguida, analisamos as dependências globais dentro de cada agente ao longo de todo o horizonte de planejamento, sem alterar o tamanho do tensor.

Somente na última etapa fazemos a previsão de múltiplos cenários possíveis para cada sequência. O número de trajetórias previstas é definido por um parâmetro externo do método, especificado pelo programa principal.

Aqui, vale destacar que a previsão de múltiplos cenários é uma característica fundamental do método proposto. No entanto, precisamos de um mecanismo para selecionar a trajetória mais provável. Para isso, primeiro projetamos as trajetórias geradas para a dimensionalidade correspondente ao número de trajetórias previstas para cada Agente.

   if(!cProbProj.Init(0, 15, OpenCL, iForecast * iNumTraj, iForecast * iNumTraj, iNumTraj, iVariables,
                                                                                 optimization, iBatch))
      return false;

Em seguida, utilizamos a função SoftMax para converter essas projeções em probabilidades.

   if(!cProbability.Init(0, 16, OpenCL, iForecast * iNumTraj * iVariables, optimization, iBatch))
      return false;
   cProbability.SetHeads(iVariables); // Agent * Traj

Ao ponderar as trajetórias previstas com suas respectivas probabilidades, obtemos a trajetória média esperada para o movimento futuro do nosso Agente.

   if(!cForecast.Init(0, 17, OpenCL, iForecast * iVariables, optimization, iBatch))
      return false;

Por fim, resta apenas ajustar os valores previstos para que tenham a mesma dimensionalidade dos dados de entrada. Essa funcionalidade é realizada por meio da transposição dos dados.

   if(!cTransposeTA.Init(0, 18, OpenCL, iVariables, iForecast, optimization, iBatch))
      return false;

Para reduzir as operações de cópia de dados e otimizar o uso dos recursos de memória, redefinimos os ponteiros dos buffers de resultados e dos gradientes de erro do nosso bloco para os buffers correspondentes do último bloco interno de transposição de dados.

   SetOutput(cTransposeTA.getOutput(),true);
   SetGradient(cTransposeTA.getGradient(),true);
//---
   return true;
  }

Finalizamos a execução do método retornando um resultado lógico à função chamadora, indicando o sucesso das operações realizadas.

Após concluir a inicialização do objeto da classe, passamos à construção do algoritmo de propagação para frente no método feedForward.

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

Nos parâmetros do método, recebemos um ponteiro para o objeto que contém os dados de entrada. Esse ponteiro é imediatamente passado para o método Prepare, responsável pela preparação dos dados de entrada. Esse método funciona como um "wrapper" para chamar o kernel de vetorização de dados HiVTPrepare, cujo algoritmo foi discutido no artigo anterior. Já abordamos diversas vezes os diferentes métodos de enfileiramento de kernels OpenCL para execução, e o algoritmo do método Prepare não apresenta peculiaridades ou desafios ocultos. Portanto, nesta parte do artigo, omitiremos a descrição detalhada do algoritmo. No entanto, você pode consultá-lo no anexo, se desejar analisá-lo.

A partir das representações vetoriais geradas, criamos os embeddings dos agentes em cada passo de tempo.

   if(!cEmbeddingTAD.FeedForward(cDataTAD.AsObject()))
      return false;

Em seguida, transpomos os embeddings.

   if(!cTransposeATD.FeedForward(cEmbeddingTAD.AsObject()))
      return false;

E enriquecemos esses embeddings ao capturar as dependências locais por meio da análise das relações Agente-Agente.

   if(!cAAEncoder.FeedForward(cTransposeATD.AsObject()))
      return false;

O próximo passo é enriquecer os embeddings do estado dos agentes com as dependências temporais. Para isso, primeiro transpomos o tensor de dados atual.

   if(!cTransposeTAD.FeedForward(cAAEncoder.AsObject()))
      return false;

Adicionamos as marcações de codificação posicional.

   if(!cPosEmbeddingTAD.FeedForward(cTransposeTAD.AsObject()))
      return false;

E chamamos o método de propagação para frente do módulo de atenção temporal para cada agente individualmente.

   if(!cTemporalEncoder.FeedForward(cPosEmbeddingTAD.AsObject()))
      return false;

Após a execução bem-sucedida das operações de atenção temporal, obtemos um tensor de embeddings dos dados analisados, enriquecido com as dependências locais e temporais. Agora, precisamos complementar esses embeddings com informações sobre possíveis padrões de movimento. Para isso, primeiro criamos embeddings para os padrões do histórico de movimento analisado.

   if(!cLineEmbeddibg.FeedForward(NeuronOCL))
      return false;

Adicionamos a codificação posicional aos embeddings dos padrões gerados.

   if(!cPosLineEmbeddingTAD.FeedForward(cLineEmbeddibg.AsObject()))
      return false;

E, no módulo de cross-attention, enriquecemos os embeddings dos agentes com informações sobre os diferentes padrões de movimento.

   if(!cALEncoder.FeedForward(cTemporalEncoder.AsObject(), cPosLineEmbeddingTAD.getOutput()))
      return false;

Aplicamos o módulo de atenção global ao tensor dos embeddings enriquecidos dos agentes.

   if(!cGlobalEncoder.FeedForward(cALEncoder.AsObject()))
      return false;

Em seguida, partimos para o bloco de previsão do movimento futuro dos agentes. Lembramos que planejamos prever os valores futuros dos parâmetros analisados dentro das sequências individuals individuais. Portanto, primeiro transpomos o tensor de dados atual.

   if(!cTransposeADT.FeedForward(cGlobalEncoder.AsObject()))
      return false;

Em seguida, realizamos a propagação para frente do nosso bloco de previsão baseado em MLP de três camadas.

   if(!cDecoder[0].FeedForward(cTransposeADT.AsObject()))
      return false;
   if(!cDecoder[1].FeedForward(cDecoder[0].AsObject()))
      return false;
   if(!cDecoder[2].FeedForward(cDecoder[1].AsObject()))
      return false;

Aqui, é importante lembrar uma característica fundamental do método HiVT. Na saída do MLP de previsão do movimento futuro, não obtemos apenas um, mas vários cenários possíveis de continuação da série analisada. Nossa tarefa é determinar a probabilidade de ocorrência de cada uma dessas trajetórias. Para isso, primeiro geramos as trajetórias de previsão.

   if(!cProbProj.FeedForward(cDecoder[2].AsObject()))
      return false;

Em seguida, utilizamos a função SoftMax para converter essas projeções em valores probabilísticos.

   if(!cProbability.FeedForward(cProbProj.AsObject()))
      return false;

Agora, basta multiplicar o tensor das trajetórias previstas por suas respectivas probabilidades.

   if(IsStopped() ||
      !MatMul(cDecoder[2].getOutput(), cProbability.getOutput(), cForecast.getOutput(), iForecast,
                                                                          iNumTraj, 1, iVariables))
      return false;

Como resultado dessa operação, obtemos um tensor de trajetórias médias ponderadas ao longo de todo o horizonte de planejamento para cada sequência da série multimodal analisada.

Por fim, para concluir as operações do nosso método de propagação para frente, realizamos a transposição do tensor de valores previstos, garantindo que ele corresponda às dimensões dos dados de entrada analisados.

   if(!cTransposeTA.FeedForward(cForecast.AsObject()))
     return false;
//---
   return true;
  }

Como de costume, retornamos um valor lógico para a função chamadora, indicando o sucesso das operações realizadas.

Com isso, finalizamos a implementação do algoritmo de propagação para frente do método HiVT e partimos para a construção dos métodos de propagação reversa da nossa classe. Como você sabe, o algoritmo de propagação reversa é composto por dois blocos principais:

  • A distribuição do gradiente do erro para todos os elementos, de acordo com sua influência no resultado final. Essa funcionalidade é implementada no método calcInputGradients.
  • O ajuste dos parâmetros treináveis do modelo para minimizar o erro geral, realizado no método updateInputWeights.

Iniciamos a implementação dos algoritmos de propagação reversa com a construção do método de distribuição do gradiente do erro calcInputGradients. O algoritmo desse método é estruturado de forma idêntica ao da propagação para frente, com a diferença de que todas as operações são executadas em ordem reversa.

bool CNeuronHiVTOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;

Nos parâmetros desse método, recebemos um ponteiro para o objeto da camada anterior, que nos forneceu os dados de entrada durante a propagação para frente. Agora, precisamos transmitir a ele o gradiente do erro, de acordo com a influência dos dados de entrada no resultado final.

No corpo do método, a primeira etapa é verificar a validade do ponteiro recebido. Caso contrário, a execução das operações deste método não teria sentido.

Após passar pelo bloco de controle, iniciamos a execução direta da distribuição do gradiente do erro.

O gradiente do erro nos resultados da camada atual já está armazenado no buffer correspondente da nossa classe. Ele foi gravado durante a execução do método equivalente na camada subsequente. Graças à substituição dos buffers de dados realizada anteriormente, o gradiente de erro necessário já está no buffer da última camada de transposição de dados. Assim, iniciamos a transmissão do gradiente de erro para a camada das trajetórias médias ponderadas das sequências individuals.

   if(!cForecast.calcHiddenGradients(cTransposeTA.AsObject()))
      return false;

Como mencionado anteriormente, no processo de propagação para frente, obtivemos as trajetórias médias ponderadas ao multiplicar o tensor das trajetórias previstas pelo vetor de probabilidades correspondente. Portanto, na propagação reversa, precisamos distribuir o gradiente do erro tanto no tensor das múltiplas trajetórias previstas quanto no vetor de probabilidades.

   if(IsStopped() ||
      !MatMulGrad(cDecoder[2].getOutput(), cDecoder[2].getGradient(), cProbability.getOutput(),
                  cProbability.getGradient(), cForecast.getGradient(), iForecast, iNumTraj, 1, iVariables))
      return false;

O gradiente do erro das probabilidades é repassado para a camada de projeção das trajetórias previstas.

   if(!cProbProj.calcHiddenGradients(cProbability.AsObject()))
      return false;

Para obter as projeções, utilizamos as próprias trajetórias previstas. Assim, o próximo passo seria propagar o gradiente do erro para as trajetórias previstas.

No entanto, é importante notar que o gradiente do erro já foi transmitido ao tensor das múltiplas trajetórias previstas na etapa anterior, a partir da trajetória média ponderada. Um chamado direto ao método calcHiddenGradients da camada correspondente apagaria esse gradiente anteriormente transmitido e sobrescreveria o buffer com novos valores. Geralmente, utilizamos buffers auxiliares para somar os valores de dois fluxos de dados nesses casos. No entanto, optamos por não propagar o gradiente do erro da camada de projeção de dados. Dessa forma, buscamos manter a previsão das trajetórias futuras "pura", sem distorcer seus valores devido às imprecisões do modelo de distribuição probabilística da relevância de cada trajetória.

Assim, o gradiente do erro das trajetórias previstas passa através do bloco MLP de previsão.

   if(!cDecoder[1].calcHiddenGradients(cDecoder[2].AsObject()))
      return false;
   if(!cDecoder[0].calcHiddenGradients(cDecoder[1].AsObject()))
      return false;

O tensor de gradientes de erro obtido na saída é transposto e processado pelo bloco de interação global.

   if(!cTransposeADT.calcHiddenGradients(cDecoder[0].AsObject()))
      return false;
   if(!cGlobalEncoder.calcHiddenGradients(cTransposeADT.AsObject()))
      return false;
   if(!cALEncoder.calcHiddenGradients(cGlobalEncoder.AsObject()))
      return false;

Do bloco de interação global, o gradiente do erro é encaminhado para o bloco de análise das dependências locais.

Lembrando que, nesse bloco, realizamos uma análise abrangente das interdependências entre os objetos locais. Aqui, primeiro propagamos o gradiente do erro através do bloco de cross-attention Agente-Trajetória até a camada de análise das dependências temporais e codificação posicional dos embeddings dos padrões de movimento.

   if(!cTemporalEncoder.calcHiddenGradients(cALEncoder.AsObject(), cPosLineEmbeddingTAD.getOutput(), 
                                                                   cPosLineEmbeddingTAD.getGradient(), 
                                                  (ENUM_ACTIVATION)cPosLineEmbeddingTAD.Activation()))
      return false;

Em seguida, transmitimos o gradiente do erro através das operações de codificação posicional.

   if(!cLineEmbeddibg.calcHiddenGradients(cPosLineEmbeddingTAD.AsObject()))
      return false;

E, por fim, o repassamos para o nível dos dados de entrada.

   if(!NeuronOCL.calcHiddenGradients(cLineEmbeddibg.AsObject()))
      return false;

Em um segundo fluxo de dados, conduzimos o gradiente do erro primeiro pelo bloco de análise das dependências temporais.

   if(!cPosEmbeddingTAD.calcHiddenGradients(cTemporalEncoder.AsObject()))
      return false;

Depois, ajustamos o gradiente do erro obtido com base nas operações de codificação posicional.

   if(!cTransposeTAD.calcHiddenGradients(cPosEmbeddingTAD.AsObject()))
      return false;

Transpomos os dados e propagamos o gradiente pelo bloco de análise das dependências Agente-Agente.

   if(!cAAEncoder.calcHiddenGradients(cTransposeTAD.AsObject()))
      return false;
   if(!cTransposeATD.calcHiddenGradients(cAAEncoder.AsObject()))
      return false;

Para concluir as operações do método, realizamos a transposição dos dados para sua representação original e propagamos o gradiente do erro pelo bloco de geração de embeddings até a representação vetorial dos dados de entrada.

   if(!cEmbeddingTAD.calcHiddenGradients(cTransposeATD.AsObject()))
      return false;
   if(!cDataTAD.calcHiddenGradients(cEmbeddingTAD.AsObject()))
      return false;
//---
   return true;
  }

Por fim, retornamos à função chamadora um valor lógico indicando o sucesso da execução das operações.

Neste estágio, distribuímos o gradiente do erro para todos os elementos do modelo, de acordo com sua influência no resultado final. Agora, precisamos ajustar os parâmetros treináveis do modelo para minimizar o erro geral. Esse procedimento é implementado no método updateInputWeights.

É importante lembrar que todos os parâmetros treináveis da nova classe CNeuronHiVTOCL estão contidos em seus objetos internos. No entanto, nem todos os objetos internos possuem parâmetros treináveis. Por exemplo, os blocos de transposição de dados não possuem parâmetros ajustáveis. Por isso, neste método, trabalhamos apenas com os objetos que os contêm. Para ajustá-los, basta chamar o método equivalente de cada objeto interno correspondente.

Como pode ser observado, o algoritmo desse método é bastante simples. Por essa razão, não reproduziremos seu código completo neste artigo. No entanto, você pode consultá-lo no anexo, se desejar analisá-lo. No mesmo anexo, também estão disponíveis o código completo da nova classe e de todos os seus métodos.


2. Arquitetura do modelo

Finalizamos a construção da nova classe CNeuronHiVTOCL e de seus métodos. Nessa classe, implementamos nossa interpretação das abordagens propostas pelos autores do método HiVT. Agora, é o momento de integrar o novo objeto à arquitetura do nosso modelo.

Assim como nas etapas anteriores, incorporamos o objeto responsável pela previsão do movimento futuro da série multimodal analisada à Rede Codificadora do estado do ambiente. A solução arquitetônica dessa estrutura está implementada no método CreateEncoderDescriptions. Nos parâmetros desse método, recebemos um ponteiro para um objeto de matriz dinâmica, no qual registraremos a definição arquitetônica do modelo gerado.

bool CreateEncoderDescriptions(CArrayObj *&encoder)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }

No corpo do método, verificamos a validade do ponteiro recebido e, se necessário, criamos uma nova instância da matriz dinâmica. Em seguida, descrevemos sequencialmente a arquitetura de cada camada do nosso modelo.

Para obter os dados de entrada, utilizamos uma camada totalmente conectada de tamanho adequado.

//--- Encoder
   encoder.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(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

O modelo recebe os dados brutos, sem processamento prévio. Para normalizá-los e torná-los comparáveis, utilizamos camadas de normalização em lote.

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

Após o processamento inicial, encaminhamos os dados de entrada diretamente para o nosso novo bloco, construído com base nos princípios do método HiVT.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronHiVTOCL;
     {
      int temp[] = {BarDescr, NForecast, 6};          // {Variables, Forecast, NumTraj}
      ArrayCopy(descr.windows, temp);
     }
   descr.window_out = EmbeddingSize;                  // Inside Dimension
   descr.count = HistoryBars;                         // Units
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Aqui, repetimos praticamente os mesmos parâmetros utilizados nos trabalhos anteriores, com a adição de um novo parâmetro: o número de trajetórias previstas pelo modelo. Neste caso, utilizamos seis trajetórias possíveis.

Na saída do bloco CNeuronHiVTOCL, esperamos obter os valores previstos da série temporal multimodal analisada. No entanto, há um detalhe importante. Para garantir a eficiência do modelo ao lidar com séries temporais multimodais, normalizamos todos os valores para um formato comparável. Consequentemente, os valores previstos também foram gerados nesse formato. Para convertê-los de volta às unidades originais dos dados de entrada, adicionamos os parâmetros estatísticos de distribuição extraídos durante a normalização dos dados brutos.

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronRevInDenormOCL;
   descr.count = BarDescr * NForecast;
   descr.activation = None;
   descr.optimization = ADAM;
   descr.layers = 1;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Em seguida, ajustamos os resultados obtidos na frequência apropriada.

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFreDFOCL;
   descr.window = BarDescr;
   descr.count =  NForecast;
   descr.step = int(true);
   descr.probability = 0.7f;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

A arquitetura dos modelos Ator e Crítico não sofreu modificações. O mesmo se aplica aos programas de treinamento das redes. Por isso, não nos aprofundaremos nesses aspectos nesta parte do artigo. No entanto, disponibilizamos o código completo de todas as implementações utilizadas para esta pesquisa no anexo, para que possam ser estudadas detalhadamente.


3. Testes

Finalizamos a implementação da nossa versão das abordagens propostas pelos autores do método HiVT. Agora, chegou o momento de avaliar a eficácia das soluções que desenvolvemos. Para isso, primeiro treinamos os modelos com dados históricos reais e, em seguida, testamos os modelos treinados em um conjunto de dados que não faz parte do conjunto de treinamento.

Para o treinamento, utilizamos dados históricos do par EUR/USD no timeframe H1, cobrindo todo o ano de 2023.

O treinamento dos modelos é realizado offline. Portanto, antes de iniciá-lo, precisamos compilar o conjunto de dados necessário. Mais detalhes sobre esse processo podem ser encontrados no artigo dedicado ao método Real-ORL. No nosso caso, utilizamos um conjunto de treinamento gerado a partir dos modelos anteriores para treinar o nosso Codificador do estado do ambiente.

Como você sabe, o modelo do Codificador do estado do ambiente opera exclusivamente com dados históricos do movimento dos preços e dos indicadores analisados, que não dependem das ações executadas pelo Agente. Portanto, nesta fase do treinamento, não há necessidade de atualizar constantemente o conjunto de treinamento, pois novas trajetórias adicionadas não fornecem informações adicionais ao Codificador. Assim, prosseguimos com o treinamento até atingir os resultados desejados.

Os resultados dos testes do modelo treinado são apresentados abaixo.

Como podemos ver nos gráficos, o modelo que desenvolvemos consegue capturar com eficiência as principais tendências do movimento futuro dos preços.

Agora, passamos para a segunda etapa do treinamento dos modelos: o aprendizado da política lucrativa do Ator e da função de recompensa do Crítico. Naturalmente, nesse caso, as recompensas reais obtidas do ambiente dependem fortemente das ações tomadas pelo Ator. Para garantir um treinamento eficaz, é essencial manter o conjunto de treinamento sempre atualizado. Por isso, precisamos atualizar periodicamente os dados do conjunto de treinamento, ajustando-os à política atual do Ator.

O treinamento dos modelos continua até que o erro da rede se estabilize em um determinado nível ou até que novas atualizações no conjunto de treinamento deixem de otimizar a política do Ator em ciclos de aprendizado subsequentes.

Para avaliar a eficácia do modelo treinado, realizamos testes no testador de estrat do MetaTrader 5 utilizando dados históricos de janeiro de 2024 e mantendo os demais parâmetros constantes. Os resultados dos testes do modelo treinado são apresentados abaixo.

Como podemos observar nos resultados dos testes, conseguimos treinar uma política do Ator capaz de gerar lucro tanto nos dados de treinamento quanto nos de teste. Durante o período de teste, o modelo realizou 39 operações, das quais mais de 43% foram encerradas com lucro. Sim, a proporção de operações lucrativas foi ligeiramente menor que a de operações perdedoras. No entanto, como os valores máximo e médio das operações vencedoras foram superiores aos das operações perdedoras, conseguimos encerrar o teste com um pequeno lucro. O fator de lucro registrado foi de 1,22.

Ainda assim, é importante notar que os resultados obtidos não podem ser considerados totalmente representativos devido à ausência de uma tendência clara na curva do saldo e ao número reduzido de operações durante o teste.


Considerações finais

Neste artigo, concluímos a implementação do método HiVT usando MQL5. Implementamos o algoritmo proposto no modelo Codificador do estado do ambiente, treinamos e testamos os modelos resultantes. Os testes demonstraram que o método HiVT é capaz de capturar com eficiência as tendências de movimento futuro dos preços. Além disso, a qualidade da previsão do movimento futuro é suficiente para o treinamento de uma política de negociação lucrativa.


Referências

  • HiVT: Hierarchical Vector Transformer for Multi-Agent Motion Prediction
  • Outros artigos da série

  • Programas utilizados no artigo

    # Nome Tipo Descrição
    1 Research.mq5 Expert Advisor EA para coleta de exemplos
    2 ResearchRealORL.mq5
    Expert Advisor
    EA para coleta de exemplos pelo método Real-ORL
    3 Study.mq5 Expert Advisor EA para treinamento de Modelos
    4 StudyEncoder.mq5 Expert Advisor
    EA para treinamento do Codificador
    5 Test.mq5 Expert Advisor EA para testar o modelo
    6 Trajectory.mqh Biblioteca de classe Estrutura de descrição do estado do sistema
    7 NeuroNet.mqh Biblioteca de classe Biblioteca de classes para criação de redes neurais
    8 NeuroNet.cl Biblioteca Biblioteca de código do programa OpenCL

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

    Arquivos anexados |
    MQL5.zip (1699.23 KB)
    Busca com restrições — Tabu Search (TS) Busca com restrições — Tabu Search (TS)
    O artigo analisa o algoritmo de busca tabu, um dos primeiros e mais conhecidos métodos meta-heurísticos. Exploraremos detalhadamente como o algoritmo funciona, desde a escolha da solução inicial até a exploração das soluções vizinhas, com foco no uso da lista tabu. O artigo cobre os aspectos-chave do algoritmo e suas particularidades.
    Gráficos do índice do dólar e do índice do euro — exemplo de serviço no MetaTrader 5 Gráficos do índice do dólar e do índice do euro — exemplo de serviço no MetaTrader 5
    Por meio de um programa-serviço como exemplo, analisaremos a criação e a atualização dos gráficos do índice do dólar (USDX) e do índice do euro (EURX). Ao iniciar o serviço, verificaremos se o instrumento sintético necessário está presente, criaremos caso ele não exista e o adicionaremos à janela "Observação do Mercado". Em seguida, será gerado o histórico do instrumento sintético — tanto o de minutos quanto o de ticks — e o gráfico do instrumento criado será aberto.
    Redes neurais em trading: Análise de nuvem de pontos (PointNet) Redes neurais em trading: Análise de nuvem de pontos (PointNet)
    A análise direta da nuvem de pontos permite evitar um aumento excessivo no volume de dados e aprimorar a eficiência dos modelos em tarefas de classificação e segmentação. Abordagens deste tipo demonstram um bom desempenho e resistência a perturbações nos dados brutos.
    Do básico ao intermediário: Struct (I) Do básico ao intermediário: Struct (I)
    Que tal começarmos a estudar estruturas de uma forma bem mais simples, prática e agradável? Isto por que estruturas é um dos fundamentos, ou pilares da programação. Seja ela estruturada ou não. Sei que muitos acham que estruturas são apenas coleções de dados. Mas posso garantir que elas são muito mais do que isto. E aqui iremos começar a explorar este novo universo, de uma maneira que seja a mais didática possível.