English Русский 中文 Deutsch 日本語 Português
preview
Redes neuronales: así de sencillo (Parte 38): Exploración auto-supervisada por desacuerdo (Self-Supervised Exploration via Disagreement)

Redes neuronales: así de sencillo (Parte 38): Exploración auto-supervisada por desacuerdo (Self-Supervised Exploration via Disagreement)

MetaTrader 5Asesores Expertos | 6 septiembre 2023, 10:39
329 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introducción

El problema de la exploración es un obstáculo importante en el aprendizaje por refuerzo, especialmente cuando el agente recibe recompensas poco frecuentes y tardías, lo cual dificulta el desarrollo de una estrategia eficaz. Una posible solución a este problema es generar recompensas "intrínsecas" basadas en un modelo de entorno. Ya nos familiarizamos con un algoritmo similar al estudiar el módulo de curiosidad intrínseca. No obstante, la mayoría de los algoritmos creados solo se han investigado en el contexto de los juegos de computadora, y fuera de los entornos simulados silenciosos, el entrenamiento de modelos predictivos supone un reto debido a la naturaleza estocástica de las interacciones agente-entorno. Un posible enfoque para hacer frente a la estocasticidad del entorno es el algoritmo propuesto por Deepak Pathak en el artículo "Self-Supervised Exploration via Disagreement".

Este algoritmo se basa en un método de autoaprendizaje en el que el agente usa la información obtenida al interactuar con el entorno para generar recompensas "internas" y actualizar su estrategia. El algoritmo se basa en múltiples modelos de agentes que interactúan con el entorno y generan diversas predicciones. Si los modelos discrepan entre sí, se considerará un acontecimiento "interesante" y se incentivará al agente a explorar esa zona del entorno. De esta forma, el algoritmo estimulará al agente para explorar nuevas zonas del entorno y le permitirá obtener predicciones más precisas sobre futuras recompensas.


1. Algoritmo de exploración por desacuerdo

El algoritmo de exploración por desacuerdo es uno de los métodos de aprendizaje por refuerzo que permite a un agente explorar el entorno sin depender de recompensas externas, usando para ello nuevas áreas inexploradas con la ayuda de un conjunto de modelos.

En el artículo "Self-Supervised Exploration via Disagreement", los autores describen este enfoque y proponen un método sencillo que consiste en entrenar un conjunto de modelos de dinámica progresiva y estimular al agente para explorar el espacio de acción en el que existe el máximo desacuerdo o varianza entre las predicciones de los modelos del conjunto.

De esta manera, en lugar de elegir las acciones que produzcan la mayor recompensa esperada, el agente seleccionará las acciones que maximicen el desacuerdo entre los modelos del conjunto. Esto permite al agente explorar regiones del espacio de estados en las que los modelos del conjunto no están de acuerdo entre sí y en las que es probable que existan zonas nuevas e inexploradas del entorno.

En este caso, todos los modelos del conjunto convergen al valor medio, lo cual reduce la dispersión del conjunto y ofrece al agente predicciones más precisas sobre los estados del entorno y las posibles consecuencias de las acciones.

Además, el algoritmo de exploración por desacuerdo permite al agente hacer frente con éxito a la estocasticidad de la interacción con el entorno. Los resultados de los experimentos realizados por los autores del artículo demostraron que el enfoque propuesto mejora efectivamente la exploración en entornos estocásticos y supera a los métodos anteriormente existentes en cuanto a motivación intrínseca y modelado de la incertidumbre. Además, observaron que su enfoque puede ampliarse al aprendizaje supervisado, en el que el valor de un patrón no se determina según una etiqueta verdadera, sino en función del estado de un conjunto de patrones.

Así, el algoritmo de exploración por desacuerdo supone un enfoque prometedor para resolver el problema de la exploración en entornos estocásticos. Permite al agente explorar el entorno de forma más eficiente y sin tener que depender de recompensas externas, lo cual puede resultar especialmente útil en aplicaciones del mundo real en las que las recompensas externas pueden ser limitadas o costosas.

Además, este algoritmo puede aplicarse en diversos contextos, incluido el tratamiento de datos de alta dimensionalidad como las imágenes, en los que medir y maximizar la incertidumbre del modelo puede resultar especialmente difícil.

Los autores del artículo demostraron la eficacia del algoritmo propuesto en varias tareas, como el control de robots, varios juegos de Atari y tareas de navegación por laberintos. Como resultado de su investigación, demostraron que el algoritmo de exploración por desacuerdo supera significativamente a otros métodos de exploración en cuanto a la velocidad, la convergencia y la calidad del aprendizaje.

Así pues, este enfoque de la exploración por desacuerdo supone un paso importante en el campo del aprendizaje por refuerzo, que puede ayudar a los agentes a explorar mejor y más eficazmente el entorno y lograr un mejor rendimiento en distintas tareas.

Vamos a analizar el algoritmo propuesto.

Mientras interactúa con el entorno, el agente evalúa el estado Xt actual y, guiado por su política intrínseca, realiza alguna acción At. El resultado será un cambio del estado del entorno a un nuevo estado Xt+1. Un conjunto de estos datos se almacena en un búfer de reproducción de experiencias que utilizamos para entrenar un conjunto de modelos dinámicos que predicen el estado futuro del entorno.

Para preservar la independencia de la valoración del estado futuro del entorno en la fase inicial, todas las matrices de pesos de los modelos dinámicos del conjunto se rellenan con valores aleatorios. Durante el proceso de entrenamiento, cada modelo recibe su propio conjunto de datos de entrenamiento aleatorios del búfer de reproducción de experiencias.

Cada modelo de nuestro conjunto se entrena para predecir el siguiente estado del entorno actual. Las partes del espacio de estados que han sido bien exploradas por el agente han recogido suficientes datos para entrenar todos los modelos, lo cual conduce a la coherencia entre modelos. Dado que los modelos están entrenados, esta característica debería generalizarse a partes desconocidas pero similares del espacio de estados. No obstante, los espacios nuevos e inexplorados seguirán presentando un elevado error de predicción para todos los modelos, ya que ninguno de ellos se ha entrenado aún con esos ejemplos, lo cual provocará una divergencia en la predicción del siguiente estado. Así que utilizamos esta divergencia como recompensa intrínseca para orientar la política. En concreto, la recompensa intrínseca Ri se define como la varianza de la salida de los distintos modelos del conjunto.

Nótese que en la fórmula anterior no existe dependencia respecto a la recompensa intrínseca del estado futuro del sistema. Usaremos esta propiedad más adelante en la implementación de este método.

En cuanto al escenario estocástico, dado un número suficiente de muestras, el modelo de previsión dinámico deberá aprender a predecir la media de las muestras estocásticas. De esta manera, la varianza de las salidas en el conjunto disminuirá, evitando que el agente se atasque en los mínimos locales estocásticos de la exploración. Debemos tener cuenta que esto difiere de los objetivos basados en el error de predicción, que se establecerán en la media después de un número suficiente de muestras. En su caso, la media difiere de los verdaderos estados aleatorios individuales, el error de predicción sigue siendo alto, lo cual hace que el agente se interese siempre por el comportamiento estocástico.

