English Русский Español Deutsch 日本語
preview
Redes neurais de maneira fácil (Parte 71): Previsão de estados futuros com base em objetivos (GCPC)

Redes neurais de maneira fácil (Parte 71): Previsão de estados futuros com base em objetivos (GCPC)

MetaTrader 5Sistemas de negociação | 14 junho 2024, 17:00
253 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introdução

O aprendizado por imitação (Behavior Cloning — BC), voltado para o alcance de objetivos, é uma abordagem promissora para resolver várias tarefas de aprendizado por reforço em um cenário off-line. Em vez de avaliar o valor dos estados e ações, o BC treina diretamente a política de comportamento do Agente, criando dependências entre o objetivo definido, o estado do ambiente analisado e a ação do Agente. Isso é alcançado através de métodos de aprendizado supervisionado em trajetórias off-line previamente coletadas. O método Decision Transformer, que conhecemos, e seus algoritmos derivados demonstraram eficácia na modelagem de sequências para aprendizado por reforço em um cenário off-line.

Anteriormente, ao usar os algoritmos mencionados, experimentamos com diferentes maneiras de definir objetivos para estimular as ações necessárias do Agente. O estudo da trajetória já percorrida pelo modelo sempre ficou fora de nosso foco. E aqui podemos nos perguntar sobre a relevância de estudar a trajetória em geral. Essa pergunta também foi levantada pelos autores do artigo «Goal-Conditioned Predictive Coding for Offline Reinforcement Learning». Em seu trabalho, eles exploram várias questões importantes:

  1. As trajetórias off-line são úteis para a modelagem de sequências ou apenas fornecem mais dados para o treinamento da política com supervisão?

  2. Quais serão os objetivos de aprendizado mais eficazes para representar a trajetória e apoiar o treinamento da política? Os modelos de sequências devem aprender a codificar a experiência passada, a dinâmica futura ou ambos?

  3. Como a mesma modelo de sequência pode ser usada tanto para o aprendizado da representação da trajetória quanto para o treinamento da política, devemos ter os mesmos objetivos de aprendizado ou não?

O artigo apresenta os resultados de experimentos em 3 ambientes artificiais, que permitem aos autores tirar as seguintes conclusões:

  • a modelagem de sequências, se projetada corretamente, pode efetivamente ajudar na tomada de decisões, quando a representação da trajetória obtida é usada como entrada para o treinamento da política;

  • existe uma divergência entre o objetivo ideal de aprendizado da representação da trajetória e o objetivo de aprendizado da política.

Essas observações levam os autores do artigo a criar uma estrutura de duas etapas, que comprime a informação da trajetória em representações compactas usando o pré-treinamento da modelagem de sequência. A representação compactada é então usada para treinar a política de comportamento do Agente utilizando um modelo baseado em perceptron multicamadas (MLP). O método proposto por eles, Goal-Conditioned Predictive Coding (GCPC), é o objetivo de aprendizado mais eficaz para a representação de trajetória. Ele garante desempenho competitivo em todas as tarefas de teste realizadas pelos autores. Destaca-se especialmente sua eficácia para resolver tarefas com um horizonte de planejamento longo. A forte performance empírica do GCPC é alcançada graças à representação latente dos estados passados e previstos. Ao mesmo tempo, a previsão de estados é orientada pelos objetivos estabelecidos, proporcionando uma orientação fundamental para a tomada de decisões.

1. Algoritmo Goal-Conditioned Predictive Coding

Os autores do método GCPC utilizam a modelagem de sequências para aprendizado por reforço em um cenário off-line. Para resolver o problema de aprendizado por reforço off-line, é usado o aprendizado por imitação condicional, filtrado ou ponderado. Pressupõe-se a existência de um conjunto de dados de treinamento previamente coletados. Mas as políticas usadas para coletar os dados podem ser desconhecidas. Os dados de treinamento contêm um conjunto de trajetórias. Cada trajetória é representada como um conjunto de estados e ações (St, At). A trajetória pode opcionalmente conter a recompensa Rt recebida no passo de tempo t.

Como as trajetórias são coletadas usando políticas desconhecidas, elas podem não ser ótimas ou não possuir nível suficiente de expertise. Já discutimos que o uso correto de trajetórias off-line, que contêm dados subótimos, pode levar à criação de políticas de comportamento mais eficazes. Isso porque trajetórias subótimas podem conter subtrajetórias que demonstram "habilidades" úteis, que podem ser combinadas para resolver as tarefas em questão.

Os autores do método acreditam que a política de comportamento do Agente deve ser capaz de aceitar qualquer forma de informação sobre o estado ou trajetória como entrada e prever a próxima ação:

  • quando é usado apenas o estado observado atual St e o objetivo G, a política do Agente ignora o histórico de observações; 
  • quando a política do Agente é um modelo de sequência, ela pode usar toda a trajetória observada para prever a próxima ação At.

Para otimizar a política de comportamento do Agente, normalmente é usada a função objetivo de máxima verossimilhança.

A modelagem de sequência pode ser usada para a tomada de decisões sob duas perspectivas: aprendizado de representações de trajetórias e aprendizado da política de comportamento. A primeira direção busca obter representações úteis das trajetórias de entrada brutas na forma de uma representação latente compacta ou pesos de uma rede previamente treinada. A segunda direção busca transformar a observação e o objetivo na ação otimizada para a tarefa.

O aprendizado da função de trajetória e da função de política pode ser realizado usando modelos Transformers. Os autores do método GCPC acreditam que é útil para a função de trajetória comprimir os dados de entrada em uma representação compacta, usando técnicas de modelagem de sequência. Também é desejável separar o treinamento da representação da trajetória e o treinamento da política. Essa separação não apenas oferece flexibilidade na escolha dos objetivos de treinamento das representações, mas também permite estudar o impacto da modelagem da sequência no treinamento das representações da trajetória e no treinamento da política de forma independente. Por isso, no GCPC é utilizada uma estrutura de dois estágios com TrajNet (modelo de trajetória) e PolicyNet (modelo de política). Para o treinamento do TrajNet, são usados métodos de aprendizado não supervisionado para modelagem de sequência, como autocodificador mascarado ou previsão do próximo token. O PolicyNet visa obter uma política eficaz usando uma função objetivo de aprendizado supervisionado a partir das trajetórias coletadas off-line.

No primeiro estágio, o treinamento da representação da trajetória utiliza o autocodificador mascarado. TrajNet recebe a trajetória e, se necessário, a meta G, e é treinado para restaurar τ a partir da forma mascarada da mesma trajetória. Opcionalmente, TrajNet também gera uma representação compacta da trajetória B, que pode ser usada pelo PolicyNet para o posterior treinamento da política. Em seu trabalho, os autores do método GCPC propõem que a entrada do modelo do autocodificador seja a representação mascarada da trajetória percorrida. O objetivo do decodificador é obter a representação não mascarada da trajetória percorrida e dos estados subsequentes.

