English Русский Deutsch
preview
Redes neurais de maneira fácil (Parte 74): previsão adaptativa de trajetórias

Redes neurais de maneira fácil (Parte 74): previsão adaptativa de trajetórias

MetaTrader 5Sistemas de negociação | 4 julho 2024, 12:45
55 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introdução

A criação de uma estratégia de trading é inseparável da análise da situação de mercado com a previsão do movimento mais provável de um instrumento financeiro, que frequentemente se correlaciona com outros ativos financeiros e indicadores macroeconômicos. Pode-se traçar um paralelo com o movimento de veículos, onde cada veículo segue para seu ponto de destino individual. No entanto, suas ações na estrada são de certa forma interligadas e estritamente reguladas pelas regras de trânsito. Além disso, devido à individualidade da percepção da situação rodoviária pelos motoristas, permanece uma parcela de aleatoriedade nas estradas.

De maneira semelhante, no mundo financeiro, a formação de preços obedece a certas regras. No entanto, a aleatoriedade da oferta e da demanda criadas pelos participantes do mercado leva à aleatoriedade do preço. Talvez por isso, muitos métodos de previsão de trajetórias, aplicados na área de navegação, mostram bons resultados ao prever o movimento futuro dos preços.

Neste artigo, quero apresentar a você um método para a previsão eficaz conjunta de trajetórias de todos os agentes em cena com aprendizado dinâmico de pesos ADAPT, que foi proposto para resolver problemas na área de navegação de veículos autônomos. O método foi apresentado pela primeira vez no artigo "ADAPT: Efficient Multi-Agent Trajectory Prediction with Adaptation".


1. Algoritmo ADAPT

O método ADAPT analisa as trajetórias passadas de todos os agentes no mapa da cena e prevê suas futuras trajetórias. Na representação vetorizada da cena, são modelados diferentes tipos de interações entre os agentes e o mapa para obter a representação mais abrangente possível dos agentes. Semelhante às abordagens de definição de metas, o algoritmo prevê primeiro um conjunto possível de pontos finais. Então, cada ponto final é refinado considerando o deslocamento do agente na cena. Depois disso, a trajetória completa é prevista, condicionada aos pontos finais.

Os autores do método estabilizam o treinamento do modelo, separando a previsão do ponto final e da trajetória com a desconexão dos gradientes. O modelo apresentado pelos autores utiliza pequenos perceptrons multicamadas para prever os pontos finais e trajetórias, a fim de manter a complexidade do modelo baixa.

No método proposto pelos autores, a representação vetorizada estruturada é utilizada para codificar o mapa e os agentes. Essa representação cria um grafo conectado para cada elemento da cena de forma independente, considerando as trajetórias passadas dos agentes e o mapa da cena. Além disso, os autores do método propõem o uso de dois subgrafos separados para os agentes e os objetos do mapa.

ADAPT permite modelar diferentes tipos de interações entre os elementos da cena. Os autores propuseram modelar quatro tipos de relações: agente-faixa (AL), faixa-faixa (LL), faixa-agente (LA) e agente-agente (AA).

Para a análise de interdependências, são usados blocos de atenção multi-head, similar aos AutoBots. No entanto, aos blocos de autoatenção (AA, LL) são adicionados blocos de relações cruzadas (AL, LA) utilizando um codificador de atenção cruzada. Cada interação é modelada sequencialmente e o processo é repetido L vezes.

Dessa forma, as características intermediárias podem ser atualizadas a cada iteração e, em seguida, as características atualizadas são usadas para calcular a atenção na próxima iteração. Cada elemento da cena pode ser informado por diferentes tipos de interações L vezes.

Para prever o ponto final no caso de uso de um sistema de referência centrado no agente, é possível usar MLP, o que pode ser preferível devido às suas vantagens na previsão para um único agente. Mas ao usar um sistema de referência centrado na cena, é recomendado usar uma cabeça adaptativa com pesos dinâmicos, que é mais eficaz na previsão multiagente de pontos finais de trajetórias.

Após obter o ponto final para cada agente, o algoritmo prevê a interpolação das coordenadas futuras entre o ponto inicial e o final usando MLP. Durante este processo, "desconectamos" os pontos finais para garantir que as atualizações de pesos para a previsão completa da trajetória sejam separadas da previsão do ponto final. Analogamente, prevemos a probabilidade para cada trajetória usando os pontos finais desconectados.

Para o treinamento dos modelos, são previstas K trajetórias e aplicadas diversas funções de perda para capturar cenários futuros multimodais. O gradiente de erro inverso é propagado apenas pela trajetória mais precisa. Como as trajetórias completas são previstas condicionadas aos pontos finais, a precisão da previsão do ponto final é fundamental para a previsão completa da trajetória. Desse modo, os autores do método aplicam uma função de perda separada para melhorar a previsão do ponto final. O elemento final da função de perda dos autores é o erro de classificação para o direcionamento das probabilidades atribuídas às trajetórias.

A visualização do método dos autores é apresentada abaixo.

Visualização do método dos autores


2. Implementação com MQL5

Acima é apresentada uma descrição teórica bastante concisa do método ADAPT, devido ao grande volume de trabalho e às limitações do formato do artigo. Alguns aspectos serão discutidos em mais detalhes durante a nossa implementação das abordagens propostas. De imediato, aviso que nossa implementação será em muitos aspectos diferente do método dos autores. Por isso, não julguem com severidade.

A primeira diferença é que descartamos tensores separados para codificação de agentes e polilinhas. Os agentes, no nosso caso, são características analisadas. Cada característica, podemos dizer, é diferenciada por 2 parâmetros: valor e tempo. E durante o período analisado, realiza um movimento em certa trajetória. Embora cada indicador tenha seu próprio intervalo de valores, na prática, não temos um mapa de cena. No entanto, temos um corte da cena em um momento específico com todos os agentes nela. Tecnicamente, podemos substituir uma entidade por outra. Precisamos criar um tensor separado para isso? Afinal, é uma visualização dos mesmos dados em outra dimensão. Decidimos usar um único tensor com diferentes ênfases.

2.1 Bloco de relações cruzadas


Ao refletir sobre a forma de implementar as abordagens propostas, me deparei com a ausência da implementação do bloco de relações cruzadas. Anteriormente, nossas tarefas eram mais autorregressivas. E nelas, o bloco de autoatenção era aceitável. Agora, necessitamos analisar a relação de várias entidades e começamos a implementar uma nova camada neural, CNeuronMH2AttentionOCL. Os algoritmos de implementação da classe são amplamente emprestados do bloco de autoatenção, mas as entidades Query, Key e Value serão formadas a partir de diferentes dimensões do tensor dos dados brutos. Isso exigiu várias melhorias. Por isso, decidimos criar uma classe em vez de modernizar a existente.

