English Русский 中文 Deutsch 日本語 Português
preview
Redes neuronales: así de sencillo (Parte 54): Usamos un codificador aleatorio para una exploración eficiente (RE3)

Redes neuronales: así de sencillo (Parte 54): Usamos un codificador aleatorio para una exploración eficiente (RE3)

MetaTrader 5Sistemas comerciales | 30 enero 2024, 11:22
296 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introducción

La cuestión de la exploración eficiente del entorno es uno de los principales problemas de los métodos de aprendizaje por refuerzo. Ya hemos discutido este tema en más de una ocasión, y cada vez, su solución ha conllevado una complicación adicional del algoritmo. En la mayoría de los casos, hemos recurrido al uso de mecanismos de recompensa internos adicionales para alentar al modelo a explorar nuevas acciones y buscar caminos inexplorados.

Sin embargo, para valorar la novedad de las acciones y los estados visitados, hemos tenido que entrenar modelos adicionales. Es importante señalar que el concepto de “novedad de acciones” no siempre coincide con la integridad y uniformidad de la investigación del entorno, y en este aspecto, los métodos basados ​​​​en la estimación de la entropía de las acciones y estados parecen más atractivos, si bien imponen sus propias limitaciones a los modelos entrenados. El uso de la entropía requiere una cierta comprensión en cuanto a las probabilidades de realizar acciones y transiciones a nuevos estados, lo cual, en el caso de un espacio continuo de acciones y estados, puede resultar bastante difícil de calcular directamente. En busca de métodos más simples y efectivos, le sugerimos al lector que se familiarice con el algoritmo Random Encoders for Efficient Exploration (RE3), presentado en el artículo "State Entropy Maximization with Random Encoders for Efficient Exploration".


1. La idea principal de RE3

Al analizar casos reales con un espacio continuo de acciones y estados, nos enfrentamos a una situación en la que cada par estado-acción sucede solo una vez en el conjunto de entrenamiento. Las posibilidades de observar un estado idéntico en el futuro son próximas a "0", y llegamos a la búsqueda de métodos para agrupar estados y acciones cercanos (similares), lo cual lleva al entrenamiento de modelos adicionales. Por ejemplo, en el método BAC, entrenamos un autocodificador para valorar la novedad de los estados y acciones.

No obstante, el entrenamiento de modelos adicionales introduce cierta complejidad en el algoritmo. Después de todo, se requieren tiempo y recursos adicionales tanto para seleccionar hiperparámetros adicionales como para entrenar el modelo, y la calidad del entrenamiento de un modelo adicional puede tener un impacto significativo en los resultados del entrenamiento del actor principal de la política.

El objetivo principal del método Random Encoders for Efficient Exploration (RE3) consiste en minimizar el número de modelos entrenados. En su trabajo, los autores del método RE3 llaman la atención sobre el hecho de que en el campo del procesamiento de imágenes, solo las redes convolucionales son capaces de identificar rasgos y características individuales de un objeto. Son precisamente las redes convolucionales las que ayudarán a reducir la dimensionalidad del espacio multidimensional, resaltar los rasgos característicos y hacer frente al escalamiento del objeto original.

Y aquí nos surge una pregunta es bastante razonable: ¿de qué tipo de minimización de los modelos entrenados hablaremos si además recurrimos a redes convolucionales?

En este aspecto, la palabra clave es “entrenados”. Los autores del método llamaron la atención sobre el hecho de que incluso un codificador convolucional inicializado con parámetros aleatorios captura efectivamente la información sobre la proximidad de dos estados. A continuación le mostramos una visualización de los estados k más próximos encontrados midiendo las distancias en el espacio de representación de un codificador inicializado aleatoriamente (Random Encoder) y en el espacio del estado verdadero (True State), del artículo de autor.

Visualización de estados k más cercanos

Usando esta observación como base, los autores del método RE3 proponen maximizar la estimación de la entropía del estado en un espacio de representación fijo de un codificador inicializado aleatoriamente durante el entrenamiento del modelo.

El método RE3 fomenta la exploración en espacios de observación de alta dimensionalidad maximizando la entropía del estado. La idea principal del RE3 consiste en estimar la entropía utilizando una estimación de los k-vecinos más próximos en un espacio de baja dimensionalidad obtenido por un codificador inicializado aleatoriamente.

Los autores del método proponen calcular la distancia entre estados en el espacio de representación de un codificador aleatorio f(θ), cuyos parámetros θ se inicializarán aleatoriamente y se fijarán durante todo el proceso de aprendizaje.

La motivación del Agente surgirá de la observación de que las distancias en el espacio de representación de un codificador aleatorio ya resultarán útiles para encontrar estados similares sin necesidad de entrenar la representación.

En este caso, la recompensa interna será proporcional a la evaluación de la entropía del estado y estará determinada por la fórmula:

donde yi será la representación del estado en el espacio del codificador aleatorio.

En la fórmula de recompensa intrínseca presentada, utilizaremos la norma de distancia L2, que siempre es no negativa. El aumento de la norma en "1" nos permitirá obtener siempre un valor de logaritmo no negativo. Por tanto, siempre obtendremos recompensas internas no negativas. Además, podemos notar fácilmente que con un número suficiente de estados cercanos, la recompensa interna se aproximará a "0".

Como muestra la práctica, la medición de la distancia entre estados en un espacio de representación fijo ofrece recompensas internas más estables. Después de todo, la distancia entre pares de estados no cambiará durante el proceso de aprendizaje.

Para calcular distancias en el espacio latente, resultará más eficiente desde el punto de vista computacional almacenar representaciones de estado de baja dimensionalidad en un búfer de reproducción de experiencias mientras se interactúa con el entorno. Esto eliminará la necesidad de procesar estados de alta dimensionalidad a través del codificador para obtener representaciones en cada iteración de actualización del modelo. Además, nos permitirá calcular la distancia a todos los registros de estado en lugar de a una sola muestra de un minilote. Este esquema ofrecerá una estimación de entropía estable y precisa con eficiencia computacional.

