English Русский
preview
Redes neurais de maneira fácil (Parte 82): modelos de equações diferenciais ordinárias (NeuralODE)

Redes neurais de maneira fácil (Parte 82): modelos de equações diferenciais ordinárias (NeuralODE)

MetaTrader 5Sistemas de negociação | 19 agosto 2024, 12:07
45 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introdução

Apresento a você uma nova família de modelos baseados em equações diferenciais ordinárias (EDOs). Em vez de especificar uma sequência discreta de camadas ocultas, esses modelos parametrizam a derivada do estado oculto por meio de uma rede neural. Os resultados do modelo são calculados utilizando uma "caixa preta" — o solucionador de equações diferenciais. Esses modelos, que possuem profundidade contínua, utilizam uma quantidade constante de memória e adaptam sua estratégia de estimativa para cada sinal de entrada. Modelos como esses foram introduzidos pela primeira vez no artigo "Neural Ordinary Differential Equations". Nele, os autores demonstram a capacidade de escalar a retropropagação do erro utilizando qualquer solucionador de EDO sem acesso às suas operações internas, permitindo o treinamento de ponta a ponta de EDOs dentro de modelos maiores.


1. Descrição do algoritmo

A principal dificuldade técnica ao treinar modelos de equações diferenciais ordinárias está em realizar a diferenciação reversa durante a propagação do erro mediante um solucionador de EDO. Aplicar diferenciação utilizando operações de propagação para frente é simples, mas consome muita memória e pode introduzir erros numéricos adicionais.

Os autores do método propõem tratar o solucionador de EDO como uma caixa preta e calcular os gradientes utilizando o método de sensibilidade adjunta. Essa abordagem permite calcular gradientes resolvendo uma segunda EDO estendida de trás para frente, sendo aplicável a todos os solucionadores de EDO. Ela escala linearmente com o tamanho do problema e tem baixo consumo de memória, além de controlar explicitamente o erro numérico.

Vamos considerar a otimização de uma função escalar de perda L(), cujos dados de entrada são os resultados do solucionador de EDO:

Para otimizar o erro L, precisamos dos gradientes em relação a θ. O primeiro passo do algoritmo, conforme proposto pelos autores, é determinar como o gradiente do erro depende do estado oculto z(t) em cada momento a(t)=∂L/∂z(t). Sua dinâmica é definida por outra EDO, que pode ser considerada análoga à regra:

Podemos calcular ∂L/∂z(t) usando mais uma chamada ao solucionador de EDO. Este solucionador deve operar no sentido inverso, começando com o valor inicial ∂L/∂z(t1). Uma das dificuldades está no fato de que, para resolver essa EDO, é necessário conhecer os valores de z(t) ao longo de toda a trajetória. No entanto, podemos simplesmente enumerar z(t) de trás para frente, começando pelo seu valor final z(t1).

O cálculo dos gradientes em relação aos parâmetros θ requer a definição de um terceiro integral, que depende tanto de z(t) quanto de a(t):

Todos os integrais para resolver 𝐳, 𝐚 e ∂L/∂θ podem ser calculados em uma única chamada ao solucionador de EDO, que combina o estado original, o adjunto e outras derivadas parciais em um único vetor. Abaixo está o algoritmo para construir a dinâmica necessária e chamar o solucionador de EDO para calcular todos os gradientes simultaneamente.

A maioria dos solucionadores de EDO permite calcular o estado z(t) várias vezes. Quando as perdas dependem desses estados intermediários, a derivada do modo reverso precisa ser dividida em uma sequência de soluções separadas, uma para cada par consecutivo de valores de saída. A cada observação, o adjunto deve ser ajustado na direção da derivada parcial correspondente ∂L/∂z(t).


Os solucionadores de EDO podem garantir aproximadamente que os resultados obtidos estão dentro de uma tolerância definida em relação à solução real. Alterar essa tolerância modifica o comportamento do modelo. O tempo gasto na execução direta é proporcional ao número de cálculos da função, portanto, ajustar a tolerância nos oferece um equilíbrio entre precisão e custo computacional. É possível treinar com alta precisão, mas durante a operação mudar para uma precisão mais baixa.


2. Implementação usando MQL5

Para implementar as abordagens propostas, criaremos uma nova classe CNeuronNODEOCL, que herdará a funcionalidade básica da nossa camada totalmente conectada CNeuronBaseOCL. Abaixo está a estrutura da nova classe. Nela, além do conjunto básico de métodos, são adicionados alguns métodos e objetos específicos, cujo funcionamento será explorado durante a implementação.

class CNeuronNODEOCL     :  public CNeuronBaseOCL
  {
protected:
   uint              iDimension;
   uint              iVariables;
   uint              iLenth;

   int               iBuffersK[];
   int               iInputsK[];
   int               iMeadl[];
   CBufferFloat      cAlpha;
   CBufferFloat      cTemp;
   CCollection       cBeta;
   CBufferFloat      cSolution;
   CCollection       cWeights;
   //---
   virtual bool      CalculateKBuffer(int k);
   virtual bool      CalculateInputK(CBufferFloat* inputs, int k);
   virtual bool      CalculateOutput(CBufferFloat* inputs);
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   //---
   virtual bool      CalculateOutputGradient(CBufferFloat* inputs);
   virtual bool      CalculateInputKGradient(CBufferFloat* inputs, int k);
   virtual bool      CalculateKBufferGradient(int k);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);

public:
                     CNeuronNODEOCL(void) {};
                    ~CNeuronNODEOCL(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint dimension, uint variables, uint lenth,
                          ENUM_OPTIMIZATION optimization_type,
                          uint batch);
   //---
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer);
   //---
   virtual int       Type(void)   const   {  return defNeuronNODEOCL;   }
   //--- methods for working with files
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

Antes de tudo, é importante mencionar que, para possibilitar o trabalho com sequências de vários estados do ambiente, descritos por embeddings de várias características, criamos um objeto capaz de trabalhar com dados de entrada representados em 3 dimensões:

  • iDimension — o tamanho do vetor de embedding de uma característica em um estado específico do ambiente;
  • iVariables — o número de características que descrevem um estado do ambiente;
  • iLenth — o número de estados do sistema que serão analisados.

A função de EDO em nosso caso será representada por duas camadas totalmente conectadas com a função de ativação ReLU entre elas. No entanto, assumimos que a dinâmica de cada característica individual pode ser diferente. Portanto, para cada característica, teremos matrizes de pesos próprias. Essa abordagem não nos permite usar camadas convolucionais como internas, como foi feito anteriormente. Por isso, na nossa nova classe, vamos decompor as camadas internas da função EDO. Declararemos buffers de dados que compõem as camadas internas. Em seguida, criaremos kernels e métodos para implementar os processos.

2.1 Kernel de propagação da função

Ao construir o kernel de propagação da função EDO, partimos das seguintes restrições:

  • Cada estado do ambiente é descrito por um número fixo e igual de características;
  • Todas as características têm o mesmo tamanho fixo de embedding.

Considerando essas restrições, criamos o kernel FeedForwardNODEF no lado do programa OpenCL. Nos parâmetros do nosso kernel, passaremos ponteiros para 3 buffers de dados e 3 variáveis. O kernel será executado em um espaço de tarefas tridimensional.

