English Русский 中文 Español Deutsch 日本語
preview
Redes neurais de maneira fácil (Parte 37): atenção esparsa

Redes neurais de maneira fácil (Parte 37): atenção esparsa

MetaTrader 5Integração | 20 julho 2023, 10:06
369 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introdução

No artigo anterior, abordamos modelos relacionais que usavam mecanismos de atenção em sua arquitetura. Usamos esse tipo de modelo e criamos um Expert Advisor, que apresentou bons resultados. No entanto, notamos que a velocidade de aprendizado do modelo diminuiu em comparação com os experimentos anteriores. Isso aconteceu porque o bloco do transformador usado no modelo já era uma solução estrutural bastante complexa, que realizava um grande número de operações. O número dessas operações cresce em progressão quadrática à medida que o tamanho da sequência analisada aumenta, o que leva a um aumento no consumo de memória e no tempo de treinamento do modelo.

No entanto, sabemos que os recursos disponíveis para o aprimoramento do modelo são limitados. Por isso, é necessário otimizar o modelo com perda mínima de qualidade de desempenho.

1. Atenção esparsa

Quando falamos em otimizar o desempenho do modelo, em primeiro lugar, devemos prestar atenção aos seus hiperparâmetros. Eles devem ser escolhidos de forma ideal, levando em conta os recursos consumidos e a qualidade do desempenho do modelo. Aumentar o número de neurônios em uma camada após um determinado patamar praticamente não melhora a qualidade do desempenho do modelo, e o mesmo pode ser dito sobre o número de camadas de neurônios. Entretanto, a determinação dos hiperparâmetros ideais depende da tarefa específica e de sua complexidade.

Tudo isso também se aplica ao número de cabeças de atenção no bloco Self-Attention multicabeça. Às vezes, duas cabeças são suficientes para obter bons resultados, mas esse não é o valor ideal para todo tipo de tarefa. Até porque todos os hiperparâmetros devem ser selecionados experimentalmente para cada tarefa específica e arquitetura de modelo.

Este artigo se concentra na discussão de abordagens estruturais para reduzir o número de operações no bloco Self-Attention. Entretanto, antes de prosseguir com a otimização do algoritmo, é importante relembrar como funciona o bloco Self-Attention.

Primeiro, são calculadas três entidades: Query (consulta), Key (chave) e Value (valor) para cada elemento da sequência. Isso é feito multiplicando o vetor que descreve o elemento da sequência pela matriz de ponderação correspondente. Em seguida, multiplicamos a matriz Query pela matriz Key transposta para obter os coeficientes das dependências entre os elementos da sequência. Esses coeficientes são então normalizados usando a função SoftMax.

Query * Key

Score

Depois de normalizar os coeficientes de dependência, nós os multiplicamos pela matriz de entidades Value para obter valores de saída para cada elemento da sequência. Esses valores de saída são somas ponderadas de valores de elementos que levam em conta a importância de cada elemento no contexto da tarefa.

Out Self-Attention

O aumento do número de elementos da sequência leva a um aumento na complexidade computacional das operações em algoritmos que usam mecanismos de atenção. Isso se deve ao fato de que, em cada etapa, as operações de computação de entidades, multiplicação de matrizes e normalização de coeficientes de dependência são realizadas para cada elemento da sequência.

No caso de um grande número de elementos de sequência, isso pode levar a um aumento significativo no tempo de computação e nos recursos computacionais. Para otimizar o algoritmo e reduzir o número de cálculos em cada etapa, é possível aplicar vários métodos, inclusive a atenção esparsa (sparce attention). Esse método foi proposto por Rewon Child no artigo "Generating Long Sequences with Sparse Transformers" (Gerando sequências longas com transformadores esparsos), publicado em abril de 2019.

A atenção esparsa (sparse attention) é uma abordagem para otimizar o mecanismo de atenção que reduz o número de cálculos necessários para processar os elementos de uma sequência.

O método consiste em considerar apenas os elementos mais importantes de uma sequência ao calcular os coeficientes de atenção entre eles. Assim, em vez de calcular os coeficientes de atenção para todos os pares de elementos da sequência, selecionamos apenas os pares mais significativos.

Uma das vantagens do método de atenção esparsa é que ele pode reduzir significativamente o número de cálculos necessários para processar os elementos da sequência. Isso é especialmente importante no caso do processamento de sequências grandes, em que o número de cálculos pode ser enorme.

Além disso, a atenção esparsa pode ajudar a contornar o problema da "atenção em tudo", quando o mecanismo de atenção distribui uniformemente a atenção a todos os elementos da sequência, o que leva ao uso ineficiente de recursos e torna o algoritmo mais lento.

A atenção esparsa pode ser implementada de várias maneiras. Uma delas consiste em dividir a sequência em blocos e calcular a atenção somente entre os elementos dentro de cada bloco e entre os elementos de blocos diferentes. Ao fazer isso, para reduzir o número de cálculos, consideram-se apenas os elementos com espaçamento mais próximo.

Outra abordagem envolve selecionar os elementos mais importantes da sequência com base em sua similaridade. Para isso, podem ser usados vários métodos de agrupamento.

Uma terceira abordagem é usar várias heurísticas e algoritmos para selecionar os elementos mais importantes em uma sequência, por exemplo, com base em sua frequência, importância ou contexto.

Os autores observam que, para que a atenção esparsa funcione de forma eficaz, é necessário usar um algoritmo para distribuir os elementos da sequência em blocos que forneçam uma estrutura de blocos diferente para cada cabeça de atenção. Essa abordagem permite determinar de forma mais completa a influência de cada elemento da sequência e aumentar a eficiência do algoritmo.

A atenção esparsa se aplica em diversas áreas do aprendizado de máquina e do processamento de linguagem natural, incluindo tradução automática, geração de texto, análise de tons e muitas outras. Em seu artigo, os autores do método apresentam o resultado do algoritmo para textos, imagens e gravações de áudio.

