English Русский 中文 Español Deutsch 日本語
preview
Redes neurais de maneira fácil (Parte 30): Algoritmos genéticos

Redes neurais de maneira fácil (Parte 30): Algoritmos genéticos

MetaTrader 5Sistemas de negociação | 9 janeiro 2023, 16:22
258 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Conteúdo

Introdução

Continuamos a estudar os algoritmos de treinamento de modelos. Como você se lembra, todos os métodos discutidos anteriormente utilizavam um método analítico para determinar a direção e a força das mudanças nos parâmetros do modelo durante o treinamento. Daí, o principal requisito para todos os modelos treináveis é que a função do modelo deve ser diferenciável ao longo de toda a gama de valores válidos. É por conta desta propriedade que usamos o método de descida de gradiente e determinamos analiticamente a influência de cada parâmetro do modelo no resultado geral, e ajustamos os pesos para a redução do erro.

Entretanto, existem um grande número de tarefas em que não é possível diferenciar a função inicial. Estas podem ser funções indiferenciadas ou um modelo propenso a exibir defeitos de gradiente explosivo ou decrescente (aumento ou diminuição drásticos, respectivamente), e os métodos para resolver esses problemas se revelam ineficazes. Nesses casos, recorremos a métodos evolutivos de otimização.


1. Métodos evolutivos de otimização

Os métodos de otimização evolutivos se relacionam com os métodos sem gradiente e permitem a otimização de modelos que não podem ser otimizados pelos métodos considerados anteriormente. No entanto, eles não se limitam a eles. Às vezes até se torna interessante observar o processo de treinamento de modelo pelo método evolutivo e por um dos métodos que usam o algoritmo de distribuição de gradiente de erro.

Como podemos adivinhar pelo nome, as principais ideias do método são emprestadas das ciências naturais. Em particular, são emprestadas da teoria da evolução de Darwin. De acordo com esta teoria, qualquer população de organismos vivos é fecunda o suficiente para produzir descendência e fazer crescer a população. Mas a escassez de recursos disponíveis para a vida limita o crescimento da população. E é aqui que a seleção natural tem um papel fundamental. Graças a ela o mais forte ou o mais adaptado ao meio ambiente sobrevive. Assim, com cada geração, a população se desenvolve e se adapta cada vez mais ao seu ambiente. Quando isso acontece, os membros da população desenvolvem novas propriedades e habilidades que os ajudam a sobreviver. Tudo o que não é mais relevante é esquecido.

Como podemos ver, não há absolutamente nenhuma matemática nessa descrição altamente resumida da teoria apresentada acima. Naturalmente, é possível calcular o maior tamanho possível de população com base no número total de recursos disponíveis e no consumo dos mesmos por membro da população. Entretanto, isto não afeta os princípios gerais da teoria.

E, por estranho que pareça, é esta teoria que serviu de protótipo para toda uma família de métodos evolutivos. Neste artigo vou apresentar-lhes um algoritmo de otimização genética, que é um dos algoritmos básicos dos métodos evolutivos. O algoritmo é baseado em dois postulados básicos da teoria da evolução de Darwin: hereditariedade e seleção natural.

A essência do método consiste em observar cada geração da população e selecionar o melhor de seus representantes. Mas antes de mais nada.

Como nós observamos a população como um todo, surge a necessidade de que cada geração seja transitória. Isto é, como nos algoritmos de aprendizado por reforço discutidos anteriormente, o processo deverá ter um fim. Aqui, também, usaremos as mesmas abordagens, em particular, o uso de um limite temporal de uma sessão.

Como mencionado acima, observaremos uma população inteira. Consequentemente, ao contrário dos algoritmos discutidos anteriormente, não criamos um modelo único, mas uma população inteira que "vive" sob as mesmas condições simultaneamente. O tamanho da população é um hiperparâmetro e determina a capacidade da população de compreender o ambiente. Cada membro da população realiza ações de acordo com sua política individual. Assim, quanto maior a população observada, mais diferentes políticas observamos e melhor o ambiente é estudado.

Este processo pode ser comparado à seleção aleatória repetida da ação do agente em um mesmo estado durante o aprendizado de reforço. Só que agora usamos vários agentes ao mesmo tempo, cada um deles faz uma escolha diferente.

O uso de membros independentes da população é conveniente para paralelizar o processo de otimização. Muito frequentemente, para reduzir o tempo necessário para encontrar o modelo ideal, o processo de otimização é executado em paralelo em várias máquinas, utilizando todos os recursos disponíveis. Cada membro da população "vive" em seu próprio thread de microprocessador. Já todo o processo de otimização é controlado e processado por um dispositivo ou servidor. Na qual os resultados de cada agente são avaliados e uma nova população é gerada.

Após o final da sessão de uma geração da população, a seleção natural entra em jogo. Durante este processo de seleção, os melhores indivíduos de toda a população são selecionados para gerar descendentes, a nova geração da população. A quantidade de melhores indivíduos selecionados é um hiperparâmetro e é mais frequentemente dada como uma fração do tamanho total da população.

Os critérios para selecionar os melhores representantes dependem do arquiteto do processo de otimização. Pode haver recompensas, como no aprendizado por reforço, ou uma função de perda pode ser introduzida, como no aprendizado supervisionado. Assim, selecionaremos os agentes com a recompensa máxima total ou o valor mínimo da função perda.

Notemos que não utilizamos um gradiente de erro. Portanto, a melhor função de seleção de representantes pode não ser diferenciável.

Após selecionar os pais para a futura descendência, temos que criar uma nova geração de população. Para isso, selecionamos aleatoriamente um par de modelos entre os melhores representantes selecionados para serem os pais do novo modelo. Convenha que é apenas simbólico escolher um par para criar um novo modelo.

No processo de criação de um novo modelo, todos os seus parâmetros são tratados como um cromossomo. E cada coeficiente de peso individual é um gene separado, que é herdado de um dos pais.

Os algoritmos de herança podem variar, mas todos eles são baseados em 2 regras:

  • cada gene não muda de lugar;
  • há aleatoriedade na escolha dos pais para cada gene.

