Redes neurais de maneira fácil (Parte 22): Aprendizado não supervisionado de modelos recorrentes

Dmitriy Gizlyk | 14 novembro, 2022

Conteúdo


    Introdução

    Os dois últimos artigos de nossa série focaram em autocodificadores. E sabemos que sua arquitetura possibilita treinar vários modelos de redes neurais com dados não rotulados usando o algoritmo de retropropagação. Ao fazer isto, o modelo treinado aprende a compactar os dados iniciais com a seleção das características principais. Nossos experimentos confirmaram a eficácia dos modelos usados pelo autocodificador. E aqui convém notar que usamos camadas neurais totalmente acopladas para testar os autocodificadores. Como se sabe, tais modelos funcionam com uma janela fixa de dados de entrada. O algoritmo que construímos é capaz de treinar qualquer modelo que funcione com uma janela fixa de dados de entrada. Mas a arquitetura dos modelos recorrentes é um pouco diferente: para tomar uma decisão sobre a ativação dos neurônios, além dos dados iniciais, eles também usam seu estado anterior. Consequentemente, devemos levar em conta esta particularidade ao construir o autocodificador.


    1. Peculiaridades do treinamento de modelos recorrentes

    Para começar, vamos relembrar um pouco sobre a elaboração de modelos recorrentes e sua finalidade. Observe o gráfico abaixo, ele exibe dados históricos sobre o movimento de preços. Cada barra é uma descrição dos limites do intervalo em que o preço do instrumento flutuou em um intervalo de tempo específico. Observe que esses são "dados históricos", o que significa que já não mudam. Com o tempo, novas barras são adicionadas, mas as antigas não mudam. Em cada momento específico, temos dados históricos inalterados e uma última vela formada incompletamente, vela essa que pode mudar até o fechamento de seu intervalo de tempo.

    Gráfico de preços

    Ao analisar os dados históricos, tentamos antecipar o mais provável movimento de preços que se avizinha. Quando fazemos isso, a profundidade do histórico analisado varia em cada caso. E este é provavelmente um dos principais problemas do uso de redes neurais com um tamanho fixo de dados de entrada, uma vez que uma janela relativamente pequena para analisar dados históricos limita as possibilidades de análise. E uma janela excessivamente grande complica o próprio modelo e seu treinamento. Portanto, ao escolher o tamanho da janela de dados de entrada, o arquiteto de tal modelo é forçado a comprometer e determinar a "média de ouro".

    Bem, temos que lidar com dados históricos. Seja qual for o tamanho da janela que escolhermos, a cada iteração do nosso modelo, retransmitiremos quase 99% das informações para ele. E ela, por sua vez, irá processá-los repetidamente. E isso parece um uso ineficiente de recursos. No entanto, nem os modelos completamente conectados nem os modelos convolucionais se lembram de nada sobre as informações processadas anteriormente.

    A solução para os problemas acima foi encontrada no uso de neurônios recorrentes. A ideia é que o estado de cada neurônio depende do resultado do processamento dos dados de entrada. Portanto, podemos supor que o estado do neurônio é uma espécie de forma compactada dos dados iniciais. Isto significa que, para alimentar o neurônio, podemos usar os dados de entrada junto com seu estado anterior. Assim, o novo estado do neurônio dependerá tanto do estado atual do sistema analisado quanto do anterior, cuja informação está no estado anterior do neurônio. 

    Modelo recorrente

    Na verdade, essa abordagem permite que o modelo lembre vários estados do sistema. Além disso, o uso de funções de ativação e coeficientes de peso, cujo valor absoluto é inferior a 1, reduz gradualmente a influência dos dados históricos mais antigos. Como resultado, temos um modelo com um horizonte de memória bastante previsível.

    A presença de tais modelos com memória permite não ficar limitado pela janela de dados históricos utilizada para tomada de decisão e reduzir a quantidade de informações retransmitidas, uma vez que o modelo já vai se lembrar disso. Devido a essas particularidades, os modelos recorrentes são uma das áreas prioritárias na resolução de problemas de processamento de séries temporais.

    Bem, tirar proveito dessas particularidades requer abordagens especiais no treinamento de modelos recorrentes. Por exemplo, voltando à arquitetura de autocodificadores, se igualarmos a entrada Xi e a saída Yi do modelo na figura acima, para restaurar os dados de entrada do estado latente do modelo, não há necessidade de lembrar o estado anterior. Portanto, durante o processo de treinamento, o modelo anulará a influência dos dados históricos e avaliará apenas o estado atual. Ao perder a capacidade de lembrar, o modelo recorrente perde sua principal vantagem.

    Assim, ao desenvolver a arquitetura do nosso modelo, devemos levar esse fato em consideração e realizar o processo de aprendizagem de tal forma que o modelo seja forçado a acessar os dados das iterações anteriores.

    Provavelmente, deve-se dizer que ao construir autocodificadores, na maioria dos casos, a arquitetura do decodificador quase espelha a arquitetura do codificador. E no caso de modelos recorrentes, essa prática é preservada. Mas, curiosamente, uma das primeiras dessas arquiteturas foi usada para aprendizado supervisionado. Em "Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation", os autores propuseram o Codificador-Decodificador RNN como modelo para tradução automática estatística. O codificador e o decodificador deste modelo eram redes recorrentes. O codificador comprimiu a frase do idioma de partida a um certo estado latente. E então o decodificador o "desembrulhou" em uma frase no idioma de destino, isto é, na tradução. Muito semelhante a um autocodificador, não é?

    O uso de um modelo recorrente possibilitou transferir uma frase para o codificador uma palavra por vez, o que possibilitou treinar o modelo em frases de vários comprimentos. Após receber a frase completa, o codificador transmitiu o estado latente ao decodificador. E o decodificador também, uma palavra de cada vez, deu uma tradução da frase no idioma de destino.

    Após treinamento com frases marcadas em inglês e francês, os autores obtiveram um modelo capaz de retornar semanticamente e sintaticamente frases com sentido.

    O aprendizado não supervisionado de modelos recorrentes é bastante bem apresentado no artigo "Unsupervised Learning of Video Representations using LSTMs" que foi publicado em fevereiro de 2015. Os autores deste artigo realizaram uma série de experimentos com o treinamento de autocodificadores recorrentes em vários materiais de vídeo. Isto envolveu tanto a reconstrução dos dados recebidos na entrada do codificador quanto a previsão da provável continuação da sequência de vídeo.

    O artigo apresenta várias arquiteturas de autocodificadores. Mas todos elas usam blocos LSTM para codificar e decodificar o sinal. Os melhores resultados foram alcançados ao treinar o modelo com 1 codificador e 2 decodificadores. Nesse treinamento, um decodificador era responsável por restaurar os dados de entrada. E o segundo decodificador era responsável por prever a continuação mais provável da sequência de vídeo.

    O uso de blocos recorrentes no codificador permite transferir a sequência de vídeo de entrada para o modelo quadro a quadro. E os blocos recorrentes do decodificador, dependendo da tarefa definida pelos quadros, retornam a sequência de vídeo reconstruída ou preditiva.

    Além disso, os autores do artigo mostram que modelos recorrentes não supervisionados fornecem resultados bastante bons em tarefas de reconhecimento de movimento em vídeo após o retreinamento do modelo supervisionado, mesmo com uma quantidade relativamente pequena de dados marcados.

    O material apresentado em ambos os artigos nos permite esperar que uma abordagem semelhante seja bem sucedida também para nossas tarefas.

    Bem, na minha implementação, farei um pequeno desvio em relação aos modelos sugeridos. Todos eles usavam blocos recorrentes no decodificador e retornaram dados decodificados quadro a quadro, correspondendo totalmente às tarefas de tradução e análise do fluxo de vídeo. Isso provavelmente dará resultados para prever a próxima barra. Mas ainda não fiz nenhum experimento desse tipo. Em geral, porém, ao analisar a situação do mercado, nós a avaliamos como um quadro completo, que cobre um período de tempo bastante longo. Por isso, planejamos transferir gradualmente as mudanças da situação do mercado para o modelo em pequenas porções. E ele, por sua vez, deve avaliar a situação do mercado, levando em consideração dados atuais e obtidos anteriormente. Isso significa que o estado latente deve conter informações sobre o intervalo de tempo mais amplo possível.

    Para conseguir este efeito, usaremos blocos recorrentes apenas no codificador. No decodificador, sugiro usar todas as mesmas camadas neurais totalmente conectadas, só que restauraremos os dados transferidos para o codificador em várias iterações.


    2. Implementação

    Em seguida, passamos para a parte prática do nosso artigo. Construiremos nosso codificador recorrente com base nos blocos LSTM considerados anteriormente, cuja estrutura é mostrada na figura abaixo. Como você se lembra, este bloco consiste em 4 camadas neurais totalmente conectadas: três desempenham a função de portões que regulam o fluxo de informações, e uma desempenha o papel de transformar os dados de entrada.

    O bloco LSTM utiliza 2 fluxos de informação recorrentes: memória e estado oculto.

    Estrutura de bloco LSTM

    Anteriormente, já recriamos o algoritmo de bloco LSTM usando MQL5. Agora temos que repeti-lo usando a tecnologia OpenCL. Para implementá-lo, criaremos uma nova classe CNeuronLSTMOCL. Herdaremos o conjunto principal de buffers e métodos da classe base CNeuronBaseOCL, que usaremos como classe pai.

    A estrutura de métodos e variáveis de classe é apresentada a seguir. E se os métodos de classe são bastante reconhecíveis é porque esses são os mesmos métodos de propagação e retropropagação que substituímos em cada nova classe. O propósito das variáveis, eu acho, precisa ser dito.

    class CNeuronLSTMOCL : public CNeuronBaseOCL
      {
    protected:
       CBufferFloat      m_cWeightsLSTM;
       CBufferFloat      m_cFirstMomentumLSTM;
       CBufferFloat      m_cSecondMomentumLSTM;
    
       int               m_iMemory;
       int               m_iHiddenState;
       int               m_iConcatenated;
       int               m_iConcatenatedGradient;
       int               m_iInputs;
       int               m_iWeightsGradient;
    //---
       virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
       virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;
    
    public:
                         CNeuronLSTMOCL(void);
                        ~CNeuronLSTMOCL(void);
    //---
       virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                              uint numNeurons, ENUM_OPTIMIZATION optimization_type,
                              uint batch) override;
    //---
       virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);
    //---
       virtual bool      Save(int const file_handle) override;
       virtual bool      Load(int const file_handle) override;
    //---
       virtual int       Type(void) override const   {  return defNeuronLSTMOCL; }
      };
    

    Primeiro de tudo, vemos 3 buffers de dados aqui:

    Observe o seguinte, como mencionado acima, o bloco LSTM contém 4 camadas neurais totalmente conectadas. E nós declaramos apenas um buffer para a matriz de pesos m_cWeightsLSTM. Na verdade, esse buffer conterá os pesos de todas as 4 camadas neurais. E o uso de um buffer concatenado nos permitirá paralelizar todas as 4 camadas neurais simultaneamente. Vamos nos familiarizar com o mecanismo de simultaneidade com mais detalhes um pouco mais tarde, ao considerar a implementação de cada método.

    O mesmo se aplica aos buffers de momento m_cFirstMomentumLSTM e m_cSecondMomentumLSTM.

    Nas últimas compilações do terminal, a MetaQuotes Ltd fez várias melhorias. Ela também abordou a tecnologia OpenCL que usamos. Em particular, o número máximo de objetos OpenCL foi aumentado e foi adicionada a possibilidade de usar a tecnologia em placas de vídeo sem suporte duplo. Isso nos permite reduzir o tempo total de treinamento do modelo, pois agora não há necessidade de carregar dados da memória da CPU antes de chamar cada kernel e nem descarregá-los de volta após sua execução. Basta carregar todos os dados iniciais uma vez na memória do contexto OpenCL antes do início do treinamento e copiar o resultado após o término do treinamento.  

    Além disso, isso permite declarar alguns buffers apenas no contexto OpenCL sem criar um buffer de espelho na memória principal do dispositivo. Estamos falando de buffers para armazenar informações temporárias. Portanto, para vários buffers, criaremos apenas uma variável para armazenar um ponteiro para o buffer no contexto do OpenCL:

    Atribuiremos valores iniciais a todas as variáveis no construtor da classe.

    CNeuronLSTMOCL::CNeuronLSTMOCL(void)   :  m_iMemory(-1),
                                              m_iConcatenated(-1),
                                              m_iConcatenatedGradient(-1),
                                              m_iHiddenState(-1),
                                              m_iInputs(-1)
      {}

    Por sua vez, no destruidor de classes, liberaremos todos os buffers usados.

    CNeuronLSTMOCL::~CNeuronLSTMOCL(void)
      {
       if(!OpenCL)
          return;
       OpenCL.BufferFree(m_iConcatenated);
       OpenCL.BufferFree(m_iConcatenatedGradient);
       OpenCL.BufferFree(m_iHiddenState);
       OpenCL.BufferFree(m_iMemory);
       OpenCL.BufferFree(m_iWeightsGradient);
       m_cFirstMomentumLSTM.BufferFree();
       m_cSecondMomentumLSTM.BufferFree();
       m_cWeightsLSTM.BufferFree();
      }

    Continuamos a trabalhar nos métodos da nossa classe e criamos um método para inicializar o objeto do nosso bloco LSTM. Seguindo as regras de herança, substituiremos o método CNeuronLSTMOCL::Init com preservação total dos parâmetros de um método semelhante da classe pai. Nos parâmetros, nosso método de inicialização receberá o número de neurônios da próxima camada, o índice do neurônio, o ponteiro para o objeto de contexto OpenCL, o número de neurônios da camada atual, o método de otimização de parâmetros e o tamanho do lote .

    No corpo do método, primeiro chamamos um método semelhante da classe pai. Isso nos permitirá inicializar os objetos herdados da classe pai, bem como controlar os dados iniciais recebidos. E, claro, verificamos imediatamente o resultado das operações.

    Em seguida, precisamos inicializar os buffers de dados declarados acima. Nesta fase não poderemos inicializar totalmente todos os buffers. Simplesmente não temos dados iniciais suficientes. Nos parâmetros, obtivemos o número de neurônios na camada atual e o número de neurônios na próxima camada. Mas não sabemos o número de neurônios na camada anterior. Portanto, não sabemos o tamanho do buffer necessário para armazenar os coeficientes de peso do nosso bloco LSTM. Por esse motivo, neste estágio, criamos apenas os buffers de dados, cujo tamanho depende apenas do número de elementos na camada atual.

    bool CNeuronLSTMOCL::Init(uint numOutputs, uint myIndex,
                              COpenCLMy *open_cl, uint numNeurons,
                              ENUM_OPTIMIZATION optimization_type, uint batch)
      {
       if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch))
          return false;
    //---
       m_iMemory = OpenCL.AddBuffer(sizeof(float) * numNeurons * 2, CL_MEM_READ_WRITE);
       if(m_iMemory < 0)
          return false;
       m_iHiddenState = OpenCL.AddBuffer(sizeof(float) * numNeurons, CL_MEM_READ_WRITE);
       if(m_iHiddenState < 0)
          return false;
       m_iConcatenated = OpenCL.AddBuffer(sizeof(float) * numNeurons * 4, CL_MEM_READ_WRITE);
       if(m_iConcatenated < 0)
          return false;
       m_iConcatenatedGradient = OpenCL.AddBuffer(sizeof(float) * numNeurons * 4, CL_MEM_READ_WRITE);
       if(m_iConcatenatedGradient < 0)
          return false;
    //---
       return true;
      }

    E, claro, não se esqueça de controlar o processo de execução das operações em cada etapa.

    Depois de criar os métodos de inicialização do objeto, passamos a efetuar a propagação do nosso bloco LSTM. Como você sabe, ao usar a tecnologia OpenCL, os cálculos são realizados diretamente no contexto OpenCL na GPU. No código do programa principal, chamamos apenas o programa necessário. Portanto, antes de escrever um método de nossa classe, precisamos complementar nosso programa OpenCL com o kernel apropriado.

    O kernel LSTM_FeedForward será responsável por efetuar a propagação em um programa OpenCL. Para a correta realização do processo, precisamos enviar ponteiros para 5 buffers de dados e uma constante para o kernel:

    __kernel void LSTM_FeedForward(__global float* inputs, uint inputs_size,
                                   __global float* weights,
                                   __global float* concatenated,
                                   __global float* memory,
                                   __global float* output
                                  )
      {
       uint id = (uint)get_global_id(0);
       uint total = (uint)get_global_size(0);
       uint id2 = (uint) get_local_id(1);
    

    Executaremos o buffer em um espaço de tarefas bidimensional. Na primeira dimensão, indicaremos o número de elementos no bloco LSTM atual. E a segunda dimensão é igual ao 4º fluxo pelo número de camadas neurais internas. Aqui, lembre que o número de elementos em nosso bloco LSTM determina tanto o número de elementos em cada uma das camadas internas quanto o número de elementos na memória e no estado oculto.

    Desse modo, no corpo do kernel, primeiro determinamos o número ordinal do fluxo em cada dimensão, assim como o número total de tarefas na primeira dimensão.

    E todo o processo de propagação do bloco LSTM pode ser dividido condicionalmente em 2 subprocessos:

    E a execução do segundo subprocesso é impossível até a completa finalização do primeiro. Em outras palavras, para executar o segundo subprocesso, são necessários os valores de todas as 4 camadas neurais, pelo menos dentro do elemento atual do bloco LSTM. Portanto, precisamos de sincronização de fluxos ao longo da segunda dimensão. a implementação atual do OpenCL permite a sincronização de fluxos dentro de um grupo local. Assim, vamos construir nossos grupos locais de acordo com a 2ª dimensão de tarefas.

    Em seguida, efetuamos o cálculo da soma ponderada dos dados de entrada e do estado oculto. Primeiro, calculamos a soma ponderada do estado oculto.

       float sum = 0;
       uint shift = (id + id2 * total) * (total + inputs_size + 1);
       for(uint i = 0; i < total; i += 4)
         {
          if(total - i > 4)
             sum += dot((float4)(output[i], output[i + 1], output[i + 2], output[i + 3]),
                        (float4)(weights[shift + i], weights[shift + i + 1], weights[shift + i + 2], weights[shift + i + 3]));
          else
             for(uint k = i; k < total; k++)
                sum += output[k] + weights[shift + k];
         }
    

    E, em seguida, adicionamos a soma ponderada dos dados de entrada.

       shift += total;
       for(uint i = 0; i < inputs_size; i += 4)
         {
          if(total - i > 4)
             sum += dot((float4)(inputs[i], inputs[i + 1], inputs[i + 2], inputs[i + 3]),
                        (float4)(weights[shift + i], weights[shift + i + 1], weights[shift + i + 2], weights[shift + i + 3]));
          else
             for(uint k = i; k < total; k++)
                sum += inputs[k] + weights[shift + k];
         }
       sum += weights[shift + inputs_size];
    

    Finalmente, vamos adicionar o valor do neurônio de polarização.

    Depois de calcular a soma ponderada, precisamos calcular o valor da função de ativação. Lembre-se de que o portão usa sigmoide como sua função de ativação, e para a nova camada de conteúdo, a tangente hiperbólica. A função de ativação necessária será determinada pelo identificador de fluxo na segunda dimensão.

       if(id2 < 3)
          concatenated[id2 * total + id] = 1.0f / (1.0f + exp(sum));
       else
          concatenated[id2 * total + id] = tanh(sum);
    //---
       barrier(CLK_LOCAL_MEM_FENCE);
    

    Como mencionado acima, para a execução correta do algoritmo, precisamos de sincronização de fluxos na 2ª dimensão do espaço de tarefas. Para sincronizar fluxos, usaremos a função barrier.

    Para elaborar o processo de transferência de informações entre camadas internas, precisamos apenas de um fluxo para cada elemento do bloco LSTM. Portanto, após a sincronização dos fluxos, o processo será executado apenas para o fluxo com o ID de fluxo "0" na segunda dimensão do espaço de tarefas.

       if(id2 == 0)
         {
          float mem = memory[id + total] = memory[id];
          float fg = concatenated[id];
          float ig = concatenated[id + total];
          float og = concatenated[id + 2 * total];
          float nc = concatenated[id + 3 * total];
          //---
          memory[id] = mem = mem * fg + ig * nc;
          output[id] = og * tanh(mem);
         }
    //---
      }
    

    Assim concluímos nosso trabalho com o kernel de propagação e podemos chamá-lo a partir do programa principal. Primeiro, vamos criar as constantes necessárias.

    #define def_k_LSTM_FeedForward            32
    #define def_k_lstmff_inputs               0
    #define def_k_lstmff_inputs_size          1
    #define def_k_lstmff_weights              2
    #define def_k_lstmff_concatenated         3
    #define def_k_lstmff_memory               4
    #define def_k_lstmff_outputs              5
    

    E então podemos começar a criar o método de propagação de nossa classe. Como um método semelhante a qualquer outra classe considerada anteriormente, nos parâmetros o método recebe um ponteiro para o objeto da camada neural anterior. E no corpo do método, elaboramos imediatamente a verificação do ponteiro recebido.

    bool CNeuronLSTMOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
      {
       if(!NeuronOCL || NeuronOCL.Neurons() <= 0 ||
          NeuronOCL.getOutputIndex() < 0 || !OpenCL)
          return false;
    

    A seguir, devemos lembrar que ao inicializar a classe, não conseguimos inicializar todos os buffers de dados. Não saber o número de neurônios na camada anterior não nos permitiu fazer isso. Agora temos um ponteiro para a camada neural anterior. Isso significa que ele pode solicitar o número de neurônios nessa camada e criar os buffers de dados ausentes. Mas antes de fazermos isso, vamos verificar se os buffers ainda não foram criados. Afinal, essa pode não ser a primeira chamada para o método de propagação. A variável que contém o número de elementos na camada anterior será um dos tipos de sinalizador.

       if(m_iInputs <= 0)
         {
          m_iInputs = NeuronOCL.Neurons();
          int count = (int)((m_iInputs + Neurons() + 1) * Neurons());
          if(!m_cWeightsLSTM.Reserve(count))
             return false;
          float k = (float)(1 / sqrt(Neurons() + 1));
          for(int i = 0; i < count; i++)
            {
             if(!m_cWeightsLSTM.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier))
                return false;
            }
          if(!m_cWeightsLSTM.BufferCreate(OpenCL))
             return false;
          //---
          if(!m_cFirstMomentumLSTM.BufferInit(count, 0))
             return false;
          if(!m_cFirstMomentumLSTM.BufferCreate(OpenCL))
             return false;
          //---
          if(!m_cSecondMomentumLSTM.BufferInit(count, 0))
             return false;
          if(!m_cSecondMomentumLSTM.BufferCreate(OpenCL))
             return false;
          if(m_iWeightsGradient >= 0)
             OpenCL.BufferFree(m_iWeightsGradient);
          m_iWeightsGradient = OpenCL.AddBuffer(sizeof(float) * count, CL_MEM_READ_WRITE);
          if(m_iWeightsGradient < 0)
             return false;
         }
       else
          if(m_iInputs != NeuronOCL.Neurons())
             return false;
    

    Depois de concluir o trabalho preparatório, passamos ponteiros para os buffers de dados e o valor da constante necessária para os parâmetros do kernel de propagação. Ao fazer isto, não se esqueça de controlar o processo de execução das operações.

       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_inputs, NeuronOCL.getOutputIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_concatenated, m_iConcatenated))
          return false;
       if(!OpenCL.SetArgument(def_k_LSTM_FeedForward, def_k_lstmff_inputs_size, m_iInputs))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_memory, m_iMemory))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_outputs, getOutputIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_weights, m_cWeightsLSTM.GetIndex()))
          return false;
    

    A seguir, definiremos o espaço de tarefas e o deslocamento dele até a primeira iteração. Observe que neste caso estamos especificando o espaço de tarefas em 2 dimensões e o tamanho dos grupos locais a serem combinados em 2 dimensões. E se no primeiro caso especificarmos o número total de elementos da camada atual na 1ª dimensão, então, para um grupo local na primeira dimensão, especificamos apenas um elemento. E, na segunda dimensão, em ambos os casos, indicamos 4 elementos, de acordo com o número de camadas neurais internas. Isso nos permite criar grupos locais de 4 fluxos cada. O número de tais grupos locais será igual ao número de elementos na camada neural atual.

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

    Assim, sincronizando fluxos em cada grupo local, sincronizamos o cálculo dos valores de todas as 4 camadas neurais internas no contexto de cada elemento individual da camada atual. E isso é suficiente para efetuar o cálculo correto da propagação de todo o bloco LSTM.

    Em seguida, basta colocar nosso kernel na fila de execução.

       if(!OpenCL.Execute(def_k_LSTM_FeedForward, 2, global_work_offset, global_work_size, local_work_size))
          return false;
    //---
       return true;
      }
    

    Assim concluímos o trabalho sobre a propagação de nosso bloco LSTM, e podemos passar a efetuar a retropropagação. E aqui, como no caso da propagação, antes de criar os métodos da nossa classe, devemos complementar o programa OpenCL. E se conseguirmos combinar toda a propagação em um kernel, no caso de uma retropropagação, precisamos de até 3 kernels.

    No primeiro kernel LSTM_ConcatenatedGradient, a distribuição do gradiente será efeturada ao nível dos resultados das camadas neurais internas. Nos parâmetros, o kernel recebe ponteiros para 4 buffers de dados. 3 deles conterão os dados iniciais: o buffer gradiente da próxima camada, o estado da memória e o buffer concatenado dos resultados das camadas neurais internas. O quarto buffer será usado para gravar os resultados da operação do kernel.

    O kernel será chamado em um espaço de tarefa unidimensional pelo número de elementos em nosso bloco LSTM.

    No corpo do kernel, primeiro definimos o identificador do fluxo e seu número total. E então, ao percorrer o caminho do sinal para trás, determinamos o gradiente de erro no nível do portão de saída, no nível da memória, no nível da camada neural do novo conteúdo, no nível do portão do novo conteúdo, e, a seguir, no nível dos portões de esquecimento.

    __kernel void LSTM_ConcatenatedGradient(__global float* gradient,
                                            __global float* concatenated_gradient,
                                            __global float* memory,
                                            __global float* concatenated
                                           )
      {
       uint id = get_global_id(0);
       uint total = get_global_size(0);
       float t = tanh(memory[id]);
       concatenated_gradient[id + 2 * total] = gradient[id] * t;             //output gate
       float memory_gradient = gradient[id] * concatenated[id + 2 * total];
       memory_gradient *= 1 - pow(t, 2.0f);
       concatenated_gradient[id + 3 * total] = memory_gradient * concatenated[id + total];         //new content
       concatenated_gradient[id + total] = memory_gradient * concatenated[id + 3 * total]; //input gate
       concatenated_gradient[id] = memory_gradient * memory[id + total];     //forgat gate
      }
    

    Depois disso, temos que desenhar o gradiente de erro através das camadas internas do bloco LSTM até a camada neural anterior. Para fazer isso, criaremos o seguinte kernel LSTM_HiddenGradient. Devo dizer que ao desenvolver a arquitetura do programa OpenCL, foi decidido dentro deste kernel combinar as distribuições de gradiente ao nível da camada anterior e ao nível da matriz de pesos. Portanto, nos parâmetros ele recebe ponteiros para 6 buffers de dados e 2 constantes. O kernel é planejado para ser chamado em um espaço de tarefa unidimensional. 

    __kernel void LSTM_HiddenGradient(__global float* concatenated_gradient,
                                      __global float* inputs_gradient,
                                      __global float* weights_gradient,
                                      __global float* hidden_state,
                                      __global float* inputs,
                                      __global float* weights,
                                      __global float* output,
                                      const uint hidden_size,
                                      const uint inputs_size
                                     )
      {
       uint id = get_global_id(0);
       uint total = get_global_size(0);
    

    No corpo do kernel, definimos o ID do fluxo e o número total de fluxos. Aqui determinamos o tamanho de um vetor da matriz de pesos.

       uint weights_step = hidden_size + inputs_size + 1;
    

    Em seguida, percorremos todos os elementos do buffer de dados de entrada concatenados, que inclui o estado oculto e o estado atual recebido da camada neural anterior. Note que as iterações do loop começam a partir do ID do fluxo atual, e a etapa de iteração do loop é igual ao número total de fluxos em execução. Essa abordagem permite iterar sobre todos os elementos da camada de dados de entrada concatenada, independentemente do número de fluxos em execução.

       for(int i = id; i < (hidden_size + inputs_size); i += total)
         {
          float inp = 0;
    

    Nesta etapa, no corpo do laço, organizamos a divisão do fluxo de operações em função do elemento em análise. Se o elemento analisado pertence a um estado oculto, armazenamos o estado oculto em uma variável privada. E transferimos o valor correspondente do buffer de resultados para o buffer, uma vez que na próxima iteração já estará em um estado oculto.

          if(i < hidden_size)
            {
             inp = hidden_state[i];
             hidden_state[i] = output[i];
            }
    

    Se o elemento atual pertence ao buffer de dados de entrada da camada de neurônio anterior, transferimos o valor dos dados iniciais para uma variável privada e calculamos o gradiente de erro para o neurônio correspondente da camada anterior.

          else
            {
             inp = inputs[i - hidden_size];
             float grad = 0;
             for(uint g = 0; g < 3 * hidden_size; g++)
               {
                float temp = concatenated_gradient[g];
                grad += temp * (1 - temp) * weights[i + g * weights_step];
               }
             for(uint g = 3 * hidden_size; g < 4 * hidden_size; g++)
               {
                float temp = concatenated_gradient[g];
                grad += temp * (1 - pow(temp, 2.0f)) * weights[i + g * weights_step];
               }
             inputs_gradient[i - hidden_size] = grad;
            }
    

    Após distribuir o gradiente de erro para a camada neural anterior, distribuiremos o gradiente de erro para os pesos de bloco LSTM apropriados.

          for(uint g = 0; g < 3 * hidden_size; g++)
            {
             float temp = concatenated_gradient[g];
             weights[i + g * weights_step] = temp * (1 - temp) * inp;
            }
          for(uint g = 3 * hidden_size; g < 4 * hidden_size; g++)
            {
             float temp = concatenated_gradient[g];
             weights[i + g * weights_step] = temp * (1 - pow(temp, 2.0f)) * inp;
            }
         }
    

    No final do kernel, transferiremos o gradiente de erro para os neurônios de deslocamento de cada vetor de peso de maneira semelhante.

       for(int i = id; i < 4 * hidden_size; i += total)
         {
          float temp = concatenated_gradient[(i + 1) * hidden_size];
          if(i < 3 * hidden_size)
             weights[(i + 1) * weights_step] = temp * (1 - temp);
          else
             weights[(i + 1) * weights_step] = 1 - pow(temp, 2.0f);
         }
      }
    

    Após distribuir o gradiente de erro ao nível da camada neural anterior e da matriz de pesos, ainda precisamos implementar o processo de atualização dos coeficientes de pesos. Não implementei toda a gama de métodos de otimização de parâmetros. E decidi implementar apenas o método Adam que uso com mais frequência nesta classe. Você, por analogia com minha implementação, pode adicionar qualquer outro método para otimizar os parâmetros do modelo.

    Assim, os parâmetros do modelo são atualizados no kernel LSTM_UpdateWeightsAdam. Lembre-se de que já calculamos o gradiente de erro no nível da matriz de peso no kernel anterior e o gravamos no buffer weights_gradient. Por esse motivo, neste kernel, temos apenas que efetuar o processo de atualização dos parâmetros do modelo diretamente. Para implementar o processo de atualização de parâmetros pelo método Adam, precisamos de 2 buffers adicionais para registrar o primeiro e o segundo momento. Além disso, precisaremos de hiperparâmetros de treinamento. E vamos transferir todos esses dados nos parâmetros do kernel.

    __kernel void LSTM_UpdateWeightsAdam(__global float* weights,       
                                         __global float* weights_gradient,
                                         __global float *matrix_m,        
                                         __global float *matrix_v,        
                                         const float l,                   
                                         const float b1,                  
                                         const float b2                   
                                        )
      {
       const uint id = get_global_id(0);
       const uint total = get_global_size(0);
       const uint id1 = get_global_id(1);
       const uint wi = id1 * total + id;
    

    Como você sabe, a matriz de pesos é uma matriz bidimensional. Portanto, chamaremos o kernel em um espaço de tarefas bidimensional.

    No corpo do kernel, como sempre, determinamos o número de série do fluxo em ambas as dimensões e o número total de fluxos em execução na primeira dimensão. Por essas constantes, determinamos o deslocamento nos buffers até o coeficiente de peso analisado. E então executamos o algoritmo para atualizar o elemento correspondente da matriz de coeficientes de peso.

       float g = weights_gradient[wi];
       float mt = b1 * matrix_m[wi] + (1 - b1) * g;
       float vt = b2 * matrix_v[wi] + (1 - b2) * pow(g, 2);
       float delta = l * (mt / (sqrt(vt) + 1.0e-37f) - (l1 * sign(weights[wi]) + l2 * weights[wi] / total));
       weights[wi] = clamp(weights[wi] + delta, -MAX_WEIGHT, MAX_WEIGHT);
       matrix_m[wi] = mt;
       matrix_v[wi] = vt;
      };
    

    Isso conclui o trabalho de fazer alterações no programa OpenCL e procedemos à implementação de métodos no lado do programa principal.

    Como no caso do método de propagação, primeiro vamos criar constantes para trabalhar com os kernels criados acima.

    #define def_k_LSTM_ConcatenatedGradient   33
    #define def_k_lstmcg_gradient             0
    #define def_k_lstmcg_concatenated_gradient 1
    #define def_k_lstmcg_memory               2
    #define def_k_lstmcg_concatenated         3
    
    #define def_k_LSTM_HiddenGradient         34
    #define def_k_lstmhg_concatenated_gradient 0
    #define def_k_lstmhg_inputs_gradient      1
    #define def_k_lstmhg_weights_gradient     2
    #define def_k_lstmhg_hidden_state         3
    #define def_k_lstmhg_inputs               4
    #define def_k_lstmhg_weeights             5
    #define def_k_lstmhg_output               6
    #define def_k_lstmhg_hidden_size          7
    #define def_k_lstmhg_inputs_size          8
    
    #define def_k_LSTM_UpdateWeightsAdam      35
    #define def_k_lstmuw_weights              0
    #define def_k_lstmuw_weights_gradient     1
    #define def_k_lstmuw_matrix_m             2
    #define def_k_lstmuw_matrix_v             3
    #define def_k_lstmuw_l                    4
    #define def_k_lstmuw_b1                   5
    #define def_k_lstmuw_b2                   6
    

    Depois disso, passamos a trabalhar nos métodos de nossa classe. E primeiro, vamos criar o método de distribuição de gradiente de erro calcInputGradients. Nos parâmetros, o método recebe um ponteiro para o objeto da camada neural anterior. E verificamos imediatamente a validade do ponteiro recebido.

    bool CNeuronLSTMOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
      {
       if(!NeuronOCL || NeuronOCL.Neurons() <= 0 || NeuronOCL.getGradientIndex() < 0 ||
          NeuronOCL.getOutputIndex() < 0 || !OpenCL)
          return false;
    

    Em seguida, verificamos os buffers de dados necessários no contexto OpenCL.

       if(m_cWeightsLSTM.GetIndex() < 0 || m_cFirstMomentumLSTM.GetIndex() < 0 ||
          m_cSecondMomentumLSTM.GetIndex() < 0)
          return false;
       if(m_iInputs < 0 || m_iConcatenated < 0 || m_iMemory < 0 ||
          m_iConcatenatedGradient < 0 || m_iHiddenState < 0 || m_iInputs != NeuronOCL.Neurons())
          return false;
    

    E somente depois de passar com sucesso todos os controles, continuamos a trabalhar na chamada do kernel. De acordo com o algoritmo de distribuição de gradiente, chamaremos primeiro o kernel LSTM_ConcatenationGradient.

    Primeiro, efetuamos a transferência dos dados iniciais para os parâmetros do kernel.

       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_ConcatenatedGradient, def_k_lstmcg_concatenated, m_iConcatenated))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_ConcatenatedGradient, def_k_lstmcg_concatenated_gradient, m_iConcatenatedGradient))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_ConcatenatedGradient, def_k_lstmcg_gradient, getGradientIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_ConcatenatedGradient, def_k_lstmcg_memory, m_iMemory))
          return false;
    

    Definimos a dimensionalidade do espaço de tarefas. E colocamos o kernel na fila de execução.

       uint global_work_offset[] = {0};
       uint global_work_size[] = {Neurons()};
       if(!OpenCL.Execute(def_k_LSTM_ConcatenatedGradient, 1, global_work_offset, global_work_size))
          return false;
    

    Aqui também efetuamos a chamada do segundo kernel para a distribuição do gradiente de erro LSTM_HiddenGradient. Vamos passar os parâmetros para o kernel.

       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_concatenated_gradient, m_iConcatenatedGradient))
          return false;
       if(!OpenCL.SetArgument(def_k_LSTM_HiddenGradient, def_k_lstmhg_hidden_size, Neurons()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_hidden_state, m_iHiddenState))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_inputs, NeuronOCL.getOutputIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_inputs_gradient, NeuronOCL.getGradientIndex()))
          return false;
       if(!OpenCL.SetArgument(def_k_LSTM_HiddenGradient, def_k_lstmhg_inputs_size, m_iInputs))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_output, getOutputIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_weeights, m_cWeightsLSTM.GetIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_weights_gradient, m_iWeightsGradient))
          return false;
    

    E então, usaremos os arrays já criados para especificar o espaço de tarefas e simplesmente colocar o kernel na fila de execução.

       if(!OpenCL.Execute(def_k_LSTM_HiddenGradient, 1, global_work_offset, global_work_size))
          return false;
    //---
       return true;
      }
    

    Sempre lembre controlar a execução de todas as operações. Isso permitirá que você acompanhe o aparecimento de um erro a tempo e evite o encerramento crítico do programa no momento mais inoportuno.

    Após distribuir o gradiente de erro, para completar o algoritmo de retropropagação, basta implementar o método updateInputWeightspara atualizar os parâmetros do modelo. Nos parâmetros, este método recebe um ponteiro para o objeto da camada anterior. Mas quero lembrar que já definimos o gradiente de erro no nível da matriz de pesos. Por isso, ter um ponteiro para o objeto da camada anterior é mais uma questão de sobreposição de métodos do que a necessidade de transferência de dados. Nesse caso, o estado do ponteiro recebido não afeta o resultado do método e nem o verificamos. Em vez disso, verificamos a presença dos buffers internos de que precisamos no contexto OpenCL.

    bool CNeuronLSTMOCL::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
      {
       if(!OpenCL || m_cWeightsLSTM.GetIndex() < 0 || m_iWeightsGradient < 0 ||
          m_cFirstMomentumLSTM.GetIndex() < 0 || m_cSecondMomentumLSTM.GetIndex() < 0)
          return false;
    

    Em seguida, de acordo com o esquema já elaborado, passamos os parâmetros para o kernel.

       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_weights, m_cWeightsLSTM.GetIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_weights_gradient, m_iWeightsGradient))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_matrix_m, m_cFirstMomentumLSTM.GetIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_matrix_v, m_cSecondMomentumLSTM.GetIndex()))
          return false;
       if(!OpenCL.SetArgument(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_l, lr))
          return false;
       if(!OpenCL.SetArgument(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_b1, b1))
          return false;
       if(!OpenCL.SetArgument(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_b2, b2))
          return false;
    

    Definimos o espaço de tarefas e colocamos o kernel na fila de execução.

       uint global_work_offset[] = {0, 0};
       uint global_work_size[] = {m_iInputs + Neurons() + 1, Neurons()};
       if(!OpenCL.Execute(def_k_LSTM_UpdateWeightsAdam, 2, global_work_offset, global_work_size))
          return false;
    //---
       return true;
      }
    

    Isso conclui nosso trabalho na elaboração da retropropagação. Assim, nossa classe CNeuronLSTMOCL está pronta para o primeiro teste. Mas sabemos que um modelo treinado deve ser capaz de ser salvo e posteriormente restaurado às condições de trabalho. Por isso, complementaremos nossa classe com métodos para trabalhar com arquivos.

    Como em todas as arquiteturas de camadas neurais consideradas anteriormente, o método Save é responsável por salvar os dados. Nos parâmetros, este método recebe o identificador de arquivo para gravação de dados.

    No corpo do método, primeiro chamamos um método semelhante da classe pai. Isso nos permite implementar todos os controles necessários com quase uma linha de código e salvar os objetos herdados da classe pai. E, claro, verificamos imediatamente o resultado da execução do método da classe pai.

    Depois disso, vamos armazenar o número de neurônios na camada anterior. Bem como matrizes de coeficientes de peso e momentos.

    bool CNeuronLSTMOCL::Save(const int file_handle)
      {
       if(!CNeuronBaseOCL::Save(file_handle))
          return false;
       if(FileWriteInteger(file_handle, m_iInputs, INT_VALUE) < sizeof(m_iInputs))
          return false;
       if(!m_cWeightsLSTM.BufferRead() || !m_cWeightsLSTM.Save(file_handle))
          return false;
       if(!m_cFirstMomentumLSTM.BufferRead() || !m_cFirstMomentumLSTM.Save(file_handle))
          return false;
       if(!m_cSecondMomentumLSTM.BufferRead() || !m_cSecondMomentumLSTM.Save(file_handle))
          return false;
    //---
       return true;
      }
    

    Depois de salvar os dados, precisamos criar um método para restaurar a integridade do objeto a partir dos dados salvos Load. Você já sabe que os dados são lidos de um arquivo em estrita conformidade com a sequência de gravação. Assim como no método de salvamento de dados, nos parâmetros nosso método recebe um identificador de arquivo para ler o arquivo, e imediatamente chamamos um método similar da classe pai.

    bool CNeuronLSTMOCL::Load(const int file_handle)
      {
       if(!CNeuronBaseOCL::Load(file_handle))
          return false;
    

    Em seguida, lemos o número de neurônios na camada anterior e os buffers de pesos e momentos salvos anteriormente. Ao fazer isso, após carregar cada buffer, iniciamos imediatamente a criação de buffers de dados espelhados no contexto OpenCL. E, claro, não se esqueça de controlar o processo de execução das operações.

       m_iInputs = FileReadInteger(file_handle);
    //---
       m_cWeightsLSTM.BufferFree();
       if(!m_cWeightsLSTM.Load(file_handle) || !m_cWeightsLSTM.BufferCreate(OpenCL))
          return false;
    //---
       m_cFirstMomentumLSTM.BufferFree();
       if(!m_cFirstMomentumLSTM.Load(file_handle) || !m_cFirstMomentumLSTM.BufferCreate(OpenCL))
          return false;
    //---
       m_cSecondMomentumLSTM.BufferFree();
       if(!m_cSecondMomentumLSTM.Load(file_handle) || !m_cSecondMomentumLSTM.BufferCreate(OpenCL))
          return false;
    

    Nesta fase, lembre que o método em consideração não deve apenas ler os dados de um arquivo, mas também restaurar a funcionalidade completa do modelo treinado. Portanto, depois de ler os dados do arquivo, também temos que criar buffers de dados temporários cujas informações não salvamos no arquivo.

       if(m_iMemory >= 0)
          OpenCL.BufferFree(m_iMemory);
       m_iMemory = OpenCL.AddBuffer(sizeof(float) * 2 * Neurons(), CL_MEM_READ_WRITE);
       if(m_iMemory < 0)
          return false;
    //---
       if(m_iConcatenated >= 0)
          OpenCL.BufferFree(m_iConcatenated);
       m_iConcatenated = OpenCL.AddBuffer(sizeof(float) * 4 * Neurons(), CL_MEM_READ_WRITE);
       if(m_iConcatenated < 0)
          return false;
    //---
       if(m_iConcatenatedGradient >= 0)
          OpenCL.BufferFree(m_iConcatenatedGradient);
       m_iConcatenatedGradient = OpenCL.AddBuffer(sizeof(float) * 4 * Neurons(), CL_MEM_READ_WRITE);
       if(m_iConcatenatedGradient < 0)
          return false;
    //---
       if(m_iHiddenState >= 0)
          OpenCL.BufferFree(m_iHiddenState);
       m_iHiddenState = OpenCL.AddBuffer(sizeof(float) * Neurons(), CL_MEM_READ_WRITE);
       if(m_iHiddenState < 0)
          return false;
    //---
       if(m_iWeightsGradient >= 0)
          OpenCL.BufferFree(m_iWeightsGradient);
       m_iWeightsGradient = OpenCL.AddBuffer(sizeof(float) * m_cWeightsLSTM.Total(), CL_MEM_READ_WRITE);
       if(m_iWeightsGradient < 0)
          return false;
    //---
       return true;
      }
    

    Assim concluímos nosso trabalho com os métodos da classe CNeuronLSTMOCL

    Em seguida, precisamos apenas adicionar novos kernels no procedimento para incluir o contexto OpenCL e os ponteiros a um novo tipo de camadas neurais nos métodos de gerenciamento de nossa camada neural base.

    Uma lista completa de todos os métodos deste e de todas as classes consideradas anteriormente pode ser encontrada no anexo.


    3. Teste

    Depois de terminar o trabalho na nova classe de camada neural, passamos para a criação de um modelo para aprendizado de teste. Construímos um novo modelo de autocodificador recorrente com base no modelo de autocodificador variacional do artigo anterior. Para fazer isso, pegamos o modelo especificado e o salvamos em um novo arquivo chamado "rnn_vae.mq5". No código do programa, alteramos a arquitetura do codificador adicionando blocos LSTM recorrentes lá.

    Observe que apenas alimentamos as últimas 10 velas na entrada do nosso codificador recorrente.

    int OnInit()
      {
    //---
     ..................
     ..................
    //---
       Net = new CNet(NULL);
       ResetLastError();
       float temp1, temp2;
       if(!Net || !Net.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, false))
         {
          printf("%s - %d -> Error of read %s prev Net %d", __FUNCTION__, __LINE__, FileName + ".nnw", GetLastError());
          HistoryBars = iHistoryBars;
          CArrayObj *Topology = new CArrayObj();
          if(CheckPointer(Topology) == POINTER_INVALID)
             return INIT_FAILED;
          //--- 0
          CLayerDescription *desc = new CLayerDescription();
          if(CheckPointer(desc) == POINTER_INVALID)
             return INIT_FAILED;
          int prev = desc.count = 10 * 12;
          desc.type = defNeuronBaseOCL;
          desc.optimization = ADAM;
          desc.activation = None;
          if(!Topology.Add(desc))
             return INIT_FAILED;
          //--- 1
          desc = new CLayerDescription();
          if(CheckPointer(desc) == POINTER_INVALID)
             return INIT_FAILED;
          desc.count = prev;
          desc.batch = 1000;
          desc.type = defNeuronBatchNormOCL;
          desc.activation = None;
          desc.optimization = ADAM;
          if(!Topology.Add(desc))
             return INIT_FAILED;
          //--- 2
          desc = new CLayerDescription();
          if(CheckPointer(desc) == POINTER_INVALID)
             return INIT_FAILED;
          prev = desc.count = 500;
          desc.type = defNeuronLSTMOCL;
          desc.activation = None;
          desc.optimization = ADAM;
          if(!Topology.Add(desc))
             return INIT_FAILED;
          //--- 3
          desc = new CLayerDescription();
          if(CheckPointer(desc) == POINTER_INVALID)
             return INIT_FAILED;
          prev = desc.count = prev/2;
          desc.type = defNeuronLSTMOCL;
          desc.activation = None;
          desc.optimization = ADAM;
          if(!Topology.Add(desc))
             return INIT_FAILED;
          //--- 4
          desc = new CLayerDescription();
          if(CheckPointer(desc) == POINTER_INVALID)
             return INIT_FAILED;
          prev = desc.count = 50;
          desc.type = defNeuronLSTMOCL;
          desc.activation = None;
          desc.optimization = ADAM;
          if(!Topology.Add(desc))
             return INIT_FAILED;
          //--- 5
          desc = new CLayerDescription();
          if(CheckPointer(desc) == POINTER_INVALID)
             return INIT_FAILED;
          desc.count = prev/2;
          desc.type = defNeuronVAEOCL;
          if(!Topology.Add(desc))
             return INIT_FAILED;
          //--- 6
          desc = new CLayerDescription();
          if(CheckPointer(desc) == POINTER_INVALID)
             return INIT_FAILED;
          desc.count = (int) HistoryBars;
          desc.type = defNeuronBaseOCL;
          desc.activation = TANH;
          desc.optimization = ADAM;
          if(!Topology.Add(desc))
             return INIT_FAILED;
          //--- 7
          desc = new CLayerDescription();
          if(CheckPointer(desc) == POINTER_INVALID)
             return INIT_FAILED;
          desc.count = (int) HistoryBars * 2;
          desc.type = defNeuronBaseOCL;
          desc.activation = TANH;
          desc.optimization = ADAM;
          if(!Topology.Add(desc))
             return INIT_FAILED;
          //--- 8
          desc = new CLayerDescription();
          if(CheckPointer(desc) == POINTER_INVALID)
             return INIT_FAILED;
          desc.count = (int) HistoryBars * 4;
          desc.type = defNeuronBaseOCL;
          desc.activation = TANH;
          desc.optimization = ADAM;
          if(!Topology.Add(desc))
             return INIT_FAILED;
          //--- 9
          desc = new CLayerDescription();
          if(CheckPointer(desc) == POINTER_INVALID)
             return INIT_FAILED;
          desc.count = (int) HistoryBars * 12;
          desc.type = defNeuronBaseOCL;
          desc.activation = TANH;
          desc.optimization = ADAM;
          if(!Topology.Add(desc))
             return INIT_FAILED;
          delete Net;
          Net = new CNet(Topology);
          delete Topology;
          if(CheckPointer(Net) == POINTER_INVALID)
             return INIT_FAILED;
          dError = FLT_MAX;
         }
       else
         {
          CBufferFloat *temp;
          Net.getResults(temp);
          HistoryBars = temp.Total() / 12;
          delete temp;
         }
    //---
     ..................
     ..................
    //---
       return(INIT_SUCCEEDED);
      }
    

    Além disso, conforme discutido neste artigo acima, para efetuar o treinamento de um bloco recorrente, precisamos adicionar condições em que o modelo simplesmente terá que olhar para a "memória". Portanto, para fins de aprendizado, criaremos uma pilha de dados. E após cada iteração da propagação, removeremos as informações sobre a vela mais antiga da pilha e adicionaremos informações sobre a nova vela ao final da pilha.

    Assim, nossa pilha sempre conterá informações sobre vários estados históricos do modelo analisado. E a profundidade desse histórico será determinado por um parâmetro externo. É essa pilha que daremos ao autocodificador como valores alvo. E o excesso do tamanho da pilha sobre o valor dos dados iniciais na entrada do codificador forçará nosso codificador automático a examinar a memória de estados anteriores. 

     ..................
     ..................
             Net.feedForward(TempData, 12, true);
             TempData.Clear();
             if(!Net.GetLayerOutput(1, TempData))
                break;
             uint check_total = check_data.Total();
             if(check_total >= check_count)
               {
                if(!check_data.DeleteRange(0, check_total - check_count + 12))
                   return;
               }
             for(int t = TempData.Total() - 12 - 1; t < TempData.Total(); t++)
               {
                if(!check_data.Add(TempData.At(t)))
                   return;
               }
             if((total-it)>(int)HistoryBars)
                Net.backProp(check_data);
     ..................
     ..................
    

    O modelo foi testado com a preservação de todos os parâmetros usados anteriormente: EURUSD, H1, período de teste como últimos 15 anos. Configurações padrão do indicador. As últimas 10 velas são alimentadas na entrada do codificador. Nesse caso, o decodificador é treinado para decifrar as últimas 40 velas. Os resultados do teste são mostrados no gráfico abaixo. Os dados são alimentados na entrada do codificador após a conclusão da formação de cada nova vela.

    Resultados do treinamento do RNN Autoencoer

    Como você pode ver no gráfico, os resultados dos testes confirmam a viabilidade dessa abordagem para pré-treinamento não supervisionado de modelos recorrentes. Durante o treinamento de teste do nosso modelo, após 20 épocas de treinamento, o erro do modelo quase se estabilizou com uma taxa de perda inferior a 9%. Ao mesmo tempo, deve-se dizer que as informações sobre pelo menos 30 iterações anteriores são armazenadas no estado latente do modelo.


    Considerações finais

    Neste artigo, nos familiarizamos com os problemas de treinamento de modelos recorrentes usando autocodificadores. Na parte prática do artigo, eles criaram um autocodificador recorrente e realizaram seu treinamento de teste. Os resultados de nosso experimento nos permitem concluir que a abordagem proposta para aprender modelos recorrentes não supervisionados usando autocodificadores é viável. Quando testado, o modelo mostrou resultados muito bons ao restaurar dados das últimas 30 iterações.


    Referências

    1. Redes neurais de maneira fácil (Parte 4): redes recorrentes
    2. Redes neurais de maneira fácil (Parte 14): agrupamento de dados
    3. Redes neurais de maneira fácil (Parte 15): agrupamento de dados via MQL5
    4. Redes neurais de maneira fácil (Parte 16): uso prático do agrupamento
    5. Redes neurais de maneira fácil (Parte 17): redução de dimensionalidade
    6. Redes neurais de maneira fácil (Parte 18): regras de associação
    7. Redes neurais de maneira fácil (Parte 19): regras de associação usando MQL5
    8. Redes neurais de maneira fácil (Parte 20): autocodificadores
    9. Redes neurais de maneira fácil (Parte 21): autocodificadores variacionais (VAE)
    10. Aprendizado não supervisionado de representações em vídeo usando LSTMs
    11. Representações de aprendizado de frases utilizando codificador-decodificador RNN para tradução de máquina estatística

    Programas utilizados no artigo

    # Nome Tipo Descrição
    1 rnn_vae.mq5 EA   Robô de treinamento de autocodificador recorrente
    2 VAE.mqh Biblioteca de classe Biblioteca da classe de camada latente de autocodificador variacional
    3 NeuroNet.mqh Biblioteca de classe Biblioteca de classes para a criação de uma rede neural
    4 NeuroNet.cl Biblioteca Biblioteca de códigos do programa OpenCL