Além disso, a atenção esparsa pode ser combinada de forma eficaz com outros métodos para otimizar o mecanismo de atenção e obter resultados mais precisos ao processar sequências.

Apesar de sua eficácia, o método de atenção esparsa tem suas desvantagens. Uma delas é que a seleção dos elementos mais importantes em uma sequência pode ser incorreta, o que pode levar à perda de informações. Desse modo, é necessário escolher um método adequado para cada tarefa específica e ajustar cuidadosamente os parâmetros do algoritmo.

Acredito que o método de atenção esparsa pode ser útil para resolver tarefas de análise de mercados financeiros. Ao examinar o histórico de alterações nas cotações de instrumentos financeiros, é necessário analisar os dados em uma profundidade considerável e, muitas vezes, apenas elementos separados do histórico influenciam a situação atual. A utilização do método de atenção Esparsa reduzirá a quantidade de recursos computacionais utilizados para alocar blocos significativos dos dados em estudo. O método também ajuda a excluir elementos insignificantes de outras operações, o que aumenta a eficiência da análise dos mercados financeiros.

Entretanto, as cotações do mercado financeiro têm uma estrutura mutável, o que não permite trabalhar com blocos fixos de elementos na sequência analisada. A esse respeito, para acelerar o processo de treinamento do modelo, podemos usar a heurística da regra de Pareto "80/20", em que pegamos apenas 20% dos elementos mais significativos da sequência total. A determinação da importância dos elementos é baseada nos coeficientes de dependência entre os elementos, que são calculados pelas duas primeiras fórmulas descritas anteriormente. Já após a primeira iteração, antes de normalizar os dados, é possível identificar com precisão os elementos mais significativos da sequência e, em seguida, excluir os elementos restantes de outras operações. Isso reduz o número de operações na fase de normalização e determinação de resultados do bloco Self-Attention.

Como cada cabeça de atenção usa suas próprias matrizes exclusivas para definir o Query e o Key, é provável que os elementos selecionados sejam diferentes em cada cabeça de atenção.

Depois de determinar as principais direções da otimização do algoritmo, podemos prosseguir com sua implementação usando as ferramentas da linguagem MQL5.

2. Implementação usando MQL5

Para implementar o método em questão, criaremos uma nova classe de camada neural, a CNeuronMLMHSparseAttention. Obviamente, não recriaremos todos os métodos da classe. Em vez disso, usaremos a herança da classe CNeuronMLMHAttentionOCL existente. E aqui vamos analisar quais métodos de classe e kernels de programa OpenCL precisamos alterar para implementar a otimização proposta.

Como já foi mencionado, nossa primeira alteração no algoritmo diz respeito ao bloco para determinar os coeficientes de dependência. Dados os valores durante a propagação no kernel do MHAttentionScore. Para nossa implementação, substituiremos o referido kernel pelo MHSparseAttentionScore.

Nos parâmetros do kernel da classe principal, passamos ponteiros para dois buffers de dados: um tensor concatenado de entidades Query, Key e Value como dados de entrada e um buffer para registrar os resultados das operações na forma de coeficientes de dependência. Além dos buffers de dados, o kernel recebeu a dimensionalidade das entidades internas. Adicionaremos um fator de redução esparsa aos parâmetros já mencionados. Nele, passaremos um valor no intervalo de 0 a 1, que indicará a parcela de elementos da sequência selecionados com influência máxima sobre o elemento analisado.

__kernel void MHSparseAttentionScore(__global float *qkv,    ///<[in] Matrix of Querys, Keys, Values
                                     __global float *score,  ///<[out] Matrix of Scores
                                     int dimension,          ///< Dimension of Key
                                     float sparse            ///< less than 1.0 coefficient of sparse
                                    )
  {
   int q = get_global_id(0);
   int h = get_global_id(1);
   int units = get_global_size(0);
   int heads = get_global_size(1);
//---

O novo kernel, assim como o kernel da classe principal, será executado em um espaço de tarefa bidimensional. A primeira dimensão apontará para o número de sequência do elemento de sequência que está sendo analisado, e a segunda dimensão corresponderá à cabeça de atenção usada. No corpo do kernel, salvamos imediatamente os identificadores globais do thread em execução em variáveis locais.

Em seguida, fazemos um pequeno trabalho preparatório, no qual declaramos as variáveis locais necessárias e definimos o deslocamento nos buffers de dados para os elementos analisados.

   int shift_q = dimension * (h + 3 * q * heads);
   int shift_s = units * (h + q * heads);
   int active_units = (int)max((float)(units * sparse), min((float)units, 3.0f));
//---
   float koef = sqrt((float)dimension);
   if(koef < 1)
      koef = 1;
   float sum = 0.0f;
   float min_s = 0.0f;
   float max_s = 0.0f;

Aqui também definimos o valor absoluto dos elementos a serem amostrados. Observe que, ao definir o número de elementos importantes da sequência a serem amostrados, restringi o limite inferior desse valor a três elementos. Isso nos ajudará a evitar o desligamento indesejável do bloco de atenção ao usar sequências pequenas. Afinal, não é segredo que o coeficiente de dependência máximo quase sempre será gerado pelos elementos analisados para seu próprio Key.

Em seguida, faremos um laço no qual multiplicaremos o vetor Query do elemento da sequência analisada pela matriz Key. No corpo desse laço, também definiremos os valores máximo e mínimo do vetor obtido.

   for(int k = 0; k < units; k++)
     {
      float result = 0;
      int shift_k = dimension * (h + heads * (3 * k + 1));
      for(int i = 0; i < dimension; i++)
        {
         if((dimension - i) > 4)
           {
            result += dot((float4)(qkv[shift_q + i], qkv[shift_q + i + 1], qkv[shift_q + i + 2], qkv[shift_q + i + 3]),
                          (float4)(qkv[shift_k + i], qkv[shift_k + i + 1], qkv[shift_k + i + 2], qkv[shift_k + i + 3]));
            i += 3;
           }
         else
            result += (qkv[shift_q + i] * qkv[shift_k + i]);
        }
      score[shift_s + k] = result;
      if(k == 0)
         min_s = max_s = result;
      else
        {
         max_s = max(max_s, result);
         min_s = min(min_s, result);
        }
     }

Para preservar as dependências entre os valores obtidos e os elementos de sequência correspondentes, não classificaremos o vetor para selecionar os elementos mais significativos. Em vez disso, aumentaremos iterativamente o limite inferior do intervalo de significância dos coeficientes de dependência até que o número necessário de elementos de sequência "importantes" seja obtido. Essa funcionalidade será implementada no próximo laço.

   int count = units;
   float temp = max_s;
   while(count > active_units)
     {
      count = 0;
      for(int k = 0; k < units; k++)
        {
         float value = score[shift_s + k];
         if(value < min_s)
            continue;
         count++;
         if(value < temp && value > min_s)
            temp = value;
        }
      if(count > active_units)
         min_s = temp;
     }

Uma vez determinado o intervalo de significância, passamos para a próxima etapa, a normalização dos dados, que consiste em duas etapas. Na primeira etapa, calculamos os valores exponenciais dos níveis de dependência que obtivemos na etapa anterior. Em seguida, dividimos esses valores pela soma total. Mas é importante lembrar que definimos um intervalo de significância e, para os itens fora desse intervalo, simplesmente zeramos as taxas de dependência e os excluímos de outras operações. Isso se aplica tanto ao cálculo dos valores exponenciais quanto à etapa de normalização.

   if(max_s == 0.0f)
      max_s = 1.0f;
   for(int k = 0; k < units; k++)
     {
      float value = score[shift_s + k];
      if(value < min_s)
        {
         score[shift_s + k] = 0.0f;
         continue;
        }
      value = exp(value / max_s / koef);
      score[shift_s + k] = value;
      sum += value;
     }
   for(int k = 0; (k < units && sum > 1); k++)
     {
      temp = score[shift_s + k];
      if(temp == 0.0f)
         continue;
      score[shift_s + k] = temp / sum;
     }
  }

