English Русский 中文 Español Deutsch 日本語
preview
Redes neurais de maneira fácil (Parte 32): Aprendizado Q distribuído

Redes neurais de maneira fácil (Parte 32): Aprendizado Q distribuído

MetaTrader 5Sistemas de negociação | 13 fevereiro 2023, 16:09
256 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introdução

No artigo Redes neurais de maneira fácil (Parte 27): aprendizado Q profundo (DQN)" já aprendemos sobre o método Aprendizado Q, que estima a função Q, que depende do estado do sistema, da ação executada e da recompensa. No entanto, o mundo real é complexo e nem sempre podemos considerar todos os fatores que influenciam o estado ao avaliá-lo. Como resultado, a relação entre os parâmetros estimados, a descrição do estado e as recompensas é imprecisa. Além disso, ao aproximar a função Q, obtemos apenas o valor médio provável da recompensa esperada, sem ver a distribuição completa das recompensas. Esses valores médios também podem ser distorcidos devido a flutuações significativas. Em 2017, foram propostos algoritmos que visam a estudar a distribuição das recompensas, que melhoraram significativamente os resultados do aprendizado Q clássico em jogos Atari.


1. Características do aprendizado Q distribuído

O aprendizado Q distribuído, assim como o aprendizado Q original, busca aproximar a função de utilidade de uma ação. Como antes, vamos aproximar a função Q para prever a recompensa esperada. A principal diferença é que, em vez de aproximar a recompensa esperada de uma ação concluída em um estado específico, agora visamos aproximar a distribuição de probabilidade da recompensa esperada. No entanto, devido aos recursos limitados, não podemos estimar a probabilidade exata de ocorrência de cada valor de recompensa individual. Em vez disso, dividimos o intervalo possível de valores de recompensa em vários intervalos separados conhecidos como quantis.

Para identificar os quantis, precisamos introduzir alguns hiperparâmetros adicionais, incluindo o valor mínimo (Vmin) e máximo (Vmax) da faixa de recompensas esperadas, bem como o número de quantis (N). Em seguida, podemos calcular o intervalo de valores de cada quantil usando a fórmula abaixo.

Ao contrário do aprendizado Q original, que se concentra na aproximação do valor esperado da recompensa, o algoritmo aprendizado Q distribuído aproxima a distribuição de probabilidade de receber uma recompensa em um determinado quantil ao realizar uma ação em um estado específico. Essa abordagem permite transformar o problema de aproximação da função Q em um problema de classificação padrão, facilitando o treinamento. Além disso, isso implica uma mudança na função de perda utilizada. Enquanto o aprendizado Q original utiliza o desvio padrão como função de perda, o aprendizado Q distribuído utiliza o LogLoss. Este recurso já foi explorado anteriormente no Policy Gradient.

LogLoss

Em geral, o aprendizado Q distribuído nos permite aproximar a distribuição de probabilidade da recompensa para cada combinação estado-ação. Isso significa que, ao selecionar uma ação, podemos prever com maior precisão tanto o nível de recompensa esperada quanto a probabilidade de recebê-la. O valor adicional é que podemos estimar as probabilidades de um determinado nível de recompensa, e não apenas seu valor médio. Isto nos permite adotar uma abordagem de risco ao avaliar a probabilidade de receber recompensas positivas e negativas ao realizar uma ação no estado atual do sistema.

Com o aprendizado Q distribuído, temos a vantagem de avaliar a probabilidade de obter recompensas positivas e negativas ao executar uma ação em determinado estado, o que é especialmente útil em situações em que a mesma ação pode resultar em recompensas distintas. Ao contrário do aprendizado Q original, que somente calcula a média da recompensa esperada, o aprendizado Q distribuído permite estimar a probabilidade de receber recompensas reais. Isso evita que ações sejam ignoradas devido a resultados negativos, pois permite uma decisão baseada em risco.

Atenção: Qualquer ação do agente resulta em uma recompensa do ambiente com 100% de probabilidade. Para garantir que a soma das probabilidades das ações do agente seja "1", usaremos a função SoftMax.

Além disso, utilizaremos o buffer de repetição de experiência, o modelo Target Net para prever recompensas futuras e um fator de desconto para recompensas futuras,

tudo baseado no algoritmo original de aprendizado Q. O treinamento segue os princípios do aprendizado Q original e a equação de Bellman.

Equação de BellmanEquação de