__kernel void FeedForwardNODEF(__global float *matrix_w,            ///<[in] Weights matrix 
                               __global float *matrix_i,            ///<[in] Inputs tensor
                               __global float *matrix_o,            ///<[out] Output tensor
                               int dimension,                       ///< input dimension
                               float step,                          ///< h
                               int activation                       ///< Activation type (#ENUM_ACTIVATION)
                              )
  {
   int d = get_global_id(0);
   int dimension_out = get_global_size(0);
   int v = get_global_id(1);
   int variables = get_global_size(1);
   int i = get_global_id(2);
   int lenth = get_global_size(2);

No corpo do kernel, primeiro identificamos o fluxo atual em todas as 3 dimensões do espaço de tarefas. Depois, determinamos os deslocamentos nos buffers de dados até os dados que estão sendo analisados.

   int shift = variables * i + v;
   int input_shift = shift * dimension;
   int output_shift = shift * dimension_out + d;
   int weight_shift = (v * dimension_out + d) * (dimension + 2);

Após a preparação, em um ciclo, calculamos os valores do resultado atual multiplicando o vetor de dados de entrada pelo vetor correspondente de coeficientes de pesos.

   float sum = matrix_w[dimension + 1 + weight_shift] + matrix_w[dimension + weight_shift] * step;
   for(int w = 0; w < dimension; w++)
      sum += matrix_w[w + weight_shift] * matrix_i[input_shift + w];

Aqui é importante destacar que a função de EDO depende não apenas do estado do ambiente, mas também da marca temporal. Neste caso, a marca temporal é única para todo o estado do ambiente. E para evitar duplicá-la em função do número de características e do comprimento da sequência, não a incluímos no tensor de dados de entrada, mas simplesmente passamos o parâmetro step no kernel.

Em seguida, precisamos apenas passar o valor obtido pela função de ativação e salvar o resultado no elemento correspondente do buffer.

   if(isnan(sum))
      sum = 0;
   switch(activation)
     {
      case 0:
         sum = tanh(sum);
         break;
      case 1:
         sum = 1 / (1 + exp(-clamp(sum, -20.0f, 20.0f)));
         break;
      case 2:
         if(sum < 0)
            sum *= 0.01f;
         break;
      default:
         break;
     }
   matrix_o[output_shift] = sum;
  }

2.2 Kernel de propagação reversa da função

Após implementar o kernel de propagação da função, imediatamente criamos, no lado do programa OpenCL, a funcionalidade reversa — o kernel de distribuição do gradiente de erro HiddenGradientNODEF.

__kernel void HiddenGradientNODEF(__global float *matrix_w,            ///<[in] Weights matrix
                                  __global float *matrix_g,            ///<[in] Gradient tensor
                                  __global float *matrix_i,            ///<[in] Inputs tensor
                                  __global float *matrix_ig,           ///<[out] Inputs Gradient tensor
                                  int dimension_out,                   ///< output dimension
                                  int activation                       ///< Input Activation type (#ENUM_ACTIVATION)
                                 )
  {
   int d = get_global_id(0);
   int dimension = get_global_size(0);
   int v = get_global_id(1);
   int variables = get_global_size(1);
   int i = get_global_id(2);
   int lenth = get_global_size(2);

Planejamos executar esse kernel também em um espaço tridimensional de tarefas, cuja identificação do fluxo ocorre no corpo do kernel. Nesse ponto, determinamos os deslocamentos nos buffers de dados até os elementos em análise.

   int shift = variables * i + v;
   int input_shift = shift * dimension + d;
   int output_shift = shift * dimension_out;
   int weight_step = (dimension + 2);
   int weight_shift = (v * dimension_out) * weight_step + d;

Em seguida, somamos o gradiente de erro para o elemento analisado dos dados de entrada.

   float sum = 0;
   for(int k = 0; k < dimension_out; k ++)
      sum += matrix_g[output_shift + k] * matrix_w[weight_shift + k * weight_step];
   if(isnan(sum))
      sum = 0;

Aqui vale a pena observar que a marca temporal, essencialmente, é uma constante para um estado específico. Portanto, não distribuímos o gradiente de erro para ela.

Corrigimos a soma obtida pela derivada da função de ativação e salvamos o valor resultante no elemento correspondente do buffer de dados.

   float out = matrix_i[input_shift];
   switch(activation)
     {
      case 0:
         out = clamp(out, -1.0f, 1.0f);
         sum = clamp(sum + out, -1.0f, 1.0f) - out;
         sum = sum * max(1 - pow(out, 2), 1.0e-4f);
         break;
      case 1:
         out = clamp(out, 0.0f, 1.0f);
         sum = clamp(sum + out, 0.0f, 1.0f) - out;
         sum = sum * max(out * (1 - out), 1.0e-4f);
         break;
      case 2:
         if(out < 0)
            sum *= 0.01f;
         break;
      default:
         break;
     }
//---
   matrix_ig[input_shift] = sum;
  }

2.3 Solucionador de EDO

Concluímos a primeira etapa do trabalho. Agora, vamos examinar o solucionador de EDO. Para sua implementação, escolhi o método Dormand-Prince de quinta ordem.

onde

É fácil perceber que a função de solução e correção dos dados de entrada para calcular os coeficientes k1..k6 difere apenas nos coeficientes numéricos. Como os coeficientes ki ausentes podem ser adicionados multiplicando-os por zero, o que não afetará o resultado, unificamos o processo criando um único kernel FeedForwardNODEInpK no lado do programa OpenCL. Nos parâmetros do kernel, passamos os ponteiros para os buffers de dados de entrada e todos os coeficientes ki. Os multiplicadores necessários serão especificados no buffer matrix_beta.

__kernel void FeedForwardNODEInpK(__global float *matrix_i,            ///<[in] Inputs tensor
                                  __global float *matrix_k1,           ///<[in] K1 tensor
                                  __global float *matrix_k2,           ///<[in] K2 tensor
                                  __global float *matrix_k3,           ///<[in] K3 tensor
                                  __global float *matrix_k4,           ///<[in] K4 tensor
                                  __global float *matrix_k5,           ///<[in] K5 tensor
                                  __global float *matrix_k6,           ///<[in] K6 tenтor
                                  __global float *matrix_beta,         ///<[in] beta tensor
                                  __global float *matrix_o             ///<[out] Output tensor
                                 )
  {
   int i = get_global_id(0);

O kernel será executado em um espaço unidimensional de tarefas, com cálculo paralelo dos valores para cada elemento do buffer de resultados.

Após a identificação do fluxo, somaremos em um ciclo os produtos das multiplicações.

   float sum = matrix_i[i];
   for(int b = 0; b < 6; b++)
     {
      float beta = matrix_beta[b];
      if(beta == 0.0f || isnan(beta))
         continue;
      //---
      float val = 0.0f;
      switch(b)
        {
         case 0:
            val = matrix_k1[i];
            break;
         case 1:
            val = matrix_k2[i];
            break;
         case 2:
            val = matrix_k3[i];
            break;
         case 3:
            val = matrix_k4[i];
            break;
         case 4:
            val = matrix_k5[i];
            break;
         case 5:
            val = matrix_k6[i];
            break;
        }
      if(val == 0.0f || isnan(val))
         continue;
      //---
      sum += val * beta;
     }

O valor obtido será salvo no elemento correspondente do buffer de resultados.

   matrix_o[i] = sum;
  }

Para a propagação reversa, criaremos o kernel HiddenGradientNODEInpK, no qual distribuiremos o gradiente de erro nos buffers de dados correspondentes, considerando os mesmos coeficientes Beta.

__kernel void HiddenGradientNODEInpK(__global float *matrix_ig,            ///<[in] Inputs tensor
                                     __global float *matrix_k1g,           ///<[in] K1 tensor
                                     __global float *matrix_k2g,           ///<[in] K2 tensor
                                     __global float *matrix_k3g,           ///<[in] K3 tensor
                                     __global float *matrix_k4g,           ///<[in] K4 tensor
                                     __global float *matrix_k5g,           ///<[in] K5 tensor
                                     __global float *matrix_k6g,           ///<[in] K6 tensor
                                     __global float *matrix_beta,          ///<[in] beta tensor
                                     __global float *matrix_og             ///<[out] Output tensor
                                    )
  {
   int i = get_global_id(0);
//---
   float grad = matrix_og[i];
   matrix_ig[i] = grad;
   for(int b = 0; b < 6; b++)
     {
      float beta = matrix_beta[b];
      if(isnan(beta))
         beta = 0.0f;
      //---
      float val = beta * grad;
      if(isnan(val))
         val = 0.0f;
      switch(b)
        {
         case 0:
            matrix_k1g[i] = val;
            break;
         case 1:
            matrix_k2g[i] = val;
            break;
         case 2:
            matrix_k3g[i] = val;
            break;
         case 3:
            matrix_k4g[i] = val;
            break;
         case 4:
            matrix_k5g[i] = val;
            break;
         case 5:
            matrix_k6g[i] = val;
            break;
        }
     }
  }