Como resultado das operações do kernel especificado, obtemos apenas um pequeno número de coeficientes de dependência diferentes de zero para os elementos selecionados da sequência analisada, com os quais continuaremos trabalhando. Ao mesmo tempo, os elementos da sequência com coeficientes de dependência zero serão excluídos por nós das futuras propagações e retropropagações.

A próxima etapa é obter a saída do bloco de atenção. Para fazer isso, segundo o algoritmo Self-Attention, precisamos multiplicar a matriz de coeficientes de dependência normalizados Score pela matriz de entidades Value. Essa operação é realizada no kernel MHSparseAttentionOut. Nele, também realizamos a verificação dos coeficientes de dependência zero para reduzir o número de operações a serem executadas.

Nos parâmetros do kernel, passaremos ponteiros para 3 buffers de dados. O tensor concatenado das entidades Query, Key e Value, juntamente com a matriz de coeficiente de dependência Score, são os dados de origem para as operações a serem executadas. E escreveremos o resultado das operações no buffer Out. Além disso, nos parâmetros, passaremos a dimensionalidade do vetor Key de um elemento da sequência. Lembre-se de que na classe de atenção multicabeça usamos vetores da mesma dimensionalidade para as entidades internas Query, Key e Value.

__kernel void MHSparseAttentionOut(__global float *scores, ///<[in] Matrix of Scores
                                   __global float *qkv,    ///<[in] Matrix of Values
                                   __global float *out,    ///<[out] Output tensor
                                   int dimension           ///< Dimension of Value
                                  )
  {
   int u = get_global_id(0);
   int units = get_global_size(0);
   int h = get_global_id(1);
   int heads = get_global_size(1);

Esse kernel, assim como o anterior, será chamado no espaço de tarefas bidimensional para separar as operações nos elementos da sequência e nas cabeças de atenção em threads separados. No início do kernel, armazenamos os identificadores de thread em variáveis locais.

Em seguida, definimos os deslocamentos nos buffers de dados.

   int shift_s = units * (h + heads * u);
   int shift_out = dimension * (h + heads * u);

Depois disso, geramos um sistema de laços aninhados para multiplicar o vetor de coeficientes de dependência pela matriz Value. É aqui que inserimos uma verificação de coeficiente de dependência zero para eliminar operações redundantes.

   for(int d = 0; d < dimension; d++)
     {
      float result = 0;
      for(int v = 0; v < units; v ++)
        {
         float cur_score = scores[shift_s + v];
         if(cur_score == 0)
            continue;
         int shift_v = dimension * (h + heads * (3 * v + 2)) + d;
         result += cur_score * qkv[shift_v];
        }
      out[shift_out + d] = result;
     }
  }

Com isso, concluímos os kernels de propagação da nossa nova classe e vamos examinar o escopo das alterações na parte de retropropagação.

A retropropagação do bloco Self-Attention da classe principal foi preparada no kernel MHAttentionInsideGradients. O algoritmo para construir esse kernel é feito de tal forma que permite adicionar os pontos de controle necessários ao longo de um kernel existente sem criar uma duplicata dele. Proponho analisar o algoritmo construído e os pontos de controle adicionados a ele.

Nos parâmetros do kernel, passaremos ponteiros para 5 buffers de dados:

  • Tensor concatenado de entidades Query, Key e Value (qkv)
  • Tensor concatenado para registrar os gradientes de erro de entidades Query, Key e Value (qkv_g)
  • Matriz de coeficientes de dependência (scores)
  • Matriz para registrar gradientes de erro no nível da matriz de coeficientes de dependência (scores_g)
  • Tensor de gradiente de erro no nível de saída do bloco de cabeça de atenção atual.