Al utilizar el algoritmo propuesto, cada paso de la interacción del agente con el entorno ofrece información no solo sobre la recompensa recibida del entorno, sino también sobre la información necesaria para actualizar el modelo intrínseco del agente sobre cómo cambia el estado del entorno al realizar acciones. Esto permite al agente extraer información valiosa sobre el entorno aunque no obtenga una recompensa externa explícita.

Representación del modelo del artículo original

La recompensa intrínseca Ri, que se calcula como la varianza de los resultados de los distintos modelos del conjunto, se utiliza para entrenar la política del agente. Cuanto mayor sea el desacuerdo entre los resultados del modelo, mayor será el valor de la recompensa intrínseca. Esto permite al agente explorar nuevas regiones del espacio de estados en las que la predicción del siguiente estado resulta incierta, así como aprender a tomar mejores decisiones basándose en estos datos.

El agente se entrena en línea usando los datos recogidos por él mismo al interactuar con el entorno. Al mismo tiempo, el conjunto de modelos se actualiza después de cada interacción entre el agente y el entorno, lo cual permite al agente actualizar su modelo intrínseco sobre el entorno a cada paso y obtener predicciones más precisas de los estados del entorno en el futuro.

2. Implementación usando MQL5

En nuestra aplicación, no repetiremos al completo el algoritmo propuesto, sino que solo tomaremos sus ideas principales y las trasladaremos a nuestras tareas.

Lo primero que haremos es pedir al conjunto de modelos dinámicos que prediga el estado comprimido (oculto) del sistema, por analogía con el módulo de curiosidad intrínseca. Esto nos permitirá "comprimir" el tamaño de los modelos dinámicos y el conjunto entero.

El segundo punto es el hecho de que no necesitamos conocer el verdadero estado del sistema para determinar la recompensa intrínseca, sino que bastará con los valores predichos por los modelos dinámicos de conjunto. Esto nos permitirá utilizar la recompensa predictiva no solo para incentivar el aprendizaje posterior, sino también para tomar la decisión de emprender una acción en tiempo real. No distorsionaremos la recompensa externa introduciendo un componente interno al entrenar la política del agente, sino que permitiremos que el agente construya inmediatamente una política propia para maximizar la recompensa externa: este es nuestro principal objetivo.

Sin embargo, para maximizar el estudio del entorno durante el proceso de aprendizaje, a la hora de seleccionar una acción del agente, añadiremos a la recompensa predictiva la varianza en la divergencia de las predicciones de los modelos dinámicos para cada posible acción del agente.

Esto nos lleva a otro punto: para calcular en paralelo los estados predichos después de cada acción, pediremos a nuestros modelos dinámicos que nos ofrezcan predicciones para cada acción posible del agente basándose en el estado actual, aumentando el tamaño de la capa de resultados de cada modelo según el número de acciones posibles.

Una vez definidas las principales áreas de nuestro trabajo, podemos proceder a aplicar el algoritmo. De inmediato, se nos plantea la cuestión de la aplicación de un conjunto de modelos dinámicos: todos nuestros modelos creados anteriormente son lineales. La organización de los cálculos paralelos se realiza usando OpenCL dentro de un subproceso y una capa neuronal. Sin embargo, la organización de cálculos paralelos de varios modelos no resulta posible por el momento. La creación de una secuencia de múltiples cálculos del modelo conlleva un aumento significativo del tiempo de entrenamiento del mismo.

Para abordar esta cuestión, hemos decidido utilizar el método de organización de cálculos paralelos usado en la organización de la atención multi-cabeza. Ahora combinaremos los datos de todas las cabezas de atención en tensores únicos y realizaremos la división a nivel del espacio de tareas en OpenCL.

No vamos a rediseñar toda nuestra biblioteca ahora mismo para resolver este tipo de problema. En esta fase, no nos interesa la precisión concreta de los valores predichos del estado futuro del sistema, basta con obtener la sincronización relativa del conjunto de modelos. Por consiguiente, utilizaremos capas totalmente conectadas en los modelos dinámicos de predicción.

En primer lugar, crearemos kernels de programas OpenCL para organizar esta funcionalidad. El kernel de pasada directa FeedForwardMultiModels ha replicado casi por completo el kernel similar de la capa básica de conexión completa, pero hay algunas diferencias leves.

Los parámetros del kernel no se han modificado. Aquí se encuentran tres búferes de datos (los tensores de la matriz de pesos, los datos de origen y la matriz de resultados), así como dos constantes: el tamaño de la capa de datos de origen y la función de activación. Pero mientras que antes especificábamos el tamaño completo de la capa anterior como el tamaño de la capa de datos de origen, ahora esperamos obtener el número de elementos del modelo actual.

__kernel void FeedForwardMultiModels(__global float *matrix_w,
                                     __global float *matrix_i,
                                     __global float *matrix_o,
                                     int inputs,
                                     int activation
                                    )
  {
   int i = get_global_id(0);
   int outputs = get_global_size(0);
   int m = get_global_id(1);
   int models = get_global_size(1);

En el cuerpo del kernel, primero identificamos el hilo actual. Y aquí podemos observar la aparición de una segunda dimensionalidad del espacio de tareas que identifica el modelo actual. La dimensionalidad global de los problemas indicará el tamaño del conjunto.

A continuación, declararemos las variables locales necesarias y determinaremos el desplazamiento en los búferes de datos dada la neurona calculada y el modelo actual en el conjunto.

   float sum = 0;
   float4 inp, weight;
   int shift = (inputs + 1) * (i + outputs * m);
   int shift_in = inputs * m;
   int shift_out = outputs * m;

La parte matemática inmediata de cálculo del estado de la neurona y de la función de activación se ha mantenido sin cambios. Solo hemos añadido correcciones para el desplazamiento en los búferes de datos.

   for(int k = 0; k <= inputs; k = k + 4)
     {
      switch(inputs - k)
        {
         case 0:
            inp = (float4)(1, 0, 0, 0);
            weight = (float4)(matrix_w[shift + k], 0, 0, 0);
            break;
         case 1:
            inp = (float4)(matrix_i[shift_in + k], 1, 0, 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], 0, 0);
            break;
         case 2:
            inp = (float4)(matrix_i[shift_in + k], matrix_i[shift_in + k + 1], 1, 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], 0);
            break;
         case 3:
            inp = (float4)(matrix_i[shift_in + k], matrix_i[shift_in + k + 1], matrix_i[shift_in + k + 2], 1);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]);
            break;
         default:
            inp = (float4)(matrix_i[shift_in + k], matrix_i[shift_in + k + 1], matrix_i[shift_in + k + 2],
                                                                                                  matrix_i[shift_in + k + 3]);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]);
            break;
        }
      float d = dot(inp, weight);
      if(isnan(sum + d))
         continue;
      sum += d;
     }