E podemos selecionar aleatoriamente um pai para cada membro da população da nova geração. Também podemos criar um par de agentes de uma vez com herança de genes espelhada.

O processo é cíclico até que a nova geração da população esteja totalmente completada. Os pais previamente selecionados não fazem parte da nova geração da população e são removidos após gerada a descendência.

Para a nova geração, iniciamos uma nova sessão e o processo de otimização é repetido.

Notem que eu digo intencionalmente 'otimização' e não 'treinamento'. O processo descrito acima tem pouca semelhança com o treinamento. Isto é pura seleção natural no processo de evolução. E, como você sabe, no processo de evolução, embora não seja muito frequente, ocorrem diversas mutações, que são parte essencial do processo evolutivo. Portanto, introduziremos também um grau de incerteza em nosso processo de otimização.

Isso deve soar estranho. No processo de otimização, quase tudo é construído sobre a seleção aleatória: primeiro geramos aleatoriamente a primeira população, depois selecionamos aleatoriamente os pais e, finalmente, copiamos aleatoriamente os parâmetros dos modelos. Mas não há nenhuma novidade por trás de toda essa aleatoriedade. É graças à mutação que vamos gerar novidade.

No processo de otimização, introduzimos outro hiperparâmetro responsável pelo grau de mutação. Ele indicará a probabilidade com a qual adicionaremos (em vez de copiar) genes aleatórios à descendência criada. Em outras palavras, cada novo membro da população recebe um gene aleatório com a probabilidade do parâmetro de mutação, em vez de ser herdado dos pais. Desta forma, além da herança dos pais, algo novo será introduzido em cada nova geração. O que significa que será o mais parecido com nossa evolução/desenvolvimento.


2. Implementação usando MQL5

Depois de examinar os aspectos teóricos dos algoritmos, passamos à parte prática de nosso artigo. E vamos elaborar o algoritmo visto por meio do MQL5. Naturalmente, não há praticamente nenhuma matemática no algoritmo apresentado. Mas existe, o mais importante, um algoritmo de ação bem definido, ao qual iremos dar vida.

Devemos dizer desde já que o modelo que construímos anteriormente não é adequado para este tipo de tarefa. Ao construir nossa classe de rede neural CNet, era esperado que apenas modelos lineares únicos fossem usados. Agora temos que implementar vários modelos lineares em paralelo. E há duas maneiras de realizar essa tarefa.

A primeiro, menos demorada para o programador, mas mais intensiva em recursos consiste em simplesmente criarmos um conjunto dinâmico de objetos nos quais geramos vários modelos idênticos. E então extraímos os modelos um a um do array e os processamos um a um. Nesta versão, todo o funcionamento de cada modelo individual será elaborado dentro do âmbito da funcionalidade existente. Tudo o que nos resta fazer é implementar métodos para selecionar os pais e formar uma nova geração, bem como um processo de seleção de agentes.

As desvantagens deste método são que ele consome muitos recursos e cria uma série de objetos redundantes. Assim, para cada agente, criamos uma instância separada da classe para gerenciar o contexto OpenCL. E com isso, um contexto separado, uma cópia do programa e os objetos de todos os kernels são criados. Isto é aceitável quando se utilizam vários dispositivos de computação em paralelo. Caso contrário, a criação de objetos redundantes leva ao uso irracional de recursos e limita severamente o tamanho da população, o que tem um impacto negativo sobre os resultados do processo de otimização.

Assim, foi decidido por mãos à obra e fazer mudanças em nossa classe de modelagem de redes neurais. Entretanto, para não estragar o fluxo de trabalho, criei uma nova classe CNetGenetic com herança pública da classe CNet.

class CNetGenetic : public CNet
  {
protected:
   uint              i_PopulationSize;
   vector            v_Probability;
   vector            v_Rewards;
   matrixf           m_Weights;
   matrixf           m_WeightsConv;

   //---
   bool              CreatePopulation(void);
   int               GetAction(CBufferFloat * probability);
   bool              GetWeights(uint layer);
   float             NextGenerationWeight(matrixf &array, uint shift, vector &probability);
   float             GenerateWeight(uint total);

public:
                     CNetGenetic();
                    ~CNetGenetic();
   //---
   bool              Create(CArrayObj *Description, uint population_size);
   bool              SetPopulationSize(uint size);
   bool              feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true);
   bool              Rewards(CArrayFloat *targetVals);
   bool              NextGeneration(double quantile, double mutation, double &average, double &mamximum);
   bool              Load(string file_name, uint population_size, bool common = true);
   bool              SaveModel(string file_name, int model, bool common = true);
   //---
   bool              CopyModel(CArrayLayer *source, uint model);
   bool              Detach(void);
  };

Conheceremos o propósito dos métodos de classe à medida que elaboramos a funcionalidade. Agora vamos olhar para as variáveis:

  • i_PopulationSize — tamanho da população;
  • v_Probability — vetor das probabilidades de selecionar o modelo como o "pai";
  • v_Rewards — vetor das recompensas totais acumuladas por cada modelo individual;
  • m_Weights — array para registrar os parâmetros de todos os modelos;
  • m_WeightsConv — matriz semelhante para registrar todos os parâmetros de camadas neurais convolutivas.

No construtor da classe, nós apenas inicializamos as variáveis mostradas acima. Aqui definiremos o tamanho padrão da população e chamaremos o método para alterar as devidas variáveis.

CNetGenetic::CNetGenetic() :  i_PopulationSize(100)
  {
   SetPopulationSize(i_PopulationSize);
  }

Esta classe não utiliza instâncias de outros objetos. Portanto, o destruidor de classe permanece vazio.

Acima mencionamos o método para especificar o tamanho da população, SetPopulationSize, seu algoritmo é bastante trivial e simples. O método obtém o tamanho da população nos parâmetros. No corpo do método, armazenamos o valor resultante na devida variável, e inicializamos com valores zero do vetor de probabilidade e de recompensa.

bool CNetGenetic::SetPopulationSize(uint size)
  {
   i_PopulationSize = size;
   v_Probability = vector::Zeros(i_PopulationSize);
   v_Rewards = vector::Zeros(i_PopulationSize);
//---
   return true;
  }

