English Русский 中文 Español Deutsch 日本語
preview
Redes neurais de maneira fácil (Parte 46): Aprendizado por reforço condicionado a metas (GCRL)

Redes neurais de maneira fácil (Parte 46): Aprendizado por reforço condicionado a metas (GCRL)

MetaTrader 5Sistemas de negociação | 4 outubro 2023, 11:51
366 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introdução

"Aprendizado por reforço condicionado a metas" soa um tanto incomum. Eu diria até estranho. Afinal, o princípio fundamental do aprendizado por reforço visa maximizar a recompensa total durante a interação do agente com o ambiente. No entanto, neste contexto, estamos falando sobre alcançar uma meta específica em uma etapa ou dentro de um cenário definido.

Já discutimos os benefícios de dividir a meta geral em sub tarefas e exploramos métodos para ensinar ao agente várias habilidades que contribuem para alcançar o resultado geral. Neste artigo, sugiro uma perspectiva diferente. Como ensinar ao agente a escolher estratégias e habilidades por conta própria para alcançar uma sub tarefa específica.


1. Características do GCRL

O aprendizado por reforço condicionado a metas (Goal-conditioned reinforcement learning, GCRL) faz parte de um conjunto complexo de tarefas de aprendizado por reforço. Estamos ensinando o agente a alcançar diferentes metas em cenários específicos. Anteriormente, ensinamos ao agente a escolher uma ação com base no estado atual do ambiente. No contexto do GCRL, queremos ensinar ao agente a agir não apenas com base no estado atual, mas também com base em uma sub tarefa específica naquele momento. Em outras palavras, além do vetor que descreve o estado atual, devemos de alguma forma indicar ao agente a sub tarefa a ser alcançada em cada momento específico. Concorda que isso se assemelha muito à tarefa de ensinar habilidades, quando, a cada momento, indicamos uma habilidade ao agente? Afinal, indicar o uso da habilidade "abrir posição" ou a tarefa de "abrir uma posição" pode parecer uma simples troca de palavras. No entanto, por trás dessas palavras, estão diferenças nas abordagens para treinar os agentes.

Em aprendizado por reforço, a função de recompensa é sempre um ponto crítico. Nas tarefas de ensino de habilidades, assim como no aprendizado por reforço clássico, usamos uma única função de recompensa ao longo de todo o treinamento. Indicar ao agente a habilidade a ser usada deve complementar o estado do ambiente e ajudar o agente a se orientar nele.

Ao usar abordagens do GCRL, introduzimos tarefas específicas. E sua realização deve se refletir nas recompensas obtidas pelo agente. Isso se assemelha ao incentivo interno do discriminador, mas tem métricas claras direcionadas para alcançar um objetivo específico (resolver uma sub tarefa).

Para entender essa sutil diferença, vamos considerar um exemplo de abertura de posição em ambos os métodos. No treinamento de habilidades, fornecíamos ao planejador o estado atual do ambiente e o vetor do estado da conta sem posições abertas. O planejador derivava a descrição da habilidade desse vetor, que então transmitíamos ao agente para tomar decisões. Como recompensa, como você se lembra, usávamos a mudança no saldo da conta. Vale ressaltar que essa recompensa é aplicada durante todo o treinamento do agente. Além disso, a abertura da posição não afeta imediatamente a mudança no saldo. A exceção seria possíveis comissões pela abertura da posição. Mas, em geral, para abrir uma posição, recebemos uma recompensa com atraso.

Por outro lado, ao usar o GCRL, além da recompensa da meta global, introduzimos recompensas adicionais para atingir tarefas específicas. Por exemplo, podemos estabelecer uma recompensa pela abertura de posição. Ou, inversamente, podemos penalizar o agente até que ele abra uma posição. Aqui, é importante abordar com equilíbrio a formação dessa recompensa. Ela não deve exceder os possíveis lucros e perdas da própria operação de negociação. Caso contrário, o agente simplesmente abrirá posições para "marcar pontos", enquanto o saldo da conta tenderá a "zero".

Outro ponto a ser considerado é que a recompensa deve depender da tarefa definida. Recompensaremos a abertura de posição e penalizaremos a ausência dessa ação apenas quando a tarefa for "abrir posição". E ao buscar um ponto de saída da posição, podemos introduzir penalidades por posições adicionais abertas, bem como por segurar a posição por muito tempo.

Ao formar o vetor de descrição da tarefa para o GCRL, é importante levar em consideração requisitos específicos. Esse vetor deve apontar claramente para a sub tarefa que o agente deve alcançar em um momento específico.

O vetor de descrição da tarefa pode incluir vários elementos, dependendo do contexto e das especificidades da tarefa. Por exemplo, no caso da abertura de posição, o vetor de descrição pode conter informações sobre o ativo-alvo, o volume de negociação, limitações de preço ou outros parâmetros relacionados à abertura da posição. Esses elementos devem ser claros e compreensíveis para o agente, para que ele possa interpretar corretamente a sub tarefa definida.

Além disso, o vetor de descrição da tarefa deve ser informativo o suficiente para que o agente possa tomar decisões altamente orientadas para alcançar essa sub tarefa. Isso pode exigir a inclusão de dados adicionais ou informações contextuais que ajudem o agente a entender melhor como agir para atingir o objetivo.

É essencial haver uma clara dependência lógica, mas não matemática, entre o vetor de descrição da sub tarefa e o resultado desejado. Podemos usar um vetor "one-hot" comum, onde cada elemento corresponde a uma sub tarefa separada. Este vetor é então transmitido ao agente juntamente com a descrição do estado atual do ambiente. O mais importante é que o agente possa interpretar claramente a sub tarefa e estabelecer suas próprias conexões internas entre a sub tarefa e a recompensa. Nesse contexto, também é importante considerar a recompensa. A recompensa adicional introduzida deve estar alinhada com a sub tarefa específica.

No entanto, existem outras abordagens para a formação do vetor de descrição da sub tarefa. Se a descrição de uma sub tarefa exigir uma combinação de muitos fatores, podemos usar um modelo separado para criar esse vetor, seguindo métodos de aprendizado de habilidades. O treinamento desse modelo pode ser realizado por meio de diversos autocodificadores ou qualquer outro método disponível.