Tras calcular el valor de la función de activación especificada en los parámetros, guardaremos el resultado obtenido en el búfer de datos matrix_o.

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

Esta solución nos permitirá calcular en paralelo y en un único kernel el valor de una capa única de todos los modelos del conjunto. Obviamente, esto tiene una limitación: la arquitectura de todos los modelos del conjunto es idéntica, las diferencias solo están en los coeficientes de peso.

La situación con la pasada inversa es ligeramente distinta. El algoritmo posibilita un entrenamiento conjunto de modelos dinámicos en un conjunto diferente de datos de entrenamiento. No generaremos por separado paquetes de entrenamiento para cada modelo. En su lugar, entrenaremos en cada pasada inversa solo un modelo seleccionado aleatoriamente del conjunto. Para otros modelos, transmitiremos un gradiente cero a la capa anterior. Estos son los cambios que realizaremos en el algoritmo del kernel de distribución de gradiente dentro de la capa CalcHiddenGradientMultiModels.

Un kernel similar de la capa neuronal totalmente conectada subyacente obtenía como parámetros los punteros a cuatro búferes de datos y dos variables. Hablamos del tensor de la matriz de pesos y el tensor de resultados de la capa anterior para calcular la derivada de la función de activación. También tenemos 2 búferes de gradiente: las capas neuronales actual y anterior. La primera contiene los gradientes de error recibidos y la segunda se utiliza para registrar los resultados del kernel y transmitir el gradiente de error a la capa neuronal anterior. Las variables incluyen el número de neuronas de la capa actual y la función de activación de la capa anterior. A los parámetros anteriores añadimos el identificador del modelo entrenado, que seleccionaremos de forma aleatoria en el lado del programa principal.

__kernel void CalcHiddenGradientMultiModels(__global float *matrix_w,
                                            __global float *matrix_g,
                                            __global float *matrix_o,
                                            __global float *matrix_ig,
                                            int outputs,
                                            int activation,
                                            int model
                                           )
  {
   

En el cuerpo del kernel, en primer lugar identificaremos el hilo. Como en el kernel de pasada directa, utilizaremos un espacio de tareas bidimensional. En la primera dimensión, identificaremos el hilo dentro de un único modelo, mientras que la segunda indicará el modelo de forma conjunta. Para recoger los gradientes de error, ejecutaremos un kernel en una sección de neuronas de la capa anterior. Cada hilo reúne los gradientes de error de todas las direcciones en una neurona individual.

   int i = get_global_id(0);
   int inputs = get_global_size(0);
   int m = get_global_id(1);
   int models = get_global_size(1);

Tenga en cuenta que distribuiremos el gradiente en un solo modelo y ejecutaremos hilos para todo el conjunto. Esto se relaciona con la necesidad de eliminar el gradiente de error de otros modelos. En el siguiente paso, comprobaremos si es necesario actualizar el gradiente para un modelo concreto, y si solo necesitamos poner a cero el gradiente, realizaremos solo esta función y saldremos del kernel sin realizar ninguna operación innecesaria.

//---
   int shift_in = inputs * m;
   if(model >= 0 && model != m)
     {
      matrix_ig[shift_in + i] = 0;
      return;
     }

Aquí dejamos un pequeño resquicio para un posible uso futuro. Y si especificamos un número negativo como número de modelo a actualizar, el gradiente se calculará para todos los modelos del conjunto.

A continuación, declararemos las variables locales y definiremos el desplazamiento en los búferes de datos.

//---
   int shift_out = outputs * m;
   int shift_w = (inputs + 1) * outputs * m;
   float sum = 0;
   float out = matrix_o[shift_in + i];
   float4 grad, weight;

A esto le seguirá la parte matemática de la distribución del gradiente de error, que reproducirá al completo una funcionalidad similar de la neurona subyacente totalmente conectada. Obviamente, añadiremos el desplazamiento necesario en los búferes de datos, y guardaremos el resultado de las operaciones en el búfer de gradiente de la capa anterior.

   for(int k = 0; k < outputs; k += 4)
     {
      switch(outputs - k)
        {
         case 1:
            weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], 0, 0, 0);
            grad = (float4)(matrix_g[shift_out + k], 0, 0, 0);
            break;
         case 2:
            grad = (float4)(matrix_g[shift_out + k], matrix_g[shift_out + k + 1], 0, 0);
            weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], matrix_w[shift_w + (k + 1) * (inputs + 1) + i], 0, 0);
            break;
         case 3:
            grad = (float4)(matrix_g[shift_out + k], matrix_g[shift_out + k + 1], matrix_g[shift_out + k + 2], 0);
            weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], matrix_w[shift_w + (k + 1) * (inputs + 1) + i],
                                                                           matrix_w[shift_w + (k + 2) * (inputs + 1) + i], 0);
            break;
         default:
            grad = (float4)(matrix_g[shift_out + k], matrix_g[shift_out + k + 1], matrix_g[shift_out + k + 2], 
                                                                                                 matrix_g[shift_out + k + 3]);
            weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], matrix_w[shift_w + (k + 1) * (inputs + 1) + i], 
                              matrix_w[shift_w + (k + 2) * (inputs + 1) + i], matrix_w[shift_w + (k + 3) * (inputs + 1) + i]);
            break;
        }
      sum += dot(grad, weight);
     }
   if(isnan(sum))
      sum = 0;
   switch(activation)
     {
      case 0:
         out = clamp(out, -1.0f, 1.0f);
         sum = clamp(sum + out, -1.0f, 1.0f) - out;
         sum = sum * max(1 - pow(out, 2), 1.0e-4f);
         break;
      case 1:
         out = clamp(out, 0.0f, 1.0f);
         sum = clamp(sum + out, 0.0f, 1.0f) - out;
         sum = sum * max(out * (1 - out), 1.0e-4f);
         break;
      case 2:
         if(out < 0)
            sum *= 0.01f;
         break;
      default:
         break;
     }
   matrix_ig[shift_in + i] = sum;
  }

A continuación deberemos modificar el kernel de actualización de la matriz de pesos UpdateWeightsAdamMultiModels. Al igual que en el kernel de distribución del gradiente de error, añadiremos un identificador de modelo a los parámetros ya disponibles del kernel básico de la capa completamente conectada.

Hay que decir que un kernel similar de la capa neuronal subyacente ya está funcionando en el espacio de tareas bidimensional, pero, al mismo tiempo, no necesitamos realizar ninguna operación con los modelos no actualizados. Por consiguiente, llamaremos al kernel para un solo modelo, y utilizaremos el parámetro ID del modelo para determinar el desplazamiento en los búferes de datos. De lo contrario, el algoritmo del kernel permanecerá inalterado. Podrá familiarizarse por sí mismo con él en el archivo adjunto.

