Redes neurais de maneira fácil (Parte 35): Módulo de curiosidade intrínseca

Dmitriy Gizlyk | 4 abril, 2023

Conteúdo


Introdução

Continuamos a explorar algoritmos de aprendizado por reforço. Gostaria de lembrar que todos os algoritmos de aprendizado por reforço são fundamentados no paradigma de obter recompensas do ambiente a cada transição do agente entre estados distintos ao realizar determinada ação. O agente, por sua vez, busca elaborar sua política de ação de maneira a maximizar as recompensas obtidas. Ao iniciar o estudo de algoritmos de aprendizado por reforço, destacamos a importância de estabelecer uma política de recompensa clara, que desempenha um papel crucial no alcance dos objetivos de treinamento do modelo.

No entanto, na maioria das situações reais, as recompensas não são atribuídas imediatamente após cada ação. Por vezes, existe um intervalo de tempo entre a ação e a recompensa, com durações variáveis. Além disso, a concessão de uma recompensa pode depender de uma série de ações. Nestes casos, fragmentamos a recompensa total em componentes menores e os distribuímos ao longo do percurso do agente, desde a ação até a obtenção da recompensa. Esse processo é bastante complexo, envolvendo condições e decisões variadas.

A negociação é um exemplo de tarefas como essa. O agente deve abrir uma posição na direção correta no momento adequado. Aguardar o instante de maior rentabilidade da posição aberta e, em seguida, fechá-la, consolidando o resultado da operação. A recompensa, na forma de alteração no saldo da nossa conta, só é obtida no momento do fechamento da posição. Nos algoritmos analisados anteriormente, essa recompensa era distribuída a cada etapa (intervalo de tempo correspondente a uma vela) proporcionalmente à variação no preço do instrumento financeiro. Porém, questiona-se até que ponto isso é apropriado. Afinal, em cada etapa, o agente realiza uma ação diferente, seja uma operação de negociação ou a recusa em realizá-la. É importante esclarecer que a não realização de operações de negociação também é considerada uma ação do agente, visto que ele deve optar por essa ação. Portanto, a questão sobre a contribuição de cada ação para o resultado geral ainda permanece em aberto. 

Existem outras abordagens para estabelecer políticas de recompensa e modelos de treinamento?


1. A curiosidade é o desejo pelo conhecimento

Pensemos no comportamento dos seres vivos. Animais e pássaros conseguem percorrer grandes distâncias em busca de alimento. Além disso, os seres humanos nem sempre recebem uma recompensa por cada ação realizada. Os princípios do aprendizado humano são bastante diversos. Um dos principais impulsionadores da aprendizagem é a curiosidade. Certamente, quando há uma porta fechada diante de você, é a curiosidade que te motiva a abri-la ligeiramente e espiar o que há dentro. Isso faz parte da natureza humana.

Nosso cérebro funciona de tal maneira que, ao realizarmos uma ação, já prevemos o resultado de seu impacto em 1 ou 2 passos adiante, e às vezes até mais. Afinal, qualquer ação que realizamos tem como objetivo alcançar um resultado desejado. Só depois de comparar o resultado obtido com nossas expectativas é que ajustamos nossas ações. Vale lembrar que só podemos repetir uma tentativa em um jogo. Na vida real, não temos a chance de voltar atrás e reviver completamente uma situação. Cada nova tentativa traz um novo resultado. Portanto, antes de executar qualquer ação, analisamos toda a experiência que adquirimos previamente. Baseando-nos nos resultados dessa análise, escolhemos a ação que consideramos correta.

Ao enfrentarmos uma situação desconhecida, buscamos explorar e memorizar o ambiente ao máximo. Nesse processo, nem sempre refletimos sobre os possíveis benefícios que essa ação pode nos proporcionar no futuro. Não recebemos recompensas imediatas por nossos atos, mas adquirimos experiências que podem ser úteis mais adiante.

Lembrando que já discutimos antes a necessidade de explorar o ambiente intensivamente e o equilíbrio entre aplicar a experiência prévia e investigar o entorno. Também mencionamos a introdução do hiperparâmetro de novidade na estratégia ε-gananciosa. No entanto, esse hiperparâmetro é uma constante, e nosso objetivo é ensinar o modelo a gerenciar o nível de novidade conforme a situação.

É com essa perspectiva que os autores do artigo "Curiosity-driven Exploration by Self-supervised Prediction aplicaram suas abordagens na construção do algoritmo. O artigo foi publicado em maio de 2017 e tem como fundamento o desenvolvimento da curiosidade, como um erro na habilidade do modelo de prever as consequências de suas ações, aumentando assim o interesse por ações antes não realizadas. O estudo aborda três grandes desafios:

  1. Recompensa externa escassa: A curiosidade possibilita alcançar objetivos com menos interações com o ambiente.
  2. Aprendizado sem recompensa externa: A curiosidade motiva o agente a explorar o ambiente de maneira eficiente, mesmo na ausência de recompensas externas.
  3. Generalização para cenários desconhecidos: O conhecimento obtido a partir de experiências anteriores auxilia o agente a explorar novos locais muito mais rapidamente do que começar do zero.

Os autores propuseram uma ideia bastante simples: adicionar uma recompensa interna ri à recompensa externa re, que servirá como uma medida de curiosidade e incentivará o conhecimento do ambiente. Então, essa combinação será apresentada ao agente para aprendizado. Para ajustar a intensidade do impacto das recompensas externas e internas, coeficientes de escala de recompensa podem ser utilizados. Esses coeficientes são hiperparâmetros do modelo.

A principal inovação reside na arquitetura do bloco ICM, que é responsável por gerar essa recompensa interna. O módulo de Curiosidade Interna contém três modelos distintos:

O módulo recebe como entrada dois estados subsequentes do sistema e a ação a ser realizada. A ação é codificada como um vetor one-hot. A codificação da ação pode ser feita tanto fora quanto dentro do módulo. Os estados do sistema que chegam à entrada do módulo são codificados usando o codificador. As funções do codificador incluem a redução da dimensionalidade do tensor que descreve o estado do sistema e a filtragem dos dados. Os autores classificam todos os atributos de descrição do estado do sistema em três grupos:

  1. Aqueles que são influenciados pelo agente.
  2. Independentes do agente, mas que exercem influência sobre ele.
  3. Independentes do agente e que não exercem influência sobre ele.

O objetivo do codificador é auxiliar a concentrar a atenção nos dois primeiros grupos e minimizar a influência do terceiro grupo.

O Modelo Inverso (Inverse Model) recebe como entrada a codificação dos estados de dois estados subsequentes e é treinado para determinar a ação ideal para a transição entre os estados. O treinamento do Modelo Inverso em conjunto com o codificador visa destacar os dois primeiros grupos de características. A função de perda LogLoss é utilizada no Modelo Inverso.

O Modelo Avançado (Forward Model) é treinado com base no estado atual codificado e na ação executada para prever o próximo estado. A qualidade dessa previsão é a medida da curiosidade. O erro de previsão, calculado por meio do Erro Médio Quadrático (MSE), representa a recompensa intrínseca.

Módulo de curiosidade interior

Pode parecer estranho que, à medida que o erro do Modelo Avançado aumenta, a recompensa interna para o modelo DQN em treinamento também aumenta. O objetivo é incentivar o modelo a realizar mais ações cujo resultado é desconhecido, explorando assim o ambiente ao máximo. Conforme o ambiente é explorado, a curiosidade do modelo diminui, e o DQN passa a maximizar a recompensa extrínseca.

O módulo de Curiosidade Intrínseca pode ser usado em conjunto com qualquer um dos modelos que abordamos até agora. Ao mesmo tempo, não nos esquecemos de aplicar todas as soluções arquitetônicas previamente estudadas para aprimorar a convergência do modelo.

Os testes práticos realizados pelos autores da metodologia demonstram a eficácia do algoritmo em jogos de computador que oferecem recompensas ao final de cada fase. Além disso, o modelo exibe a capacidade de generalização, manifestada na habilidade de utilizar a experiência adquirida anteriormente ao avançar para um novo nível do jogo. Nesse contexto, destaca-se a capacidade do modelo de apresentar bom desempenho ao lidar com mudanças de texturas e adição de ruído. Ou seja, o modelo aprende a focar nos aspectos principais e a ignorar diversos fenômenos de ruído. Isso contribui para aumentar a estabilidade do modelo em diferentes estados do ambiente.


2. Bloco de curiosidade interna utilizando MQL5

Após uma breve familiarização com os aspectos teóricos da metodologia, passamos para a parte prática do nosso artigo, na qual implementaremos o método em questão utilizando MQL5. Antes de prosseguir com a implementação, vale mencionar que neste trabalho nos desviaremos das abordagens anteriormente analisadas por diversas razões.

A primeira diferença significativa será a política de recompensa. Decidi aproximar ao máximo da situação real. A recompensa externa será representada pela mudança no saldo da conta, e não no patrimônio líquido. Sim, entendo que tal recompensa pode ser bastante rara, mas é justamente esse problema que pretendemos resolver com a aplicação da metodologia em análise.

E como estamos limitados à recompensa na forma de alteração do saldo, mas ao mesmo tempo, cada ação do agente pode ser expressa em operações de negociação, precisamos adicionar à descrição do estado do sistema indicadores que caracterizem o estado da conta de negociação. Neste contexto, precisamos monitorar a abertura e o fechamento de posições, bem como o acúmulo de lucros não realizados para cada posição.

Para não elaborar o rastreamento de cada posição no código do EA, optou-se por mover o processo de treinamento do modelo para o testador de estratégias. Permitiremos que o modelo realize operações no testador de estratégias. As funções de consulta ao status da conta e das posições abertas nos fornecerão todas as informações necessárias a partir do testador de estratégias.

Isso implica na necessidade de criar um buffer de memória para reprodução da experiência. Discutimos os motivos para a criação de tal buffer no artigo "Redes neurais de maneira fácil (Parte 27): aprendizado Q profundo (DQN)". Anteriormente, utilizávamos todo o histórico do instrumento durante o período de treinamento como buffer. No entanto, adicionar dados sobre o estado da conta não nos permite manter essa abordagem. Portanto, implementaremos um buffer de experiência acumulativa dentro do programa.

Além disso, permitiremos que o EA (Expert Advisor) abra várias posições simultaneamente, incluindo posições com direções diferentes. Com isso, modificaremos o espaço das ações permitidas para o agente. Daremos ao agente a capacidade de realizar 4 ações:

0 - compra

1 - venda

2 - fechamento de todas as posições abertas

3 - ignorar a rodada, aguardando um estado adequado.

Iniciaremos nosso trabalho com a criação de um buffer para reproduzir a experiência.


2.1. Buffer de Reprodução de Experiência (Experience Replay)

Do buffer de reprodução de experiência, precisamos da capacidade de adicionar registros constantemente. A cada vez, adicionaremos um pacote completo de dados, que inclui:

Na minha opinião, a abordagem mais adequada para implementar o buffer seria utilizar um vetor dinâmico de objetos. Cada registro individual conterá um objeto com as informações mencionadas acima.