Mencionamos anteriormente que utilizaremos o Target Net para prever as recompensas futuras. É uma cópia "congelada" do modelo em treinamento. Agora, discutiremos as abordagens para seu uso.

Aprendizado por reforço e, em particular, aprendizado Q, permite construir estratégias de ação para obter o melhor resultado. A equação de Bellman permite avaliar o valor do estado futuro, considerando a recompensa máxima possível até o fim da sessão. Se não houver essa medição, o modelo apenas preverá a recompensa esperada da transição atual para um novo estado.

Analisemos o processo de outra perspectiva. Não há uma recompensa definitiva até o fim da sessão, por isso usamos uma segunda rede neural para prever dados faltantes. Para evitar treinar dois modelos ao mesmo tempo, usamos uma cópia do modelo com pesos congelados para prever as recompensas futuras. No entanto, a precisão das previsões de um modelo não treinado será questionável, pois provavelmente serão aleatórias. Definir valores aleatórios como alvos para o modelo treinado distorce a percepção do ambiente e leva o treinamento na direção errada.

Ao negarmos o uso do Target Net no início, podemos treinar o modelo para prever com certa precisão a recompensa da transição atual. Embora o modelo não possa criar uma estratégia imediatamente, este é apenas o primeiro passo de aprendizado. Se conseguirmos previsões razoáveis de um passo à frente com o modelo, podemos utilizá-lo como Target Net. Então, podemos re-treinar o modelo para criar uma estratégia de dois passos à frente.

Essa é a abordagem certa, com uma atualização progressiva do Target Net. O uso de previsões de estado futuro razoáveis permitirá ao modelo construir a estratégia correta e, assim, alcançarmos o resultado desejado.

Adicionalmente, é importante mencionar o fator de desconto para as recompensas futuras. É por meio dele que gerenciamos as previsões do modelo na construção da estratégia. O tipo de estratégia que será construído depende fortemente deste hiperparâmetro. Se usarmos um coeficiente próximo a "1", o modelo entenderá a necessidade de criar uma estratégia de longo prazo, resultando em estratégias de investimento de longa duração.

Ao invés disso, uma redução deste parâmetro mais próxima a "0" faz com que o modelo negligencie as recompensas futuras e se enfoque em obter lucro de curto prazo, construindo uma estratégia de curto prazo, como scalping. É importante notar que também o período gráfico considerado afeta a duração da posição mantida.

Vamos resumir o anterior.

  1. O método aprendizado Q distribuído é baseado no aprendizado Q clássico e o complementa.
  2. Uma rede neural é usada como modelo.
  3. No processo de treinamento do modelo, aproximamos a distribuição de probabilidade da recompensa esperada para a transição para um novo estado, dependendo do par estado-ação.
  4. A distribuição é representada por um conjunto de quantis de uma faixa fixa de recompensa.
  5. O número de quantis e o intervalo de valores possíveis são determinados por hiperparâmetros.
  6. A distribuição para cada ação possível é representada pelo mesmo vetor de probabilidade.
  7. Para normalizar a distribuição de probabilidade, usamos a função SoftMax no contexto de cada ação individual.
  8. O modelo é treinado com base na equação de Bellman.
  9. A abordagem probabilística para resolver o problema requer o uso de LogLoss como uma função de perda.
  10. Para estabilizar o processo de aprendizagem, são utilizadas heurísticas do algoritmo aprendizado Q original (Target Net, buffer de reprodução de experiência).

E, como sempre, a parte teórica é seguida pela implementação prática da abordagem usando ferramentas MQL5.


2. Implementação usando MQL5

Para implementar o método de aprendizado Q distribuído usando MQL5, é preciso planejar o trabalho. Com base no algoritmo original de aprendizado Q, mencionado na teoria do artigo, já foi realizada uma implementação anterior. É recomendável criar um novo Expert Advisor a partir desta implementação anterior.

O uso de uma abordagem probabilística exigirá alterações no bloco usado para transmitir os valores alvo do modelo.

Ao processar a saída do modelo, é necessário normalizar os dados através da função SoftMax. Esta função já foi conhecida e implementada no artigo sobre Policy Gradient, onde também normalizamos as probabilidades para a escolha das ações. E normalizamos os dados em toda a camada neural. Contudo, agora é preciso normalizar as probabilidades para cada ação separadamente, o que requer ajuste em relação à classe CNeuronSoftMaxOCL usada anteriormente.

Neste caso, temos 2 opções: podemos criar uma nova classe ou atualizar uma já existente. Resolvi usar a segunda opção. Lembre-se da estrutura da classe criada anteriormente.