Con esto daremos por finalizado el trabajo con la parte de OpenCL del programa y pasaremos al código de nuestra biblioteca MQL5. Aquí crearemos una nueva clase CNeuronMultiModel heredando nuestra clase básica CNeuronBaseOCL.

El conjunto de métodos de la clase es bastante estándar e incluye métodos para la inicialización de la clase, el manejo de archivos y la pasada directa e inversa. También introduciremos dos nuevas variables en las que registraremos el número de modelos del conjunto y el ID del modelo a entrenar. Este último cambiará en cada pasada inversa.

class CNeuronMultiModel : public CNeuronBaseOCL
  {
protected:
   int               iModels;
   int               iUpdateModel;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL); 
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);

public:
                     CNeuronMultiModel(void){};
                    ~CNeuronMultiModel(void){};
   virtual bool      Init(uint numInputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                                            ENUM_OPTIMIZATION optimization_type, int models);
   virtual void      SetActivationFunction(ENUM_ACTIVATION value) {  activation = value;         }    
   //---
   virtual bool      calcHiddenGradients(CNeuronBaseOCL *NeuronOCL);   
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual int       Type(void)        const                      {  return defNeuronMultiModels; }
  };

No vamos a crear ningún nuevo objeto interno en la clase, por lo que el constructor y el destructor de la clase permanecerán vacíos. Comenzaremos nuestro trabajo de creación de métodos con el método de inicialización de la clase Init. En los parámetros, el método obtendrá:

  • numInputs — número de neuronas en la capa anterior para un modelo
  • open_cl — puntero al objeto OpenCL
  • numNeurons — número de neuronas en una capa del modelo
  • models — número de modelos del conjunto.

bool CNeuronMultiModel::Init(uint numInputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                             ENUM_OPTIMIZATION optimization_type, int models)
  {
   if(CheckPointer(open_cl) == POINTER_INVALID || numNeurons <= 0  || models <= 0)
      return false;

En el cuerpo del método comprobaremos directamente si el puntero al objeto OpenCL es pertinente y si la especificación de la dimensionalidad de la capa y del conjunto es correcta. Luego almacenaremos las constantes necesarias en variables internas.

   OpenCL = open_cl;
   optimization = ADAM;
   iBatch = 1;
   iModels = models;

Aquí cabe señalar que hemos creado el kernel de actualización de las matrices de pesos utilizando únicamente el método Adam. Por ello, especificaremos este método de optimización del modelo independientemente del obtenido en los parámetros.

Después, crearemos búferes para registrar los resultados de la capa neuronal y los gradientes de error. Tenga en cuenta que el tamaño de todos los búferes aumentará proporcionalmente al número de modelos del conjunto. En la etapa inicial, los búferes se inicializarán con valores cero. 

//---
   if(CheckPointer(Output) == POINTER_INVALID)
     {
      Output = new CBufferFloat();
      if(CheckPointer(Output) == POINTER_INVALID)
         return false;
     }
   if(!Output.BufferInit(numNeurons * models, 0.0))
      return false;
   if(!Output.BufferCreate(OpenCL))
      return false;
//---
   if(CheckPointer(Gradient) == POINTER_INVALID)
     {
      Gradient = new CBufferFloat();
      if(CheckPointer(Gradient) == POINTER_INVALID)
         return false;
     }
   if(!Gradient.BufferInit((numNeurons + 1)*models, 0.0))
      return false;
   if(!Gradient.BufferCreate(OpenCL))
      return false;

A continuación, inicializaremos el búfer de la matriz de pesos con valores aleatorios. El tamaño del búfer deberá ser suficiente para almacenar los coeficientes de peso de todos los modelos del conjunto dentro de la capa neuronal actual.

//---
   if(CheckPointer(Weights) == POINTER_INVALID)
     {
      Weights = new CBufferFloat();
      if(CheckPointer(Weights) == POINTER_INVALID)
         return false;
     }
   int count = (int)((numInputs + 1) * numNeurons * models);
   if(!Weights.Reserve(count))
      return false;
   float k = (float)(1 / sqrt(numInputs + 1));
   for(int i = 0; i < count; i++)
     {
      if(!Weights.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier))
         return false;
     }
   if(!Weights.BufferCreate(OpenCL))
      return false;

La aplicación del método de optimización de Adam requiere la creación de dos búferes de datos para registrar los momentos 1 y 2. El tamaño de los búferes indicados es similar al tamaño de la matriz de pesos. En la fase inicial, estos búferes se inicializarán con valores cero.

//---
   if(CheckPointer(DeltaWeights) != POINTER_INVALID)
      delete DeltaWeights;
//---
   if(CheckPointer(FirstMomentum) == POINTER_INVALID)
     {
      FirstMomentum = new CBufferFloat();
      if(CheckPointer(FirstMomentum) == POINTER_INVALID)
         return false;
     }
   if(!FirstMomentum.BufferInit(count, 0))
      return false;
   if(!FirstMomentum.BufferCreate(OpenCL))
      return false;
//---
   if(CheckPointer(SecondMomentum) == POINTER_INVALID)
     {
      SecondMomentum = new CBufferFloat();
      if(CheckPointer(SecondMomentum) == POINTER_INVALID)
         return false;
     }
   if(!SecondMomentum.BufferInit(count, 0))
      return false;
   if(!SecondMomentum.BufferCreate(OpenCL))
      return false;
//---
   return true;
  }

No olvide supervisar el proceso de las operaciones en cada fase, y después de completar con éxito todas las operaciones anteriores, finalizar el método.

Tras la realizar la inicialización, pasaremos al método feedForward. Este método solo recibe en sus parámetros el puntero al objeto de la capa neuronal anterior. En el cuerpo del método comprobaremos inmediatamente la relevancia del puntero recibido.

bool CNeuronMultiModel::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;

Ya hemos creado un kernel en el programa OpenCL para realizar todas las operaciones de pasada directa requeridas por el algoritmo de capa neuronal. Ahora nos toca a nosotros transmitir los datos necesarios al kernel y provocar su ejecución.

En primer lugar, definiremos el espacio de tareas. Arriba hemos decidido utilizar un espacio de tareas bidimensional. En la primera dimensión, indicaremos el número de neuronas a la salida del modelo, mientras que en la segunda, estableceremos el número de modelos de este tipo. Al inicializar la clase, no hemos mantenido el número de neuronas en una sola capa del modelo. Por lo tanto, ahora dividiremos el número total de neuronas a la salida de nuestra capa por el número de modelos del conjunto para determinar la dimensionalidad de la primera dimensión del espacio de tareas. Resulta más fácil con la segunda dimensión. Aquí tenemos una variable independiente con el número de modelos del conjunto.

   uint global_work_offset[2] = {0, 0};
   uint global_work_size[2];
   global_work_size[0] = Output.Total() / iModels;
   global_work_size[1] = iModels;