class CNeuronMH2AttentionOCL       :  public CNeuronBaseOCL
  {
protected:
   uint              iHeads;                                      ///< Number of heads
   uint              iWindow;                                     ///< Input window size
   uint              iUnits;                                      ///< Number of units
   uint              iWindowKey;                                  ///< Size of Key/Query window
   //---
   CNeuronConvOCL    Q_Embedding;
   CNeuronConvOCL    KV_Embedding;
   CNeuronTransposeOCL Transpose;
   int               ScoreIndex;
   CNeuronBaseOCL    MHAttentionOut;
   CNeuronConvOCL    W0;
   CNeuronBaseOCL    AttentionOut;
   CNeuronConvOCL    FF[2];
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   virtual bool      attentionOut(void);
   //---
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);
   virtual bool      AttentionInsideGradients(void);
public:
   /** Constructor */
                     CNeuronMH2AttentionOCL(void);
   /** Destructor */~CNeuronMH2AttentionOCL(void) {};
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                          uint window, uint window_key, uint heads, 
                          uint units_count, ENUM_OPTIMIZATION optimization_type, 
                          uint batch);
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer);
   //---
   virtual int       Type(void)   const   {  return defNeuronMH2AttentionOCL;   }
   //--- 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);
  };

No construtor da classe, apenas definimos os valores iniciais das variáveis locais.

CNeuronMH2AttentionOCL::CNeuronMH2AttentionOCL(void)  :  iHeads(0),
                                                         iWindow(0),
                                                         iUnits(0),
                                                         iWindowKey(0)
  {
   activation = None;
  }

O destrutor da classe permanece vazio.

A inicialização dos objetos da classe CNeuronMH2AttentionOCL é realizada no método Init. No início do método, chamamos um método similar da classe pai, onde os dados recebidos do programa externo são verificados e os objetos herdados são inicializados.

bool CNeuronMH2AttentionOCL::Init(uint numOutputs, uint myIndex, 
                                  COpenCLMy *open_cl, uint window,
                                  uint window_key, uint heads, 
                                  uint units_count, 
                                  ENUM_OPTIMIZATION optimization_type, 
                                  uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count,
                                                       optimization_type, batch))
      return false;

Preservamos os valores dos parâmetros principais.

   iWindow = fmax(window, 1);
   iWindowKey = fmax(window_key, 1);
   iUnits = fmax(units_count, 1);
   iHeads = fmax(heads, 1);
   activation = None;

Como analisaremos os dados brutos em diferentes dimensões, será necessário transpor o tensor dos dados brutos.

   if(!Transpose.Init(0, 0, OpenCL, iUnits, iWindow, optimization_type, batch))
      return false;
   Transpose.SetActivationFunction(None);

Para gerar as entidades Query, Key e Value, usaremos camadas convolucionais. O número de filtros é igual à dimensionalidade do vetor de uma entidade. Geraremos Query a partir de uma dimensão do tensor dos dados brutos e Key e Value a partir de outra. Portanto, criaremos 2 camadas (uma para cada dimensão).

   if(!Q_Embedding.Init(0, 0, OpenCL, iWindow, iWindow, iWindowKey * iHeads, iUnits, 
                                                                     optimization_type, batch))
      return false;
   Q_Embedding.SetActivationFunction(None);

   if(!KV_Embedding.Init(0, 0, OpenCL, iUnits, iUnits, 2 * iWindowKey * iHeads, iWindow, 
                                                                     optimization_type, batch))
      return false;
   KV_Embedding.SetActivationFunction(None);

A matriz de coeficientes de dependência será necessária apenas no lado do contexto OpenCL. E para economizar recursos, criaremos o buffer apenas no contexto. No lado do programa principal, armazenamos apenas um ponteiro para o buffer.

   ScoreIndex = OpenCL.AddBuffer(sizeof(float) * iUnits * iWindow * iHeads, CL_MEM_READ_WRITE);
   if(ScoreIndex == INVALID_HANDLE)
      return false;

Em seguida, criamos objetos semelhantes ao bloco de autoatenção. Aqui, criamos uma camada de saída de atenção multi-head.

//---
   if(!MHAttentionOut.Init(0, 0, OpenCL, iWindowKey * iUnits * iHeads, optimization_type, batch))
      return false;
   MHAttentionOut.SetActivationFunction(None);

Uma camada de redução de dimensionalidade.

   if(!W0.Init(0, 0, OpenCL, iWindowKey * iHeads, iWindowKey * iHeads, iWindow, iUnits, 
                                                                      optimization_type, batch))
      return false;
   W0.SetActivationFunction(None);

Na saída do bloco de atenção, somamos os resultados obtidos com os dados brutos em uma camada separada.

   if(!AttentionOut.Init(0, 0, OpenCL, iWindow * iUnits, optimization_type, batch))
      return false;
   AttentionOut.SetActivationFunction(None);

Depois vem o bloco linear MLP.

   if(!FF[0].Init(0, 0, OpenCL, iWindow, iWindow, 4 * iWindow, iUnits, optimization_type, batch))
      return false;
   if(!FF[1].Init(0, 0, OpenCL, 4 * iWindow, 4 * iWindow, iWindow, iUnits, optimization_type, 
                                                                                          batch))
      return false;
   for(int i = 0; i < 2; i++)
      FF[i].SetActivationFunction(None);

Para evitar cópias excessivas de gradientes de erro do buffer da classe pai para o buffer da camada interna durante a propagação reversa, substituímos os ponteiros para os objetos.

   Gradient.BufferFree();
   delete Gradient;
   Gradient = FF[1].getGradient();
//---
   return true;
  }

Ao descrever a propagação para frente, é importante notar que, apesar do grande número de camadas internas que já estabelecem uma funcionalidade específica, a análise das interdependências ainda precisa ser realizada. E embora essa funcionalidade matemática seja totalmente idêntica ao bloco de autoatenção, enfrentamos o fato de que o número de entidades Query provavelmente será diferente do número de entidades Key e Value, o que leva a uma matriz retangular Score e quebra a lógica dos kernels criados anteriormente. Portanto, começamos a criar novos kernels.

Para a propagação para frente, criaremos o kernel MH2AttentionOut. Nos parâmetros do kernel, passaremos 4 ponteiros para buffers de dados e a dimensionalidade do vetor de um elemento da entidade. Todas as nossas entidades têm o mesmo tamanho de elementos.

