English Русский 中文 Español Deutsch 日本語
preview
Redes neurais de maneira fácil (Parte 54): usando o codificador aleatório para exploração eficiente (RE3)

Redes neurais de maneira fácil (Parte 54): usando o codificador aleatório para exploração eficiente (RE3)

MetaTrader 5Sistemas de negociação | 30 janeiro 2024, 11:26
206 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introdução

A questão da exploração eficiente do ambiente é um dos principais problemas dos métodos de aprendizado por reforço. Já discutimos essa questão várias vezes. E, a cada vez, sua solução levava a uma complicação adicional do algoritmo. Na maioria dos casos, recorremos ao uso de mecanismos internos adicionais de recompensa para estimular o modelo a explorar novas ações e buscar caminhos desconhecidos.

Porém, tivemos que treinar modelos adicionais para avaliar a novidade das ações e dos estados atendidos. É importante notar que o conceito de "novidade das ações" nem sempre coincide com a completude e uniformidade da exploração do ambiente. E neste aspecto, os métodos baseados na avaliação da entropia das ações e dos estados parecem ser os mais atraentes. Mas eles impõem suas próprias limitações aos modelos treinados. O uso da entropia requer uma certa compreensão das probabilidades de realizar ações e de transições para novos estados, o que pode ser bastante complicado para cálculo direto em um espaço contínuo de ações e estados. Na busca por métodos mais simples e eficazes, convido você a conhecer o algoritmo Random Encoders for Efficient Exploration (RE3), que foi apresentado no artigo "State Entropy Maximization with Random Encoders for Efficient Exploration".


1. A ideia principal do RE3

Após analisar casos reais com espaço contínuo de ações e estados, nos deparamos com uma situação em que cada par estado-ação na amostra de treinamento é encontrado apenas uma vez. As chances de observar um estado idêntico no futuro são próximas a "0". E chegamos à procura de métodos para agrupar estados e ações próximos (similares), levando ao treinamento de modelos adicionais. Por exemplo, no método BAC, treinamos um autocodificador para avaliar a novidade dos estados e ações.

No entanto, treinar modelos adicionais introduz uma certa complexidade no algoritmo. Afinal, requerem-se gastos adicionais de tempo e recursos tanto na seleção de hiperparâmetros adicionais quanto no treinamento do modelo. E a qualidade do treinamento do modelo adicional pode ter um impacto significativo nos resultados do treinamento da política principal do Ator.

O objetivo principal do método Random Encoders for Efficient Exploration (RE3) é minimizar o número de modelos treinados. Em seu trabalho, os autores do método RE3 chamam a atenção para o fato de que, na área de processamento de imagens, apenas redes convolucionais são capazes de destacar características individuais e características de um objeto. São justamente as redes convolucionais que ajudam a reduzir a dimensionalidade do espaço multidimensional, destacar características distintas e lidar com o escalonamento do objeto original.

E então surge a pergunta: que tipo de minimização de modelos treinados estamos falando se recorremos a redes convolucionais adicionais?

Neste aspecto, a palavra-chave é "treinados". Os autores do método notaram o fato de que até um codificador convolucional (Convolutional Encoder) inicializado com parâmetros aleatórios captura eficientemente informações sobre a proximidade de dois estados. Abaixo está uma visualização dos k-estados mais próximos, encontrados medindo distâncias no espaço de representação de um codificador aleatório (Random Encoder) inicializado aleatoriamente e no espaço do estado verdadeiro (True State), da publicação original.

Visualização dos k-estados mais próximos

Baseando-se nesta observação, os autores do método RE3 propõem maximizar a avaliação da entropia do estado no espaço de representação fixo de um codificador aleatório inicializado aleatoriamente durante o processo de treinamento do modelo.

O método do codificador aleatório para exploração eficiente (Random Encoders for Efficient Exploration, RE3), estimula a exploração em espaços de alta dimensão de observações através da maximização da entropia do estado. A ideia principal do RE3 é avaliar a entropia usando a estimativa dos k vizinhos mais próximos em um espaço de baixa dimensão, obtido com um codificador aleatório inicializado aleatoriamente.

Os autores do método sugerem calcular a distância entre os estados no espaço de representação do codificador aleatório f(θ), cujos parâmetros θ são aleatoriamente inicializados e fixados durante todo o processo de treinamento.

A motivação do Agente surge da observação de que as distâncias no espaço de representação do codificador aleatório já são úteis para encontrar estados semelhantes sem a necessidade de treinar a representação.

Neste caso, a recompensa interna é proporcional à avaliação da entropia do estado e é determinada pela fórmula:

onde yi representa o estado no espaço do codificador aleatório.

Na fórmula de recompensa interna apresentada, usamos a norma L2 da distância, que é sempre não-negativa. Aumentar a norma em "1" permite obter um valor sempre não-negativo do logaritmo. Assim, obtemos uma recompensa interna sempre não-negativa. Além disso, é fácil notar que, com um número suficiente de estados próximos, a recompensa interna é próxima a "0".

Como a prática mostra, medir a distância entre estados em um espaço de representação fixo fornece uma recompensa interna mais estável. Isso porque a distância entre pares de estados não muda durante o treinamento.