Tras definir el espacio de tareas, transmitiremos los datos de entrada necesarios a los parámetros del kernel. Al mismo tiempo, nos aseguraremos de comprobar el resultado de las operaciones.

   if(!OpenCL.SetArgumentBuffer(def_k_FFMultiModels, def_k_ff_matrix_w, getWeightsIndex()))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FFMultiModels, def_k_ff_matrix_i, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FFMultiModels, def_k_ff_matrix_o, Output.GetIndex()))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_FFMultiModels, def_k_ff_inputs, NeuronOCL.Neurons() / iModels))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_FFMultiModels, def_k_ff_activation, (int)activation))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }

Observe que utilizamos el ID recién creado de nuestro nuevo kernel para especificar este, mientras que para especificar los parámetros se utilizan los identificadores del kernel correspondiente de la capa básica completamente conectada. Esto es posible porque se conservan todos los parámetros del kernel y sus secuencias.

Después de transmitir todos los parámetros, solo nos quedará enviar el kernel a la cola de ejecución.

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

Luego comprobaremos si todas las operaciones son correctas y finalizaremos el método.

A continuación, comenzaremos a trabajar con los métodos de pasada inversa. Consideramos en primer lugar el método de distribución de gradientes de error calcHiddenGradients. Como en la pasada directa, en los parámetros del método obtendremos el puntero al objeto de la capa neuronal anterior, e inmediatamente, en el cuerpo del método, comprobaremos la relevancia del puntero recibido.

bool CNeuronMultiModel::calcHiddenGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;

El siguiente paso será definir el espacio de tareas. Aquí todo es similar al método de pasada directa.

   uint global_work_offset[2] = {0, 0};
   uint global_work_size[2];
   global_work_size[0] = NeuronOCL.Neurons() / iModels;
   global_work_size[1] = iModels;

Luego transmitiremos los datos de origen a los parámetros del kernel.

   if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_w, getWeightsIndex()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_g, getGradientIndex()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_o, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_ig, NeuronOCL.getGradientIndex()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_HGMultiModels, def_k_chg_outputs, Neurons() / iModels))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_HGMultiModels, def_k_chg_activation, NeuronOCL.Activation()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }

Como podemos ver, se trata de un algoritmo bastante estándar para organizar el kernel de un programa OpenCL, que ya hemos implementado muchas veces. Sin embargo, hay un matiz a considerar en la transmisión del ID del modelo para el entrenamiento. Vamos a seleccionar un número aleatorio de modelo para el entrenamiento. Para ello utilizaremos un generador de números pseudoaleatorios. No obstante, debemos recordar que es precisamente para este modelo que debemos actualizar la matriz de pesos en la siguiente etapa. Por lo tanto, almacenaremos el identificador aleatorio del modelo resultante en la variable iUpdateModel creada anteriormente. Podemos utilizar su valor al actualizar la matriz de pesos.

   iUpdateModel = (int)MathRound(MathRand() / 32767.0 * (iModels - 1));
   if(!OpenCL.SetArgument(def_k_HGMultiModels, def_k_chg_model, iUpdateModel))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }

Después de transmitir con éxito todos los parámetros, enviaremos el kernel a la cola de ejecución y finalizaremos el método.

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

El algoritmo del método de actualización de la matriz de pesos repite paso por paso la preparación y colocación del kernel en la cola de ejecución y no contiene trampas, por lo que no nos detendremos en su descripción. Su código completo se encuentra en el archivo adjunto.

Los métodos Save y Load se utilizan para trabajar con archivos. Su algoritmo es bastante sencillo. La cuestión es que solo hemos creado dos variables en la nueva clase: el número de modelos del conjunto y el ID del modelo entrenado. Solo la primera variable contiene el hiperparámetro que vamos a almacenar. El proceso de almacenamiento de todos los objetos y variables heredados ya está organizado en los métodos de la clase padre. Allí mismo es donde se establecen todos los controles necesarios. Por lo tanto, para guardar los datos, solo tendremos que llamar primero al método similar de la clase padre y luego guardar el valor de un solo hiperparámetro.

bool CNeuronMultiModel::Save(const int file_handle)
  {
   if(!CNeuronBaseOCL::Save(file_handle))
      return false;
   if(FileWriteInteger(file_handle, iModels) <= 0)
      return false;
//---
   return true;
  }

El trabajo de carga de datos desde un archivo se organiza de forma similar.

Con esto podemos dar por finalizado nuestro trabajo con el código de la nueva clase. El código completo de todos sus métodos se encuentra en el archivo adjunto,

pero antes de poder utilizarlo, deberemos hacer algunas cosas más en el código de nuestra biblioteca. En primer lugar, deberemos crear constantes para identificar los kernels y los parámetros añadidos.

#define def_k_FFMultiModels             46 ///< Index of the kernel of the multi-models neuron to calculate feed forward
#define def_k_HGMultiModels             47 ///< Index of the kernel of the multi-models neuron to calculate hiden gradient
#define def_k_chg_model                 6  ///< Number of model to calculate
#define def_k_UWMultiModels             48 ///< Index of the kernel of the multi-models neuron to update weights
#define def_k_uwa_model                 9  ///< Number of model to update

Luego añadiremos:

  • el bloque de creación de un nuevo tipo de capa neuronal en el método CNet::Create;
  • el nuevo tipo de capa al método CLayer::CreateElement;
  • el nuevo tipo al método de gestión de pasada directa de la red neuronal de la clase básica;
  • el nuevo tipo al método de gestión de pasada inversa CNeuronBaseOCL::calcHiddenGradients(CObject *TargetObject).

Así, hemos construido una clase para ejecutar en paralelo múltiples capas independientes totalmente conectadas, lo cual nos permite crear conjuntos de modelos. Pero esto es solo una parte, no todo el algoritmo de exploración por desacuerdo. Para implementar el algoritmo completo, crearemos una nueva clase de modelos CEVD, similar al módulo de curiosidad intrínseca. Podemos encontrar muchas similitudes en la estructura de las clases. Esto se observa en la denominación de los métodos y variables. Podemos ver el búfer de reproducción de experiencias CReplayBuffer. Existen dos modelos internos cTargetNet y cForwardNet, pero no un modelo inverso. Como cForwardNet, utilizaremos un conjunto de modelos. Las diferencias, como siempre, las encontraremos en los detalles.

