English Русский 中文 Deutsch 日本語 Português
preview
Redes neuronales: así de sencillo (Parte 21): Autocodificadores variacionales (VAE)

Redes neuronales: así de sencillo (Parte 21): Autocodificadores variacionales (VAE)

MetaTrader 5Sistemas comerciales | 25 octubre 2022, 14:31
468 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Contenido


    Introducción

    Continuamos con la introducción a los algoritmos para los métodos de aprendizaje no supervisado. En el artículo de la semana pasada, hablamos de los autocodificadores. El tema de los autocodificadores es bastante amplio y no se puede exponer de ninguna forma en un solo artículo. Me gustaría continuar con este tema y presentarles una modificación de los autocodificadores, los autocodificadores variacionales.


    1. La arquitectura del autocodificador variacional

    Antes de explorar la arquitectura de un autocodificador variacional, repasaremos un poco el material del artículo anterior.

    • El autocodificador es una red neuronal entrenada usando el método de propagación hacia atrás del error.
    • Cualquier autocodificador está formado por dos unidades de codificación y decodificación.
    • El tamaño de la capa de datos de entrada del codificador resulta igual al tamaño de la capa de salida del decodificador.
    • El codificador y el decodificador están unidos por un estado latente de "cuello de botella", en el que se comprime la información del estado de origen.

    Autocodificador

    En el proceso de entrenamiento, logramos la máxima similitud entre los resultados de la decodificación del estado latente con el decodificador y los datos de origen. En este caso, podemos decir que en el estado latente está codificada la máxima cantidad de información sobre los datos de origen, y que resulta suficiente para reconstruir los datos de origen con cierta probabilidad. Pero como decíamos en el último artículo, el uso de autocodificadores es algo mucho más amplio que la aplicación de tareas de compresión de datos.

    Y aquí topamos con un problema que se identificó en el uso de los autocodificadores para la generación de imágenes. Supongamos que nuestros datos de origen están representados por algún tipo de nube, y que en el proceso de entrenamiento, nuestro modelo ha aprendido a reconstruir perfectamente 2 objetos elegidos al azar A y B. Para exagerar un poco, el codificador y el decodificador se han puesto de acuerdo en que el objeto A en estado latente indique 1 y 5 para el objeto B. Esto no tiene nada de malo a la hora de comprimir los datos. Por el contrario, los objetos se separan bien y el modelo puede reconstruirlos.

    Nube de datos

    Pero cuando los investigadores trataron de usar autocodificadores para generar imágenes, la diferencia de valores de estado latente entre los 2 objetos resultó ser un problema. Los experimentos mostraron que el decodificador reconstruía estos objetos con algunas distorsiones al cambiar los valores de estado latente del objeto A al objeto B en las zonas próximas a los objetos. No obstante, en mitad de la brecha, el descodificador generaba algo que no resultaba propio de los datos originales.

    En otras palabras, el estado latente del autocodificador en el que se codifican y comprimen los datos de origen, puede no ser continuo o permitir cierta interpolación. Este es el problema fundamental de los autocodificadores a la hora de generar cualquier dato.

    Pero nosotros, desde luego, no vamos a generar ningún dato. Eso sí, no debemos olvidar la inconstancia de nuestro mundo. Al fin y al cabo, al estudiar una situación de mercado, la probabilidad de obtener un patrón de una muestra de entrenamiento con precisión matemática en el futuro resulta insignificante, y a nosotros, en cambio, nos gustaría que nuestro modelo gestionara correctamente la situación del mercado y diera resultados adecuados en el futuro. Por lo tanto, resolver este problema resulta tan necesario para nosotros como para los modelos generativos.

    La solución al problema no es tan trivial como podría parecer de primeras. El aumento de la muestra de entrenamiento y el uso de diferentes métodos de regularización del estado latente solo provocan un aumento del problema. Por ejemplo, aplicando la regularización, reducimos la distancia entre los vectores de estado latente de los objetos. Para el ejemplo exagerado anterior, digamos que los números serían 1 y 2, pero siempre puede haber un objeto que sea codificado por el codificador como 1.5. Una vez más, ponemos a nuestro decodificador en un "callejón sin salida". Una mayor convergencia con solapamiento puede dificultar la separación de los objetos.

    El aumento de la muestra de entrenamiento tiene un efecto similar, ya que cada estado continúa siendo discreto. No obstante, el aumento de la muestra de entrenamiento conlleva un incremento del tiempo y los recursos de entrenamiento. Al mismo tiempo, el autocodificador, en su intento de aislar cada patrón individual de datos de origen, tratará de maximizar la distancia al estado colindante más próximo.

    A diferencia de nuestro modelo, sabemos que cada uno de nuestros estados discretos es una representación de alguna clase de objetos. En nuestra nube de datos de origen, dichos objetos se encuentran cerca unos de otros y se distribuyen según una determinada ley de distribución. Vamos a añadir al modelo nuestro conocimiento a priori.

    Nube de datos

    Pero, ¿cómo hacer que el modelo retorne toda una serie de valores en lugar de un solo valor? En este caso, además, esta zona de valores puede diferir en el número de valores discretos y su diseminación. Esto podría recordarnos las tareas de clusterización, pero desconocemos el número de clases, y este puede variar en función de la muestra de datos de origen utilizada. Necesitamos un modelo presentación de datos más universal.

    Ya hemos mencionado que la posición de los objetos de cada clase en nuestra nube de datos de origen se somete a algún tipo de distribución. Probablemente, la más utilizada sea la distribución normal. Así que vamos a suponer que cada característica de nuestro estado latente en la salida del codificador se corresponde con una distribución normal. Como ya sabemos, una distribución normal está definida por 2 parámetros: la esperanza matemática y la desviación estándar. Así que pidamos a nuestro codificador que no retorne un valor discreto para cada característica, sino dos: la esperanza (valor medio) y la desviación estándar de la distribución a la que pertenece el patrón de datos de origen analizado.

    No importa cómo llamemos a los valores de salida del codificador, seguirán siendo los mismos números para el decodificador. Y aquí llegamos a la arquitectura del autocodificador variacional. En su arquitectura no existe transmisión directa de valores entre el codificador y el decodificador. Por el contrario, tomaremos los parámetros de distribución del codificador, muestrearemos un valor aleatorio de la distribución especificada y lo transmitiremos a la entrada del decodificador. Así, como resultado del procesamiento del codificador del mismo patrón de datos de origen, la entrada del decodificador podrá tener un vector de valores distinto, pero siempre estará sujeto a la misma distribución normal.

    Autocodificador variacional

    Como podemos ver, esta operación siempre dará como resultado dos veces menos valores en la entrada del decodificador que en la salida del codificador.

    Pero aquí es donde nos encontramos con el problema del entrenamiento de nuestro modelo. Como recordará, para entrenar el modelo se usa el método de propagación hacia atrás del error. Uno de los principales requisitos de este método es que todas las funciones deben ser diferenciables a lo largo de la trayectoria del gradiente de error, y un generador de números aleatorios, por desgracia, no lo es.

    Pero también hemos solucionado esta tarea. Veamos con detalle las propiedades de una distribución normal y el sentido de los parámetros que la describen. La distribución normal es una distribución de probabilidades matemáticas centrada en el punto de esperanza matemática y el 68% no se encuentra a más de la desviación estándar del centro de la distribución. En consecuencia, un cambio en la esperanza matemática desplazará el centro de la distribución, y el cambio de la desviación estándar escalará la dispersión de los valores alrededor del centro.

    Así, para obtener un valor único de una distribución normal con los parámetros indicados, podremos generar un valor para una distribución normal estándar con una esperanza de "0" y una desviación estándar de "1", y luego multiplicar este valor por la desviación estándar dada y sumarlo a la esperanza matemática indicada. Este enfoque se conoce comúnmente como reparameterization trick.

    Truco de reparametrización

    Como resultado, al generar un valor aleatorio a partir de una distribución normal estándar en una pasada directa, guardaremos este, mientras que el vector corregido con los parámetros establecidos se introducirá en la entrada del decodificador. En la pasada inversa, transmitiremos fácilmente el gradiente de error al codificador usando operaciones de suma y multiplicación, que son fácilmente diferenciables, y el generador no diferenciable de valores aleatorios se dejará a nuestro modelo.

    Parece que hemos armado el puzzle y sorteado todos los obstáculos, pero los experimentos prácticos han demostrado que el modelo no quiere "jugar" según nuestras nuevas reglas. En lugar de aprender reglas más complejas con las nuevas entradas, nuestro autocodificador ha reducido los signos de desviación estándar a "0" en el proceso de entrenamiento. Al multiplicar por "0", nuestra variable aleatoria no tiene efecto, y el decodificador obtiene como entrada un valor de esperanza discreto. Es decir, al reducir las características de la desviación estándar a "0", el modelo anula todo nuestro trabajo anterior y vuelve a realizar un intercambio de valores discretos entre el codificador y el decodificador.

    Para que el modelo funcione según nuestras reglas, tendremos que introducir reglas y restricciones adicionales. En particular, indicaremos a nuestro modelo que las características de la esperanza matemática y la desviación estándar deben corresponderse lo más posible con los parámetros de una distribución normal estándar. Podemos implementar esto añadiendo una penalización extra por desviación. Los autores del enfoque eligieron la divergencia de Kullback-Leibler como medida de esta desviación. No nos vamos a meter ahora con las matemáticas, solo daremos el resultado del error para cuando los valores empíricos se desvíen de los parámetros de la distribución normal. Precisamente esta función usaremos para regularizar los valores del estado latente. En la práctica, añadiremos su valor al error de estado latente.

    Divergencia de Kullback-Leibler para una distribución estándar

    Así, cada vez que penalicemos al modelo por desviar los parámetros de una característica respecto a la referencia (en este caso la distribución estándar), obligaremos al modelo a aproximar los parámetros de distribución de cada característica a los de la distribución estándar (siendo la esperanza matemática "0" y la desviación estándar "1").

    Aquí hay que decir que esta atracción de características a la salida del codificador iría en contra de la tarea principal de extracción de características de los objetos individuales. Al fin y al cabo, la regularización añadida "tirará" de todas las características hacia los valores de referencia con la misma fuerza, es decir, se esforzará por hacerlas iguales. Al mismo tiempo, el gradiente de error del descodificador tenderá a separar al máximo las características de los diferentes objetos. Existe un claro conflicto de intereses entre las dos tareas implicadas, y el modelo tiene que encontrar un equilibrio para hacer frente a las tareas planteadas. Y este equilibrio no siempre se corresponderá con nuestras expectativas. Para controlar este punto de equilibrio, introduciremos en el modelo un hiperparámetro adicional que controlará el efecto de la divergencia de Kullback-Leibler en el resultado global.


    2. Implementación

    Una vez discutidos los aspectos teóricos del algoritmo del autocodificador variacional, podemos pasar a la parte práctica de nuestro artículo. Hay que decir que para implementar el codificador y decodificador de nuestro autocodificador variacional, usaremos, como antes, las capas neuronales totalmente conectadas de la biblioteca que creamos anteriormente. Para implementar completamente el autocodificador variacional, nos falta el bloque de estado latente. En este bloque planeamos implementar todas las innovaciones del autocodificador variacional que hemos descrito antes.

    Para preservar el enfoque general de la organización de las redes neuronales en nuestra biblioteca, envolveremos todo el algoritmo de procesamiento del estado latente del autocodificador variacional en una capa neuronal aparte CVAE. Pero antes de empezar a implementar la clase en sí, crearemos varios kernels para implementar la funcionalidad en el lado del dispositivo OpenCL,

    y empezaremos nuestro trabajo con el kernel de pasada directa. Recordemos que la entrada a la capa que estamos creando son los parámetros de descripción de la distribución normal para las características de estado latente, pero aquí hay un matiz. La esperanza matemática puede tomar cualquier valor, mientras que la desviación estándar solo tiene valores no negativos. Si usamos diferentes capas neuronales para generar los parámetros, podríamos utilizar diferentes funciones de activación neuronal, pero la arquitectura de construcción de nuestra biblioteca solo permite modelos lineales. Solo se permite una función de activación dentro de una sola capa neuronal.

    Una vez más, al modelo no le importa en absoluto cómo llamaremos a un determinado indicador, simplemente cumple las fórmulas matemáticas establecidas. Esto solo es importante para nosotros, para que podamos construir nuestro modelo correctamente. Veamos la fórmula de la divergencia de Kullback-Leibler anterior. Usa la varianza y su logaritmo. Como ya sabemos, la varianza de una distribución es igual al cuadrado de la desviación típica y puede ser estrictamente no negativa. Al mismo tiempo, su logaritmo puede tomar valores positivos y negativos. Y si observamos la gráfica del logaritmo natural a partir del cuadrado del argumento, el punto en el que la gráfica de la función cruza la línea de abscisas está estrictamente en el valor "1". Este es el valor objetivo de la desviación estándar. Además, para un intervalo de valores de la función de -1 a 1, el argumento de la función tomará valores entre 0,6 y 1,6, lo cual cumple nuestra expectativa para la desviación estándar.

    Logaritmo natural de x^2

    Así, pediremos al codificador del modelo que emita la esperanza matemática y el logaritmo natural de la varianza de la distribución. Al hacerlo, podemos usar la tangente hiperbólica como función de activación de la capa neuronal, ya que su rango de valores cumple sobradamente nuestras expectativas tanto para la esperanza matemática de la distribución como para el logaritmo de su varianza.

    Una vez decidido el enfoque conceptual, procederemos a programar nuestras funciones. Como hemos mencionado antes, comenzaremos con el kernel de pasada directa VAE_FeedForward. Este kernel recibe los punteros a 3 búferes de datos en los parámetros. Dos búferes contienen los datos de origen, mientras que el otro contiene los resultados. Aquí debemos decir que no hay ningún generador de números pseudoaleatorios en la parte de OpenCL. Por consiguiente, tomaremos muestras de elementos de la distribución estándar en el lado del programa principal, y luego las transmitiremos por medio de un búfer randomen el kernel de pasada directa que estamos creando.

    El segundo búfer de datos de origen contendrá los resultados del codificador. Como ya habrá adivinado, el vector de expectativas y el vector de logaritmos de la varianza se contendrán en el mismo búfer inputs.

    En el cuerpo del kernel, todo lo que tenemos que hacer es implementar el truco de la reparametrización. Pero no olvidemos que en lugar de la desviación estándar, el codificador nos ha transmitido el logaritmo de la varianza. Así que necesitaremos obtener el valor de la desviación estándar antes de poder hacer el truco de cambio de parámetros de la distribución.

    La función exponencial es la inversa del logaritmo natural. Podemos usarla para obtener la varianza, y tras sacar la raíz cuadrada de la varianza, obtendremos la desviación estándar. O de forma alternativa, usando la propiedad de las potencias, podremos simplemente tomar el exponente de la mitad del logaritmo de la varianza, lo cual nos dará también la desviación estándar.

    Propiedad de las potencias

    En el cuerpo del kernel de pasada directa, primero definimos el ID del hilo actual y el número total de hilos en ejecución, que nos servirán como punteros a las celdas necesarias en los búferes de origen y resultados. Después haremos el truco de reparametrización teniendo en cuenta la extracción de la desviación estándar del logaritmo de varianza. A continuación, anotaremos el resultado en el elemento correspondiente del búfer de resultados y saldremos del kernel.

    __kernel void VAE_FeedForward(__global float* inputs,
                                  __global float* random,
                                  __global float* outputs
                                 )
      {
       uint i = (uint)get_global_id(0);
       uint total = (uint)get_global_size(0);
       outputs[i] = inputs[i] + exp(0.5f * inputs[i + total]) * random[i];
      }
    

    Como podemos ver, el algoritmo del kernel de pasada directa es bastante sencillo, y después de implementarlo, pasaremos a organizar una pasada inversa en el lado del contexto OpenCL. Debemos decir que nuestra capa de estado de latencia del autocodificador variacional no contendrá parámetros a entrenar. Por ello, el proceso completo de pasada inversa consistirá en organizar la transmisión del gradiente de error del decodificador al codificador. Vamos a implementar esta funcionalidad en el kernel VAE_CalcHiddenGradient.

    Al implementar este kernel, deberemos recordar que en la pasada directa tomaremos 2 elementos del vector de resultados del codificador y después del truco de reparametrización transmitiremos la característica a la entrada del decodificador. Como consecuencia, deberemos tomar un gradiente de error del decodificador y distribuirlo entre los dos elementos del codificador correspondientes.

    Para la esperanza matemática, será simple, pero al darse la suma, el gradiente de error se transmitirá completamente a ambos sumandos. Entonces para el logaritmo de varianza, estaremos tratando con la derivada de una función compleja.

    Derivada hasta el logaritmo de varianza

    Pero existe otra cara de la moneda. Como recordará, para que el modelo funcione según nuestras reglas, hemos introducido la divergencia de Kullback-Leibler, y ahora deberemos añadir al gradiente de error del descodificador el gradiente de error de la desviación de los parámetros de la distribución respecto a los valores de referencia de la distribución estándar.

    Veamos la implementación de lo anterior en el código del kernel VAE_CalcHiddenGradient. El kernel recibe en los parámetros los punteros a los 4 búferes de datos y una constante. Tres de los búferes resultantes transmiten la información sin procesar y un búfer para registrar los resultados del gradiente y transferirlos al nivel del codificador.

    • inputs son los resultados de la pasada directa del codificador. El búfer contiene los valores de las expectativas y los logaritmos de la varianza de las características;
    • random — valores de los elementos de la distribución estándar utilizados en la pasada directa;
    • gradiente — gradientes de error obtenidos del descodificador;
    • inp_grad — búfer de resultados para registrar los gradientes de error transmitidos al codificador;
    • kld_mult — valor discreto del coeficiente de efecto de la divergencia de Kullback-Leibler sobre el resultado global.

    En el cuerpo del kernel definiremos primero el número de secuencia del hilo actual y el número total de hilos del kernel en ejecución. Usaremos estos valores como punteros a los elementos correctos de los búferes de entrada y salida.

    A continuación, determinaremos el valor de la divergencia de Kullback-Leibler. Aquí debemos prestar atención a que pretendemos minimizar la distancia entre la distribución empírica y la distribución de referencia, es decir, reducirla a "0". Esto significa que el error será igual al valor de la desviación con el signo contrario. Para excluir operaciones innecesarias, simplemente eliminaremos el signo menos antes de la fórmula para definir la desviación. A continuación, corregiremos inmediatamente el valor por el coeficiente de efecto de la divergencia en el resultado global.

    Después, deberemos transmitir el gradiente de error al nivel del codificador. Aquí pasaremos la suma de los 2 gradientes sobre cada parámetro de la distribución, según las derivadas de las funciones anteriores.

    __kernel void VAE_CalcHiddenGradient(__global float* inputs,
                                         __global float* inp_grad,
                                         __global float* random,
                                         __global float* gradient,
                                         const float kld_mult
                                        )
      {
       uint i = (uint)get_global_id(0);
       uint total = (uint)get_global_size(0);
       float kld = kld_mult * 0.5f * (inputs[i + total] - exp(inputs[i + total]) - pow(inputs[i], 2.0f) + 1);
       inp_grad[i] = gradient[i] + kld * inputs[i];
       inp_grad[i + total] = 0.5f * (gradient[i] * random[i] * exp(0.5f * inputs[i + total]) -
                                     kld * (1 - exp(inputs[i + total]))) ;
      }
    

    Con esto concluimos el programa OpenCL y podemos proceder a implementar la funcionalidad en la parte principal del programa. Aquí primero creamos una nueva clase de capa neuronal CVAE, heredera de la clase básica de capas neuronales CNeuronBaseOCL.

    En esta clase, añadiremos la variable m_fKLD_Mult para almacenar el coeficiente del efecto de la divergencia de Kulback-Leibler en el resultado global y el método SetKLDMult para especificarlo. Asimismo, crearemos el búfer adicional m_cRandom para registrar los valores aleatorios de la distribución estándar. Vamos a muestrear directamente los valores usando los métodos de la biblioteca estándar de estadística y operaciones matemáticas "Math\Stat\Normal.mqh".

    Además, para implementar nuestra funcionalidad, deberemos redefinir los métodos de pasada directa e inversa, así como los métodos de trabajo con archivos.

    class CVAE : public CNeuronBaseOCL
      {
    protected:
       float             m_fKLD_Mult;
       CBufferDouble*    m_cRandom;
    
       virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL); 
       virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) { return true; } 
    
    public:
                         CVAE();
                        ~CVAE();
       virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                              uint numNeurons, ENUM_OPTIMIZATION optimization_type, uint batch);
       //---
       virtual void      SetKLDMult(float value) { m_fKLD_Mult = value;}
       virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);      
       //---
       virtual bool      Save(int const file_handle);
       virtual bool      Load(int const file_handle);
       //---
       virtual int       Type(void)        const                      {  return defNeuronVAEOCL; }
      };
    

    El constructor y el destructor de la clase son bastante simples. En el primero, solo estableceremos el valor inicial de nuestra nueva variable e inicializaremos un ejemplar del búfer de datos para trabajar con la secuencia de variables aleatorias.

    CVAE::CVAE()   : m_fKLD_Mult(0.01f)
      {
       m_cRandom = new CBufferDouble();
      }
    

    En el destructor de la clase, solo borraremos el objeto del búfer creado en el constructor.

    CVAE::~CVAE()
      {
       if(!!m_cRandom)
          delete m_cRandom;
      }
    

    El método para inicializar un ejemplar de una clase tampoco es complicado. De hecho, casi toda la funcionalidad de inicialización de un objeto es gestionada por el método de la clase padre. En ella se implementan todos los controles y la funcionalidad necesarios para inicializar los objetos herederos. Solo llamaremos al método de la clase padre en el método de nuestra clase de autocodificador variacional, y una vez que se haya ejecutado con éxito, inicializaremos el búfer de trabajo con secuencias aleatorias. Después crearemos de inmediato un búfer para ello en la memoria de contexto OpenCL.

    bool CVAE::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;
    //---
       if(!m_cRandom)
         {
          m_cRandom = new CBufferDouble();
          if(!m_cRandom)
             return false;
         }
       if(!m_cRandom.BufferInit(numNeurons, 0.0))
          return false;
       if(!m_cRandom.BufferCreate(OpenCL))
          return false;
    //---
       return true;
      }
    

    Comenzaremos la implementación de la funcionalidad principal de la clase con el método de pasada directa CVAE::feedForward. Al igual que los métodos similares de otras capas neuronales, el método recibirá en sus parámetros un puntero al objeto de la capa neuronal anterior, e inmediatamente después un bloque de controles. En el siguiente paso, comprobaremos la validez de los punteros a los objetos usados. A continuación, comprobaremos la dimensionalidad de los datos de origen obtenidos. El número de elementos del búfer de resultados de la capa anterior deberá ser múltiplo de 2 y también el doble del búfer de resultados de la capa neuronal que estamos creando. Este cumplimiento estricto viene dictado por la propia arquitectura del autocodificador variacional. Como recordará, el codificador debe retornar 2 valores para cada característica, describiendo la esperanza y la desviación estándar de la distribución de cada característica.

    bool CVAE::feedForward(CNeuronBaseOCL *NeuronOCL)
      {
       if(!OpenCL || !NeuronOCL || !m_cRandom)
          return false;
       if(NeuronOCL.Neurons() % 2 != 0 ||
          NeuronOCL.Neurons() / 2 != Neurons())
          return false;
    

    Una vez que el bloque de controles haya sido ejecutado con éxito, muestrearemos los valores aleatorios de la desviación estándar y transmitiremos sus valores al búfer correspondiente.

       double random[];
       if(!MathRandomNormal(0, 1, m_cRandom.Total(), random))
          return false;
       if(!m_cRandom.AssignArray(random))
          return false;
       if(!m_cRandom.BufferWrite())
          return false;
    

    Los valores generados se transferirán inmediatamente a la memoria contextual de OpenCL para su posterior procesamiento.

    A continuación, deberemos organizar una llamada al kernel correspondiente. Aquí deberemos transmitir primero los punteros a los búferes de datos utilizados por el kernel. Obsérvese que solo hemos transferido a la memoria de contexto el búfer del caso de los valores generados. Esperamos que todos los demás búferes de datos en uso estén ya en la memoria contextual. No obstante, si no hemos creado previamente búferes en la memoria de contexto o se ha realizado ningún cambio en los datos del búfer en el lado del programa principal, los datos deberán transmitirse a la memoria de contexto de OpenCL antes de pasar los punteros del búfer a los parámetros del kernel. Es muy importante recordar que un programa OpenCL solo trabaja con la memoria de su contexto sin acceder a la memoria global del ordenador, incluso si usamos una tarjeta gráfica integrada o una biblioteca OpenCL en el procesador.

       if(!OpenCL.SetArgumentBuffer(def_k_VAEFeedForward, def_k_vaeff_inputs, NeuronOCL.getOutput().GetIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_VAEFeedForward, def_k_vaeff_random, m_cRandom.GetIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_VAEFeedForward, def_k_vaeff_outputd, Output.GetIndex()))
          return false;
    

    Al final del método, indicaremos la dimensionalidad de las tareas y el desplazamiento de cada dimensión, y llamaremos al método encargado de poner el kernel en la cola de ejecución.

       uint off_set[] = {0};
       uint NDrange[] = {Neurons()};
       if(!OpenCL.Execute(def_k_VAEFeedForward, 1, off_set, NDrange))
          return false;
    //---
       return true;
      }
    

    Asegúrese de comprobar el resultado de cada paso. 

    Una vez completadas con éxito todas las operaciones del método de pasada directa, lo finalizaremos con el resultado true.

    La pasada directa se verá seguida de una pasada inversa. Como sabemos, se suele implementar la pasada inversa con varios métodos. Primero, usaremos los métodos calcOutputGradients, calcHiddenGradients y calcInputGradients para organizar el cálculo y la transmisión del gradiente de error secuencialmente a través de nuestro modelo desde la capa de salida neuronal hasta la capa de datos de entrada. Y luego, usando el método updateInputWeights, cambiaremos los parámetros a entrenar hacia el lado del antigradiente.

    Nuestra capa neuronal trabajará con la capa latente del autocodificador variacional y no contendrá parámetros a entrenar. Por lo tanto, redefiniremos el último método de optimización de los parámetros a entrenar con un stub que siempre retornará true cada vez que se llame al método.

    De hecho, para organizar adecuadamente el proceso de pasada inversa en una clase, solo tendremos que redefinir la función calcInputGradients. Aunque funcionalmente los métodos de pasada directa e inversa tienen la dirección opuesta de flujo de datos, el contenido de los métodos es bastante similar. Esto se debe a que la funcionalidad de los algoritmos se implementa en el lado del contexto de OpenCL. No obstante, en el lado del programa principal, solo estaremos haciendo el trabajo preparatorio para llamar a los kernels, y la llamada se realizará usando una única plantilla.

    Al igual que con el método de pasada directa, primero comprobamos la validez de los punteros de los objetos usados. No he ejecutado la nueva transferencia de datos al contexto OpenCL, pero si no está seguro de tener toda la información que necesita en la memoria contextual, será mejor que la vuelva a transferir a la memoria contextual de OpenCL ahora, y solo entonces transmita los parámetros al kernel.

    La transferencia exitosa de los parámetros va seguida de un bloque de operaciones para iniciar directamente la ejecución del kernel. Aquí establecemos primero la dimensionalidad de la tarea y el desplazamiento para cada dimensión. A continuación, llamaremos al método para colocar el kernel en cola de ejecución.

    bool CVAE::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
      {
       if(!OpenCL || !NeuronOCL)
          return false;
    //---
       if(!OpenCL.SetArgumentBuffer(def_k_VAECalcHiddenGradient, def_k_vaehg_input,
                                                            NeuronOCL.getOutput().GetIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_VAECalcHiddenGradient, def_k_vaehg_inp_grad,
                                                            NeuronOCL.getGradient().GetIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_VAECalcHiddenGradient, def_k_vaehg_random, Weights.GetIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_VAECalcHiddenGradient, def_k_vaehg_gradient, Gradient.GetIndex()))
          return false;
       if(!OpenCL.SetArgument(def_k_VAECalcHiddenGradient, def_k_vaehg_kld_mult, m_fKLD_Mult))
          return false;
       int off_set[] = {0};
       int NDrange[] = {Neurons()};
       if(!OpenCL.Execute(def_k_VAECalcHiddenGradient, 1, off_set, NDrange))
          return false;
    //---
       return true;
      }
    

    Luego verificaremos los resultados de todas las operaciones y saldremos del método.

    Esto completará la implementación de la funcionalidad básica de la clase, aunque no debemos olvidar funciones igualmente importantes en la gestión de archivos. Por lo tanto, complementaremos la funcionalidad de la clase también con estos métodos. Pero antes de empezar a escribir los métodos de la clase, pensaremos en la información que necesitamos guardar para restablecer con éxito el funcionamiento del modelo. En esta clase, solo hemos creado una variable y un búfer de datos. El contenido del búfer se rellena con valores aleatorios en cada pasada directa. Por consiguiente, no será necesario que almacenemos estos datos. Sin embargo, el valor de la variable será un hiperparámetro y tendremos que guardarlo.

    Por ello, nuestro método de almacenamiento de objetos solo contendrá 2 operaciones:

    • la llamada a un método similar de la clase padre, en el que se realizarán todos los controles necesarios y se guardarán los objetos heredados;
    • el almacenamiento del hiperparámetro del efecto de la divergencia de Kullback-Leibler en el resultado global.

    bool CVAE::Save(const int file_handle)
      {
    //---
       if(!CNeuronBaseOCL::Save(file_handle))
          return false;
       if(FileWriteFloat(file_handle, m_fKLD_Mult) < sizeof(m_fKLD_Mult))
          return false;
    //---
       return true;
      }
    

    Al mismo tiempo, no deberemos olvidarnos de comprobar el resultado de la ejecución de las operaciones. Después de completar con éxito todas las operaciones, saldremos del método con el resultado true.

    Para restablecer la funcionalidad del modelo, leeremos los datos almacenados en el archivo siguiendo estrictamente el orden de escritura de los datos. Primero llamamos a un método similar de la clase padre, en el que se organizan todos los controles necesarios y la carga de objetos heredados.

    bool CVAE::Load(const int file_handle)
      {
       if(!CNeuronBaseOCL::Load(file_handle))
          return false;
       m_fKLD_Mult=FileReadFloat(file_handle);
    

    Después de ejecutar con éxito el método de la clase padre, leeremos el valor del hiperparámetro del archivo y lo escribiremos en la variable correspondiente. Pero a diferencia del método de almacenamiento de datos, el método de carga de datos no termina aquí. Sí, no hay más información en el archivo para subir a esta clase, pero para que funcione correctamente, necesitaremos inicializar un búfer para manejar variables aleatorias del tamaño correcto. Luego crearemos un búfer con un tamaño igual al búfer de resultados cargado de la capa neuronal actual (cargado por el método de la clase padre), y crearemos inmediatamente un búfer apropiado en la memoria contextual de OpenCL.

       if(!m_cRandom)
         {
          m_cRandom = new CBufferDouble();
          if(!m_cRandom)
             return false;
         }
       if(!m_cRandom.BufferInit(Neurons(), 0.0))
          return false;
       if(!m_cRandom.BufferCreate(OpenCL))
          return false;
    //---
       return true;
      }
    

    Después de completar con éxito todas las operaciones, saldremos del método con el resultado true.

    Esto completará nuestra clase de procesamiento del estado latente para el autocodificador variacional. Podrá encontrar el código completo de todos los métodos y clases en el archivo adjunto.

    Nuestra nueva clase está lista, pero nuestra clase de organización de redes neuronales aún no sabe nada al respecto. Así que iremos al archivo NeuroNet.mqh y encontraremos la clase CNet.

    Primero iremos al constructor de la clase y describiremos el procedimiento para crear una nueva capa neuronal. A continuación, aumentaremos el número de núcleos OpenCL en uso y declararemos dos nuevos kernels.

    CNet::CNet(CArrayObj *Description)  :  recentAverageError(0),
                                           backPropCount(0)
      {
      .................
      .................
    //---
       for(int i = 0; i < total; i++)
         {
      .................
      .................
          if(CheckPointer(opencl) != POINTER_INVALID)
            {
             CNeuronBaseOCL *neuron_ocl = NULL;
             CNeuronConvOCL *neuron_conv_ocl = NULL;
             CNeuronProofOCL *neuron_proof_ocl = NULL;
             CNeuronAttentionOCL *neuron_attention_ocl = NULL;
             CNeuronMLMHAttentionOCL *neuron_mlattention_ocl = NULL;
             CNeuronDropoutOCL *dropout = NULL;
             CNeuronBatchNormOCL *batch = NULL;
             CVAE *vae = NULL;
             switch(desc.type)
               {
      .................
      .................
                //---
                case defNeuronVAEOCL:
                   vae = new CVAE();
                   if(!vae)
                     {
                      delete temp;
                      return;
                     }
                   if(!vae.Init(outputs, 0, opencl, desc.count, desc.optimization, desc.batch))
                     {
                      delete vae;
                      delete temp;
                      return;
                     }
                   if(!temp.Add(vae))
                     {
                      delete vae;
                      delete temp;
                      return;
                     }
                   vae = NULL;
                   break;
                default:
                   return;
                   break;
               }
            }
          else
             for(int n = 0; n < neurons; n++)
               {
      .................
      .................
               }
          if(!layers.Add(temp))
            {
             delete temp;
             delete layers;
             return;
            }
         }
    //---
       if(CheckPointer(opencl) == POINTER_INVALID)
          return;
    //--- create kernels
       opencl.SetKernelsCount(32);
      .................
      .................
       opencl.KernelCreate(def_k_VAEFeedForward, "VAE_FeedForward");
       opencl.KernelCreate(def_k_VAECalcHiddenGradient, "VAE_CalcHiddenGradient");
    //---
       return;
      }
    

    Realizaremos cambios similares en el método CNet::Load del modelo. Permítame no duplicar el código en este artículo, podrá leer el código del método usted mismo en el archivo adjunto.

    A continuación, añadiremos los punteros a la nueva clase en los métodos CLayer::CreateElement CLayer::Load.

    Finalmente, añadiremos los punteros de la nueva clase a los métodos de envío de la capa neuronal básica CNeuronBaseOCL FeedForward, calcHiddenGradients y UpdateInputWeights.

    Una vez realizadas todas las adiciones necesarias, podremos empezar a aplicar y probar el modelo. Podrá encontrar el código completo de todos los métodos y funciones en el archivo adjunto.


    3. Simulación

    Para probar el rendimiento de nuestro autocodificador variacional, hemos tomado el modelo del artículo anterior y lo hemos guardado en el nuevo archivo "vae.mq5". En ese modelo, el codificador ha retornado 2 valores en la neurona de la capa 5. Para organizar adecuadamente el autocodificador variacional, he aumentado el tamaño de la capa en la salida del codificador a 4 neuronas e insertado una 6ª nueva capa neuronal para trabajar con el estado latente del autocodificador variacional. El modelo ha sido entrenado con EURUSD en el marco temporal H1 sin cambiar ningún parámetro. He elegido un segmento temporal para el entrenamiento del modelo que abarque los últimos 15 años. En la siguiente figura se muestra un gráfico comparativo de la curva de aprendizaje del autocodificador multicapa y del autocodificador variacional.

    Resultados comparativos del aprendizaje 

    Como podemos ver en los resultados del entrenamiento del modelo, el autocodificador variacional ha mostrado un error de recuperación de datos significativamente menor durante todo el periodo de entrenamiento. Además, la dinámica de reducción de errores del autocodificador variacional es mayor.

    Basándonos en los resultados de las pruebas, podemos concluir que para resolver las tareas de extracción de las características de las series temporales utilizando el ejemplo de la dinámica del precio de EURUSD, los autocodificadores variacionales tienen un gran potencial en la extracción de características individuales de las descripciones de patrones.


    Conclusión

    En este artículo, hemos aprendido el algoritmo del autocodificador variacional, y también construido una clase para implementar el algoritmo del mismo. Asimismo, hemos puesto a prueba el entrenamiento de un modelo de autocodificador variacional con datos históricos reales. Los resultados de las pruebas han demostrado la validez del modelo de autocodificador variacional para su uso como modelo de preentrenamiento para la extracción de características individuales de la descripción de la situación del mercado. Los resultados de este entrenamiento pueden usarse para crear patrones comerciales que entrenar posteriormente con métodos de aprendizaje supervisado.


    Enlaces

    1. Redes neuronales: así de sencillo (Parte 14): Clusterización de datos
    2. Redes neuronales: así de sencillo (Parte 15): Clusterización de datos usando MQL5
    3. Redes neuronales: así de sencillo (Parte 16): Uso práctico de la clusterización
    4. Redes neuronales: así de sencillo (Parte 17): Reducción de la dimensionalidad
    5. Redes neuronales: así de sencillo (Parte 18): Reglas asociativas
    6. Redes neuronales: así de sencillo (Parte 19): Reglas asociativas usando MQL5
    7. Redes neuronales: así de sencillo (Parte 20): Autocodificadores
    8. Tutorial on Variational Autoencoders
    9. Intuitively Understanding Variational Autoencoders
    10. Tutorial - What is a variational autoencoder?



    Programas usados en el artículo

    # Nombre Tipo Descripción
    1 vae.mq5 Asesor   Asesor para el entrenamiento del autocodificador variacional
    2 vae2.mq5 Asesor Asesor para preparar los datos para su visualización 
    3 VAE.mqh Biblioteca de clases Biblioteca de clases de capa latente del autocodificador variacional
    4 NeuroNet.mqh Biblioteca de clases Biblioteca de clases para crear una red neuronal
    5 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/11206

    Archivos adjuntos |
    MQL5.zip (68.61 KB)
    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
    Continuamos analizando los algoritmos de aprendizaje no supervisado. Hoy hablaremos sobre el uso de autocodificadores en el entrenamiento de modelos recurrentes.
    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.
    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 20): Autocodificadores Redes neuronales: así de sencillo (Parte 20): Autocodificadores
    Continuamos analizando los algoritmos de aprendizaje no supervisado. El lector podría preguntarse sobre la relevancia de las publicaciones recientes en el tema de las redes neuronales. En este nuevo artículo, retomaremos el uso de las redes neuronales.