En general, el método RE3 se puede usar para entrenar a un agente en tiempo real, cuando el agente aprende una política basada en maximizar las recompensas externas del entorno. La recompensa interna estimula al Agente a explorar el entorno.

dondeβ será el coeficiente de temperatura que determina el equilibrio entre exploración y explotación (β≥0).

Los autores del método proponen usar la disminución exponencial de β durante todo el proceso de aprendizaje para estimular al agente a centrarse más en las recompensas externas del entorno a medida que avanza el entrenamiento.

donde p es la tasa de disminución.

Si bien la recompensa intrínseca convergerá a "0" a medida que se recopilen más estados similares durante el entrenamiento, los autores del método descubrieron que la disminución de β estabiliza empíricamente el rendimiento.

Además, el método RE3 se puede usar para el entrenamiento previo de un Agente para que este explore un espacio de entorno de alta dimensionalidad en ausencia de recompensas externas. Posteriormente, la política del Agente se podrá entrenar aún más para resolver problemas específicos.

A continuación le mostraremos la visualización personalizada del método RE3.

Visualización personalizada del método

El artículo "State Entropy Maximization with Random Encoders for Efficient Exploration", se presentan los resultados de varias pruebas que demuestran la eficacia del método. Nosotros, por otra parte, implementaremos nuestra propia versión del algoritmo propuesto y evaluaremos su efectividad para resolver nuestros problemas.


2. Implementación usando MQL5

Al comenzar a implementar este método, merece la pena señalar de inmediato que no vamos a repetir completamente el algoritmo del autor. Como siempre, usaremos las ideas principales del método y las combinaremos con enfoques previamente estudiados. En este trabajo, crearemos un cierto conglomerado de algoritmos actuales y previamente analizados.

Asimismo, construiremos nuestra propia implementación basándonos en algoritmos de la familia Actor-Crítico. Y para construir el codificador convolucional, añadiremos su descripción al método para describir las arquitecturas de los modelos.

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

Entrenaremos una política de Agente estocástica en un espacio continuo de acciones. Como en artículos anteriores, entrenaremos al Actor usando algoritmos de la familia Actor-Crítico, pero como usaremos los enfoques del método RE3 para estimar el componente de entropía de la recompensa, podemos simplificar el modelo de Actor. En este caso, repetiremos la arquitectura del actor del artículo "Actor-crítico conductual".

//--- Actor
   actor.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = 1000;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count - 1;
   descr.window = 2;
   descr.step = 1;
   descr.window_out = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count;
   descr.window = 8;
   descr.step = 8;
   descr.window_out = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = 128;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = AccountDescr;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NActions;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 10
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Al igual que antes, nuestro Crítico se verá privado del bloque de procesamiento preliminar de datos de origen. Utilizaremos el estado latente de nuestro Actor como datos de origen del crítico. También usaremos la descomposición de la recompensa, y reduciremos ligeramente el número de puntos de recompensa. En lugar de 6 elementos separados de componentes de entropía para cada acción, tendremos solo un elemento de recompensa interna.

//+------------------------------------------------------------------+
//| Rewards structure                                                |
//|   0     -  Delta Balance                                         |
//|   1     -  Delta Equity ( "-" Drawdown / "+" Profit)             |
//|   2     -  Penalty for no open positions                         |
//|   3     -  Mean distance                                         |
//+------------------------------------------------------------------+

Como resultado, obtendremos la siguiente arquitectura del Crítico.

//--- Critic
   critic.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = LatentCount;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = NActions;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = NRewards;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }

A continuación deberemos describir la arquitectura del codificador convolucional. Y aquí residirá la primera diferencia respecto al método de autor. El método RE3 ofrece una recompensa intrínseca basada en una estimación de la distancia entre las representaciones del estado latente. Usaremos una representación latente de pares estado-acción, que se refleja en el tamaño de la capa de datos de origen del codificador.

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

Nuestro modelo de codificador no está entrenado, así que no tendrá sentido utilizar una capa de normalización de datos por lotes. Sin embargo, usaremos una capa completamente conectada y en su salida obtendremos datos comparables que pueden ser procesados ​​por capas convolucionales.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 512;
   descr.window = prev_count;
   descr.step = NActions;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!convolution.Add(descr))
     {по
      delete descr;
      return false;
     }

Luego reduciremos la dimensionalidad de los datos utilizando 3 capas convolucionales sucesivas. Su tarea consiste en determinar rasgos característicos para identificar estados y acciones similares.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = 512 / 8;
   descr.window = 8;
   descr.step = 8;
   int prev_wout = descr.window_out = 2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = (prev_count * prev_wout) / 4;
   descr.window = 4;
   descr.step = 4;
   prev_wout = descr.window_out = 2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = (prev_count * prev_wout) / 4;
   descr.window = 4;
   descr.step = 4;
   prev_wout = descr.window_out = 2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }

Para completar el codificador, usaremos una capa completamente conectada y reduciremos la representación oculta de los datos a una dimensionalidad determinada.

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = EmbeddingSize;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Tenga en cuenta que en todas las capas neuronales (salvo la primera) utilizaremos LReLU para activar las neuronas. La ausencia de límites en el rango de resultados de la función de activación permitirá dividir los objetos en grupos con la mayor precisión posible.

Tras crear las descripciones de la arquitectura de nuestros modelos, hablaremos un poco sobre el búfer de reproducción de experiencias. Los autores del método proponen guardar simultáneamente la representación latente del estado en el búfer de reproducción de experiencias junto con el conjunto de datos estándar. Sí, me parece adecuado. Resulta bastante lógico calcular la representación latente del estado una vez y luego usarla en el proceso de entrenamiento sin necesidad de recalcularla en cada iteración.