Como pode ser observado, ambas as abordagens são bastante poderosas e permitem resolver várias tarefas. No entanto, cada uma delas possui suas próprias limitações. Não é por acaso que surgem várias sinergias entre as duas abordagens, permitindo criar um algoritmo ainda mais robusto. Durante o treinamento de habilidades, estabelecemos dependências entre o estado atual do ambiente e a habilidade (política de ação) do agente. O uso de ferramentas adicionais voltadas para alcançar uma sub tarefa específica ajudará a ajustar a estratégia do agente para obter resultados ótimos.

Uma dessas abordagens é o aprendizado por reforço variacional adaptativo (Adaptive Variational GCRL, aVGCRL). A ideia é que em um ambiente estocástico, a distribuição da representação de cada habilidade não será homogênea. Além disso, pode variar dependendo do estado do ambiente. Em determinados estados, haverá uma associação clara com algumas habilidades, para as quais a dispersão da distribuição será mínima. Enquanto a probabilidade de uso de outras habilidades nos mesmos estados não será tão definida, e a dispersão de distribuição será significativamente maior. Em outros estados do ambiente, a dispersão das distribuições de habilidades provavelmente será radicalmente diferente. Esse efeito pode ser observado ao examinar a representação latente das dispersões de um autocodificador variacional, que usamos anteriormente para treinar o planejador. Uma solução lógica seria concentrar a atenção nas dependências explícitas. Os autores do método aVGCRL propõem dividir o erro de desvio de cada habilidade do valor alvo pela dispersão da distribuição. Obviamente, quanto menor a dispersão, maior será a influência do erro e, durante o treinamento, maior serão ajustados os pesos correspondentes. Ao mesmo tempo, a aleatoriedade de outras habilidades não introduz um desequilíbrio significativo no modelo geral.


2. Implementação em MQL5

Para uma compreensão mais detalhada do método GCRL, sugiro explorar a implementação desse método. Devo mencionar de imediato que estaremos criando uma espécie de simbiose entre os dois dos métodos discutidos anteriormente, embora unifiquemos tudo em um único modelo.

No artigo anterior, criamos duas modelos: um planejador na forma de um autocodificador variacional e um agente. Ao contrário das abordagens anteriores, o agente recebia apenas o estado latente do autocodificador como entrada, que, segundo nossa lógica, deveria conter todas as informações necessárias. O teste do modelo mostrou que treinar o agente para alcançar o estado previsto pelo autocodificador não produziu o resultado desejado. Isso pode ser atribuído à qualidade insuficiente dos estados previstos.

Ao mesmo tempo, o uso de abordagens convencionais de recompensa permitiu melhorar o processo de treinamento do agente usando o planejador previamente treinado.

Neste trabalho, decidimos abandonar o treinamento separado do autocodificador variacional e incorporamos seu codificador diretamente no modelo do Agente. Deve-se observar que essa abordagem viola um pouco os princípios de treinamento de um autocodificador. A ideia principal ao usar qualquer autocodificador é compactar dados sem vinculá-los a uma tarefa específica. No entanto, agora não temos a tarefa de treinar o codificador para resolver várias tarefas a partir dos mesmos dados iniciais.

O segundo ponto é que só alimentamos a entrada do codificador com o estado atual do ambiente. Em nosso caso, esses são dados históricos de movimento de preços do instrumento e indicadores analisados. Ou seja, excluímos informações sobre o estado da conta. Supomos que, com base nos dados históricos, o planejador (neste caso, o codificador) formará a habilidade a ser usada. Isso pode ser uma política de operação em mercados em alta ou baixa, ou até mesmo negociação em mercados laterais.

Já com base nas informações da conta, vamos criar uma sub tarefa para o Agente, que envolve encontrar pontos de entrada ou saída de posições.

É importante mencionar que a divisão entre o Planejador e o Agente é puramente convencional. Na verdade, estaremos construindo um único modelo. Mas, como mencionado acima, só alimentamos a entrada do codificador com dados históricos. Isso significa que teremos que adicionar informações sobre a sub tarefa no meio do modelo. Isso é algo que não fizemos antes. Não podemos dizer que isso é uma solução completamente nova. Já enfrentamos situações semelhantes anteriormente. Nessas situações, criamos dois modelos.

A primeira parte era resolvida por um único modelo e, em seguida, combinávamos a saída do primeiro modelo com novos dados e alimentávamos o segundo modelo. Essa abordagem é mais fácil de implementar, mas tem uma desvantagem significativa: há uma troca redundante de dados entre o programa principal e o contexto OpenCL. Precisamos obter os resultados do primeiro modelo do contexto e carregá-los novamente para o segundo modelo. O mesmo acontece com o gradiente do erro durante a retropropagação. Usar um único modelo elimina essas operações, mas levanta a questão de como adicionar nova informação em uma etapa separada do processo de modelagem.

Para resolver esse problema, criaremos um novo tipo de camada neural chamada CNeuronConcatenate. Como antes, começamos a desenvolver cada novo tipo de camada neural criando os kernels necessários no programa OpenCL. O primeiro kernel que criamos foi o Kernel de propagação Concat_FeedForward. Devo mencionar que todos os kernels foram criados com base em kernels semelhantes de camadas neurais totalmente conectadas básicas.  A principal diferença está na adição de buffers e parâmetros adicionais para lidar com dois fluxos de informação.

Nos parâmetros do kernel Concat_FeedForward, vemos uma única matriz de pesos, 2 tensores de dados de entrada, um vetor de resultados e 3 parâmetros numéricos (tamanhos dos tensores de entrada e o identificador da função de ativação).

__kernel void Concat_FeedForward(__global float *matrix_w,
                                 __global float *matrix_i1,
                                 __global float *matrix_i2,
                                 __global float *matrix_o,
                                 int inputs1,
                                 int inputs2,
                                 int activation
                                )