No segundo estágio, o TrajNet é aplicado à trajetória observada não mascarada para obter a representação compacta da trajetória B. Em seguida, o PolicyNet prevê a ação A dada a trajetória percorrida (ou o estado atual do ambiente), a meta G e a representação compacta da trajetória B.

A estrutura proposta fornece uma representação unificada para a comparação de diferentes designs de implementação de aprendizado de representações e aprendizado de políticas. Muitos métodos existentes podem ser considerados como casos especiais da estrutura proposta. Por exemplo, para a implementação do DT, a função de representação da trajetória é definida como uma função de identidade da trajetória de entrada, e a política é treinada para gerar ações autorregressivamente.

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

2. Implementação com MQL5

Após considerar os aspectos teóricos do método Goal-Conditioned Predictive Coding, passamos para sua implementação com MQL5. Aqui, em primeiro lugar, é importante prestar atenção à quantidade variável de modelos usados nas diferentes etapas do treinamento e da utilização do modelo.

2.1 Arquitetura dos modelos

No primeiro estágio, os autores do método propõem treinar o modelo de representações da trajetória. Na arquitetura desse modelo, é usado um transformador. Para seu treinamento, precisamos construir um autocodificador. No entanto, no segundo estágio, usaremos apenas o Codificador treinado. Desse modo, para não "carregar" o decodificador desnecessário no segundo estágio do treinamento, dividiremos o autocodificador em 2 modelos: Codificador e Decodificador. Vamos representar a arquitetura dos modelos no método CreateTrajNetDescriptions. Nos parâmetros, o método recebe ponteiros para dois arrays dinâmicos para especificar a arquitetura dos modelos indicados.

No corpo do método, verificamos os ponteiros recebidos e, se necessário, criamos novos objetos de arrays dinâmicos.

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

Primeiro, descreveremos a arquitetura do Codificador. Alimentamos a entrada do modelo apenas com dados históricos de movimentos de preços e indicadores analisados.  

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

Observe que, ao contrário dos modelos discutidos anteriormente, nesta etapa não utilizamos nem os dados do estado da conta nem as informações das ações anteriores do Agente. Há uma percepção de que, em alguns casos, as informações sobre ações anteriores podem ter um impacto negativo. E os autores do método GCPC as excluíram dos dados de entrada. As informações sobre o estado da conta não influenciam os estados do ambiente. Consequentemente, para prever os estados subsequentes do ambiente, essas informações são irrelevantes.

Como sempre, alimentamos o modelo com os dados de entrada não processados. Portanto, a próxima camada que utilizamos é a normalização em lote para colocar os dados originais em um formato comparável.

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

Após o pré-processamento dos dados, precisamos implementar a mascaragem aleatória dos dados, conforme previsto pelo algoritmo GCPC. Para implementar essa funcionalidade, utilizaremos a camada DropOut.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronDropoutOCL;
   descr.count = prev_count;
   descr.probability = 0.8f;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Aqui é importante notar que, na prática geral, não é recomendado o uso conjunto de uma camada de normalização em lote e DropOut no mesmo modelo. Isso ocorre porque a exclusão de algumas informações e sua substituição por valores nulos distorce a distribuição original dos dados e tem um impacto negativo no desempenho da camada de normalização em lote. Por essa razão, primeiro normalizamos os dados e só depois mascaramos. Dessa forma, a camada de normalização em lote trabalha com o conjunto completo de dados e minimiza-se o impacto da camada DropOut em seu funcionamento. Ao mesmo tempo, implementamos a funcionalidade de mascaragem para treinar nosso modelo a restaurar os dados faltantes e ignorar os "valores atípicos" característicos de um ambiente estocástico.

Em seguida, na arquitetura do nosso Codificador, temos um bloco convolucional para reduzir a dimensionalidade dos dados e extrair padrões consistentes.

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = HistoryBars;
   descr.window = BarDescr;
   descr.step = BarDescr;
   int prev_wout = descr.window_out = BarDescr / 2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = prev_wout;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count;
   descr.window = prev_wout;
   descr.step = prev_wout;
   prev_wout = descr.window_out = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = prev_wout;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

O resultado do processamento dos dados de entrada descrito acima é alimentado em um bloco de camadas totalmente conectadas, o que nos permite obter a incorporação (embedding) do estado original.

//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = EmbeddingSize;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Além dos dados históricos, os autores do método GCPC sugerem fornecer na entrada do Codificador a incorporação da meta e Slot tokens (resultados das passagens anteriores dos Codificadores). Nossa meta global, obter o máximo lucro possível, não influencia os estados do ambiente e, por isso, a omitimos. No entanto, adicionaremos os resultados da última passagem do nosso Codificador no modelo utilizando a camada de concatenação.

//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = 2 * EmbeddingSize;
   descr.window = prev_count;
   descr.step = EmbeddingSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

O processamento adicional dos dados será realizado utilizando o modelo GPT. Para sua implementação, primeiro criaremos uma pilha de dados com a camada de incorporação.

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

Em seguida, vem o bloco de atenção. Já criamos a dispersão de dados com a camada DropOut anteriormente, por isso, neste modelo, eu não utilizei a camada de atenção dispersa. 

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

Na saída do Codificador, reduzimos a dimensionalidade dos dados com uma camada totalmente conectada e normalizamos os dados com a função SoftMax.

//--- layer 12
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = EmbeddingSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 13
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = 1;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

A representação compacta da trajetória percorrida será envidada como entrada para o Decodificador.

//--- Decoder
   decoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = EmbeddingSize;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

Os dados de entrada do Decodificador são provenientes do modelo anterior e já estão em uma forma comparável. Isso significa que a camada de normalização em lote é desnecessária neste caso. Desenvolvemos os dados obtidos com uma camada totalmente conectada.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = (HistoryBars + PrecoderBars) * EmbeddingSize;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

Depois, processamos na camada de atenção.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMLMHAttentionOCL;
   prev_count = descr.count = prev_count / EmbeddingSize;
   prev_wout = descr.window = EmbeddingSize;
   descr.step = 4;
   descr.window_out = 16;
   descr.layers = 2;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

Estruturamos a arquitetura do nosso Decodificador de maneira que, na saída do bloco de atenção, temos uma incorporação para cada candle dos estados do ambiente analisados e previstos. E aqui precisamos "separar o joio do trigo". Proponho uma reflexão.

