English Русский 中文 Español Deutsch 日本語
preview
Redes neurais de maneira fácil (Parte 38): Exploração auto-supervisionada via desacordo (Self-Supervised Exploration via Disagreement)

Redes neurais de maneira fácil (Parte 38): Exploração auto-supervisionada via desacordo (Self-Supervised Exploration via Disagreement)

MetaTrader 5Experts | 1 agosto 2023, 15:43
331 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introdução

O problema da exploração é um grande obstáculo no aprendizado por reforço, principalmente nos casos em que o agente recebe recompensas pouco frequentes e atrasadas, dificultando o desenvolvimento de uma estratégia eficaz. Uma possível solução para esse problema é gerar recompensas "internas" com base no modelo do ambiente. Encontramos um algoritmo com caraterísticas semelhantes ao estudar o módulo de curiosidade interna. No entanto, a maioria dos algoritmos criados só foi estudada no contexto de jogos de computador e, fora de ambientes simulados silenciosos, o treinamento de modelos preditivos é desafiador devido à natureza estocástica das interações agente-ambiente. Uma abordagem para resolver esse problema de estocasticidade é empregar o algoritmo proposto por Deepak Pathak no artigo "Self-Supervised Exploration via Disagreement".

Este algoritmo é baseado em um método de autoaprendizado, onde o agente utiliza as informações obtidas no processo de interação com o ambiente para gerar recompensas “internas” e atualizar sua estratégia. O algoritmo utiliza vários modelos de agentes que interagem com o ambiente e produzem previsões diferentes. Quando os modelos diferem um do outro, isso é considerado um evento "interessante" e o agente é incentivado a explorar essa área do ambiente. Assim, o algoritmo estimula o agente a explorar novas áreas do ambiente e permite que ele faça previsões mais precisas sobre as recompensas futuras.


1. Algoritmo de exploração por desacordo

O algoritmo de exploração baseado em discordância é um dos métodos de aprendizado por reforço que permite que um agente explore o ambiente para encontrar áreas novas e inexploradas sem depender de recompensas externas, usando um conjunto de modelos.

No artigo "Self-Supervised Exploration via Disagreement", os autores descrevem essa abordagem e propõem um método simples que envolve o treinamento de um conjunto de modelos de dinâmica progressiva e o incentivo ao agente para explorar o espaço de ação onde há o máximo de discrepância ou variação entre as previsões dos modelos do conjunto.

Assim, em vez de escolher ações que geram a maior recompensa esperada, o agente escolhe ações que maximizam a discrepância entre os modelos do conjunto. Isso permite que o agente explore regiões do espaço de estado em que os modelos do conjunto não concordam entre si e onde é provável que existam regiões novas e inexploradas do ambiente.

Nesse caso, todos os modelos do conjunto convergem para um valor médio, reduzindo, em última análise, a variação do conjunto e proporcionando ao agente previsões mais precisas sobre os estados do ambiente e as possíveis consequências das ações.

Além disso, o algoritmo de exploração via discordância permite que o agente lide com sucesso com a estocasticidade da interação com o ambiente. Os resultados experimentais obtidos pelos autores do artigo mostraram que a abordagem proposta de fato melhora a exploração em ambientes estocásticos e supera os métodos existentes de motivação intrínseca e modelagem de incerteza. Além disso, eles observaram que sua abordagem pode ser estendida ao aprendizado supervisionado, em que o valor de uma amostra é determinado com base no estado de um conjunto de modelos em vez de na etiqueta verdadeira.

Assim, o algoritmo de exploração via desacordo representa uma abordagem promissora para o problema da exploração em ambientes estocásticos. Ele permite que o agente explore o ambiente com mais eficiência e sem depender de recompensas externas, o que pode ser particularmente útil em aplicativos do mundo real em que as recompensas externas podem ser limitadas ou caras.

Além disso, esse algoritmo pode ser aplicado em uma variedade de contextos, incluindo o tratamento de dados de alta dimensão, como imagens, em que a medição e a maximização da incerteza do modelo podem ser particularmente desafiadoras.

Os autores do artigo demonstraram a eficácia do algoritmo proposto em várias tarefas, incluindo controle de robôs, jogos de Atari e tarefas de navegação em labirintos. Como resultado de sua pesquisa, eles mostraram que o algoritmo para exploração via desacordo supera significativamente outros métodos de exploração em termos de velocidade, convergência e qualidade de aprendizado.

Assim, essa abordagem de exploração via desacordo representa uma etapa importante no campo do aprendizado por reforço, que pode ajudar os agentes a explorar o ambiente de forma melhor e mais eficiente e a obter melhores resultados em várias tarefas.

Analisemos o algoritmo proposto.

Ao interagir com o ambiente, um agente avalia o estado atual Xt e, guiado por sua política interna, executa alguma ação At. Como resultado, o estado do ambiente é alterado para um novo estado Xt+1. Um conjunto desses dados é armazenado em um buffer de repetição de experiência, que usamos para treinar um conjunto de modelos dinâmicos que preveem o estado futuro do ambiente.

Para preservar a independência da estimativa do estado futuro do ambiente, no estágio inicial, todas as matrizes de peso dos modelos dinâmicos do conjunto são preenchidas com valores aleatórios. No processo de treinamento, cada modelo recebe seu próprio conjunto aleatório de dados de treinamento do buffer de reprodução de experiência.

Cada modelo em nosso conjunto é treinado para prever o próximo estado do ambiente atual. Partes do espaço de estado que foram bem exploradas pelo agente geraram dados suficientes para treinar todos os modelos, o que garante a concordância entre os modelos. À medida que os modelos são treinados, essa característica deve ser ampliada para partes desconhecidas, mas semelhantes, do espaço de estados. No entanto, as áreas novas e inexploradas ainda terão um alto erro de previsão para todos os modelos porque nenhum deles foi treinado com esses exemplos ainda, resultando em uma divergência na previsão do próximo estado. Desse modo, usamos essa divergência como uma recompensa intrínseca para a direção da política. Especificamente, a recompensa intrínseca Ri é definida como a variação na saída dos diferentes modelos do conjunto.

Observe que a fórmula acima não apresenta nenhuma dependência da recompensa intrínseca em relação ao estado futuro do sistema. Aproveitaremos essa propriedade posteriormente na implementação desse método.

No caso do cenário estocástico, com um número suficiente de amostras, o modelo de previsão dinâmica deve aprender a prever a média das amostras estocásticas. Dessa forma, a variação dos resultados no conjunto será reduzida, evitando que o agente fique preso em mínimos locais estocásticos da exploração. Observe que isso é diferente das metas baseadas no erro de previsão, que será definido como a média após um número suficiente de amostras. E a média é diferente dos estados aleatórios verdadeiros individuais, o erro de previsão permanece alto, fazendo com que o agente fique sempre interessado no comportamento estocástico.