Pero en nuestra secuencia de acciones, con el primer inicio del asesor de recopilación de datos de entrenamiento "...\RE3\Research.mq5", no hay ningún modelo previamente entrenado que se haya guardado. El modelo del Actor lo crea el asesor y lo completa con parámetros aleatorios. También podemos generar un modelo de codificador aleatorio, pero el inicio paralelo de varios ejemplares del asesor en el modo de optimización del simulador de estrategias creará un codificador para cada paso del asesor, y el problema es que con cada pasada obtendremos un codificador aleatorio cuya representación latente no será comparable con representaciones similares en otras pasadas, lo que destruirá por completo las ideas y principios del método RE3.

En mi opinión, existen 2 formas de salir de esta situación:

  • la creación y el guardado preliminar de modelos antes del primer inicio del asesor "...\RE3\Research.mq5"
  • la generación de un codificador y la codificación de las representaciones en el cuerpo del asesor de entrenamiento de modelos "...\RE3\Study.mq5".

En nuestra implementación, hemos elegido la segunda opción. Por lo tanto, no realizaremos cambios en las estructuras de almacenamiento de datos ni en el asesor de recopilación de muestras de entrenamiento "...\RE3\Research.mq5". Encontrará su código completo en el archivo adjunto.

A continuación comenzaremos a trabajar en el asesor de entrenamiento del modelo "...\RE3\Study.mq5". Aquí crearemos los objetos para los 6 modelos y entrenaremos solo 3 de ellos. Para los modelos objetivo, aplicaremos una actualización suave de los parámetros utilizando el coeficiente ꚍ.

CNet                 Actor;
CNet                 Critic1;
CNet                 Critic2;
CNet                 TargetCritic1;
CNet                 TargetCritic2;
CNet                 Convolution;

En el método de inicialización del asesor, cargaremos el conjunto de entrenamiento y los modelos previamente entrenados. Si los modelos no se pueden cargar, generaremos nuevos modelos rellenados con parámetros aleatorios.

int OnInit()
  {
//---
   ResetLastError();
   if(!LoadTotalBase())
     {
      PrintFormat("Error of load study data: %d", GetLastError());
      return INIT_FAILED;
     }
//--- load models
   float temp;
   if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true) ||
      !Convolution.Load(FileName + "CNN.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      CArrayObj *convolution = new CArrayObj();
      if(!CreateDescriptions(actor, critic, convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || !Critic1.Create(critic) || !Critic2.Create(critic) ||
         !Convolution.Create(convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!TargetCritic1.Create(critic) || !TargetCritic2.Create(critic))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      delete convolution;
      //---
      TargetCritic1.WeightsUpdate(GetPointer(Critic1), 1.0f);
      TargetCritic2.WeightsUpdate(GetPointer(Critic2), 1.0f);
      StartTargetIter = StartTargetIteration;
     }
   else
      StartTargetIter = 0;

Como sucede en el artículo anterior, al generar nuevos modelos, pospondremos el uso de los modelos objetivo. Esta técnica nos permitirá entrenar previamente los modelos objetivo antes de usarlos para estimar los estados y acciones posteriores del Agente.

Aquí transferiremos todos los modelos a un único contexto OpenCL.

//---
   OpenCL = Actor.GetOpenCL();
   Critic1.SetOpenCL(OpenCL);
   Critic2.SetOpenCL(OpenCL);
   TargetCritic1.SetOpenCL(OpenCL);
   TargetCritic2.SetOpenCL(OpenCL);
   Convolution.SetOpenCL(OpenCL);

Y antes de iniciar el proceso de entrenamiento, comprobaremos la conformidad de las arquitecturas de los modelos utilizados.

   Actor.getResults(Result);
   if(Result.Total() != NActions)
     {
      PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", NActions, Result.Total());
      return INIT_FAILED;
     }
//---
   Actor.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Actor doesn't match state description (%d <> %d)", Result.Total(), 
                                                                                        (HistoryBars * BarDescr));
      return INIT_FAILED;
     }
//---
   Actor.GetLayerOutput(LatentLayer, Result);
   int latent_state = Result.Total();
   Critic1.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Critic doesn't match latent state Actor (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }

Al final del método, crearemos un búfer auxiliar y generaremos un evento de entrenamiento del modelo.

   Gradient.BufferInit(AccountDescr, 0);
//---
   if(!EventChartCustom(ChartID(), 1, 0, 0, "Init"))
     {
      PrintFormat("Error of create study event: %d", GetLastError());
      return INIT_FAILED;
     }
//---
   return(INIT_SUCCEEDED);
  }

En el método de desinicialización del asesor, actualizaremos los parámetros de los modelos objetivo y guardaremos los resultados del entrenamiento.