class CNeuronSoftMaxOCL    :  public CNeuronBaseOCL
  {
protected:
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return true; }

public:
                     CNeuronSoftMaxOCL(void) {};
                    ~CNeuronSoftMaxOCL(void) {};
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);   
   virtual bool      calcOutputGradients(CArrayFloat *Target, float& error) override;
   //---
   virtual int       Type(void) override  const   {  return defNeuronSoftMaxOCL; }
  };

Primeiramente, adicionaremos uma variável para armazenar o número de vetores iHeads a serem normalizados e um método para especificar o parâmetro SetHeads. Por padrão, especificaremos 1 vetor, o que corresponde à normalização dos dados em toda a camada.

class CNeuronSoftMaxOCL    :  public CNeuronBaseOCL
  {
protected:
   uint              iHeads;
.........
.........
public:
                     CNeuronSoftMaxOCL(void) : iHeads(1) {};
                    ~CNeuronSoftMaxOCL(void) {};
.........
.........
   virtual void      SetHeads(int heads)  { iHeads = heads; }
.........
.........
  };

Como é de conhecimento, adicionar uma nova variável não muda a lógica dos métodos da classe, por isso é necessário realizar ajustes no algoritmo dos métodos. Em especial, nos métodos de propagação para frente e para trás. O método feedForward é responsável pela propagação, e lembro que ele apenas faz a chamada do algoritmo auxiliar ao kernel correspondente do programa OpenCL. Os cálculos são feitos em modo multithread do lado do OpenCL, portanto antes de mudar as operações do kernel na fila de execução, é preciso primeiro fazer alterações no lado do programa OpenCL.

Vamos raciocinar. Uma característica da função SoftMax é a normalização dos dados de forma que a soma de todo o vetor de resultados seja igual a "1". Lembre a fórmula matemática da função.

SoftMax

A normalização dos dados é feita usando a soma dos valores exponenciais do vetor de dados de entrada. Usando uma matriz de dados local, transferimos os dados entre threads separados do kernel, o que permitiu criar uma implementação multithread da função do lado do OpenCL. O algoritmo que criamos é executado em um espaço de problema unidimensional e normaliza os valores dentro de um único vetor. Para aplicar o novo algoritmo, é preciso dividir o volume total de dados em partes iguais e normalizar cada parte separadamente. A dificuldade pode ser o número desconhecido dessas partes em dado momento.

A normalização em blocos individuais tem vantagens. Ela pode ocorrer de forma independente, se encaixando no conceito de computação multithread. Para a normalização distribuída, podemos iniciar múltiplas instâncias do kernel já criado.

É necessário apenas distribuir o volume de dados e resultados nos blocos correspondentes. Anteriormente, iniciamos o kernel em um espaço unidimensional, mas a tecnologia OpenCL permite usar um espaço tridimensional. Nesse caso, a segunda dimensão pode ser usada para identificar o bloco de normalização.

Assim, como resultado da adição de outra dimensão do espaço de tarefas, criamos a possibilidade de normalização distribuída na classeSoftMax_FeedForward criada anteriormente. Mas ainda teremos mudanças no código do kernel, embora menores. Afinal, precisamos adicionar o processamento da segunda dimensão do espaço de tarefas ao algoritmo do kernel.

Os parâmetros do kernel permanecem inalterados. Neles, passamos ponteiros para buffers de dados e o tamanho de um vetor de normalização de dados.