__kernel void MH2AttentionOut(__global float *q,      ///<[in] Matrix of Querys
                              __global float *kv,     ///<[in] Matrix of Keys
                              __global float *score,  ///<[out] Matrix of Scores
                              __global float *out,    ///<[out] Matrix of Scores
                              int dimension           ///< Dimension of Key
                             )
  {
//--- init
   const int q_id = get_global_id(0);
   const int k = get_global_id(1);
   const int h = get_global_id(2);
   const int qunits = get_global_size(0);
   const int kunits = get_global_size(1);
   const int heads = get_global_size(2);

Executaremos o kernel em um espaço de tarefas de até 3 dimensões. Elementos Query, Key e cabeças de atenção. Todos os threads dentro de um elemento Query e uma cabeça de atenção serão agrupados, por conta da necessidade de normalizar a matriz Score com a função SoftMax dentro dos grupos indicados.

No corpo do kernel, identificamos cada thread e determinamos o deslocamento nos buffers de dados globais.

   const int shift_q = dimension * (q_id + qunits * h);
   const int shift_k = dimension * (k + kunits * h);
   const int shift_v = dimension * (k + kunits * (heads + h));
   const int shift_s = q_id * kunits * heads + h * kunits + k;

Também definimos outras constantes e declaramos um array local.

   const uint ls = min((uint)get_local_size(1), (uint)LOCAL_ARRAY_SIZE);
   float koef = sqrt((float)dimension);
   if(koef < 1)
      koef = 1;
   __local float temp[LOCAL_ARRAY_SIZE];

Em seguida, calculamos a matriz de coeficientes de dependência.

//--- sum of exp
   uint count = 0;
   if(k < ls)
      do
        {
         if((count * ls) < (kunits - k))
           {
            float sum = 0;
            for(int d = 0; d < dimension; d++)
               sum = q[shift_q + d] * kv[shift_k + d];
            sum = exp(sum / koef);
            if(isnan(sum))
               sum = 0;
            temp[k] = (count > 0 ? temp[k] : 0) + sum;
           }
         count++;
        }
      while((count * ls + k) < kunits);
   barrier(CLK_LOCAL_MEM_FENCE);
   count = min(ls, (uint)kunits);
//---
   do
     {
      count = (count + 1) / 2;
      if(k < ls)
         temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : 0);
      if(k + count < ls)
         temp[k + count] = 0;
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
//--- score
   float sum = temp[0];
   float sc = 0;
   if(sum != 0)
     {
      for(int d = 0; d < dimension; d++)
         sc = q[shift_q + d] * kv[shift_k + d];
      sc = exp(sc / koef);
      if(isnan(sc))
         sc = 0;
     }
   score[shift_s] = sc;
   barrier(CLK_LOCAL_MEM_FENCE);

E calculamos os novos valores da entidade Query considerando os coeficientes de dependência para cada elemento do vetor separadamente.

//--- out
   for(int d = 0; d < dimension; d++)
     {
      uint count = 0;
      if(k < ls)
         do
           {
            if((count * ls) < (kunits - k))
              {
               float sum = q[shift_q + d] * kv[shift_v + d] * 
                                (count == 0 ? sc : score[shift_s + count * ls]);
               if(isnan(sum))
                  sum = 0;
               temp[k] = (count > 0 ? temp[k] : 0) + sum;
              }
            count++;
           }
         while((count * ls + k) < kunits);
      barrier(CLK_LOCAL_MEM_FENCE);
      //---
      count = min(ls, (uint)kunits);
      do
        {
         count = (count + 1) / 2;
         if(k < ls)
            temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : 0);
         if(k + count < ls)
            temp[k + count] = 0;
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      while(count > 1);
      //---
      out[shift_q + d] = temp[0];
     }
  }

Depois, criaremos um novo kernel para implementar a funcionalidade da propagação reversa MH2AttentionInsideGradients. Esse kernel também será executado em um espaço de tarefas tridimensional.

Nos parâmetros do kernel, passaremos 6 ponteiros para buffers de dados. Foram adicionados buffers de gradientes de erro para todas as entidades.

__kernel void MH2AttentionInsideGradients(__global float *q, __global float *q_g,
                                          __global float *kv, __global float *kv_g,
                                          __global float *scores,
                                          __global float *gradient,
                                          int kunits)
  {
//--- init
   const int q_id = get_global_id(0); ошибок
   const int d = get_global_id(1);
   const int h = get_global_id(2);
   const int qunits = get_global_size(0);
   const int dimension = get_global_size(1);
   const int heads = get_global_size(2);

No corpo do kernel, como sempre, identificamos o thread e criamos as constantes necessárias.

   const int shift_q = dimension * (q_id + qunits * h) + d;
   const int shift_k = dimension * (q_id + kunits * h) + d;
   const int shift_v = dimension * (q_id + kunits * (heads + h)) + d;
   const int shift_s = q_id * kunits * heads + h * kunits;
   const int shift_g = h * qunits * dimension + d;
   float koef = sqrt((float)dimension);
   if(koef < 1)
      koef = 1;

Primeiro, calculamos os gradientes de erro para a entidade Value. Para isso, basta multiplicar o vetor de gradientes de erro da saída do bloco de atenção pelos coeficientes de dependência correspondentes.

//--- Calculating Value's gradients
   int step_score = q_id * kunits * heads;
   for(int v = q_id; v < kunits; v += qunits)
     {
      int shift_score = h * kunits + v;
      float grad = 0;
      for(int g = 0; g < qunits; g++)
         grad += gradient[shift_g + g * dimension] * scores[shift_score + g * step_score];
      kv_g[shift_v + v * dimension]=grad;
     }

Em seguida, calculamos os gradientes de erro para a entidade Query. Desta vez, primeiro calculamos o gradiente de erro nos elementos da matriz de coeficientes de dependência, considerando a derivada da função SoftMax. E depois multiplicamos pelo elemento correspondente do tensor Key.

//--- Calculating Query's gradients
   float grad = 0;
   float out_g = gradient[shift_g + q_id * dimension];
   int shift_val = (heads + h) * kunits * dimension + d;
   int shift_key = h * kunits * dimension + d;
   for(int k = 0; k < kunits; k++)
     {
      float sc_g = 0;
      float sc = scores[shift_s + k];
      for(int v = 0; v < kunits; v++)
         sc_g += scores[shift_s + v] * out_g * kv[shift_val + v * dimension] * 
                                                        ((float)(k == v) - sc);
      grad += sc_g * kv[shift_key + k * dimension];
     }
   q_g[shift_q] = grad / koef;

De forma semelhante, calculamos o gradiente de erro para a entidade Key. Desta vez, calculamos os gradientes de erro dos coeficientes de dependência pela coluna correspondente do tensor.

//--- Calculating Key's gradients
   for(int k = q_id; k < kunits; k += qunits)
     {
      int shift_score = h * kunits + k;
      int shift_val = (heads + h) * kunits * dimension + d;
      grad = 0;
      float val = kv[shift_v];
      for(int scr = 0; scr < qunits; scr++)
        {
         float sc_g = 0;
         int shift_sc = scr * kunits * heads;
         float sc = scores[shift_sc + k];
         for(int v = 0; v < kunits; v++)
            sc_g += scores[shift_sc + v] * gradient[shift_g + scr * dimension] * val * 
                                                                ((float)(k == v) - sc);
         grad += sc_g * q[shift_q + scr * dimension];
        }
      kv_g[shift_k + k * dimension] = grad / koef;
     }
  }