__kernel void MHAttentionInsideGradients(__global float *qkv, __global float *qkv_g,
                                         __global float *scores, __global float *scores_g,
                                         __global float *gradient, int dimension)
  {
   int u = get_global_id(0);
   int h = get_global_id(1);
   int units = get_global_size(0);
   int heads = get_global_size(1);
   float koef = sqrt((float)dimension);
   if(koef < 1)
      koef = 1;

Chamaremos o kernel de distribuição do gradiente de erro em um espaço de tarefa bidimensional, como os dois discutidos anteriormente. Uma dimensão identificará o elemento da sequência que está sendo analisado. E a segunda dimensão apontará para a cabeça de atenção atual. São esses identificadores que nos ajudarão a determinar o deslocamento nos buffers de dados para os elementos desejados. Por isso, no início do kernel, armazenamos esses identificadores de fluxo em variáveis locais.

Além disso, o algoritmo do kernel é condicionalmente dividido em dois blocos. No primeiro, determinamos o gradiente de erro no nível da matriz de coeficiente de dependência. Aqui, realizamos um laço de coleta de gradientes no vetor de coeficientes de dependência do elemento de sequência analisado. É importante ressaltar que, como os elementos da sequência com coeficientes de dependência zero não têm influência no resultado final, o gradiente de erro para eles deve ser zero. Por isso, no corpo do laço, primeiro verificamos o coeficiente de dependência atual. E se um valor zero for detectado, simplesmente passamos para o próximo elemento.

É importante observar que o acesso à memória global, que armazena os elementos de todos os nossos buffers de dados, é uma operação relativamente dispendiosa. E o vetor de gradientes de erro no nível da matriz de coeficiente de sequência é um repositório temporário e não é usado em outros kernels. Por isso, nem sequer escrevemos um valor nulo nele. Uma vez que será uma operação desnecessária, sem carga útil.

//--- Calculating score's gradients
   uint shift_s = units * (h + u * heads);
   for(int v = 0; v < units; v++)
     {
      float s = scores[shift_s + v];
      if(s <= 0)
         continue;
      float sg = 0;
      int shift_v = dimension * (h + heads * (3 * v + 2));
      int shift_g = dimension * (h + heads * v);
      for(int d = 0; d < dimension; d++)
         sg += qkv[shift_v + d] * gradient[shift_g + d];
      scores_g[shift_s + v] = sg * (s < 1 ? s * (1 - s) : 1) / koef;
     }
   barrier(CLK_GLOBAL_MEM_FENCE);

Na próxima etapa, realizamos a distribuição do gradiente de erro para as entidades internas Query, Key e Value. Nessa etapa, primeiro definimos o deslocamento nos buffers de dados. Em seguida, preparamos um sistema de laços para coletar os gradientes de erro adequadamente.

Aqui, dentro do laço aninhado, verificamos a relação de dependência e, se encontrarmos um valor zero, simplesmente passamos para o próximo elemento. Assim, eliminamos operações desnecessárias.

//--- Calculating gradients for Query, Key and Value
   uint shift_qg = dimension * (h + 3 * u * heads);
   uint shift_kg = dimension * (h + (3 * u + 1) * heads);
   uint shift_vg = dimension * (h + (3 * u + 2) * heads);
   for(int d = 0; d < dimension; d++)
     {
      float vg = 0;
      float qg = 0;
      float kg = 0;
      for(int l = 0; l < units; l++)
        {
         float sg = scores[shift_s + l];
         if(sg <= 0)
            continue;
         uint shift_q = dimension * (h + 3 * l * heads) + d;
         uint shift_k = dimension * (h + (3 * l + 1) * heads) + d;
         uint shift_g = dimension * (h + heads * l) + d;
         //---
         vg += gradient[shift_g] * sg;
         sg = scores_g[shift_s + l];
         kg += sg * qkv[shift_q];
         qg += sg * qkv[shift_k];
        }
      qkv_g[shift_qg + d] = qg;
      qkv_g[shift_kg + d] = kg;
      qkv_g[shift_vg + d] = vg;
     }
  }

Depois de concluir totalmente as iterações desse kernel, obteremos os gradientes de erro no nível das entidades Query, Key e Value, que serão distribuídos posteriormente para as respectivas matrizes de peso e para a camada neural anterior.

Com isso, concluímos nosso trabalho nos kernels do programa OpenCL e passamos a trabalhar no código principal do programa. Acima, adicionamos dois kernels. Por isso, precisamos adicionar uma chamada para os kernels no programa principal. Primeiro, vamos criar constantes para acessar os kernels.

Observe que criamos constantes para manusear dois kernels e apenas uma constante de parâmetro. Criamos kernels com base em outros existentes e repetimos quase completamente a estrutura de parâmetros dos kernels básicos. Desse modo, podemos usar as constantes existentes durante a operação dos kernels. Nós só criamos uma constante para especificar o parâmetro de descarga.

#define def_k_MHSparseAttentionScore    44 ///< Index of the kernel of the multi-heads sparse attention neuron 
                                           //   to calculate score matrix (#MHSparseAttentionScore)
#define def_k_mhas_sparse                3  ///< less than 1.0 coefficient of sparse
//---
#define def_k_MHSparseAttentionOut      45 ///< Index of the kernel of the multi-heads sparse attention neuron 
                                           //   to calculate multi-heads out matrix (#MHSparseAttentionOut)