Observe que escrevemos valores nulos nos buffers de dados. Isso é necessário para evitar a contabilização dupla dos valores previamente salvos.

2.4 Kernel de Atualização de Pesos

Para finalizar o trabalho no lado do programa OpenCL, criaremos imediatamente o kernel de atualização dos coeficientes de peso da função de EDO. Como pode ser visto nas fórmulas apresentadas anteriormente, a função de EDO será usada para determinar todos os coeficientes ki. Consequentemente, ao ajustar os coeficientes de peso, precisamos reunir o gradiente de erro de todas as operações. Nenhum dos kernels de atualização de pesos que criamos anteriormente trabalhou com essa quantidade de buffers de gradientes. Portanto, precisamos criar um novo kernel. Para simplificar o experimento, criaremos apenas o kernel NODEF_UpdateWeightsAdam para atualizar os parâmetros usando o método Adam, que utilizo com mais frequência.

__kernel void NODEF_UpdateWeightsAdam(__global float *matrix_w,           ///<[in,out] Weights matrix 
                                      __global const float *matrix_gk1,   ///<[in] Tensor of gradients at k1
                                      __global const float *matrix_gk2,   ///<[in] Tensor of gradients at k2
                                      __global const float *matrix_gk3,   ///<[in] Tensor of gradients at k3
                                      __global const float *matrix_gk4,   ///<[in] Tensor of gradients at k4
                                      __global const float *matrix_gk5,   ///<[in] Tensor of gradients at k5
                                      __global const float *matrix_gk6,   ///<[in] Tensor of gradients at k6
                                      __global const float *matrix_ik1,   ///<[in] Inputs tensor
                                      __global const float *matrix_ik2,   ///<[in] Inputs tensor
                                      __global const float *matrix_ik3,   ///<[in] Inputs tensor
                                      __global const float *matrix_ik4,   ///<[in] Inputs tensor
                                      __global const float *matrix_ik5,   ///<[in] Inputs tensor
                                      __global const float *matrix_ik6,   ///<[in] Inputs tensor
                                      __global float *matrix_m,           ///<[in,out] Matrix of first momentum
                                      __global float *matrix_v,           ///<[in,out] Matrix of seconfd momentum
                                      __global const float *alpha,        ///< h
                                      const int lenth,                    ///< Number of inputs
                                      const float l,                      ///< Learning rates
                                      const float b1,                     ///< First momentum multiplier
                                      const float b2                      ///< Second momentum multiplier
                                     )
  {
   const int d_in = get_global_id(0);
   const int dimension_in = get_global_size(0);
   const int d_out = get_global_id(1);
   const int dimension_out = get_global_size(1);
   const int v = get_global_id(2);
   const int variables = get_global_id(2);

Como já mencionado anteriormente, os parâmetros do kernel incluem ponteiros para uma quantidade considerável de buffers de dados globais. A estes, somam-se os parâmetros padrão do método de otimização utilizado.

Planejamos executar o kernel em um espaço tridimensional de tarefas, que considera a dimensionalidade dos vetores de embeddings dos dados de entrada e dos resultados, assim como o número de características analisadas. No corpo do kernel, identificamos o fluxo no espaço de tarefas em todas as três dimensões. Em seguida, determinamos os deslocamentos nos buffers de dados.

   const int weight_shift = (v * dimension_out + d_out) * dimension_in;
   const int input_step = variables * (dimension_in - 2);
   const int input_shift = v * (dimension_in - 2) + d_in;
   const int output_step = variables * dimension_out;
   const int output_shift = v * dimension_out + d_out;

 Em seguida, em um ciclo, reunimos o gradiente de erro para todos os estados do ambiente.

   float weight = matrix_w[weight_shift];
   float g = 0;
   for(int i = 0; i < lenth; i++)
     {
      int shift_g = i * output_step + output_shift;
      int shift_i = i * input_step + input_shift;
      switch(dimension_in - d_in)
        {
         case 1:
            g += matrix_gk1[shift_g] + matrix_gk2[shift_g] +
                 matrix_gk3[shift_g] + matrix_gk4[shift_g] +
                 matrix_gk5[shift_g] + matrix_gk6[shift_g];
            break;
         case 2:
            g += matrix_gk1[shift_g] * alpha[0] +
                 matrix_gk2[shift_g] * alpha[1] +
                 matrix_gk3[shift_g] * alpha[2] +
                 matrix_gk4[shift_g] * alpha[3] +
                 matrix_gk5[shift_g] * alpha[4] +
                 matrix_gk6[shift_g] * alpha[5];
            break;
         default:
            g += matrix_gk1[shift_g] * matrix_ik1[shift_i] +
                 matrix_gk2[shift_g] * matrix_ik2[shift_i] +
                 matrix_gk3[shift_g] * matrix_ik3[shift_i] +
                 matrix_gk4[shift_g] * matrix_ik4[shift_i] +
                 matrix_gk5[shift_g] * matrix_ik5[shift_i] +
                 matrix_gk6[shift_g] * matrix_ik6[shift_i];
            break;
        }
     }

Depois, ajustamos os pesos conforme o algoritmo já implementado.

   float mt = b1 * matrix_m[weight_shift] + (1 - b1) * g;
   float vt = b2 * matrix_v[weight_shift] + (1 - b2) * pow(g, 2);
   float delta = l * (mt / (sqrt(vt) + 1.0e-37f) - (l1 * sign(weight) + l2 * weight));

No final do kernel, salvamos os resultados e os valores auxiliares nos elementos correspondentes dos buffers de dados.

   if(delta * g > 0)
      matrix_w[weight_shift] = clamp(matrix_w[weight_shift] + delta, -MAX_WEIGHT, MAX_WEIGHT);
   matrix_m[weight_shift] = mt;
   matrix_v[weight_shift] = vt;
  }

Com isso, concluímos o trabalho no lado do programa OpenCL e voltamos à implementação da nossa classe CNeuronNODEOCL.

2.5 Método de inicialização da classe CNeuronNODEOCL

A inicialização do nosso objeto de classe é realizada no método CNeuronNODEOCL::Init. Nos parâmetros do método, como de costume, passaremos os principais parâmetros da arquitetura do objeto.

bool CNeuronNODEOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint dimension, uint variables, uint lenth,
                          ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, dimension * variables * lenth, optimization_type, batch))
      return false;

No corpo do método, a primeira coisa que fazemos é chamar o método equivalente da classe pai, onde é realizado o controle dos parâmetros recebidos e a inicialização dos objetos herdados. Podemos verificar o resultado geral das operações no corpo da classe pai por meio do valor lógico retornado.

Em seguida, armazenamos os parâmetros da arquitetura do objeto em variáveis locais da classe.

   iDimension = dimension;
   iVariables = variables;
   iLenth = lenth;

Declaramos variáveis auxiliares e atribuímos os valores necessários a elas.

   uint mult = 2;
   uint weights = (iDimension + 2) * iDimension * iVariables;

Agora, vejamos os buffers dos coeficientes ki e dos dados corrigidos usados para seu cálculo. Não é difícil perceber que os valores nesses buffers de dados são mantidos da passagem direta até a reversa. Durante a próxima passagem direta, os valores são sobrescritos. Portanto, para economizar recursos, não criaremos esses buffers na memória principal do programa. Eles serão criados apenas no lado do contexto OpenCL. Na classe, criamos apenas arrays para armazenar os ponteiros para eles. Em cada array, criamos três vezes mais elementos do que os coeficientes (k) utilizados. Isso é necessário para coletar os gradientes de erro.

   if(ArrayResize(iBuffersK, 18) < 18)
      return false;
   if(ArrayResize(iInputsK, 18) < 18)
      return false;