Assim como antes, lançaremos o kernel em um espaço unidimensional de tarefas, igual ao número de neurônios em nossa camada, que é idêntico ao tamanho do buffer de resultados. No corpo do kernel, determinamos o identificador do fluxo e declaramos as variáveis locais necessárias. Também determinamos o deslocamento na matriz de pesos. Observe que, para cada neurônio na saída da camada, determinamos a quantidade de pesos igual ao tamanho cumulativo dos 2 buffers de dados de entrada e mais 1 neurônio para o viés bayesiano.

  {
   int i = get_global_id(0);
   float sum = 0;
   float4 inp, weight;
   int shift = (inputs1 + inputs2 + 1) * i;

A seguir, realizamos um laço para calcular a soma ponderada do primeiro buffer de dados de entrada. Esse processo é completamente idêntico ao que ocorre no kernel de uma camada neural totalmente conectada.

   for(int k = 0; k < inputs1; k += 4)
     {
      switch(inputs1 - k)
        {
         case 1:
            inp = (float4)(matrix_i1[k], 0, 0, 0);
            weight = (float4)(matrix_w[shift + k], 0, 0, 0);
            break;
         case 2:
            inp = (float4)(matrix_i1[k], matrix_i1[k + 1], 0, 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], 0, 0);
            break;
         case 3:
            inp = (float4)(matrix_i1[k], matrix_i1[k + 1], matrix_i1[k + 2], 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], 0);
            break;
         default:
            inp = (float4)(matrix_i1[k], matrix_i1[k + 1], matrix_i1[k + 2], matrix_i1[k + 3]);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]);
            break;
        }
      float d = dot(inp, weight);
      if(isnan(sum + d))
         continue;
      sum += d;
     }

Após a conclusão das iterações do laço, ajustamos o deslocamento na matriz de pesos para o tamanho do primeiro buffer de dados de entrada. Em seguida, criamos um laço semelhante para o segundo buffer de dados de entrada.

   shift += inputs1;
   for(int k = 0; k < inputs2; k += 4)
     {
      switch(inputs2 - k)
        {
         case 1:
            inp = (float4)(matrix_i2[k], 0, 0, 0);
            weight = (float4)(matrix_w[shift + k], 0, 0, 0);
            break;
         case 2:
            inp = (float4)(matrix_i2[k], matrix_i2[k + 1], 0, 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], 0, 0);
            break;
         case 3:
            inp = (float4)(matrix_i2[k], matrix_i2[k + 1], matrix_i2[k + 2], 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], 0);
            break;
         default:
            inp = (float4)(matrix_i2[k], matrix_i2[k + 1], matrix_i2[k + 2], matrix_i2[k + 3]);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]);
            break;
        }
      float d = dot(inp, weight);
      if(isnan(sum + d))
         continue;
      sum += d;
     }

No final do kernel, adicionamos um elemento para o viés bayesiano e ativamos a soma resultante. Em seguida, salvamos esse valor no elemento correspondente do buffer de resultados.

   sum += matrix_w[shift + inputs2];
//---
   if(isnan(sum))
      sum = 0;
   switch(activation)
     {
      case 0:
         sum = tanh(sum);
         break;
      case 1:
         sum = 1 / (1 + exp(-sum));
         break;
      case 2:
         if(sum < 0)
            sum *= 0.01f;
         break;
      default:
         break;
     }
   matrix_o[i] = sum;
  }

O mesmo método foi aplicado à modificação dos kernels de retropropagação e à atualização da matriz de pesos. Você pode revisá-los por conta própria no arquivo "NeuroNet_DNG\NeuroNet.cl" (anexado ao artigo).

Após a criação dos kernels, passamos a trabalhar no código da classe CNeuronConcatenate no programa principal. O conjunto de métodos da classe é bastante padrão.

  • construtor CNeuronConcatenate e destrutor ~CNeuronConcatenate
  • inicialização da camada de neurônios Init
  • propagação feedForward
  • distribuição de gradiente de erro calcHiddenGradients
  • atualização da matriz de pesos updateInputWeights
  • identificação do objeto Type
  • utilização de arquivos Save e Load

class CNeuronConcatenate   :  public CNeuronBaseOCL
  {
protected:
   int               i_SecondInputs;
   CBufferFloat     *ConcWeights;
   CBufferFloat     *ConcDeltaWeights;
   CBufferFloat     *ConcFirstMomentum;
   CBufferFloat     *ConcSecondMomentum;

public:
                     CNeuronConcatenate(void);
                    ~CNeuronConcatenate(void);
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                          uint inputs1, uint inputs2, ENUM_OPTIMIZATION optimization_type, uint batch);
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput);
   virtual bool      calcHiddenGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput);
   //--- methods for working with files
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual int       Type(void)        const                      {  return defNeuronConcatenate; }
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

Além disso, na classe, declaramos uma variável para armazenar o tamanho dos dados de entrada adicionais e quatro buffers de dados: matriz de pesos e momentos para diferentes métodos de otimização dos coeficientes de peso. Devo mencionar que os novos buffers serão usados para estabelecer a comunicação com a camada neural anterior e os novos dados de entrada. A realização da transferência de dados para a camada neural subsequente é gerenciada pelos recursos da classe pai da camada neural totalmente conectada CNeuronBaseOCL.

No construtor da classe, inicializamos os buffers de dados.

CNeuronConcatenate::CNeuronConcatenate(void) : i_SecondInputs(0)
  {
   ConcWeights = new CBufferFloat();
   ConcDeltaWeights = new CBufferFloat();
   ConcFirstMomentum = new CBufferFloat();
   ConcSecondMomentum = new CBufferFloat;
  }

E no destrutor da classe, realizamos a limpeza dos dados e a exclusão de objetos.

CNeuronConcatenate::~CNeuronConcatenate()
  {
   if(!!ConcWeights)
      delete ConcWeights;
   if(!!ConcDeltaWeights)
      delete ConcDeltaWeights;
   if(!!ConcFirstMomentum)
      delete ConcFirstMomentum;
   if(!!ConcSecondMomentum)
      delete ConcSecondMomentum;
  }

A definição das dimensões de todos os buffers de dados necessários é feita no método de inicialização do objeto Init. Os parâmetros do método recebem os dados de entrada necessários:

  • numOutputs — número de neurônios na camada seguinte
  • open_cl  —  ponteiro para o objeto de trabalho com OpenCL
  • numNeurons  —  número de neurônios na camada atual
  • numInputs1  —  número de elementos na camada anterior
  • numInputs2  —  número de elementos no buffer adicional de dados iniciais
  • optimization_type  — identificador do método de otimização de parâmetros