Para elaborar cada registro individual no buffer, vamos criar a classeCReplayState como herdeira da classe base CObject. Na classe, utilizaremos um objeto de buffer de dados estático e duas variáveis para armazenar informações sobre a ação realizada e a recompensa recebida.

É importante observar que a ação do agente é realizada a partir do estado atual. O agente recebe uma recompensa pela transição para esse estado, ou seja, pela passagem do estado anterior para o atual, em função da ação executada na etapa anterior. Embora adicionemos simultaneamente recompensa e ação ao buffer de experiência, eles se referem a diferentes intervalos de tempo.

class CReplayState : public CObject
  {
protected:
   CBufferFloat      cState;
   int               iAction;
   double            dReaward;

public:
                     CReplayState(CBufferFloat *state, int action, double reward);
                    ~CReplayState(void) {};
   bool              GetCurrent(CBufferFloat *&state, int &action);
   bool              GetNext(CBufferFloat *&state, double &reward);
  };

Ao utilizar os parâmetros do construtor da classe, podemos obter todas as informações necessárias e copiá-las imediatamente tanto para o objeto interno quanto para as variáveis de classe.

CReplayState::CReplayState(CBufferFloat *state, int action, double reward)
  {
   cState.AssignArray(state);
   iAction = action;
   dReaward = reward;
  }

Ao usar um objeto de buffer de dados estático, nosso destruidor de classe permanece vazio.

Vamos adicionar mais 2 métodos à nossa classe para obter os dados salvos GetCurrent e GetNext. No primeiro caso, retornamos o estado e a ação, já no segundo, ação e recompensa.

bool CReplayState::GetCurrent(CBufferFloat *&state, int &action)
  {
   action = iAction;
   double reward;
   return GetNext(state, reward);
  }

O algoritmo de ambos os métodos é bastante simples. E veremos seu uso um pouco mais tarde.

bool CReplayState::GetNext(CBufferFloat *&state, double &reward)
  {
   reward = dReaward;
   if(!state)
     {
      state = new CBufferFloat();
      if(!state)
         return false;
     }
   return state.AssignArray(GetPointer(cState));
  }

Depois de criar o objeto de registro individual, passamos a tornar nosso buffer de experiência CReplayBuffer um descendente da classe de matriz de objetos dinâmicos CArrayObj. Esta classe será constantemente atualizada com novos estados durante a operação do Expert Advisor. E para evitar estouro de memória, limitaremos o tamanho máximo ao valor da variável iMaxSize. Para controlar o tamanho do buffer, adicionaremos o método SetMaxSize. Não criamos outros objetos e variáveis no corpo da classe. Portanto, o construtor e o destruidor da classe ficam vazios.

class CReplayBuffer : protected CArrayObj
  {
protected:
   uint              iMaxSize;
public:
                     CReplayBuffer(void) : iMaxSize(500) {};
                    ~CReplayBuffer(void) {};
   //---
   void              SetMaxSize(uint size)   {  iMaxSize = size; }
   bool              AddState(CBufferFloat *state, int action, double reward);
   bool              GetRendomState(CBufferFloat *&state1, int &action, double &reward, CBufferFloat*& state2);
   bool              GetState(int position, CBufferFloat *&state1, int &action, double &reward, CBufferFloat*& state2);
   int               Total(void) { return CArrayObj::Total(); }
  };

Adicionaremos entradas ao buffer usando o método AddState. Nos parâmetros, o método recebe os dados do novo registro, incluindo o tensor de estado, a ação e a recompensa externa.

No corpo do método, verificamos o ponteiro para o objeto de buffer de estado do sistema. E depois de passar com sucesso a verificação do ponteiro, criamos um novo objeto de registro e o adicionamos ao array dinâmico. Os métodos da classe pai são utilizados para realizar as principais operações com o array dinâmico.

Por fim, verificamos o tamanho do buffer atual. E, se necessário, excluímos os objetos mais antigos, alinhando o tamanho do buffer com o tamanho máximo do buffer especificado.

bool CReplayBuffer::AddState(CBufferFloat *state, int action, double reward)
  {
   if(!state)
      return false;
//---
   if(!Add(new CReplayState(state, action, reward)))
      return false;
   while(Total() > (int)iMaxSize)
      Delete(0);
//---
   return true;
  }

Para obter dados do buffer, criaremos 2 métodosGetRendomState eGetState. O primeiro retorna um estado aleatório do buffer e o segundo retorna os estados no índice especificado no buffer. No corpo do primeiro método, apenas geramos um número aleatório dentro do tamanho do buffer e chamamos o segundo método para obter os dados com o índice gerado.

bool CReplayBuffer::GetRendomState(CBufferFloat *&state1, int &action, double &reward, CBufferFloat *&state2)
  {
   int position = (int)(MathRand() * MathRand() / pow(32767.0, 2.0) * (Total() - 1));
   return GetState(position, state1, action, reward, state2);
  }

Ao analisar o algoritmo do segundo método GetState, é impressionante a diferença na quantidade de dados que são solicitados e armazenados em relação ao método anterior. Enquanto no primeiro método recebemos um único estado do sistema para salvar, agora são solicitados dois tensores de estado do ambiente.

Vamos relembrar como é feito o processo de aprendizado Q. O treinamento é baseado em 4 objetos de dados:

Portanto, precisamos extrair dois estados subsequentes do sistema do buffer de experiência. Aqui, devemos lembrar que armazenamos a ação do estado analisado do ambiente e a recompensa pela transição para esse mesmo estado. Logo, precisamos extrair o estado e a ação de um registro. E do registro subsequente, extraímos o estado do ambiente e a recompensa. É dessa forma que organizamos os métodos GetCurrent e GetNext mencionados anteriormente.