Em seguida, proponho que analisemos o método de inicialização do objeto da classe Create. De forma similar a um método semelhante da classe mãe, o método recebe em seus parâmetros um ponteiro para descrição de um único agente. E acrescentamos o tamanho da população.

bool CNetGenetic::Create(CArrayObj *Description, uint population_size)
  {
   if(CheckPointer(Description) == POINTER_INVALID)
      return false;
//---
   if(!SetPopulationSize(population_size))
      return false;
   CNet::Create(Description);
   return CreatePopulation();
  }

No corpo do método, primeiro verificamos se o ponteiro recebido para o objeto da descrição da arquitetura do modelo é válido. E somente após ter sido aprovado é que chamamos o método que já conhecemos para especificar o tamanho da população.

Chamamos então um método similar da classe pai, nesse método será criado um agente baseado na descrição recebida e inicializará todos os objetos adicionais.

Finalmente, chamamos o método de criação da população CreatePopulation, nesse método é completada a população, copiando o modelo criado anteriormente. Vamos analisar mais de perto o algoritmo deste método.

No início do método, verificamos o número de camadas neurais no modelo criado. Deve haver pelo menos 2 delas.

bool CNetGenetic::CreatePopulation(void)
  {
   if(!layers || layers.Total() < 2)
      return false;

Em seguida, salvamos um ponteiro para uma variável local da camada neural de dados de entrada.

   CLayer *layer = layers.At(0);
   if(!layer || !layer.At(0))
      return false;
//---
   CNeuronBaseOCL *neuron_ocl = layer.At(0);
   int prev_count = neuron_ocl.Neurons();

Observe aqui que a primeira camada neural é usada apenas para registrar dados de entrada, e todos os agentes de nossa população trabalharão com os mesmos dados de entrada. Portanto, não faz sentido copiarmos a camada de dados de entrada com base no número de agentes da população. E a duplicação das camadas neurais é feita a partir da próxima camada neural, cujo índice é "1".

Recordemos a estrutura dos objetos de armazenamento de nossas camadas neurais. A classe responsável por garantir o funcionamento do modelo no nível superior é CNet. Ela contém uma instância do objeto do array dinâmico de camadas neurais CArrayLayer. Neste array dinâmico, armazenamos apontadores para objetos de arrays dinâmicos aninhados diretamente na camada neural CLayer. E já nele escrevemos apontadores para objetos de neurônios CNeuronBaseOCL e outros.

CNet -> CArrayLayer -> CLayer -> CNeuronBaseOCL

Gostaria de lembrar que essa estrutura foi inicialmente criada ao elaborar o processo de computação usando MQL5 com em CPU. Naquela altura, cada neurônio individual era um objeto separado. Posteriormente, ao migrar a computação para a GPU utilizando tecnologia OpenCL fomos forçados a mudar para o uso de buffers de dados. E, essencialmente, cada camada neural se tornou expressa em um único neurônio CNeuronBaseOCL que desempenhava a função da camada neural. O mesmo vale para o uso de outros tipos de neurônios.

Assim, cada objeto da camada neural CLayer agora contém apenas um objeto do neurônio. Anteriormente, não alterávamos a arquitetura de armazenamento para manter a compatibilidade com os avanços anteriores. Agora isto nos servirá bem de outra maneira. Nós simplesmente adicionamos o número necessário de objetos ao array dinâmico CLayer para armazenar toda a população de nossos agentes. Assim, dentro de um único modelo, obteremos objetos paralelos das camadas neurais de todos os agentes em nossa população. E só precisaremos elaborar seu funcionamento de acordo com o respectivo índice do agente.

Seguindo esta lógica, preparamos então um loop de duplicação de camadas neurais. Com ele, iteraremos sequencialmente todas as camadas neurais de nosso modelo e adicionaremos o número necessário de neurônios, semelhantes ao primeiro neurônio criado anteriormente em cada camada.

No corpo do loop, primeiro verificamos se o ponteiro para a camada neural previamente criada é válido.

   for(int i = 1; i < layers.Total(); i++)
     {
      layer = layers.At(i);
      if(!layer || !layer.At(0))
         return false;
      //---

Em seguida, obtemos uma descrição da arquitetura do neurônio.

      neuron_ocl = layer.At(0);
      CLayerDescription *desc = neuron_ocl.GetLayerInfo();
      int outputs = neuron_ocl.getConnections();

E criamos objetos similares, aumentando a camada neural até o tamanho da população necessária. Para isso, criamos outro loop aninhado.

      for(uint n = layer.Total(); n < i_PopulationSize; n++)
        {
         CNeuronConvOCL *neuron_conv_ocl = NULL;
         CNeuronProofOCL *neuron_proof_ocl = NULL;
         CNeuronAttentionOCL *neuron_attention_ocl = NULL;
         CNeuronMLMHAttentionOCL *neuron_mlattention_ocl = NULL;
         CNeuronDropoutOCL *dropout = NULL;
         CNeuronBatchNormOCL *batch = NULL;
         CVAE *vae = NULL;
         CNeuronLSTMOCL *lstm = NULL;
         switch(layer.At(0).Type())
           {
            case defNeuron:
            case defNeuronBaseOCL:
               neuron_ocl = new CNeuronBaseOCL();
               if(CheckPointer(neuron_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_ocl.Init(outputs, n, opencl, desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_ocl;
                  return false;
                 }
               neuron_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_ocl))
                 {
                  delete neuron_ocl;
                  return false;
                 }
               neuron_ocl = NULL;
               break;
            case defNeuronConvOCL:
               neuron_conv_ocl = new CNeuronConvOCL();
               if(CheckPointer(neuron_conv_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_conv_ocl.Init(outputs, n, opencl, desc.window, desc.step, desc.window_out,
                                                           desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_conv_ocl;
                  return false;
                 }
               neuron_conv_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_conv_ocl))
                 {
                  delete neuron_conv_ocl;
                  return false;
                 }
               neuron_conv_ocl = NULL;
               break;
            case defNeuronProofOCL:
               neuron_proof_ocl = new CNeuronProofOCL();
               if(!neuron_proof_ocl)
                  return false;
               if(!neuron_proof_ocl.Init(outputs, n, opencl, desc.window, desc.step, desc.count,
                                                                   desc.optimization, desc.batch))
                 {
                  delete neuron_proof_ocl;
                  return false;
                 }
               neuron_proof_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_proof_ocl))
                 {
                  delete neuron_proof_ocl;
                  return false;
                 }
               neuron_proof_ocl = NULL;
               break;
            case defNeuronAttentionOCL:
               neuron_attention_ocl = new CNeuronAttentionOCL();
               if(CheckPointer(neuron_attention_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_attention_ocl.Init(outputs, n, opencl, desc.window, desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_attention_ocl))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl = NULL;
               break;
            case defNeuronMHAttentionOCL:
               neuron_attention_ocl = new CNeuronMHAttentionOCL();
               if(CheckPointer(neuron_attention_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_attention_ocl.Init(outputs, n, opencl, desc.window, desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_attention_ocl))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl = NULL;
               break;
            case defNeuronMLMHAttentionOCL:
               neuron_mlattention_ocl = new CNeuronMLMHAttentionOCL();
               if(CheckPointer(neuron_mlattention_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_mlattention_ocl.Init(outputs, n, opencl, desc.window, desc.window_out,
                                               desc.step, desc.count, desc.layers, desc.optimization, desc.batch))
                 {
                  delete neuron_mlattention_ocl;
                  return false;
                 }
               neuron_mlattention_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_mlattention_ocl))
                 {
                  delete neuron_mlattention_ocl;
                  return false;
                 }
               neuron_mlattention_ocl = NULL;
               break;

O algoritmo para adicionar objetos é o mesmo que para criar um novo objeto na classe pai.

Após adicionar todos os elementos da população de uma camada neural, alinhamos o tamanho da camada com o tamanho da população e removemos o objeto de descrição do neurônio.

        }
      if(layer.Total() > (int)i_PopulationSize)
         layer.Resize(i_PopulationSize);
      delete desc;
     }
//---
   return true;
  }