//+------------------------------------------------------------------+
//| Exploration via Disagreement                                     |
//+------------------------------------------------------------------+
class CEVD : protected CNet
  {
protected:
   uint              iMinBufferSize;
   uint              iStateEmbedingLayer;
   double            dPrevBalance;
   bool              bUseTargetNet;
   bool              bTrainMode;
   //---
   CNet              cTargetNet;
   CReplayBuffer     cReplay;
   CNet              cForwardNet;

   virtual bool      AddInputData(CArrayFloat *inputVals);

public:
                     CEVD();
                     CEVD(CArrayObj *Description, CArrayObj *Forward);
   bool              Create(CArrayObj *Description, CArrayObj *Forward);
                    ~CEVD();
   int               feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true);
   bool              backProp(int batch, float discount = 0.999f);
   int               getAction(int state_size = 0);    
   float             getRecentAverageError() { return recentAverageError; }
   bool              Save(string file_name, bool common = true);
   bool              Save(string dqn, string forward, bool common = true);
   virtual bool      Load(string file_name, bool common = true);
   bool              Load(string dqn, string forward, uint state_layer, bool common = true);
   //---
   virtual int       Type(void)   const   {  return defEVD;   }
   virtual bool      TrainMode(bool flag) { bTrainMode = flag; return (CNet::TrainMode(flag) && cForwardNet.TrainMode(flag));}
   virtual bool      GetLayerOutput(uint layer, CBufferFloat *&result)
     { return        CNet::GetLayerOutput(layer, result); }
   //---
   virtual void      SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; }
   virtual void      SetBufferSize(uint min, uint max);
  };

Ahora añadiremos la variable bTrainMode para separar el algoritmo en procesos de explotación y aprendizaje. Luego añadiremos la bandera bUseTargetNet, ya que no vamos a actualizar constantemente cTargetNet antes de cada paquete de actualización del modelo. También hemos introducido cambios en el algoritmo de los métodos. Pero lo primero es lo primero.

Los métodos de detección de pasada directa y de acción del agente ahora pueden dividir el algoritmo en procesos de explotación y de entrenamiento. Esto se debe a que en el proceso de entrenamiento queremos que el agente explore el entorno tanto como sea posible. En el proceso de explotación, por el contrario, queremos eliminar riesgos innecesarios y seguir únicamente políticas intrínsecas. Veamos cómo se aplica todo esto.

Al principio, el método de pasada directa se construye de forma similar al método correspondiente del bloque de curiosidad intrínseca. En los parámetros, obtendremos el estado inicial del sistema, y luego lo completaremos con datos sobre el estado de la cuenta y las posiciones abiertas. A continuación, llamaremos al método de pasada directa del modelo entrenado.

int CEVD::feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true)
  {
   if(!AddInputData(inputVals))
      return -1;
//---
   if(!CNet::feedForward(inputVals, window, tem))
      return -1;

Pero además, el algoritmo de selección de acciones se divide en 2 hilos: entrenamiento y explotación. En el modo de entrenamiento, leeremos el estado oculto (comprimido) del entorno del modelo entrenado y realizaremos una pasada directa de nuestro conjunto de modelos dinámicos. Permítanme recordarles que, a diferencia del módulo de curiosidad intrínseca, nosotros examinamos la previsión de estado no para una acción concreta, sino para toda la gama de acciones posibles. Solo después de una pasada directa exitosa del conjunto Forward llamaremos al método para determinar la acción óptima. Nos familiarizaremos con este método un poco más adelante.

   int action = -1;
   if(bTrainMode)
     {
      CBufferFloat *state;
      //if(!GetLayerOutput(1, state))
      //   return -1;
      if(!GetLayerOutput(iStateEmbedingLayer, state))
         return -1;
      if(!cForwardNet.feedForward(state, 1, false))
        {
         delete state;
         return -1;
        }
      double balance = AccountInfoDouble(ACCOUNT_BALANCE);
      double reward = (dPrevBalance == 0 ? 0 : balance - dPrevBalance);
      dPrevBalance = balance;
      action = getAction(state.Total());
      delete state;
      if(action < 0 || action > 3)
         return -1;
      if(!cReplay.AddState(inputVals, action, reward))
         return -1;
     }

Tras definir correctamente una acción, añadiremos el conjunto de estados al búfer de reproducción de experiencias.

En el modo de operación, no realizamos acciones innecesarias, sino que solo determinaremos la acción óptima basada en la política intrínseca del agente y finalizaremos el método.

   else
      action = getAction();
//---
   return action;
  }

El algoritmo del método de determinación de la acción óptima también se divide en 2 hilos: entrenamiento y explotación.

int CEVD::getAction(int state_size = 0)
  {
   CBufferFloat *temp;
//--- get the result of the trained model
   CNet::getResults(temp);
   if(!temp)
      return -1;

Al principio del método, cargaremos el resultado de la pasada directa del modelo entrenado, y luego, en el caso del entrenamiento de modelos, ajustaremos este valor según el valor de la varianza de las predicciones del conjunto de modelos dinámicos para cada acción posible. Para ello, primero descargaremos el resultado del conjunto en un vector y luego transformaremos el vector en una matriz. En la matriz resultante, cada fila individual representará el estado previsto del sistema para una acción distinta. Solo nuestra matriz contendrá predicciones de todos los modelos del conjunto. Para facilitar el procesamiento de los resultados, dividiremos horizontalmente la matriz en varias matrices iguales más pequeñas. El número de estas matrices será igual al número de modelos del conjunto, y cada una de estas matrices tendrá una dimensionalidad de fila igual al espectro de acciones posibles de nuestro agente.

Ahora podremos utilizar operaciones matriciales y encontrar primero la matriz de valores medios para cada acción individual de un único componente del estado, y luego calcular la varianza de las desviaciones de las matrices predictivas respecto a la media. El valor medio de varianza de cada acción lo añadiremos a los valores de recompensa previstos del modelo entrenado. En este punto, se puede utilizar un factor para equilibrar la exploración y la explotación. Para maximizar la exploración del entorno, solo se puede usar la varianza de los valores predichos, sin centrarse en la recompensa esperada. De esta manera, estimularemos al modelo para que maximice el entrenamiento sobre el entorno sin afectar a la política del agente.

//--- make allowance for "curiosity" in training mode
   if(bTrainMode && state_size > 0)
     {
      vector<float> model;
      matrix<float> forward;
      cForwardNet.getResults(model);
      forward.Init(1, model.Size());
      forward.Row(model, 0);
      temp.GetData(model);
      //---
      int actions = (int)model.Size();
      forward.Reshape(forward.Cols() / state_size, state_size);
      matrix<float> ensemble[];
      if(!forward.Hsplit(forward.Rows() / actions, ensemble))
         return -1;
      matrix<float> means = ensemble[0];
      int total = ArraySize(ensemble);
      for(int i = 1; i < total; i++)
         means += ensemble[i];
      means = means / total;
      for(int i = 0; i < total; i++)
         ensemble[i] -= means;
      means = MathPow(ensemble[0], 2.0);
      for(int i = 1 ; i < total; i++)
         means += MathPow(ensemble[i], 2.0);
      model += means.Sum(1) / total;
      temp.AssignArray(model);
     }

Durante la explotación del modelo no realizaremos ningún ajuste, sino que determinaremos la acción óptima basándonos en el principio de maximización de la recompensa esperada.

//---
   return temp.Argmax();
  }

Podrá encontrar el código completo de los métodos en el archivo adjunto.

Merece la pena mencionar algo más sobre el método de pasada inversa. Para evitar iteraciones innecesarias durante el funcionamiento del modelo, el método de pasada inversa finalizará inmediatamente en ausencia de una bandera de entrenamiento del modelo. Esto permitirá cambiar rápidamente del modo de entrenamiento del modelo al modo de prueba sin cambiar el código del asesor.