Agora, vamos examinar a implementação do método GetState. Primeiramente, no corpo do método, verificamos o índice especificado da entrada a ser recuperada. Este deve ser, no mínimo, 0 e, no máximo, o índice da penúltima entrada no buffer. Afinal, precisamos dos dados de dois registros subsequentes.

Em seguida, chamamos o método GetCurrent para a entrada com o índice especificado. Depois, passamos para o próximo registro e chamamos o método GetNext. Retornaremos o resultado dessas operações ao programa chamador.

bool CReplayBuffer::GetState(int position, CBufferFloat *&state1, int &action, double &reward, CBufferFloat *&state2)
  {
   if(position < 0 || position >= (Total() - 1))
      return false;
   CReplayState* element = m_data[position];
   if(!element || !element.GetCurrent(state1, action))
      return false;
   element = m_data[position + 1];
   if(!element.GetNext(state2, reward))
      return false;
//---
   return true;
  }

O buffer de experiência está relacionado a uma sessão de aprendizado específica, e não há benefícios em salvar seus dados. Portanto, não implementaremos nenhum método de manipulação de arquivos nas classes mencionadas anteriormente e prosseguiremos.


2.2. Módulo de Curiosidade Intrínseca (ICM)

Após a criação do buffer de experiência, passamos diretamente à implementação do algoritmo do módulo de curiosidade intrínseca. Conforme mencionado anteriormente na parte teórica deste livro, o módulo utiliza três modelos: encoder, modelos inversos e modelos diretos. Em minha implementação, fiz um pequeno desvio da arquitetura proposta pelos autores e, para economizar recursos, optei por não criar um encoder separado para o módulo de curiosidade intrínseca.

A arquitetura original prevê a criação de um encoder semelhante ao utilizado no modelo DQN treinado. No entanto, decidi utilizar o encoder já existente no modelo de treinamento para fins de codificação de sinal. Isso, é claro, exige a sincronização dos modelos e algumas adições ao método de retropropagação do modelo, mas ao mesmo tempo, reduz o consumo de memória e recursos computacionais na criação e treinamento de um encoder adicional.

Além disso, espero obter um benefício adicional na forma de um ajuste mais refinado do encoder no modelo DQN treinado.

Para implementar o algoritmo, criaremos uma nova classe de gerenciamento de rede neural CICM, que herda nossa classe básica de gerenciamento de rede neural CNet. No corpo desta classe, adicionaremos três variáveis internas:

Além disso, declararemos quatro objetos internos, incluindo um objeto do buffer de acumulação de experiência e três objetos de redes neurais: cTargetNet, cInverseNet e cForwardNet.

Vale lembrar que utilizaremos o aprendizado Q, e a Target Net é um dos principais pilares desse método de aprendizado.

class CICM : protected CNet
  {
protected:
   uint              iMinBufferSize;
   uint              iStateEmbedingLayer;
   double            dPrevBalance;
   //---
   CReplayBuffer     cReplay;
   CNet              cTargetNet;
   CNet              cInverseNet;
   CNet              cForwardNet;

   virtual bool      AddInputData(CArrayFloat *inputVals);

public:
                     CICM(void);
                     CICM(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse);
   bool              Create(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse);
   int               feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true, bool sample = true); 
   bool              backProp(int batch, float discount = 0.9f);
   int               getAction(void);      
   int               getSample(void);
   float             getRecentAverageError() { return recentAverageError; }
   bool              Save(string file_name, bool common = true);
   bool              Save(string dqn, string forward, string invers, bool common = true);
   virtual bool      Load(string file_name, bool common = true);
   bool              Load(string dqn, string forward, string invers, uint state_layer, bool common = true);
   //---
   virtual int       Type(void)   const   {  return defICML;   }
   virtual bool      TrainMode(bool flag)
            { return (CNet::TrainMode(flag) && cForwardNet.TrainMode(flag) && cInverseNet.TrainMode(flag)); } 
   virtual bool      GetLayerOutput(uint layer, CBufferFloat *&result) 
     { return        CNet::GetLayerOutput(layer, result); }
   //---
   virtual bool      UpdateTarget(string file_name);
   virtual void      SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; }
   virtual void      SetBufferSize(uint min, uint max);
  };

Em artigos anteriores, já criamos herdeiros semelhantes de nossa classe base de gerenciamento para o funcionamento do modelo de rede neural, e o conjunto de métodos da nova classe repete quase integralmente os métodos redefinidos anteriormente. Vamos nos concentrar nas principais mudanças feitas nos métodos redefinidos. E começaremos com o método "Create" para criar o modelo. O procedimento de transferência da descrição da arquitetura do modelo que criamos anteriormente não contempla a criação de modelos aninhados. Para não realizar alterações globais neste subprocesso, simplesmente decidi adicionar a descrição de mais dois modelos nos parâmetros do método "Create". No corpo do método, chamamos sequencialmente o método homônimo de todos os modelos utilizados. Ao mesmo tempo, fornecemos a cada um deles sua própria descrição da arquitetura. E, claro, não se esqueça de monitorar a execução dos métodos chamados.

bool CICM::Create(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse)
  {
   if(!CNet::Create(Description))
      return false;
   if(!cForwardNet.Create(Forward))
      return false;
   if(!cInverseNet.Create(Inverse))
      return false;
   cTargetNet.Create(NULL);
//---
   return true;
  }

É importante observar que, após chamar este método, é necessário especificar o número da camada neural do modelo principal para ler a incorporação do estado. Essa operação é realizada ao chamar o método SetStateEmbeddingLayer.

   virtual void      SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; }

Diferentemente das classes semelhantes criadas anteriormente, nas quais utilizamos a propagação da classe pai, neste caso, precisamos fazer alterações na elaboração do propagação.