Em seguida, temos que criar os kernels no contexto do OpenCL. Precisamos aumentar o número total de kernels ativos no contexto para 46 e chamar os métodos de criação de kernel.

   opencl.SetKernelsCount(46);
   if(!opencl.KernelCreate(def_k_MHSparseAttentionScore, "MHSparseAttentionScore"))
     {
      PrintFormat("Error of create kernell: %d line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!opencl.KernelCreate(def_k_MHSparseAttentionOut, "MHSparseAttentionOut"))
     {
      PrintFormat("Error of create kernell: %d line %d", GetLastError(), __LINE__);
      return false;
     }

É importante dizer aqui que as operações acima para criar kernels no contexto do OpenCL terão de ser repetidas em três métodos da classe de gerenciamento da rede neural CNet. Reconheço que isso não é muito conveniente. No futuro, planejo colocar essas operações em um método separado.

   bool              Create(CArrayObj *Description);
   bool              Load(string file_name, float &error, float &undefine, float &forecast, datetime &time, 
                          bool common = true);
   ///< Load method. @param[in] file_name File name to save @param[out] error Average error 
   ///< @param[out] undefine Undefined percent @param[out] Forecast percent 
   ///< @param[out] time Last study time @param[in] common Common flag
   virtual bool      Load(const int file_handle);

Na próxima etapa de nosso trabalho, passaremos diretamente para a criação de métodos de nossa nova classe. A funcionalidade da nossa nova classe de rede neural CNeuronMLMHSparseAttention repete em grande parte a funcionalidade da classe principal CNeuronMLMHAttentionOCL. Então, usaremos principalmente métodos herdados. As principais diferenças estão relacionadas à criação da atenção esparsa. Nesta parte, criaremos uma nova variável interna m_dSparse para armazenar o nível de atenção esparsa.

Decidi não complicar o trabalho reescrevendo desnecessariamente os métodos e deixei o construtor e o destruidor da classe vazios. Afinal, não criamos novos objetos na nova classe e criaremos métodos Sparse sobrecarregados para processar o parâmetro de atenção esparsa. A possibilidade de sobrecarregar métodos nos permite usar métodos com o mesmo nome para diferentes funcionalidades: com a especificação do valor nos parâmetros, passamos o valor do parâmetro para o método; sem especificar os parâmetros, o método retornará o valor armazenado anteriormente.

class CNeuronMLMHSparseAttention  : public CNeuronMLMHAttentionOCL
  {
protected:
   float             m_dSparse;
   //---
   virtual bool      AttentionScore(CBufferFloat *qkv, CBufferFloat *scores, bool mask = true);
   ///< \brief Multi-heads attention scores method of calling kernel ::MHAttentionScore().
   virtual bool      AttentionOut(CBufferFloat *qkv, CBufferFloat *scores, CBufferFloat *out);
   ///< \brief Multi-heads attention out method of calling kernel ::MHAttentionOut().

public:
                     CNeuronMLMHSparseAttention(void)   :  m_dSparse(0.3f) {};
                    ~CNeuronMLMHSparseAttention(void) {};
   //---
   void              Sparse(float value)  { m_dSparse = value;}
   float             Sparse(void)         { return m_dSparse; }
   virtual int       Type(void)   const   {  return defNeuronMLMHSparseAttentionOCL;   }
                     ///< Identificatory of class.@return Type of class
   //--- methods for working with files
   virtual bool      Save(int const file_handle);  
                     ///< Save method @param[in] file_handle handle of file @return logical result of operation
   virtual bool      Load(int const file_handle);  
                     ///< Load method @param[in] file_handle handle of file @return logical result of operation
  };

Não se esqueça de substituir o método virtual de identificação do objeto Type.

Também entre os métodos públicos, devemos substituir os métodos Save e Load para trabalhar com arquivos. O algoritmo desses métodos é bastante simples. Neles, primeiro chamamos os métodos com o mesmo nome da classe pai, nos quais já estão definidos todos os pontos de controle e estão preparados os algoritmos de salvamento e carregamento de variáveis e objetos herdados. Só precisamos verificar o resultado lógico da execução dos métodos chamados. Após a execução bem-sucedida do método da classe principal, salvamos ou lemos o valor do parâmetro de atenção esparsa, dependendo da funcionalidade do método iniciado.

bool CNeuronMLMHSparseAttention::Save(const int file_handle)
  {
   if(!CNeuronMLMHAttentionOCL::Save(file_handle))
      return false;
   if(FileWriteFloat(file_handle, m_dSparse) < sizeof(float))
      return false;
//---
   return true;
  }

Classificamos os métodos públicos da nova classe. Mas a principal finalidade da classe é criar o algoritmo da camada neural. E aqui voltamos às propagações e retropropagações. Foi justamente com esse objetivo que atualizamos os kernels do programa OpenCL.

Vou me desviar um pouco da estrutura usual da discussão sobre métodos ao descrever a funcionalidade das redes neurais - começarei não com as propagações, mas retropropagações. Acima, não criamos novos kernels da funcionalidade de retropropagação. Fizemos apenas alterações no kernel existente que era usado pela classe principal. Ao herdar a funcionalidade da classe principal, também obtivemos os algoritmos para chamar o kernel MHAttentionInsideGradients discutido acima. Isso significa que agora podemos simplesmente usar o método de retropropagação calcInputGradients da classe principal para distribuir os gradientes de erro. Quanto à funcionalidade de atualização dos parâmetros treinados, não fizemos nenhuma alteração e também podemos usar o método da classe principal updateInputWeights.

Vamos passar para os métodos de propagação. Ao construir o algoritmo de propagação para a classe pai, não combinamos todo o algoritmo ramificado no corpo de um único método. Em vez disso, criamos um método de gerenciamento estruturado feedForward, no qual os métodos de execução da funcionalidade individual são chamados sequencialmente, conforme o algoritmo Self-Attention. Graças a essa abordagem, agora não precisamos reescrever completamente o método de propagação. Tudo o que precisamos fazer é redefinir os métodos para chamar dois novos kernels. Esses são os métodos AttentionScore e AttentionOut.

bool CNeuronMLMHAttentionOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;
//---
   for(uint i = 0; (i < iLayers && !IsStopped()); i++)
     {
      //--- Calculate Queries, Keys, Values
      CBufferFloat *inputs = (i == 0 ? NeuronOCL.getOutput() : FF_Tensors.At(6 * i - 4));
      CBufferFloat *qkv = QKV_Tensors.At(i * 2);
      if(IsStopped() || !ConvolutionForward(QKV_Weights.At(i * (optimization == SGD ? 2 : 3)),
                                            inputs, qkv, iWindow, 3 * iWindowKey * iHeads, None))
         return false;
      //--- Score calculation
      CBufferFloat *temp = S_Tensors.At(i * 2);
      if(IsStopped() || !AttentionScore(qkv, temp, true))
         return false;
      //--- Multi-heads attention calculation
      CBufferFloat *out = AO_Tensors.At(i * 2);
      if(IsStopped() || !AttentionOut(qkv, temp, out))
         return false;
      //--- Attention out calculation
      temp = FF_Tensors.At(i * 6);
      if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9)), 
                                            out, temp, iWindowKey * iHeads, iWindow, None))
         return false;
      //--- Sum and normilize attention
      if(IsStopped() || !SumAndNormilize(temp, inputs, temp))
         return false;
      //--- Feed Forward
      inputs = temp;
      temp = FF_Tensors.At(i * 6 + 1);
      if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 1), 
                                            inputs, temp, iWindow, 4 * iWindow, LReLU))
         return false;
      out = FF_Tensors.At(i * 6 + 2);
      if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 2), 
                                            temp, out, 4 * iWindow, iWindow, activation))
         return false;
      //--- Sum and normilize out
      if(IsStopped() || !SumAndNormilize(out, inputs, out))
         return false;
     }