Após construir o algoritmo no lado do contexto OpenCL, voltamos à nossa classe para elaborar o processo no lado do programa principal. Primeiro, consideramos o método de propagação para frente feedForward. Semelhante aos métodos homônimos de outras camadas neurais, nos parâmetros recebemos um ponteiro para a camada neural anterior, que nos fornece os dados brutos.

bool CNeuronMH2AttentionOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
//---
   if(!Q_Embedding.FeedForward(NeuronOCL))
      return false;

No entanto, não verificaremos a relevância do ponteiro recebido. Em vez disso, chamamos o método de propagação para frente da camada interna Q_Embedding para criar o tensor das entidades Query, passando o ponteiro recebido. No corpo desse método, todos os controles necessários já estão implementados e não precisamos realizá-los novamente.

Em seguida, geramos as entidades Key e Value. Como mencionado anteriormente, usamos outra dimensão do tensor dos dados brutos para elas. Assim, primeiro transpomos a matriz dos dados brutos e depois chamamos o método de propagação para frente da camada interna correspondente.

   if(!Transpose.FeedForward(NeuronOCL) || !KV_Embedding.FeedForward(NeuronOCL))
      return false;

A chamada do kernel MH2AttentionOut será elaborada em um método separado attentionOut.

   if(!attentionOut())
      return false;

O tensor de resultados de atenção multi-head é reduzido ao tamanho dos dados brutos.

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

Depois, os valores obtidos são somados aos dados brutos e normalizados. O método SumAndNormilize é herdado da classe pai.

//---
   if(!SumAndNormilize(W0.getOutput(), NeuronOCL.getOutput(), AttentionOut.getOutput(), iWindow))
      return false;

No final do bloco de atenção, passamos os dados pelo MLP.

   if(!FF[0].FeedForward(GetPointer(AttentionOut)))
      return false;
   if(!FF[1].FeedForward(GetPointer(FF[0])))
      return false;

Repetimos a soma e normalização.

   if(!SumAndNormilize(FF[1].getOutput(), AttentionOut.getOutput(), Output, iWindow))
      return false;
//---
   return true;
  }

Para uma "visão completa" do algoritmo de propagação para frente, consideramos o método attentionOut. O método não recebe parâmetros e trabalha apenas com os objetos internos da classe. Por isso, no corpo do método, verificamos apenas a validade do ponteiro para o contexto OpenCL.

bool CNeuronMH2AttentionOCL::attentionOut(void)
  {
   if(!OpenCL)
      return false;

Depois, criamos arrays de espaço de tarefas e deslocamento. Como discutido na construção do kernel, criamos um espaço de tarefas tridimensional com grupos locais na segunda dimensão.

   uint global_work_offset[3] = {0};
   uint global_work_size[3] = {iUnits, iWindow, iHeads};
   uint local_work_size[3] = {1, iWindow, 1};

Passamos os parâmetros necessários para o kernel.

   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_q, 
                                                       Q_Embedding.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, 
                                                            GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_kv, 
                                                       KV_Embedding.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, 
                                                             GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_score, ScoreIndex))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, 
                                                             GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_out, 
                                                       MHAttentionOut.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, 
                                                              GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_MH2AttentionOut, def_k_mh2ao_dimension, (int)iWindowKey))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, 
                                                              GetLastError(), __LINE__);
      return false;
     }

Depois, colocamos o kernel na fila de execução.

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

Acima, implementamos o processo de propagação para frente tanto no lado do programa principal quanto no lado do contexto OpenCL. E agora, precisamos organizar o processo de propagação reversa. Lembro que, para implementar o algoritmo no lado do contexto OpenCL, já criamos o kernel MH2AttentionInsideGradients. E agora precisamos criar o método de chamada desse kernel AttentionInsideGradients. O método não receberá parâmetros, similar ao método de propagação para frente.

bool CNeuronMH2AttentionOCL::AttentionInsideGradients(void)
  {
   if(!OpenCL)
      return false;

No corpo do método, verificamos a validade do ponteiro para o contexto OpenCL. Depois, criamos arrays indicando a dimensionalidade do espaço de tarefas e o deslocamento nele.

   uint global_work_offset[3] = {0};
   uint global_work_size[3] = {iUnits, iWindowKey, iHeads};

Passamos os parâmetros necessários para o kernel.

   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_q, 
                                                            Q_Embedding.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                 __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_qg, 
                                                            Q_Embedding.getGradientIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                 __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_kv, 
                                                            KV_Embedding.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                  __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_kvg, 
                                                           KV_Embedding.getGradientIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                  __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_score, 
                                                                                ScoreIndex))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                  __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_outg,
                                                         MHAttentionOut.getGradientIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                  __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_MH2AttentionInsideGradients, def_k_mh2aig_kunits, (int)iWindow))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                  __LINE__);
      return false;
     }

E colocamos o kernel na fila de execução.

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

No geral, esse é um algoritmo padrão para tais tarefas. Todo o algoritmo de distribuição do gradiente de erro dentro da nossa camada é descrito pelo método calcInputGradients. Nos parâmetros, o método recebe um ponteiro para o objeto da camada anterior, ao qual deve passar o gradiente de erro.

bool CNeuronMH2AttentionOCL::calcInputGradients(CNeuronBaseOCL *prevLayer)
  {
   if(!FF[1].calcInputGradients(GetPointer(FF[0])))
      return false;

No corpo do método, descemos o gradiente de erro da saída do bloco para a camada anterior. Como você lembra, ao inicializar a classe, substituímos o ponteiro para o buffer de gradiente de erro. E a camada subsequente escreveu o gradiente de erro diretamente na última camada interna MLP. De lá, descemos o gradiente de erro até o nível da saída do bloco de atenção.

   if(!FF[0].calcInputGradients(GetPointer(AttentionOut)))
      return false;

Nesse nível, somamos os resultados do bloco de atenção com os dados brutos. De forma semelhante, coletamos o gradiente de 2 direções.

   if(!SumAndNormilize(FF[1].getGradient(), AttentionOut.getGradient(), W0.getGradient(), 
                                                                           iWindow, false))
      return false;

Depois, distribuímos o gradiente de erro pelas cabeças de atenção.

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

E distribuímos o gradiente de erro nas entidades.

   if(!AttentionInsideGradients())
      return false;

O gradiente de erro de Key e Value passamos para a camada de transposição. Na propagação para frente, transpusemos a matriz dos dados brutos. Com o gradiente de erro, fazemos a operação inversa.

   if(!KV_Embedding.calcInputGradients(GetPointer(Transpose)))
      return false;

Depois, passamos o gradiente de erro de todas as entidades para a camada anterior.

   if(!Q_Embedding.calcInputGradients(prevLayer))
      return false;

Mas é importante entender que o gradiente de erro vai para a camada anterior a partir de 4 fluxos:

  • Query
  • Key
  • Value
  • Contornar o bloco de atenção.

No entanto, os métodos das nossas camadas internas ao passar o gradiente de erro removem os dados anteriormente gravados. Assim sendo, ao receber o gradiente de erro de Query, somamos com o gradiente de erro na saída do bloco de atenção no buffer da camada interna.

   if(!SumAndNormilize(prevLayer.getGradient(), W0.getGradient(), AttentionOut.getGradient(), 
                                                                              iWindow, false))
      return false;

E após receber os dados de Key e Value, somamos todos os fluxos.

   if(!Transpose.calcInputGradients(prevLayer))
      return false;
   if(!SumAndNormilize(prevLayer.getGradient(), AttentionOut.getGradient(), 
                                                      prevLayer.getGradient(), iWindow, false))
      return false;
//---
   return true;
  }