bool CEVD::backProp(int batch, float discount = 0.999000f)
  {
//---
   if(cReplay.Total() < (int)iMinBufferSize || !bTrainMode)
      return true;

Después de pasar el bloque de control, crearemos las variables locales necesarias,

//---
   CBufferFloat *state1, *state2, *targetVals = new CBufferFloat();
   vector<float> target, actions, st1, st2, result;
   matrix<float> forward;
   double reward;
   int action;

y después de hacer el trabajo preparatorio, organizaremos el ciclo de entrenamiento de los modelos en el tamaño de paquete especificado en los parámetros del método.

//--- training loop in the batch size
   for(int i = 0; i < batch; i++)
     {
      //--- get a random state and the buffer replay
      if(!cReplay.GetRendomState(state1, action, reward, state2))
         return false;
      //--- feed forward pass of the training model ("current" state)
      if(!CNet::feedForward(state1, 1, false))
         return false;

En el cuerpo del ciclo, primero obtendremos un conjunto de estados aleatorios del búfer de reproducción de experiencias y realizaremos una pasada directa del modelo entrenado con el estado obtenido.

      getResults(target);
      //--- unload state embedding
      if(!GetLayerOutput(iStateEmbedingLayer, state1))
         return false;
      //--- target net direct pass
      if(!cTargetNet.feedForward(state2, 1, false))
         return false;

Tras realizar una pasada directa del modelo entrenado, guardaremos el resultado obtenido y el estado oculto.

Con la ayuda de Target Net, obtendremos la integración del estado posterior del sistema de forma similar.

      //--- reward adjustment
      if(bUseTargetNet)
        {
         cTargetNet.getResults(targetVals);
         reward += discount * targetVals.Maximum();
        }
      target[action] = (float)reward;
      if(!targetVals.AssignArray(target))
         return false;
      //--- backpropagation pass of the model being trained
      CNet::backProp(targetVals);

De ser necesario, ajustaremos la recompensa externa del sistema según el valor de Net Target previsto y realizaremos una pasada inversa del modelo entrenado.

En la siguiente etapa, entrenaremos el conjunto de modelos con las integraciones de los dos estados posteriores obtenidos anteriormente.

      //--- forward net feed forward pass - next state prediction
      if(!cForwardNet.feedForward(state1, 1, false))
         return false;
      //--- unload "future" state embedding
      if(!cTargetNet.GetLayerOutput(iStateEmbedingLayer, state2))
         return false;

Primero realizaremos una pasada directa del conjunto de modelos con la primera integración del estado.

A continuación, descargaremos los resultados de la pasada directa y prepararemos los valores objetivo basados en ellos, sustituyendo el vector de acción perfecta por la integración del estado posterior obtenida con Target Net.

Para ello, transmitiremos los resultados de la pasada directa del conjunto de modelos a una matriz con el número de columnas igual a la integración del estado. Recordemos que la matriz contiene los resultados del conjunto de modelos al completo. Por lo tanto, organizaremos un ciclo y sustituiremos el estado predicho por el estado objetivo para la acción perfecta en todos los modelos de conjunto.

      //--- prepare forward net targets
      cForwardNet.getResults(result);
      forward.Init(1, result.Size());
      forward.Row(result, 0);
      forward.Reshape(result.Size() / state2.Total(), state2.Total());
      int ensemble = (int)(forward.Rows() / target.Size());
      //--- copy the target state to the matrix of ensemble targets
      state2.GetData(st2);
      for(int r = 0; r < ensemble; r++)
         forward.Row(st2, r * target.Size() + action);

A primera vista, sustituir el estado objetivo en todos los modelos va en contra de la idea de entrenar los modelos del conjunto con datos diferentes, pero no debemos olvidar que hemos organizado la selección aleatoria del modelo en el método de pasada inversa de la clase CNeuronMultiModel, y en esta fase, no sabemos qué modelo se entrenará. Por ello, prepararemos valores objetivo para todos los modelos, mientras que la selección del modelo para el entrenamiento se realizará más adelante.

      //--- forward net backpropagation
      targetVals.AssignArray(forward);
      cForwardNet.backProp(targetVals);
     }
//---
   delete state1;
   delete state2;
   delete targetVals;
//---
   return true;
  }

Al final de las iteraciones en el cuerpo del ciclo de entrenamiento, ejecutaremos nuevamente la pasada inversa de los modelos Forward dinámicos con los datos preparados. Tenga en cuenta que al preparar los valores objetivo, solo cambiaremos los valores objetivo de una acción individual, dejando el resto al nivel de los valores previstos. Esto nos permitirá obtener el gradiente de error de solo una acción específica al realizar una pasada inversa. Para el resto de direcciones, esperamos obtener un error cero.

Una vez completadas con éxito las iteraciones del ciclo, eliminaremos los objetos innecesarios y finalizaremos el método.

Los demás métodos de la clase se construirán de forma similar a los métodos correspondientes del módulo de curiosidad intrínseca. Podrá ver su código entero en el archivo adjunto.


3. Simulación

Tras crear las clases necesarias y sus métodos, procederemos a probar el trabajo realizado. Para poner a prueba la funcionalidad de las clases creadas, crearemos el asesor experto "EVDRL-learning.mq5". Como antes, crearemos un asesor experto basado en el asesor del artículo anterior. Esta vez, no haremos ningún cambio en la arquitectura del modelo entrenado, en su lugar, cambiaremos la clase del modelo utilizado. Luego sustituiremos el módulo de curiosidad intrínseca por una unidad de exploración por desacuerdo.

//+------------------------------------------------------------------+
//| Includes                                                         |
//+------------------------------------------------------------------+
#include "EVD.mqh"
...........
...........
...........
...........
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
CEVD                 StudyNet;

También introduciremos cambios en el método de descripción de la arquitectura de los modelos. Aquí eliminaremos la descripción de la arquitectura del modelo inverso y realizaremos modificaciones en la arquitectura del modelo directo. Merece la pena detenerse un poco en este último punto. Antes, utilizábamos un perceptrón con una sola capa oculta para el modelo forward. Crearemos una arquitectura similar para los modelos del conjunto.

Si resolvemos el problema de forma directa, deberemos crear una capa de datos de origen con un tamaño de búfer lo suficientemente grande para todos los modelos y 2 capas consecutivas de nuestra nueva clase de conjunto de modelos CNeuronMultiModel, pero tenga en cuenta que todos los modelos de conjunto utilizan el mismo estado del sistema, y eso significa que para mantener tal conjunto, necesitaremos repetir el conjunto de datos tantas veces en la capa de datos inicial como modelos haya en nuestro conjunto. En mi opinión, esto supone un uso ineficiente de la memoria de nuestro contexto OpenCL, pues implica costes de tiempo adicionales para concatenar un gran búfer de datos de origen y, al mismo tiempo, aumenta el coste de tiempo al transferir una gran cantidad de datos desde la RAM del dispositivo a la memoria del contexto OpenCL.