Fazemos o mesmo com os valores intermediários dos cálculos, com o tamanho do array sendo menor.

   if(ArrayResize(iMeadl, 12) < 12)
      return false;

Para melhorar a legibilidade do código, criaremos os buffers em um ciclo.

   for(uint i = 0; i < 18; i++)
     {
      iBuffersK[i] = OpenCL.AddBuffer(sizeof(float) * Output.Total(), CL_MEM_READ_WRITE);
      if(iBuffersK[i] < 0)
         return false;
      iInputsK[i] = OpenCL.AddBuffer(sizeof(float) * Output.Total(), CL_MEM_READ_WRITE);
      if(iInputsK[i] < 0)
         return false;
      if(i > 11)
         continue;
      //--- Initilize Meadl Output and Gradient buffers
      iMeadl[i] = OpenCL.AddBuffer(sizeof(float) * Output.Total(), CL_MEM_READ_WRITE);
      if(iMeadl[i] < 0)
         return false;
     }

Na etapa seguinte, criamos as matrizes de coeficientes de peso da função EDO e os momentos correspondentes. Como já foi mencionado anteriormente, utilizaremos 2 camadas.

//--- Initilize Weights
   for(int i = 0; i < 2; i++)
     {
      temp = new CBufferFloat();
      if(CheckPointer(temp) == POINTER_INVALID)
         return false;
      if(!temp.Reserve(weights))
         return false;
      float k = (float)(1 / sqrt(iDimension + 2));
      for(uint w = 0; w < weights; w++)
        {
         if(!temp.Add((GenerateWeight() - 0.5f)* k))
            return false;
        }
      if(!temp.BufferCreate(OpenCL))
         return false;
      if(!cWeights.Add(temp))
         return false;
      for(uint d = 0; d < 2; d++)
        {
         temp = new CBufferFloat();
         if(CheckPointer(temp) == POINTER_INVALID)
            return false;
         if(!temp.BufferInit(weights, 0))
            return false;
         if(!temp.BufferCreate(OpenCL))
            return false;
         if(!cWeights.Add(temp))
            return false;
        }
     }

Em seguida, criaremos os buffers dos multiplicadores constantes:

  • Passo de tempo Alpha

     {
      float temp_ar[] = {0, 0.2f, 0.3f, 0.8f, 8.0f / 9, 1, 1};
      if(!cAlpha.AssignArray(temp_ar))
         return false;
      if(!cAlpha.BufferCreate(OpenCL))
         return false;
     }

  • Correções dos dados de entrada

//--- Beta K1
     {
      float temp_ar[] = {0, 0, 0, 0, 0, 0};
      temp = new CBufferFloat();
      if(!temp || !temp.AssignArray(temp_ar))
        {
         delete temp;
         return false;
        }
      if(!temp.BufferCreate(OpenCL))
        {
         delete temp;
         return false;
        }
      if(!cBeta.Add(temp))
        {
         delete temp;
         return false;
        }
     }
//--- Beta K2
     {
      float temp_ar[] = {0.2f, 0, 0, 0, 0, 0};
      temp = new CBufferFloat();
      if(!temp || !temp.AssignArray(temp_ar))
        {
         delete temp;
         return false;
        }
      if(!temp.BufferCreate(OpenCL))
        {
         delete temp;
         return false;
        }
      if(!cBeta.Add(temp))
        {
         delete temp;
         return false;
        }
     }
//--- Beta K3
     {
      float temp_ar[] = {3.0f / 40, 9.0f / 40, 0, 0, 0, 0};
      temp = new CBufferFloat();
      if(!temp || !temp.AssignArray(temp_ar))
        {
         delete temp;
         return false;
        }
      if(!temp.BufferCreate(OpenCL))
        {
         delete temp;
         return false;
        }
      if(!cBeta.Add(temp))
        {
         delete temp;
         return false;
        }
     }
//--- Beta K4
     {
      float temp_ar[] = {44.0f / 44, -56.0f / 15, 32.0f / 9, 0, 0, 0};
      temp = new CBufferFloat();
      if(!temp || !temp.AssignArray(temp_ar))
        {
         delete temp;
         return false;
        }
      if(!temp.BufferCreate(OpenCL))
        {
         delete temp;
         return false;
        }
      if(!cBeta.Add(temp))
        {
         delete temp;
         return false;
        }
     }
//--- Beta K5
     {
      float temp_ar[] = {19372.0f / 6561, -25360 / 2187.0f, 64448 / 6561.0f, -212.0f / 729, 0, 0};
      temp = new CBufferFloat();
      if(!temp || !temp.AssignArray(temp_ar))
        {
         delete temp;
         return false;
        }
      if(!temp.BufferCreate(OpenCL))
        {
         delete temp;
         return false;
        }
      if(!cBeta.Add(temp))
        {
         delete temp;
         return false;
        }
     }
//--- Beta K6
     {
      float temp_ar[] = {9017 / 3168.0f, -355 / 33.0f, 46732 / 5247.0f, 49.0f / 176, -5103.0f / 18656, 0};
      temp = new CBufferFloat();
      if(!temp || !temp.AssignArray(temp_ar))
        {
         delete temp;
         return false;
        }
      if(!temp.BufferCreate(OpenCL))
        {
         delete temp;
         return false;
        }
      if(!cBeta.Add(temp))
        {
         delete temp;
         return false;
        }
     }

  • Soluções das EDOs

     {
      float temp_ar[] = {35.0f / 384, 0, 500.0f / 1113, 125.0f / 192, -2187.0f / 6784, 11.0f / 84};
      if(!cSolution.AssignArray(temp_ar))
         return false;
      if(!cSolution.BufferCreate(OpenCL))
         return false;
     }

No final do método de inicialização, adicionaremos um buffer local para armazenar valores intermediários.

   if(!cTemp.BufferInit(Output.Total(), 0) ||
      !cTemp.BufferCreate(OpenCL))
      return false;
//---
   return true;
  }

2.6 Configuração da propagação para frente

Após a inicialização do objeto da classe, passamos para o desenvolvimento do algoritmo de propagação. Aqui é importante lembrar que, anteriormente, criamos 2 kernels no lado do programa OpenCL para configurar a propagação para frente. Portanto, precisamos criar métodos para chamá-los. Começaremos com um método relativamente simples, o CalculateInputK, que prepara os dados de entrada para o cálculo dos coeficientes k.