Ao usar o algoritmo proposto, cada etapa da interação do agente com o ambiente apresenta informações não apenas sobre a recompensa recebida do ambiente, mas também sobre as informações necessárias para atualizar o modelo interno do agente de como o estado do ambiente muda ao realizar ações. Isso permite que o agente extraia informações valiosas sobre o ambiente, mesmo que não receba uma recompensa externa explícita.

Vista do modelo do artigo original

A recompensa interna Ri, que é calculada como a variação dos resultados dos diferentes modelos no conjunto, é usada para treinar a política do agente. Quanto maior for a variação entre as saídas dos modelos, maior será o valor da recompensa intrínseca. Isso permite que o agente explore novas regiões do espaço de estado em que a previsão do próximo estado é incerta e aprenda a tomar decisões melhores com base nesses dados.

O agente é treinado on-line com base nos dados que coleta de suas próprias interações com o ambiente. Ao mesmo tempo, o conjunto de modelos é atualizado após cada interação entre o agente e o ambiente, permitindo que o agente atualize seu modelo interno sobre o ambiente em cada etapa e obtenha previsões mais precisas dos estados do ambiente no futuro.

2. Implementação usando MQL5

Em nossa implementação, não repetiremos totalmente o algoritmo proposto, limitando-nos a pegar suas ideias principais e aplicá-las às nossas tarefas.

A primeira coisa que faremos é pedir ao conjunto de modelos dinâmicos que preveja o estado comprimido (oculto) do sistema, por analogia com o módulo de curiosidade interna. Isso nos permitirá "comprimir" o tamanho dos modelos dinâmicos e do conjunto como um todo.

O segundo aspecto é o fato de que não precisamos conhecer o estado real do sistema para determinar a recompensa intrínseca, mas os valores previstos pelos modelos dinâmicos do conjunto são suficientes. Isso nos permite usar a recompensa prevista não apenas para incentivar o aprendizado posterior, mas também para decidir se devemos realizar uma ação em tempo real. Não falsearemos a recompensa extrínseca introduzindo um componente intrínseco ao treinar a política do agente, e sim permitiremos que o agente crie imediatamente uma política para maximizar a recompensa extrínseca - esse é o nosso principal objetivo.

Entretanto, para maximizar o aprendizado do ambiente durante o próprio processo de aprendizado, adicionaremos a variação da divergência das previsões dos modelos dinâmicos para cada ação possível do agente à recompensa prevista quando o agente escolher uma ação.

Isso nos leva a outro ponto: para calcular os estados previstos após cada ação em paralelo, pediremos aos nossos modelos dinâmicos que nos forneçam previsões para cada ação possível do agente com base no estado atual, aumentando o tamanho da camada de resultados de cada modelo de acordo com o número de ações possíveis.

Depois de definirmos as principais áreas de nosso trabalho, podemos passar para a implementação do algoritmo. A questão da implementação de um conjunto de modelos dinâmicos surge imediatamente. Todos os nossos modelos criados anteriormente são lineares. A disposição dos cálculos paralelos é realizada por meio do OpenCL em um subprocesso e uma camada neural. Por enquanto, não é possível realizar cálculos paralelos de vários modelos. A criação de uma sequência de cálculos de vários modelos acarreta um aumento significativo do tempo de treinamento do modelo.

Para resolver esse problema, decidi usar o método de processamento paralelo ao preparar a atenção de várias cabeças. Então, combinamos os dados de todas as cabeças de atenção em tensores únicos, e fizemos a divisão no nível do espaço da tarefa no OpenCL.

Não vamos redesenhar toda a nossa biblioteca para resolver essas tarefas agora. Neste estágio, não nos importamos com a precisão específica dos valores previstos do estado futuro do sistema. É suficiente apenas obter a sincronização relativa do conjunto de modelos. Portanto, usaremos camadas totalmente conectadas em modelos de previsão dinâmica.

Primeiro, criaremos kernels OpenCL para realizar essa funcionalidade. O kernel de propagação FeedForwardMultiModels repete quase completamente o kernel semelhante que corresponde à camada totalmente conectada básica. Mas há algumas pequenas diferenças.

Os parâmetros do kernel permanecem inalterados. Aqui podemos encontrar três buffers de dados (tensores da matriz de pesos, dados iniciais e resultados), bem como duas constantes: o tamanho da camada de dados iniciais e a função de ativação. Mas, embora tenhamos especificado anteriormente o tamanho total da camada anterior como o tamanho da camada de dados iniciais, agora esperamos obter o número de elementos do modelo atual.

__kernel void FeedForwardMultiModels(__global float *matrix_w,
                                     __global float *matrix_i,
                                     __global float *matrix_o,
                                     int inputs,
                                     int activation
                                    )
  {
   int i = get_global_id(0);
   int outputs = get_global_size(0);
   int m = get_global_id(1);
   int models = get_global_size(1);

No corpo do kernel, primeiro identificamos o fluxo atual. E aqui podemos notar o surgimento da segunda dimensionalidade do espaço da tarefa, que identifica o modelo atual. A dimensionalidade geral das tarefas indicará o tamanho do conjunto.

Em seguida, declaramos as variáveis locais necessárias e identificamos o deslocamento nos buffers de dados, considerando o neurônio que está sendo processado e o modelo atual no conjunto.

   float sum = 0;
   float4 inp, weight;
   int shift = (inputs + 1) * (i + outputs * m);
   int shift_in = inputs * m;
   int shift_out = outputs * m;

A parte matemática imediata do cálculo do estado do neurônio e da função de ativação permanece inalterada. Apenas adicionamos correções para o viés nos buffers de dados.

   for(int k = 0; k <= inputs; k = k + 4)
     {
      switch(inputs - k)
        {
         case 0:
            inp = (float4)(1, 0, 0, 0);
            weight = (float4)(matrix_w[shift + k], 0, 0, 0);
            break;
         case 1:
            inp = (float4)(matrix_i[shift_in + k], 1, 0, 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], 0, 0);
            break;
         case 2:
            inp = (float4)(matrix_i[shift_in + k], matrix_i[shift_in + k + 1], 1, 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], 0);
            break;
         case 3:
            inp = (float4)(matrix_i[shift_in + k], matrix_i[shift_in + k + 1], matrix_i[shift_in + k + 2], 1);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]);
            break;
         default:
            inp = (float4)(matrix_i[shift_in + k], matrix_i[shift_in + k + 1], matrix_i[shift_in + k + 2],
                                                                                                  matrix_i[shift_in + 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;
     }

Depois de calcular o valor da função de ativação especificada nos parâmetros, salvamos o resultado obtido no buffer de dados matrix_o.

   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[shift_out + i] = sum;
  }