//---
   return true;
  }

Para conservar as regras de herança, ambos os métodos recebem parâmetros semelhantes aos métodos da classe principal. Isso é fundamental porque a alteração dos parâmetros dos métodos criaria métodos sobrecarregados, enquanto precisamos substituir os métodos da classe principal. Quando os métodos são sobrecarregados, o sistema seleciona um deles de acordo com os parâmetros especificados quando o método é chamado, ao passo que, quando os métodos são substituídos, o sistema segue a hierarquia de herança e usa o último método substituído. Assim, somente no caso de substituição de métodos, quando chamado a partir do método herdado feedForward, o sistema fará referência aos métodos substituídos de nossa classe.

O método AttentionScore recebe em seus parâmetros um ponteiro para os objetos de dois buffers: o tensor concatenado das entidades Query, Key, Value e a matriz de coeficiente de dependência. Além disso, o sinalizador de máscara é passado nos parâmetros do método. Cabe dizer que nós não usamos esse sinalizador, ele é deixado nos parâmetros pelos motivos mencionados acima.

No corpo do método, verificamos imediatamente a relevância dos ponteiros recebidos. Aqui também verificamos a relevância do objeto de trabalho com o contexto OpenCL. Além dos ponteiros para os próprios objetos, verificamos a presença dos buffers de dados criados no contexto do OpenCL. Somente depois de passar com êxito por todos esses pontos de verificação é que podemos prosseguir com o posicionamento do kernel na fila de execução.

Lembre que todos os kernels que criamos foram planejados para serem usados no espaço de tarefas bidimensional. E agora temos de criar matrizes que descrevam o espaço de tarefa global_work_size e o deslocamento no espaço de tarefa global_work_offset. A dimensionalidade de ambas as matrizes deve corresponder ao espaço da tarefa. Para criar um espaço de tarefa bidimensional, criamos ambos os vetores com 2 elementos.

Nos elementos da primeira matriz, especificamos o número total de elementos da sequência analisada e o número de cabeças de atenção. A posição de um elemento na matriz indica a dimensão. E seu valor indica o número de threads. Assim, cada elemento da sequência para cada cabeça de atenção terá seu próprio thread separado para executar operações. De modo geral, serão realizadas operações em todos os elementos da sequência simultaneamente (na medida em que for tecnicamente possível) em threads paralelas.

Preencheremos os elementos da segunda matriz com valores nulos, pois não presumimos nenhuma mudança no espaço da tarefa.

bool CNeuronMLMHSparseAttention::AttentionScore(CBufferFloat *qkv, CBufferFloat *scores, bool mask = true)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(qkv) == POINTER_INVALID ||
      CheckPointer(scores) == POINTER_INVALID)
      return false;
//---
   if(qkv.GetIndex() < 0)
      return false;
   if(scores.GetIndex() < 0)
      return false;
//---
   uint global_work_offset[2] = {0, 0};
   uint global_work_size[2];
   global_work_size[0] = iUnits;
   global_work_size[1] = iHeads;
   OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionScore, def_k_mhas_qkv, qkv.GetIndex());
   OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionScore, def_k_mhas_score, scores.GetIndex());
   OpenCL.SetArgument(def_k_MHSparseAttentionScore, def_k_mhas_dimension, (int)iWindowKey);
   OpenCL.SetArgument(def_k_MHSparseAttentionScore, def_k_mhas_sparse, (float)m_dSparse);
   if(!OpenCL.Execute(def_k_MHSparseAttentionScore, 2, global_work_offset, global_work_size))
     {
      string error;
      CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error);
      printf("Error of execution kernel %s: %s", __FUNCSIG__, error);
      return false;
     }
//---
   return true;
  }

A próxima etapa será a passagem de parâmetros para o kernel. Para isso, usaremos os métodos SetArgumentBuffer e SetArgument. O primeiro é usado para passar ponteiros para buffers de dados. O segundo é usado para passar valores discretos. Nos parâmetros dos métodos, especificamos o identificador do kernel, o número de sequência do parâmetro passado (corresponde à sequência de parâmetros do kernel no programa OpenCL a partir do valor "0") e o valor a ser passado diretamente.

Aqui você deve ter cuidado com o tipo dos valores passados e o tipo especificado do parâmetro no kernel. Se os tipos não corresponderem, é possível que ocorra um erro de execução do kernel.

Uma vez concluído o trabalho preparatório, chamamos o método Execute para enviar o kernel para a fila de execução. Nos parâmetros do método, especificamos o identificador do kernel, a dimensionalidade do espaço da tarefa e as matrizes de descrição do espaço da tarefa criadas anteriormente.

E, imediatamente, verificamos o resultado da execução do método para enfileirar o kernel. Se ocorrer um erro de enfileiramento, solicitamos informações sobre o erro e as enviamos para o registro do terminal.

Após o enfileiramento bem-sucedido do kernel, encerramos o método com o resultado true.