bool CNeuronNODEOCL::CalculateInputK(CBufferFloat* inputs, int k)
  {
   if(k < 0)
      return false;
   if(iInputsK.Size()/3 <= uint(k))
      return false;

Nos parâmetros do método, recebemos um ponteiro para o buffer de dados de entrada, obtido da camada anterior, e o índice do coeficiente a ser calculado. No corpo do método, verificamos se o índice do coeficiente corresponde à nossa arquitetura.

Após passar no bloco de verificação, lidamos com o caso específico para k1.

Neste caso, não chamamos a execução do kernel, mas simplesmente copiamos o ponteiro para o buffer de dados de entrada.

   if(k == 0)
     {
      if(iInputsK[k] != inputs.GetIndex())
        {
         OpenCL.BufferFree(iInputsK[k]);
         iInputsK[k] = inputs.GetIndex();
        }
      return true;
     }

De forma geral, chamaremos o kernel FeedForwardNODEInpK e gravaremos os dados de entrada corrigidos no buffer correspondente. Para isso, primeiro definimos o espaço de tarefas. Neste caso, unidimensional.

   uint global_work_offset[1] = {0};
   uint global_work_size[1] = {Neurons()};

Passamos os ponteiros dos buffers como parâmetros do kernel.

   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_i, inputs.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_k1, iBuffersK[0]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_k2, iBuffersK[1]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_k3, iBuffersK[2]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_k4, iBuffersK[3]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_k5, iBuffersK[4]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_k6, iBuffersK[5]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_beta, 
                                                            ((CBufferFloat *)cBeta.At(k)).GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_o, iInputsK[k]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

E colocamos o kernel na fila de execução.

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

Após a correção dos dados de entrada, precisamos calcular os valores dos coeficientes. Esse processo é realizado no método CalculateKBuffer. Como o método trabalha apenas com objetos internos, para realizar as operações basta indicar o índice do coeficiente necessário nos parâmetros do método.

bool CNeuronNODEOCL::CalculateKBuffer(int k)
  {
   if(k < 0)
      return false;
   if(iInputsK.Size()/3 <= uint(k))
      return false;

No corpo do método, verificamos se o índice recebido é compatível com a arquitetura da classe.

Depois, definimos o espaço tridimensional de tarefas.

   uint global_work_offset[3] = {0, 0, 0};
   uint global_work_size[3] = {iDimension, iVariables, iLenth};

Em seguida, passamos os parâmetros ao kernel para a execução da primeira camada. Aqui, utilizamos LReLU para introduzir a não linearidade.

   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEF, def_k_ffdoprif_matrix_i, iInputsK[k]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEF, def_k_ffdoprif_matrix_w, ((CBufferFloat*)cWeights.At(0)).GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEF, def_k_ffdoprif_matrix_o, iMeadl[k * 2]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_FeedForwardNODEF, def_k_ffdoprif_dimension, int(iDimension)))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_FeedForwardNODEF, def_k_ffdoprif_step, float(cAlpha.At(k))))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_FeedForwardNODEF, def_k_ffdoprif_activation, int(LReLU)))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

E colocamos o kernel na fila de execução.

   if(!OpenCL.Execute(def_k_FeedForwardNODEF, 3, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }

O próximo passo é realizar a propagação pela segunda camada. O espaço de tarefas permanece o mesmo. Por isso, não alteramos os arrays correspondentes. Precisamos novamente passar os parâmetros ao kernel. Aqui, alteramos os buffers de dados de entrada, os coeficientes de peso e os resultados.

   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEF, def_k_ffdoprif_matrix_i, iMeadl[k * 2]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEF, def_k_ffdoprif_matrix_w, ((CBufferFloat*)cWeights.At(3)).GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEF, def_k_ffdoprif_matrix_o, iBuffersK[k]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

Além disso, não utilizamos a função de ativação.

   if(!OpenCL.SetArgument(def_k_FeedForwardNODEF, def_k_ffdoprif_activation, int(None)))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

Os demais parâmetros permanecem inalterados.

   if(!OpenCL.SetArgument(def_k_FeedForwardNODEF, def_k_ffdoprif_dimension, int(iDimension)))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_FeedForwardNODEF, def_k_ffdoprif_step, cAlpha.At(k)))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

E enviamos o kernel para a fila de execução.

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

Depois de calcular todos os coeficientes k, podemos determinar o resultado da solução da EDO. Na prática, utilizaremos o kernel FeedForwardNODEInpK para esse fim, cuja chamada já foi implementada no método CalculateInputK. No entanto, neste caso, precisamos alterar os buffers de dados utilizados. Por isso, reescreveremos o algoritmo no método CalculateOutput.

bool CNeuronNODEOCL::CalculateOutput(CBufferFloat* inputs)
  {
//---
   uint global_work_offset[1] = {0};
   uint global_work_size[1] = {Neurons()};

Nos parâmetros deste método, recebemos apenas um ponteiro para o buffer de dados de entrada. No corpo do método, imediatamente definimos o espaço unidimensional de tarefas. Em seguida, passamos os ponteiros dos buffers de dados de entrada como parâmetros do kernel.

   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_i, inputs.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_k1, iBuffersK[0]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_k2, iBuffersK[1]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_k3, iBuffersK[2]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_k4, iBuffersK[3]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_k5, iBuffersK[4]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_k6, iBuffersK[5]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

Como multiplicadores, indicaremos o buffer de coeficientes da solução de EDO.

   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_beta, cSolution.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

E os resultados serão gravados no buffer de resultados da nossa classe.

   if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardNODEInpK, def_k_ffdopriInp_matrix_o, Output.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

E colocamos o kernel na fila de execução.

   if(!OpenCL.Execute(def_k_FeedForwardNODEInpK, 1, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }

Os valores obtidos serão somados aos dados de entrada e normalizados.

   if(!SumAndNormilize(Output, inputs, Output, iDimension, true, 0, 0, 0, 1))
      return false;
//---
   return true;
  }

Anteriormente, preparamos métodos para chamar os kernels que realizam o processo de propagação para frente. Agora, resta apenas estabelecer o algoritmo no método de alto nível CNeuronNODEOCL::feedForward.

bool CNeuronNODEOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   for(int k = 0; k < 6; k++)
     {
      if(!CalculateInputK(NeuronOCL.getOutput(), k))
         return false;
      if(!CalculateKBuffer(k))
         return false;
     }
//---
   return CalculateOutput(NeuronOCL.getOutput());
  }

Nos parâmetros, o método recebe um ponteiro para o objeto da camada anterior. No corpo do método, fazemos um loop, no qual corrigimos sequencialmente os dados de entrada e calculamos todos os coeficientes k. Durante cada iteração, monitoramos o processo de execução das operações. E, após calcular com sucesso os coeficientes necessários, chamamos o método de solução da EDO. Graças ao extenso trabalho preparatório, o algoritmo de alto nível do método ficou bastante conciso.

2.7 Preparação da propagação reversa

O algoritmo de propagação para frente garante o processo de utilização do modelo. No entanto, o treinamento do modelo é inseparável do processo de propagação reversa. É nesse estágio que os parâmetros de aprendizado são ajustados para minimizar o erro do modelo.

Assim como fizemos com os kernels de propagação para frente, no lado do programa OpenCL criamos dois kernels de propagação reversa. E agora, no lado do programa principal, precisamos criar métodos para chamar esses kernels de propagação reversa. E como estamos organizando a propagação reversa, a execução dos métodos será realizada na sequência da propagação reversa.

Ao receber o gradiente de erro da próxima camada, distribuímos o gradiente recebido entre a camada de dados de entrada e os coeficientes k. Esse processo é feito no método CalculateOutputGradient, que chama o kernel HiddenGradientNODEInpK.