__kernel void SoftMax_FeedForward(__global float *inputs,
                                  __global float *outputs,
                                  const uint total)
  {
   uint i = (uint)get_global_id(0);
   uint l = (uint)get_local_id(0);
   uint h = (uint)get_global_id(1);
   uint ls = min((uint)get_local_size(0), (uint)256);
   uint shift_head = h * total;

No kernel, imediatamente capturamos os identificadores de thread em ambas as dimensões do espaço de tarefas Eles determinam a quantidade de trabalho do thread atual e o deslocamento nos buffers de dados em relação aos elementos sendo processados. A primeira dimensão identifica o ponto de fluxo no algoritmo de normalização de dados, enquanto a segunda dimensão determina o deslocamento nos buffers de dados. As linhas adicionadas estão destacadas no código.

Em seguida, no algoritmo do kernel, há uma etapa de soma dos valores exponenciais dos dados iniciais. Corrigimos o deslocamento em relação ao primeiro elemento do bloco de dados de origem normalizado, que é destacado no código.

Observe que estamos usando apenas o deslocamento para o buffer de dados de entrada global, e não consideramos a matriz de dados de entrada local. Cada grupo de trabalho opera isoladamente e usa sua própria matriz de dados local.

   __local float temp[256];
   uint count = 0;
   if(l < 256)
      do
        {
         uint shift = shift_head + count * ls + l;
         temp[l] = (count > 0 ? temp[l] : 0) + (shift < ((h + 1) * total) ? exp(inputs[shift]) : 0);
         count++;
        }
      while((count * ls + l) < total);
   barrier(CLK_LOCAL_MEM_FENCE);

No bloco anterior, coletamos partes da soma total nos elementos de um array local. Em seguida, realizamos uma iteração para consolidar a soma total dos valores do array. Nesta etapa, tratamos apenas com um array local e esse processo é completamente separado da segunda dimensão do nosso espaço de tarefas e não sofre alterações.

   count = ls;
   do
     {
      count = (count + 1) / 2;
      if(l < 256)
         temp[l] += (l < count && (l + count) < total ? temp[l + count] : 0);
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
//---
   float sum = temp[0];

No final do kernel, normalizamos os dados iniciais e salvamos o valor resultante no buffer de resultados. Aqui, como no primeiro loop, usamos o deslocamento calculado anteriormente nos buffers de dados globais.

   if(sum != 0)
     {
      count = 0;
      while((count * ls + l) < total)
        {
         uint shift = shift_head + count * ls + l;
         if(shift < ((h + 1) * total))
            outputs[shift] = exp(inputs[shift] / 10) / (sum + 1e-37f);
         count++;
        }
     }
  }

Usamos uma abordagem semelhante ao fazer alterações no kernel de distribuição de gradiente antes da camada SoftMax_HiddenGradientanterior, Sendo que só adicionamos um deslocamento nos buffers de dados globais sem alterar o algoritmo geral do kernel.

__kernel void SoftMax_HiddenGradient(__global float* outputs,
                                     __global float* output_gr,
                                     __global float* input_gr)
  {
   size_t i = get_global_id(0);
   size_t outputs_total = get_global_size(0);
   size_t h = get_global_id(1);
   uint shift = h * outputs_total;
   float output = outputs[shift + i];
   float result = 0;
   for(int j = 0; j < outputs_total ; j++)
      result += outputs[shift + j] * output_gr[shift + j] * ((float)(i == j) - output);
   input_gr[shift + i] = result;
  }

Não fizemos nenhuma alteração no kernel para determinar o desvio em relação à distribuição SoftMax_OutputGradient de referência. Já que neste kernel o desvio é determinado para um elemento específico da sequencia, e realmente não importa a que bloco um determinado elemento pertence.

__kernel void SoftMax_OutputGradient(__global float* outputs,
                                     __global float* targets,
                                     __global float* output_gr)
  {
   size_t i = get_global_id(0);
   output_gr[i] = targets[i] / (outputs[i] + 1e-37f);
  }

Assim concluímos nosso trabalho no lado do OpenCL e retornamos ao código de nossa classe CNeuronSoftMaxOCL. Primeiro, fizemos alterações no kernel de propagação. E faremos alterações nos métodos de nossa classe seguindo uma ordem semelhante.

Não adicionamos ou alteramos parâmetros nos kernels. Portanto, o algoritmo de preparação de dados e a chamada do kernel permanecem inalterados. As únicas mudanças serão na especificação do espaço de tarefas.

Definimos a dimensão de um vetor de normalização de dados como o tamanho do buffer de resultado dividido pelo número de vetores a serem normalizados. Armazenamos o resultado na variável local size. Preenchemos a matriz do espaço de tarefas global global_work_size. indicando na primeira dimensão o tamanho de um vetor de normalização e na segunda dimensão, o número de tais vetores.

Para poder sincronizar threads e trocar dados entre eles, criamos anteriormente um grupo de trabalho igual ao espaço de tarefas global. Normalizamos também os dados dentro de todo o buffer de dados. Porém, a situação agora é um pouco diferente, porque temos que normalizar vários blocos individuais no buffer de dados. Ao construir o kernel de propagação, notamos que o trabalho com a matriz de dados local permaneceu inalterado. Isso foi possível graças à separação da normalização de cada vetor em um grupo de trabalho diferente. Portanto, neste caso, precisamos criar uma outra matriz de espaço de tarefas de grupo local local_work_size.

Como as dimensões dos espaços de tarefas globais e locais devem ser as mesmas, precisamos definir um espaço de tarefas local bidimensional. Além disso, o número de threads globais deve ser um múltiplo do número de threads locais em cada dimensão do espaço de tarefas.

Acima, indicamos o espaço global do problema como um vetor normalizável na primeira dimensão e o número de tais vetores na segunda dimensão. Planejamos normalizar apenas um vetor em cada grupo de trabalho, então é lógico indicar o tamanho de um vetor normalizável na primeira dimensão do espaço de tarefas local e "1" na segunda dimensão, correspondendo a um único vetor.

Abaixo está o código modificado do método feedForward, com todas as alterações realçadas. Como você pode ver, são poucas, porém é importante prestar atenção à todos os pontos-chave.

bool CNeuronSoftMaxOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!OpenCL || !NeuronOCL)
      return false;
   uint global_work_offset[2] = {0, 0};
   uint size = Output.Total() / iHeads;
   uint global_work_size[2] = { size, iHeads };
   uint local_work_size[2] = { size, 1 };
   OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_inputs, NeuronOCL.getOutputIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_outputs, getOutputIndex());
   OpenCL.SetArgument(def_k_SoftMax_FeedForward, def_k_softmaxff_total, size);
   if(!OpenCL.Execute(def_k_SoftMax_FeedForward, 2, global_work_offset, global_work_size, local_work_size))
     {
      printf("Error of execution kernel SoftMax FeedForward: %d", GetLastError());
      return false;
     }