Após completar todas as iterações, teremos uma população completa dentro de nossa única instância de modelo e sairemos do método com um resultado positivo.

O código completo para este método e toda a classe pode ser encontrado no anexo ao artigo.

Após termos terminado de trabalhar com métodos de inicialização do objeto da classe CNetGenetic passamos a descrever o método de propagação. Seu nome e parâmetros são os mesmos que os do método da classe pai. Aqui podemos ver um ponteiro para um objeto de array dinâmico de dados de entrada, bem como os parâmetros para a criação dos rótulos de tempo dos dados de entrada.

No corpo do método vamos verificar se o ponteiro resultante e os objetos internos utilizados são válidos.

bool CNetGenetic::feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true)
  {
   if(CheckPointer(layers) == POINTER_INVALID || CheckPointer(inputVals) == POINTER_INVALID || layers.Total() <= 1)
      return false;

E vamos preparar as variáveis locais.

   CLayer *previous = NULL;
   CLayer *current = layers.At(0);
   int total = MathMin(current.Total(), inputVals.Total());
   CNeuronBase *neuron = NULL;
   if(CheckPointer(opencl) == POINTER_INVALID)
      return false;
   CNeuronBaseOCL *neuron_ocl = current.At(0);
   CBufferFloat *inputs = neuron_ocl.getOutput();
   int total_data = inputVals.Total();
   if(!inputs.Resize(total_data))
      return false;

Vamos transferir os dados de entrada para o buffer da camada neural de dados de entrada e registrá-los no contexto OpenCL. Ao mesmo tempo, se necessário, adicionamos os rótulos de tempo.

   for(int d = 0; d < total_data; d++)
     {
      int pos = d;
      int dim = 0;
      if(window > 1)
        {
         dim = d % window;
         pos = (d - dim) / window;
        }
      float value = pos / pow(10000, (2 * dim + 1) / (float)(window + 1));
      value = (float)(tem ? (dim % 2 == 0 ? sin(value) : cos(value)) : 0);
      value += inputVals.At(d);
      if(!inputs.Update(d, value))
         return false;
     }
   if(!inputs.BufferWrite())
      return false;

Em seguida, preparamos um sistema de loops para fazer a propagação com todos os agentes da população que está sendo analisada. O loop externo irá percorrer as camadas neurais em ordem ascendente. E o loop aninhado irá iterar sobre os agentes.

Observe que ao especificar o neurônio da camada anterior, devemos controlar claramente que os agentes correspondam. Cada agente opera em sua própria vertical de neurônios, que é determinada pelo número que indica a posição do neurônio na camada. Mas, ao fazer o controlo, não duplicamos a camada de dados de entrada. Por isso, ao especificar o índice do neurônio correspondente da camada anterior, primeiro verificamos o número que indica a posição da própria camada neural. E para a camada de dados de entrada, o número que indica a posição do neurônio da camada anterior será sempre "0". Já para todas as camadas subsequentes, ele corresponderá à posição do agente.

Como todos os agentes são completamente independentes, podemos realizar operações para todos os agentes ao mesmo tempo.

   for(int l = 1; l < layers.Total(); l++)
     {
      previous = current;
      current = layers.At(l);
      if(CheckPointer(current) == POINTER_INVALID)
         return false;
      //---
      for(uint n = 0; n < i_PopulationSize; n++)
        {
         CNeuronBaseOCL *current_ocl = current.At(n);
         if(!current_ocl.FeedForward(previous.At(l == 1 ? 0 : n)))
            return false;
         continue;
        }
     }
//---
   return true;
  }

Naturalmente, o uso de um loop não dá total paralelismo aos cálculos. Mas, também vamos iterar sequencialmente cada operação, para todos os agentes. Isto nos permitirá aproveitar os dados de entrada, uma vez gerados, para todos os agentes. E assim reduzimos o custo de preparação dos dados de entrada para cada agente de maneira individual.

E, claro, não esquecemos de controlar o processo de execução das operações em cada etapa. E quando as iterações do sistema de loop aninhado estiverem completas, sairemos do método.