Para calcular as distâncias no espaço latente, é computacionalmente eficiente armazenar representações de baixa dimensão dos estados em um buffer de reprodução de experiências durante a interação com o ambiente. Isso elimina a necessidade de processar estados de alta dimensão através do codificador para obter representações a cada iteração de atualização dos modelos. Além disso, isso permite calcular a distância até todos os registros de estados, e não apenas de uma amostra selecionada de um mini-lote. Este esquema fornece uma avaliação estável e precisa da entropia com eficiência computacional.

De maneira geral, o método RE3 pode ser usado para treinar um agente em tempo real, quando o Agente aprende uma política baseada na maximização da recompensa externa do ambiente. A recompensa interna estimula o Agente a explorar o ambiente.

onde β é o coeficiente de temperatura que determina o equilíbrio entre exploração e explotação (β≥0).

Os autores do método sugerem usar um decaimento exponencial para β ao longo do processo de treinamento, a fim de encorajar o agente a se concentrar mais na recompensa externa do ambiente à medida que o treinamento avança.

onde p é a taxa de decaimento.

Enquanto a recompensa interna converge para "0" à medida que mais estados similares são coletados durante o treinamento, os autores do método descobriram que o decaimento de β estabiliza empiricamente o desempenho.

Além disso, o método RE3 pode ser usado para o treinamento preliminar de um Agente para explorar um espaço de alta dimensão do ambiente na ausência de recompensas externas. Posteriormente, a política do Agente pode ser adicionalmente treinada para resolver tarefas específicas.

Abaixo está a visualização que o autor fez do método RE3.

Visualização do autor do método

No artigo "State Entropy Maximization with Random Encoders for Efficient Exploration" , são apresentados os resultados de vários testes que demonstram a eficácia do método. Agora, vamos implementar nossa versão do algoritmo proposto e avaliar sua eficácia para resolver nossas tarefas.


2. Implementação usando MQL5

Ao começar a implementação deste método, vale ressaltar que não reproduziremos completamente o algoritmo original dos autores. Como sempre, usaremos as ideias principais do método e as combinaremos com abordagens previamente estudadas. Neste trabalho, criaremos uma espécie de conglomerado dos algoritmos atuais e dos que foram estudados anteriormente.

Construiremos nossa implementação com base nos algoritmos da família Ator-Crítico. Para a construção do codificador convolucional, adicionaremos sua descrição ao método de descrição das arquiteturas de modelos.

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

Treinaremos a política estocástica do Agente em um espaço contínuo de ações. Como nos artigos anteriores, treinaremos o Ator usando algoritmos da família Ator-Crítico. No entanto, como usaremos abordagens do método RE3 para avaliar a componente de entropia da recompensa, podemos simplificar o modelo do Ator. Neste caso, repetiremos a arquitetura do Ator do artigo "ator-crítico comportamental (BAC)".