Essa solução nos permite calcular o valor de uma camada de todos os modelos do conjunto paralelamente em um kernel. Obviamente, há uma limitação aqui, e é que a arquitetura de todos os modelos do conjunto é idêntica, as diferenças estão apenas nos coeficientes de peso.

A situação com a retropropagação é um pouco diferente. O algoritmo possibilita o treinamento de modelos dinâmicos no conjunto em um conjunto diferente de dados de treinamento. Não geraremos pacotes de treinamento separadamente para cada modelo. Em vez disso, treinaremos apenas um modelo selecionado aleatoriamente do conjunto em cada retropropagação. Para os outros modelos, passaremos o gradiente zero para a camada anterior. Essas são as alterações que faremos no algoritmo do kernel de distribuição de gradiente dentro da camada CalcHiddenGradientMultiModels.

O kernel correspondente da camada neural totalmente conectada básica recebeu ponteiros para quatro buffers de dados e duas variáveis em seus parâmetros. Esses são o tensor da matriz de pesos, o tensor dos resultados da camada anterior para calcular a derivada da função de ativação. Há também dois buffers de gradiente: as camadas neurais atual e anterior. O primeiro contém os gradientes de erro recebidos, e o segundo é usado para registrar os resultados do kernel e transferir o gradiente de erro para a camada neural anterior. As variáveis especificam o número de neurônios na camada atual e a função de ativação da camada anterior. A esses parâmetros, adicionamos o identificador do modelo treinado, que será selecionado aleatoriamente no programa principal.

__kernel void CalcHiddenGradientMultiModels(__global float *matrix_w,
                                            __global float *matrix_g,
                                            __global float *matrix_o,
                                            __global float *matrix_ig,
                                            int outputs,
                                            int activation,
                                            int model
                                           )
  {
   

No corpo do kernel, primeiro identificamos o fluxo. Como no kernel de propagação, usamos um espaço de tarefa bidimensional. Na primeira dimensão, identificamos o fluxo relativamente a um único modelo, enquanto a segunda dimensão aponta para o modelo no conjunto. Para coletar gradientes de erro, executamos o kernel na seção de neurônios da camada anterior. Cada thread coleta gradientes de erro de todas as direções em um neurônio individual.

   int i = get_global_id(0);
   int inputs = get_global_size(0);
   int m = get_global_id(1);
   int models = get_global_size(1);

Observe que distribuiremos o gradiente em apenas um modelo e executaremos threads para todo o conjunto. Isso se deve à necessidade de zerar o gradiente de erro de outros modelos. Na próxima etapa, verificamos se precisamos atualizar o gradiente para um modelo específico. E se precisarmos apenas zerar o gradiente, executamos somente essa função e saímos do kernel sem realizar nenhuma operação desnecessária.

//---
   int shift_in = inputs * m;
   if(model >= 0 && model != m)
     {
      matrix_ig[shift_in + i] = 0;
      return;
     }

Aqui deixamos uma pequena margem para possível uso futuro. E se especificarmos um número negativo como o número do modelo a ser atualizado, o gradiente será calculado para todos os modelos do conjunto.

Em seguida, declaramos as variáveis locais e definimos o deslocamento nos buffers de dados.

//---
   int shift_out = outputs * m;
   int shift_w = (inputs + 1) * outputs * m;
   float sum = 0;
   float out = matrix_o[shift_in + i];
   float4 grad, weight;

Em seguida, vem a parte matemática da distribuição do gradiente de erro, que replica totalmente a funcionalidade análoga do neurônio subjacente totalmente conectado. Obviamente, adicionamos o viés necessário nos buffers de dados. Salvamos o resultado das operações no buffer de gradiente da camada anterior.

   for(int k = 0; k < outputs; k += 4)
     {
      switch(outputs - k)
        {
         case 1:
            weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], 0, 0, 0);
            grad = (float4)(matrix_g[shift_out + k], 0, 0, 0);
            break;
         case 2:
            grad = (float4)(matrix_g[shift_out + k], matrix_g[shift_out + k + 1], 0, 0);
            weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], matrix_w[shift_w + (k + 1) * (inputs + 1) + i], 0, 0);
            break;
         case 3:
            grad = (float4)(matrix_g[shift_out + k], matrix_g[shift_out + k + 1], matrix_g[shift_out + k + 2], 0);
            weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], matrix_w[shift_w + (k + 1) * (inputs + 1) + i],
                                                                           matrix_w[shift_w + (k + 2) * (inputs + 1) + i], 0);
            break;
         default:
            grad = (float4)(matrix_g[shift_out + k], matrix_g[shift_out + k + 1], matrix_g[shift_out + k + 2], 
                                                                                                 matrix_g[shift_out + k + 3]);
            weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], matrix_w[shift_w + (k + 1) * (inputs + 1) + i], 
                              matrix_w[shift_w + (k + 2) * (inputs + 1) + i], matrix_w[shift_w + (k + 3) * (inputs + 1) + i]);
            break;
        }
      sum += dot(grad, weight);
     }
   if(isnan(sum))
      sum = 0;
   switch(activation)
     {
      case 0:
         out = clamp(out, -1.0f, 1.0f);
         sum = clamp(sum + out, -1.0f, 1.0f) - out;
         sum = sum * max(1 - pow(out, 2), 1.0e-4f);
         break;
      case 1:
         out = clamp(out, 0.0f, 1.0f);
         sum = clamp(sum + out, 0.0f, 1.0f) - out;
         sum = sum * max(out * (1 - out), 1.0e-4f);
         break;
      case 2:
         if(out < 0)
            sum *= 0.01f;
         break;
      default:
         break;
     }
   matrix_ig[shift_in + i] = sum;
  }

Em seguida, temos de modificar o kernel de atualização da matriz de peso UpdateWeightsAdamMultiModels. Assim como no kernel de distribuição de gradiente de erro, adicionaremos um identificador de modelo aos parâmetros já existentes do kernel da camada base totalmente conectada.

É importante dizer que o kernel correspondente da camada neural básica já é executado em um espaço de tarefa bidimensional. Mas, ao ser assim, não precisamos executar nenhuma operação em modelos não atualizados. Por isso, chamaremos o kernel somente para um modelo e usaremos o parâmetro do identificador do modelo para determinar o deslocamento nos buffers de dados. Caso contrário, o algoritmo do kernel permanecerá inalterado. Você pode obter informações sobre ele no anexo.

Com isso, terminamos o trabalho no lado OpenCL do programa e passamos para o código da nossa biblioteca MQL5. Aqui criaremos uma nova classe CNeuronMultiModel como herdeira da nossa classe base CNeuronBaseOCL.