//---
   return true;
  }

Alterações semelhantes foram feitas no método de distribuição de gradiente de erro antes da camada anterior calcInputGradients, só que neste método dispensamos a criação de grupos de trabalho.

bool CNeuronSoftMaxOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;
   uint global_work_offset[2] = {0, 0};
   uint size = Output.Total() / iHeads;
   uint global_work_size[2] = {size, iHeads};
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_input_gr, NeuronOCL.getGradientIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_output_gr, getGradientIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_outputs, getOutputIndex());
   if(!OpenCL.Execute(def_k_SoftMax_HiddenGradient, 2, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel SoftMax InputGradients: %d", GetLastError());
      return false;
     }
//---
   return true;
  }

A normalização distribuída é um recurso de design e afeta os métodos de manipulação de arquivos na classe CNeuronSoftMaxOCL. Essa classe não tinha métodos de arquivo anteriormente, mas utilizava métodos semelhantes da classe pai. Porém, a adição de uma nova variável que precisa ser salva e restaurada requer a redefinição desses métodos.

O método Save começa verificando a validade do handle do arquivo fornecido. Em vez de criar um novo bloco de controle, chamamos um método semelhante da classe pai, passando o handle recebido. Dessa forma, resolvemos duas tarefas ao mesmo tempo. O método da classe pai já contém todos os controles necessários e preserva objetos e variáveis herdados. Verificamos o resultado do método da classe pai para determinar o estado da funcionalidade.

Após a execução bem-sucedida do método da classe pai, basta salvar o valor da nova variável e concluir o método.

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

O método de carregamento de dados CNeuronSoftMaxOCL é construído quase de acordo com o mesmo esquema. Eu apenas adicionei a ele o controle sobre o número mínimo de vetores normalizáveis.

bool CNeuronSoftMaxOCL::Load(const int file_handle)
  {
   if(!CNeuronBaseOCL::Load(file_handle))
      return false;
   iHeads = (uint)FileReadInteger(file_handle);
   if(iHeads <= 0)
      iHeads = 1;
//---
   return true;
  }

Assim concluímos nosso trabalho com a classe CNeuronSoftMaxOCL. Precisamos apenas permitir que o usuário especifique a quantidade de vetores normalizáveis. Não mudaremos a descrição do objeto da camada neural. Para fazer isso, usamos o parâmetro step no método de inicialização da rede neural CNet::Create. Ao criar a camada SoftMax, transferiremos o parâmetro especificado para a instância da classe CNeuronSoftMaxOCL. As mudanças são mostradas no código a seguir.