void OnDeinit(const int reason)
  {
//---
   TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
   TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
   Actor.Save(FileName + "Act.nnw", 0, 0, 0, TimeCurrent(), true);
   TargetCritic1.Save(FileName + "Crt1.nnw", Critic1.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   TargetCritic2.Save(FileName + "Crt2.nnw", Critic2.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   Convolution.Save(FileName + "CNN.nnw", 0, 0, 0, TimeCurrent(), true);
   delete Result;
  }

El proceso real de entrenamiento de modelos se organiza en el procedimiento Train. Pero hoy su algoritmo será ligeramente diferente a los procedimientos similares de los asesores anteriormente analizados.

Primero, contaremos el número total de estados en el conjunto de entrenamiento. Como ya sabe, el número de estados en cada pasada individual se almacena en la variable Total. Luego organizaremos un ciclo y recopilaremos la suma total de los valores de la variable especificada de cada pasada.

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   uint ticks = GetTickCount();
//---
   int total_states = Buffer[0].Total;
   for(int i = 1; i < total_tr; i++)
      total_states += Buffer[i].Total;

Necesitaremos el valor resultante para declarar las matrices de una representación comprimida de pares estado-acción y las recompensas reales recolectadas del entorno.

   vector<float> temp;
   Convolution.getResults(temp);
   matrix<float> state_embedding = matrix<float>::Zeros(total_states,temp.Size());
   matrix<float> rewards = matrix<float>::Zeros(total_states,NRewards);

A continuación, organizaremos un sistema de ciclos en el que crearemos representaciones latentes para todos los pares estado-acción del conjunto de entrenamiento. Aquí primero recopilaremos los datos de origen en un único búfer de datos,

   for(int tr = 0; tr < total_tr; tr++)
     {
      for(int st = 0; st < Buffer[tr].Total; st++)
        {
         State.AssignArray(Buffer[tr].States[st].state);
         float PrevBalance = Buffer[tr].States[MathMax(st,0)].account[0];
         float PrevEquity = Buffer[tr].States[MathMax(st,0)].account[1];
         State.Add((Buffer[tr].States[st].account[0] - PrevBalance) / PrevBalance);
         State.Add(Buffer[tr].States[st].account[1] / PrevBalance);
         State.Add((Buffer[tr].States[st].account[1] - PrevEquity) / PrevEquity);
         State.Add(Buffer[tr].States[st].account[2]);
         State.Add(Buffer[tr].States[st].account[3]);
         State.Add(Buffer[tr].States[st].account[4] / PrevBalance);
         State.Add(Buffer[tr].States[st].account[5] / PrevBalance);
         State.Add(Buffer[tr].States[st].account[6] / PrevBalance);
         double x = (double)Buffer[tr].States[st].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_MN1);
         State.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_W1);
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_D1);
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         State.AddArray(Buffer[tr].States[st].action);

y luego llamaremos a la pasada directa del codificador convolucional.

         if(!Convolution.feedForward(GetPointer(State),1,false,NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            ExpertRemove();
            return;
           }

El resultado resultante se guarda en la fila correspondiente de la matriz de incorporaciones de estados y acciones. Y luego guardaremos la recompensa externa correspondiente en la matriz de recompensas con el mismo número de fila. Luego aumentaremos el contador de filas registradas.

         Convolution.getResults(temp);
         state_embedding.Row(temp,state);
         temp.Assign(Buffer[tr].States[st].rewards);
         rewards.Row(temp,state);
         state++;

El tiempo dedicado a este proceso dependerá del tamaño de la muestra de entrenamiento y puede ser significativo. Por tanto, en el cuerpo del ciclo añadiremos un mensaje informativo para el control visual del proceso por parte del usuario.

         if(GetTickCount() - ticks > 500)
           {
            string str = StringFormat("%-15s %6.2f%%", "Embedding ", state * 100.0 / (double)(total_states));
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

Una vez se completen las iteraciones del sistema de ciclos, ajustaremos los tamaños de las matrices hasta el número de filas almacenadas.

   if(state != total_states)
     {
      rewards.Resize(state,NRewards);
      state_embedding.Reshape(state,state_embedding.Cols());
      total_states = state;
     }

Con esto completaremos la etapa preparatoria y pasaremos directamente al entrenamiento de los modelos. Aquí, como antes, organizamos un ciclo de entrenamiento con el número de iteraciones indicadas por el usuario en los parámetros externos del asesor.

   vector<float> rewards1, rewards2;
   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int tr = (int)((MathRand() / 32767.0) * (total_tr - 1));
      int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2));
      if(i < 0)
        {
         iter--;
         continue;
        }

En el cuerpo del ciclo, seleccionaremos aleatoriamente una pasada y un estado para la iteración actual del entrenamiento del modelo, y luego comprobaremos si es necesario utilizar modelos objetivo.

Si nuestro proceso de entrenamiento ha alcanzado el umbral de uso de los modelos objetivo, entonces generaremos datos de entrada sobre los estados posteriores para una pasada directa de esos modelos.

      vector<float> reward, target_reward = vector<float>::Zeros(NRewards);
      reward.Assign(Buffer[tr].States[i].rewards);
      //--- Target
      if(iter >= StartTargetIter)
        {
         State.AssignArray(Buffer[tr].States[i + 1].state);
         float PrevBalance = Buffer[tr].States[i].account[0];
         float PrevEquity = Buffer[tr].States[i].account[1];
         Account.Clear();
         Account.Add((Buffer[tr].States[i + 1].account[0] - PrevBalance) / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[1] / PrevBalance);
         Account.Add((Buffer[tr].States[i + 1].account[1] - PrevEquity) / PrevEquity);
         Account.Add(Buffer[tr].States[i + 1].account[2]);
         Account.Add(Buffer[tr].States[i + 1].account[3]);
         Account.Add(Buffer[tr].States[i + 1].account[4] / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[5] / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[6] / PrevBalance);
         double x = (double)Buffer[tr].States[i + 1].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_MN1);
         Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_W1);
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_D1);
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         //---
         if(Account.GetIndex() >= 0)
            Account.BufferWrite();

Recordamos que para la pasada directa de los modelos objetivo de los Críticos, necesitaremos una descripción del estado inicial del entorno y las acciones del Agente. Y aquí hay 2 puntos cuya implementación requerirá una pasada directa del Actor:

  • Los críticos no tienen una unidad de preprocesamiento para los datos de origen (ni utilizan una representación latente del Actor);
  • El modelo objetivo del Crítico evalúa el estado posterior en relación con el uso de la actual política del Actor (será necesaria la generación de un nuevo vector de acciones).