//--- Actor
   actor.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(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- 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(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count - 1;
   descr.window = 2;
   descr.step = 1;
   descr.window_out = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count;
   descr.window = 8;
   descr.step = 8;
   descr.window_out = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = 128;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   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(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NActions;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 10
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Como antes, nosso Crítico será desprovido de um bloco de pré-processamento de dados brutos. Usaremos o estado latente do nosso Ator como dados de entrada para o Crítico. Também utilizaremos a decomposição da recompensa. Porém, reduziremos o número de elementos da recompensa. Em vez de 6 elementos separados de componentes de entropia para cada ação, teremos apenas um elemento de recompensa interna.

//+------------------------------------------------------------------+
//| Rewards structure                                                |
//|   0     -  Delta Balance                                         |
//|   1     -  Delta Equity ( "-" Drawdown / "+" Profit)             |
//|   2     -  Penalty for no open positions                         |
//|   3     -  Mean distance                                         |
//+------------------------------------------------------------------+

Como resultado, obteremos a seguinte arquitetura para o Crítico.

//--- Critic
   critic.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = LatentCount;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!critic.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 = NActions;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!critic.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(!critic.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(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = NRewards;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }

Em seguida, descreveremos a arquitetura do codificador convolucional. E aqui está a primeira diferença em relação ao método original. No método RE3, a recompensa interna é baseada na avaliação da distância entre as representações latentes dos estados. Nós usaremos a representação latente de pares "estado-ação", o que se reflete no tamanho da camada de dados de entrada do codificador.

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

Nosso modelo de codificador não é treinado, então não faz sentido usar uma camada de normalização de lote. Mas usaremos uma camada totalmente conectada e, em sua saída, obteremos dados comparáveis que podem ser processados por camadas convolucionais.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 512;
   descr.window = prev_count;
   descr.step = NActions;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!convolution.Add(descr))
     {по
      delete descr;
      return false;
     }

Depois, reduziremos a dimensionalidade dos dados usando 3 camadas convolucionais consecutivas. Elas têm a tarefa de identificar características distintas para a identificação de estados e ações próximos.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = 512 / 8;
   descr.window = 8;
   descr.step = 8;
   int prev_wout = descr.window_out = 2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = (prev_count * prev_wout) / 4;
   descr.window = 4;
   descr.step = 4;
   prev_wout = descr.window_out = 2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = (prev_count * prev_wout) / 4;
   descr.window = 4;
   descr.step = 4;
   prev_wout = descr.window_out = 2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }

Na conclusão do codificador, usaremos uma camada totalmente conectada e converteremos a representação oculta dos dados para a dimensionalidade especificada.

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = EmbeddingSize;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Note que em todas as camadas neurais (exceto a primeira), usamos LReLU para a ativação dos neurônios. A ausência de limites na área de resultados da função de ativação permitirá separar os objetos em grupos com a máxima precisão.

Após criar as descrições da arquitetura de nossos modelos, vamos falar um pouco sobre o buffer de reprodução de experiências. Os autores do método sugerem salvar simultaneamente o conjunto de dados padrão e a representação latente do estado no buffer de reprodução de experiências. Sim, eu concordo. Faz todo sentido calcular uma vez a representação latente do estado e usá-la posteriormente no processo de treinamento sem a necessidade de recalculá-la a cada iteração.

Mas na nossa sequência de ações, na primeira execução do EA de coleta de dados de treinamento "...\RE3\Research.mq5", ainda não existem modelos previamente treinados salvos. O modelo do Ator é criado pelo EA e preenchido com parâmetros aleatórios. Podemos também gerar um modelo de codificador aleatório. No entanto, a execução paralela de várias instâncias do EA no modo de otimização do testador de estratégias criará um codificador para cada passagem do EA. E o problema é que em cada passagem obteremos um codificador aleatório, cuja representação latente será incomparável com as representações semelhantes em outras passagens. Isso destruirá completamente as ideias e princípios do método RE3.

Vejo duas maneiras de solucionar esta situação:

  • Criação e salvamento prévios de modelos antes da primeira execução do EA "...\RE3\Research.mq5"
  • Geração do codificador e codificação das representações no corpo do EA de treinamento de modelos "...\RE3\Study.mq5".

Na minha implementação, escolhi a segunda opção. Desse modo, não faremos alterações nas estruturas de armazenamento de dados e no EA de coleta de amostras de treinamento "...\RE3\Research.mq5". Você pode ver o código completo no anexo.

Em seguida, passamos para o trabalho no EA de treinamento do modelo "...\RE3\Study.mq5". Aqui, criamos objetos para 6 modelos, mas treinaremos apenas 3 deles. Para os modelos-alvo, aplicamos uma atualização suave dos parâmetros usando o coeficiente ꚍ.

CNet                 Actor;
CNet                 Critic1;
CNet                 Critic2;
CNet                 TargetCritic1;
CNet                 TargetCritic2;
CNet                 Convolution;

No método de inicialização do EA, carregamos a amostra de treinamento e os modelos pré-treinados. Se os modelos não puderem ser carregados, geramos novos modelos preenchidos com parâmetros aleatórios.

int OnInit()
  {
//---
   ResetLastError();
   if(!LoadTotalBase())
     {
      PrintFormat("Error of load study data: %d", GetLastError());
      return INIT_FAILED;
     }
//--- load models
   float temp;
   if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true) ||
      !Convolution.Load(FileName + "CNN.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      CArrayObj *convolution = new CArrayObj();
      if(!CreateDescriptions(actor, critic, convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || !Critic1.Create(critic) || !Critic2.Create(critic) ||
         !Convolution.Create(convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!TargetCritic1.Create(critic) || !TargetCritic2.Create(critic))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      delete convolution;
      //---
      TargetCritic1.WeightsUpdate(GetPointer(Critic1), 1.0f);
      TargetCritic2.WeightsUpdate(GetPointer(Critic2), 1.0f);
      StartTargetIter = StartTargetIteration;
     }
   else
      StartTargetIter = 0;

Como no artigo anterior, ao gerar novos modelos, adiamos o uso dos modelos-alvo. Esta abordagem nos permite realizar um treinamento preliminar dos modelos-alvo antes de usá-los para avaliar os estados e ações subsequentes do Agente.

Aqui, também movemos todos os modelos para um único contexto OpenCL.

//---
   OpenCL = Actor.GetOpenCL();
   Critic1.SetOpenCL(OpenCL);
   Critic2.SetOpenCL(OpenCL);
   TargetCritic1.SetOpenCL(OpenCL);
   TargetCritic2.SetOpenCL(OpenCL);
   Convolution.SetOpenCL(OpenCL);

E antes de iniciar o processo de treinamento, verificamos se as arquiteturas dos modelos utilizados são consistentes.

   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;
     }
//---
   Actor.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Actor doesn't match state description (%d <> %d)", Result.Total(), 
                                                                                        (HistoryBars * BarDescr));
      return INIT_FAILED;
     }
//---
   Actor.GetLayerOutput(LatentLayer, Result);
   int latent_state = Result.Total();
   Critic1.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Critic doesn't match latent state Actor (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }

No final do método, criamos um buffer auxiliar e geramos um evento de treinamento dos modelos.

   Gradient.BufferInit(AccountDescr, 0);
//---
   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, atualizamos os parâmetros dos modelos-alvo e salvamos os resultados do treinamento.

void OnDeinit(const int reason)
  {
//---
   TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
   TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
   Actor.Save(FileName + "Act.nnw", 0, 0, 0, TimeCurrent(), true);
   TargetCritic1.Save(FileName + "Crt1.nnw", Critic1.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   TargetCritic2.Save(FileName + "Crt2.nnw", Critic2.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   Convolution.Save(FileName + "CNN.nnw", 0, 0, 0, TimeCurrent(), true);
   delete Result;
  }

O próprio processo de treinamento dos modelos é estabelecido no procedimento Train. Mas hoje seu algoritmo será um pouco diferente dos procedimentos semelhantes presentes nos EAs discutidos anteriormente.

No início, calculamos o número total de estados na amostra de treinamento. Como você sabe, o número de estados em cada passagem é armazenado na variável Total. Fazemos um ciclo e coletamos a soma total dos valores da variável mencionada de cada passagem.

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   uint ticks = GetTickCount();
//---
   int total_states = Buffer[0].Total;
   for(int i = 1; i < total_tr; i++)
      total_states += Buffer[i].Total;

Esse valor é necessário para declarar matrizes de representação comprimida de pares "estado-ação" e recompensas factuais coletadas do ambiente.

   vector<float> temp;
   Convolution.getResults(temp);
   matrix<float> state_embedding = matrix<float>::Zeros(total_states,temp.Size());
   matrix<float> rewards = matrix<float>::Zeros(total_states,NRewards);

Em seguida, estruturamos um sistema de ciclos, no qual criaremos representações latentes para todos os pares "estado-ação" da amostra de treinamento. Aqui, primeiro coletamos os dados brutos em um buffer de dados unificado.

   for(int tr = 0; tr < total_tr; tr++)
     {
      for(int st = 0; st < Buffer[tr].Total; st++)
        {
         State.AssignArray(Buffer[tr].States[st].state);
         float PrevBalance = Buffer[tr].States[MathMax(st,0)].account[0];
         float PrevEquity = Buffer[tr].States[MathMax(st,0)].account[1];
         State.Add((Buffer[tr].States[st].account[0] - PrevBalance) / PrevBalance);
         State.Add(Buffer[tr].States[st].account[1] / PrevBalance);
         State.Add((Buffer[tr].States[st].account[1] - PrevEquity) / PrevEquity);
         State.Add(Buffer[tr].States[st].account[2]);
         State.Add(Buffer[tr].States[st].account[3]);
         State.Add(Buffer[tr].States[st].account[4] / PrevBalance);
         State.Add(Buffer[tr].States[st].account[5] / PrevBalance);
         State.Add(Buffer[tr].States[st].account[6] / PrevBalance);
         double x = (double)Buffer[tr].States[st].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_MN1);
         State.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_W1);
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_D1);
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         State.AddArray(Buffer[tr].States[st].action);

E então chamamos a propagação do codificador convolucional.

         if(!Convolution.feedForward(GetPointer(State),1,false,NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            ExpertRemove();
            return;
           }

O resultado obtido é salvo na linha correspondente da matriz de incorporação de estados e ações. Imediatamente salvamos a recompensa externa correspondente na matriz de recompensas com o mesmo número de linha. Depois disso, aumentamos o contador de linhas gravadas.

         Convolution.getResults(temp);
         state_embedding.Row(temp,state);
         temp.Assign(Buffer[tr].States[st].rewards);
         rewards.Row(temp,state);
         state++;

O tempo gasto neste processo depende do tamanho da amostra de treinamento e pode ser significativo. Por isso, no corpo do ciclo, adicionaremos uma mensagem informativa para o controle visual do processo pelo usuário.

         if(GetTickCount() - ticks > 500)
           {
            string str = StringFormat("%-15s %6.2f%%", "Embedding ", state * 100.0 / (double)(total_states));
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

Após a conclusão total das iterações do sistema de ciclos, ajustaremos os tamanhos das matrizes para o número de linhas salvas.

   if(state != total_states)
     {
      rewards.Resize(state,NRewards);
      state_embedding.Reshape(state,state_embedding.Cols());
      total_states = state;
     }

Com isso, concluímos a fase preparatória e passamos diretamente para o treinamento dos modelos. Aqui, como antes, definimos um ciclo de treinamento com o número de iterações especificado pelo usuário nos parâmetros externos do EA.

   vector<float> rewards1, rewards2;
   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int tr = (int)((MathRand() / 32767.0) * (total_tr - 1));
      int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2));
      if(i < 0)
        {
         iter--;
         continue;
        }

No corpo do ciclo, selecionamos aleatoriamente a passagem e o estado para a iteração atual de treinamento dos modelos. E imediatamente verificamos a necessidade de usar os modelos-alvo.

Se nosso processo de treinamento atingir o valor limite para o uso de modelos-alvo, dispomos os dados de entrada do estado subsequente para a propagação desses modelos.

      vector<float> reward, target_reward = vector<float>::Zeros(NRewards);
      reward.Assign(Buffer[tr].States[i].rewards);
      //--- Target
      if(iter >= StartTargetIter)
        {
         State.AssignArray(Buffer[tr].States[i + 1].state);
         float PrevBalance = Buffer[tr].States[i].account[0];
         float PrevEquity = Buffer[tr].States[i].account[1];
         Account.Clear();
         Account.Add((Buffer[tr].States[i + 1].account[0] - PrevBalance) / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[1] / PrevBalance);
         Account.Add((Buffer[tr].States[i + 1].account[1] - PrevEquity) / PrevEquity);
         Account.Add(Buffer[tr].States[i + 1].account[2]);
         Account.Add(Buffer[tr].States[i + 1].account[3]);
         Account.Add(Buffer[tr].States[i + 1].account[4] / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[5] / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[6] / PrevBalance);
         double x = (double)Buffer[tr].States[i + 1].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_MN1);
         Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_W1);
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_D1);
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         //---
         if(Account.GetIndex() >= 0)
            Account.BufferWrite();