void CNet::Create(CArrayObj *Description)
  {
.........
.........
//---
   for(int i = 0; i < total; i++)
     {
.........
.........
      if(!!opencl)
        {
.........
.........
         CNeuronSoftMaxOCL *softmax = NULL;
         switch(desc.type)
           {
.........
.........
            case defNeuronSoftMaxOCL:
               softmax = new CNeuronSoftMaxOCL();
               if(!softmax)
                 {
                  delete temp;
                  return;
                 }
               if(!softmax.Init(outputs, 0, opencl, desc.count, desc.optimization, desc.batch))
                 {
                  delete softmax;
                  delete temp;
                  return;
                 }
               softmax.SetHeads(desc.step);
               if(!temp.Add(softmax))
                 {
                  delete softmax;
                  delete temp;
                  return;
                 }
               softmax = NULL;
               break;
.........
.........
           }
        }
.........
.........
//---
   return;
  }

Nenhuma outra alteração na arquitetura da rede neural é necessária para implementar o método visto hoje.

O processo de aprendizado do modelo é realizado diretamente no EA "DistQ-learning.mq5". Este Expert Advisor foi criado com base no Expert Advisor "Q-learning.mq5", que foi usado para treinar o modelo usando o método aprendizado Q original.

O algoritmo de aprendizado Q distribuído oferece a possibilidade de ajustar hiperparâmetros adicionais, que definem o intervalo de recompensas esperadas e o número de quantis da distribuição de probabilidade.

Na implementação proposta, abordei este assunto de uma perspectiva diferente. Usaremos a ferramenta NetCreator para criar o modelo, como nos testes anteriores. O número de quantis será determinado com base no tamanho da camada de resultados dos modelos, claro, levando em consideração o número de ações possíveis, definido pelo parâmetro "Action" do Expert Advisor.

int                  Actions     =  3; 

Durante o processo de aprendizado, precisamos associar uma recompensa específica do ambiente a um quantil determinado. Supomos que a política de recompensa previamente desenvolvida inclua recompensas positivas e negativas (recompensas e punições). A mediana do vetor será considerada a recompensa zero. Para determinar o tamanho do quantil em termos físicos de recompensa, usaremos um parâmetro externo Step.

input double               Step = 5e-4;

Outros parâmetros externos do EA permaneceram inalterados.

Na função de inicialização OnInit, após o carregamento bem-sucedido do modelo, determinamos o número de quantis pelo tamanho da camada neural da saída do modelo e o número do quantil mediano.

int OnInit()
  {
.........
.........
//---
   float temp1, temp2;
   if(!StudyNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, false) ||
      !TargetNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, false))
      return INIT_FAILED;
   if(!StudyNet.TrainMode(true))
      return INIT_FAILED;
//---
   if(!StudyNet.GetLayerOutput(0, TempData))
      return INIT_FAILED;
   HistoryBars = TempData.Total() / 12;
   StudyNet.getResults(TempData);
   action_dist = TempData.Total() / Actions;
   if(action_dist <= 0)
      return INIT_PARAMETERS_INCORRECT;
   action_midle = (action_dist + 1) / 2;
//---
.........
.........
//---
   return(INIT_SUCCEEDED);
  }

Em seguida, tratamos da função de treinamento do modelo. Neste caso, o bloco de preparação de dados permaneceu inalterado. Afinal, não alteramos os dados da amostra de treinamento. As alterações afetaram apenas o bloco que indica os resultados desejados para prever a recompensa esperada.

Primeiramente, criamos um vetor com os custos previstos dos estados futuros. Este vetor terá três elementos, correspondentes a cada ação. Para calcular os valores do vetor, utilizaremos operações vetoriais. Transferimos o buffer de resultados do Target Net para uma matriz linha e reformatamos a matriz em uma tabela com três linhas, uma para cada ação. Em cada linha, identificamos o elemento com a maior probabilidade e convertemos os quantis dos elementos máximos em uma recompensa expressa de forma natural.