Alteramos o tipo de retorno. Se antes o método retornava o valor booleano da execução das operações do método e o método CNet::getResults era utilizado para obter os resultados da propagação, que estava associada ao retorno de um tensor de resultados. Agora, o método de propagação da nova classe retornará o valor discreto da ação selecionada. Ao mesmo tempo, deixamos a critério do usuário escolher entre uma estratégia gulosa ou amostragem de ação a partir de uma distribuição de probabilidade. Um parâmetro adicional, chamado "sample", é responsável por isso.

int CICM::feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true, bool sample = true)
  {
   if(!AddInputData(inputVals))
      return -1;
//---
   if(!CNet::feedForward(inputVals, window, tem))
      return -1;
   double balance = AccountInfoDouble(ACCOUNT_BALANCE);
   double reward = (dPrevBalance == 0 ? 0 : balance - dPrevBalance);
   dPrevBalance = balance;
   int action = (sample ? getSample() : getAction());
   if(!cReplay.AddState(inputVals, action, reward))
      return -1;
//---
   return action;
  }

Para não violar a abordagem geral do funcionamento dos nossos modelos, no tensor de descrição do estado atual, esperamos receber do programa chamador apenas sinais do estado de mercado do instrumento. Porém, nosso novo modelo também exige informações sobre o estado da conta. Adicionaremos essas informações ao tensor obtido no método AddInputData. E somente após adicionar com sucesso as informações necessárias, chamamos o método de propagação da classe pai.

Mas nossas inovações não terminam aqui. A seguir, temos que adicionar novos dados ao buffer de experiência. Para isso, primeiro definimos uma recompensa extrínseca pela transição para o estado atual. Conforme mencionado anteriormente, utilizamos as mudanças de saldo como recompensa externa.

Em seguida, determinamos a próxima ação do agente de acordo com a estratégia escolhida pelo usuário. E enviamos todos esses dados para o buffer de acumulação de experiência. E somente após a execução bem-sucedida de todas as operações descritas acima, retornamos a ação selecionada do agente ao programa chamador.

Observe que, em cada estágio, controlamos o processo de execução das operações. E em caso de erro em qualquer uma das etapas descritas, o método retorna o valor "-1" ao programa chamador. Portanto, ao organizar o espaço de possíveis ações do agente, deve-se levar isso em consideração ou alterar o valor de retorno para que o chamador possa separar claramente o estado de erro da ação do agente.

Na próxima etapa, faremos alterações no método de retropropagação backProp. Devo dizer que esse método foi o que sofreu as mudanças mais radicais. A primeira coisa que se nota é uma mudança completa no conjunto de parâmetros. Aqui, você não verá mais o tensor dos valores-alvo. Nos parâmetros, o novo método recebe apenas o tamanho do pacote de atualização e o fator de desconto.

No corpo do método, primeiramente verificamos o tamanho do buffer de experiência. As demais operações do método só são possíveis se o modelo acumular experiência suficiente para o aprendizado.

Note que, na ausência de experiência, saímos do método com o resultado true. O retorno false só deve ser usado quando ocorrer um erro na execução das operações. Isso permite que o modelo realize as operações. Isso permite que o modelo realize as operações subsequentes em modo normal.

