English Русский 中文 Deutsch 日本語 Português
preview
Redes neuronales: así de sencillo (Parte 22): Aprendizaje no supervisado de modelos recurrentes

Redes neuronales: así de sencillo (Parte 22): Aprendizaje no supervisado de modelos recurrentes

MetaTrader 5Integración | 27 octubre 2022, 07:42
345 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Contenido


    Introducción

    En los dos últimos artículos de nuestra serie hemos hablado de los autocodificadores. Su arquitectura permite entrenar varios modelos de redes neuronales con datos no marcados usando un algoritmo de propagación hacia atrás del error. Al hacerlo, el modelo entrenado aprende a comprimir los datos de origen destacando las características principales. Nuestros experimentos confirmaron el rendimiento de los modelos de autocodificación. Aquí hay que señalar que hemos utilizado capas neuronales totalmente conectadas para probar los autocodificadores. Se sabe que estos modelos funcionan con una ventana fija de datos de entrada. El algoritmo que hemos construido es capaz de entrenar cualquier modelo que funcione con una ventana fija de datos de entrada, pero la arquitectura de los modelos recurrentes es ligeramente distinta, ya que, además de los datos de origen, utilizan su estado anterior para tomar decisiones sobre la activación neuronal. Por consiguiente, deberemos tener en cuenta esta característica al construir el autocodificador.


    1. Peculiaridades del entrenamiento de modelos recurrentes

    Para empezar, vamos a recordar un poco la organización de los modelos recurrentes y su finalidad. Observemos el gráfico de movimiento de precios: muestra los datos históricos de movimiento de precios. Cada barra supone una descripción del rango dentro del cual ha fluctuado el precio de un instrumento durante un intervalo temporal determinado. Tenga en cuenta que se trata de "datos históricos", lo cual significa que ya no cambiarán. Con el tiempo, se añadirán nuevas barras, pero las antiguas no se modificarán. En cada momento concreto, tenemos datos históricos sin cambios y una última vela incompleta, que está sujeta a cambios hasta el cierre de su intervalo temporal.

    Gráfico de precios

    Mediante el análisis de los datos históricos, intentaremos predecir el movimiento de precios más probable. La profundidad de la historia analizada puede variar de un caso a otro, y este es probablemente uno de los principales problemas de la utilización de redes neuronales con un tamaño de datos de entrada fijo: una ventana relativamente pequeña para analizar los datos históricos limitará el análisis, mientras que una ventana demasiado grande complicará el propio modelo y su proceso de aprendizaje. Por ello, el arquitecto de un modelo de este tipo tendrá que comprometerse y definir un "término medio" al elegir el tamaño de la ventana de datos de entrada.

    Al mismo tiempo, también tratamos con datos históricos. Sea cual sea el tamaño de la ventana que elijamos, en cada iteración de nuestro modelo estaremos transmitiendo de nuevo casi el 99% de la información. El modelo luego reprocesará estos datos. Esto no parece un uso eficiente de los recursos, pero ni los modelos totalmente conectados ni los convolucionales recuerdan nada sobre la información procesada previamente.

    Encontramos la solución a los problemas anteriores en el uso de neuronas recurrentes. La idea era que el estado de cada neurona dependa del resultado del procesamiento de los datos de origen. En consecuencia, podemos suponer que el estado de la neurona es alguna forma comprimida de los datos de origen. Esto significa que podremos introducir en la entrada el estado anterior de una neurona, así como sus datos de origen. Así, el nuevo estado de la neurona dependerá tanto del estado actual del sistema analizado como del estado anterior, cuya información se ha comprimido en el estado anterior de la neurona. 

    Modelo recurrente

    Resulta que este enfoque permite al modelo recordar varios estados del sistema. El uso de funciones de activación y pesos cuyo valor absoluto es inferior a 1 reduce paulatinamente la influencia de los datos históricos más antiguos. Como resultado, tenemos un modelo con un horizonte de memoria bastante predecible.

    La presencia de estos modelos con memoria permite ir más allá de la ventana de datos históricos usada para la toma de decisiones y reducir la cantidad de información nuevamente transmitida, ya que el modelo ya la recordará. Estas propiedades hacen que los modelos recurrentes sean una de las áreas prioritarias en el procesamiento de las series temporales.

    Al mismo tiempo, el uso de estas características también requiere enfoques especiales en el entrenamiento de los modelos recurrentes. Por ejemplo, volviendo a la arquitectura del autocodificador, si igualamos la entrada Xi y la salida Yi del modelo en la figura anterior, no será necesario recordar el estado anterior para recuperar los datos de origen del estado latente del modelo. Por consiguiente, durante el entrenamiento, el modelo no tendrá en cuenta la influencia de los datos históricos y valorará únicamente el estado actual. Perdiendo la capacidad de recordar, el modelo recurrente perderá su principal ventaja.

    Por ello, al diseñar la arquitectura de nuestro modelo deberemos tener en cuenta este hecho y organizar el proceso de entrenamiento de forma que el modelo se vea obligado a recurrir a los datos de las iteraciones anteriores.

    Probablemente haya que decir que al construir autocodificadores, en la mayoría de los casos la arquitectura del decodificador es casi idéntica a la del codificador, y en el caso de los modelos recurrentes, esta práctica se mantiene. Pero, curiosamente, una de las primeras arquitecturas de este tipo se usó para el aprendizaje supervisado. En "Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation", los autores propusieron un RNN Encoder-Decoder como modelo para la traducción automática estadística. El codificador y decodificador de este modelo eran redes recurrentes. El codificador comprime la frase del idioma de origen a un cierto estado latente. A continuación, el descodificador la "desenvuelve" en una frase en la lengua meta. Recuerda mucho a un autocodificador, ¿verdad?

    El uso de un modelo recursivo permitía enviar las frases al codificador palabra por palabra, lo cual hizo posible entrenar el modelo con frases de distinta longitud. Tras recibir la frase completa, el codificador transmitía el estado latente al descodificador, y el descodificador, también palabra por palabra, daba una traducción de la frase en la lengua meta.

    Tras un entrenamiento con frases marcadas en inglés y francés, los autores obtuvieron un modelo capaz de retornar frases semántica y sintácticamente significativas.

    El aprendizaje directo de modelos de recurrencia no supervisados se presenta bastante bien en el artículo "Unsupervised Learning of Video Representations using LSTMs", publicado en febrero de 2015. Los autores del artículo realizaron una serie de experimentos con el entrenamiento de autocodificadores recurrentes en diversos materiales de vídeo. Esto implicaba tanto la reconstrucción de los datos recibidos en la entrada del codificador como la predicción de la continuación probable de la secuencia de vídeo.

    En el artículo se presentan varias arquitecturas de autocodificación, pero todas ellas usan bloques LSTM tanto para la codificación como para la decodificación de la señal. Hay que decir que los mejores resultados se obtuvieron entrenando un modelo con 1 codificador y 2 decodificadores. En él, un descodificador se encargaba de recuperar los datos de origen. El segundo decodificador se encarga de predecir la continuación más probable de la secuencia de vídeo.

    El uso de bloques recursivos en el codificador permite transmitir cuadro a cuadro la secuencia de vídeo original al modelo, mientras que los bloques decodificadores recursivos retornan cuadro a cuadro la secuencia de vídeo reconstruida o predicha, en función de la tarea.

    Además, los autores del artículo muestran que los modelos recurrentes preentrenados sin supervisión ofrecen resultados bastante buenos en tareas de reconocimiento de movimiento en vídeo después de terminar de entrenar un modelo con supervisión, incluso con una cantidad relativamente pequeña de datos marcados.

    El material presentado en ambos artículos nos permite esperar que un enfoque similar tenga también éxito en nuestras tareas.

    Al mismo tiempo, en mi aplicación me desviaré ligeramente de los modelos sugeridos. Todos ellos usaban bloques recursivos en el descodificador y retornaban los datos descodificados cuadro a cuadro. Esto se ajustaba a los objetivos de la traducción y el análisis del flujo de vídeo. Probablemente, esto dará resultados al predecir la siguiente barra, pero aún no he hecho ningún experimento de este tipo. No obstante, en general, al analizar la situación del mercado, la evaluaremos como una imagen completa que abarca un periodo temporal bastante amplio. Por ello, los cambios en las condiciones de mercado se introducirán en el modelo gradualmente y en pequeñas tandas. A su vez, el modelo deberá evaluar la situación del mercado a la luz de los datos actuales y los obtenidos previamente. Esto significa que el estado latente deberá contener información en un horizonte temporal lo más amplio posible.

    Para conseguir este efecto, en el codificador solo usaremos bloques recursivos. En el decodificador, proponemos utilizar las mismas capas neuronales completamente conectadas, solo que para recuperar los datos enviados al codificador en varias iteraciones.


    2. Implementación

    A continuación, pasaremos a la parte práctica de nuestro artículo. Aquí, construiremos nuestro codificador recursivo basándonos en los bloques LSTM comentados anteriormente, cuya estructura se muestra en la siguiente figura. Como recordará, este bloque consta de 4 capas neuronales totalmente conectadas. Tres de ellas actúan como puertas de enlace que regulan el flujo de información, mientras que la capa restante se encarga de transformar los datos de origen.

    El bloque LSTM usa dos flujos de información recurrentes: la memoria y el estado latente.

    Estructura del bloque LSTM

    Antes, ya hemos recreado el algoritmo de bloques LSTM usando MQL5. Ahora tenemos que replicarlo usando la tecnología OpenCL. Para implementarlo, creamos la nueva clase CNeuronLSTMOCL. Heredaremos el conjunto básico de búferes y métodos de la clase básica CNeuronBaseOCL, que usaremos como clase padre.

    La estructura de los métodos y variables de la clase se muestra a continuación. Los métodos de clase son bastante reconocibles: estos son los métodos de pasada directa e inversa que redefinimos en cada nueva clase. El propósito de las variables necesita ser explicado.

    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; }
      };
    
    

    En primer lugar, aquí vemos 3 búferes de datos:

    • m_cPesosLSTM — matriz de pesos LSTM del bloque;
    • m_cFirstMomentumLSTM — matriz de primeros momentos de la actualización del factor de ponderación;
    • m_cSecondMomentumLSTM — matriz de segundos momentos de la actualización del factor de ponderación.

      Preste atención. Como hemos mencionado antes, el bloque LSTM contiene 4 capas neuronales totalmente conectadas. Al mismo tiempo declaramos un solo búfer para la matriz de pesos m_cPesosLSTM. Este búfer contendrá en realidad los pesos de las 4 capas neuronales, mientras que el uso de un búfer concatenado nos permitirá computar en paralelo las 4 capas neuronales simultáneamente. Hablaremos un poco más tarde sobre el mecanismo de paralelismo, cuando veamos la implementación de cada método.

      Lo mismo ocurre con los búferes de impulso m_cFirstMomentumLSTM y m_cSecondMomentumLSTM.

      Hay que decir que en las últimas construcciones del terminal la empresa MetaQuotes Ltd. ha introducido una serie de mejoras. La tecnología OpenCL que usamos también se ha visto afectada. En particular, se ha aumentado el número máximo de objetos OpenCL y se ha añadido la posibilidad de usar la tecnología en tarjetas gráficas sin soporte de double. Esto nos permite reducir el tiempo total necesario para entrenar el modelo, ya que ahora no deberemos cargar los datos de la memoria de la CPU antes de llamar a cada kernel y no tendremos que descargarlos de nuevo después de su ejecución. Lo único que hay que hacer es cargar todos los datos de origen en la memoria contextual de OpenCL una vez antes del entrenamiento y copiar el resultado cuando este haya finalizado.  

      Lo que es más, esto nos permitirá declarar algunos búferes solo en el contexto OpenCL sin crear un búfer espejo en la memoria principal del dispositivo. Hablamos de búferes de almacenamiento de información, por lo tanto, para un rango de búferes, solo crearemos una variable para almacenar el puntero del búfer en un contexto OpenCL:

      • m_iMemory — puntero al búfer de memoria;
      • m_iHiddenState — puntero al búfer de estado oculto;
      • m_iConcatenated — puntero al búfer de resultados concatenados de las 4 capas neuronales internas;
      • m_iConcatenatedGradient — puntero al búfer del gradiente de error concatenado en el nivel de salida de las 4 capas neuronales internas;
      • m_iWeightsGradient — puntero al búfer del gradiente de error en el nivel de la matriz de pesos de las 4 capas neuronales internas.

      Asignaremos los valores iniciales a todas las variables en el constructor de la clase.

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

      A su vez, en el destructor de la clase, liberaremos los búferes en uso.

      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();
        }
      

      Vamos a continuar con los métodos de nuestra clase y a crear un método para inicializar el objeto de nuestro bloque LSTM. Siguiendo las reglas de la herencia, anularemos el método CNeuronLSTMOCL::Init conservando totalmente los parámetros del mismo método de la clase padre. En los parámetros nuestro método de inicialización obtendrá el número de neuronas de la siguiente capa, el índice de la neurona, el puntero al objeto de contexto OpenCL, el número de neuronas de la capa actual, el método de optimización de parámetros y el tamaño del paquete.

      En el cuerpo del método, primero llamaremos al método similar de la clase padre. Esto nos permitirá inicializar los objetos heredados de la clase padre, y también controlar los datos de origen obtenidos. Y, obviamente, comprobaremos enseguida el resultado de las operaciones.

      A continuación, tendremos que inicializar los búferes de datos declarados anteriormente. Aquí debemos decir que en esta fase no podremos inicializar completamente todos los búferes, porque simplemente no tenemos datos de origen suficientes. En los parámetros, hemos obtenido el número de neuronas de la capa actual y el número de neuronas de la capa siguiente, pero no sabemos el número de neuronas de la capa anterior. Por consiguiente, desconocemos el tamaño del búfer necesario para almacenar los pesos de nuestro bloque LSTM. Así que en esta etapa solo crearemos los búferes de datos cuyo tamaño solo dependerá del número de elementos de la capa actual.

      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;
        }
      

      Y, por supuesto, no nos olvidaremos de supervisar el proceso de las operaciones en cada paso.

      Una vez creados los métodos de inicialización de objetos, procederemos a organizar la pasada directa de nuestro bloque LSTM. Como ya sabemos, con la tecnología OpenCL, el cálculo se realiza directamente en el contexto de OpenCL en la GPU. En el código del programa principal, sin embargo, solo llamaremos al programa requerido. Por lo tanto, antes de escribir el método de nuestra clase, tendremos que añadir el kernel correspondiente a nuestro programa OpenCL.

      El kernel LSTM_FeedForward se encargará de organizar la pasada directa en el programa OpenCL. Para organizar el proceso correctamente, necesitaremos transmitir al kernel los punteros a los 5 búferes de datos y una constante:

      • inputs — búfer de datos de origen:
      • inputs_size — número de elementos del búfer de datos de origen;
      • weights — búfer de la matriz de pesos;
      • concatenated — búfer de resultados concatenados de todas las capas neuronales internas;
      • memory — búfer de memoria;
      • output — búfer de resultados (también conocido como búfer de estado oculto).

      __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);
      
      

      Ejecutaremos el búfer en un espacio de tareas bidimensional. En la primera dimensión indicaremos el número de elementos del bloque LSTM actual, mientras que la segunda dimensión será igual a los 4 hilos por el número de capas neuronales internas. Aquí debemos recordar que el número de elementos de nuestro bloque LSTM determina tanto el número de elementos en cada una de las capas internas como el número de elementos en la memoria y el estado oculto.

      Por consiguiente, en el cuerpo del kernel, lo primero que haremos es determinar el número ordinal del hilo en cada dimensión, así como el número total de tareas en la primera dimensión.

      Debemos añadir que el proceso completo de pasada directa de bloques LSTM puede dividirse a grandes rasgos en 2 subprocesos:

      • el cálculo de los valores de las capas neuronales internas;
      • la organización del flujo de datos desde las capas neuronales hasta la salida del bloque LSTM.

      La ejecución del segundo subproceso no puede efectuarse hasta que el primero esté completo. Más concretamente, para ejecutar el 2º subproceso necesitamos los valores de las 4 capas neuronales, al menos dentro del elemento actual del bloque LSTM. Por lo tanto, necesitamos sincronizar los hilos en la segunda dimensión; la implementación actual de OpenCL permite la sincronización de hilos dentro de un grupo local, lo cual significa que construiremos nuestros grupos locales en la segunda dimensión de las tareas.

      A continuación, organizaremos el cálculo de la suma ponderada de los datos de origen y el estado oculto. Primero calcularemos una suma ponderada del 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];
           }
      
      

      y luego añadiremos la suma ponderada de los datos de origen.

         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];
      
      

      Por último, añadiremos el valor de la neurona de desplazamiento.

      Después de calcular la suma ponderada, tendremos que calcular el valor de la función de activación. Recordemos que para las puertas, utilizaremos un sigmoide como función de activación, mientras que para la capa con el nuevo contenido, usaremos una tangente hiperbólica. La función de activación necesaria será determinada por el identificador de hilo en la segunda dimensión.

         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 ya hemos mencionado antes, para ejecutar el algoritmo correctamente, necesitaremos sincronizar los hilos en la segunda dimensión del espacio de tareas. Para sincronizar los flujos, utilizaremos la función barrier.

      Para organizar el proceso de transmisión de la información entre las capas internas, solo necesitaremos un hilo para cada elemento del bloque LSTM. Por consiguiente, una vez que los hilos están sincronizados, el proceso solo se ejecutará para el hilo con el ID de hilo "0" en la segunda dimensión del espacio de tareas.

         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);
           }
      //---
        }
      
      

      Con esto, podemos dar por completado el kernel de pasada directa: ya podemos llamarlo desde el programa principal. Primero crearemos las constantes necesarias,

      #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
      
      

      y entonces podremos proceder a crear el método de pasada directa para nuestra clase. Al igual que cualquier método similar de cualquiera de las otras clases previamente discutidas, el método obtiene un puntero al objeto de la capa neuronal anterior en sus parámetros, y ya en el cuerpo del método, organizaremos inmediatamente la comprobación del puntero recibido.

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

      A continuación, deberemos recordar que no pudimos inicializar todos los búferes de datos al inicializar la clase, pues el desconocimiento del número de neuronas de la capa anterior nos lo impedía. Ahora tenemos un puntero a la capa neuronal anterior, lo cual significa que podremos consultar el número de neuronas en esa capa y crear los búferes de datos que faltan. Pero antes de hacerlo, comprobaremos que los búferes aún no se han creado. Al fin y al cabo, puede que no sea la primera vez que se recurre al método de pasada directa. La variable que contiene el número de elementos de la capa anterior actuará como una especie de bandera.

         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;
      
      

      Una vez completado el trabajo preparatorio, podemos pasar los punteros a los búferes de datos y el valor de la constante requerida a los parámetros del kernel de pasada directa. Recuerde controlar el proceso de ejecución de las operaciones.

         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 continuación, estableceremos el espacio de tareas y el desplazamiento en él hasta la iteración 1. Obsérvese que en este caso estableceremos el espacio de tareas en 2 dimensiones y el tamaño de los grupos locales a combinar en 2 dimensiones. Y si en el primer caso especificaremos el número total de elementos de la capa actual en la 1ª dimensión, para el grupo local de la primera dimensión, especificamos solo un elemento. En la segunda dimensión, indicaremos 4 elementos en ambos casos, según el número de capas neuronales internas. Esto nos permitirá crear grupos locales de 4 hilos cada uno, y el número de estos grupos locales será igual al número de elementos de la capa neuronal actual.

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

      Así, al sincronizar los hilos en cada grupo local, sincronizaremos el cálculo de los valores de las 4 capas neuronales internas según cada elemento de la capa actual, y esto será suficiente para organizar el cálculo correcto de la pasada directa de todo el bloque LSTM.

      A continuación, todo lo que tendremos que hacer es poner nuestro kernel en la cola de ejecución.

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

      Esto completará la pasada directa de nuestro bloque LSTM y podremos proceder a organizar la pasada inversa. Aquí, como en la pasada directa, tendremos que completar el programa OpenCL antes de crear los métodos de nuestra clase. Y aunque hemos podido combinar en un solo kernel la pasada directa completa, necesitaremos hasta 3 kernels en el caso de la pasada inversa.

      El primer kernel LSTM_ConcatenatedGradient organizará la distribución del gradiente hasta el nivel de los resultados de las capas neuronales internas. Este kernel obtendrá en los parámetros los punteros a 4 búferes de datos. 3 de ellos contendrán los datos de origen: el búfer de gradiente de la capa subsiguiente, el estado de la memoria y el búfer de resultados concatenados de la capa neuronal interna. El cuarto búfer se usará para registrar los resultados del kernel.

      El kernel será llamado en un espacio de tareas unidimensional según el número de elementos de nuestro bloque LSTM.

      En el cuerpo del kernel, primero definiremos el ID del hilo y el número total de hilos, y luego, siguiendo el camino inverso de la señal, determinaremos el gradiente de error en el nivel de la puerta de salida, en el nivel de la memoria, en el nivel de la capa neuronal de nuevo contenido, en el nivel de la puerta de nuevo contenido y en el nivel de la puerta de olvido.

      __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
        }
      
      

      A continuación, tendremos que ejecutar un gradiente de error a través de las capas internas del bloque LSTM hasta la capa neuronal anterior. Para ello, crearemos el siguiente kernel LSTM_HiddenGradient. Debemos decir que al elaborar la arquitectura del programa OpenCL, decidimos combinar dentro de este kernel las distribuciones de gradiente hasta el nivel de la capa anterior y hasta el nivel de la matriz de pesos. Por lo tanto, recibirá en los parámetros los punteros a 6 búferes de datos y 2 constantes. Hemos planificado el kernel para ser llamado en un espacio de tareas 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);
      
      

      En el cuerpo del kernel, determinaremos el ID del hilo y el número total de hilos. Aquí también se determinará el tamaño de un solo vector de la matriz de pesos.

         uint weights_step = hidden_size + inputs_size + 1;
      
      

      A continuación, realizaremos un ciclo a través de todos los elementos del búfer de datos concatenados de origen, que incluye el estado oculto y el estado actual de la capa neuronal anterior. Cabe destacar que las iteraciones del ciclo comienzan a partir del ID del hilo actual y el paso de las iteraciones del ciclo es igual al número total de hilos en ejecución. Este enfoque permite iterar todos los elementos de la capa de datos concatenados de origen, independientemente del número de hilos que se ejecuten.

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

      En este paso, en el cuerpo del ciclo, organizaremos la división del flujo de operaciones según el elemento analizado. Si el elemento analizado se refiere a un estado latente, almacenaremos el estado latente en una variable privada, y transferiremos el valor correspondiente del búfer de resultados al búfer, dado que en la siguiente iteración ya se encontrará en el estado oculto.

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

      Si el elemento actual se refiere al búfer de datos de origen de la capa neuronal anterior, trasladaremos el valor de los datos de origen a una variable privada y calcularemos el gradiente de error para la neurona correspondiente de la capa 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;
              }
      
      

      Después de distribuir el gradiente de error en la capa neuronal anterior, distribuiremos el gradiente de error en los coeficientes de peso correspondientes del bloque LSTM.

            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;
              }
           }
      
      

      Al concluir el kernel, trasladaremos de forma similar el gradiente de error a las neuronas de sesgo de cada vector de coeficientes de peso.

         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);
           }
        }
      
      

      Después de distribuir el gradiente de error en la capa neuronal anterior y la matriz de pesos, todavía tendremos que implementar el proceso de actualización de los mismos. No he implementado toda la gama de métodos de optimización de parámetros, así que decidí implementar dentro de esta clase solo el método Adam que uso más a menudo. Podrá añadir cualquier otro método de optimización de los parámetros del modelo por analogía con mi implementación.

      Así, los parámetros del modelo se actualizarán en el kernel LSTM_UpdateWeightsAdam. Recordemos que el gradiente de error de la matriz de pesos ya ha sido calculado en el kernel anterior y almacenado en el búfer weights_gradient. Por lo tanto, en este kernel solo tendremos que organizar el proceso real de actualización de los parámetros del modelo. Para realizar el proceso de actualización de los parámetros usando el método Adam, necesitaremos 2 búferes adicionales para registrar el primer y el segundo momento. Además, necesitaremos hiperparámetros para el entrenamiento. Todos estos datos serán transmitidos en los parámetros del 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 sabemos, una matriz de pesos es una matriz bidimensional. Por lo tanto, llamaremos al kernel en un espacio de tareas igualmente bidimensional.

      En el cuerpo del kernel, determinaremos, como siempre, el número ordinal de los hilos en ambas dimensiones y el número total de hilos ejecutados en la primera dimensión. Partiendo de estas constantes, determinaremos el desplazamiento de los búferes hasta el coeficiente de peso a analizar, y luego ejecutaremos el algoritmo para actualizar el elemento correspondiente de la 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;
        };
      
      

      Con esto, podemos dar por terminada la implementación de los cambios en el programa OpenCL y proceder a implementar los métodos en el lado del programa principal.

      Al igual que sucede con el método de pasada directa, primero crearemos las constantes para trabajar con los kernels creados anteriormente.

      #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
      
      

      Después de ello, comenzaremos a trabajar con los métodos de nuestra clase. Primero crearemos el método de distribución del gradiente de error calcInputGradients. El método obtiene en sus parámetros el puntero al objeto de la capa neuronal anterior. Luego, verificaremos directamente la validez del puntero resultante.

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

      A continuación, comprobaremos que los búferes de datos necesarios estén disponibles en el 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;
      
      

      Solo después de que todos los controles hayan sido transmitidos con éxito, pasaremos a la llamada del kernel. Según el algoritmo de distribución del gradiente, llamaremos primero al kernel LSTM_ConcatenatedGradient.

      En primer lugar, organizaremos la transmisión de los datos de origen a los parámetros del 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;
      
      

      Después definiremos la dimensionalidad del espacio de tareas y podremos el kernel en la cola de ejecución.

         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;
      
      

      Aquí es también donde organizaremos la llamada del segundo kernel de distribución del gradiente de error LSTM_HiddenGradient. A continuación, transmitiremos los parámetros al 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;
      
      

      y luego, utilizaremos las matrices ya creadas para especificar el espacio de tareas y simplemente poner en la cola el kernel para su ejecución.

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

      Una vez más, querríamos recordarles que todas las operaciones deben ser supervisadas. Esto le permitirá rastrear posibles errores a tiempo y evitar una finalización crítica del programa en el peor momento posible.

      Una vez distribuido el gradiente de error, lo único que nos quedará por hacer para completar el algoritmo de pasada inversa será la implementación del método updateInputWeights para actualizar los parámetros del modelo. En los parámetros, este método obtiene el puntero al objeto de la capa anterior. Sin embargo, quiero recordar que ya hemos definido el gradiente de error a nivel de la matriz de pesos. Por ello, la presencia de un puntero al objeto de la capa anterior tiene que ver más con la organización de las redefiniciones de los métodos que con la necesidad de transmitir los datos. En este caso, el estado del puntero recibido no tendrá absolutamente ningún efecto sobre el resultado del método y ni siquiera lo comprobaremos. En su lugar, buscaremos los búferes internos que necesitamos en el 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;
      
      

      A continuación, transmitiremos los parámetros al kernel según el patrón establecido.

         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;
      
      

      Luego definiremos el espacio de tareas y pondremos el kernel en la cola para su ejecución.

         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;
        }
      
      

      Con esto concluiremos la organización de la pasada inversa. Así, nuestra clase CNeuronLSTMOCL estará lista para su primera prueba. No obstante, sabemos que hay que almacenar el modelo entrenado y luego restaurarlo para que funcione. Por lo tanto, vamos a añadir los métodos necesarios para manejar los archivos a nuestra clase.

      Como en todas las arquitecturas de capas neuronales que hemos comentado antes, el método para guardar los datos será el métodoSave. Este método recibe el manejador del archivo para escribir los datos en los parámetros.

      Lo primero que haremos en el cuerpo del método será llamar al mismo método de la clase padre. Esto nos permitirá implementar prácticamente todos los controles necesarios y guardar los objetos heredados de la clase padre con una sola línea de código. Y, por supuesto, comprobaremos inmediatamente el resultado del método de la clase padre.

      Después de ello, guardaremos el número de neuronas de la capa anterior, así como las matrices de pesos y 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;
        }
      
      

      Una vez guardados los datos, necesitaremos crear un método para restaurar el objeto a partir de los datos guardados Load. Ya sabemos que la lectura de datos desde un archivo sigue una estricta secuencia de escritura. Al igual que sucede con el método de almacenamiento de datos, nuestro método obtendrá en los parámetros el manejador del archivo para la lectura del mismo; inmediatamente después llamaremos al método similar de la clase padre.

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

      A continuación, leeremos el número de neuronas de la capa anterior y los búferes de pesos y momentos guardados previamente. Al hacerlo, después de cargar cada búfer, iniciaremos inmediatamente la creación de búferes espejo de datos en el contexto de OpenCL. Y, por supuesto, no nos olvidaremos de supervisar las operaciones.

         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;
      
      

      En este punto, me gustaría recordar que el método en cuestión no solo debe leer los datos del archivo, sino también restaurar la funcionalidad completa del modelo entrenado. Por lo tanto, después de leer los datos del archivo, también tendremos que crear búferes de datos temporales cuya información no hemos guardado en el archivo.

         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;
        }
      
      

      Con esto concluirá nuestro trabajo con los métodos de la clase CNeuronLSTMOCL

      A continuación, todo lo que tendremos que hacer es añadir los nuevos kernels en el procedimiento de conexión del contexto OpenCL y los punteros al nuevo tipo de capa neuronal en los métodos de envío de nuestra capa neuronal básica.

      Podrá encontrar una lista completa con todos los métodos de esta clase y las vistas anteriormente en los archivos anexos.


      3. Simulación

      Una vez completada la nueva clase de capa neuronal, podemos crear un modelo para el entrenamiento de prueba. Hemos construido un nuevo modelo de autocodificador recurrente basado en el modelo de autocodificador variacional del artículo anterior. Para ello, hemos tomado el modelo especificado y lo hemos guardado en un nuevo archivo llamado "rnn_vae.mq5". En el código del programa, hemos cambiado la arquitectura del codificador añadiendo bloques recursivos LSTM.

      Obsérvese que solo hemos introducido las últimas 10 velas en la entrada de nuestro codificador recurrente.

      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);
        }
      
      

      Además, como ya hemos comentado en este artículo, para organizar el entrenamiento de los bloques recurrentes tenemos que añadir las condiciones en las que el modelo estará simplemente obligado a buscar en la "memoria". Por ello, hemos creado una pila de datos para el entrenamiento, y después de cada iteración de la pasada directa, eliminaremos la vela más antigua de la pila y añadiremos la nueva vela al final de la misma.

      Así, nuestra pila siempre contendrá información sobre varios estados históricos del modelo analizado, y la profundidad de dicha historia vendrá determinada por un parámetro externo. Precisamente esta pila daremos al autocodificador como valores objetivo. Si el tamaño de la pila supera el valor de los datos de origen a la entrada del codificador, nuestro autocodificador se verá obligado a buscar en la memoria de estados pasados. 

       ..................
       ..................
               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);
       ..................
       ..................
      
      

      Hemos probado el modelo con todos los parámetros usados antes: EURUSD, marco temporal H1, periodo de prueba - últimos 15 años. Parámetros por defecto de los indicadores. Las últimas 10 velas se introducirán en la entrada del codificador. El decodificador está entrenado para decodificar las últimas 40 velas. Podemos ver los resultados de la prueba en el siguiente gráfico. La entrada del codificador recibe los datos después de que se forme cada nueva vela.

      Resultados del entrenamiento del autocodificador RNN

      Como podemos ver en el gráfico, los resultados de la prueba confirman la validez de este enfoque para el entrenamiento preliminar no supervisado de modelos recurrentes. Durante el entrenamiento de prueba de nuestro modelo, después de 20 épocas de entrenamiento, el error del modelo casi se ha estabilizado con una tasa de pérdida inferior al 9%. No obstante, debemos decir que el estado latente del modelo almacena información sobre al menos 30 iteraciones anteriores.


      Conclusión

      En este artículo hemos analizado el problema del entrenamiento de modelos recurrentes usando autocodificadores. En la parte práctica del artículo hemos creado un autocodificador recurrente y realizado un entrenamiento de prueba con el mismo. Los resultados de nuestro experimento nos permiten confirmar la validez del enfoque propuesto para el entrenamiento de modelos recurrentes no supervisado usando autocodificadores. En las pruebas, el modelo se ha comportado razonablemente bien al recuperar los datos de las últimas 30 iteraciones.


      Enlaces

      1. Redes neuronales: así de sencillo (Parte 4): Redes recurrentes
      2. Redes neuronales: así de sencillo (Parte 14): Clusterización de datos
      3. Redes neuronales: así de sencillo (Parte 15): Clusterización de datos usando MQL5
      4. Redes neuronales: así de sencillo (Parte 16): Uso práctico de la clusterización
      5. Redes neuronales: así de sencillo (Parte 17): Reducción de la dimensionalidad
      6. Redes neuronales: así de sencillo (Parte 18): Reglas asociativas
      7. Redes neuronales: así de sencillo (Parte 19): Reglas asociativas usando MQL5
      8. Redes neuronales: así de sencillo (Parte 20): Autocodificadores
      9. Redes neuronales: así de sencillo (Parte 21): Autocodificadores variacionales (VAE)
      10. Unsupervised Learning of Video Representations using LSTMs
      11. Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation

      Programas usados en el artículo.

      # Nombre Tipo Descripción
      1 rnn_vae.mq5 Asesor   Asesor de entrenamiento para el autocodificador recurrente
      2 VAE.mqh Biblioteca de clases Biblioteca de clases de capa latente del autocodificador variacional
      3 NeuroNet.mqh Biblioteca de clases Biblioteca de clases para crear una red neuronal
      4 NeuroNet.cl Biblioteca Biblioteca de código de programa OpenCL


      Traducción del ruso hecha por MetaQuotes Ltd.
      Artículo original: https://www.mql5.com/ru/articles/11245

      Archivos adjuntos |
      MQL5.zip (68.41 KB)
      Experimentos con redes neuronales (Parte 2): Optimización inteligente de una red neuronal Experimentos con redes neuronales (Parte 2): Optimización inteligente de una red neuronal
      Las redes neuronales lo son todo. Vamos a comprobar en la práctica si esto es así. MetaTrader 5 como herramienta autosuficiente para el uso de redes neuronales en el trading. Una explicación sencilla.
      Redes neuronales: así de sencillo (Parte 21): Autocodificadores variacionales (VAE) Redes neuronales: así de sencillo (Parte 21): Autocodificadores variacionales (VAE)
      En el anterior artículo, vimos el algoritmo del autocodificador. Como cualquier otro algoritmo, tiene ventajas y desventajas. En la implementación original, el autocodificador se encarga de dividir los objetos de la muestra de entrenamiento tanto como sea posible. Y en este artículo, en cambio, hablaremos de cómo solucionar algunas de sus deficiencias.
      DoEasy. Elementos de control (Parte 12): Objeto de lista básico, objetos WinForms ListBox y ButtonListBox DoEasy. Elementos de control (Parte 12): Objeto de lista básico, objetos WinForms ListBox y ButtonListBox
      En este artículo, crearemos un objeto de lista básico de objetos WinForms y dos nuevos objetos: ListBox y ButtonListBox.
      DoEasy. Elementos de control (Parte 11): Objetos WinForms: grupos, el objeto WinForms CheckedListBox DoEasy. Elementos de control (Parte 11): Objetos WinForms: grupos, el objeto WinForms CheckedListBox
      En este artículo, analizaremos el agrupamiento de objetos WinForms y crearemos una lista de objeto con objetos CheckBox.