void Train(void)
  {
//---
.........
.........
//---
   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
.........
.........
      for(int batch = 0; batch < (Batch * UpdateTarget); batch++)
        {
.........
.........
//---
         vectorf add = vectorf::Zeros(Actions); 
         if(use_target)
           {
            if(!TargetNet.feedForward(GetPointer(State2), 12, true))
               return;
            TargetNet.getResults(TempData);
            vectorf temp;
            TempData.GetData(temp);
            matrixf target = matrixf::Zeros(1, temp.Size());
            if(!target.Row(temp, 0) || !target.Reshape(Actions, action_dist))
               return;
            add = DiscountFactor * (target.ArgMax(1) - action_midle) * Step;
           }

Após determinar o valor previsto do estado futuro, podemos preparar um buffer de valores alvo para nosso modelo. Em primeiro lugar, faremos um pequeno trabalho preparatório, vamos preencher o buffer de recompensa com valores zero e determinar o lucro potencial do estado atual do sistema para 1 vela à frente.

         Rewards.BufferInit(Actions * action_dist, 0);
         double reward = Rates[i].close - Rates[i].open;

Outras etapas dependem da direção da vela. No caso de uma vela de alta, damos uma recompensa para a ação de compra e uma penalidade negativa para a ação de venda. Além disso, aplicamos uma penalidade por "estar fora do mercado". Adicionamos o valor do estado futuro previsto à recompensa. Na implementação original do algoritmo de aprendizado Q, indicamos a recompensa no buffer de resultados alvo. Determinamos o quantil de recompensa para cada ação e marcamos a probabilidade "1" do evento correspondente. Os outros elementos do buffer permanecem com probabilidade zero.

         if(reward >= 0)
           {
            int rew = (int)fmax(fmin((2 * reward + add[0]) / Step + action_midle, action_dist - 1), 0);
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-5 * reward + add[1]) / Step + action_midle, action_dist - 1), 0) + action_dist;
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-reward + add.Max()) / Step + action_midle, action_dist - 1), 0) + 2 * action_dist;
            if(!Rewards.Update(rew, 1))
               return;
           }

Para uma vela de baixa, o algoritmo de ações é semelhante, só que a recompensa e a penalidade são por ações de compra e de venda.

         else
           {
            int rew = (int)fmax(fmin((5 * reward + add[0]) / Step + action_midle, action_dist - 1), 0);
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-2 * reward + add[1]) / Step + action_midle, action_dist - 1), 0) + action_dist;
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((reward + add.Max()) / Step + action_midle, action_dist - 1), 0) + 2 * action_dist;
            if(!Rewards.Update(rew, 1))
               return;
           }

O restante da estrutura da função permanece inalterada, assim como outras partes do código do Expert Advisor que não foram mencionadas aqui. O código integral do Expert Advisor pode ser encontrado no anexo. 


3. Teste

Com o auxílio do EA criado acima, foi treinado um modelo composto por:

  • 3 camadas convolucionais de pré-processamento de dados,
  • 3 camadas ocultas totalmente conectadas de 1000 neurônios cada,
  • 1ª camada de tomada de decisão totalmente conectada de 45 neurônios (15 neurônios para 3 distribuições de probabilidade de ações),
  • 1ª camada SoftMax para normalização de distribuições de probabilidade.

O treinamento foi realizado usando dados históricos dos últimos 2 anos no instrumento EURUSD. Os dados são retirados do período H1. A lista de indicadores utilizados e seus parâmetros permanecem inalterados ao longo da série de artigos.

O modelo treinado foi testado no testador de estratégia com dados históricos das últimas 2 semanas, que não foram incluídos no conjunto de treinamento. Assim, preservamos a pureza do experimento. E o modelo é testado com novos dados.

Para testar o modelo no testador de estratégia, criamos o EA "DistQ-learning-test.mq5". Este Expert Advisor é quase uma cópia completa do "Q-learning-test.mq5", usado para testar o modelo treinado pelo método aprendizado Q original. A única alteração no código do EA é a adição da função de seleção de ação GetAction.

A função recebe como parâmetro um ponteiro para o buffer de distribuição de probabilidade, resultado da avaliação atual do modelo. Esse buffer possui distribuições de probabilidade para todas as ações possíveis. Para facilitar o processamento, transferimos seus valores para uma matriz e formatamos em uma tabela, com o número de linhas igual ao número de ações possíveis do agente.

Em seguida, determinamos os quantis com a recompensa mais provável para cada ação individual. 

int GetAction(CBufferFloat* probability)
  {
   vectorf prob;
   if(!probability.GetData(prob))
      return -1;
   matrixf dist = matrixf::Zeros(1, prob.Size());
   if(!dist.Row(prob, 0))
      return -1;
   if(!dist.Reshape(Actions, prob.Size() / Actions))
      return -1;
   prob = dist.ArgMax(1);

Depois disso, comparamos o retorno esperado da compra e da venda no estado atual. Se o retorno esperado for igual, escolhemos a ação com maior probabilidade de receber uma recompensa.

   if(prob[0] == prob[1])
     {
      if(prob[2] > prob[0])
         return 2;
      if(dist[0, (int)prob[0]] >= dist[1, (int)prob[1]])
         return 0;
      else
         return 1;
     }

Caso contrário, escolhemos a ação com a recompensa máxima esperada.

//---
   return (int)prob.ArgMax();
  }