O conjunto de métodos da classe é bastante padrão e inclui métodos de inicialização de classe, trabalho com arquivos, propagação e retropropagação. Também incluímos duas novas variáveis nas quais registraremos o número de modelos no conjunto e o identificador do modelo a ser treinado. Esse último será alterado a cada retropropagação.

class CNeuronMultiModel : public CNeuronBaseOCL
  {
protected:
   int               iModels;
   int               iUpdateModel;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL); 
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);

public:
                     CNeuronMultiModel(void){};
                    ~CNeuronMultiModel(void){};
   virtual bool      Init(uint numInputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                                            ENUM_OPTIMIZATION optimization_type, int models);
   virtual void      SetActivationFunction(ENUM_ACTIVATION value) {  activation = value;         }    
   //---
   virtual bool      calcHiddenGradients(CNeuronBaseOCL *NeuronOCL);   
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual int       Type(void)        const                      {  return defNeuronMultiModels; }
  };

Como não criamos novos objetos internos na classe, o construtor e o destruidor da classe permanecem vazios. Em nosso trabalho de criação de métodos, começaremos com o método Init de inicialização da classe. Nos parâmetros, o método recebe:

  • numInputs - número de neurônios na camada anterior para um só modelo
  • open_cl - ponteiro para o objeto OpenCL
  • numNeurons - número de neurônios na camada para um modelo
  • models - número de modelos no conjunto.

bool CNeuronMultiModel::Init(uint numInputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                             ENUM_OPTIMIZATION optimization_type, int models)
  {
   if(CheckPointer(open_cl) == POINTER_INVALID || numNeurons <= 0  || models <= 0)
      return false;

No corpo do método, verificamos logo se o ponteiro para o objeto OpenCL é relevante e se a especificação da dimensionalidade da camada e do conjunto está correta. Em seguida, salvamos as constantes necessárias em variáveis internas.

   OpenCL = open_cl;
   optimization = ADAM;
   iBatch = 1;
   iModels = models;

Aqui devemos prestar atenção ao fato de que criamos um kernel para atualizar a matriz de peso somente pelo método Adam. Por isso, especificaremos esse método de otimização do modelo independentemente do método obtido nos parâmetros.

Depois disso, criamos buffers para registrar os resultados da camada neural e os gradientes de erro. Observe que os tamanhos de todos os buffers aumentam proporcionalmente ao número de modelos no conjunto. Inicialmente, os buffers são inicializados com valores zero. 

//---
   if(CheckPointer(Output) == POINTER_INVALID)
     {
      Output = new CBufferFloat();
      if(CheckPointer(Output) == POINTER_INVALID)
         return false;
     }
   if(!Output.BufferInit(numNeurons * models, 0.0))
      return false;
   if(!Output.BufferCreate(OpenCL))
      return false;
//---
   if(CheckPointer(Gradient) == POINTER_INVALID)
     {
      Gradient = new CBufferFloat();
      if(CheckPointer(Gradient) == POINTER_INVALID)
         return false;
     }
   if(!Gradient.BufferInit((numNeurons + 1)*models, 0.0))
      return false;
   if(!Gradient.BufferCreate(OpenCL))
      return false;

Em seguida, inicializamos o buffer da matriz de peso com valores aleatórios. O tamanho do buffer deve ser suficiente para armazenar os coeficientes de peso de todos os modelos do conjunto dentro da camada neural atual.

//---
   if(CheckPointer(Weights) == POINTER_INVALID)
     {
      Weights = new CBufferFloat();
      if(CheckPointer(Weights) == POINTER_INVALID)
         return false;
     }
   int count = (int)((numInputs + 1) * numNeurons * models);
   if(!Weights.Reserve(count))
      return false;
   float k = (float)(1 / sqrt(numInputs + 1));
   for(int i = 0; i < count; i++)
     {
      if(!Weights.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier))
         return false;
     }
   if(!Weights.BufferCreate(OpenCL))
      return false;

A implementação do método de otimização Adam requer a criação de dois buffers de dados para registrar os momentos 1 e 2. O tamanho dos referidos buffers é semelhante ao tamanho da matriz de peso. No estágio inicial, inicializamos esses buffers com valores zero.

//---
   if(CheckPointer(DeltaWeights) != POINTER_INVALID)
      delete DeltaWeights;
//---
   if(CheckPointer(FirstMomentum) == POINTER_INVALID)
     {
      FirstMomentum = new CBufferFloat();
      if(CheckPointer(FirstMomentum) == POINTER_INVALID)
         return false;
     }
   if(!FirstMomentum.BufferInit(count, 0))
      return false;
   if(!FirstMomentum.BufferCreate(OpenCL))
      return false;
//---
   if(CheckPointer(SecondMomentum) == POINTER_INVALID)
     {
      SecondMomentum = new CBufferFloat();
      if(CheckPointer(SecondMomentum) == POINTER_INVALID)
         return false;
     }
   if(!SecondMomentum.BufferInit(count, 0))
      return false;
   if(!SecondMomentum.BufferCreate(OpenCL))
      return false;
//---
   return true;
  }

Não se esqueça de controlar o processo de operações em cada estágio. E após a conclusão bem-sucedida de todas as operações acima, encerramos o método.

Após a inicialização, passamos para o método de propagação feedForward. Nos parâmetros, esse método recebe apenas um ponteiro para o objeto da camada de neurônio anterior. E no corpo do método, verificamos logo se o ponteiro recebido é relevante.

bool CNeuronMultiModel::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;

Já criamos um kernel no programa OpenCL para executar todas as operações de propagação previstas pelo algoritmo da camada neural. Agora temos que passar os dados necessários para o kernel e chamar sua execução.

Primeiro, definiremos o espaço da tarefa. Acima, decidimos usar um espaço de tarefa bidimensional. Na primeira dimensão, especificaremos o número de neurônios na saída de um modelo e, na segunda dimensão, especificaremos o número desses modelos. Ao inicializar a classe, não salvamos o número de neurônios na camada de um modelo. Por isso, agora, para determinar o tamanho da primeira dimensão do espaço da tarefa, dividimos o número total de neurônios na saída da nossa camada pelo número de modelos no conjunto. A segunda dimensão é mais fácil. Aqui temos uma variável separada com o número de modelos no conjunto.

   uint global_work_offset[2] = {0, 0};
   uint global_work_size[2];
   global_work_size[0] = Output.Total() / iModels;
   global_work_size[1] = iModels;