Lembramos que para a propagação dos modelos Críticos-alvo, precisamos da descrição do estado ambiental inicial e das ações do Agente. E aqui existem dois pontos para os quais precisamos da propagação do Ator:

  • Os Críticos não têm um bloco de pré-processamento de dados brutos (eles usam a representação latente do Ator);
  • O modelo Crítico-alvo avalia o estado subsequente em função do uso da política atual do Ator (é necessário gerar um novo vetor de ações).

Portanto, primeiro realizamos a propagação do Ator.

         if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

E então chamamos os métodos de propagação dos 2 modelos Críticos-alvo.

         if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
            !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

Em seguida, de acordo com o algoritmo Soft Actor-Critic, precisamos escolher o modelo-alvo com a menor avaliação do estado subsequente. Na minha implementação, usei uma simples soma dos elementos da recompensa. Mas se o seu modelo prevê diferentes coeficientes de peso para elementos individuais da função de recompensa, você pode usar o produto vetorial dos resultados das operações dos modelos no vetor de coeficientes de peso.

         TargetCritic1.getResults(rewards1);
         TargetCritic2.getResults(rewards2);
         if(rewards1.Sum() <= rewards2.Sum())
            target_reward = rewards1;
         else
            target_reward = rewards2;

Em seguida, subtraímos o resultado previsto do modelo selecionado da recompensa real obtida na interação com o ambiente e ajustamos pelo coeficiente de desconto.

         for(ulong r = 0; r < target_reward.Size(); r++)
            target_reward -= Buffer[tr].States[i + 1].rewards[r];
         target_reward *= DiscFactor;
        }