Como se pode ver, neste caso usamos uma estratégia gulosa para escolher a ação com o maior retorno.

O código integral do Expert Advisor pode ser encontrado no anexo.

O EA de teste apresentou resultados no testador de estratégia MetaTrader 5, após 2 semanas de análise com base nos sinais do modelo, resultando em um lucro aproximado de US$ 20. É importante lembrar que as operações de negociação foram realizadas com um lote mínimo fixo. O gráfico mostra uma tendência de alta evidente no saldo.

Teste do modelo no testador de estratégia

Teste do modelo de aprendizado Q distribuído

As estatísticas das operações de negociação demonstram que quase 56% das operações foram lucrativas. No entanto, o EA foi criado apenas para testar o modelo no testador de estratégia e não é adequado para negociação real nos mercados financeiros.

O código completo de todos os programas usados no artigo pode ser encontrado no anexo.


Considerações finais

Neste artigo, apresentamos o aprendizado Q distribuído, um algoritmo de aprendizado por reforço. Ao utilizá-lo, o modelo aprende a distribuição probabilística de recompensas ao realizar uma ação em um estado ambiental específico. A análise da distribuição de probabilidade, ao invés de apenas prever o valor médio da recompensa, fornece mais informações sobre a natureza da recompensa e melhora a estabilidade do treinamento. Além disso, a compreensão da distribuição esperada de recompensa permite uma avaliação mais precisa dos riscos ao negociar.

Testes do modelo treinado no MetaTrader 5 mostraram o potencial de lucratividade desta abordagem. Portanto, recomendamos mais desenvolvimento e aplicação na construção de decisões de negociação.

O código completo de todos os programas e bibliotecas usados pode ser encontrado no anexo.


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. A Distributional Perspective on Reinforcement Learning
  5. Aprendizagem por Reforço Distribucional com Regressão Quantílica

Programas utilizados no artigo

# Nome Tipo Descrição
1 DistQ-learning.mq5 EA EA para otimização de modelos
2 DistQ-learning-test.mq5 EA
EA para prova do modelo no testador de estratégia
3 NeuroNet.mqh Biblioteca de classes Biblioteca para preparar modelos de redes neurais
4 NeuroNet.cl Biblioteca
Biblioteca de código OpenCL para manusear modelos de redes neurais
NetCreator.mq5 EA Ferramenta para construção de modelos
6 NetCreatotPanel.mqh  Biblioteca de classes Biblioteca da classe para criação da ferramenta

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

Arquivos anexados |
MQL5.zip (82.71 KB)
DoEasy. Controles (Parte 26): Apurando o objeto WinForms "ToolTip" e desenvolvendo o "ProgressBar". DoEasy. Controles (Parte 26): Apurando o objeto WinForms "ToolTip" e desenvolvendo o "ProgressBar".
Neste artigo vamos completar o desenvolvimento do controle ToolTip e começar a desenvolver o objeto WinForms ProgressBar. Ao trabalharmos nesses objetos, desenvolveremos uma funcionalidade versátil para animar os controles e seus componentes.
Funcionalidades do assistente MQL5 que você precisa conhecer (Parte 04): Análise discriminante linear Funcionalidades do assistente MQL5 que você precisa conhecer (Parte 04): Análise discriminante linear
O trader moderno está quase sempre à procura de novas ideias. Para isso, tenta novas estratégias, modifica e descarta aquelas que não funcionam. Nesta série de artigos, tentarei provar que o assistente MQL5 é a verdadeira espinha dorsal de um trader moderno.
Algoritmos de otimização populacionais: Colônia artificial de abelhas (Artificial Bee Colony, ABC) Algoritmos de otimização populacionais: Colônia artificial de abelhas (Artificial Bee Colony, ABC)
Hoje estudaremos o algoritmo de colônia artificial de abelhas. Complementaremos nosso conhecimento com novos princípios para estudar espaços funcionais. E neste artigo falarei sobre minha interpretação da versão clássica do algoritmo.
DoEasy. Controles (Parte 25): Objeto WinForms Tooltip DoEasy. Controles (Parte 25): Objeto WinForms Tooltip
Neste artigo, começaremos a desenvolver o controle Tooltip (dica de ferramenta) e começaremos a criar novas primitivas gráficas para a biblioteca. Naturalmente, nem todo elemento tem uma dica de ferramenta, mas todo objeto gráfico pode ter uma.