Depois de definir o espaço da tarefa, passamos os dados iniciais necessários para os parâmetros do kernel. Ao fazer isso, verificamos o resultado das operações.

   if(!OpenCL.SetArgumentBuffer(def_k_FFMultiModels, def_k_ff_matrix_w, getWeightsIndex()))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FFMultiModels, def_k_ff_matrix_i, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FFMultiModels, def_k_ff_matrix_o, Output.GetIndex()))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_FFMultiModels, def_k_ff_inputs, NeuronOCL.Neurons() / iModels))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_FFMultiModels, def_k_ff_activation, (int)activation))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }

Observe que, para especificar o kernel, usamos o identificador recém-criado do nosso novo kernel. E para especificar os parâmetros, usamos os identificadores do kernel correspondente da camada base totalmente conectada. Isso é possível porque todos os parâmetros do kernel e sua sequência são preservados.

Depois de passar todos os parâmetros, só precisamos enviar o kernel para a fila de execução.

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

Verificamos se todas as operações estão corretas e encerramos o método.

Em seguida, passamos a trabalhar com os métodos de retropropagação. Primeiro, analisaremos o método calcHiddenGradients de distribuição de gradiente de erro. Assim como na propagação, obtemos um ponteiro para o objeto da camada neural anterior nos parâmetros do método. E logo em seguida, no corpo do método, verificamos se o ponteiro recebido é relevante.

bool CNeuronMultiModel::calcHiddenGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;

A próxima etapa é definir o espaço da tarefa. Aqui tudo é parecido com o método de propagação.

   uint global_work_offset[2] = {0, 0};
   uint global_work_size[2];
   global_work_size[0] = NeuronOCL.Neurons() / iModels;
   global_work_size[1] = iModels;

E então passamos os dados iniciais para os parâmetros do kernel.

   if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_w, getWeightsIndex()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_g, getGradientIndex()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_o, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_ig, NeuronOCL.getGradientIndex()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_HGMultiModels, def_k_chg_outputs, Neurons() / iModels))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_HGMultiModels, def_k_chg_activation, NeuronOCL.Activation()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }

Como você pode ver, esse é um algoritmo bastante padrão no que se refere à preparação do kernel OpenCL, que já implementamos várias vezes. Mas há uma nuance na passagem do identificador do modelo para treinamento. Temos que escolher um número aleatório do modelo para treinamento. Para fazer isso, usaremos um gerador de números pseudo-aleatórios. Mas, ao fazer isso, não devemos nos esquecer de que, para esse mesmo modelo, precisamos atualizar a matriz de peso no próximo estágio. Por isso, salvaremos o identificador de modelo aleatório obtido na variável iUpdateModel criada anteriormente. Podemos usar seu valor ao atualizar a matriz de pesos.

   iUpdateModel = (int)MathRound(MathRand() / 32767.0 * (iModels - 1));
   if(!OpenCL.SetArgument(def_k_HGMultiModels, def_k_chg_model, iUpdateModel))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }

Após a entrega bem-sucedida de todos os parâmetros, enviamos o kernel para a fila de execução e encerramos o método.

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

O algoritmo do método de atualização da matriz de pesos repete completamente as etapas de preparação e colocação do kernel na fila de execução e não contém nenhuma "armadilha". Por isso, não vou me alongar em sua descrição. Seu código completo pode ser encontrado no anexo.

Os métodos Save e Load são usados para trabalhar com arquivos. Seus algoritmos são bastante simples. A questão é que, na nova classe, criamos apenas duas variáveis: o número de modelos no conjunto e o identificador do modelo treinado. Somente a primeira variável contém o hiperparâmetro que vamos salvar. O processo para salvar todos os objetos e variáveis herdados já está preparado nos métodos da classe principal. Todos os controles necessários também são criados lá. Assim, para salvar dados, só precisamos primeiro chamar um método parecido da classe principal e, em seguida, salvar o valor de apenas um hiperparâmetro.

bool CNeuronMultiModel::Save(const int file_handle)
  {
   if(!CNeuronBaseOCL::Save(file_handle))
      return false;
   if(FileWriteInteger(file_handle, iModels) <= 0)
      return false;
//---
   return true;
  }

O carregamento de dados de um arquivo é feito de forma semelhante.

Isso conclui o trabalho com o código da nova classe. O código completo de todos os seus métodos pode ser encontrado no anexo.

Mas antes de usá-lo, precisamos executar mais algumas ações no código de nossa biblioteca. Em primeiro lugar, precisamos criar constantes para identificar os kernels e os parâmetros adicionados.

#define def_k_FFMultiModels             46 ///< Index of the kernel of the multi-models neuron to calculate feed forward
#define def_k_HGMultiModels             47 ///< Index of the kernel of the multi-models neuron to calculate hiden gradient
#define def_k_chg_model                 6  ///< Number of model to calculate
#define def_k_UWMultiModels             48 ///< Index of the kernel of the multi-models neuron to update weights
#define def_k_uwa_model                 9  ///< Number of model to update

Em seguida, adicionamos:

  • bloco para criar um novo tipo de camada neural no método CNet::Create;
  • novo tipo de camada no método CLayer::CreateElement;
  • novo tipo no método de propagação da classe de base da rede neural;
  • novo tipo no método gerenciador de retropropagação CNeuronBaseOCL::calcHiddenGradients(CObject *TargetObject).

Criamos uma classe para executar várias camadas independentes totalmente conectadas em paralelo, o que nos permite criar conjuntos de modelos. Mas isso é apenas uma parte, não todo o algoritmo de exploração via desacordo. Para implementar o algoritmo completo, criaremos uma nova classe de modelos CEVD, de modo similar ao módulo de curiosidade interna. Muitas semelhanças podem ser encontradas na estrutura das classes. Isso é observado na nomeação de métodos e variáveis. Vemos o buffer de reprodução de experiência CReplayBuffer. Há dois modelos internos cTargetNet e cForwardNet, mas nenhum modelo inverso. E usaremos um conjunto de modelos como cForwardNet. As diferenças, como sempre, estão nos detalhes.

//+------------------------------------------------------------------+
//| Exploration via Disagreement                                     |
//+------------------------------------------------------------------+
class CEVD : protected CNet
  {
protected:
   uint              iMinBufferSize;
   uint              iStateEmbedingLayer;
   double            dPrevBalance;
   bool              bUseTargetNet;
   bool              bTrainMode;
   //---
   CNet              cTargetNet;
   CReplayBuffer     cReplay;
   CNet              cForwardNet;

   virtual bool      AddInputData(CArrayFloat *inputVals);

public:
                     CEVD();
                     CEVD(CArrayObj *Description, CArrayObj *Forward);
   bool              Create(CArrayObj *Description, CArrayObj *Forward);
                    ~CEVD();
   int               feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true);
   bool              backProp(int batch, float discount = 0.999f);
   int               getAction(int state_size = 0);    
   float             getRecentAverageError() { return recentAverageError; }
   bool              Save(string file_name, bool common = true);
   bool              Save(string dqn, string forward, bool common = true);
   virtual bool      Load(string file_name, bool common = true);
   bool              Load(string dqn, string forward, uint state_layer, bool common = true);
   //---
   virtual int       Type(void)   const   {  return defEVD;   }
   virtual bool      TrainMode(bool flag) { bTrainMode = flag; return (CNet::TrainMode(flag) && cForwardNet.TrainMode(flag));}
   virtual bool      GetLayerOutput(uint layer, CBufferFloat *&result)
     { return        CNet::GetLayerOutput(layer, result); }
   //---
   virtual void      SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; }
   virtual void      SetBufferSize(uint min, uint max);
  };