O algoritmo genético não oferece retropropagação com distribuição de gradiente de erro. No entanto, precisamos avaliar o desempenho dos modelos. Neste artigo, vou otimizar o agente do artigo anterior, que treinamos com o algoritmo de gradiente de política. E para otimizar o desempenho do modelo, vamos maximizar a recompensa total do modelo por sessão. Consequentemente, após a ação que se segue, devemos devolver a cada agente sua recompensa. Como você deve se lembrar, a recompensa depende da ação escolhida. E cada agente realiza uma ação diferente. Anteriormente, o agente costumava nos dar uma distribuição da probabilidade de ele tomar uma determinação ação, amostrávamos uma ação a partir da distribuição resultante e devolvíamos a recompensa apropriada ao agente. Temos muitos agentes desse tipo agora. E para evitar repetir estas iterações para cada agente individual no programa externo, nós simplesmente envolvemos tudo em um método separado Rewards. Nos parâmetros desse método, um programa externo (ambiente) passará a recompensa para todas as ações possíveis. Esta abordagem nos permite avaliar cada ação apenas uma vez, independentemente do número de agentes utilizados.

No corpo do método, primeiro verificamos se os ponteiros para o vetor de recompensas e o array dinâmico de nossas camadas neurais obtidas nos parâmetros são válidos.

bool CNetGenetic::Rewards(CArrayFloat *rewards)
  {
   if(!rewards || !layers || layers.Total() < 2)
      return false;

Em seguida, recuperamos do array dinâmico o ponteiro para a camada de resultados dos agentes e verificamos imediatamente se o ponteiro recuperado é válido.

   CLayer *output = layers.At(layers.Total() - 1);
   if(!output)
      return false;

Preparamos então um loop sobre todos os agentes de nossa população, para selecionar e sondar. Para cada agente, amostramos uma ação a partir da distribuição correspondente. Dependendo da ação selecionada, o agente recebe sua recompensa, que é somada àquelas recebidas anteriormente no vetor v_Rewards sob o índice do agente.

   for(int i = 0; i < output.Total(); i++)
     {
      CNeuronBaseOCL *neuron = output.At(i);
      if(!neuron)
         return false;
      int action = GetAction(neuron.getOutput());
      if(action < 0)
         return false;
      v_Rewards[i] += rewards.At(action);
     }

A partir dos resultados da avaliação dos agentes, podemos construir uma distribuição de probabilidade de os agentes serem selecionados como os pais da próxima geração.

   v_Probability = v_Rewards - v_Rewards.Min();
   if(!v_Probability.Clip(0, v_Probability.Max()))
      return false;
   v_Probability = v_Probability / v_Probability.Sum();
//---
   return true;
  }

E saímos do método com um resultado positivo. E você pode encontrar no anexo o código completo de todos os métodos e classes utilizados.

A funcionalidade criada é suficiente para gerar cada sessão individual para a população analisada e para avaliar as ações dos agentes. Mas uma vez terminada a sessão, precisamos selecionar os melhores representantes e gerar uma nova geração de nossa população. Nós elaboramos esta funcionalidade no método NextGeneration. Nos parâmetros deste método passaremos 2 hiperparâmetros: a proporção de indivíduos a serem eliminados e o parâmetro de mutação. Além disso, os parâmetros do método contêm 2 variáveis nas quais retornaremos a recompensa média e máxima dos agentes selecionados.

No corpo do método, primeiro zeramos as probabilidades de seleção dos agentes que não estão entre os selecionados. E calculamos imediatamente a recompensa máxima e a média ponderada para os candidatos selecionados.