Por que analisamos indicadores? Indicadores de tendência nos mostram a direção da tendência. Osciladores indicam zonas de sobrecompra e sobrevenda, apontando possíveis pontos de reversão do mercado. Tudo isso é valioso no momento atual. Mas qual é o valor de prever esses dados a uma certa profundidade? Minha opinião pessoal é que, considerando a margem de erro na previsão desses dados, o valor da previsão dos indicadores se aproxima de "0". No final, obtemos lucro e prejuízo da variação do preço do ativo, não dos valores dos indicadores. Portanto, no Decodificador, preveremos os dados de movimento de preços.

Devemos lembrar quais informações sobre o movimento de preços armazenamos no buffer de reprodução de experiência. Isso representa 3 desvios:

  • corpo do candle (close - open)
  • da abertura ao máximo (high - open)
  • da abertura ao mínimo (low - open).

Esses serão os dados que prevemos. E para a restauração independente dos valores a partir da incorporação dos candles, usaremos a camada do conjunto de modelos.

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMultiModels;
   descr.count = 3;
   descr.window = prev_wout;
   descr.step = prev_count;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Com isso, concluímos a descrição da arquitetura do autocodificador para o primeiro estágio do treinamento da representação das trajetórias TrajNet. Mas, antes de passar a trabalhar nos experts de treinamento dos modelos, proponho finalizar a descrição da arquitetura dos modelos. Vamos examinar a arquitetura dos modelos do segundo estágio de treinamento da política PolicyNet, apresentada no método CreateDescriptions.

Vale dizer que, ao contrário do esperado, no segundo estágio, treinaremos não apenas 1 modelo de política de comportamento do Ator, mas 3 modelos.

O primeiro é um pequeno modelo de Codificador do estado atual. Por favor, não confunda com o Codificador do Autocodificador treinado no primeiro estágio. Este modelo combinará a representação compacta da trajetória do Codificador do Autocodificador com informações sobre o estado da conta.

O segundo é o modelo da política do Ator, sobre o qual já falamos bastante.

E o terceiro é o modelo de definição de metas com base na análise da representação compacta da trajetória.

Como de costume, nos parâmetros do método passamos ponteiros para os arrays dinâmicos que descrevem a arquitetura dos modelos. No corpo do método, verificamos a validade dos ponteiros recebidos e, se necessário, criamos novas instâncias dos objetos dos arrays dinâmicos.

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

Conforme mencionado acima, alimentamos a entrada do Codificador com uma representação compacta da trajetória.

//--- State Encoder
   encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = EmbeddingSize;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Os dados obtidos são combinados com as informações sobre o estado da conta na camada de concatenação.

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

Nesse ponto, as tarefas definidas para o Codificador são consideradas concluídas e nós passamos para a arquitetura do Ator, que recebe na entrada os resultados do trabalho do modelo anterior.

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

Os dados obtidos são combinados com o objetivo estabelecido.

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

E processamos com camadas totalmente conectadas.

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

Na saída do Ator, adicionamos estocasticidade à política de seu comportamento.

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

E por último, mas não menos importante, o modelo de geração de objetivos. Acredito que não é segredo para ninguém que as possibilidades de geração de lucro dependem fortemente de vários aspectos do estado do ambiente. Portanto, com base na experiência anterior, foi decidido adicionar um modelo separado de geração de objetivos, dependendo do estado do ambiente.

Na entrada do modelo, forneceremos uma representação compacta da trajetória percorrida. Especificamente a trajetória sem levar em conta o estado da conta. Nossa função de recompensa é construída de tal forma que opera com valores relativos sem se vincular a um tamanho específico de depósito. Portanto, também para definir metas, procedemos apenas com a análise do ambiente sem levar em conta o estado da conta.

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

Os dados obtidos são analisados por 2 camadas totalmente conectadas.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!goal.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!goal.Add(descr))
     {

      delete descr;
      return false;
     }

E na saída do modelo, usamos uma função quantílica totalmente parametrizada. Uma vantagem dessa abordagem é o retorno do resultado mais provável, e não do valor médio, característico de uma camada totalmente conectada. As diferenças nos resultados são mais perceptíveis para distribuições com 2 ou mais picos.

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFQF;
   descr.count = NRewards;
   descr.window_out = 32;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!goal.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

2.2 Modelo de interação com o ambiente

Continuamos nosso trabalho na implementação do método Goal-Conditioned Predictive Coding. E depois descrever a arquitetura dos modelos, passamos para a implementação direta dos algoritmos. Primeiro, implementamos um Expert Advisor para interagir com o ambiente e coletar dados para o conjunto de treinamento. Os autores do método não focaram no método de coleta de dados de treinamento. Na verdade, o conjunto de treinamento pode ser coletado de qualquer forma disponível, incluindo os algoritmos discutidos anteriormente ExORL e Real-ORL. É necessário apenas que os formatos de gravação e apresentação de dados sejam compatíveis. Mas para otimizar os modelos pré-treinados, precisamos de um Expert Advisor que, durante a interação com o ambiente, use a política de comportamento que aprendemos e salve os resultados da interação na trajetória. Essa funcionalidade será implementada no Expert Advisor «...\Experts\GCPC\Research.mq5». Os princípios básicos do algoritmo EA foram emprestados de trabalhos anteriores. No entanto, o número de modelos utilizados deixa sua marca. E nos concentraremos em alguns métodos do Expert Advisor.

Neste Expert Advisor, usaremos 4 modelos.

CNet                 Encoder;
CNet                 StateEncoder;
CNet                 Actor;
CNet                 Goal;

O carregamento dos modelos pré-treinados é realizado no método de inicialização do Expert Advisor OnInit. Não repetirei completamente o código do método, que você pode encontrar no anexo. Vou apenas destacar as mudanças.

Primeiro, carregamos o modelo do Autocodificador Codificador. Em caso de erro de carregamento, inicializamos um novo modelo com parâmetros aleatórios.

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
........
........
//--- load models
   float temp;
   if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *encoder = new CArrayObj();
      CArrayObj *decoder = new CArrayObj();
      if(!CreateTrajNetDescriptions(encoder, decoder))
        {
         delete encoder;
         delete decoder;
         return INIT_FAILED;
        }
      if(!Encoder.Create(encoder))
        {
         delete encoder;
         delete decoder;
         return INIT_FAILED;
        }
      delete encoder;
      delete decoder;
      //---
     }