Adicionamos a variável bTrainMode para separar o algoritmo em um processo de exploração e um processo de aprendizado. Adicionamos o sinalizador bUseTargetNet porque abandonamos a atualização constante do cTargetNet antes de cada lote de atualização do modelo. Também fizemos alterações no algoritmo de métodos. Mas vamos examiná-los todos em ordem.

Os métodos de propagação e detecção de ação do agente receberam a divisão do processo do algoritmo de exploração e treinamento. Isso se deve ao fato de que, no processo de aprendizado, queremos fazer com que o agente explore o ambiente o máximo possível. No processo de exploração, por outro lado, queremos eliminar riscos desnecessários e seguir apenas as políticas internas. Vamos ver como isso é implementado.

No início, o método de propagação é criado de forma semelhante ao método correspondente do bloco de curiosidade interna. Nos parâmetros, obtemos o estado inicial do sistema. Nós o complementamos com dados sobre o estado da conta e as posições abertas. E chamamos o método de propagação do modelo treinado.

int CEVD::feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true)
  {
   if(!AddInputData(inputVals))
      return -1;
//---
   if(!CNet::feedForward(inputVals, window, tem))
      return -1;

Além disso, o algoritmo de seleção de ações é dividido em dois fluxos: treinamento e exploração. No modo de aprendizado, lemos o estado oculto (compactado) do ambiente a partir do modelo treinado e realizamos uma propagação direta de nosso conjunto de modelos dinâmicos. Lembre-se de que, diferentemente do módulo de curiosidade interna, analisamos a previsão de estado não para uma ação específica, mas para toda a gama de ações possíveis de uma só vez. E somente após uma propagação Forward do conjunto bem-sucedida, chamamos o método de determinação da ação ideal. Vamos nos familiarizar com esse método um pouco mais tarde.

   int action = -1;
   if(bTrainMode)
     {
      CBufferFloat *state;
      //if(!GetLayerOutput(1, state))
      //   return -1;
      if(!GetLayerOutput(iStateEmbedingLayer, state))
         return -1;
      if(!cForwardNet.feedForward(state, 1, false))
        {
         delete state;
         return -1;
        }
      double balance = AccountInfoDouble(ACCOUNT_BALANCE);
      double reward = (dPrevBalance == 0 ? 0 : balance - dPrevBalance);
      dPrevBalance = balance;
      action = getAction(state.Total());
      delete state;
      if(action < 0 || action > 3)
         return -1;
      if(!cReplay.AddState(inputVals, action, reward))
         return -1;
     }

Após a determinação bem-sucedida da ação, adicionamos o conjunto de estados ao buffer de reprodução da experiência.

No modo de operação, não executamos ações desnecessárias, mas apenas determinamos a ação ideal com base na política interna do agente e encerramos o método.

   else
      action = getAction();
//---
   return action;
  }

O algoritmo do método de determinação da ação ideal também é dividido em duas seções: treinamento e exploração.

int CEVD::getAction(int state_size = 0)
  {
   CBufferFloat *temp;
//--- get the result of the trained model
   CNet::getResults(temp);
   if(!temp)
      return -1;

No início do método, carregamos o resultado da propagação do modelo treinado. Em seguida, no caso do treinamento do modelo, ajustamos esse valor de acordo com o valor da variância das previsões do conjunto de modelos dinâmicos para cada ação possível. Para fazer isso, primeiro descarregamos o resultado do conjunto em um vetor e, em seguida, transformamos o vetor em uma matriz. Na matriz resultante, cada linha individual representará o estado previsto do sistema para uma ação individual. Somente nossa matriz contém previsões de todos os modelos do conjunto. Para facilitar o processamento dos resultados, dividiremos a matriz horizontalmente em várias matrizes iguais de tamanho menor. O número dessas matrizes será igual ao número de modelos no conjunto. E cada uma dessas matrizes terá uma dimensionalidade de linha igual ao espectro de ações possíveis do nosso agente.

Agora podemos usar operações matriciais e primeiro encontrar a matriz de valores médios para cada ação separada de um componente de estado separado. E, em seguida, calcular a variação dos desvios das matrizes previstas em relação à média. Adicionaremos a variação média de cada ação aos valores de recompensa previstos do modelo treinado. Nesse ponto, podemos usar um coeficiente para equilibrar o reconhecimento e a exploração. Para maximizar a exploração do ambiente, podemos usar apenas a variação dos valores previstos, sem nos concentrarmos na recompensa esperada. Dessa forma, incentivamos o modelo a maximizar a exploração do ambiente sem afetar a política do agente.

//--- make allowance for "curiosity" in training mode
   if(bTrainMode && state_size > 0)
     {
      vector<float> model;
      matrix<float> forward;
      cForwardNet.getResults(model);
      forward.Init(1, model.Size());
      forward.Row(model, 0);
      temp.GetData(model);
      //---
      int actions = (int)model.Size();
      forward.Reshape(forward.Cols() / state_size, state_size);
      matrix<float> ensemble[];
      if(!forward.Hsplit(forward.Rows() / actions, ensemble))
         return -1;
      matrix<float> means = ensemble[0];
      int total = ArraySize(ensemble);
      for(int i = 1; i < total; i++)
         means += ensemble[i];
      means = means / total;
      for(int i = 0; i < total; i++)
         ensemble[i] -= means;
      means = MathPow(ensemble[0], 2.0);
      for(int i = 1 ; i < total; i++)
         means += MathPow(ensemble[i], 2.0);
      model += means.Sum(1) / total;
      temp.AssignArray(model);
     }

Não fazemos nenhum ajuste durante a operação do modelo, mas determinamos a ação ideal com base no princípio de maximizar a recompensa esperada.

//---
   return temp.Argmax();
  }

O código completo do método pode ser encontrado no anexo.

Vale a pena nos determos um pouco mais no método de retropropagação. Para evitar iterações desnecessárias no processo de operação do modelo, o método de retropropagação encerra imediatamente seu trabalho na ausência do sinalizador de treinamento do modelo. Isso nos permite mudar rapidamente do modo de treinamento do modelo para o modo de teste sem alterar o código do EA.