bool CNeuronConcatenate::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                              uint numInputs1, uint numInputs2, ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch))
      return false;

No corpo do método, em vez de um bloco de controle, chamamos um método semelhante da classe pai e verificamos o resultado das operações. Os controles principais já estão implementados na classe pai, portanto, não precisamos repeti-los. Além disso, o método na classe pai inicializa todos os objetos e variáveis herdados. Consequentemente, neste método, só precisamos realizar o processo de inicialização dos objetos adicionados.

Primeiro, criaremos e inicializaremos uma matriz de coeficientes de peso com valores aleatórios para estabelecer a troca de dados com a camada neural anterior. Observe que o tamanho da matriz de pesos é definido para permitir o trabalho com a camada anterior e um buffer adicional de dados de entrada. Essa foi a abordagem que previmos ao criar o kernel de propagação. E agora a seguimos ao criar os métodos da classe no programa principal.

   i_SecondInputs = (int)numInputs2;
   if(!ConcWeights)
     {
      ConcWeights = new CBufferFloat();
      if(!ConcWeights)
         return false;
     }
   int count = (int)((numInputs1 + numInputs2 + 1) * numNeurons);
   if(!ConcWeights.Reserve(count))
      return false;
   float k = (float)(1.0 / sqrt(numNeurons + 1.0));
   for(int i = 0; i < count; i++)
     {
      if(!ConcWeights.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier))
         return false;
     }
   if(!ConcWeights.BufferCreate(OpenCL))
      return false;

Em seguida, dependendo do método de atualização dos coeficientes de peso especificado nos parâmetros, inicializamos os buffers de momentos. Lembro que para o SGD usamos apenas um buffer de momentos, enquanto no caso do método Adam, inicializamos dois buffers de momentos. Os objetos não utilizados são excluídos, o que permite uma utilização mais eficiente dos recursos disponíveis.

   if(optimization == SGD)
     {
      if(!ConcDeltaWeights)
        {
         ConcDeltaWeights = new CBufferFloat();
         if(!ConcDeltaWeights)
            return false;
        }
      if(!ConcDeltaWeights.BufferInit(count, 0))
         return false;
      if(!ConcDeltaWeights.BufferCreate(OpenCL))
         return false;
      if(!!ConcFirstMomentum)
         delete ConcFirstMomentum;
      if(!!ConcSecondMomentum)
         delete ConcSecondMomentum;
     }
   else
     {
      if(!!ConcDeltaWeights)
         delete ConcDeltaWeights;
      //---
      if(!ConcFirstMomentum)
        {
         ConcFirstMomentum = new CBufferFloat();
         if(CheckPointer(ConcFirstMomentum) == POINTER_INVALID)
            return false;
        }
      if(!ConcFirstMomentum.BufferInit(count, 0))
         return false;
      if(!ConcFirstMomentum.BufferCreate(OpenCL))
         return false;
      //---
      if(!ConcSecondMomentum)
        {
         ConcSecondMomentum = new CBufferFloat();
         if(!ConcSecondMomentum)
            return false;
        }
      if(!ConcSecondMomentum.BufferInit(count, 0))
         return false;
      if(!ConcSecondMomentum.BufferCreate(OpenCL))
         return false;
     }
//---
   return true;
  }

Concluímos o trabalho com os métodos de inicialização da classe e passamos para a realização da funcionalidade principal. Primeiro, criaremos o método feedForward para a propagação. Ao contrário dos métodos de propagação de todas as classes anteriormente discutidas, este método recebe dois ponteiros para objetos nos parâmetros: a camada neural anterior e um buffer de dados de entrada adicional. Não há nada de surpreendente nisso, pois essa é a principal diferença da classe que estamos criando. No entanto, essa abordagem requer trabalho adicional no programa principal, além do escopo da classe em criação. Falaremos sobre isso um pouco mais tarde.

bool CNeuronConcatenate::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput)
  {
   if(!OpenCL || !NeuronOCL || !SecondInput)
      return false;

No corpo do método, primeiro verificamos a validade dos ponteiros recebidos. Também verificamos a existência do ponteiro para o objeto que lida com o contexto do OpenCL. Na ausência de pelo menos um desses ponteiros, encerramos o método com um resultado negativo.

Em seguida, verificamos o tamanho do buffer de dados adicionais. No mínimo, ele deve conter um número suficiente de elementos. Observe que permitimos especificar um buffer de tamanho maior. No entanto, durante a operação, apenas os primeiros elementos serão usados, na quantidade que foi especificada durante a inicialização da classe.

   if(SecondInput.Total() < i_SecondInputs)
      return false;
   if(SecondInput.GetIndex() < 0 && !SecondInput.BufferCreate(OpenCL))
      return false;

Em seguida, verificamos a existência do ponteiro para o buffer de dados no contexto do OpenCL e, se necessário, criamos um novo buffer.

Observe que criamos um novo buffer apenas se não houver um ponteiro para o buffer de dados no contexto. Se o ponteiro estiver presente, não realizamos o recarregamento dos dados no contexto. Consideramos que a existência do ponteiro indica a presença de dados no contexto. Portanto, se o conteúdo do buffer for alterado no programa principal, será necessário copiar os dados para o contexto. O controle da validade dos dados na memória do contexto fica a cargo do usuário.

A seguir, passamos os ponteiros para os buffers de dados e as constantes necessárias como parâmetros para o kernel. Este procedimento é idêntico para todos os kernels. Apenas os identificadores de kernels, parâmetros e ponteiros para os respectivos buffers de dados mudam. Todas as operações matemáticas devem ser especificadas no corpo do kernel em OpenCL.

   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_w, ConcWeights.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_i1, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_i2, SecondInput.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_o, Output.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_inputs1, (int)NeuronOCL.Neurons()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_inputs2, (int)i_SecondInputs))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_activation, (int)activation))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

No final das operações do método, especificamos o espaço de tarefas para executar o kernel e o colocamos na fila de execução.

   uint global_work_offset[1] = {0};
   uint global_work_size[1];
   global_work_size[0] = Output.Total();
   if(!OpenCL.Execute(def_k_ConcatFeedForward, 1, global_work_offset, global_work_size))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