Por lo tanto, primero realizaremos una pasada directa del Actor,

         if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

y luego llamaremos a los métodos de pasada directa de los 2 modelos de los Críticos objetivo.

         if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
            !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

A continuación, según el algoritmo SAC, deberemos seleccionar un modelo objetivo con una estimación mínima del estado posterior. En nuestra implementación, hemos utilizado la suma simple de elementos de recompensa. Pero si su modelo ofrece diferentes coeficientes de peso para elementos individuales de la función de recompensa, entonces podrá usar el producto vectorial de los resultados de los modelos por el vector de coeficientes de peso.

         TargetCritic1.getResults(rewards1);
         TargetCritic2.getResults(rewards2);
         if(rewards1.Sum() <= rewards2.Sum())
            target_reward = rewards1;
         else
            target_reward = rewards2;

Luego restaremos la recompensa real obtenida por interactuar con el entorno de los resultados previstos del modelo seleccionado y ajustaremos el factor de descuento.

         for(ulong r = 0; r < target_reward.Size(); r++)
            target_reward -= Buffer[tr].States[i + 1].rewards[r];
         target_reward *= DiscFactor;
        }

Por lo tanto, en el vector target_reward hemos obtenido la desviación para cada punto de recompensa entre la evaluación predicha del Crítico y la recompensa real del entorno. ¿Y qué nos aporta esto?

Permítanme recordarles que el búfer de repetición de experiencias para cada par "estado-acción" almacena la cantidad acumulada de recompensa hasta el final de la pasada considerando el factor de descuento. Como comprenderá, esta recompensa total se acumula según las políticas utilizadas por el Agente durante la interacción con el entorno.

Más arriba, hemos predicho el coste del par "estado-acción" considerando la política actual del Agente y hemos restado la estimación del mismo estado, pero considerando la acción del búfer de repetición de experiencias. Por lo tanto, en el vector target_reward hemos obtenido el efecto del cambio de política del Actor en el valor del estado.

Tenga en cuenta que estamos hablando de un cambio en el valor del estado. Después de todo, prácticamente no dependerá del Agente. No obstante, sus acciones en un mismo estado podrán diferir según la política utilizada.

Tras evaluar el impacto del cambio de política de acciones del Actor en el resultado global, pasaremos al bloque de entrenamiento de Críticos. Es la calidad de su entrenamiento lo que afectará la exactitud de la transmisión del gradiente de error a las acciones del Actor.

Aquí también prepararemos los datos de la descripción del entorno, que incluyen los datos históricos de los movimientos de precio y los indicadores. Asimismo, prepararemos los datos sobre el estado de la cuenta utilizando un búfer separado.

      //--- Q-function study
      State.AssignArray(Buffer[tr].States[i].state);
      float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
      float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
      Account.Clear();
      Account.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance);
      Account.Add(Buffer[tr].States[i].account[1] / PrevBalance);
      Account.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity);
      Account.Add(Buffer[tr].States[i].account[2]);
      Account.Add(Buffer[tr].States[i].account[3]);
      Account.Add(Buffer[tr].States[i].account[4] / PrevBalance);
      Account.Add(Buffer[tr].States[i].account[5] / PrevBalance);
      Account.Add(Buffer[tr].States[i].account[6] / PrevBalance);
      double x = (double)Buffer[tr].States[i].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_MN1);
      Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_W1);
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_D1);
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      if(Account.GetIndex() >= 0)
         Account.BufferWrite();

Una vez completado el trabajo de preparación de los datos, realizaremos una pasada directa del Actor,

      if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

solo que esta vez tomaremos del Actor solo una representación latente del estado del entorno. Usaremos la acción del Agente del búfer de reproducción de experiencias. Después de todo, es precisamente por esta acción que recibiremos una verdadera recompensa del entorno.

Con estos datos realizaremos una pasada directa de ambos Críticos.

      Actions.AssignArray(Buffer[tr].States[i].action);
      if(Actions.GetIndex() >= 0)
         Actions.BufferWrite();
      //---
      if(!Critic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)) ||
         !Critic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

A continuación, generaremos los valores objetivo y realizaremos una pasada inversa de los Críticos. Ya hemos realizado operaciones similares varias veces. Normalmente, en esta etapa ajustaríamos la recompensa real del búfer de repetición de experiencias conforme a la influencia de la política modificada y transmitiríamos el valor resultante como valor objetivo para ambos modelos de los Críticos. Pero en esta implementación usaremos la recompensa descompuesta. En el último artículo, utilizamos el algoritmo Conflict-Averse Gradient Descent (CAGrad) para ajustar el gradiente de error. Luego corregimos la desviación de los valores en el métodoCNet_SAC_D_DICE::CAGrad y guardaremos los valores resultantes directamente en el búfer de gradiente de error de la capa neuronal de resultados. Ahora no tenemos la capacidad de acceder directamente al búfer de gradiente de la última capa neuronal de los modelos y necesitamos valores objetivo.

Para obtener los valores objetivo corregidos usando el método Conflict-Averse Gradient Descent, haremos una pequeña manipulación de los datos. Primero, generaremos valores objetivo a partir de los datos disponibles. Luego les restaremos los valores pronosticados por el Crítico, obteniendo así una desviación (error). Después corregiremos la desviación resultante utilizando el ya conocido método CAGrad. Y al resultado le sumaremos el valor predicho del Crítico, que restamos anteriormente.

Esto nos dará un valor objetivo ajustado usando el método Conflict-Averse Gradient Descent. No obstante, dicho valor objetivo solo será relevante para un modelo del Crítico. Para el segundo modelo del Crítico tendremos que repetir las operaciones, pero teniendo en cuenta sus valores previstos.