No método de atualização dos coeficientes de peso, tudo é bastante simples. Basta chamar os métodos correspondentes das camadas internas.

bool CNeuronMH2AttentionOCL::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   if(!Q_Embedding.UpdateInputWeights(NeuronOCL))
      return false;
   if(!KV_Embedding.UpdateInputWeights(GetPointer(Transpose)))
      return false;
   if(!W0.UpdateInputWeights(GetPointer(MHAttentionOut)))
      return false;
   if(!FF[0].UpdateInputWeights(GetPointer(AttentionOut)))
      return false;
   if(!FF[1].UpdateInputWeights(GetPointer(FF[0])))
      return false;
//---
   return true;
  }

Com isso, concluímos a revisão dos métodos para configurar processo de relações cruzadas. Você pode consultar o código completo da classe e todos os seus métodos no anexo. Agora passamos para a construção dos EAs de treinamento e teste de modelos.

2.2 Arquitetura dos modelos

Como pode ser observado na descrição teórica do método ADAPT, a abordagem proposta tem uma estrutura hierárquica bastante complexa. Para nós, isso se traduz em muitos modelos de treinamento. A descrição de sua arquitetura será dividida em 2 métodos. Primeiro, criaremos 3 modelos relacionados ao processo de previsão de pontos finais.

bool CreateTrajNetDescriptions(CArrayObj *encoder, CArrayObj *endpoints, CArrayObj *probability)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
   if(!endpoints)
     {
      endpoints = new CArrayObj();
      if(!endpoints)
         return false;
     }
   if(!probability)
     {
      probability = new CArrayObj();
      if(!probability)
         return false;
     }

O codificador de estado do ambiente recebe como entrada os dados brutos que descrevem 1 estado.

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

 Os dados recebidos são, como sempre, normalizados.

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

E geramos um embedding que adicionamos ao buffer de acumulação da sequência histórica.

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

Implementamos a codificação posicional.

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

Em seguida, vêm os blocos de atenção abrangente. E aqui, para facilitar a gestão da arquitetura do modelo, criaremos um ciclo para o número de iterações de repetição do bloco.

   for(int l = 0; l < Lenc; l++)
     {
      //--- layer 4
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronTransposeOCL;
      descr.count = prev_count;
      descr.window = prev_wout;
      if(!encoder.Add(descr))
        {
         delete descr;
         return false;
        }

De acordo com o algoritmo proposto pelos autores do método ADAPT, primeiro verificamos as relações entre as polilinhas (no nosso caso, estados) e os agentes. Antes de aplicar nosso bloco de relações cruzadas nessa direção, é necessário transpor o volume de informações obtido. E então adicionamos nossa nova camada.

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

Em seguida, vem o bloco de autoatenção das trajetórias.

      //--- layer 6
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronMLMHAttentionOCL;
      descr.count = prev_wout;
      descr.window = prev_count;
      descr.step = 8;
      descr.window_out = 16;
      descr.layers = 1;
      descr.optimization = ADAM;
      if(!encoder.Add(descr))
        {
         delete descr;
         return false;
        }

Depois, analisamos as relações em outra dimensão. Para isso, transponhamos os dados e repetimos os blocos de atenção.

      //--- layer 7
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronTransposeOCL;
      descr.count = prev_wout;
      descr.window = prev_count;
      if(!encoder.Add(descr))
        {
         delete descr;
         return false;
        }
      //--- layer 8
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronMH2AttentionOCL;
      descr.count = prev_count;
      descr.window = prev_wout;
      descr.step = 8;
      descr.window_out = 16;
      descr.layers = 1;
      descr.optimization = ADAM;
      if(!encoder.Add(descr))
        {
         delete descr;
         return false;
        }
      //--- layer 9
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronMLMHAttentionOCL;
      descr.count = prev_count;
      descr.window = prev_wout;
      descr.step = 8;
      descr.window_out = 16;
      descr.layers = 1;
      descr.optimization = ADAM;
      if(!encoder.Add(descr))
        {
         delete descr;
         return false;
        }
     }

Como mencionado anteriormente, o bloco do codificador é encapsulado em um ciclo. O número de iterações do ciclo é definido por uma constante.

#define        Lenc                    3             //Number ADAPT Encoder blocks

Assim, alterando uma constante, podemos rapidamente mudar a quantidade de blocos de atenção no codificador.

Os resultados do codificador são usados para prever vários conjuntos de pontos finais. O número desses conjuntos é determinado pela constante NForecast.

#define        NForecast               5             //Number of forecast

Como modelo de previsão de pontos finais, usaremos um simples MLP. Nele, os dados recebidos do codificador passam por camadas totalmente conectadas.

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

O estado latente é normalizado pela função SoftMax.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = LatentCount;
   descr.step = 1;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!endpoints.Add(descr))
     {
      delete descr;
      return false;
     }

E geramos os pontos finais na camada totalmente conectada.

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

O modelo de previsão das probabilidades de escolha de trajetórias também usa os resultados do codificador como dados de entrada.

//--- Probability
   probability.Clear();
//--- Input layer
   if(!probability.Add(endpoints.At(0)))
      return false;

Mas eles são analisados considerando os pontos finais previstos.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = 3 * NForecast;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }

Nesse caso, trabalhar com valores probabilísticos nos permite usar a camada SoftMax na saída do modelo.

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = NForecast;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = NForecast;
   descr.step = 1;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Além disso, podemos dizer que fizemos mudanças radicais no algoritmo do método ADAPT. Nossas alterações foram ditadas pela especificidade do trabalho nos mercados financeiros. No entanto, em minha opinião, elas não contradizem de forma alguma as abordagens propostas pelos autores do método.