Assim, no vetor target_reward obtivemos a discrepância em cada ponto de recompensa entre a avaliação prevista do Crítico e a recompensa real do ambiente. O que isso nos traz?

Lembre-se que no buffer de reprodução de experiências, para cada par "estado-ação", é armazenada a soma acumulada da recompensa até o final da passagem, considerando o coeficiente de desconto. Como você entende, essa recompensa acumulada é baseada na política usada pelo Agente durante a interação com o ambiente.

Anteriormente, previmos o valor do par "estado-ação" considerando a política atual do Agente e subtraímos a avaliação desse mesmo estado, mas considerando a ação do buffer de reprodução de experiências. Assim, no vetor target_reward obtivemos o impacto da mudança de política do Ator no valor do estado.

Observe que estamos falando sobre a mudança no valor do estado. Pois ele quase não depende do Agente. No entanto, suas ações no mesmo estado podem variar dependendo da política usada.

Após avaliar o impacto da mudança da política de ações do Ator no resultado geral, passamos para o bloco de treinamento dos Críticos. É a qualidade do treinamento deles que afeta a correta transmissão do gradiente de erro às ações do Ator.

Aqui, também preparamos dados da descrição do estado do ambiente, que inclui dados históricos de movimentos de preços e indicadores. E em um buffer separado, preparamos dados sobre o estado da conta.

      //--- Q-function study
      State.AssignArray(Buffer[tr].States[i].state);
      float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
      float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
      Account.Clear();
      Account.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance);
      Account.Add(Buffer[tr].States[i].account[1] / PrevBalance);
      Account.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity);
      Account.Add(Buffer[tr].States[i].account[2]);
      Account.Add(Buffer[tr].States[i].account[3]);
      Account.Add(Buffer[tr].States[i].account[4] / PrevBalance);
      Account.Add(Buffer[tr].States[i].account[5] / PrevBalance);
      Account.Add(Buffer[tr].States[i].account[6] / PrevBalance);
      double x = (double)Buffer[tr].States[i].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_MN1);
      Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_W1);
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_D1);
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      if(Account.GetIndex() >= 0)
         Account.BufferWrite();

Após a preparação dos dados, realizamos a propagação do Ator.

      if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

Mas desta vez, do Ator, tomamos apenas a representação latente do estado do ambiente. Usamos a ação do Agente do buffer de reprodução de experiências. Pois é para essa ação que temos a recompensa real do ambiente.

Com esses dados, realizamos a propagação de ambos os Críticos.

      Actions.AssignArray(Buffer[tr].States[i].action);
      if(Actions.GetIndex() >= 0)
         Actions.BufferWrite();
      //---
      if(!Critic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)) ||
         !Critic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

Em seguida, precisamos formar os valores-alvo e realizar a retropropagação dos Críticos. Já realizamos operações semelhantes muitas vezes. Normalmente, nesta etapa, ajustávamos a recompensa real do buffer de reprodução de experiências pelo impacto da política alterada e passávamos o valor obtido como alvo para ambos os modelos Críticos. Mas nesta implementação, usamos uma recompensa decomposta. Já no artigo anterior, usamos o algoritmo Conflict-Averse Gradient Descent (CAGrad) para ajustar o gradiente de erro. Naquela ocasião, ajustamos a discrepância de valores no método CNet_SAC_D_DICE::CAGrad e salvamos os valores obtidos diretamente no buffer de gradientes de erro da camada neural de resultados. Agora, não temos acesso direto ao buffer de gradientes da última camada neural dos modelos e precisamos de valores-alvo.

Para obter valores-alvo ajustados pelo método Conflict-Averse Gradient Descent, realizaremos uma pequena manobra com os dados. Primeiro, formamos os valores-alvo a partir dos dados existentes. Em seguida, subtraímos deles os valores previstos pelo Crítico, obtendo assim a discrepância (erro). Ajustamos a discrepância obtida usando o método CAGrad já conhecido. E ao resultado obtido, adicionamos o valor previsto pelo Crítico, que foi subtraído anteriormente.