Tras efectuar la pasada inversa de los Críticos, realizaremos una pasada inversa parcial del Actor para distribuir el gradiente de error en todo el bloque de preprocesamiento de datos.

      Critic1.getResults(rewards1);
      Result.AssignArray(CAGrad(reward + target_reward - rewards1) + rewards1);
      if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
      Critic2.getResults(rewards2);
      Result.AssignArray(CAGrad(reward + target_reward - rewards2) + rewards2);
      if(!Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

Después de actualizar los parámetros de los Críticos vendrá el bloque de actualización de la política del Actor. Según el algoritmo Soft Actor-Critic, para actualizar los parámetros del Actor se utiliza un Crítico con una estimación de estado mínima. Usaremos un Crítico con el error promedio mínimo, lo que potencialmente proporcionará una transmisión más correcta del gradiente de error.

      //--- Policy study
      CNet *critic = NULL;
      if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError())
         critic = GetPointer(Critic1);
      else
         critic = GetPointer(Critic2);

Aquí introduciremos el método RE3 en nuestro proceso de entrenamiento. Así, recopilaremos en un único búfer de datos de descripciones del estado analizado del entorno, el estado de la cuenta y la acción seleccionada del agente, considerando la política actualizada. Permítanme recordarles que hemos realizado una pasada directa del Actor en la etapa de actualización de los parámetros de los Críticos.

Tras lo cual realizaremos una pasada directa del Crítico seleccionado. En esta ocasión evaluaremos las acciones del Actor en el estado analizado considerando la política actualizada. Y llamaremos la pasada directa de nuestro Codificador para obtener la incorporación del par de estado analizado y la acción del Actor con la política actualizada.

      Actor.getResults(rewards1);
      State.AddArray(GetPointer(Account));
      State.AddArray(rewards1);
      if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
         !Convolution.feedForward(GetPointer(State)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

A la pasada directa le seguirá la pasada inversa de los modelos. Y nuevamente tendremos que formar los valores objetivo de la crítica. Pero esta vez tendremos que combinar los algoritmos CAGrad y RE3. Además, no tendremos los valores objetivo correctos para el estado analizado y la acción del Actor con la política actualizada.

Hemos trasladado la determinación del valor objetivo mediante enfoques RE3 a una función KNNReward aparte, con cuyo algoritmo nos familiarizaremos un poco más adelante. Y el ajuste de la recompensa descompuesta se realizará según el algoritmo descrito en el bloque de actualización de parámetros de los Críticos.

      Convolution.getResults(rewards1);
      critic.getResults(reward);
      reward += CAGrad(KNNReward(7,rewards1,state_embedding,rewards) - reward);
      //---
      Result.AssignArray(reward + target_reward);

A continuación, solo deberemos desactivar el modo de entrenamiento del Crítico y llamar secuencialmente a los métodos de pasada inversa para el Crítico y el Actor, sin olvidarnos de comprobar los resultados de las operaciones.

      critic.TrainMode(false);
      if(!critic.backProp(Result, GetPointer(Actor)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         critic.TrainMode(true);
         break;
        }
      critic.TrainMode(true);

Después de actualizar la política del Actor, retornaremos el Crítico al modo de entrenamiento del modelo.

Al final del ciclo de entrenamiento del modelo, actualizaremos los parámetros de los modelos objetivo e informaremos al usuario sobre el progreso del proceso de entrenamiento.

      //--- Update Target Nets
      TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
      TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
      //---
      if(GetTickCount() - ticks > 500)
        {
         string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic1", 
                                     iter * 100.0 / (double)(Iterations), Critic1.getRecentAverageError());
         str += StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic2", 
                                     iter * 100.0 / (double)(Iterations), Critic2.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }

Tras completar todas las iteraciones del proceso de entrenamiento del modelo, limpiaremos el campo de comentarios, enviaremos los resultados del entrenamiento al registro e iniciaremos el proceso de finalización del asesor.

   Comment("");
//---
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic1", Critic1.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic2", Critic2.getRecentAverageError());
   ExpertRemove();
//---
  }

Más arriba hemos analizado el algoritmo del método de entrenamiento del modelo Train. Pero para comprender completamente el proceso, deberemos analizar el algoritmo de las funciones CAGrad y KNNReward. El primer algoritmo lo hemos transferido al completo desde el método del mismo nombre, que analizamos en el artículo anterior, así que podrá familiarizarse con él en el archivo adjunto. Le propongo centrarnos en el algoritmo de la segunda función. Además, este se distingue del algoritmo original descrito anteriormente.

En sus parámetros, la función KNNReward obtendrá el número de vecinos a analizar, el vector de incorporación del estado deseado, la matriz de incorporación del estado del conjunto de entrenamiento y la matriz de recompensa. Permítanme recordarles que las matrices de incorporación de estados del búfer de reproducción de experiencias y recompensas están sincronizadas en todas las filas. Este importante punto lo explotaremos más adelante.

El resultado de las operaciones de la función será retornado por el vector de valores de las recompensas correspondientes.