bool CNeuronNODEOCL::CalculateOutputGradient(CBufferFloat *inputs)
  {
//---
   uint global_work_offset[1] = {0};
   uint global_work_size[1] = {Neurons()};

Nos parâmetros do método, recebemos um ponteiro para o buffer de gradientes de erro da camada anterior. No corpo do método, geramos o processo de chamada do kernel do programa OpenCL. Primeiro, definimos o espaço unidimensional de tarefas. Em seguida, passamos os ponteiros dos buffers de dados como parâmetros do kernel.

Vale destacar que os parâmetros do kernel HiddenGradientNODEInpK são idênticos aos do kernel FeedForwardNODEInpK. A única diferença é que, na propagação para frente, foram utilizados os buffers de dados de entrada e os coeficientes k. Enquanto na propagação reversa, são utilizados os buffers dos gradientes correspondentes. Por essa razão, não redefini as constantes dos buffers do kernel, mas reutilizei as constantes da propagação para frente.

   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_i, inputs.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_k1, iBuffersK[6]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_k2, iBuffersK[7]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_k3, iBuffersK[8]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_k4, iBuffersK[9]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_k5, iBuffersK[10]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_k6, iBuffersK[11]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_beta, cSolution.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_o, Gradient.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

Outro ponto importante é que, para gravar os coeficientes k, utilizamos buffers com índices no intervalo [0, 5]. Neste caso, para gravar os gradientes de erro, utilizamos buffers com índices no intervalo [6, 11].

Após transmitir com sucesso todos os parâmetros ao kernel, colocamos o kernel na fila de execução.

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

Agora, vamos considerar o método CalculateInputKGradient, que chama o mesmo kernel. No entanto, há nuances na construção do algoritmo que gostaria de destacar.

Primeiro, é claro, os parâmetros do método. Aqui, é adicionado o índice do coeficiente k.

bool CNeuronNODEOCL::CalculateInputKGradient(CBufferFloat *inputs, int k)
  {
//---
   uint global_work_offset[1] = {0};
   uint global_work_size[1] = {Neurons()};

No corpo do método, definimos o mesmo espaço unidimensional de tarefas. Em seguida, passamos os parâmetros para o kernel.

   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_i, inputs.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

Desta vez, para armazenar os gradientes de erro dos coeficientes k, utilizamos buffers com índices no intervalo [12, 17]. Isso é necessário para acumular os gradientes de erro para cada coeficiente.

   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_k1, iBuffersK[12]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_k2, iBuffersK[13]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_k3, iBuffersK[14]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_k4, iBuffersK[15]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_k5, iBuffersK[16]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_k6, iBuffersK[17]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

Além disso, utilizamos os multiplicadores do array cBeta.

   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_beta, 
                                                               ((CBufferFloat *)cBeta.At(k)).GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEInpK, def_k_ffdopriInp_matrix_o, iInputsK[k + 6]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

Após transmitir com sucesso todos os parâmetros necessários ao kernel, colocamos o kernel na fila de execução.

   if(!OpenCL.Execute(def_k_HiddenGradientNODEInpK, 1, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }

Em seguida, precisamos somar o gradiente de erro atual com o acumulado anteriormente para o coeficiente k correspondente. Para isso, fazemos um ciclo reverso, no qual somamos gradualmente os gradientes de erro, começando pelo coeficiente k em análise até o menor índice.

   for(int i = k - 1; i >= 0; i--)
     {
      float mult = 1.0f / (i == (k - 1) ? 6 - k : 1);
      uint global_work_offset[1] = {0};
      uint global_work_size[1] = {iLenth * iVariables};
      if(!OpenCL.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix1, iBuffersK[k + 6]))
        {
         printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
         return false;
        }
      if(!OpenCL.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix2, iBuffersK[k + 12]))
        {
         printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
         return false;
        }
      if(!OpenCL.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix_out, iBuffersK[k + 6]))
        {
         printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
         return false;
        }
      if(!OpenCL.SetArgument(def_k_MatrixSum, def_k_sum_dimension, iDimension))
        {
         printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
         return false;
        }
      if(!OpenCL.SetArgument(def_k_MatrixSum, def_k_sum_shift_in1, 0))
        {
         printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
         return false;
        }
      if(!OpenCL.SetArgument(def_k_MatrixSum, def_k_sum_shift_in2, 0))
        {
         printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
         return false;
        }
      if(!OpenCL.SetArgument(def_k_MatrixSum, def_k_sum_shift_out, 0))
        {
         printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
         return false;
        }
      if(!OpenCL.SetArgument(def_k_MatrixSum, def_k_sum_multiplyer, mult))
        {
         printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
         return false;
        }
      if(!OpenCL.Execute(def_k_MatrixSum, 1, global_work_offset, global_work_size))
        {
         string error;
         CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error);
         printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
         return false;
        }
     }
//---
   return true;
  }

Vale destacar que somamos apenas os gradientes de erro para os coeficientes k com índices menores que o atual. Isso ocorre porque o multiplicador ß para os coeficientes com índices maiores é evidentemente igual a "0". Esses coeficientes são calculados após o coeficiente atual e não participam da sua determinação. Consequentemente, o gradiente de erro deles será nulo. Além disso, para um treinamento mais estável, fazemos a média dos gradientes de erro acumulados.

O último kernel envolvido na distribuição do gradiente de erro é o kernel de distribuição do gradiente de erro através da camada interna da função EDO HiddenGradientNODEF. Ele é chamado no método CalculateKBufferGradient. Nos parâmetros, o método recebe apenas o índice do coeficiente k para o qual o gradiente está sendo distribuído.

bool CNeuronNODEOCL::CalculateKBufferGradient(int k)
  {
   if(k < 0)
      return false;
   if(iInputsK.Size()/3 <= uint(k))
      return false;

No corpo do método, verificamos se o índice recebido é compatível com a arquitetura do objeto. Em seguida, definimos o espaço tridimensional de tarefas.

   uint global_work_offset[3] = {0, 0, 0};
   uint global_work_size[3] = {iDimension, iVariables, iLenth};

E passamos os parâmetros para o kernel. Como estamos distribuindo o gradiente de erro no contexto da propagação reversa, começamos especificando os buffers das duas camadas da função.

   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEF, def_k_hddoprif_matrix_i, iMeadl[k * 2]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEF, def_k_hddoprif_matrix_ig, iMeadl[k * 2 + 1]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEF, def_k_hddoprif_matrix_w, ((CBufferFloat*)cWeights.At(3)).GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEF, def_k_hddoprif_matrix_g, iBuffersK[k + 6]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_HiddenGradientNODEF, def_k_hddoprif_dimension_out, int(iDimension)))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_HiddenGradientNODEF, def_k_hddoprif_activation, int(LReLU)))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

E colocamos o kernel na fila de execução.

   if(!OpenCL.Execute(def_k_HiddenGradientNODEF, 3, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }

O próximo passo, mantendo os arrays que definem o espaço de tarefas inalterados, é passar os dados da primeira camada da função como parâmetros para o kernel.

   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEF, def_k_hddoprif_matrix_i, iInputsK[k]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEF, def_k_hddoprif_matrix_ig, iInputsK[k + 12]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEF, def_k_hddoprif_matrix_w, ((CBufferFloat*)cWeights.At(0)).GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientNODEF, def_k_hddoprif_matrix_g, iMeadl[k * 2 + 1]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_HiddenGradientNODEF, def_k_hddoprif_dimension_out, int(iDimension)))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_HiddenGradientNODEF, def_k_hddoprif_activation, int(None)))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

E, por fim, executamos o kernel.

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

Acima, criamos os métodos para chamar os kernels de distribuição do gradiente de erro entre os objetos da camada. No entanto, neste estado, são apenas fragmentos de código que ainda não formam um algoritmo coeso. Precisamos agora unificá-los em um único algoritmo. Escrevemos o algoritmo geral de distribuição do gradiente de erro dentro da nossa classe usando o método calcInputGradients.

bool CNeuronNODEOCL::calcInputGradients(CNeuronBaseOCL *prevLayer)
  {
   if(!CalculateOutputGradient(prevLayer.getGradient()))
      return false;
   for(int k = 5; k >= 0; k--)
     {
      if(!CalculateKBufferGradient(k))
         return false;
      if(!CalculateInputKGradient(GetPointer(cTemp), k))
         return false;
      if(!SumAndNormilize(prevLayer.getGradient(), GetPointer(cTemp), prevLayer.getOutput(), iDimension, 
                                                                      false, 0, 0, 0, 1.0f / (k == 0 ? 6 : 1)))
         return false;
     }
//---
   return true;
  }

Nos parâmetros, o método recebe um ponteiro para o objeto da camada anterior, para o qual precisamos passar o gradiente de erro. Na primeira etapa, distribuímos o gradiente de erro recebido da camada subsequente entre a camada anterior e os coeficientes k segundo os multiplicadores da solução EDO. Como você deve lembrar, esse processo foi implementado no método CalculateOutputGradient.

Depois, configuramos o ciclo reverso de distribuição dos gradientes através da função EDO ao calcular os coeficientes correspondentes. Primeiro, passamos o gradiente de erro pelas duas camadas no método CalculateKBufferGradient. Em seguida, distribuímos o gradiente de erro obtido entre os respectivos k coeficientes e os dados de entrada no método CalculateInputKGradient. No entanto, em vez de usar o buffer de gradientes de erro da camada anterior, armazenamos os dados em um buffer temporário. Depois, somamos o gradiente obtido ao acumulado anteriormente no buffer de gradientes da camada anterior usando o método SumAndNormalize. Durante a última iteração, fazemos a média do gradiente de erro acumulado.