Em seguida, carregamos os 3 modelos restantes. Que, se necessário, também inicializamos com parâmetros aleatórios.

   if(!StateEncoder.Load(FileName + "StEnc.nnw", temp, temp, temp, dtStudied, true) ||
      !Goal.Load(FileName + "Goal.nnw", temp, temp, temp, dtStudied, true) ||
      !Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *goal = new CArrayObj();
      CArrayObj *encoder = new CArrayObj();
      if(!CreateDescriptions(actor, goal, encoder))
        {
         delete actor;
         delete goal;
         delete encoder;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || !StateEncoder.Create(encoder) || !Goal.Create(goal))
        {
         delete actor;
         delete goal;
         delete encoder;
         return INIT_FAILED;
        }
      delete actor;
      delete goal;
      delete encoder;
      //---
     }

Transferimos todos os modelos para um único contexto OpenCL.

   StateEncoder.SetOpenCL(Actor.GetOpenCL());
   Encoder.SetOpenCL(Actor.GetOpenCL());
   Goal.SetOpenCL(Actor.GetOpenCL());

E, obrigatoriamente, desativamos o modo de treinamento do modelo do Codificador.

   Encoder.TrainMode(false);

Observe que, embora não planejemos usar métodos de retropropagação neste Expert Advisor, a aplicação da camada DropOut no Codificador nos obriga a mudar o modo de treinamento para desativar a máscara em condições de uso do modelo.

E verificamos a compatibilidade das arquiteturas dos modelos carregados.

   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;
     }
   Encoder.getResults(Result);
   if(Result.Total() != EmbeddingSize)
     {
      PrintFormat("The scope of the Encoder does not match the embedding size (%d <> %d)", EmbeddingSize, 
                                                                                                  Result.Total());
      return INIT_FAILED;
     }
//---
   Encoder.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;
     }
//---
   PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE);
   PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY);
//---
   return(INIT_SUCCEEDED);
  }

A interação com o ambiente é realizada no método OnTick. No início do método, verificamos a ocorrência de um novo evento de abertura de barra e, se necessário, carregamos dados históricos do terminal. Transferimos as informações obtidas para os buffers de dados. Essas operações foram transferidas completamente sem mudanças, e não vamos nos aprofundar nelas. Vamos apenas considerar a sequência de chamadas dos métodos de propagação dos modelos. Conforme previsto pelo algoritmo GCPC, primeiro chamamos o método de propagação do Codificador.

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
//---
........
........
//---
   if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CNet*)GetPointer(Encoder)) ||

Observe que, como fonte de dados para o segundo fluxo de informações, o modelo se usa recursivamente.

Em seguida, chamamos o método de propagação do Codificador de estado e do modelo de definição de objetivos. Ambos os modelos usam como dados de entrada a representação compactada da trajetória percorrida.

      !StateEncoder.feedForward((CNet *)GetPointer(Encoder), -1, (CBufferFloat *)GetPointer(bAccount)) ||
      !Goal.feedForward((CNet *)GetPointer(Encoder), -1, (CBufferFloat *)NULL) ||

Os resultados desses modelos são fornecidos na entrada do modelo de política do Ator para a geração da ação subsequente.

      !Actor.feedForward((CNet *)GetPointer(StateEncoder), -1, (CNet *)GetPointer(Goal)))
      return;

E não nos esquecemos de verificar os resultados da execução das operações.

Depois, é realizado a decodificação dos resultados do trabalho do modelo do Ator e a execução das ações no ambiente, seguido pelo salvamento da experiência obtida na trajetória. O algoritmo dessas operações foi transferido sem alterações, e sugiro não nos aprofundarmos agora na sua consideração. Você pode encontrar o código completo do Expert Advisor de interação com o ambiente no anexo.

2.3 Treinamento da função de trajetória

Após a coleta dos dados do conjunto de treinamento, passamos para a construção dos Expert Advisors de treinamento dos modelos. E de acordo com o algoritmo GCPC, a primeira etapa é o treinamento do modelo da função de trajetória TrajNet. Essa funcionalidade será implementada no Expert Advisor «...\Experts\GCPC\StudyEncoder.mq5».

Como foi mencionado na parte teórica deste artigo, na primeira etapa treinamos o modelo do Autocodificador mascarado, que no nosso caso consiste em 2 modelos: Codificador e Decodificador.

//+------------------------------------------------------------------+
//| Input parameters                                                 |
//+------------------------------------------------------------------+
input int                  Iterations     = 1e4;
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
STrajectory          Buffer[];
CNet                 Encoder;
CNet                 Decoder;

E aqui vale a pena destacar um ponto. O Codificador usa recursivamente seus próprios resultados do passe anterior como dados de entrada para o segundo fluxo de informações. E, embora para uma propagação possamos simplesmente usar um ponteiro para o próprio modelo, essa abordagem não é aceitável para uma retropropagação. Afinal, o buffer de resultados do modelo conterá dados da última passagem, e não da anterior. Isso não é aceitável para nosso processo de treinamento do modelo. Portanto, precisamos de um buffer de dados adicional para armazenar os resultados da passagem anterior.

CBufferFloat         LastEncoder;

No método de inicialização do Expert Advisor, primeiro carregamos o conjunto de treinamento e verificamos o resultado das operações. Afinal, na ausência de dados para o treinamento dos modelos, todas as operações subsequentes não fazem sentido.

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

Após o carregamento bem-sucedido do conjunto de treinamento, tentamos abrir os modelos pré-treinados. E se ocorrer um erro, inicializamos novos modelos com parâmetros aleatórios.

//--- load models
   float temp;
   if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true) ||
      !Decoder.Load(FileName + "Dec.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Init new models");
      CArrayObj *encoder = new CArrayObj();
      CArrayObj *decoder = new CArrayObj();
      if(!CreateTrajNetDescriptions(encoder, decoder))
        {
         delete encoder;
         delete decoder;
         return INIT_FAILED;
        }
      if(!Encoder.Create(encoder) || !Decoder.Create(decoder))
        {
         delete encoder;
         delete decoder;
         return INIT_FAILED;
        }
      delete encoder;
      delete decoder;
      //---
     }

 Colocamos os dois modelos em um único contexto OpenCL.

   OpenCL = Encoder.GetOpenCL();
   Decoder.SetOpenCL(OpenCL);

E verificamos se as arquiteturas do modelo correspondem.

   Encoder.getResults(Result);
   if(Result.Total() != EmbeddingSize)
     {
      PrintFormat("The scope of the Encoder does not match the embedding size count (%d <> %d)", EmbeddingSize,
                                                                                                 Result.Total());
      return INIT_FAILED;
     }
//---
   Encoder.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;
     }
//---
   Decoder.GetLayerOutput(0, Result);
   if(Result.Total() != EmbeddingSize)
     {
      PrintFormat("Input size of Decoder doesn't match Encoder output (%d <> %d)", Result.Total(), EmbeddingSize);
      return INIT_FAILED;
     }