bool CNetGenetic::NextGeneration(double quantile, double mutation, double &average, double &maximum)
  {
   maximum = v_Rewards.Max();
   v_Probability = v_Rewards - v_Rewards.Quantile(quantile);
   if(!v_Probability.Clip(0, v_Probability.Max()))
      return false;
   v_Probability = v_Probability / v_Probability.Sum();
   average = v_Rewards.Average(v_Probability);

Note que estamos usando as operações vetoriais recém-adicionadas aqui. Isto nos permite eliminar o uso de loops e encurtar nosso código de programa. O método vector::Max() permite definir o valor máximo de um vetor inteiro em uma linha de código. O método vector::Quantile(...) retorna o valor do quantil especificado para o vetor. Usamos este valor para selecionar agentes fracos. E após uma operação vetorial de subtração, suas probabilidades se tornarão negativas.

Ao utilizar funções vector::Clip(0, vector::Max()) anulamos todos os valores negativos do vetor.

E também elegantemente, dentro de uma única linha, normalizamos todos os valores vetoriais entre 0 e 1 com o valor total de todos os elementos igual a 1.

v_Probability = v_Probability / v_Probability.Sum();

E a operação vector::Average(weights) é usada para determinar o valor médio ponderado de um vetor. O vetor weights contém os pesos de cada elemento vetorial. Acima zeramos as probabilidades dos agentes fracos, portanto, seus valores não serão levados em conta no cálculo da média vetorial ponderada.

Assim, o uso de operações vetoriais reduz muito o código do programa e torna o trabalho do programador mais fácil. Agradecimentos especiais à equipe da MetaQuotes. A seção Documentação o ajudará a aprender mais sobre operações matriciais e vetoriais.

Mas voltando ao nosso método. Identificamos os candidatos e suas probabilidades. Agora adicionamos a proporção de mutações à distribuição e recalculamos as probabilidades.

   if(!v_Probability.Resize(i_PopulationSize + 1))
      return false;
   v_Probability[i_PopulationSize] = mutation;
   v_Probability = (v_Probability / (1 + mutation)).CumSum();

Nesta fase, temos uma distribuição de probabilidade do uso de agentes como pais da próxima geração. E podemos proceder diretamente à geração de uma nova população. Para isso, criaremos um loop para gerarmos cada camada neural da nova população. É importante dizer que em cada nível da camada neural vamos gerar as matrizes de peso de todos os agentes de vez. E assim por diante, camada por camada.

Mas para evitar a criação de novos objetos, simplesmente sobrescreveremos as matrizes de peso dos agentes existentes. Assim, antes de atualizar os pesos da próxima camada neural, primeiro chamamos o método GetWeights que copia os parâmetros da camada neural atual de todos os agentes para as matrizes m_Weights e m_WeightsConv especialmente criadas. Aqui só são indicadas as matrizes de peso das camadas convolucionais e das completamente conectadas, pois são as únicas utilizadas na arquitetura do modelo otimizado. Se forem usados outros tipos de arquitetura de camadas neurais, será necessário adicionar os respectivoss arrays para armazenar temporariamente os parâmetros.

   for(int l = 1; l < layers.Total(); l++)
     {
      if(!GetWeights(l))
        {
         PrintFormat("Error of load weights from layer %d", l);
         return false;
        }

Uma vez que tenhamos uma cópia dos parâmetros do modelo, podemos começar a editar de maneira segura os parâmetros nos objetos. Primeiramente, obtemos o ponteiro para o objeto da camada neural. E depois preparamos um loop aninhado para iterar sobre todos os nossos agentes. Ele servirá para recuperarmos o ponteiro para a matriz de peso do respectivo agente.

      CLayer* layer = layers.At(l);
      for(uint i = 0; i < i_PopulationSize; i++)
        {
         CNeuronBaseOCL* neuron = layer.At(i);
         CBufferFloat* weights = neuron.getWeights();

E, caso o ponteiro resultante seja válido, efetuamos outro loop aninhado para iterar sobre todos os elementos da matriz de peso e os substituiremos pelos parâmetros parentais correspondentes.

         if(!!weights)
           {
            for(int w = 0; w < weights.Total(); w++)
               if(!weights.Update(w, NextGenerationWeight(m_Weights, w, v_Probability)))
                 {
                  Print("Error of update weights");
                  return false;
                 }
            weights.BufferWrite();
           }

Aqui é importante dizer que nos afastamos um pouco em relação ao algoritmo básico. Nós não nos demos ao trabalho de extrair um casal de pais ao acaso. Em vez disso, tomaremos pesos aleatórios de todos os agentes selecionados de uma só vez, de acordo com sua distribuição de probabilidade. A amostragem dos pesos é feita no método NextGenerationWeight.

Após gerar os valores do próximo buffer de dados, copiamos seus valores para o contexto OpenCL.

Se necessário, repetimos as operações para a matriz da camada convolucional.

         if(neuron.Type() != defNeuronConvOCL)
            continue;
         CNeuronConvOCL* temp = neuron;
         weights = temp.GetWeightsConv();
         for(int w = 0; w < weights.Total(); w++)
            if(!weights.Update(w, NextGenerationWeight(m_WeightsConv, w, v_Probability)))
              {
               Print("Error of update weights");
               return false;
              }
         weights.BufferWrite();
        }
     }

Após atualizar os parâmetros de todos os agentes, zeramos o valor do vetor de acumulação de recompensa para determinar corretamente o rendimento da nova geração e saímos do método com um resultado positivo.

   v_Rewards.Fill(0);
//---
   return true;
  }

Analisamos o algoritmo dos métodos básicos da classe que formam a base para elaborar o algoritmo genético. No entanto, existem também alguns métodos auxiliares, cujo algoritmo não é complicado, e você pode ler sobre eles no anexo. Entretanto, gostaria também de chamar sua atenção para o método para salvar o modelo. A questão é que o método de salvamento da classe pai salvará todos os agentes. E pode ser utilizado para uma posterior continuação da otimização. Mas não é aplicável ao salvamento de um agente tomado individualmente. Afinal, o objetivo da otimização é encontrar o agente ideal. Portanto, para salvar o melhor agente, vamos criar o método SaveModel. Nos parâmetros do método, passaremos o nome do arquivo para salvar o modelo, o número que indica a posição do agente e o sinalizador de gravação no diretório Common.

No corpo do método, primeiro verificamos o número que indica a posição do agente. Se ele não satisfaz o número de agentes ativos, nós o substituímos pelo número do agente que tem a maior probabilidade. Pois ele é o agente com rentabilidade máxima.

bool CNetGenetic::SaveModel(string file_name, int model, bool common = true)
  {
   if(model < 0 || model >= (int)i_PopulationSize)
      model = (int)v_Probability.ArgMax();

Em seguida, criamos uma instância do objeto do novo modelo e copiamos nela os parâmetros do modelo necessário.

   CNetGenetic *new_model = new CNetGenetic();
   if(!new_model)
      return false;
   if(!new_model.CopyModel(layers, model))
     {
      new_model.Detach();
      delete new_model;
      return false;
     }

Agora podemos simplesmente chamar o método de salvamento da classe pai para o novo modelo.

   bool result = new_model.Save(file_name, 0, 0, 0, 0, common);

Após salvar o modelo, devemos apagar o objeto recém-criado antes de sair do método. Entretanto, ao copiar dados, em vez de criarmos novos objetos da camada neural, simplesmente utilizamos ponteiros para eles. Por isso, quando apagamos o objeto do modelo, apagaremos também todos os objetos do agente armazenados em nosso modelo geral. Para evitar que isto aconteça, usaremos primeiro o método Detach, para que os objetos da camada neural sejam desafixados do modelo armazenado. Depois disso, podemos apagar de maneira segura o objeto do modelo criado neste método.

   new_model.Detach();
   delete new_model;
//---
   return result;
  }

O código completo para todos os métodos desta classe pode ser encontrado no anexo. Agora vamos criar o Expert Advisor "Genetic.mq5", nele prepararemos o processo de otimização do modelo. Criamos um novo EA com base no "Actor_Critic.mq5" do artigo anterior.

Nos parâmetros externos do EA, vamos adicionar hiperparâmetros para elaborar o novo processo.

input int                  PopulationSize =  50;
input int                  Generations = 1000;
input double               Quantile =  0.5;
input double               Mutation = 0.01;

Também substituímos o objeto de trabalho do modelo.

CNetGenetic          Models;

A inicialização do modelo no EA é feira de forma semelhante à inicialização do modelo pai nos EAs discutidos anteriormente.