bool CICM::backProp(int batch, float discount = 0.900000f)
  {
//---
   if(cReplay.Total() < (int)iMinBufferSize)
      return true;
   if(!UpdateTarget(TargetNetFile))
      return false;

Além disso, antes de iniciar o processo de treinamento do modelo, atualizamos a Target Net obrigatoriamente. Afinal, usaremos seu encoder para obter a incorporação do estado do ambiente após a transição.

Em seguida, realizamos um pequeno trabalho preparatório e declaramos várias variáveis e objetos internos que executarão funções de armazenamento intermediário de dados.

   CLayer *currentLayer, *nextLayer, *prevLayer;
   CNeuronBaseOCL *neuron;
   CBufferFloat *state1, *state2, *targetVals = new CBufferFloat();
   vector<float> target, actions, st1, st2, result;
   double reward;
   int action;

Após a realização dos trabalhos preparatórios, organizamos um ciclo de treinamento dos modelos. O número de iterações do loop é igual ao tamanho do lote de atualização do modelo especificado nos parâmetros.

No corpo do loop, primeiro extraímos aleatoriamente um conjunto de dados do buffer de experiência, consistindo em 2 estados consecutivos do sistema, a ação selecionada e a recompensa recebida. Depois disso, realizamos a propagação do modelo em treinamento.

//--- training loop in the batch size
   for(int i = 0; i < batch; i++)
     {
      //--- get a random state and the buffer replay
      if(!cReplay.GetRendomState(state1, action, reward, state2))
         return false;
      //--- feed forward pass of the training model ("current" state)
      if(!CNet::feedForward(state1, 1, false))
         return false;

Após a execução bem-sucedida da propagação do modelo principal, faremos o trabalho preparatório para executar a passagem direta do Forward Model. Aqui, extraímos a incorporação do estado atual do sistema e criamos um vetor one-hot da ação realizada.

      //--- unload state embedding
      if(!GetLayerOutput(iStateEmbedingLayer, state1))
         return false;
      //--- prepare a one-hot action vector and concatenate with the current state vector
      getResults(target);
      actions = vector<float>::Zeros(target.Size());
      actions[action] = 1;
      if(!targetVals.AssignArray(actions) || !targetVals.AddArray(state1))
         return false;

Em seguida, executamos propagação do Forward Model com a previsão da incorporação do estado subsequente.

      //--- forward net feed forward pass - next state prediction
      if(!cForwardNet.feedForward(targetVals, 1, false))
         return false;

Depois, realizamos a passagem direta da Target Net e extraímos a incorporação do estado subsequente.

      //--- feed forward
      if(!cTargetNet.feedForward(state2, 1, false))
         return false;
      //--- unload the state embedding and concatenate with the "current" state embedding
      if(!cTargetNet.GetLayerOutput(iStateEmbedingLayer, state2))
         return false;

Combinamos as 2 incorporações resultantes de estados sucessivos em um único tensor e chamamos o método de propagação Inverse Model.

      //--- inverse net feed forward - defining the performed action.
      if(!state1.AddArray(state2) || !cInverseNet.feedForward(state1, 1, false))
         return false;

E então realizamos as retropropagações Forward Model e do Inverse Model. Já preparamos os valores-alvo para eles anteriormente na forma de incorporação do próximo estado e do vetor one-hot da ação realizada.

      //--- inverse net backpropagation
      if(!targetVals.AssignArray(actions) || !cInverseNet.backProp(targetVals))
         return false;
      //--- forward net backpropagation
      if(!cForwardNet.backProp(state2))
         return false;

Em seguida, voltamos a trabalhar com o modelo principal. Aqui, ajustamos a recompensa, adicionando a recompensa interna de curiosidade e a recompensa futura esperada, que previmos usando a Target Net.

      //--- reward adjustment
      cForwardNet.getResults(st1);
      state2.GetData(st2);
      reward += (MathPow(st2 - st1, 2)).Sum();
      cTargetNet.getResults(targetVals);
      target[action] = (float)(reward + discount * targetVals.Maximum());
      if(!targetVals.AssignArray(target))
         return false;

Após preparar a recompensa-alvo, podemos realizar a passagem reversa no modelo DQN principal. No entanto, há uma ressalva. Além de distribuir o gradiente de erro da recompensa prevista, também precisamos adicionar o gradiente de erro do modelo inverso ao bloco de incorporação de estado. E para isso, temos que copiar os dados do gradiente de erro da camada de dados de origem do modelo inverso para o buffer de gradiente de erro da camada de incorporação do modelo principal antes de executar a passagem reversa do modelo principal. Afinal, todo o algoritmo é construído de tal maneira que, a cada retropropagação, simplesmente sobrescrevemos os dados que estavam nos buffers. Isso significa que precisamos nos inserir no processo de transferência dos gradientes de erro. E para isso, temos que reescrever completamente o código da retropropagação do modelo principal.

Aqui, primeiro determinamos o erro de previsão da recompensa pelo modelo e chamamos o método calcOutputGradients da última camada neural, no qual é determinado o gradiente de erro na saída do nosso modelo.

      //--- backpropagation pass of the model being trained
        {
         getResults(result);
         float error = result.Loss(target, LOSS_MSE);
         //---
         currentLayer = layers.At(layers.Total() - 1);
         if(CheckPointer(currentLayer) == POINTER_INVALID)
            return false;
         neuron = currentLayer.At(0);
         if(!neuron.calcOutputGradients(targetVals, error))
            return false;
         //---
         backPropCount++;
         recentAverageError += (error - recentAverageError) / fmin(recentAverageSmoothingFactor, (float)backPropCount);

Aqui, calcularemos o erro médio de previsão do modelo.

O próximo passo é distribuir o gradiente de erro por todas as camadas neurais do modelo em treinamento. Para isso, criaremos um loop com iteração reversa por todas as camadas neurais do modelo e chamadas sequenciais do método calcHiddenGradients para todas as camadas neurais. Como você deve se lembrar, esse método é responsável por distribuir o gradiente de erro através da camada neural.

         //--- Calc Hidden Gradients
         int total = layers.Total();
         for(int layerNum = total - 2; layerNum >= 0; layerNum--)
           {
            nextLayer = currentLayer;
            currentLayer = layers.At(layerNum);
            neuron = currentLayer.At(0);
            if(!neuron.calcHiddenGradients(nextLayer.At(0)))
               return false;

Até esta etapa, no subprocesso de treinamento do modelo principal, replicamos completamente o algoritmo de um método semelhante da classe pai. No entanto, é neste momento que precisamos fazer uma pequena modificação no algoritmo.

Incluímos uma condição para verificar se a camada neural analisada corresponde à saída do codificador do estado do sistema. Caso passe nesta verificação, adicionaremos os valores do gradiente de erro provenientes do modelo inverso ao gradiente de erro obtido da camada neural subsequente.

Para somar 2 tensores, utilizei o kernel MatrixSum, criado anteriormente. Uma descrição detalhada do seu algoritmo pode ser encontrada no artigo "Redes neurais de maneira fácil (Parte 8): Mecanismos de atenção".

            if(layerNum == iStateEmbedingLayer)
              {
               CLayer* temp = cInverseNet.layers.At(0);
               CNeuronBaseOCL* inv = temp.At(0);
               uint global_work_offset[1] = {0};
               uint global_work_size[1];
               global_work_size[0] = neuron.Neurons();
               opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix1, neuron.getGradientIndex());
               opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix2, inv.getGradientIndex());
               opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix_out, neuron.getGradientIndex());
               opencl.SetArgument(def_k_MatrixSum, def_k_sum_dimension, 1);
               opencl.SetArgument(def_k_MatrixSum, def_k_sum_multiplyer, 1);
               if(!opencl.Execute(def_k_MatrixSum, 1, global_work_offset, global_work_size))
                 {
                  printf("Error of execution kernel MatrixSum: %d", GetLastError());
                  return false;
                 }
              }
           }

Para executar corretamente esta ação, é necessário prestar atenção em dois aspectos.