Depois de passar o bloco de controle com sucesso, inicializamos os buffers auxiliares no mesmo contexto do OpenCL.

   if(!LastEncoder.BufferInit(EmbeddingSize,0) ||
      !Gradient.BufferInit(EmbeddingSize,0) ||
      !LastEncoder.BufferCreate(OpenCL) ||
      !Gradient.BufferCreate(OpenCL))
     {
      PrintFormat("Error of create buffers: %d", GetLastError());
      return INIT_FAILED;
     }

No final do método de inicialização do EA, geramos um evento personalizado para iniciar o processo de treinamento.

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

O próprio processo de aprendizagem é realizado diretamente no método Train. Nele, combinamos tradicionalmente o algoritmo Goal-Conditioned Predictive Coding com nossos desenvolvimentos de artigos anteriores. No início do método, criamos um vetor de probabilidades de uso das trajetórias para o treinamento dos modelos.

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

No entanto, vale notar que, neste caso, o efeito prático da ponderação das trajetórias é inexistente. Afinal, no processo de treinamento do Autocodificador, usamos apenas dados históricos de movimentação de preços e indicadores analisados. Todas as trajetórias são coletadas em um único intervalo histórico de um instrumento. Portanto, no entendimento do nosso Autocodificador, todas as trajetórias contêm dados idênticos. No entanto, o funcional foi mantido "com vistas ao futuro" para possibilitar o treinamento de modelos em trajetórias de diferentes intervalos de tempo e instrumentos.

Em seguida, inicializamos variáveis locais e vetores. Aqui, chama a atenção o vetor de desvios quadráticos médios. Seu tamanho é igual ao vetor de resultados do Decodificador. Os princípios de sua utilização serão explicados mais adiante.

   vector<float> result, target;
   matrix<float> targets;
   STD = vector<float>::Zeros((HistoryBars + PrecoderBars) * 3);
   int std_count = 0;
   uint ticks = GetTickCount();

Após o trabalho preparatório, organizamos o sistema de ciclos de treinamento dos modelos. Lembro que o Autocodificador usa um bloco GPT com uma pilha de estados latentes, que é sensível à sequência dos dados de entrada. Portanto, ao treinar os modelos, usaremos pacotes inteiros de estados sequenciais de cada trajetória amostrada.

No corpo do ciclo externo, amostramos uma trajetória considerando as probabilidades geradas anteriormente e escolhemos aleatoriamente o estado inicial nela.

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

Depois, limpamos as pilhas dos modelos e o buffer de resultados anteriores do Codificador.

      Encoder.Clear();
      Decoder.Clear();
      LastEncoder.BufferInit(EmbeddingSize,0);

Agora, tudo está pronto para iniciar o ciclo de treinamento na trajetória selecionada.

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

No corpo do ciclo, preenchemos o buffer de dados de entrada do conjunto de treinamento e chamamos sequencialmente os métodos de propagação dos nossos modelos. Primeiro, o do Codificador.

         if(!LastEncoder.BufferWrite() || !Encoder.feedForward((CBufferFloat*)GetPointer(State), 1, false, 
                                                               (CBufferFloat*)GetPointer(LastEncoder)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

Depois, o do Decodificador.

         if(!Decoder.feedForward(GetPointer(Encoder), -1, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

Depois de uma propagação bem-sucedida de nossos modelos, temos que realizar uma retropropagação com ajustes nos parâmetros dos modelos. Mas primeiro, devemos preparar os valores alvo dos resultados do Decodificador. Como você deve lembrar, na saída do Decodificador planejamos obter valores reconstruídos e resultados das previsões das flutuações de preços para várias velas, que estão indicados nos primeiros três elementos do array de descrição do estado de cada candle. Para obter esses dados, criaremos uma matriz na qual salvaremos as descrições do estado do ambiente no intervalo de tempo que nos interessa. Depois, pegamos apenas as três primeiras colunas da matriz resultante. Estes serão nossos valores alvo.

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

Inspirado pelos resultados do artigo anterior sobre o uso de operadores em forma fechada, decidi modificar o processo de treinamento e focar mais nos grandes desvios. Assim, ignoro pequenos desvios como erro de previsão. Portanto, nesta etapa, calculo o desvio quadrático médio móvel dos resultados do modelo em relação aos valores alvo.

         Decoder.getResults(result);
         vector<float> error = target - result;
         std_count = MathMin(std_count, 999);
         STD = MathSqrt((MathPow(STD, 2) * std_count + MathPow(error, 2)) / (std_count + 1));
         std_count++;

Aqui, é importante notar que controlamos o desvio de cada parâmetro separadamente.

Depois, verificamos se o erro de previsão atual excede o valor limite. A retropropagação é realizada apenas se o erro de previsão exceder o valor limite pelo menos para um parâmetro.

         vector<float> check = MathAbs(error) - STD * STD_Multiplier;
         if(check.Max() > 0)
           {
            //---
            Result.AssignArray(CAGrad(error) + result);
            if(!Decoder.backProp(Result, (CNet *)NULL) ||
               !Encoder.backPropGradient(GetPointer(LastEncoder), GetPointer(Gradient)))
              {
               PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
               break;
              }
           }

Vale a pena destacar que essa abordagem tem várias nuances. O erro médio do modelo é considerado apenas ao realizar a retropropagação. Portanto, o erro atual afeta o erro médio apenas quando excede o valor limite. Como resultado, pequenos erros que ignoramos não influenciam no valor do erro médio do modelo. Assim, obtemos uma superestimação desse indicador. Para nós, isso não é crítico, pois o indicador é puramente informativo.

A "outra face da moeda" é que, focando apenas nos grandes desvios, ajudamos o modelo a destacar os principais fatores que influenciam certos indicadores. E o uso do desvio quadrático médio móvel como referência para o valor limite permite reduzir o limite de erro admissível durante o treinamento. Isso leva a um ajuste mais fino do modelo.

No final das iterações do ciclo, salvamos os resultados do trabalho do Codificador no buffer auxiliar e informamos o usuário sobre o andamento do processo de treinamento dos modelos.

         Encoder.getResults(result);
         LastEncoder.AssignArray(result);
         //---
         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", "Decoder", percent, Decoder.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

Após completar com sucesso todas as iterações do nosso sistema de ciclos de treinamento, como de costume, limpamos o campo de comentários no gráfico do instrumento, registramos os resultados do treinamento e encerramos o trabalho do Expert Advisor.

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

Não se esqueça de salvar os modelos treinados e liberar a memória no método de desinicialização do Expert Advisor.

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//---
   if(!(reason == REASON_INITFAILED || reason == REASON_RECOMPILE))
     {
      Encoder.Save(FileName + "Enc.nnw", 0, 0, 0, TimeCurrent(), true);
      Decoder.Save(FileName + "Dec.nnw", Decoder.getRecentAverageError(), 0, 0, TimeCurrent(), true);
     }
   delete Result;
   delete OpenCL;
  }

2.4 Treinamento da política

Na próxima etapa, passamos ao treinamento da política de comportamento do Agente, que é implementada no Expert Advisor «...\Experts\GCPC\Study.mq5». Aqui, também treinaremos o modelo do codificador de estado, que essencialmente é uma parte integrante do modelo do nosso Agente. E o modelo de definição de objetivos.

Embora exista a possibilidade funcional de separar o processo de treinamento da política de comportamento do Agente e do modelo de definição de objetivos em dois programas distintos, decidi unificá-los em um único Expert Advisor. Como será visto no algoritmo de implementação, esses dois processos estão intimamente ligados e utilizam uma grande quantidade de dados comuns. Nesse caso, a eficácia de separar o treinamento dos modelos em dois processos paralelos com muitas operações duplicadas é bastante duvidosa.

No trabalho deste Expert Advisor, assim como no Expert Advisor de interação com o ambiente, usamos 4 modelos, 3 dos quais são treinados nele.

CNet                 Actor;
CNet                 StateEncoder;
CNet                 Encoder;
CNet                 Goal;

No método OnInit da inicialização do EA, carregamos o conjunto de treinamento, exatamente como no EA discutido acima.

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

Depois, carregamos os modelos. Primeiro, tentamos abrir o Codificador pré-treinado. Ele deve ser treinado na primeira etapa do algoritmo Goal-Conditioned Predictive Coding. A ausência desse modelo nos impede de passar para a próxima etapa.

//--- load models
   float temp;
   if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Cann't load Encoder model");
      return INIT_FAILED;
     }

Após a leitura bem-sucedida do modelo do Codificador, tentamos abrir os modelos restantes. Todos eles são treinados neste EA. Portanto, ao ocorrer algum erro, criamos novos modelos e os inicializamos com parâmetros aleatórios.

   if(!StateEncoder.Load(FileName + "StEnc.nnw", temp, temp, temp, dtStudied, true) ||
      !Goal.Load(FileName + "Goal.nnw", temp, temp, temp, dtStudied, true) ||
      !Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *goal = new CArrayObj();
      CArrayObj *encoder = new CArrayObj();
      if(!CreateDescriptions(actor, goal, encoder))
        {
         delete actor;
         delete goal;
         delete encoder;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || !StateEncoder.Create(encoder) || !Goal.Create(goal))
        {
         delete actor;
         delete goal;
         delete encoder;
         return INIT_FAILED;
        }
      delete actor;
      delete goal;
      delete encoder;
      //---
     }

Em seguida, movemos todos os modelos para um único contexto OpenCL. É obrigatório configurar o modo de treinamento do Codificador para false, para desativar a máscara dos dados de entrada.

Na próxima etapa, verificamos se as arquiteturas de todos os modelos carregados correspondem, para eliminar possíveis erros na transferência de dados entre os 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;
     }
   Encoder.getResults(Result);
   if(Result.Total() != EmbeddingSize)
     {
      PrintFormat("The scope of the Encoder does not match the embedding size (%d <> %d)", EmbeddingSize, Result.Total());
      return INIT_FAILED;
     }
//---
   Encoder.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;
     }