//---
   return true;
  }

É muito importante controlar a correção da chamada do kernel em cada etapa, bem como o identificador do buffer e seu conteúdo. E, claro, não esquecemos de controlar a correção da execução das operações em cada etapa.

Os métodos de distribuição de gradientes de erro e atualização da matriz de pesos são construídos com base em um algoritmo semelhante, e você pode conhecê-los no anexo. Apenas observe que, ao distribuir o gradiente de erro, um buffer adicional de gradiente de erro será adicionado no nível de dados de entrada adicionais. Neste trabalho, não carregaremos ou usaremos seus dados, mas ele pode ser necessário no futuro se o vetor de dados de entrada adicional for formado por um segundo modelo.

Após a criação dos métodos de nossa classe CNeuronConcatenate, devemos cuidar da realização do processo de transferência do buffer de dados de entrada adicional do usuário no programa principal para uma camada neural específica. Lembro que, em geral, o processo é realizado de tal forma que, após a criação do modelo, o usuário em seu programa trabalha apenas com dois métodos: propagação e retropropagação do modelo como um todo. O usuário não controla o processo de transferência de dados entre as camadas neurais. Todo o processo acontece, por assim dizer, "sob o capô" de nossa biblioteca. Portanto, o usuário deve ser capaz de chamar um único método de propagação e especificar dois buffers de dados em seus parâmetros. O modelo deve, então, alocar os dados de forma independente para os fluxos de informação apropriados.

Nesta fase, planejamos usar apenas uma camada com reabastecimento de dados. E para não complicar o processo com o rastreamento adicional de qual camada neural deve receber dados adicionais, foi decidido passar um ponteiro para o buffer para todas as camadas neurais. A decisão de usar ou não é tomada no nível da própria classe.

Neste momento, não entraremos em detalhes sobre a adição de um parâmetro em vários métodos em cadeia. O código completo de todos os métodos e funções pode ser encontrado no anexo. Apenas destacaremos um detalhe: embora os métodos de propagação de todas as classes tenham nomes idênticos e sejam declarados como virtuais, adicionar um parâmetro em alguns e não em outros impede a completa substituição dos métodos nas classes derivadas. Para manter a herança, teríamos que reformular os métodos de propagação e retropropagação de todas as classes criadas anteriormente. Optamos por não fazer isso. Em vez disso, adicionamos apenas um controle adicional aos métodos de despacho da camada neural base. Vamos considerar o método de propagação como exemplo.

Nos parâmetros do método de gerenciamento CNeuronBaseOCL::FeedForward, adicionamos um ponteiro para um buffer de dados e atribuímos um valor padrão a ele. Essa artimanha nos permite continuar a usar o método apenas especificando um ponteiro para a camada neural anterior. Isso será útil ao usar a biblioteca para modelos criados anteriormente e permitirá compilar programas criados anteriormente sem qualquer modificação.

A seguir, verificamos o tipo da camada neural atual. Se estivermos em uma classe que combina dados de dois fluxos, chamamos o método de propagação apropriado. Caso contrário, usamos o algoritmo anteriormente criado. Abaixo está apenas uma parte do código do método com as alterações. O restante do código do método CNeuronBaseOCL::FeedForward não sofreu alterações. Você também encontrará os métodos de gerenciamento de retrocesso alterados no mesmo arquivo. Neles, também foram adicionados buffers adicionais com ponteiros nulos como padrão.