Repetimos o mesmo algoritmo no método AttentionOut para chamar o segundo kernel.

bool CNeuronMLMHSparseAttention::AttentionOut(CBufferFloat *qkv, CBufferFloat *scores, CBufferFloat *out)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(qkv) == POINTER_INVALID || 
      CheckPointer(scores) == POINTER_INVALID || CheckPointer(out) == POINTER_INVALID)
      return false;
   uint global_work_offset[2] = {0, 0};
   uint global_work_size[2];
   global_work_size[0] = iUnits;
   global_work_size[1] = iHeads;
   if(qkv.GetIndex() < 0)
      return false;
   if(scores.GetIndex() < 0)
      return false;
   if(out.GetIndex() < 0)
      return false;
//---
   OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionOut, def_k_mhao_qkv, qkv.GetIndex());
   OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionOut, def_k_mhao_score, scores.GetIndex());
   OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionOut, def_k_mhao_out, out.GetIndex());
   OpenCL.SetArgument(def_k_MHSparseAttentionOut, def_k_mhao_dimension, (int)iWindowKey);
   if(!OpenCL.Execute(def_k_MHSparseAttentionOut, 2, global_work_offset, global_work_size))
     {
      string error;
      CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error);
      printf("Error of execution kernel %s: %s", __FUNCSIG__, error);
      return false;
     }
//---
   return true;
  }

Com isso, concluímos nosso trabalho com a nova classe de rede neural. Mas ainda falta um pequeno detalhe. Precisamos adicionar o processamento de nossa nova classe aos métodos de gerenciamento para preparar o funcionamento do modelo.

Primeiro, adicionamos um bloco para criar um novo tipo de camada neural no método CNet::Create.

            case defNeuronMLMHSparseAttentionOCL:
               neuron_sparseattention = new CNeuronMLMHSparseAttention();
               if(CheckPointer(neuron_sparseattention) == POINTER_INVALID)
                 {
                  delete temp;
                  return false;
                 }
               if(!neuron_sparseattention.Init(outputs, 0, opencl, desc.window, desc.window_out, desc.step, 
                                                               desc.count, desc.layers, desc.optimization, desc.batch))
                 {
                  delete neuron_sparseattention;
                  delete temp;
                  return false;
                 }
               neuron_sparseattention.SetActivationFunction(desc.activation);
               neuron_sparseattention.Sparse(desc.probability);
               if(!temp.Add(neuron_sparseattention))
                 {
                  delete neuron_mlattention_ocl;
                  delete temp;
                  return false;
                 }
               neuron_sparseattention = NULL;
               break;

Adicionamos o novo tipo de camada ao método CLayer::CreateElement.

         case  defNeuronMLMHSparseAttentionOCL:
            if(CheckPointer(OpenCL) == POINTER_INVALID)
               return false;
            temp_mlat_ocl = new CNeuronMLMHSparseAttention();
            if(CheckPointer(temp_mlat_ocl) == POINTER_INVALID)
               result = false;
            if(temp_mlat_ocl.Init(iOutputs, index, OpenCL, 1, 1, 1, 1, 0, ADAM, 1))
              {
               m_data[index] = temp_mlat_ocl;
               return true;
              }
            break;

Além disso, incorporamos o novo tipo ao método de gerenciamento de propagação da classe base da rede neural.

bool CNeuronBaseOCL::FeedForward(CObject *SourceObject)
  {
   if(CheckPointer(SourceObject) == POINTER_INVALID)
      return false;
//---
   CNeuronBaseOCL *temp = NULL;
   switch(SourceObject.Type())
     {
      case defNeuronBaseOCL:
      case defNeuronProofOCL:
      case defNeuronConvOCL:
      case defNeuronAttentionOCL:
      case defNeuronMHAttentionOCL:
      case defNeuronMLMHAttentionOCL:
      case defNeuronMLMHSparseAttentionOCL:
      case defNeuronDropoutOCL:
      case defNeuronBatchNormOCL:
      case defNeuronVAEOCL:
      case defNeuronLSTMOCL:
      case defNeuronSoftMaxOCL:
         temp = SourceObject;
         return feedForward(temp);
         break;
     }
//---
   return false;
  }

E repetimos a operação no método de retropropagação semelhante CNeuronBaseOCL::calcHiddenGradients(CObject *TargetObject).

      case defNeuronMLMHAttentionOCL:
      case defNeuronMLMHSparseAttentionOCL:
         mlat = TargetObject;
         if(!bTrain && !mlat.TrainMode())
            return true;
         temp = GetPointer(this);
         return mlat.calcInputGradients(temp);

O código completo de todas as classes e seus métodos pode ser encontrado no anexo.


3. Teste

Concluído o trabalho sobre a nova classe de camada neural, podemos passar a testar o algoritmo no testador de estratégias de negociação da plataforma MetaTrader 5. Esse testador permite avaliar o funcionamento de Expert Advisors (EAs) e indicadores de negociação a partir de dados históricos. Para testar o funcionamento do algoritmo, criaremos um pequeno EA, que treinará o modelo diretamente a partir de dados históricos. Já criamos EAs semelhantes ao testar os algoritmos abordados anteriormente. Agora, simplesmente tomaremos como base o EA do artigo anterior e substituiremos a camada neural de atenção multicabeça na arquitetura de seu modelo pela camada recém-criada de atenção esparsa.

Lembre-se de que, no artigo anterior, testamos um modelo relacional de aprendizado por reforço que usava um algoritmo de função quantílica totalmente parametrizado com o uso de um bloco de curiosidade interno. Para implementar esse modelo, criamos uma combinação de três modelos Model, Forward e Inverse. Usamos o bloco de atenção no primeiro modelo. Será em sua arquitetura que faremos alterações. A arquitetura dos outros dois modelos permanecerá inalterada.

A arquitetura dos modelos é descrita na função CreateDescriptions. É importante dizer que, para tornar o modelo mais leve, foi decidido não utilizar blocos LSTM recursivos. E seu lugar foi ocupado por camadas totalmente conectadas. Assim, o modelo treinado tem a seguinte arquitetura.