//---
   StateEncoder.GetLayerOutput(0, Result);
   if(Result.Total() != EmbeddingSize)
     {
      PrintFormat("Input size of State Encoder doesn't match Bottleneck (%d <> %d)", Result.Total(), EmbeddingSize);
      return INIT_FAILED;
     }
//---
   StateEncoder.getResults(Result);
   int latent_state = Result.Total();
   Actor.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Actor doesn't match output State Encoder (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }
//---
   Goal.GetLayerOutput(0, Result);
   latent_state = Result.Total();
   Encoder.getResults(Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Goal doesn't match output Encoder (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }
//---
   Goal.getResults(Result);
   if(Result.Total() != NRewards)
     {
      PrintFormat("The scope of Goal doesn't match rewards count (%d <> %d)", Result.Total(), NRewards);
      return INIT_FAILED;
     }

Após a conclusão bem-sucedida de todas as verificações necessárias, criamos buffers auxiliares no contexto do OpenCL utilizado.

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

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

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

No método de desinicialização do EA, salvamos os modelos treinados e removemos os objetos dinâmicos utilizados.

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//---
   if(!(reason == REASON_INITFAILED || reason == REASON_RECOMPILE))
     {
      Actor.Save(FileName + "Act.nnw", 0, 0, 0, TimeCurrent(), true);
      StateEncoder.Save(FileName + "StEnc.nnw", 0, 0, 0, TimeCurrent(), true);
      Goal.Save(FileName + "Goal.nnw", 0, 0, 0, TimeCurrent(), true);
     }
   delete Result;
   delete OpenCL;
  }

O próprio processo de treinamento dos modelos é realizado no método Train. No corpo do método, primeiro geramos um buffer de probabilidades de seleção de trajetórias para o treinamento dos modelos. Lembre-se de que ponderamos todas as trajetórias no conjunto de treinamento de acordo com sua rentabilidade. E as passagens mais lucrativas têm maior probabilidade de participar do processo de treinamento.

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

Depois, inicializamos variáveis locais. E aqui já podemos ver 2 vetores de desvios quadráticos médios, que usaremos para os modelos de política e definição de metas.

   vector<float> result, target;
   matrix<float> targets;
   STD_Actor = vector<float>::Zeros(NActions);
   STD_Goal = vector<float>::Zeros(NRewards);
   int std_count = 0;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

Embora nenhum dos modelos em treinamento tenha blocos recorrentes e pilhas em sua arquitetura, ainda criamos um sistema de ciclos para o treinamento dos modelos. Afinal, os dados de entrada para os modelos em treinamento são gerados pelo Codificador, que utiliza a arquitetura GPT.

No corpo do ciclo externo, amostramos a trajetória e o estado inicial nela.

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

Limpamos a pilha do Codificador e o buffer de seus últimos resultados.

      Encoder.Clear();
      bLastEncoder.BufferInit(EmbeddingSize, 0);

Note que usamos o buffer para registrar o último estado do Codificador, embora não planejemos retropropagações desse modelo. De fato, para as propagações, podemos usar um ponteiro para o modelo, como foi implementado no EA de interação com o ambiente. No entanto, ao mudar para uma nova trajetória, precisamos zerar não apenas a pilha de estados latentes, mas também o buffer dos resultados do modelo. E isso é mais fácil de fazer usando um buffer adicional.