bool CNeuronBaseOCL::FeedForward(CObject *SourceObject, CBufferFloat *SecondInput = NULL)
  {
   if(CheckPointer(SourceObject) == POINTER_INVALID)
      return false;
//---
   CNeuronBaseOCL *temp = NULL;
   if(Type() == defNeuronConcatenate)
     {
      temp = SourceObject;
      CNeuronConcatenate *concat = GetPointer(this);
      return concat.feedForward(temp, SecondInput);
     }

Há muita informação e o tamanho do artigo é limitado. Assim, passei rapidamente pelos métodos da nova classe CNeuronConcatenate. Espero que isso não afete negativamente a compreensão das ideias e abordagens. De qualquer forma, o algoritmo deles é muito semelhante aos métodos das classes anteriormente consideradas. O código completo de todos os métodos e classes está no anexo. Mas, se você tiver alguma dúvida, estou à disposição para respondê-las nos fóruns e mensagens privadas deste site. Escolha o canal de comunicação que lhe for mais conveniente.

Agora, nos aproximaremos do método de aprendizado por reforço GCRL e examinaremos os processos de construção e treinamento do modelo. Como antes, criaremos 3 EAs:

  • conjunto inicial de exemplos "GCRL\Research.mq5"
  • treinamento do agente "GCRL\StudyActor.mq5"
  • testes de desempenho do modelo "GCRL\Test.mq5

A arquitetura do modelo será especificada no arquivo incluído "GCRL\Trajectory.mqh".

Conforme mencionado anteriormente, montaremos todo o modelo dentro de um único agente. Consequentemente, teremos apenas uma descrição de arquitetura do modelo. No corpo do método CreateDescriptions, primeiro verificaremos a validade do ponteiro para um objeto de matriz dinâmica e, se necessário, criaremos um novo objeto. Em seguida, limparemos a matriz dinâmica antes de adicionar novos objetos de descrição das camadas neurais.

bool CreateDescriptions(CArrayObj *actor)
  {
//---
   CLayerDescription *descr;
//---
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
//--- Actor
   actor.Clear();

Como sempre, começamos criando a camada de dados de entrada, seguida pela camada de normalização. Como mencionado anteriormente, para o codificador, os dados de entrada são apenas os dados históricos e os indicadores. Isso é refletido nos tamanhos das camadas neurais especificadas.

//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.window = 0;
   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;
     }

Em seguida, repetimos completamente a arquitetura do codificador da artigo anterior. Ele consiste em um bloco de convolução, seguido por 3 camadas totalmente conectadas, e é concluído com camadas de representação latente de um autocodificador variacional. É uma solução um pouco incomum para um modelo abrangente. Mas já falamos sobre a conveniência de dividir algoritmos e modelos. Veremos os resultados práticos.

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

A descrição do codificador está completa. Vamos passar para a criação de nosso Agente. Sua arquitetura começa com uma camada de fusão de 2 fluxos de informações. O primeiro fluxo tem o tamanho dos resultados do codificador. O segundo tem o tamanho do vetor de descrição da tarefa atribuída. Usaremos a descrição do saldo como o vetor de descrição da tarefa.

Na parte teórica, falamos sobre a necessidade de dividir tarefas. Em nosso esquema simplificado, usaremos apenas 2 tarefas:

  • pesquisa do ponto de entrada na posição
  • pesquisa do ponto de saída

Na estrutura de descrição do estado da conta, mencionamos as posições abertas. Consequentemente, se o volume das posições abertas for igual a "0", a tarefa é abrir uma posição. Caso contrário, estamos procurando um ponto de saída. A ideia é simples e lembra o uso de um vetor one-hot. A diferença está apenas no volume de posições abertas. Isso raramente será igual a "1". Afinal, estamos usando um lote mínimo e permitindo a abertura simultânea de várias posições.

//--- layer 10
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = 256;
   descr.window=prev_count;
   descr.step=AccountDescr;
   descr.optimization = ADAM;
   descr.activation = TANH;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Na descrição do saldo, usamos unidades relativas. Esperamos que seus valores sejam próximos aos dados normalizados. Portanto, não usaremos uma camada de normalização em lote aqui.

Em seguida, vem um bloco de tomada de decisão composto por 2 camadas totalmente conectadas e um bloco de função quantílica totalmente parametrizado (FQF). Como você pode notar, usamos um bloco de tomada de decisão semelhante no agente do artigo anterior. E já discutimos as principais propriedades e características das decisões de cada camada neural.

//--- layer 11
   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 12
   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 13
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFQF;
   descr.count = NActions;
   descr.window_out = 32;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Após a descrição da arquitetura do modelo, passamos para a criação do robô de coleta de dados primários "GCRL\Research.mq5". A maior parte do algoritmo deste EA está migrando de um artigo para outro quase sem alterações. Permita-me deixar a revisão detalhada desse EA fora do escopo deste artigo. Você pode encontrar o código completo deste EA no anexo. Apenas faremos uma breve pausa nas alterações causadas pelo uso do método GCRL.

Primeiramente, lembremos que uma das desvantagens dos modelos anteriores era a manutenção prolongada de posições em aberto. Após uma breve reflexão, podemos perceber que em nosso vetor de descrição do estado da conta, há informações sobre o volume de posições em aberto e os lucros acumulados em cada direção. No entanto, não há nenhuma indicação do período em que as posições foram abertas. Se quisermos ensinar o agente a controlar esse processo, devemos fornecer um indicador apropriado.

Dentro do escopo das ações do nosso agente, há apenas a opção de fechar todas as posições. Portanto, não vejo necessidade de separar explicitamente o tempo das posições longas e curtas. Vamos introduzir um indicador geral para todas as posições. Ao mesmo tempo, gostaríamos de criar um indicador que dependa não apenas do tempo, mas também do volume da posição e dos lucros ou perdas acumulados.

Sugerimos usar a soma dos valores absolutos dos lucros ou perdas acumulados, ponderada pelo período de manutenção da posição, como indicador. Isso nos permitirá adaptar o indicador ao tempo de abertura da posição, ao volume e à volatilidade do mercado (indiretamente, por meio dos lucros). E o uso do valor absoluto dos lucros nos permitirá eliminar o impacto mútuo de posições lucrativas e perdedoras. 

 Com base no exposto, vamos ajustar o processo de descrição do estado da conta, que é realizado no método OnTick do EA.

Nos dois primeiros elementos da descrição do estado da conta, vamos armazenar os indicadores de saldo e patrimônio da conta. Para reduzir a quantidade de informações e aumentar sua qualidade, decidimos não incluir os indicadores de margem, devido à sua baixa informatividade no contexto da tarefa atual. No entanto, não excluímos a possibilidade de adicioná-los em trabalhos futuros.

O tempo de abertura das posições é considerado em segundos, e estamos trabalhando com o intervalo de tempo H1. Determinaremos imediatamente um multiplicador para ajustar o tempo de vigência da posição em horas. Também adicionaremos uma variável para calcular a penalização pela manutenção da posição, conforme a fórmula mencionada anteriormente. No entanto, não queremos que a penalização pela manutenção exceda o lucro dessa posição. E, para isso, decidimos que a cada hora, penalizaremos 1/10 do lucro acumulado. O uso do valor absoluto do lucro na fórmula nos permitirá penalizar tanto posições lucrativas quanto perdedoras.

Armazenamos o horário atual em uma variável local e iniciamos um laço para percorrer as posições abertas. No corpo do laço, calculamos o volume das posições abertas, os lucros/perdas acumulados em cada direção e o total de penalizações pela manutenção das posições.

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

Após concluir as iterações do laço, salvamos os valores obtidos nos elementos correspondentes de uma matriz para registro na base de exemplos.

Antes de enviar os dados para o nosso modelo, os convertemos em unidades relativas.

   State.AssignArray(sState.state);
   Account.Clear();
   float PrevBalance = (Base.Total <= 0 ? sState.account[0] : Base.States[Base.Total - 1].account[0]);
   float PrevEquity = (Base.Total <= 0 ? sState.account[1] : Base.States[Base.Total - 1].account[1]);
   Account.Add((sState.account[0] - PrevBalance) / PrevBalance);
   Account.Add(sState.account[1] / PrevBalance);
   Account.Add((sState.account[1] - PrevEquity) / PrevEquity);
   Account.Add(sState.account[2]);
   Account.Add(sState.account[3]);
   Account.Add(sState.account[4] / PrevBalance);
   Account.Add(sState.account[5] / PrevBalance);
   Account.Add(sState.account[6] / PrevBalance);

Lembrando que, ao descrever o método de propagação, enfatizamos que a responsabilidade pela atualização dos dados no buffer de informações de estado da conta na memória do contexto OpenCL está nas mãos do usuário. Portanto, após atualizar o buffer de informações de estado da conta, transferimos seu conteúdo para a memória do contexto. Somente após isso chamamos o método de propagação do nosso agente, fornecendo os ponteiros para ambos os buffers de dados.

   if(Account.GetIndex()>=0)
      if(!Account.BufferWrite())
         return;
   if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
      return;

O bloco de amostragem e ação do agente foram transferidos dos EAs equivalentes sem alterações, e aqui omitiremos sua descrição.

No encerramento da descrição das alterações na função OnTick do EA de coleta de exemplos, é necessário mencionar algumas palavras sobre a função de recompensa. Como antes, a base da nossa função de recompensa é o valor relativo da mudança no saldo da conta. No entanto, o método GCRL prevê recompensas adicionais por atingir metas locais. No nosso caso, usaremos penalizações. Para a tarefa de fechamento de posições, subtrairá o indicador calculado anteriormente da soma ponderada dos valores absolutos dos lucros e perdas acumulados. Isso deve penalizar fortemente a manutenção de posições com lucros ou perdas significativos, incentivando o agente a fechá-las. Por outro lado, posições com pequenos lucros não gerarão penalizações significativas, permitindo que o agente espere até que os lucros se acumulem.

   float reward = Account[0];
   if((buy_value+sell_value)>0)
     reward+=(float)position_discount;
   else
     reward-=atr;
   if(!Base.Add(sState, act, reward))
      ExpertRemove();
//---
  }