Na entrada do modelo, há uma camada de dados iniciais que consiste em 12 elementos para descrever cada barra, o histórico analisado e 9 elementos para descrever o estado atual da conta.

//--- Model
   Description.Clear();
   CLayerDescription *descr;
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (int)(HistoryBars * 12 + 9);
   descr.window = 0;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }

Isso é seguido por uma camada de normalização de dados.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = 1000;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }

Em seguida, há dois blocos consecutivos de convolução e camadas de link completo.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = prev_count - 2;
   descr.window = 3;
   descr.step = 1;
   descr.window_out = 6;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 100;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = 50;
   descr.window = 2;
   descr.step = 2;
   descr.window_out = 4;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 100;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }

Os dados compactados são analisados pelo bloco de atenção. Aqui usamos uma nova camada de atenção esparsada. Dividimos toda a sequência de dados comprimidos em 20 blocos de 5 elementos cada. Cada bloco representa um elemento da sequência analisada. Usaremos 4 cabeças de atenção para analisar os dados com a seleção de 30% dos elementos mais significativos da sequência em cada cabeça de atenção. A análise será feita em duas camadas sequenciais com parâmetros semelhantes. Especificaremos isso no parâmetro de camadas  

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMLMHSparseAttentionOCL;
   descr.count = 20;
   descr.window = 5;
   descr.step = 4;
   descr.window_out = 8;
   descr.layers = 2;
   descr.probability = 0.3f;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }

A decisão de executar uma operação de negociação com o EA é tomada no bloco de uma função de quantil totalmente parametrizada. O Expert Advisor pode decidir executar uma das seguintes 4 ações:

  • comprar, 
  • vender, 
  • fechar todas as negociações
  • não executar operações de negociação.

//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFQF;
   descr.count = 4;
   descr.window_out = 32;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }

O código completo do Expert Advisor "SparseRL-learning.mq5" pode ser encontrado no anexo.

Treinamos o modelo e testamos o Expert Advisor em dados históricos do EURUSD no período H1 para março de 2023. No processo de treinamento, conseguimos obter lucro para o período de teste. É importante observar que o lucro foi obtido devido ao fato de que o tamanho da média das negociações lucrativas foi maior do que o tamanho da média das negociações perdedoras. Ao mesmo tempo, o número de posições vencedoras e perdedoras era aproximadamente o mesmo. Como resultado, o fator de lucro foi de 1,12 e o fator de recuperação foi de 1,01.

Gráfico de teste
Tabela de resultados de testes


Considerações finais

Neste artigo, estudamos o mecanismo de atenção esparsa e incorporamos seu algoritmo à nossa biblioteca de classes, para depois testá-lo com base em dados históricos. Como resultado do teste, o modelo gerou lucro, o que indica o uso potencial dessa arquitetura para a criação de soluções de negociação. No entanto, vale ressaltar que o modelo apresentado no artigo serve apenas para fins introdutórios e de teste.

Para usar esse modelo em condições reais de negociação, é necessário realizar uma análise mais detalhada de sua eficiência e estabilidade às mudanças no mercado. Isso também requer um ajuste mais cuidadoso dos hiperparâmetros do modelo para obter os melhores resultados.

Além disso, convém observar que o uso de qualquer modelo para negociação nos mercados financeiros está sempre associado ao risco de perdas. Logo, antes de usar qualquer modelo para negociação real, é necessário estudar minuciosamente seu princípio de funcionamento e avaliar os possíveis riscos.

Apesar disso, o mecanismo de atenção esparsa pode ser uma ferramenta útil para a criação de modelos de negociação.


Referências

  1. Generating Long Sequences with Sparse Transformers
  2. Attention Is All You Need
  3. Redes neurais de maneira fácil (Parte 8): mecanismos de atenção
  4. Redes neurais de maneira fácil (Parte 10): atenção multi-cabeça
  5. Redes neurais de maneira fácil (Parte 11): uma visão sobre GPT
  6. Redes neurais de maneira fácil (Parte 35): módulo de curiosidade intrínseca
  7. Redes neurais de maneira fácil (Parte 36): modelos relacionais de aprendizado por reforço

Programas utilizados no artigo

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

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

Arquivos anexados |
MQL5.zip (207.29 KB)
Estratégia de negociação no indicador de reconhecimento apurado de velas Doji Estratégia de negociação no indicador de reconhecimento apurado de velas Doji
O indicador baseado em metabarras detecta mais velas do que o clássico baseado em barras únicas. Vamos ver se ele oferece benefícios reais na negociação automatizada.
Implementando o fator Janus em MQL5 Implementando o fator Janus em MQL5
Gary Anderson desenvolveu um método de análise de mercado baseado em uma teoria que chamou de fator Janus. Essa teoria descreve um conjunto de indicadores que podem ser usados ​​para identificar tendências e avaliar o risco de mercado. Neste artigo, vamos implementar essas ferramentas no MQL5.
Desenvolvendo um sistema de Replay - Simulação de mercado ( Parte 21):  FOREX (II) Desenvolvendo um sistema de Replay - Simulação de mercado ( Parte 21): FOREX (II)
Vamos continuar a montagem do sistema para cobrir o mercado de FOREX. Então para resolver este problema, precisaríamos primeiramente, declarar o carregamento dos tickets, antes de fazer o carregamento das barras previas. Isto resolve o problema, mas ao mesmo tempo força o usuário, a um tipo de modelagem do arquivo de configuração, que ao meu ver não faz muito sentido. O motivo é que, ao desenvolver a programação, responsável por analisar e executar o que esta no arquivo de configuração, podemos permitir ao usuário, declarar as coisas em qualquer ordem.
Encontrando padrões de velas usando MQL5 Encontrando padrões de velas usando MQL5
Neste artigo, falaremos sobre como detectar automaticamente padrões de velas usando MQL5.