Assim, obtemos um valor-alvo ajustado pelo método Conflict-Averse Gradient Descent. No entanto, esse valor-alvo é relevante apenas para um modelo Crítico. Para o segundo modelo Crítico, teremos que repetir as operações, mas considerando seus valores previstos.

Após a retropropagação dos Críticos, realizamos uma retropropagação parcial do Ator para distribuir o gradiente de erro pelo bloco de pré-processamento de dados.

      Critic1.getResults(rewards1);
      Result.AssignArray(CAGrad(reward + target_reward - rewards1) + rewards1);
      if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
      Critic2.getResults(rewards2);
      Result.AssignArray(CAGrad(reward + target_reward - rewards2) + rewards2);
      if(!Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

Após a atualização dos parâmetros dos Críticos, segue-se o bloco de atualização da política do Ator. De acordo com o algoritmo Soft Actor-Critic, para a atualização dos parâmetros do Ator, é usado o Crítico com a menor avaliação do estado. Nós, no entanto, usaremos o Crítico com o menor erro médio, o que potencialmente fornecerá uma transmissão mais correta do gradiente de erro.

      //--- Policy study
      CNet *critic = NULL;
      if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError())
         critic = GetPointer(Critic1);
      else
         critic = GetPointer(Critic2);

Aqui também integramos o método RE3 em nosso processo de aprendizado. Compilamos em um único buffer de dados as descrições do estado analisado do ambiente, do estado da conta e da ação selecionada do agente, considerando a política atualizada. Lembre-se de que a propagação do Ator foi realizada na fase de atualização dos parâmetros dos Críticos.