bool CEVD::backProp(int batch, float discount = 0.999000f)
  {
//---
   if(cReplay.Total() < (int)iMinBufferSize || !bTrainMode)
      return true;

Depois de passar o bloco de controle, criamos as variáveis locais necessárias.

//---
   CBufferFloat *state1, *state2, *targetVals = new CBufferFloat();
   vector<float> target, actions, st1, st2, result;
   matrix<float> forward;
   double reward;
   int action;

E, após o trabalho preparatório, realizamos um laço de modelos de treinamento no tamanho do pacote, que foi especificado nos parâmetros do método.

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

No corpo do laço, primeiro obtemos um conjunto de estados aleatórios do buffer de repetição de experiência e realizamos uma propagação do modelo treinado com o estado obtido.

      getResults(target);
      //--- unload state embedding
      if(!GetLayerOutput(iStateEmbedingLayer, state1))
         return false;
      //--- target net direct pass
      if(!cTargetNet.feedForward(state2, 1, false))
         return false;

Depois de realizar a propagação do modelo treinado, salvamos o resultado obtido e o estado oculto.

Com o uso da Target Net, obtemos a incorporação do estado subsequente do sistema de forma semelhante.

      //--- correct reward
      if(bUseTargetNet)
        {
         cTargetNet.getResults(targetVals);
         reward += discount * targetVals.Maximum();
        }
      target[action] = (float)reward;
      if(!targetVals.AssignArray(target))
         return false;
      //--- backpropagation of the trained model
      CNet::backProp(targetVals);

Se necessário, ajustamos a recompensa externa do sistema pelo valor previsto da Target Net e realizamos uma retropropagação do modelo treinado.

Na próxima etapa, treinamos o conjunto de modelos com base nas incorporações dos dois estados subsequentes obtidos acima.

      //--- forward net direct pass - next state forecast
      if(!cForwardNet.feedForward(state1, 1, false))
         return false;
      //--- unload the "future" state embedding
      if(!cTargetNet.GetLayerOutput(iStateEmbedingLayer, state2))
         return false;

Primeiro, realizamos uma propagação do conjunto de modelos com a incorporação do primeiro estado.

Em seguida, descarregamos os resultados da propagação e os usamos para treinar os valores-alvo, substituindo o vetor de ação perfeito pela incorporação do estado subsequente obtido usando a Target Net.

Para fazer isso, transferimos os resultados da propagação do conjunto de modelos para uma matriz com um número de colunas igual à incorporação do estado. Lembramos que a matriz contém os resultados de todo o conjunto de modelos. Portanto, organizamos um laço e substituímos o estado previsto pelo estado-alvo para a ação perfeita em todos os modelos do conjunto.

      //--- prepare forward net targets
      cForwardNet.getResults(result);
      forward.Init(1, result.Size());
      forward.Row(result, 0);
      forward.Reshape(result.Size() / state2.Total(), state2.Total());
      int ensemble = (int)(forward.Rows() / target.Size());
      //--- copy the target state to the matrix of ensemble targets
      state2.GetData(st2);
      for(int r = 0; r < ensemble; r++)
         forward.Row(st2, r * target.Size() + action);

À primeira vista, a substituição do estado-alvo em todos os modelos vai contra a ideia de treinar modelos do conjunto em dados diferentes. Mas quero lembrá-lo de que organizamos a seleção aleatória de modelos no método retropropagação da classe CNeuronMultiModel. E, nesse estágio, não sabemos qual modelo será treinado. Por isso, prepararemos valores-alvo para todos os modelos. E a seleção do modelo a ser treinado será feita posteriormente.

      //--- foward net backpropagation
      targetVals.AssignArray(forward);
      cForwardNet.backProp(targetVals);
     }
//---
   delete state1;
   delete state2;
   delete targetVals;
//---
   return true;
  }

No final das iterações no corpo do laço de treinamento, realizamos uma retropropagação do conjunto de modelos dinâmicos Forward com os dados preparados. É importante observar que, ao preparar os valores-alvo, alteramos apenas os valores-alvo de uma única ação. Os outros foram mantidos no nível dos valores previstos. Isso nos permite obter o gradiente de erro de apenas uma ação específica ao executar a retropropagação. Para as outras direções, esperamos obter erro zero.

Após a conclusão bem-sucedida das iterações do laço, removemos os objetos desnecessários e encerramos o método.

Os demais métodos da classe são criados de forma semelhante aos métodos correspondentes do módulo de curiosidade interna. Seu código completo pode ser encontrado no anexo.


3. Teste

Depois de criar as classes necessárias e seus métodos, passamos a testar o trabalho realizado. Para testar a funcionalidade das classes criadas, criaremos o Expert Advisor "EVDRL-learning.mq5". Como antes, criaremos um Expert Advisor baseado no Expert Advisor do artigoanterior. Desta vez, não alteraremos a arquitetura do modelo treinado. Em vez disso, mudaremos a classe do modelo usado. Substituiremos o módulo de curiosidade interna por um bloco de exploração via desacordo.

//+------------------------------------------------------------------+
//| Includes                                                         |
//+------------------------------------------------------------------+
#include "EVD.mqh"
...........
...........
...........
...........
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
CEVD                 StudyNet;

Também modificaremos o método de descrição de arquitetura dos modelos. Aqui, removeremos a descrição da arquitetura do modelo inverso e alteraremos a arquitetura do modelo Forward. Vale a pena nos determos um pouco na última. Anteriormente, usamos um perceptron com uma camada oculta para o modelo Forward. Vamos criar uma arquitetura semelhante para os modelos de conjunto.

Ao resolver o problema diretamente, devemos criar uma camada de dados iniciais com um tamanho de buffer suficiente para todos os modelos e duas camadas consecutivas de nossa nova classe de modelo de conjunto CNeuronMultiModel. Mas observe que todos os modelos do conjunto usam o mesmo estado do sistema. Isso significa que, para manter esse conjunto, precisamos repetir tantas vezes na camada de dados iniciais, cada vez que um conjunto de dados, quantos forem os modelos em nosso conjunto. Na minha opinião, esse é um uso ineficiente da memória do nosso contexto OpenCL, faz com que haja custos adicionais de tempo para a concatenação de um grande buffer de dados iniciais e, ao mesmo tempo, aumenta os custos de tempo para a transferência de uma grande quantidade de dados da RAM do dispositivo para a memória do contexto OpenCL.

Seria muito mais eficiente fazer o manuseio de todos os modelos em um buffer de dados pequeno, que contém apenas uma cópia do estado do sistema. Mas não incluímos essa opção ao criar o método de propagação da nossa classe CNeuronMultiModel.