No caso de não haver posições abertas, incentivaremos o agente a realizar negociações. Nesse caso, uma penalização será aplicada com base no valor atual do indicador ATR.

No mais, o algoritmo do EA não sofreu alterações. Você pode consultar o código completo no anexo.

Após concluir o trabalho no EA de coleta de exemplos "GCRL\Research.mq5", o executamos no modo de otimização lenta do testador de estratégia. Em seguida, passamos para o trabalho no EA de treinamento do Agente "GCRL\StudyActor.mq5".

Neste trabalho, estaremos treinando o agente apenas com ações e recompensas armazenadas na base de exemplos. Não calcularemos recompensas previstas para outras ações, como fizemos no artigo anterior. Em vez disso, concentraremos nosso esforço no treinamento do agente para construir uma política dependendo da tarefa definida. Aproveitaremos o fato de que em nossa base de exemplos temos passagens por um único intervalo de tempo histórico. Devido a algumas ações selecionadas aleatoriamente durante a coleta da base de exemplos, em cada passagem para um momento histórico, obteremos um conjunto diferente de posições abertas, lucros/perdas acumulados com diferentes ações do agente e subsequentes recompensas. Isso significa que podemos realizar várias passagens diretas e reversas do modelo a partir de um único momento histórico, estabelecendo diferentes tarefas locais para o agente. Isso nos dará o efeito de re-jogar um único momento várias vezes e explorar o ambiente circundante.

Não gastaremos recursos nem tempo procurando estados históricos idênticos. Em vez disso, usaremos a estacionariedade dos dados históricos. É fácil perceber que todos os nossos agentes de teste partiram de um único momento histórico. E "passaram" pelo mesmo número de etapas (velas). A única exceção pode ser a parada de teste devido ao stop-out. Mas sempre a cada N-etapa em todas as passagens corresponderá a um único momento histórico. É isso que usaremos para nosso treinamento do agente.

Como sempre, o treinamento do modelo é realizado na função "Train" do EA "GCRL\StudyActor.mq5". No início da função, determinamos o número de passagens em nossa base de exemplos. Em seguida, realizamos o primeiro laço, no qual encontraremos a passagem com o maior número de etapas. Não salvamos a passagem em si, apenas o número de etapas. Isso será usado ao amostrar um momento histórico específico para treinamento.

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   int total_steps = 0;
   for(int tr = 0; tr < total_tr; tr++)
     {
      if(Buffer[tr].Total > total_steps)
         total_steps = Buffer[tr].Total;
     }

Em seguida, criamos um sistema com 2 laços aninhados. O primeiro controla o número de iterações para treinamento do modelo. No corpo desse laço, amostramos um momento histórico para a iteração atual. No laço aninhado, iteramos por todas as passagens disponíveis e verificamos se o estado amostrado está presente nelas.

   uint ticks = GetTickCount();