int OnInit()
  {
//---
.............
.............
//---
   if(!Models.Load(MODEL + ".nnw", PopulationSize, false))
      return INIT_FAILED;
//---
   if(!Models.GetLayerOutput(0, TempData))
      return INIT_FAILED;
   HistoryBars = TempData.Total() / 12;
   Models.getResults(TempData);
   if(TempData.Total() != Actions)
      return INIT_PARAMETERS_INCORRECT;
//---
   bEventStudy = EventChartCustom(ChartID(), 1, 0, 0, "Init");
//---
   return(INIT_SUCCEEDED);
  }

O processo de otimização real é, como sempre, elaborado na função Train. No início da função, semelhante aos EAs discutidos anteriormente, definimos um período de otimização (de aprendizado).

void Train(void)
  {
//---
   MqlDateTime start_time;
   TimeCurrent(start_time);
   start_time.year -= StudyPeriod;
   if(start_time.year <= 0)
      start_time.year = 1900;
   datetime st_time = StructToTime(start_time);

E carregamos a amostra de treinamento.

   int bars = CopyRates(Symb.Name(), TimeFrame, st_time, TimeCurrent(), Rates);
   if(!RSI.BufferResize(bars) || !CCI.BufferResize(bars) || !ATR.BufferResize(bars) || !MACD.BufferResize(bars))
     {
      ExpertRemove();
      return;
     }
   if(!ArraySetAsSeries(Rates, true))
     {
      ExpertRemove();
      return;
     }
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();

Após a geração dos dados de entrada, prepararemos as variáveis locais. Ao fazer isso, excluímos o último mês da amostra de treinamento para testar o desempenho do modelo otimizado com os novos dados.

   CBufferFloat* State = new CBufferFloat();
   float loss = 0;
   uint count = 0;
   uint total = bars - HistoryBars - 1;
   ulong ticks = GetTickCount64();
   uint test_size=22*24;

Em seguida, criamos um sistema de loops aninhados para elaborar o processo de otimização. O loop externo é responsável pela contagem das gerações de otimização. Em um loop aninhado, vamos contar as iterações da otimização. Neste caso, fiz uma iteração completa da amostra de treinamento por todos os agentes. Entretanto, você pode usar a amostragem aleatória para reduzir o tempo por sessão. Basta ter o cuidado de avaliar as principais tendências na amostra de treinamento. É claro que, nesse caso, a precisão da otimização pode vir a diminuir. Mas aqui é importante encontrar um equilíbrio entre a precisão dos resultados e o custo da otimização do modelo.

   for(int gen = 0; (gen < Generations && !IsStopped()); gen ++)
     {
      for(uint i = total; i > test_size; i--)
        {
         uint r = i + HistoryBars;
         if(r > (uint)bars)
            continue;

No corpo do loop aninhado, definimos os limites do padrão atual e criamos um buffer de dados inicial.

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

E chamamos o método de propagação para nossa população otimizada.

         if(!Models.feedForward(State, 12, true))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

Como se pode ver, este processo é praticamente o mesmo que ao treinar o modelo anterior. Afinal, toda a diferença nos processos é realizada nas bibliotecas. Já a interface dos métodos permanece inalterada. E agora chamamos a propagação para um modelo. E o corpo da classe CNetGenetic contém uma propagação para todos os agentes ativos da população.

A seguir, temos que entregar a recompensa atual aos agentes. Como mencionado acima, não sondaremos todos os agentes aqui. Em vez disso, criaremos um buffer no qual especificaremos a recompensa para cada ação em um determinado estado. E passaremos este buffer nos parâmetros do próximo método. 

         double reward = Rates[i - 1].close - Rates[i - 1].open;
         TempData.Clear();
         if(!TempData.Add((float)(reward < 0 ? 20 * reward : reward)) ||
            !TempData.Add((float)(reward > 0 ? -reward * 20 : -reward)) ||
            !TempData.Add((float) - fabs(reward)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         if(!Models.Rewards(TempData))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

Utilizamos a política de remuneração em sua forma original, sem alterações. Isto nos permitirá avaliar exatamente o impacto do processo de otimização sobre o resultado geral.

Uma vez concluído loop para o processamento de um estado do sistema, mostraremos suas informações de iteração no gráfico e procederemos à próxima iteração do loop.

         if(GetTickCount64() - ticks > 250)
           {
            uint x = total - i;
            double perc = x * 100.0 / (total - test_size);
            Comment(StringFormat("%d from %d -> %.2f%% from %.2f%%", x, total - test_size, perc, 100));
            ticks = GetTickCount64();
           }
        }

Quando a próxima sessão terminar, salvaremos os parâmetros do melhor agente.

      Models.SaveModel(MODEL+".nnw", -1, false);

E procedemos à criação da próxima geração. Para isso, basta chamarmos um único método CNetGenetic::NextGeneration. Ao fazer isto, não nos esquecemos de controlar o processo de execução das operações.

      double average, maximum;
      if(!Models.NextGeneration(Quantile, Mutation, average, maximum))
        {
         PrintFormat("Error of create next generation: %d", GetLastError());
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
      //---
      PrintFormat("Genegation %d, Average Cummulative reward %.5f, Max Reward %.5f", gen, average, maximum);
     }

Finalmente, registraremos informações sobre os resultados alcançados e avaliamos a nova geração da população por meio de um novo loop.

Uma vez que o processo de otimização tenha sido concluído, limparemos os dados e encerraremos o EA.

   delete State;
   Comment("");
//---
   ExpertRemove();
  }

Como se pode ver, esta maneira de elaborar a classe facilitou ao máximo o trabalho no lado do programa principal. Na prática, a elaboração do processo de otimização consiste em chamadas sucessivas a 3 métodos da classe. O que é comparável ao treinamento de modelos quando se utilizam métodos de gradiente. Ao fazer isso, reduzimos significativamente o número total de operações por agente.


3. Teste

Os testes do processo de otimização foram realizados mantendo todos os parâmetros utilizados anteriormente. A amostra de treinamento for retirada do histórico do instrumento EURUSD, H1. O histórico dos últimos 2 anos foi usado para o processo de otimização. Todos os parâmetros externos do EA foram usados por padrão. Como modelo para teste, tomamos a arquitetura do artigo anterior que inclui a busca da distribuição de probabilidade ótima a nível de tomada de decisão. Esta abordagem nos permite colocar o modelo otimizado no EA "REINFORCE-test.mq5" usado anteriormente. Como se pode ver, esta é a terceira abordagem no processo de treinamento do modelo de arquitetura única. Treinamos anteriormente um modelo similar com os algoritmos Policy Gradient e Actor-Critic. É ainda mais interessante observar os resultados da otimização.

Como você deve se lembrar, não usamos os dados do último mês para otimizar o modelo. Isto deixa poucos dados para testar o modelo otimizado. Após executar o modelo otimizado no testador de estratégia com os dados do último mês, temos o seguinte resultado.

Gráfico de teste do modelo otimizado

Como pode ser visto no gráfico apresentado, obtivemos um gráfico crescente. Mas sua rentabilidade é um pouco menor do que a obtida através do treinamento de um modelo similar utilizando o método Ator-Crítico. Ao mesmo tempo, também podemos ver uma diminuição no número de negócios. De fato, o número de negócios diminuiu pela metade.

Gráfico com o histórico de negociação do modelo

Se você olhar para o gráfico do instrumento com os negócios realizados, você pode ver uma clara tentativa de negociar ao longo da tendência. Acho isto um resultado interessante, já que, ao treinar um modelo similar com métodos de gradiente, ele tentou fazer um negócio na maioria dos movimentos. E muitas vezes isso teve uma aparência caótica. Aqui podemos ver uma certa lógica que ressoa com os bem conhecidos princípios da negociação.

Ou sou só eu? E todas as minhas conclusões carecem de argumentos? Faça suas próprias experiências e será interessante observar os resultados.

Tabela de resultados de teste

Em geral, vemos um aumento na participação de negócios lucrativos de quase 1,5% em comparação com o mesmo teste do modelo de treinamento do Ator-Crítico. Mas, ao mesmo tempo, o número de negócios foi reduzido em duas vezes. Além disso, vemos também uma diminuição no lucro e no prejuízo médio por negócio. Tudo isso leva a uma diminuição geral do volume de negócios e, com ele, da rentabilidade total para o período. Entretanto, vale notar que os testes de 1 mês não podem ser julgados como decentes para um EA em uma perspectiva de longo prazo. Portanto, mais uma vez, peço-lhes que testem seus modelos completa e exaustivamente antes de usá-los para a negociação real.


Considerações finais

Neste artigo, fomos iniciados no método genético de otimização de modelos. Ele pode ser usado para otimizar qualquer modelo paramétrico. Uma das principais vantagens deste método é que ele pode ser usado para otimizar modelos indiferenciados. Isto é absolutamente impossível quando se trata de treinar modelos com métodos de gradiente e, em particular, com o método de descida de gradiente em todas as suas variações.

O artigo também propõe uma variante de elaboração de algoritmos por meio de MQL5. Nós até otimizamos o modelo de teste e olhamos para seus resultados no testador de estratégia.

Com base nos resultados dos testes, pode-se dizer que o modelo foi executado decentemente. E o método pode ser usado para otimizar padrões de negociação. Mas antes de por o modelo a andar em uma conta real, ele deve ser testado minuciosamente e de forma abrangente.

Referências

  1. Redes neurais de maneira fácil (Parte 26): aprendizado por reforço
  2. Redes neurais de maneira fácil (Parte 27): aprendizado Q profundo (DQN)
  3. Redes neurais de maneira fácil (Parte 28): algoritmo de gradiente de política
  4. Redes neurais de maneira fácil (Parte 29): algoritmo ator-crítico de vantagem (advantage actor-critic)

Programas utilizados no artigo

# Nome Tipo Descrição
1 Genetic.mq5 EA EA para otimização de modelos
2 NetGenetic.mqh Biblioteca de classe
Biblioteca para elaborar algoritmo genético
3 REINFORCE-test.mq5 EA
EA para prova do modelo no testador de estratégia
4 NeuroNet.mqh Biblioteca de classes Biblioteca para preparar modelos de redes neurais
5 NeuroNet.cl Biblioteca
Biblioteca de código OpenCL para manusear modelos de redes neurais


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

Arquivos anexados |
MQL5.zip (680.14 KB)
DoEasy. Controles (Parte 20): Objeto WinForms SplitContainer DoEasy. Controles (Parte 20): Objeto WinForms SplitContainer
Hoje começaremos a desenvolver o controle SplitContainer a partir da caixa de ferramentas do MS Visual Studio. Este elemento consiste em dois painéis separados por um separador móvel vertical ou horizontal.
DoEasy. Controles (Parte 19): Rolagem de guias no elemento TabControl, eventos de objetos WinForms DoEasy. Controles (Parte 19): Rolagem de guias no elemento TabControl, eventos de objetos WinForms
Neste artigo, veremos como podemos criar uma funcionalidade para a rolagem dos cabeçalhos das guias no controle TabControl por meio de botões de rolagem. Essa funcionalidade organizará os cabeçalhos das guias em uma única linha em ambos os lados do controle.
DoEasy. Controles (Parte 21): O controle SplitContainer. Separador de painéis DoEasy. Controles (Parte 21): O controle SplitContainer. Separador de painéis
Neste artigo, criaremos uma classe do objeto separador de painéis auxiliar para o controle SplitContainer.
Aprendendo a construindo um Expert Advisor que opera de forma automática (Parte 10): Automação (II) Aprendendo a construindo um Expert Advisor que opera de forma automática (Parte 10): Automação (II)
Automação não é nada sem que você consiga controlar o horário. Nenhum trabalhador consegue ser eficiente trabalhando 24 horas. No entanto, muitos acreditam que um sistema automático deva trabalhar 24 horas. Mas é sempre bom que você tenha meios de configurar um range de horário para o Expert Advisor. Neste artigo iremos tratar disto. Como adicionar adequadamente uma faixa de horário.