vector<float> KNNReward(ulong k, vector<float> &embedding, matrix<float> &state_embedding, matrix<float> &rewards)
  {
   if(embedding.Size() != state_embedding.Cols())
     {
      PrintFormat("%s -> %d Inconsistent embedding size", __FUNCTION__, __LINE__);
      return vector<float>::Zeros(0);
     }

En el cuerpo de la función, primero verificaremos el tamaño de la incorporación del estado analizado y las incorporaciones creadas del búfer de reproducción de experiencias.

A continuación, determinaremos la distancia entre los vectores de incorporación. Para ello, de cada columna de incorporación de los estados del búfer de reproducción de experiencias, restaremos el valor del elemento correspondiente de la descripción del estado analizado. Luego elevaremos al cuadrado los valores resultantes.

   ulong size = embedding.Size();
   ulong states = state_embedding.Rows();
   ulong rew_size = rewards.Cols();
   matrix<float> temp = matrix<float>::Zeros(states,size);
//---
   for(ulong i = 0; i < size; i++)
      temp.Col(MathPow(state_embedding.Col(i) - embedding[i],2.0f),i);

Asimismo, extraemos la raíz cuadrada de la suma fila por fila y colocaremos el vector resultante en la primera columna de la matriz.

   temp.Col(MathSqrt(temp.Sum(1)),0);

De esta forma, hemos obtenido las distancias entre el estado deseado y los ejemplos del búfer de reproducción de experiencias en la primera columna de nuestra matriz.

Después cambiaremos la dimensionalidad de nuestra matriz y añadiremos los elementos de recompensa correspondientes del búfer de reproducción de experiencias a las columnas adyacentes.

   temp.Resize(states,1 + rew_size);
   for(ulong i = 0; i < rew_size; i++)
      temp.Col(rewards.Col(i),i + 1);

Como resultado de estas operaciones, hemos obtenido una matriz de recompensa cuya primera columna contiene la distancia hasta el estado deseado en el espacio de incorporación comprimido.

Permítanme recordarles que el estado deseado en este caso será el estado analizado con la acción del Actor según la política actualizada.

Ahora, para determinar la recompensa interna por una acción de Actor determinada, necesitamos determinar los k-vecinos más próximos. Resulta bastante lógico que podamos encontrarlos fácilmente tras clasificar la matriz resultante en orden descendente de distancias. Sin embargo, para clasificar completamente los valores, necesitaremos varias pasadas sucesivas sobre todo el vector de distancias. Al mismo tiempo, no necesitaremos clasificar completamente la matriz. Nuestra tarea consistirá en encontrar solo los valores k-mínimos. En este caso, además, su secuencia en la pequeña matriz de resultados ni siquiera resultará importante para nosotros. Para ello solo necesitaremos 1 pasada por el vector distancias.

Copiaremos solo las primeras k filas en nuestra matriz de resultados. Luego determinaremos la distancia máxima y la posición del elemento de distancia máxima en esta pequeña matriz, y organizaremos un ciclo de búsqueda en las filas restantes de la matriz original. En el cuerpo del ciclo, verificaremos secuencialmente la distancia hasta el estado analizado y el máximo en nuestra matriz de resultados. Si encontramos un estado más próximo, lo almacenaremos en la fila de distancia máxima de nuestra matriz de resultados. Luego actualizaremos el valor de la distancia máxima y su posición en la matriz de distancias mínimas.

   matrix<float> min_dist = temp;
   min_dist.Resize(k,rew_size + 1);
   float max = min_dist.Col(0).Max();
   ulong max_row = min_dist.Col(0).ArgMax();
   for(ulong i = k; i < states; i++)
     {
      if(temp[i,0] >= max)
         continue;
      min_dist.Row(temp.Row(i),max_row);
      max = min_dist.Col(0).Max();
      max_row = min_dist.Col(0).ArgMax();
     }

Y repetiremos las iteraciones hasta que todas las filas de nuestra matriz de distancias y recompensas estén completamente enumeradas. Tras realizar una búsqueda exhaustiva en la matriz de distancia mínima min_dist, obtendremos las distancias k-mínimas (los k vecinos más cercanos) con las recompensas correspondientes del búfer de repetición de experiencias. Sí, es posible que no estén clasificadas, pero no necesitamos esto para calcular la recompensa interna. 

   vector<float> t = vector<float>::Ones(k);
   vector<float> ri = MathLog(min_dist.Col(0) + 1.0f);

En esta etapa, tendremos todos los datos para determinar la recompensa interna (entropía) de la acción que se está analizando. Pero todavía tenemos una pregunta abierta sobre el valor objetivo de la recompensa para el estado y la acción analizados. Y aquí vale la pena prestar atención una vez más a los vecinos k más próximos encontrados. No en vano les hemos otorgado las recompensas correspondientes. Todo nuestro proceso de entrenamiento de modelos se basará en estadísticas de estados-acciones y las recompensas recibidas. Por lo tanto, los vecinosk más próximos serán nuestra muestra representativa y la relevancia de sus recompensas según la acción deseada será directamente proporcional a la distancia de incorporación.

Por lo tanto, definiremos la recompensa objetivo como el promedio ponderado según la distancia de las recompensas de los vecinos k más próximos.

   t = (t - ri) / k;
//---
   vector<float> result = vector<float>::Zeros(rew_size);
   for(ulong i = 0; i < rew_size - 1; i++)
      result[i] = (t * min_dist.Col(i + 1)).Sum();

En el campo del componente de entropía de la función de recompensa, escribiremos el valor promedio como el logaritmo de las distancias usando el método RE3.

   result[rew_size - 1] = ri.Mean();
//---
   return (result);
  }

Ya hemos definido completamente el vector de valores objetivo de la recompensa descompuesta, así que retornaremos el vector de los resultados al programa que ha realizado la llamada.

Con esto concluiremos nuestra revisión de los métodos y funciones del asesor de entrenamiento del modelo "...\RE3\Study.mq5". El código completo de este asesor y todos los programas utilizados en el artículo se pueden encontrar en el archivo adjunto.


3. Simulación

La implementación anteriormente presentada difícilmente puede denominarse método Random Encoders for Efficient Exploration (RE3) en su forma pura. Sin embargo, hemos utilizado los enfoques básicos de este algoritmo y los hemos complementado con nuestra visión de los algoritmos previamente estudiados. Ahora ha llegado el momento de evaluar los resultados de nuestro trabajo utilizando datos históricos reales.