Primeiramente, o método de retropropagação do modelo inverso deve transmitir o gradiente de erro para a camada de dados de origem. Para isso, a condição "layerNum >= 0" deve ser utilizada no loop de distribuição do gradiente através das camadas ocultas.

         //--- Calc Hidden Gradients
         int total = layers.Total();
         for(int layerNum = total - 2; layerNum >= 0; layerNum--)
           {

A segunda nuance é que, ao declarar a arquitetura do modelo inverso para a camada de resultados, especificamos um método de ativação semelhante ao método de ativação da camada responsável pela incorporação de estado. Essa ação não tem impacto na propagação direta, mas ajusta o gradiente de erro com base na derivada da função de ativação durante a retropropagação.

As etapas subsequentes são similares ao algoritmo de retropropagação da classe base. Após distribuir o gradiente de erro, atualizamos as matrizes de peso de todas as camadas neurais do modelo principal.

         //---
         prevLayer = layers.At(total - 1);
         for(int layerNum = total - 1; layerNum > 0; layerNum--)
           {
            currentLayer = prevLayer;
            prevLayer = layers.At(layerNum - 1);
            neuron = currentLayer.At(0);
            if(!neuron.UpdateInputWeights(prevLayer.At(0)))
               return false;
           }
         //---
         for(int layerNum = 0; layerNum < total; layerNum++)
           {
            currentLayer = layers.At(layerNum);
            CNeuronBaseOCL *temp = currentLayer.At(0);
            if(!temp.TrainMode())
               continue;
            if((layerNum + 1) == total && !temp.getGradient().BufferRead())
               return false;
            break;
           }
        }
     }

Note que estamos atualizando apenas as matrizes de peso do modelo principal que pode ser treinado. Os parâmetros do Forward Model e do Inverse Model são atualizados quando os métodos de retropropagação dos respectivos modelos são executados.

Por fim, removemos os objetos auxiliares criados dentro do método e encerramos seu funcionamento com um resultado positivo.

   delete state1;
   delete state2;
   delete targetVals;
//---
   return true;
  }

Dedicarei algumas palavras aos métodos de trabalho com arquivos. Como utilizamos diversos modelos neste algoritmo, somos confrontados com uma questão pertinente sobre como salvar os modelos treinados. E aqui vejo duas opções. Podemos salvar todos os modelos em um único arquivo ou salvar cada modelo em um arquivo separado. Sugiro salvar os modelos em arquivos separados, pois isso proporciona maior liberdade de ação. Dessa forma, podemos carregar o modelo DQN treinado em um arquivo separado e utilizá-lo juntamente com os modelos analisados anteriormente. Ou podemos carregar todos os três modelos e utilizar o método descrito neste artigo. A única inconveniência é a necessidade de especificar a camada de incorporação de estado no modelo principal todas as vezes. No entanto, podemos experimentar com a arquitetura de cada modelo individual durante o processo de aprendizado para alcançar os melhores resultados.

Não vou me deter agora na descrição dos algoritmos para trabalhar com arquivos. O código de todos os programas e classes utilizados, bem como seus métodos, pode ser encontrado no anexo.


3. Teste

Acima, criamos uma classe para fazer o trabalho do modelo aprendizado Q utilizando o método de curiosidade intrínseca. Agora, estamos criando um Expert Advisor para treinar e testar o desempenho do modelo. Conforme mencionado anteriormente, o novo modelo será treinado no testador de estratégias, o que difere fundamentalmente dos métodos usados antes. Portanto, o Expert Advisor para treinamento do modelo passou por mudanças significativas.

Para testar o funcionamento do modelo, foi criado um Expert Advisor chamado "ICM-learning.mq5". Para descrever a situação do mercado, utilizamos os mesmos indicadores com parâmetros similares. Assim, os parâmetros externos do consultor permaneceram praticamente inalterados. O mesmo se aplica à declaração de variáveis globais e classes.

O método de inicialização do Expert Advisor praticamente repetiu os métodos similares dos Expert Advisors analisados anteriormente. A diferença está na ausência de geração de um evento para iniciar o processo de aprendizado. Isso se deve à remoção completa da função de treinamento do modelo Train, que estava presente em todos os Expert Advisors anteriores.

Todo o processo de treinamento do modelo foi transferido para o método OnTick. Como nosso modelo é treinado para analisar o mercado com base em velas fechadas, iniciaremos o processo de aprendizado apenas na abertura de uma nova vela. Para fazer isso, no corpo do método OnTick, primeiro verificamos a ocorrência de um evento de abertura de uma nova vela. E somente após um resultado positivo, realizamos as ações subsequentes.