A questão é que os autores do método propuseram seu algoritmo para resolver problemas de navegação de veículos autônomos. E aqui a qualidade da previsão da trajetória tem grande importância. Afinal, a colisão de 2 ou mais veículos em qualquer trecho da trajetória pode levar a consequências críticas.

No nosso caso, de trading nos mercados financeiros, mais atenção é dada aos pontos de controle. Para nós, não é tão importante a trajetória do movimento do preço e suas pequenas flutuações dentro do intervalo da tendência geral, mas sim os extremos de lucro máximo e rebaixamentos possíveis dentro desse movimento.

Por isso, excluímos o bloco de previsão de trajetória e o substituímos pelo modelo do Ator, que gerará os parâmetros da operação realizada. Mantivemos a abordagem geral para o treinamento dos modelos. Mas falaremos sobre isso mais tarde.

Aqui é importante dizer que nosso Ator utiliza 4 fontes de dados para tomar decisões:

  • Embedding do estado
  • Descrição do estado da conta
  • Conjuntos de pontos finais previstos
  • Probabilidades de cada conjunto de pontos finais previstos.

Anteriormente, criamos um mecanismo para combinar apenas 2 fluxos de informação. Para combinar 4 fluxos, construiremos uma cascata de modelos.

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

Os conjuntos de pontos finais previstos e suas probabilidades serão combinados em um embedding de pontos finais.

//--- Endpoints Encoder
   end_encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = 3 * NForecast;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!end_encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = NForecast;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!end_encoder.Add(descr))
     {
      delete descr;
      return false;
     }

O embedding do estado do ambiente será combinado com os parâmetros de saldo e posições abertas.

//--- State Encoder
   state_encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = GPTBars * EmbeddingSize;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!state_encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = AccountDescr;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!state_encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Os resultados do trabalho desses 2 modelos serão passados para o Ator para a tomada de decisão.

//--- Actor
   actor.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = LatentCount;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = LatentCount;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Dentro do Ator, usamos 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;
     }

E geramos seu comportamento estocástico.

//--- 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;
     }
//--- 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;
     }
//---
   return true;
  }

Como pode ser observado, planejamos usar arquiteturas de modelos o mais simples possível. O que é uma das vantagens do método ADAPT.

Neste artigo, decidi não me aprofundar na descrição detalhada dos EAs de interação com o ambiente. A estrutura dos dados coletados não mudou, assim como os métodos de interação com o ambiente. Claro, foram feitas alterações na sequência de chamadas dos modelos para a tomada de decisões. Mas isso, sugiro que você conheça por conta própria. Você pode encontrar o código dos EAs no anexo. Por outro lado, o EA de treinamento dos modelos tem alguns momentos únicos.

2.3 Treinamento de modelos


Ao contrário dos últimos artigos, desta vez treinaremos todos os modelos dentro de um único EA "...\Experts\ADAPT\Study.mq5". Isso está relacionado à passagem do gradiente de erro de quase todos os modelos para o codificador de estado do ambiente.

O método de inicialização do EA é construído de acordo com o esquema padrão. Primeiro, carregamos o conjunto de treinamento.

int OnInit()
  {
//---
   ResetLastError();
   if(!LoadTotalBase())
     {
      PrintFormat("Error of load study data: %d", GetLastError());
      return INIT_FAILED;
     }

Depois, em 2 etapas, carregamos os modelos criados anteriormente e, se necessário, criamos novos.

//--- load models
   float temp;
   if(!ADAPTEncoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true) ||
      !ADAPTEndpoints.Load(FileName + "Endp.nnw", temp, temp, temp, dtStudied, true) ||
      !ADAPTProbability.Load(FileName + "Prob.nnw", temp, temp, temp, dtStudied, true)
     )
     {
      CArrayObj *encoder = new CArrayObj();
      CArrayObj *endpoint = new CArrayObj();
      CArrayObj *prob = new CArrayObj();
      if(!CreateTrajNetDescriptions(encoder, endpoint, prob))
        {
         delete endpoint;
         delete prob;
         delete encoder;
         return INIT_FAILED;
        }
      if(!ADAPTEncoder.Create(encoder) ||
         !ADAPTEndpoints.Create(endpoint) ||
         !ADAPTProbability.Create(prob))
        {
         delete endpoint;
         delete prob;
         delete encoder;
         return INIT_FAILED;
        }
      delete endpoint;
      delete prob;
      delete encoder;
     }
   if(!StateEncoder.Load(FileName + "StEnc.nnw", temp, temp, temp, dtStudied, true) ||
      !EndpointEncoder.Load(FileName + "EndEnc.nnw", temp, temp, temp, dtStudied, true) ||
      !Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *endpoint = new CArrayObj();
      CArrayObj *encoder = new CArrayObj();
      if(!CreateDescriptions(actor, endpoint, encoder))
        {
         delete actor;
         delete endpoint;
         delete encoder;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || 
         !StateEncoder.Create(encoder) || 
         !EndpointEncoder.Create(endpoint))
        {
         delete actor;
         delete endpoint;
         delete encoder;
         return INIT_FAILED;
        }
      delete actor;
      delete endpoint;
      delete encoder;
      //---
     }

Transferimos todos os modelos para um contexto único OpenCL.

   OpenCL = Actor.GetOpenCL();
   StateEncoder.SetOpenCL(OpenCL);
   EndpointEncoder.SetOpenCL(OpenCL);
   ADAPTEncoder.SetOpenCL(OpenCL);
   ADAPTEndpoints.SetOpenCL(OpenCL);
   ADAPTProbability.SetOpenCL(OpenCL);

E realizamos o controle mínimo da arquitetura dos modelos.

   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;
     }
//---
   ADAPTEndpoints.getResults(Result);
   if(Result.Total() != 3 * NForecast)
     {
      PrintFormat("The scope of the Endpoints does not match forecat endpoints (%d <> %d)", 
                                                            3 * NForecast, Result.Total());
      return INIT_FAILED;
     }
//---
   ADAPTEncoder.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;
     }

Criamos um buffer auxiliar.

   if(!bGradient.BufferInit(MathMax(AccountDescr, NForecast), 0) ||
      !bGradient.BufferCreate(OpenCL))
     {
      PrintFormat("Error of create buffers: %d", GetLastError());
      return INIT_FAILED;
     }

E geramos um evento de usuário para iniciar o treinamento dos modelos.

   if(!EventChartCustom(ChartID(), 1, 0, 0, "Init"))
     {
      PrintFormat("Error of create study event: %d", GetLastError());
      return INIT_FAILED;
     }
//---
   return(INIT_SUCCEEDED);
  }

O processo de treinamento é realizado no método Train.

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

No corpo do método, primeiro criamos um vetor de probabilidades de escolha de trajetórias a partir do buffer de reprodução de experiência. Depois, criamos as variáveis locais necessárias.

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