Resultaría mucho más eficiente organizar todos los modelos para acceder a un pequeño búfer de datos que contenga una sola copia del estado del sistema, pero no hemos previsto esta opción al crear el método de pasada directa de nuestra clase CNeuronMultiModel.

Echemos un vistazo a la arquitectura de nuestra capa neuronal básica totalmente conectada. En ella, cada neurona tiene su propio vector de coeficientes de peso independiente de las demás neuronas de esa capa. En la práctica, se trata de un conjunto de modelos independientes con un tamaño de una sola neurona, lo cual significa que podemos utilizar una capa neuronal básica totalmente conectada como capa oculta para todos los modelos de nuestro conjunto. Solo tenemos que organizar una capa neuronal de tamaño suficiente para proporcionar datos a todos los modelos de nuestro conjunto.

Así, para nuestro conjunto de modelos Forward, crearemos una capa de datos de origen de 100 elementos. Este es el tamaño de la representación comprimida del estado del sistema que obtenemos del modelo básico. Recordemos que en este caso no añadiremos un vector de acciones, ya que esperamos obtener estados predictivos del modelo para toda la gama de acciones posibles.

A continuación usaremos un conjunto de 5 modelos. Como capa oculta, crearemos una capa neuronal totalmente conectada de 1000 elementos (200 neuronas por modelo),

y le seguirá nuestra nueva capa de conjunto de modelos. Aquí especificaremos la siguiente descripción de la capa neuronal:

  • Tipo de capa neuronal (descr.type) defNeuronMultiModels;
  • El número de neuronas de un modelo (descr.count) será de 400 (100 elementos para la descripción del estado de cada una de las 4 acciones posibles;
  • El número de neuronas en la capa anterior para 1 modelo (descr.window ) será 200;
  • El número de modelos en el conjunto (descr.step) es de 5;
  • La función de activación (descr.activation) TANH (tangente hiperbólica) debe coincidir con la función de activación de la capa de integración en el modelo principal;
  • El método de optimización (descr.optimization) es ADAM (el único posible para este tipo de capa neuronal).
bool CreateDescriptions(CArrayObj *Description, CArrayObj *Forward)
  {
//---
...........
...........
//---
   if(!Forward)
     {
      Forward = new CArrayObj();
      if(!Forward)
         return false;
     }
//--- Model
...........
...........
...........
...........
//--- Forward
   Forward.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 100;
   descr.window = 0;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!Forward.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 1000;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!Forward.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMultiModels;
   descr.count = 400;
   descr.window = 200;
   descr.step = 5;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!Forward.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Entrenaremos y probaremos el modelo sin cambiar las condiciones: EURUSD, marco temporal H1, parámetros del indicador por defecto.

Según los resultados de la prueba de entrenamiento, debemos decir que entrenar un conjunto de modelos requiere más tiempo que entrenar un único modelo Forward. En este caso, podemos observar cómo el modelo realiza acciones de forma bastante caótica al principio. En el proceso de aprendizaje, esta aleatoriedad se reduce.

En general, el modelo ha sido capaz de obtener beneficios durante el proceso de prueba.

Gráfico de la pruebas

Resultados de las pruebas


Conclusión

En los modelos de aprendizaje por refuerzo, el aprendizaje del entorno sigue suponiendo un problema importante. Este artículo presenta otro enfoque de este problema: la exploración por desacuerdo. El agente se entrena en línea a partir de los datos que él mismo recoge al interactuar con el entorno usando un método de optimización de políticas. Al mismo tiempo, tras cada interacción entre el agente y el entorno, se actualiza el conjunto de modelos, lo cual permite al agente actualizar su modelo interno sobre el entorno a cada paso y obtener predicciones más precisas sobre los estados del entorno en el futuro.

El modelo ha sido creado y probado con datos reales en el simulador de estrategias de MetaTrader 5. Como resultado de las pruebas, el modelo ha resultado rentable. Esto sugiere un posible potencial para el desarrollo en esta dirección. Al mismo tiempo, el entrenamiento y las pruebas del modelo se han realizado en un horizonte temporal bastante corto. Para utilizar el modelo en el comercio real, necesitaremos un entrenamiento adicional del modelo con datos históricos ampliados.


Enlaces

  1. Self-Supervised Exploration via Disagreement
  2. Redes neuronales: así de sencillo (Parte 35): Módulo de curiosidad intrínseca (Intrinsic Curiosity Module)
  3. Redes neuronales: así de sencillo (Parte 36): Modelos relacionales de aprendizaje por refuerzo (Relational Reinforcement Learning)
  4. Redes neuronales: así de sencillo (Parte 37): Atención dispersa (Sparse Attention)

Programas usados en el artículo

# Nombre Tipo Descripción
1 EVDRL-learning.mq5 Asesor Asesor para el entrenamiento de modelos
2 EVD.mqh Biblioteca de clases Biblioteca de clases para organizar el trabajo de exploración por desacuerdo
2 ICM.mqh Biblioteca de clases Biblioteca de clases para organizar el trabajo del módulo de curiosidad intrínseca
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/12508

Archivos adjuntos |
MQL5.zip (203.62 KB)
Redes neuronales: así de sencillo (Parte 39): Go-Explore: un enfoque diferente sobre la exploración Redes neuronales: así de sencillo (Parte 39): Go-Explore: un enfoque diferente sobre la exploración
Continuamos con el tema de la exploración del entorno en los modelos de aprendizaje por refuerzo. En este artículo, analizaremos otro algoritmo: Go-Explore, que permite explorar eficazmente el entorno en la etapa de entrenamiento del modelo.
Teoría de categorías en MQL5 (Parte 8): Monoides Teoría de categorías en MQL5 (Parte 8): Monoides
El presente artículo continúa la serie sobre la implementación de la teoría de categorías en MQL5. Aquí presentamos los monoides como un dominio (conjunto) que distingue la teoría de categorías de otros métodos de clasificación de datos al incluir reglas y un elemento de identidad.
Implementando el algoritmo de aprendizaje ARIMA en MQL5 Implementando el algoritmo de aprendizaje ARIMA en MQL5
En este artículo, implementaremos un algoritmo que aplica un modelo autorregresivo de media móvil integrada (modelo Box-Jenkins) utilizando el método de minimización de la función de Powell. Box y Jenkins argumentaron que la mayoría de las series temporales se pueden modelar con una o ambas estructuras.
Matrices y vectores en MQL5: funciones de activación Matrices y vectores en MQL5: funciones de activación
En este artículo, describiremos solo uno de los aspectos del aprendizaje automático: las funciones de activación. En las redes neuronales artificiales, las funciones de activación de neuronas calculan el valor de la señal de salida en función de los valores de una señal de entrada o un conjunto de señales de entrada. Hoy le mostraremos lo que hay "debajo del capó".