//---
   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (total_steps - 2));
      for(int tr = 0; tr < total_tr; tr++)
        {
         if(i >= (Buffer[tr].Total - 1))
            continue;

Se esse estado estiver presente, treinamos o Agente usando os dados salvos e passamos para a próxima passagem.

         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);
         //---
         if(Account.GetIndex()>=0)
            Account.BufferWrite();
         if(!Actor.feedForward(GetPointer(State), 1, false,GetPointer(Account)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            ExpertRemove();
            break;
           }
         //---
      ActorResult = vector<float>::Zeros(NActions);
      ActorResult[Buffer[tr].Actions[i]] = Buffer[tr].Revards[i];
      Result.AssignArray(ActorResult);
      if(!Actor.backProp(Result, 0, NULL, 1, false,GetPointer(Account),GetPointer(Gradient)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }
         if(GetTickCount() - ticks > 500)
           {
            string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Actor", 
                                       iter * 100.0 / (double)(Iterations),
                                       Actor.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

Dessa forma, cada estado individual será recriado pelo nosso agente em termos de passagens com diferentes configurações locais. Isso deve mostrar ao agente que suas ações devem levar em consideração não apenas o estado do ambiente, mas também a tarefa local. Lembro que, ao coletar a base de exemplos, adicionamos penalidades em cada etapa por não cumprir a tarefa local. Agora, em cada passagem, teremos diferentes recompensas para um único momento histórico, correspondendo às tarefas locais das passagens.

No restante, o código do EA permaneceu inalterado. O código completo de todos os programas usados no artigo pode ser encontrado no anexo.


3. Teste

Após concluir o trabalho nos EAs, avançamos para a etapa de treinamento do modelo e teste dos resultados obtidos. Não alteramos os parâmetros de treinamento do modelo. Como antes, o modelo é treinado com dados históricos do EURUSD no intervalo de tempo H1. Os parâmetros dos indicadores são usados com as configurações padrão. Nosso agente foi treinado com dados de quatro meses de 2023. Testamos a qualidade do treinamento e a capacidade do Agente de operar em novos dados no período de 1 a 18 de junho de 2023.

Os resultados dos testes estão apresentados nas capturas de tela abaixo. Como pode ser visto, durante o teste, o modelo conseguiu obter lucro. O gráfico de saldo apresenta fases de crescimento e movimento lateral. É reconfortante não ver quedas significativas. No geral, ao longo de 12 dias de negociação, o fator de lucro foi de 2,2 e o fator de recuperação foi de 1,47. O EA realizou 220 negociações. Mais de 53% delas foram fechadas com lucro. Além disso, a posição lucrativa média é quase o dobro da posição com prejuízo. Infelizmente, o EA abriu apenas posições longas. Já nos deparamos com esse efeito antes e a abordagem utilizada não resolveu esse problema.

Gráfico do teste

Resultados do teste

Tempo de retenção da posição

Um aspecto positivo do uso do método GCRL é a redução do tempo de retenção da posição. Durante o teste, o tempo máximo de retenção da posição foi de 21 horas e 15 minutos. O tempo médio de retenção da posição foi de 5 horas e 49 minutos. Lembro que, por não cumprir a tarefa de fechar a posição, aplicamos uma penalidade equivalente a 1/10 do lucro acumulado a cada hora de retenção. Ou seja, após 10 horas de retenção, a penalidade excedeu o lucro da posição.


Considerações finais

Neste artigo, nos familiarizamos com o método de aprendizado por reforço voltado para o alcance de metas locais (Goal-conditioned reinforcement learning, GCRL). A característica deste método é a introdução de metas locais e recompensas por alcançá-las. Isso nos permite dividir uma tarefa global em várias tarefas menores e avançar passo a passo em direção à sua realização.

Essa abordagem possui várias vantagens. Ela reduz a complexidade do treinamento, dividindo a tarefa em componentes menores e mais gerenciáveis. Isso simplifica o processo de tomada de decisões e melhora a velocidade de aprendizado do agente.

Além disso, o GCRL contribui para aumentar a capacidade de generalização do agente. À medida que o agente aprende a resolver diversas tarefas locais, ele desenvolve um conjunto de habilidades e estratégias que podem ser aplicadas em diferentes contextos.

Por fim, o GCRL oferece flexibilidade na definição de metas e tarefas para o agente. Podemos escolher e modificar as tarefas locais com base em nossas necessidades e nas condições do ambiente circundante. Isso permite que o agente se adapte a diferentes situações e utilize efetivamente suas habilidades para alcançar os objetivos estabelecidos.

Implementamos o método apresentado utilizando a linguagem MQL5. Treinamos o modelo e testamos os resultados do treinamento com dados além do conjunto de treinamento. Os resultados dos testes revelaram a existência de questões ainda não resolvidas, como o fato de o EA abrir posições apenas em uma direção. No entanto, isso não o impediu de obter lucro durante os testes.

Também é importante notar a redução no tempo de retenção da posição, o que confirma o trabalho do Agente na resolução das duas tarefas locais definidas: abrir e fechar posições.

No geral, os resultados dos testes são positivos e permitem a utilização deste método na busca por novas soluções.


Referências

  • Variational Empowerment as Representation Learning for Goal-Based Reinforcement Learning
  • Redes neurais de maneira fácil (Parte 43): Dominando habilidades sem função de recompensa
  • Redes neurais de maneira fácil (Parte 44): Explorando habilidades de forma dinâmica
  • Redes neurais de maneira fácil (Parte 45): Ensinando habilidades para investigar estados

  • Programas utilizados no artigo

    # Nome Tipo Descrição
    1 Research.mq5 EA EA de coleta de exemplos
    StudyActor.mq5  EA EA de treinamento do agente
    3 Test.mq5 EA EA para teste do modelo
    4 Trajectory.mqh Biblioteca de classe Estrutura de descrição do estado do sistema
    5 FQF.mqh Biblioteca de classe Biblioteca de classes de preparação de modelos totalmente parametrizada
    6 NeuroNet.mqh Biblioteca de classe Biblioteca de classes para a criação de uma rede neural
    7 NeuroNet.cl Biblioteca Biblioteca do código do programa OpenCL
    8 VAE.mqh
    Biblioteca de classe
    Biblioteca da classe de camada latente de autocodificador variacional

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

    Arquivos anexados |
    MQL5.zip (602.06 KB)
    Desenvolvendo um sistema de Replay (Parte 29): Projeto Expert Advisor — Classe C_Mouse (III) Desenvolvendo um sistema de Replay (Parte 29): Projeto Expert Advisor — Classe C_Mouse (III)
    Agora que a classe C_Mouse foi melhorada. Podemos focar em criar uma classe que será usada para promover uma base completamente diferente de estudos. Mas como expliquei no inicio do artigo, não iremos usar herança ou polimorfismo para gerar esta nova classe. Iremos modificar, ou melhor dizendo, agregar alguns objetos novos a linha de preço. Isto neste primeiro momento, no próximo artigo mostrarei como modificar os estudos. Mas faremos isto sem mexer no código da classe C_Mouse. Sei que na pratica, isto seria mais simples ser feito usando herança ou polimorfismo. No entanto, existem técnicas diferentes para se conseguir a mesma coisa.
    Avaliando modelos ONNX usando métricas de regressão Avaliando modelos ONNX usando métricas de regressão
    A regressão é uma tarefa de prever um valor real a partir de um exemplo não rotulado. Para avaliar a precisão das previsões de modelos de regressão, são utilizadas as chamadas métricas de regressão.
    Como detectar tendências e padrões gráficos usando MQL5 Como detectar tendências e padrões gráficos usando MQL5
    Neste artigo, é apresentado um método de detecção automática de padrões de ação de preços usando o MQL5, como tendências (de alta, de baixa e laterais) e padrões gráficos (topo duplo, fundo duplo).
    Redes neurais de maneira fácil (Parte 45): Ensinando habilidades para investigar estados Redes neurais de maneira fácil (Parte 45): Ensinando habilidades para investigar estados
    Aprender habilidades úteis sem uma função de recompensa explícita é um dos principais desafios do aprendizado por reforço hierárquico. Anteriormente, já nos familiarizamos com dois algoritmos para resolver esse problema. Mas a questão da completa exploração do ambiente ainda está em aberto. Neste artigo, é apresentada uma abordagem diferente para o treinamento de habilidades, cujo uso depende diretamente do estado atual do sistema.