Em seguida, realizamos a propagação do Crítico selecionado. Desta vez, avaliamos as ações do Ator no estado analisado, considerando a política atualizada. E chamamos a propagação do nosso Codificador para obter uma incorporação do par de estado analisado e ação do Ator com a política atualizada.

      Actor.getResults(rewards1);
      State.AddArray(GetPointer(Account));
      State.AddArray(rewards1);
      if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
         !Convolution.feedForward(GetPointer(State)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

Após a propagação, segue-se a retropropagação dos modelos. E novamente precisamos conformar os valores-alvo do Crítico. Mas desta vez, temos que combinar os algoritmos CAGrad e RE3. Além disso, não temos valores-alvo corretos para o estado analisado e a ação do Ator com a política atualizada.

A definição do valor-alvo usando as abordagens do RE3 foi externalizada para a função separada KNNReward, cujo algoritmo será examinado mais tarde. E a correção da recompensa decomposta é feita de acordo com o algoritmo descrito no bloco de atualização dos parâmetros dos Críticos.

      Convolution.getResults(rewards1);
      critic.getResults(reward);
      reward += CAGrad(KNNReward(7,rewards1,state_embedding,rewards) - reward);
      //---
      Result.AssignArray(reward + target_reward);

Em seguida, desativamos o modo de treinamento do Crítico e realizamos chamadas sequenciais dos métodos de retropropagação para o Crítico e o Ator. E não esquecemos de verificar o resultado das operações.

      critic.TrainMode(false);
      if(!critic.backProp(Result, GetPointer(Actor)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         critic.TrainMode(true);
         break;
        }
      critic.TrainMode(true);

Após a atualização da política do Ator, retornamos o Crítico ao modo de treinamento do modelo.

Ao finalizar as operações do ciclo de treinamento dos modelos, atualizamos os parâmetros dos modelos-alvo e informamos o usuário sobre o progresso do processo de treinamento.

      //--- Update Target Nets
      TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
      TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
      //---
      if(GetTickCount() - ticks > 500)
        {
         string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic1", 
                                     iter * 100.0 / (double)(Iterations), Critic1.getRecentAverageError());
         str += StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic2", 
                                     iter * 100.0 / (double)(Iterations), Critic2.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }

Após a todas as iterações do processo de treinamento dos modelos, limpamos o campo de comentários, exibimos os resultados do treinamento no jornal e iniciamos o encerramento do trabalho do EA.

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

Acima, examinamos o algoritmo do método de treinamento dos modelos Train. No entanto, para uma compreensão completa do processo, é necessário analisar os algoritmos das funções CAGrad e KNNReward. O algoritmo da primeira foi completamente transferido do método de mesmo nome, discutido no artigo anterior artigo, e você pode se familiarizar com ele no anexo. Eu sugiro focar no algoritmo da segunda função. Especialmente porque ele tem diferenças em relação ao algoritmo original do autor, descrito anteriormente.

Nos parâmetros, a função KNNReward recebe o número de vizinhos analisados, o vetor de incorporação do estado procurado, a matriz de incorporações dos estados da amostra de treinamento e a matriz de recompensas. Lembro que as matrizes de incorporação dos estados do buffer de reprodução de experiências e as recompensas são sincronizadas por linhas. Este ponto importante será explorado mais tarde.

O resultado das operações da função é retornado como um vetor de valores de recompensas correspondentes.

vector<float> KNNReward(ulong k, vector<float> &embedding, matrix<float> &state_embedding, matrix<float> &rewards)
  {
   if(embedding.Size() != state_embedding.Cols())
     {
      PrintFormat("%s -> %d Inconsistent embedding size", __FUNCTION__, __LINE__);
      return vector<float>::Zeros(0);
     }

No corpo da função, primeiro verificamos a correspondência entre o tamanho da incorporação do estado analisado e as incorporações criadas do buffer de reprodução da experiência.

Em seguida, determinamos a distância entre os vetores de incorporação. Para isso, subtraímos de cada coluna da incorporação dos estados do buffer de reprodução de experiência o valor do elemento correspondente da descrição do estado analisado. Elevamos os valores obtidos ao quadrado.

   ulong size = embedding.Size();
   ulong states = state_embedding.Rows();
   ulong rew_size = rewards.Cols();
   matrix<float> temp = matrix<float>::Zeros(states,size);
//---
   for(ulong i = 0; i < size; i++)
      temp.Col(MathPow(state_embedding.Col(i) - embedding[i],2.0f),i);

Depois, extraímos a raiz quadrada da soma de cada linha e colocamos o vetor resultante na primeira coluna da matriz.

   temp.Col(MathSqrt(temp.Sum(1)),0);

Dessa forma, obtivemos as distâncias entre o estado procurado e os exemplos do buffer de reprodução de experiências na primeira coluna da nossa matriz.

Alteramos a dimensionalidade da nossa matriz e adicionamos nas colunas adjacentes os elementos correspondentes de recompensas do buffer de reprodução de experiências.

   temp.Resize(states,1 + rew_size);
   for(ulong i = 0; i < rew_size; i++)
      temp.Col(rewards.Col(i),i + 1);

Como resultado dessas operações, obtemos uma matriz de recompensas, na qual a primeira coluna contém a distância até o estado procurado no espaço comprimido das incorporações.

Lembre-se que o estado procurado, neste caso, é o estado analisado com a ação do Ator de acordo com a política atualizada.

Agora, para determinar a recompensa interna para essa ação do Ator, precisamos identificar os k vizinhos mais próximos. É lógico que podemos encontrá-los facilmente após ordenar a matriz obtida por distâncias decrescentes. No entanto, para uma ordenação completa dos valores, precisaremos de várias passagens sequenciais pelo vetor de distâncias. Ao mesmo tempo, não há necessidade de ordenar completamente a matriz. Nossa tarefa é encontrar apenas os k valores mínimos. Além disso, a sequência deles na matriz de resultados menores não é importante. Para isso, precisamos apenas de uma passagem pelo vetor de distâncias.

Copiamos apenas as primeiras k linhas na nossa matriz de resultados. Determinamos a distância máxima e a posição do elemento de distância máxima nesta matriz menor. Em seguida, organizamos um ciclo para percorrer as linhas restantes da matriz original. No corpo do ciclo, verificamos sequencialmente a distância até o estado analisado e a distância máxima na nossa matriz de resultados. Se encontrarmos um estado mais próximo, vamos salvá-lo na linha de distância máxima da nossa matriz de resultados. Depois disso, atualizamos o valor da distância máxima e sua posição na matriz de distâncias mínimas.

   matrix<float> min_dist = temp;
   min_dist.Resize(k,rew_size + 1);
   float max = min_dist.Col(0).Max();
   ulong max_row = min_dist.Col(0).ArgMax();
   for(ulong i = k; i < states; i++)
     {
      if(temp[i,0] >= max)
         continue;
      min_dist.Row(temp.Row(i),max_row);
      max = min_dist.Col(0).Max();
      max_row = min_dist.Col(0).ArgMax();
     }

Repetimos as iterações até completar a análise de todas as linhas da nossa matriz de distâncias e recompensas. Após uma passagem completa, na matriz de distâncias mínimas min_dist, obtemos os k menores distâncias (k vizinhos mais próximos) com as recompensas correspondentes do buffer de reprodução de experiências. Sim, eles podem não estar ordenados, mas isso não é necessário para calcular a recompensa interna. 

   vector<float> t = vector<float>::Ones(k);
   vector<float> ri = MathLog(min_dist.Col(0) + 1.0f);

Nesta fase, temos todos os dados para determinar a recompensa interna (entropia) da ação analisada. Mas ainda resta a questão do valor-alvo da recompensa para o estado e ação analisados. E aqui vale a pena prestar atenção novamente aos k vizinhos mais próximos encontrados. Não os associamos a suas respectivas recompensas sem motivo. Todo o nosso processo de treinamento dos modelos é uma estatística de estados-ações e recompensas recebidas. Portanto, os k vizinhos mais próximos são nossa amostra representativa e a relevância de suas recompensas para a ação desejada é diretamente proporcional à distância das incorporações.

Assim, determinamos a recompensa-alvo como a média ponderada pela distância das recompensas dos k vizinhos mais próximos.

   t = (t - ri) / k;
//---
   vector<float> result = vector<float>::Zeros(rew_size);
   for(ulong i = 0; i < rew_size - 1; i++)
      result[i] = (t * min_dist.Col(i + 1)).Sum();

No campo da componente entrópica da função de recompensa, registramos o valor médio do logaritmo das distâncias pelo método RE3.

   result[rew_size - 1] = ri.Mean();
//---
   return (result);
  }

Com isso, definimos completamente o vetor de valores-alvo da recompensa decomposta e retornamos o vetor de resultados ao programa chamador.

Com isso, concluímos a revisão dos métodos e funções do EA de treinamento de modelos "...\RE3\Study.mq5". O código completo deste EA e todos os programas usados no artigo podem ser consultados no anexo.


3. Testes

A implementação apresentada acima, provavelmente, é difícil de ser chamada de método Random Encoders for Efficient Exploration (RE3) em sua forma pura. No entanto, usamos as abordagens principais deste algoritmo e as complementamos com nossa visão de algoritmos estudados anteriormente. E agora é hora de avaliar os resultados do nosso trabalho com dados históricos reais.

Como antes, o treinamento e os testes dos modelos são realizados com dados históricos dos primeiros 5 meses de 2023 do instrumento EURUSD, time-frame H1. Todos os parâmetros dos indicadores são usados por padrão. Saldo inicial de 10.000 USD.

Repito mais uma vez que o processo de treinamento dos modelos é iterativo. Primeiro, no testador de estratégias, lançamos o EA de interação com o ambiente e de coleta de exemplos de treinamento "...\RE3\Research.mq5".


Aqui, usamos o modo de otimização lenta com uma varredura exaustiva dos parâmetros, o que nos permite preencher o buffer de reprodução de experiências com dados o mais variados possível. Isso proporciona uma representação abrangente da natureza do ambiente para o nosso modelo.

Os exemplos de treinamento coletados são usados pelo EA de treinamento de modelos "...\RE3\Study.mq5" no processo de treinamento dos Críticos e do Ator.

Repetimos várias vezes as iterações de coleta de exemplos de treinamento e treinamento dos modelos até obter o resultado desejado.

Durante a preparação do artigo, consegui treinar uma política para o Ator capaz de gerar lucro na amostra de treinamento. Na amostra de treinamento, o EA mostrou impressionantes 83% de operações lucrativas. No entanto, devo admitir que o número de operações de trading realizadas foi muito baixo. Em 5 meses do período de treinamento, meu Ator realizou apenas 6 operações. E apenas uma delas foi fechada com uma perda relativamente pequena de 18.62 USD. Enquanto isso, o lucro médio por operação foi de 114.96 USD. Como consequência, o fator de lucro excedeu a marca de 30, e o fator de recuperação foi de 4.62.

Resultados do treinamento do modelo Resultados do treinamento do modelo

Com base nos resultados dos testes, pode-se concluir que o algoritmo proposto permite encontrar combinações eficazes. No entanto, um retorno de 5,5% e 6 operações de trading em 5 meses é um resultado bastante baixo. Para alcançar resultados mais altos, os esforços devem ser focados no aumento do número de operações de trading. No entanto, é necessário prestar atenção especial para garantir que o aumento do número de operações não leve a uma deterioração no desempenho geral da estratégia.


Considerações finais

Neste artigo, nos familiarizamos com o método Random Encoders for Efficient Exploration (RE3), que representa uma abordagem eficaz para explorar o ambiente no contexto de aprendizado por reforço. Este método visa resolver o problema de exploração eficiente de ambientes complexos, o que é uma das principais dificuldades na área de aprendizado por reforço profundo.

A ideia principal do RE3 é avaliar a entropia dos estados no espaço de representações de baixa dimensão, obtidas com um codificador inicializado aleatoriamente. Os parâmetros do codificador são fixados durante todo o período de treinamento. Isso evita a introdução de modelos adicionais e o treinamento de representações, tornando o método mais simples e computacionalmente eficiente.

Na parte prática do artigo, apresentamos nossa visão e implementação do método proposto. Deve-se dizer que nossa implementação utiliza as ideias principais do algoritmo proposto, mas é complementada por uma série de abordagens de algoritmos anteriormente estudados. Isso permitiu criar e treinar um modelo bastante interessante. A porcentagem de operações lucrativas é impressionante, mas infelizmente o número total de operações é muito baixo.

No geral, o modelo obtido tem potencial, mas é necessário um trabalho adicional para encontrar maneiras de aumentar o número de operações de trading.


Referências


Programas utilizados no artigo

# Nome Tipo Descrição
1 Research.mq5 EA EA de coleta de exemplos
2 Study.mq5  EA EA de treinamento do agente
3 Test.mq5 EA EA para testar o modelo
4 Trajectory.mqh Biblioteca de classe Estrutura de descrição do estado do sistema
5 NeuroNet.mqh Biblioteca de classe Biblioteca de classes para criação de rede neural
6 NeuroNet.cl Biblioteca Biblioteca de código do programa OpenCL


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

Arquivos anexados |
MQL5.zip (442.11 KB)
Teoria das Categorias em MQL5 (Parte 17): funtores e monoides Teoria das Categorias em MQL5 (Parte 17): funtores e monoides
Este é o último artigo da série dedicada a funtores. Nele, reconsideramos monoides como uma categoria. Os monoides, que já apresentamos nesta série, são usados aqui para ajudar na definição do tamanho da posição juntamente com perceptrons multicamadas.
Testando o conteúdo informativo de diferentes tipos de médias móveis Testando o conteúdo informativo de diferentes tipos de médias móveis
Todos conhecemos a importância da média móvel para muitos traders. Existem diferentes tipos de médias móveis que podem ser úteis no trading. Vamos examiná-las e realizar uma simples comparação para ver qual delas pode apresentar os melhores resultados.
Testes de permutação de Monte Carlo no MetaTrader 5 Testes de permutação de Monte Carlo no MetaTrader 5
Este artigo explora o uso de testes de permutação, aplicando-os a qualquer Expert Advisor através da reorganização de dados de ticks, recorrendo exclusivamente aos recursos disponíveis no MetaTrader 5.
Biblioteca de análise numérica ALGLIB em MQL5 Biblioteca de análise numérica ALGLIB em MQL5
Neste artigo, vamos brevemente revisar a biblioteca de análise numérica ALGLIB 3.19, suas aplicações e novos algoritmos que aumentam a eficácia da análise de dados financeiros.