Neste estágio, distribuímos completamente o gradiente de erro entre todos os objetos que influenciam o resultado, de acordo com sua contribuição. Agora, só resta atualizar os parâmetros do modelo. Anteriormente, para realizar essa função, criamos o kernel NODEF_UpdateWeightsAdam. Agora, precisamos implementar a chamada desse kernel no programa principal. Essa funcionalidade é realizada no método updateInputWeights.

bool CNeuronNODEOCL::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   uint global_work_offset[3] = {0, 0, 0};
   uint global_work_size[3] = {iDimension + 2, iDimension, iVariables};

Nos parâmetros, o método recebe um ponteiro para o objeto da camada neural anterior, que neste caso é nominal e necessário apenas para o processo de virtualização dos métodos.

De fato, durante a propagação para frente e reversa, utilizamos os dados da camada anterior. E, para atualizar os parâmetros da primeira camada da função EDO, esses dados serão necessários. No entanto, a situação é tal que o ponteiro para o buffer de resultados da camada anterior na propagação para frente foi salvo no array iInputsK com o índice "0". É esse ponteiro que utilizaremos na nossa implementação.

No corpo do método, primeiro definimos o espaço tridimensional de tarefas. Em seguida, passamos os parâmetros necessários para o kernel. Inicialmente, atualizaremos os parâmetros da primeira camada.

   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_ik1, iInputsK[0]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_gk1, iMeadl[1]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_ik2, iInputsK[1]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_gk2, iMeadl[3]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_ik3, iInputsK[2]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_gk3, iMeadl[5]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_ik4, iInputsK[3]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_gk4, iMeadl[7]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_ik5, iInputsK[4]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_gk5, iMeadl[9]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_ik6, iInputsK[5]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_gk6, iMeadl[11]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_w, 
                                                                ((CBufferFloat*)cWeights.At(0)).GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_m, 
                                                                ((CBufferFloat*)cWeights.At(1)).GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_v, 
                                                                ((CBufferFloat*)cWeights.At(2)).GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_alpha, cAlpha.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_lenth, int(iLenth)))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_l, lr))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_b1, b1))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_b2, b2))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

E colocamos o kernel na fila de execução.

   if(!OpenCL.Execute(def_k_NODEF_UpdateWeightsAdam, 3, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }

Depois, repetiremos as operações para organizar o processo de atualização dos parâmetros da segunda camada.

   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_ik1, iMeadl[0]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_gk1, iBuffersK[6]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_ik2, iMeadl[2]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_gk2, iBuffersK[7]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_ik3, iMeadl[4]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_gk3, iBuffersK[8]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_ik4, iMeadl[6]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_gk4, iBuffersK[9]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_ik5, iMeadl[8]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_gk5, iBuffersK[10]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_ik6, iMeadl[10]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_gk6, iBuffersK[11]))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_w, 
                                                               ((CBufferFloat*)cWeights.At(3)).GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_m, 
                                                               ((CBufferFloat*)cWeights.At(4)).GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_matrix_v, 
                                                               ((CBufferFloat*)cWeights.At(5)).GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_alpha, cAlpha.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_lenth, int(iLenth)))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_l, lr))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_b1, b1))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_NODEF_UpdateWeightsAdam, def_k_uwdoprif_b2, b2))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.Execute(def_k_NODEF_UpdateWeightsAdam, 3, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }
//--
   return true;
  }

2.8 Métodos de manipulação de arquivos

Acima, discutimos os métodos que lidam com o processo principal na classe. No entanto, gostaria de dizer algumas palavras sobre os métodos de manipulação de arquivos. Se analisarmos cuidadosamente a estrutura dos objetos internos da classe, podemos identificar que a única coleção a ser salva é a cWeights, que contém os coeficientes de peso e os momentos de sua correção. Além disso, precisamos salvar três parâmetros que definem a arquitetura da classe. Faremos isso no método Save.

bool CNeuronNODEOCL::Save(const int file_handle)
  {
   if(!CNeuronBaseOCL::Save(file_handle))
      return false;
   if(!cWeights.Save(file_handle))
      return false;
   if(FileWriteInteger(file_handle, int(iDimension), INT_VALUE) < INT_VALUE ||
      FileWriteInteger(file_handle, int(iVariables), INT_VALUE) < INT_VALUE ||
      FileWriteInteger(file_handle, int(iLenth), INT_VALUE) < INT_VALUE)
      return false;
//---
   return true;
  }

Nos parâmetros, o método recebe um handle do arquivo para salvar os dados. E, logo no início do corpo do método, chamamos o método homônimo da classe pai. Em seguida, salvamos a coleção e as constantes.

O método de salvamento da classe é bastante conciso e permite economizar espaço em disco. No entanto, essa economia tem um custo no método de carregamento dos dados.

bool CNeuronNODEOCL::Load(const int file_handle)
  {
   if(!CNeuronBaseOCL::Load(file_handle))
      return false;
   if(!cWeights.Load(file_handle))
      return false;
   cWeights.SetOpenCL(OpenCL);
//---
   iDimension = (int)FileReadInteger(file_handle);
   iVariables = (int)FileReadInteger(file_handle);
   iLenth = (int)FileReadInteger(file_handle);

Aqui, primeiro carregamos os dados salvos. Em seguida, criamos os objetos faltantes segundo os parâmetros da arquitetura carregados.

//---
   CBufferFloat *temp = NULL;
   for(uint i = 0; i < 18; i++)
     {
      OpenCL.BufferFree(iBuffersK[i]);
      OpenCL.BufferFree(iInputsK[i]);
      //---
      iBuffersK[i] = OpenCL.AddBuffer(sizeof(float) * Output.Total(), CL_MEM_READ_WRITE);
      if(iBuffersK[i] < 0)
         return false;
      iInputsK[i] = OpenCL.AddBuffer(sizeof(float) * Output.Total(), CL_MEM_READ_WRITE);
      if(iBuffersK[i] < 0)
         return false;
      if(i > 11)
         continue;
      //--- Initilize Output and Gradient buffers
      OpenCL.BufferFree(iMeadl[i]);
      iMeadl[i] = OpenCL.AddBuffer(sizeof(float) * Output.Total(), CL_MEM_READ_WRITE);
      if(iMeadl[i] < 0)
         return false;
     }
//---
   cTemp.BufferFree();
   if(!cTemp.BufferInit(Output.Total(), 0) ||
      !cTemp.BufferCreate(OpenCL))
      return false;
//---
   return true;
  }

Com isso, concluímos a análise dos métodos da nossa nova classe CNeuronNODEOCL. O código completo da classe criada e de seus métodos, assim como de todos os programas utilizados na preparação deste artigo, está disponível no anexo.

2.9 Arquitetura dos modelos treináveis

Acima, criamos uma nova classe de camada neural baseada na solução de EDO CNeuronNODEOCL. O objeto desta classe será adicionado à arquitetura do Codificador, criado na artigo anterior.

Como sempre, a arquitetura dos modelos é especificada no método CreateDescriptions, no qual são passados ponteiros para três arrays dinâmicos que indicam a arquitetura dos modelos a serem criados.

bool CreateDescriptions(CArrayObj *encoder, CArrayObj *actor, CArrayObj *critic)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
   if(!critic)
     {
      critic = new CArrayObj();
      if(!critic)
         return false;
     }

No corpo do método, verificamos os ponteiros recebidos e, se necessário, criamos novos objetos para os arrays.

Na entrada do modelo Codificador, alimentamos os dados "brutos" que descrevem o estado do ambiente.

//--- Encoder
   encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Esses dados passam por um processamento inicial na camada de normalização em lotes.

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