O treinamento, como de costume, é realizado em um sistema de ciclos aninhados. No corpo do ciclo externo, amostramos a trajetória e o pacote de estados de treinamento nela.

   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;
        }
      ADAPTEncoder.Clear();
      int end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars);

No ciclo interno, o processo de treinamento dos modelos na sequência de dados históricos é construído.

      for(int i = state; i < end; i++)
        {
         bState.AssignArray(Buffer[tr].States[i].state);

Pegamos um estado do ambiente e o passamos para o Codificador.

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

Depois, formamos conjuntos de pontos finais previstos e suas probabilidades.

         if(!ADAPTEndpoints.feedForward((CNet*)GetPointer(ADAPTEncoder), -1, 
                                                             (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         if(!ADAPTProbability.feedForward((CNet*)GetPointer(ADAPTEncoder), -1, 
                                               (CNet*)GetPointer(ADAPTEndpoints)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Para realizar treinamento dos pontos finais, precisamos formar valores-alvo. Pegamos do buffer de reprodução de experiência os estados subsequentes até a profundidade de planejamento definida.

         targets = matrix<float>::Zeros(PrecoderBars, 3);
         for(int t = 0; t < PrecoderBars; t++)
           {
            target.Assign(Buffer[tr].States[i + 1 + t].state);
            if(target.Size() > BarDescr)
              {
               matrix<float> temp(1, target.Size());
               temp.Row(target, 0);
               temp.Reshape(target.Size() / BarDescr, BarDescr);
               temp.Resize(temp.Rows(), 3);
               target = temp.Row(temp.Rows() - 1);
              }
            targets.Row(target, t);
           }

Mas em vez de pegar o último estado, como poderia ser esperado ao definir pontos finais, procuramos os extremos mais próximos. Primeiro, calculamos o desvio acumulado do preço de fechamento de cada vela a partir do estado analisado. E aos valores obtidos, adicionamos os intervalos para High e Low de cada barra. Os resultados dos cálculos são salvos em uma matriz.

         target = targets.Col(0).CumSum();
         targets.Col(target, 0);
         targets.Col(target + targets.Col(1), 1);
         targets.Col(target + targets.Col(2), 2);

Na matriz obtida, encontramos o extremo mais próximo.

         int extr = 1;
         if(target[0] == 0)
            target[0] = target[1];
         int direct = (target[0] > 0 ? 1 : -1);
         for(int i = 1; i < PrecoderBars; i++)
           {
            if((target[i]*direct) < 0)
               break;
            extr++;
           }

E formamos um vetor dos extremos mais próximos encontrados.

         targets.Resize(extr, 3);
         if(direct >= 0)
           {
            target = targets.Max(AXIS_HORZ);
            target[2] = targets.Col(2).Min();
           }
         else
           {
            target = targets.Min(AXIS_HORZ);
            target[1] = targets.Col(1).Max();
           }

Entre os conjuntos de pontos finais previstos, identificamos o vetor com o menor desvio. E o substituímos pelos valores-alvo.

         ADAPTEndpoints.getResults(result);
         targets.Reshape(1, result.Size());
         targets.Row(result, 0);
         targets.Reshape(NForecast, 3);
         temp_m = targets;
         for(int i = 0; i < 3; i++)
            temp_m.Col(temp_m.Col(i) - target[i], i);
         temp_m = MathPow(temp_m, 2.0f);
         ulong pos = temp_m.Sum(AXIS_VERT).ArgMin();
         targets.Row(target, pos);

Usamos a matriz obtida para treinar o modelo de previsão de pontos finais.

         Result.AssignArray(targets);
         //---
         if(!ADAPTEndpoints.backProp(Result, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

O gradiente de erro é propagado para o modelo Codificador, e seus parâmetros são atualizados.

         if(!ADAPTEncoder.backPropGradient((CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Neste ponto, também treinamos o modelo de previsão das probabilidades das trajetórias. Mas o gradiente de erro dessa não é passado para outros modelos.

         bProbs.AssignArray(vector<float>::Zeros(NForecast));
         bProbs.Update((int)pos, 1);
         bProbs.BufferWrite();
         if(!ADAPTProbability.backProp(GetPointer(bProbs), GetPointer(ADAPTEndpoints)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Depois de atualizar os parâmetros dos modelos de previsão de pontos finais, passamos para o treinamento da política do nosso Ator. Para realizar as operações de propagação para frente do nosso Ator neste estágio, só falta o tensor de descrição do estado da conta e das posições abertas. Vamos formá-lo.

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

Em seguida, chamamos sequencialmente os métodos de propagação para frente da nossa cascata de modelos do Ator.

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

Aqui, devemos esclarecer que, em vez de usar os valores previstos dos conjuntos de pontos finais e suas probabilidades, usamos os tensores dos valores-alvo que usamos anteriormente para treinar os modelos correspondentes.

         //--- Endpoint embedding
         if(!EndpointEncoder.feedForward(Result, -1, false, (CBufferFloat*)GetPointer(bProbs)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         //--- Actor
         if(!Actor.feedForward((CNet *)GetPointer(StateEncoder), -1, 
                                                          (CNet*)GetPointer(EndpointEncoder)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Após a propagação para frente, devemos atualizar os parâmetros dos modelos. Para isso, precisamos dos valores-alvo. O método ADAPT pressupõe o treinamento do modelo de previsão de trajetórias com dados reais do buffer de reprodução de experiência. Poderíamos, como anteriormente, pegar as ações do Agente do buffer de reprodução de experiência. Mas, neste caso, não temos um mecanismo para avaliar e priorizar tais ações.

Nesta situação, decidi usar uma abordagem diferente. Como já temos os valores-alvo dos pontos finais, construídos com base em dados reais do movimento subsequente de preços do conjunto de treinamento. Por que não os usar para gerar a "operação ideal" nas condições analisadas? Determinamos a direção e os níveis de negociação da "operação ideal". O volume da posição é definido considerando um risco de 1% do Equity por operação.

         result = vector<float>::Zeros(NActions);
         double value = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE_LOSS);
         double risk = AccountInfoDouble(ACCOUNT_EQUITY) * 0.01;
         if(direct > 0)
           {
            float tp = float(target[1] / _Point / MaxTP);
            result[1] = tp;
            int sl = int(MathMax(MathMax(target[1] / 3, -target[2]) / _Point, MaxSL/10));
            result[2] = float(sl) / MaxSL;
            result[0] = float(MathMax(risk / (value * sl), 0.01))+FLT_EPSILON;
           }
         else
           {
            float tp = float((-target[2]) / _Point / MaxTP);
            result[4] = tp;
            int sl = int(MathMax(MathMax((-target[2]) / 3, target[1]) / _Point, MaxSL/10));
            result[5] = float(sl) / MaxSL;
            result[3] = float(MathMax(risk / (value * sl), 0.01))+FLT_EPSILON;
           }

Ao calcular o volume da posição, usamos o Equity, pois, no momento da operação, pode haver posições abertas, cujo lucro (ou prejuízo) não está contabilizado no saldo da conta.

A posição "ideal" gerada dessa forma é usada para treinar os modelos do Ator.

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

O gradiente de erro do treinamento dos modelos do Ator é usado para atualizar os parâmetros do Codificador.

         if(!ADAPTEncoder.backPropGradient((CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Observe que, neste estágio, não atualizamos os parâmetros do modelo de previsão de pontos finais. Essa limitação é introduzida pelos autores do método ADAPT para aumentar a estabilidade do treinamento dos modelos.

Após atualizar os parâmetros de todos os modelos, resta informar o usuário sobre o andamento do processo de treinamento e passar para a próxima iteração do sistema de ciclos.

         //---
         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", "Endpoints", percent, 
                                                         ADAPTEndpoints.getRecentAverageError());
            str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Probability", percent, 
                                                       ADAPTProbability.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

No final do método, limpamos o campo de comentários no gráfico. Registramos no log os resultados do treinamento dos modelos. 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__, 
                                          "Endpoints", ADAPTEndpoints.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, 
                                      "Probability", ADAPTProbability.getRecentAverageError());
   ExpertRemove();
//---
  }

Com isso, concluímos a descrição da implementação da nossa visão do algoritmo utilizando MQL5. Você pode encontrar o código completo de todos os programas utilizados no artigo no anexo.


3. Teste

Acima, realizamos um grande trabalho para implementar o método ADAPT utilizando MQL5. Nossa implementação está longe do algoritmo original. No entanto, ela segue o espírito das abordagens propostas e explora a ideia original de análise abrangente das relações entre os objetos da cena analisada. Agora é hora de verificar os resultados do nosso trabalho em dados históricos reais no testador de estratégias.

O treinamento dos modelos foi realizado com dados históricos dos primeiros 7 meses de 2023 do instrumento EURUSD, time-frame H1. Os parâmetros de todos os indicadores analisados são usados por padrão.

O teste dos modelos treinados foi realizado com total conformidade aos parâmetros de treinamento. Apenas o intervalo temporal dos dados históricos foi alterado. Neste estágio, utilizamos dados históricos de agosto de 2023.

Aqui devo dizer que, como a estrutura dos dados coletados no processo de interação com o ambiente não mudou, não realizei uma nova coleta de dados de treinamento no meu experimento. Para treinar os modelos, usei os passes coletados no treinamento de modelos anteriores. Além disso, a abordagem proposta com o cálculo da "operação ideal" nos permite dispensar a necessidade de coletar passes adicionais que precisariam ser refinados e complementados para expandir o espaço de dados de treinamento.

Pode parecer que um único passe seja suficiente para treinar o modelo. No entanto, durante o treinamento, precisamos fornecer ao modelo o máximo de informações variadas possível, incluindo informações sobre o estado da conta e as posições abertas.

Com base nos testes realizados, posso afirmar a eficácia do método considerado. A simplicidade dos modelos permite treinar os modelos de forma bastante rápida. E a eficácia das abordagens propostas é confirmada pelos resultados do modelo treinado. Que permite gerar lucro tanto no conjunto de treinamento quanto no de teste.


Considerações finais

O método ADAPT discutido neste artigo representa uma abordagem inovadora para a previsão de trajetórias de agentes em vários cenários complexos. Essa abordagem é eficaz, requer poucos recursos computacionais e garante alta qualidade das previsões para cada agente na cena.

As melhorias introduzidas no método ADAPT incluem uma cabeça adaptativa, que aumenta a capacidade do modelo sem aumentar seu tamanho, bem como o uso de aprendizado dinâmico de pesos para melhor adaptação às situações individuais de cada agente. Essas inovações contribuem significativamente para a previsão eficaz de trajetórias.

Na parte prática do artigo, implementamos nossa visão das abordagens propostas utilizando MQL5. Realizamos o treinamento e teste dos modelos com dados históricos reais. Os resultados obtidos permitem concluir sobre a eficácia do método ADAPT e a possibilidade de usar suas variações para construir modelos e operá-los nas condições dos mercados financeiros.

No entanto, lembro mais uma vez que todos os programas utilizados na preparação do artigo são apresentados apenas como uma demonstração de possibilidade e não são otimizados para uso real nos mercados financeiros.


Links

  • ADAPT: Efficient Multi-Agent Trajectory Prediction with Adaptation
  • 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 pelo método Real-ORL
    3 Study.mq5  EA EA de treinamento de Modelos
    4 Test.mq5 EA EA para teste do 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/14143

    Arquivos anexados |
    MQL5.zip (3615.14 KB)
    Algoritmos de otimização populacionais: evolução de grupos sociais (Evolution of Social Groups, ESG) Algoritmos de otimização populacionais: evolução de grupos sociais (Evolution of Social Groups, ESG)
    Neste artigo, consideraremos o princípio de construção de algoritmos multipopulacionais e, como exemplo desse tipo de algoritmos, analisaremos a Evolução de Grupos Sociais (ESG), um novo algoritmo autoral. Analisaremos os conceitos principais, os mecanismos de interação entre populações e as vantagens desse algoritmo, bem como examinaremos seu desempenho em tarefas de otimização.
    Introdução ao MQL5 (Parte 3): Estudando os elementos básicos do MQL5 Introdução ao MQL5 (Parte 3): Estudando os elementos básicos do MQL5
    Neste artigo, continuamos a estudar os fundamentos da programação em MQL5. Vamos abordar arrays, funções personalizadas, pré-processadores e manipulação de eventos. Para maior clareza, cada passo de todas as explicações será acompanhado por código. Esta série de artigos estabelece a base para o estudo do MQL5, com ênfase na explicação de cada linha de código.
    Algoritmos de otimização populacionais: objetos de busca multissociais artificiais (artificial Multi-Social search Objects, MSO) Algoritmos de otimização populacionais: objetos de busca multissociais artificiais (artificial Multi-Social search Objects, MSO)
    Continuação do artigo anterior como desenvolvimento da ideia de grupos sociais. No novo artigo, explora-se a evolução dos grupos sociais utilizando algoritmos de movimentação e memória. Os resultados ajudarão a entender a evolução dos sistemas sociais e aplicá-los na otimização e busca de soluções.
    Tipo de desenho DRAW_ARROW em indicadores multissímbolos e multiperíodos Tipo de desenho DRAW_ARROW em indicadores multissímbolos e multiperíodos
    No artigo, vamos considerar o desenho de indicadores multissímbolos e multiperíodos com setas. Aprimoraremos os métodos da classe para a correta exibição das setas, que exibem dados dos indicadores de seta calculados em símbolo/período diferentes do símbolo/período do gráfico atual.