No corpo do ciclo aninhado, carregamos os dados do estado analisado do conjunto de treinamento e geramos sua representação compacta usando o modelo do Codificador.

      int end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars);
      for(int i = state; i < end; i++)
        {
         bState.AssignArray(Buffer[tr].States[i].state);
         //---
         if(!bLastEncoder.BufferWrite() ||
            !Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)GetPointer(bLastEncoder)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Depois, preenchemos o buffer da descrição do estado da conta, que é complementado com as harmônicas do carimbo de data/hora.

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

A representação compacta do estado analisado do ambiente é combinada com o vetor de descrição do estado da conta.

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

E agora, para realizar a propagação do Ator, precisamos indicar a ele um objetivo. Como no caso do Decision Transformer, nesta etapa usamos os resultados reais obtidos na interação com o ambiente como indicação de objetivo. E as ações efetivamente realizadas pelo Agente como resultados-alvo do trabalho da política. Assim, estabelecemos conexões entre objetivo e ação em um estado específico do ambiente. Mas há um detalhe. Ao treinar o Autocodificador, buscamos obter dados de previsão para vários candles à frente. Portanto, agora esperamos a presença de informações de previsão sobre vários candles subsequentes na representação compacta do estado atual. É lógico supor que as ações realizadas pelo Agente nesta etapa devem ser calculadas para obter resultados no período de tempo previsto. Parece que podemos usar a recompensa total pelo período previsto como objetivo para a ação realizada. Mas quem disse que a negociação aberta no momento deve ser fechada apenas ao final do período previsto? Ela pode ser fechada tanto antes quanto depois. No caso de "depois", não podemos olhar além dos valores previstos. Portanto, podemos pegar apenas o resultado no final do período previsto. Porém, ao mudar a direção do movimento dos preços dentro do período previsto, a negociação deve ser fechada antes. Assim, nossa meta potencial deve ser o valor máximo para o período previsto, considerando o fator de desconto.

O problema é que o buffer de reprodução de experiência armazena recompensas acumulativas até o final do episódio. Precisamos, ao contrário, somar as recompensas do estado analisado ao horizonte dos dados previstos. Desse modo, primeiro restauramos a recompensa em cada etapa sem considerar o fator de desconto.

         targets = matrix<float>::Zeros(PrecoderBars, NRewards);
         result.Assign(Buffer[tr].States[i + 1].rewards);
         for(int t = 0; t < PrecoderBars; t++)
           {
            target = result;
            result.Assign(Buffer[tr].States[i + t + 2].rewards);
            target = target - result * DiscFactor;
            targets.Row(target, t);
           }

Depois, somamos em ordem inversa, considerando o fator de desconto.

         for(int t = 1; t < PrecoderBars; t++)
           {
            target = targets.Row(t - 1) + targets.Row(t) * MathPow(DiscFactor, t);
            targets.Row(target, t);
           }

Da matriz obtida, escolhemos a linha com a recompensa máxima, que será nossa meta.

         result = targets.Sum(1);
         ulong row = result.ArgMax();
         target = targets.Row(row);
         bGoal.AssignArray(target);

Concordo plenamente que os lucros (ou perdas) obtidos nos passos de tempo subsequentes podem estar associados a negociações que o Agente realizou antes ou depois. Há dois pontos aqui.

A referência a negociações realizadas anteriormente não é muito correto. O fato de o Agente mantê-las abertas é uma ação do momento atual. Portanto, o resultado subsequente delas é consequência dessa ação.

Quanto às ações subsequentes, dentro da análise das trajetórias, analisamos não ações individuais, mas a política de comportamento do Ator como um todo. Assjm sendo, o objetivo é definido para a política no futuro previsto, não para uma ação individual. Dessa perspectiva, definir a meta máxima para o período previsto é bastante pertinente.

Com o objetivo preparado, temos dados suficientes para realizar a propagação do Ator.

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

Em seguida, ajustamos os parâmetros do modelo para minimizar o erro entre as ações previstas e as realizadas durante a interação com o ambiente. Aqui usamos métodos de aprendizado supervisionado. Que complementamos com ênfase nos desvios máximos. Como no algoritmo descrito acima, primeiro calculamos o erro quadrático médio móvel das previsões para cada parâmetro.

         target.Assign(Buffer[tr].States[i].action);
         target.Clip(0, 1);
         Actor.getResults(result);
         vector<float> error = target - result;
         std_count = MathMin(std_count, 999);
         STD_Actor = MathSqrt((MathPow(STD_Actor, 2) * std_count + MathPow(error, 2)) / (std_count + 1));

E comparamos o erro atual com o valor limiar. A retropropagação é realizada apenas se houver um desvio acima do limiar em pelo menos um parâmetro.

         check = MathAbs(error) - STD_Actor * STD_Multiplier;
         if(check.Max() > 0)
           {
            Result.AssignArray(CAGrad(error) + result);
            if(!Actor.backProp(Result, (CBufferFloat *)GetPointer(bGoal), (CBufferFloat *)GetPointer(bGradient)) ||
               !StateEncoder.backPropGradient(GetPointer(bAccount), (CBufferFloat *)GetPointer(bGradient)))
              {
               PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
               Stop = true;
               break;
              }
           }

Após a atualização dos parâmetros do Ator, passamos para o treinamento do modelo de definição de metas. Ao contrário do Ator, ele utiliza apenas a representação compacta do estado analisado, obtida pelo Codificador, como dados de entrada. Não precisamos preparar dados adicionais antes de realizar a propagação.

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

Usaremos as metas definidas anteriormente para a política do Ator como valores-alvo de treinamento para o modelo de definição de metas. Mas com um pequeno ajuste. Em muitos trabalhos, recomenda-se usar um fator de aumento para os resultados obtidos ao definir metas para políticas treinadas. Isso deve estimular a política de comportamento a escolher ações melhores. Nós, no entanto, treinaremos o modelo de definição de metas diretamente em melhores resultados. Para isso, ao formar o vetor de valores-alvo, dobraremos os resultados reais. Mas, como sempre, há um detalhe. Não podemos simplesmente multiplicar o vetor de recompensas reais por 2. Como entre as recompensas obtidas podem haver valores negativos, multiplicá-los por 2 só pioraria as expectativas. Portanto, primeiro determinamos o sinal da recompensa.

         target=targets.Row(row);
         result = target / (MathAbs(target) + FLT_EPSILON);

Como resultado dessa operação, esperamos obter um vetor contendo "-1" para valores negativos e "1" para positivos. Elevando o vetor "2" à potência do vetor obtido, obteremos "2" para valores positivos e "½" para os negativos.

        result = MathPow(vector<float>::Full(NRewards, 2), result);

Agora podemos multiplicar o vetor de resultados reais pelo vetor de coeficientes obtido acima para aumentar a recompensa esperada em 2 vezes. Este será o vetor usado como valores-alvo para o treinamento do nosso modelo de definição de metas.

         target = target * result;
         Goal.getResults(result);
         error = target - result;
         std_count = MathMin(std_count, 999);
         STD_Goal = MathSqrt((MathPow(STD_Goal, 2) * std_count + MathPow(error, 2)) / (std_count + 1));
         std_count++;
         check = MathAbs(error) - STD_Goal * STD_Multiplier;
         if(check.Max() > 0)
           {
            Result.AssignArray(CAGrad(error) + result);
            if(!Goal.backProp(Result, (CBufferFloat *)NULL, (CBufferFloat *)NULL))
              {
               PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
               Stop = true;
               break;
              }
           }

Aqui, também utilizamos a ideia de usar expressões em forma fechada para otimizar o modelo com ênfase nos desvios máximos.

Neste estágio, otimizamos os parâmetros de todos os modelos em treinamento. Salvamos os resultados do Codificador no buffer correspondente.

         Encoder.getResults(result);
         bLastEncoder.AssignArray(result);

Informamos o usuário sobre o processo de treinamento e passamos 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", "Goal", percent, Goal.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

Após todas as iterações do sistema de ciclos de treinamento dos modelos, limpamos o campo de comentários no gráfico do instrumento. Registramos os resultados do treinamento no log e encerramos o EA.

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

Com isso, finalizamos a descrição dos algoritmos usados nos programas. O código completo de todos os programas utilizados no artigo pode ser encontrado em anexo. Lá, você também encontrará o EA para testar os modelos treinados, no qual não entraremos em detalhes agora.


3. Teste

Acima, realizamos um trabalho considerável para implementar o método Goal-Conditioned Predictive Coding utilizando MQL5. O tamanho deste artigo confirma a quantidade de trabalho realizado. E chegou a hora de verificar seus resultados.

Como de costume, o treinamento e teste dos modelos são realizados em dados históricos do instrumento EURUSD, timeframe H1. O treinamento dos modelos é realizado em dados históricos dos primeiros 7 meses de 2023. O teste dos modelos treinados foi realizado com dados de agosto de 2023, que seguem diretamente após o período histórico de treinamento.

O treinamento foi realizado de forma iterativa. Primeiro, foi coletado um conjunto de dados de treinamento em 2 etapas. Na primeira etapa, incluímos no conjunto de treinamento passagens pelos dados de sinais reais, conforme proposto no método Real-ORL. Em seguida, o conjunto de treinamento foi complementado com passagens utilizando o EA «...\Experts\GCPC\Research.mq5» e políticas aleatórias.

Com esses dados, foi treinado um Autocodificador usando o EA «...\Experts\GCPC\StudyEncoder.mq5». Como mencionado anteriormente, para os fins de treinamento deste EA, todas as passagens são idênticas. E o treinamento do modelo não requer atualização adicional do conjunto de treinamento. Portanto, treinamos o Autocodificador mascarado até obtermos resultados aceitáveis.

Na segunda etapa, é realizado o treinamento da política de comportamento do Agente e do modelo de definição de objetivos. Aqui, já usamos uma abordagem iterativa com treinamento dos modelos e subsequente atualização dos dados de treinamento. Devo dizer que, nesta etapa, fiquei agradavelmente surpreso. O processo de treinamento foi bastante estável e com uma boa dinâmica de resultados. No processo de treinamento, foi obtida uma política capaz de gerar lucro tanto no período de treinamento quanto no período de teste.


Considerações finais

Neste artigo, aprendemos um método bastante interessante chamado Goal-Conditioned Predictive Coding. Sua principal contribuição é a divisão do processo de treinamento do modelo em 2 subprocessos: estudo da trajetória e estudo separado da política. No estudo da trajetória, o foco está na capacidade de projetar as tendências observadas em estados futuros, o que, em geral, aumenta a informatividade dos dados transmitidos ao Agente para tomada de decisão.

Na parte prática deste artigo, implementamos nossa visão do método proposto usando MQL5 e comprovamos na prática a eficácia da abordagem sugerida.

No entanto, gostaria de ressaltar mais uma vez que todos os materiais apresentados no artigo são destinados à demonstração de possibilidades. Mas não estão prontos para uso em condições reais dos mercados financeiros.


Referências


Programas utilizados no artigo

# Nome Tipo Descrição
1 Research.mq5 EA EA para coleta de exemplos
2 ResearchRealORL.mq5
EA
EA para coleta de exemplos com o método Real-ORL
3 Study.mq5  EA EA para treinamento da Política
4 StudyCodificador.mq5 EA
EA para treinamento do Autocodificador
5 Test.mq5 EA 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 criar 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/14012

Arquivos anexados |
MQL5.zip (757.07 KB)
Desenvolvendo um sistema de Replay (Parte 53): Complicando as coisas (V) Desenvolvendo um sistema de Replay (Parte 53): Complicando as coisas (V)
Neste artigo irei introduzir um tema muito importante, porém que poucos de fato compreender. Eventos Customizados. Perigos. Vantagens e falhas causados por tais coisas. Este assunto é muito importante para quem deseja se tornar um programador profissional em MQL5, ou em qualquer outro tipo de linguagem. Mas aqui iremos focar no MQL5 e no MetaTrader 5.
Algoritmos de otimização populacionais: algoritmo híbrido de otimização de forrageamento bacteriano com algoritmo genético (Bacterial Foraging Optimization - Genetic Algorithm, BFO-GA) Algoritmos de otimização populacionais: algoritmo híbrido de otimização de forrageamento bacteriano com algoritmo genético (Bacterial Foraging Optimization - Genetic Algorithm, BFO-GA)
Este artigo apresenta uma nova abordagem para resolver problemas de otimização, combinando as ideias dos algoritmos de otimização de forrageamento bacteriano (BFO) com as técnicas usadas no algoritmo genético (GA), resultando no algoritmo híbrido BFO-GA. Ele utiliza o comportamento de enxameamento das bactérias para a busca global da solução ótima e operadores genéticos para refinar os ótimos locais. Ao contrário do BFO original, as bactérias agora podem mutar e herdar genes.
Criando um algoritmo de market making no MQL5 Criando um algoritmo de market making no MQL5
Como funcionam os market makers no mercado? Vamos explorar isso e criar um algoritmo simples de market making.
Redes neurais de maneira fácil (Parte 70): melhorando a política usando operadores de forma fechada (CFPI) Redes neurais de maneira fácil (Parte 70): melhorando a política usando operadores de forma fechada (CFPI)
Neste artigo, propomos explorar um algoritmo que utiliza operadores de melhoria de política de forma fechada para otimizar as ações do Agente em um ambiente off-line.