Al igual que antes, realizaremos el entrenamiento y la prueba de los modelos con los datos históricos de los primeros 5 meses de 2023 del instrumento EURUSD con el marco temporal H1. Todos los parámetros de los indicadores se usan por defecto. El balance inicial es de 10000 USD.

Repito una vez más que el proceso de entrenamiento de modelos es iterativo. Primero, en el simulador de estrategias, iniciaremos el asesor para la interacción con el entorno y la recopilación de ejemplos de entrenamiento "...\RE3\Research.mq5".


Aquí utilizaremos el modo de optimización lenta con iteración exhaustiva de parámetros, lo cual nos permitirá rellenar el búfer de reproducción de experiencias con los datos más diversos, ofreciendo así una comprensión lo más amplia posible de la naturaleza del entorno de nuestro modelo.

Los ejemplos de entrenamiento recopilados serán utilizados por el asesor de entrenamiento de modelos "...\RE3\Study.mq5" durante el entrenamiento de los Críticos y el Actor.

Repetiremos las iteraciones de recopilación de ejemplos y entrenamiento de modelos varias veces hasta obtener el resultado deseado.

Mientras preparaba el artículo, pude entrenar una política del Actor capaz de generar beneficio en el conjunto de entrenamiento. En el grupo de entrenamiento, el asesor ha mostrado un impresionante 83% de transacciones rentables. Aunque debo admitir que el número de transacciones comerciales realizadas ha sido muy pequeño. Durante los 5 meses del periodo de entrenamiento, nuestro Actor ha realizado solo 6 transacciones. Y solo una de ellas se ha cerrado con una pérdida relativamente pequeña de 18,62 dólares. Al mismo tiempo, la transacción rentable promedio ha sido de 114,96 dólares. Como resultado, el factor de beneficio ha superado la marca de 30, mientras que el factor de recuperación ha ascendido a 4,62.

Resultados del entrenamiento del modelo Resultados del entrenamiento del modelo

Según los resultados de las pruebas, podemos concluir que el algoritmo propuesto permite encontrar combinaciones efectivas. Sin embargo, una rentabilidad del 5,5% y 6 transacciones comerciales en 5 meses supone un resultado bastante bajo. Para lograr mejores resultados, deberemos centrar nuestros esfuerzos en aumentar el número de transacciones realizadas. No obstante lo dicho, debemos prestar especial atención para garantizar que el aumento en el número de transacciones no provoque un deterioro de la eficacia general de la estrategia.


Conclusión

En este artículo, hemos presentado el método Random Encoders for Efficient Exploration (RE3), que es un enfoque eficiente para explorar el entorno en el contexto del aprendizaje por refuerzo. Este método tiene como objetivo resolver el problema que supone explorar de forma eficiente entornos complejos, que es uno de los principales desafíos en el campo del aprendizaje por refuerzo profundo.

La idea principal del RE3 consiste en estimar la entropía de los estados en un espacio de representaciones de baja dimensionalidad obtenidas utilizando un codificador inicializado aleatoriamente. Los parámetros del codificador se fijan durante todo el periodo de entrenamiento. Esto evita la introducción de modelos adicionales y el entrenamiento de representaciones, lo que hace que el método resulte más simple y computacionalmente más eficiente.

En la parte práctica del artículo hemos presentado nuestra visión e implementación del método propuesto. Debemos decir que nuestra implementación utiliza las ideas básicas del algoritmo propuesto, pero a la vez se complementa con una serie de enfoques de algoritmos previamente estudiados. Esto ha hecho posible crear y entrenar un modelo bastante interesante. La proporción de transacciones rentables es asombrosa, pero lamentablemente el número total de estas resulta muy pequeño.

En general, el modelo resultante tiene razón de ser, pero necesitaremos trabajo adicional para encontrar formas de aumentar el número de transacciones comerciales.


Enlaces


Programas usados en el artículo

# Nombre Tipo Descripción
1 Research.mq5 Asesor Asesor de recopilación de datos
2 Study.mq5  Asesor Asesor de entrenamiento del agente
3 Test.mq5 Asesor Asesor para la prueba de modelos
4 Trajectory.mqh Biblioteca de clases Estructura de descripción del estado del sistema.
5 NeuroNet.mqh Biblioteca de clases Biblioteca de clases para crear una red neuronal
6 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/13158

Archivos adjuntos |
MQL5.zip (442.11 KB)
Teoría de Categorías en MQL5 (Parte 17): Funtores y monoides Teoría de Categorías en MQL5 (Parte 17): Funtores y monoides
Este es el último artículo de la serie sobre funtores. En él, revisaremos los monoides como categoría. Los monoides, que ya hemos introducido en esta serie, se utilizan aquí para ayudar a dimensionar la posición junto con los perceptrones multicapa.
Comprobando la informatividad de distintos tipos de medias móviles Comprobando la informatividad de distintos tipos de medias móviles
Todos conocemos la importancia de la media móvil para muchos tráders. Existen diferentes tipos de medias móviles que pueden resultar útiles en el trading. Vamos a echarles un vistazo y a hacer una sencilla comparación para ver cuál puede dar mejores resultados.
Pruebas de permutación de Monte Carlo en MetaTrader 5 Pruebas de permutación de Monte Carlo en MetaTrader 5
En este artículo echaremos un vistazo a cómo podemos realizar pruebas de permutación sobre la base de datos de ticks barajados en cualquier asesor experto utilizando solo MetaTrader 5.
Teoría de categorías en MQL5 (Parte 16): Funtores con perceptrones multicapa Teoría de categorías en MQL5 (Parte 16): Funtores con perceptrones multicapa
Seguimos analizando los funtores y cómo se pueden implementar utilizando redes neuronales artificiales. Dejaremos temporalmente el enfoque que implica el pronóstico de la volatilidad e intentaremos implementar nuestra propia clase de señales para establecer señales de entrada y salida para una posición.