void OnTick()
  {
   if(!IsNewBar())
      return;

Em seguida, carregamos dados históricos com o tamanho da janela analisada.

   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();

Realizamos uma descrição da situação atual do mercado. Esse processo é desenvolvido conforme o algoritmo de um procedimento semelhante aos Expert Advisors analisados anteriormente.

   State1.Clear();
   for(int b = 0; b < (int)HistoryBars; b++)
     {
      float open = (float)Rates[b].open;
      TimeToStruct(Rates[b].time, sTime);
      float rsi = (float)RSI.Main(b);
      float cci = (float)CCI.Main(b);
      float atr = (float)ATR.Main(b);
      float macd = (float)MACD.Main(b);
      float sign = (float)MACD.Signal(b);
      if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
         continue;
      //---
      if(!State1.Add((float)Rates[b].close - open) || !State1.Add((float)Rates[b].high - open) ||
         !State1.Add((float)Rates[b].low - open) || !State1.Add((float)Rates[b].tick_volume / 1000.0f) ||
         !State1.Add(sTime.hour) || !State1.Add(sTime.day_of_week) || !State1.Add(sTime.mon) ||
         !State1.Add(rsi) || !State1.Add(cci) || !State1.Add(atr) || !State1.Add(macd) || !State1.Add(sign))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
     }

Após carregar com sucesso os dados históricos e gerar uma descrição da situação do mercado, invocamos o método de propagação do nosso modelo e verificamos o resultado.

Lembre-se de que na nova implementação da propagação do modelo, o método feedForward retorna a ação do agente. E, com base no resultado obtido, realizamos uma operação de negociação.

   switch(StudyNet.feedForward(GetPointer(State1), 12, true, true))
     {
      case 0:
         Trade.Buy(Symb.LotsMin(), Symb.Name());
         break;
      case 1:
         Trade.Sell(Symb.LotsMin(), Symb.Name());
         break;
      case 2:
         for(int i=PositionsTotal()-1;i>=0;i--)
            if(PositionGetSymbol(i)==Symb.Name())
              Trade.PositionClose(PositionGetInteger(POSITION_IDENTIFIER));
         break;
     }

É importante destacar que, ao construir o modelo, mencionamos 4 ações do agente. No entanto, aqui vemos a análise de apenas três ações e a execução da operação de negociação correspondente. O fato é que a quarta ação prevê a espera por uma situação de mercado mais favorável e a ausência de operações de negociação. Por isso, simplesmente não tratamos dessa ação.

Ao final do método, chamamos o método de retropropagação do modelo.

   StudyNet.backProp(Batch, DiscountFactor);
//---
  }

Provavelmente, você percebeu que durante o processo de treinamento nunca salvamos o modelo treinado. O processo de salvar o modelo treinado foi transferido para o método de desinicialização do Expert Advisor.

void OnDeinit(const int reason)
  {
//---
   StudyNet.Save(FileName + ".nnw", FileName + ".fwd", FileName + ".inv", true);
  }

Para treinar o modelo no modo de otimização do Expert Advisor, reproduzi um procedimento de salvamento semelhante após a conclusão de cada execução do otimizador.

void OnTesterPass()
  {
   StudyNet.Save(FileName + ".nnw", FileName + ".fwd", FileName + ".inv", true);
  }

É importante mencionar que o processo de otimização deve ser realizado apenas em um núcleo ativo. Caso contrário, as threads paralelas apagarão os dados de outros agentes, anulando completamente o uso de múltiplos agentes.

Para treinar o Expert Advisor, todos os modelos foram criados usando a ferramenta NetCreator.. Vale ressaltar que, para o Expert Advisor funcionar no testador de estratégias, os arquivos do modelo devem estar localizados no diretório comum do terminal "Terminal\Common\Files", já que cada agente trabalha em seu próprio ambiente isolado (sandbox), e a troca de dados só é possível através da pasta comum dos terminais.

O treinamento no testador de estratégias leva um pouco mais de tempo em comparação com a abordagem de treinamento virtual utilizada anteriormente. Por esse motivo, reduzi o período de treinamento do modelo para 10 meses. Os demais parâmetros de teste permaneceram inalterados. Como de costume, utilizei EURUSD no período H1. Os parâmetros do indicador foram usados por padrão.

Para ser sincero, eu esperava que o processo de aprendizado começasse com a perda do depósito. No entanto, durante a primeira corrida, o modelo apresentou um resultado próximo a "0". E na segunda corrida, houve lucro. O modelo realizou 330 negociações com eficácia superior a 98% nas operações lucrativas.

Resultado do teste de modelo Resultado do teste de modelo


Considerações finais

Neste artigo, exploramos o funcionamento do módulo de curiosidade interna. Essa tecnologia permite treinar com sucesso modelos usando métodos de aprendizado por reforço em situações com poucas recompensas externas, como ocorre no mercado de negociação. A tecnologia de curiosidade interna possibilita que o modelo explore ao máximo o ambiente e encontre os melhores caminhos para alcançar seus objetivos.  Isso funciona mesmo quando o ambiente oferece uma recompensa para várias ações consecutivas.

Na parte prática deste artigo, implementamos a tecnologia apresentada utilizando ferramentas MQL5. Os experimentos realizados permitem concluir sobre a possível eficácia dessa abordagem na negociação.

Gostaria de salientar que o Expert Advisor apresentado no artigo é capaz de realizar operações de negociação. No entanto, não está pronto para ser usado em negociações reais. O Expert Advisor é apresentado apenas para fins de demonstração da tecnologia. Antes de utilizar o Expert Advisor em contas reais, é necessário um aprimoramento significativo e testes rigorosos em diversas condições.


Referências

  1. Redes neurais de maneira fácil (Parte 26): aprendizado por reforço
  2. Redes neurais de maneira fácil (Parte 27): aprendizado Q profundo (DQN)
  3. Redes neurais de maneira fácil (Parte 28): algoritmo de gradiente de política
  4. Redes neurais de maneira fácil (Parte 32): aprendizado Q distribuído
  5. Redes neurais de maneira fácil (Parte 33): regressão quantílica em aprendizado Q distribuído
  6. Redes neurais de maneira fácil (Parte 34): Função quantil totalmente parametrizada
  7. Curiosity-driven Exploration by Self-supervised Prediction

Programas utilizados no artigo

# Nome Tipo Descrição
1 ICM-learning.mq5 EA EA para treinamento do modelo. 
2 ICM.mqh Biblioteca de classe Biblioteca da classe de elaboração de modelo
3 NeuroNet.mqh Biblioteca de classe Biblioteca das classes para a criação de uma rede neural
4 NeuroNet.cl Biblioteca Biblioteca do código do programa OpenCL