Vejamos a arquitetura da nossa camada de neurônios totalmente conectada básica. Nela, cada neurônio tem seu próprio vetor de coeficientes de peso, independente dos outros neurônios dessa camada. Na prática, é um conjunto de modelos independentes com o tamanho de um neurônio. Isso significa que podemos usar uma camada básica de neurônios totalmente conectados como uma camada oculta para todos os modelos do nosso conjunto. Só precisamos fazer uma camada neural de tamanho suficiente para proporcionar dados a todos os modelos do nosso conjunto.

Assim, para nosso conjunto de modelos Forward, criamos uma camada de dados iniciais de 100 elementos. Esse é o tamanho da representação compactada do estado do sistema que obtemos do modelo principal. Lembre-se de que, nesse caso, não adicionamos um vetor de ação, pois esperamos obter estados previstos do modelo para toda a gama de ações possíveis.

Em seguida, usaremos um conjunto de 5 modelos. Como uma camada oculta, criamos uma camada neural totalmente conectada de 1.000 elementos (200 neurônios por modelo).

E isso é seguido por nossa nova camada de conjunto de modelos. Aqui especificamos a seguinte descrição da camada neural:

  • Tipo de camada neural (descr.type) defNeuronMultiModels;
  • Número de neurônios em um modelo (descr.count) - 400 (100 elementos por descrição de estado de cada uma das 4 ações possíveis);
  • Número de neurônios na camada anterior para 1 modelo (descr.window ) - 200;
  • Número de modelos no conjunto (descr.step) 5;
  • Função de ativação (descr.ativação) TANH (tangente hiperbólica, deve corresponder à função de ativação da camada de incorporação no modelo principal;
  • Método de otimização (descr.optimization) ADAM (único possível para este tipo de camada neural.)
bool CreateDescriptions(CArrayObj *Description, CArrayObj *Forward)
  {
//---
...........
...........
//---
   if(!Forward)
     {
      Forward = new CArrayObj();
      if(!Forward)
         return false;
     }
//--- Model
...........
...........
...........
...........
//--- Forward
   Forward.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 100;
   descr.window = 0;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!Forward.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 1000;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!Forward.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMultiModels;
   descr.count = 400;
   descr.window = 200;
   descr.step = 5;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!Forward.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Treinamos e testamos o modelo sem alterar as condições: EURUSD, período de tempo H1, parâmetros do indicador padrão.

Pelos resultados do treinamento de teste, é possível dizer que o treinamento de um conjunto de modelos requer mais tempo do que o treinamento de um único modelo Forward. Nesse caso, observamos como o modelo executa ações de forma bastante caótica no início. Durante o processo de treinamento, esse comportamento caótico diminui.

Em geral, o modelo foi capaz de obter lucro durante o processo de teste.

Gráfico do teste

Resultados do teste


Considerações finais

Nos modelos de treinamento com aprendizado por reforço, o ambiente continua sendo um problema importante. Neste artigo, foi apresentada outra abordagem para esse problema, a exploração via desacordo. O agente é treinado on-line com base nos dados que ele mesmo coleta ao interagir com o ambiente usando um método de otimização de política. Ao mesmo tempo, após cada interação do agente com o ambiente, o conjunto de modelos é atualizado, o que permite que o agente atualize seu modelo interno sobre o ambiente em cada etapa e obtenha previsões mais precisas sobre os estados do ambiente no futuro.

O modelo foi criado e testado em dados reais no testador de estratégias MetaTrader 5. Como resultado do teste, o modelo obteve lucro. Isso nos permite supor que há um possível potencial para o desenvolvimento dessa direção. Ao mesmo tempo, o treinamento e o teste do modelo foram realizados em um período de tempo bastante curto. Para usar o modelo em negociações reais, será necessário um treinamento adicional do modelo em dados históricos ampliados.


Referências

  1. Self-Supervised Exploration via Disagreement
  2. Redes neurais de maneira fácil (Parte 35): módulo de curiosidade intrínseca
  3. Redes neurais de maneira fácil (Parte 36): modelos relacionais de aprendizado por reforço
  4. Redes neurais de maneira fácil (Parte 37): atenção esparsa

Programas utilizados no artigo

# Nome Tipo Descrição
1 EVDRL-learning.mq5 EA EA para treinamento de modelos
2 EVD.mqh Biblioteca de classe Biblioteca da classe para organizar o trabalho de exploração via desacordo
2 ICM.mqh Biblioteca de classe Biblioteca da classe para organizar o trabalho do módulo de curiosidade interna
3 NeuroNet.mqh Biblioteca de classe Biblioteca das classes para a criação de uma rede neural
4 NeuroNet.cl Biblioteca Biblioteca de códigos do programa OpenCL

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

Arquivos anexados |
MQL5.zip (203.62 KB)
Ciência de Dados e Aprendizado de Máquina (Parte 14): aplicando mapas de Kohonen nos mercados Ciência de Dados e Aprendizado de Máquina (Parte 14): aplicando mapas de Kohonen nos mercados
Deseja descobrir uma nova metodologia de negociação que facilite a orientação em mercados complexos e voláteis? Explore os mapas de Kohonen - uma versão inovadora de redes neurais artificiais, capazes de identificar regularidades e tendências ocultas nos dados do mercado. Neste texto, analisaremos a funcionalidade dos mapas de Kohonen e a forma de utilizá-los na elaboração de estratégias de negociação eficazes. Estou convencido de que esta abordagem inédita será do interesse de traders novatos e experientes.
Desenvolvendo um sistema de Replay - Simulação de mercado (Parte 22): FOREX (III) Desenvolvendo um sistema de Replay - Simulação de mercado (Parte 22): FOREX (III)
Para quem ainda não entendeu a diferença entre o mercado de bolsa e o de forex, apesar de este já ser o terceiro artigo em que estou abordando isto. Devo deixar claro, que a grande diferença, é o fato de que no forex não existe, ou melhor, não nos é informado algumas coisas a respeito do que aconteceu de fato na negociação.
Encapsulando modelos ONNX em classes Encapsulando modelos ONNX em classes
A programação orientada a objetos permite criar códigos mais compactos, fáceis de ler e modificar. Apresentamos um exemplo para três modelos ONNX.
Aprenda algumas lições com as Empresas de Prop Trading (Parte 1) — Uma introdução Aprenda algumas lições com as Empresas de Prop Trading (Parte 1) — Uma introdução
Neste artigo introdutório, discutirei algumas lições que podem ser aprendidas com os testes que as empresas de prop trading empregam. Isso é especialmente relevante para iniciantes e para aqueles que estão lutando para encontrar seu lugar no mundo do trading. O próximo artigo abordará a implementação do código.