Em seguida, geramos os embeddings dos estados recebidos usando a camada de Embedding e uma camada convolucional subsequente.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronEmbeddingOCL;
     {
      int temp[] = {prev_count};
      ArrayCopy(descr.windows, temp);
     }
   prev_count = descr.count = GPTBars;
   int prev_wout = descr.window_out = EmbeddingSize / 2;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = prev_count;
   descr.step = descr.window = prev_wout;
   prev_wout = descr.window_out = EmbeddingSize;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Os embeddings gerados são complementados com codificação posicional.

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronPEOCL;
   descr.count = prev_count;
   descr.window = prev_wout;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Depois, utilizamos uma camada complexa de análise de dados considerando o contexto.

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronCCMROCL;
   descr.count = prev_count;
   descr.window = prev_wout;
   descr.window_out = EmbeddingSize;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Até esse ponto, replicamos completamente o modelo apresentado na artigo anterior. No entanto, agora adicionamos duas camadas da nova classe.

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronNODEOCL;
   descr.count = prev_count;
   descr.window = EmbeddingSize/4;
   descr.step = 4;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronNODEOCL;
   descr.count = prev_count;
   descr.window = EmbeddingSize/4;
   descr.step = 4;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Os modelos do Ator e do Crítico foram completamente transferidos do artigo anterior sem alterações. Portanto, não entraremos em detalhes sobre eles agora.

A adição das novas camadas não afeta os processos de interação com o ambiente nem o treinamento dos modelos. Consequentemente, todos os EAs foram transferidos sem alterações. O código completo de todos os programas utilizados na preparação da artigo pode ser encontrado no anexo. Agora, passamos para a próxima etapa — o teste do trabalho realizado.


3. Testes

Acima, nos familiarizamos com a nova família de modelos de equações diferenciais ordinárias. E, com base nas abordagens propostas, implementamos no MQL5 uma nova classe CNeuronNODEOCL para preparar a camada neural em nossos modelos. Agora, passamos para a terceira etapa do nosso trabalho: o treinamento e o teste dos modelos com dados reais no testador de estratégias do MetaTrader 5.

Assim como com os modelos criados anteriormente, o treinamento e o teste foram realizados com dados históricos do par EURUSD no timeframe H1. Realizamos o treinamento offline dos modelos. Para isso, coletamos um conjunto de treinamento de 500 trajetórias distintas com dados históricos dos primeiros sete meses de 2023. Vale mencionar que a maioria das trajetórias foi coletada por meio de passagens aleatórias, resultando em uma proporção relativamente pequena de passagens lucrativas. Para equilibrar a rentabilidade média das passagens durante o treinamento, utilizamos amostragem das trajetórias com priorização de acordo com seu resultado. Isso atribui maior peso às passagens lucrativas, aumentando também a probabilidade de serem escolhidas. O teste dos modelos treinados foi realizado no testador de estratégias com dados históricos de agosto de 2023, mantendo o mesmo instrumento e timeframe.

Essa abordagem permite avaliar o desempenho do modelo treinado em novos dados (não incluídos no conjunto de treinamento) enquanto preserva ao máximo as características estatísticas dos conjuntos de treinamento e teste.

Os resultados dos testes demonstraram a viabilidade da abordagem para o treinamento de estratégias que geram lucro tanto no período de treinamento quanto no período de teste. As capturas de tela dos testes são apresentadas abaixo.

Resultados dos testes

Resultados dos Testes

Com base nos resultados dos testes realizados em agosto de 2023, o modelo treinado efetuou 160 operações, das quais 84 foram lucrativas. O que corresponde a 52,5%. Pode-se dizer que o equilíbrio entre as operações inclinou-se ligeiramente a favor do lucro. A operação média lucrativa foi pouco mais de 4% superior à média das operações com perda. A sequência média de operações lucrativas foi equivalente à sequência média de operações com perda. A maior sequência lucrativa em termos de número de operações foi igual à maior sequência de perdas sob esse critério. No entanto, a maior operação lucrativa e a maior sequência lucrativa em termos de valor superaram as correspondentes sequências de perdas. Como resultado, durante o período de teste, o modelo apresentou um fator de lucro de 1,15 com um índice de Sharpe de 2,14. 


Considerações finais

Neste artigo, exploramos uma nova classe de modelos baseados em equações diferenciais ordinárias (EDOs — ODEs). O uso de ODEs como componentes em modelos de aprendizado de máquina oferece várias vantagens e potenciais. Eles permitem modelar processos dinâmicos e mudanças nos dados, o que é especialmente importante para tarefas relacionadas a séries temporais, dinâmica de sistemas e previsão. As ODEs neurais podem ser integradas com sucesso em diversas arquiteturas de redes neurais, incluindo modelos profundos e recorrentes, ampliando assim o escopo de aplicação desses métodos.

Na parte prática do nosso artigo, implementamos as abordagens propostas usando MQL5. Treinamos e testamos o modelo com dados reais no testador de estratégias do MetaTrader 5. Os resultados dos testes apresentados acima permitem avaliar a eficácia das abordagens propostas para resolver nossas tarefas.

No entanto, é importante lembrar que todos os programas apresentados neste artigo são de caráter informativo e destinam-se apenas à demonstração e verificação das abordagens propostas.


Referências

  • Neural Ordinary Differential Equations
  • Outros artigos da série


  • Programas Utilizados no Artigo

    # Nome Tipo Descrição
    1 Research.mq5 EA EA para coleta de exemplos
    2 ResearchRealORL.mq5
    EA
    EA para coleta de exemplos usando o método Real-ORL
    3 Study.mq5  EA EA para treinamento de modelos
    4 Test.mq5 EA EA para teste do modelo
    5 Trajectory.mqh Biblioteca de classes Estrutura de descrição do estado do sistema
    6 NeuroNet.mqh Biblioteca de classes Biblioteca de classes para criação de redes neurais
    7 NeuroNet.cl Biblioteca Biblioteca de código para o programa OpenCL

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

    Arquivos anexados |
    MQL5.zip (1067.88 KB)
    Filtragem de Sazonalidade e Período de Tempo para Modelos de Deep Learning ONNX com Python para EA Filtragem de Sazonalidade e Período de Tempo para Modelos de Deep Learning ONNX com Python para EA
    Podemos nos beneficiar da sazonalidade ao criar modelos de Deep Learning com Python? A filtragem de dados para os modelos ONNX ajuda a obter melhores resultados? Qual período de tempo devemos usar? Cobriremos tudo isso neste artigo.
    Hibridização de algoritmos populacionais. Estruturas sequenciais e paralelas Hibridização de algoritmos populacionais. Estruturas sequenciais e paralelas
    Aqui, vamos mergulhar no mundo da hibridização de algoritmos de otimização, analisando três tipos principais: mistura de estratégias, hibridização sequencial e paralela. Realizaremos uma série de experimentos combinando e testando algoritmos de otimização relevantes.
    Desenvolvendo um sistema de Replay (Parte 61): Dando play no serviço (II) Desenvolvendo um sistema de Replay (Parte 61): Dando play no serviço (II)
    Acompanhe neste artigo, as modificações que foram necessárias serem feitas, para que o serviço de replay / simulação, pudesse trabalhar de forma mais eficiente e segura. Aqui também, irei mostrar algo que pode ser de grande interesse para quem deseja fazer um uso mais eficiente das classes. Além de falar e explicar como contornar um problema que existe no MQL5, que reduz a performance do código quando usamos classes.
    Do básico ao intermediário: Passagem por valor ou por referência Do básico ao intermediário: Passagem por valor ou por referência
    Neste artigo você entenderá na prática a diferença entre passagem por valor e passagem por referência. Apesar de ser algo aparentemente simples e que não causa problemas. Muitos programadores com uma boa carga de experiência costumam tomar verdadeiras surras de seus códigos, justamente por conta deste pequeno detalhe. Saber quando, como e porque usar uma passagem por valor ou uma passagem por referência, fará grande diferença na sua vida como programador. O conteúdo exposto aqui, visa e tem como objetivo, pura e simplesmente a didática. De modo algum deve ser encarado como sendo, uma aplicação cuja finalidade não venha a ser o aprendizado e estudo dos conceitos mostrados.