English Русский 中文 Deutsch 日本語 Português
preview
Redes neuronales: así de sencillo (Parte 35): Módulo de curiosidad intrínseca (Intrinsic Curiosity Module)

Redes neuronales: así de sencillo (Parte 35): Módulo de curiosidad intrínseca (Intrinsic Curiosity Module)

MetaTrader 5Sistemas comerciales | 30 marzo 2023, 15:52
473 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Contenido


Introducción

Seguimos analizando los algoritmos de aprendizaje por refuerzo. Recordemos una vez más que todos los algoritmos de aprendizaje por refuerzo se basan en el paradigma de obtención de una recompensa del entorno por cada transición del agente de un estado del entorno a otro al realizar alguna acción. A su vez, el agente busca construir su política de acción de tal forma que maximice la recompensa recibida. Comenzando con el estudio de los algoritmos de aprendizaje por refuerzo, hablamos sobre la importancia de construir una política de recompensas clara, ya que esta juega uno de los papeles clave para lograr el objetivo del entrenamiento del modelo.

Sin embargo, en la mayoría de las situaciones de la vida real, las recompensas no siguen a cada acción. A veces hay un lapso de tiempo entre la acción y la recompensa, y este varía en su duración. Y a veces la obtención de una recompensa depende de toda una serie de acciones. En tales casos, dividiremos la recompensa total en algunos componentes y los colocaremos a lo largo de todo el camino del agente desde la acción hasta la recompensa. Se trata de un proceso bastante complicado, lleno de convenciones y compromisos.

A semejantes tareas pertenece el trading. El agente debe abrir una posición en la dirección correcta en el momento oportuno, esperando el momento de máxima rentabilidad de la posición abierta, y cerrando esta para fijar el resultado de la operación. Solo en el momento de cerrar la posición, obtendremos una recompensa en forma de cambio en el balance de nuestra cuenta. En los algoritmos anteriormente analizados, distribuimos esta recompensa por cada paso (intervalo de tiempo de una vela) en un número múltiplo del cambio en el precio del instrumento. Pero, ¿cómo de correcto es eso? Después de todo, en cada paso, el agente realizaba otra acción, ya sea una transacción comercial o una negativa a hacerla. Cabe aclarar aquí que la no realización de transacciones comerciales también supone una acción del agente. Después de todo, deberá elegir esta acción. Queda abierta la cuestión de la contribución de cada acción al resultado global. 

¿Existen otros enfoques para organizar la política de recompensas y el entrenamiento de los modelos?


1. La curiosidad es el deseo de conocimiento

Observemos el comportamiento de los organismos vivos. Los animales y las aves pueden viajar largas distancias en busca de alimento. E incluso las personas no siempre reciben una recompensa por cada acción. Los principios del aprendizaje humano son multifacéticos. Uno de los motores del aprendizaje es la curiosidad. Estará de acuerdo conmigo en que, cuando hay una puerta cerrada frente a nosotros, es la curiosidad la que nos hace abrirla un poco y mirar dentro: esto es inherente a la naturaleza humana.

Nuestro cerebro está diseñado de tal forma que cuando realizamos alguna acción, ya anticipamos en 1-2 pasos el resultado de su impacto, y a veces más. Después de todo, realizamos cualquier acción para obtener el resultado deseado, y solo comparando el resultado con nuestras expectativas, ajustaremos nuestras acciones. Al mismo tiempo, no debemos olvidar que solo podemos hacer un nuevo intento en los juegos: en la vida, no tendremos la oportunidad de dar un paso atrás y repetir por completo una situación. Cada nuevo intento es un nuevo resultado, por consiguiente, antes de realizar cualquier acción, deberemos analizar al completo nuestra experiencia adquirida previamente, y, en función de sus resultados, elegir la acción que sea correcta en nuestra opinión.

Si resultamos en una situación desconocida, nos esforzaremos por explorar y recordar el entorno tanto como sea posible. Al mismo tiempo, a veces ni siquiera pensamos en qué beneficio nos puede traer en el futuro, y no obtenemos recompensas inmediatas por nuestras acciones. Se trata solo de nuestra experiencia, que puede servirnos bien en el futuro.

Recuerde que hablamos anteriormente sobre la necesidad de explorar el entorno tanto como sea posible, y sobre el equilibrio entre el uso de la experiencia adquirida previamente y el estudio del entorno. E incluso introdujimos el hiperparámetro novedad en la estrategia codiciosa ɛ. Pero un hiperparámetro es una constante, y nos gustaría enseñar al propio modelo a gestionar el nivel de novedad según la situación.

Precisamente estos enfoques intentaron aplicar los autores del artículo "Curiosity-driven Exploration by Self-supervised Prediction" al construir su algoritmo. Este artículo se publicó en mayo de 2017. El método se basa en la formación de la curiosidad como un error en la capacidad de predecir el modelo de las consecuencias de las propias acciones. Por consiguiente, el interés en acciones previamente no comprometidas aumenta. El artículo explora 3 grandes tareas:

  1. La recompensa externa rara. La curiosidad nos permite alcanzar nuestro objetivo con menos interacciones con el entorno.
  2. Entrenamiento sin recompensa externa. La curiosidad empuja al agente a explorar el entorno de forma eficiente incluso en ausencia de recompensas externas del entorno.
  3. Generalización hacia escenarios invisibles. Cuando el conocimiento obtenido de la experiencia previa ayuda al agente a explorar nuevos lugares mucho más rápido que partir desde cero.

Los autores propusieron una idea bastante simple: a la recompensa externa re le añadimos una recompensa interna ri, que será una medida de la curiosidad y estimulará el conocimiento del entorno, y este cóctel ya se le servirá al agente para el entrenamiento. Los factores de escala de recompensas se pueden usar para ajustar la fuerza de la influencia de las recompensas extrínsecas e intrínsecas. Estos coeficientes son los hiperparámetros del modelo.

La principal novedad se encuentra precisamente en la arquitectura del bloque ICM que genera esta recompensa interna. El módulo de curiosidad intrínseca contiene tres modelos separados:

  • Encoder
  • Inverse Model
  • Forward Model

En la entrada, el módulo recibe los dos estados posteriores del sistema y la acción a realizar. La acción se codificará como un vector one-hot. Además, la codificación de acciones será posible tanto fuera como dentro del módulo. Los estados del sistema recibidos en la entrada del módulo, a su vez, se codificarán con la ayuda de un codificador. Entre las tareas del codificador se incluyen la reducción de la dimensionalidad del tensor que describe el estado del sistema y la filtración de datos. Los autores dividen todos los signos de descripción del estado del sistema en tres grupos:

  1. Aquellos a los que influye el agente.
  2. Aquellos que son independientes del agente, pero que ejercen influencia sobre este.
  3. Aquellos que son independientes del agente, pero que no ejercen influencia sobre este.

El codificador está diseñado para centrar la atención en los primeros dos grupos y neutralizar la influencia del tercer grupo.

El modelo inverso (Inverse Model) recibe en la entrada la codificación del estado de los dos estados posteriores y se entrena para determinar la acción perfecta para la transición entre estados. El entrenamiento del modelo inverso emparejado con el codificador se ha diseñado para destacar los dos primeros grupos de características. LogLoss se usa como la función de pérdida del modelo inverso.

El modelo de acción directa (Forward Model) aprende del estado actual codificado y de la acción realizada para predecir el siguiente estado. La calidad del pronóstico será la medida de la curiosidad, mientras que el error de predicción calculado por MSE será la recompensa intrínseca.

Módulo de curiosidad intrínseca

Al lector le puede parecer extraño que a medida que aumenta el error del modelo directo, aumenta la recompensa interna para el modelo DQN entrenado. El truco consiste en estimular el modelo a realizar más acciones cuyo resultado se desconoce, y así explorar el entorno tanto como resulte posible. A medida que se explore el entorno, la curiosidad del modelo disminuirá y el DQN maximizará la recompensa externa.

El módulo de curiosidad intrínseca se puede usar con cualquiera de los modelos analizados hasta ahora. Al mismo tiempo, no deberemos olvidarnos de utilizar todas las soluciones arquitectónicas previamente estudiadas para mejorar la convergencia del modelo.

Las pruebas prácticas de los autores de la metodología muestran la efectividad del algoritmo en juegos de computadora con una recompensa al final del nivel del juego. Además, el modelo muestra su capacidad de generalizar: esta se manifiesta en la capacidad de usar la experiencia adquirida previamente al pasar a un nuevo nivel del juego. Aquí la atención se centrará en la capacidad del modelo para funcionar bien al cambiar texturas y añadir ruido. Es decir, el modelo aprenderá a resaltar lo principal e ignorar diversos fenómenos de ruido. Con esto se aumenta la estabilidad del modelo en varios estados del entorno.


2. El bloque de curiosidad interna utilizando MQL5

Tras familiarizarnos brevemente con los aspectos teóricos de la metodología, vamos a pasar a la parte práctica de nuestro artículo, en la que implementaremos el método analizado utilizando MQL5. Antes de abordar la implementación, debemos decir que en este trabajo nos alejaremos de los enfoques considerados anteriormente por una serie de razones.

La primera diferencia cardinal será la política de recompensas. Hemos decidido acercarnos lo más posible a la situación real, por lo que la recompensa externa será el cambio en el balance de la cuenta. Es un cambio de balance, no de equidad. Sí, claro que una recompensa así puede ser bastante rara, pero es precisamente este problema el que queremos resolver usando el método en cuestión,

y como estamos limitados a la recompensa como cambio de balance, pero al mismo tiempo, cada acción del agente puede expresarse en transacciones comerciales, deberemos añadir a la descripción del estado del sistema indicadores que caractericen el estado de la cuenta comercial. Al mismo tiempo, tendremos que monitorear la apertura y el cierre de posiciones, así como la acumulación de beneficio no fijado por cada posición.

Para no organizar el seguimiento de cada posición en el código del asesor, hemos decidido trasladar el proceso de entrenamiento del modelo al simulador de estrategias. Dejaremos que el modelo realice operaciones en el simulador de estrategias, mientras que las funciones de consulta del estado de la cuenta y las posiciones abiertas nos permitirán obtener toda la información necesaria del simulador de estrategias.

Esto implicará la necesidad de crear un búfer de memoria para la reproducción de la experiencia. Ya hablamos sobre las razones para crear dicho búfer en el artículo "Redes neuronales: así de sencillo (Parte 27): Aprendizaje Q profundo (DQN)". Antes, usábamos como búfer la historia completa del instrumento durante el periodo de entrenamiento, pero la adición de datos sobre el estado de la cuenta no nos dejará esa oportunidad, además, nosotros implementaremos el búfer de experiencia acumulativa dentro del programa.

Asimismo, permitiremos que el asesor abra varias posiciones al mismo tiempo, incluidas las posiciones en direcciones opuestas, y junto con esto, cambiaremos el espacio de acciones admisibles del agente. Le daremos al agente la capacidad de realizar cuatro acciones:

0 — compra

1 — venta

2 — cerrar todas las posiciones abiertas

3 — omitir un turno, a la espera de un estado adecuado.

Comenzaremos nuestro trabajo creando un búfer para reproducir la experiencia.


2.1. Búfer de reproducción de experiencia

Del búfer de reproducción de experiencia, necesitaremos la capacidad de añadir registros de forma constante. Al mismo tiempo, cada vez añadiremos un paquete completo de datos, entre los cuales tendremos:

  • el tensor de descripción del estado del entorno
  • la acción realizada
  • la recompensa externa obtenida

El enfoque más apropiado para implementar el búfer, a nuestro juicio, será usar un array de objetos dinámico, y cada registro individual contendrá un objeto con la información anterior.

Para organizar cada registro individual en el búfer, crearemos la clase CReplayState como heredera de la clase básicaCObject. En la clase, usaremos un objeto de búfer de datos estáticos y dos variables para almacenar los datos, la acción realizada y la recompensa.

Aquí debemos prestar atención a que la acción del agente se realiza desde el estado actual, mientras que el agente recibe la recompensa por la transición a este estado. Es decir, por la transición del estado anterior al actual por la acción realizada en el paso anterior. A pesar de que añadimos al búfer de experiencia en un mismo momento, la recompensa y la acción se refieren a intervalos temporales distintos.

class CReplayState : public CObject
  {
protected:
   CBufferFloat      cState;
   int               iAction;
   double            dReaward;

public:
                     CReplayState(CBufferFloat *state, int action, double reward);
                    ~CReplayState(void) {};
   bool              GetCurrent(CBufferFloat *&state, int &action);
   bool              GetNext(CBufferFloat *&state, double &reward);
  };

En los parámetros del constructor de clases, obtenemos toda la información necesaria e inmediatamente la copiamos en el objeto interno y las variables de clase.

CReplayState::CReplayState(CBufferFloat *state, int action, double reward)
  {
   cState.AssignArray(state);
   iAction = action;
   dReaward = reward;
  }

Gracias al objeto estático de búfer de datos, nuestro destructor de clases permanece vacío.

Ahora añadiremos dos métodos más a nuestra clase para obtener los datos guardados, GetCurrent y GetNext. En el primer caso, retornaremos el estado y la acción, mientras que en el segundo retornaremos la acción y la recompensa.

bool CReplayState::GetCurrent(CBufferFloat *&state, int &action)
  {
   action = iAction;
   double reward;
   return GetNext(state, reward);
  }

El algoritmo de ambos métodos es bastante sencillo, y veremos su uso un poco más tarde.

bool CReplayState::GetNext(CBufferFloat *&state, double &reward)
  {
   reward = dReaward;
   if(!state)
     {
      state = new CBufferFloat();
      if(!state)
         return false;
     }
   return state.AssignArray(GetPointer(cState));
  }

Tras crear el objeto de registro individual, haremos que nuestro búfer de experiencia CReplayBuffer sea descendiente de la clase de array dinámico de objetos CArrayObj. Esta clase se actualizará constantemente con los nuevos estados durante el funcionamiento del asesor experto. Para evitar el desbordamiento de memoria, limitaremos el tamaño máximo al valor de la variable iMaxSize. Para controlar el tamaño del búfer, añadiremos el métodoSetMaxSize. No crearemos más objetos y variables en el cuerpo de la clase. Así, el constructor y el destructor de la clase quedarán vacíos.

class CReplayBuffer : protected CArrayObj
  {
protected:
   uint              iMaxSize;
public:
                     CReplayBuffer(void) : iMaxSize(500) {};
                    ~CReplayBuffer(void) {};
   //---
   void              SetMaxSize(uint size)   {  iMaxSize = size; }
   bool              AddState(CBufferFloat *state, int action, double reward);
   bool              GetRendomState(CBufferFloat *&state1, int &action, double &reward, CBufferFloat*& state2);
   bool              GetState(int position, CBufferFloat *&state1, int &action, double &reward, CBufferFloat*& state2);
   int               Total(void) { return CArrayObj::Total(); }
  };

La edición de registros en el búfer la realizaremos con el métodoAddState. En los parámetros, el método obtendrá los datos del nuevo registro, incluido el tensor de estado, la acción y la recompensa externa.

En el cuerpo del método, verificamos el puntero al objeto de búfer de estado del sistema, y después de superar la verificación del puntero, crearemos un nuevo objeto de registro y lo añadiremos al array dinámico. El trabajo principal con arrays dinámicos se organizará usando los métodos de la clase principal.

Finalmente, verificaremos el tamaño actual del búfer, y, si fuera necesario, eliminaremos los objetos más antiguos, ajustando el tamaño del búfer al tamaño máximo del búfer especificado.

bool CReplayBuffer::AddState(CBufferFloat *state, int action, double reward)
  {
   if(!state)
      return false;
//---
   if(!Add(new CReplayState(state, action, reward)))
      return false;
   while(Total() > (int)iMaxSize)
      Delete(0);
//---
   return true;
  }

Para obtener los datos del búfer, crearemos dos métodos, GetRendomState y GetState. El primero retornará un estado aleatorio del búfer, mientras que el segundo retornará los estados en el índice especificado en el búfer. En el cuerpo del primer método, solo generaremos un número aleatorio dentro del tamaño del búfer y llamaremos al segundo método para obtener los datos con el índice generado.

bool CReplayBuffer::GetRendomState(CBufferFloat *&state1, int &action, double &reward, CBufferFloat *&state2)
  {
   int position = (int)(MathRand() * MathRand() / pow(32767.0, 2.0) * (Total() - 1));
   return GetState(position, state1, action, reward, state2);
  }

Al analizar el algoritmo del segundo método GetState, llama la atención la diferencia entre la cantidad de datos solicitados y previamente almacenados. Antes, para guardar, obteníamos un estado del sistema, ahora se piden dos tensores del estado del entorno.

Recordemos cómo se organiza el proceso de aprendizaje Q. El entrenamiento se basará en cuatro objetos de datos:

  • el estado actual del entorno
  • la acción tomada por el agente
  • el estado posterior del entorno
  • la recompensa por la transición entre los estados del entorno.

Por consiguiente, necesitaremos extraer dos estados posteriores del sistema del búfer de experiencia. Aquí deberemos recordar que guardamos la acción del estado analizado del entorno y la recompensa por la transición al mismo estado. Por ello, necesitaremos extraer de un registro el estado y la acción, y del registro posterior, extraeremos el estado del entorno y la recompensa. Así es como organizamos los métodos GetCurrent yGetNext anteriores.

Ahora veremos la implementación del método GetState. En primer lugar, en el cuerpo del método, comprobaremos el índice especificado del registro que se va a recuperar. Deberá ser al menos 0, y como máximo, el índice de la penúltima entrada en el búfer, ya que necesitaremos los datos de los dos registros posteriores.

A continuación, llamaremos al métodoGetCurrent para el registro con el índice especificado, y luego pasaremos al siguiente registro y llamaremos al método GetNext. El resultado de las operaciones se retornará al programa que realiza la llamada.

bool CReplayBuffer::GetState(int position, CBufferFloat *&state1, int &action, double &reward, CBufferFloat *&state2)
  {
   if(position < 0 || position >= (Total() - 1))
      return false;
   CReplayState* element = m_data[position];
   if(!element || !element.GetCurrent(state1, action))
      return false;
   element = m_data[position + 1];
   if(!element.GetNext(state2, reward))
      return false;
//---
   return true;
  }

El búfer de experiencia es específico para una sesión de entrenamiento en particular, por lo que almacenar sus datos no tendrá valor alguno. Por ello, no crearemos los métodos para trabajar con archivos en las clases analizadas anteriormente: seguiremos adelante.


2.2. Módulo de curiosidad intrínseca (ICM)

Después de crear el búfer de experiencia, procederemos directamente a implementar el algoritmo del módulo de curiosidad intrínseca. Como se mencionó anteriormente en la parte teórica de este libro, el módulo utiliza tres modelos: el codificador, el modelo inverso y el modelo directo. En nuestra implementación, nos hemos desviado levemente de la arquitectura presentada por los autores, y para ahorrar recursos, no hemos creado un codificador aparte para el módulo de curiosidad intrínseco.

La arquitectura del autor prevé la creación de un codificador similar al usado en el modelo DQN entrenado. Hemos decidido usar el codificador existente del modelo de entrenamiento para codificar las señales. Esto, por supuesto, requiere la sincronización de los modelos y algunas adiciones al método de pasada inversa del modelo. Pero al mismo tiempo, reducirá los costes de memoria y recursos informáticos en una cantidad equivalente a la creación y el entrenamiento de un codificador adicional.

Además, esperamos obtener un beneficio adicional como el ajuste más preciso del codificador del modelo DQN entrenado.

Para implementar el algoritmo, crearemos una nueva clase de administrador de red neuronal CICM que heredará nuestra clase de administrador de red neuronal CNet básica. En el cuerpo de esta clase, añadiremos tres variables internas:

  • iMinBufferSize — tamaño mínimo del búfer de experiencia para comenzar a entrenar modelos.
  • iStateEmbedingLayer — número de la capa neuronal en el modelo entrenado, desde el cual leeremos el estado codificado del entorno. Esta será la capa neuronal que completará el codificador entrenado.
  • dPrevBalance — variable para registrar el último estado del balance de la cuenta. Lo usaremos para determinar la recompensa externa.

Además, declararemos cuatro objetos internos. Entre ellos, el objeto de búfer de acumulación de experiencia y tres objetos de las red neuronal cTargetNet, cInverseNet y cForwardNet.

Recordemos que usaremos el aprendizaje Q, y la Target Net es uno de los principales pilares de este método de aprendizaje.

class CICM : protected CNet
  {
protected:
   uint              iMinBufferSize;
   uint              iStateEmbedingLayer;
   double            dPrevBalance;
   //---
   CReplayBuffer     cReplay;
   CNet              cTargetNet;
   CNet              cInverseNet;
   CNet              cForwardNet;

   virtual bool      AddInputData(CArrayFloat *inputVals);

public:
                     CICM(void);
                     CICM(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse);
   bool              Create(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse);
   int               feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true, bool sample = true); 
   bool              backProp(int batch, float discount = 0.9f);
   int               getAction(void);      
   int               getSample(void);
   float             getRecentAverageError() { return recentAverageError; }
   bool              Save(string file_name, bool common = true);
   bool              Save(string dqn, string forward, string invers, bool common = true);
   virtual bool      Load(string file_name, bool common = true);
   bool              Load(string dqn, string forward, string invers, uint state_layer, bool common = true);
   //---
   virtual int       Type(void)   const   {  return defICML;   }
   virtual bool      TrainMode(bool flag)
            { return (CNet::TrainMode(flag) && cForwardNet.TrainMode(flag) && cInverseNet.TrainMode(flag)); } 
   virtual bool      GetLayerOutput(uint layer, CBufferFloat *&result) 
     { return        CNet::GetLayerOutput(layer, result); }
   //---
   virtual bool      UpdateTarget(string file_name);
   virtual void      SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; }
   virtual void      SetBufferSize(uint min, uint max);
  };

En artículos anteriores, ya creamos herederos similares de nuestra clase de administrador básica para el funcionamiento del modelo de red neuronal, y el conjunto de métodos de la nueva clase repetirá casi por completo los métodos anteriormente redefinidos. Vamos a detenernos un poco en los principales cambios que se han realizado en los métodos redefinidos. Comenzaremos con el método Create, que se usa para crear el modelo. El procedimiento para transmitir la descripción de la arquitectura del modelo que creamos anteriormente no prevé la creación de modelos anidados. Y para no realizar cambios globales en este subproceso, simplemente hemos decidido añadir la descripción de dos modelos más en los parámetros del método Create. En el cuerpo del método, simplemente llamaremos por turno al método homónimo para todos los modelos usados. Al mismo tiempo, daremos a cada uno nuestra propia descripción de la arquitectura, y, por supuesto, no nos olvidaremos de controlar la ejecución de los métodos llamados.

bool CICM::Create(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse)
  {
   if(!CNet::Create(Description))
      return false;
   if(!cForwardNet.Create(Forward))
      return false;
   if(!cInverseNet.Create(Inverse))
      return false;
   cTargetNet.Create(NULL);
//---
   return true;
  }

Debemos señalar que, tras llamar a este método, será necesario especificar el número de la capa neuronal del modelo principal para poder leer la integración del estado. Esta operación se realizará llamando al método SetStateEmbedingLayer.

   virtual void      SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; }

A diferencia de las clases similares anteriores, en las que usamos la pasada directa de la clase padre, en este caso hemos necesitado hacer cambios en la organización de la pasada directa.

Hemos cambiado el tipo de valor retornado. Si antes el método retornaba el valor booleano de la ejecución de las operaciones del método, y el método CNet::getResults se usaba para obtener los resultados de la pasada directa (lo cual se relacionaba con el retorno del tensor de resultado), ahora, el método de pasada directa de la nueva clase retornará el valor discreto de la acción seleccionada. Al mismo tiempo, dejaremos a la elección del usuario el uso de una estrategia codiciosa o el muestreo de una acción de una distribución de probabilidad. El responsable de esto será un parámetro adicional del método sample.

int CICM::feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true, bool sample = true)
  {
   if(!AddInputData(inputVals))
      return -1;
//---
   if(!CNet::feedForward(inputVals, window, tem))
      return -1;
   double balance = AccountInfoDouble(ACCOUNT_BALANCE);
   double reward = (dPrevBalance == 0 ? 0 : balance - dPrevBalance);
   dPrevBalance = balance;
   int action = (sample ? getSample() : getAction());
   if(!cReplay.AddState(inputVals, action, reward))
      return -1;
//---
   return action;
  }

Para no perturbar el enfoque general del funcionamiento de nuestros modelos, en el tensor de descripción del estado actual, esperaremos obtener del programa que realiza la llamada solo las señales del estado de mercado del instrumento, pero nuestro nuevo modelo también requerirá información sobre el estado de la cuenta. Vamos a añadir esta información al tensor resultante en el método AddInputData. Y solo después de añadir con éxito la información necesaria, llamaremos al método de pasada directa de la clase principal.

No obstante, nuestras innovaciones no terminan ahí. A continuación, deberemos añadir los nuevos datos al búfer de experiencia. Para hacer esto, primero definiremos la recompensa externa por la transición al estado actual. Como hemos mencionado antes, usaremos como recompensa externa los cambios de balance.

A continuación, determinaremos la próxima acción del agente según la estrategia elegida por el usuario, y transmitiremos todos estos datos al búfer de acumulación de experiencia. Entonces, solo después de completar con éxito todas las operaciones anteriores, retornaremos la acción del agente seleccionado al programa que realizada la llamada.

Tenga en cuenta que en cada etapa controlaremos el proceso de realización de las operaciones, y si sucede un error en cualquiera de las etapas descritas, el método retornará el valor "-1" al programa que realiza la llamada. Por lo tanto, al organizar el espacio de posibles acciones del agente, deberemos tener esto en cuenta, o bien cambiar el valor retornado para que el programa que realiza la llamada pueda separar claramente el estado de error de la acción del agente.

El siguiente paso será realizar cambios en el método backProp. Hay que decir que este es el método que ha cambiado de forma más sustancial. Lo primero que notamos es un cambio completo en el conjunto de parámetros. Aquí ya no verá el tensor de valores objetivo. En los parámetros, el nuevo método obtendrá solo el tamaño del paquete de actualización y el factor de descuento.

En el cuerpo del método, primero verificaremos el tamaño del búfer de experiencia: las operaciones posteriores del método serán posibles solo si el modelo acumula suficiente experiencia para aprender.

Tenga en cuenta que si la experiencia es insuficiente, saldremos del método con el resultado true. El retorno de false solo deberá usarse cuando ocurra un error de operación. Esto permitirá que el modelo realice operaciones posteriores con normalidad.

bool CICM::backProp(int batch, float discount = 0.900000f)
  {
//---
   if(cReplay.Total() < (int)iMinBufferSize)
      return true;
   if(!UpdateTarget(TargetNetFile))
      return false;

Además, antes de iniciar el proceso de entrenamiento del modelo, nos aseguraremos de actualizar la Target Net. Después de todo, utilizaremos su codificador para obtener la integración del estado del entorno después de la transición.

A continuación, haremos un pequeño trabajo preparatorio y declararemos varias variables internas y objetos que realizarán las funciones de almacenamiento intermedio de datos.

   CLayer *currentLayer, *nextLayer, *prevLayer;
   CNeuronBaseOCL *neuron;
   CBufferFloat *state1, *state2, *targetVals = new CBufferFloat();
   vector<float> target, actions, st1, st2, result;
   double reward;
   int action;

Después de realizar el trabajo preparatorio, organizaremos un ciclo de entrenamiento de modelos. El número de iteraciones del ciclo será igual al tamaño del paquete de actualizaciones del modelo que hayamos especificado en los parámetros.

En el cuerpo del ciclo, primero extraeremos aleatoriamente del búfer de experiencia un conjunto de datos que conste de dos estados consecutivos del sistema, la acción seleccionada y la recompensa obtenida. Después de eso, realizaremos un pasada directa del modelo entrenado.

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

Tras ejecutar con éxito la pasada directa del modelo principal, haremos el trabajo preparatorio para ejecutar la pasada directa de Forward Model. Aquí extraeremos la integración del estado actual del sistema y crearemos un vector one-hot de la acción ejecutada.

      //--- unload state embedding
      if(!GetLayerOutput(iStateEmbedingLayer, state1))
         return false;
      //--- prepare a one-hot action vector and concatenate with the current state vector
      getResults(target);
      actions = vector<float>::Zeros(target.Size());
      actions[action] = 1;
      if(!targetVals.AssignArray(actions) || !targetVals.AddArray(state1))
         return false;

Después de ello, realizaremos una pasada directa de Forward Model con la predicción de la integración del estado subsiguiente.

      //--- forward net feed forward pass - next state prediction
      if(!cForwardNet.feedForward(targetVals, 1, false))
         return false;

A continuación, realizaremos una pasada directa de la Target Net y extraeremos la integración de estado subsiguiente.

      //--- feed forward
      if(!cTargetNet.feedForward(state2, 1, false))
         return false;
      //--- unload the state embedding and concatenate with the "current" state embedding
      if(!cTargetNet.GetLayerOutput(iStateEmbedingLayer, state2))
         return false;

Luego combinaremos las dos integraciones resultantes de los estados subsiguientes en un solo tensor y llamaremos al método de pasada directa Inverse Model.

      //--- inverse net feed forward - defining the performed action.
      if(!state1.AddArray(state2) || !cInverseNet.feedForward(state1, 1, false))
         return false;

Después realizaremos pasadas inversas de Forward Model e Inverse Model. Los valores objetivo para ellos ya los preparamos antes como integración del siguiente estado y el vector one-hot de acción ejecutada.

      //--- inverse net backpropagation
      if(!targetVals.AssignArray(actions) || !cInverseNet.backProp(targetVals))
         return false;
      //--- forward net backpropagation
      if(!cForwardNet.backProp(state2))
         return false;

A continuación, regresaremos al trabajo con el modelo principal. Aquí ajustaremos la recompensa añadiendo la recompensa de curiosidad intrínseca y la recompensa futura esperada que hemos pronosticado usando la Target Net.

      //--- reward adjustment
      cForwardNet.getResults(st1);
      state2.GetData(st2);
      reward += (MathPow(st2 - st1, 2)).Sum();
      cTargetNet.getResults(targetVals);
      target[action] = (float)(reward + discount * targetVals.Maximum());
      if(!targetVals.AssignArray(target))
         return false;

Después de preparar la recompensa objetivo, podremos realizar una pasada inversa del modelo DQN principal, pero hay un pequeño detalle: Además de distribuir el gradiente de error de la recompensa pronosticada, también deberemos añadir el gradiente de error del modelo inverso al bloque de integración del estado, y para ello, deberemos copiar los datos de gradiente de error de la capa de datos de origen del modelo inverso al búfer de gradiente de error de la capa de integración del modelo principal antes de que se realice la pasada inversa del modelo principal. Después de todo, el algoritmo completo estará construido de tal forma que con cada pasada inversa, simplemente sobrescribiremos los datos que se encontraban en los búferes. Y esto significa que deberemos integrarnos en el proceso de transmisión de los gradientes de error. Para ello, deberemos reescribir completamente el código de la pasada inversa del modelo principal.

Aquí, primero determinaremos el error de predicción de la recompensa del modelo y llamaremos al método calcOutputGradients de la última capa neuronal, en la que se determina el gradiente de error en la salida de nuestro modelo.

      //--- backpropagation pass of the model being trained
        {
         getResults(result);
         float error = result.Loss(target, LOSS_MSE);
         //---
         currentLayer = layers.At(layers.Total() - 1);
         if(CheckPointer(currentLayer) == POINTER_INVALID)
            return false;
         neuron = currentLayer.At(0);
         if(!neuron.calcOutputGradients(targetVals, error))
            return false;
         //---
         backPropCount++;
         recentAverageError += (error - recentAverageError) / fmin(recentAverageSmoothingFactor, (float)backPropCount);

Aquí calcularemos el error de predicción promedio por parte del modelo.

El siguiente paso consistirá en distribuir el gradiente de error por todas las capas neuronales del modelo entrenado. Para ello, crearemos un ciclo con una enumeración inversa de todas las capas neuronales del modelo y una llamada secuencial del método calcHiddenGradients para todas las capas neuronales. Como recordará, es este método el que se encarga de distribuir el gradiente de error a través de la capa neuronal.

         //--- Calc Hidden Gradients
         int total = layers.Total();
         for(int layerNum = total - 2; layerNum >= 0; layerNum--)
           {
            nextLayer = currentLayer;
            currentLayer = layers.At(layerNum);
            neuron = currentLayer.At(0);
            if(!neuron.calcHiddenGradients(nextLayer.At(0)))
               return false;

Hasta esta etapa, en el subproceso de entrenamiento del modelo principal, hemos repetido completamente el algoritmo del método similar de la clase padre, pero en este punto deberemos hacer un pequeño ajuste al algoritmo.

Para ello, añadiremos una condición para verificar si la capa neuronal analizada es la salida del codificador de estado del sistema, y en caso de superar esta verificación, añadiremos los valores del gradiente de error del modelo inverso al gradiente de error obtenido de la siguiente capa neuronal.

Para agregar dos tensores, hemos utilizado el kernel MatrixSum previamente creado. Encontrará una descripción detallada de su algoritmo en el artículo "Redes neuronales: así de sencillo (Parte 8): Mecanismos de atención".

            if(layerNum == iStateEmbedingLayer)
              {
               CLayer* temp = cInverseNet.layers.At(0);
               CNeuronBaseOCL* inv = temp.At(0);
               uint global_work_offset[1] = {0};
               uint global_work_size[1];
               global_work_size[0] = neuron.Neurons();
               opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix1, neuron.getGradientIndex());
               opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix2, inv.getGradientIndex());
               opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix_out, neuron.getGradientIndex());
               opencl.SetArgument(def_k_MatrixSum, def_k_sum_dimension, 1);
               opencl.SetArgument(def_k_MatrixSum, def_k_sum_multiplyer, 1);
               if(!opencl.Execute(def_k_MatrixSum, 1, global_work_offset, global_work_size))
                 {
                  printf("Error of execution kernel MatrixSum: %d", GetLastError());
                  return false;
                 }
              }
           }

Para ejecutar correctamente esta acción, deberemos prestar atención a dos puntos.

Primero, el método de pasada inversa del modelo inverso deberá transmitir el gradiente de error a la capa de datos de origen, y para ello, en el ciclo de distribución de gradiente por las capas ocultas, deberemos tener la condición “layerNum >= 0”.

         //--- Calc Hidden Gradients
         int total = layers.Total();
         for(int layerNum = total - 2; layerNum >= 0; layerNum--)
           {

El segundo matiz es el siguiente: al declarar la arquitectura del modelo inverso para la capa de resultados, especificaremos un método de activación similar al método de activación de la capa receptora de la integración del estado. Esta acción no tendrá efecto en el pasada directa, pero corregirá el gradiente de error en la derivada de la función de activación en la pasada inversa.

Los pasos posteriores serán similares al algoritmo de pasada inversa de la clase principal. Después de distribuir el gradiente de error, actualizaremos las matrices de peso de todas las capas neuronales del modelo principal.

         //---
         prevLayer = layers.At(total - 1);
         for(int layerNum = total - 1; layerNum > 0; layerNum--)
           {
            currentLayer = prevLayer;
            prevLayer = layers.At(layerNum - 1);
            neuron = currentLayer.At(0);
            if(!neuron.UpdateInputWeights(prevLayer.At(0)))
               return false;
           }
         //---
         for(int layerNum = 0; layerNum < total; layerNum++)
           {
            currentLayer = layers.At(layerNum);
            CNeuronBaseOCL *temp = currentLayer.At(0);
            if(!temp.TrainMode())
               continue;
            if((layerNum + 1) == total && !temp.getGradient().BufferRead())
               return false;
            break;
           }
        }
     }

Tenga en cuenta que solo estamos actualizando las matrices de peso del modelo principal entrenado. Los parámetros Forward Model y Inverse Model se actualizarán al ejecutarse los métodos de pasada inversa de los modelos respectivos.

Al final, eliminaremos los objetos auxiliares creados dentro del método y completaremos su funcionamiento con un resultado positivo.

   delete state1;
   delete state2;
   delete targetVals;
//---
   return true;
  }

Hablemos ahora sobre los métodos para trabajar con archivos. Como estamos usando varios modelos en este algoritmo, nos enfrentaremos a la siguiente pregunta razonable: ¿cómo guardar los modelos entrenados? Aquí hay dos posibles opciones. Podemos guardar todos los modelos en un archivo. O podemos guardar cada modelo en un archivo separado. Le sugerimos guardar los modelos en archivos separados, ya que esto le dará más libertad de acción. Podemos cargar un modelo DQN entrenado de esta forma en un archivo separado y luego usarlo a la par con los modelos considerados anteriormente, o bien podemos cargar los tres modelos y usar el método descrito en este artículo. El único inconveniente será la necesidad de especificar cada vez la capa de integración del estado en el modelo principal. No obstante, podemos experimentar con la arquitectura de cada modelo individual en el proceso de aprendizaje para lograr resultados óptimos.

No nos detendremos ahora en la descripción de los algoritmos para trabajar con archivos. El código de todos los programas y clases utilizados, así como sus métodos, se puede encontrar en el archivo adjunto.


3. Simulación

Más arriba, hemos creado una clase para organizar el trabajo del modelo de aprendizaje Q utilizando el método de curiosidad intrínseca, así que ahora vamos a crear un asesor experto para entrenar y probar el modelo. Como hemos mencionado anteriormente, el nuevo modelo se entrenará en el simulador de estrategias, lo cual resulta fundamentalmente distinto de los métodos usados antes. Por lo tanto, el asesor experto para entrenar el modelo ha sufrido cambios significativos.

Para probar el funcionamiento del modelo, hemos creado el asesor experto "ICM-learning.mq5". Para describir la situación del mercado, hemos usado los mismos indicadores con parámetros similares. Por consiguiente, los parámetros externos del asesor se han mantenido prácticamente sin cambios. Lo mismo podemos decir de la declaración de variables y clases globales.

El método de inicialización del asesor experto ha repetido casi por completo los métodos similares de los asesores expertos considerados anteriormente. La diferencia está en que no se genera un evento para iniciar el proceso de entrenamiento. Esto se debe a la completa eliminación de la función de entrenamiento del modelo Train, que estaba presente en todos los asesores expertos anteriores.

El proceso de entrenamiento de modelos al completo se ha trasladado al método OnTick. Como nuestro modelo ha sido entrenado para analizar el mercado según las velas cerradas, comenzaremos el proceso de entrenamiento solo en la apertura de una nueva vela. Para hacer esto, en el cuerpo del método OnTick , primero verificaremos la aparición de un nuevo evento de apertura de vela, y solo después de un resultado positivo, realizaremos acciones posteriores.

void OnTick()
  {
   if(!IsNewBar())
      return;

A continuación, cargaremos los datos históricos en el tamaño de la ventana analizada,

   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();

y crearemos una descripción de la situación actual del mercado. Este proceso se construirá según el algoritmo del proceso similar de los asesores expertos que hemos analizado antes.

   State1.Clear();
   for(int b = 0; b < (int)HistoryBars; b++)
     {
      float open = (float)Rates[b].open;
      TimeToStruct(Rates[b].time, sTime);
      float rsi = (float)RSI.Main(b);
      float cci = (float)CCI.Main(b);
      float atr = (float)ATR.Main(b);
      float macd = (float)MACD.Main(b);
      float sign = (float)MACD.Signal(b);
      if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
         continue;
      //---
      if(!State1.Add((float)Rates[b].close - open) || !State1.Add((float)Rates[b].high - open) ||
         !State1.Add((float)Rates[b].low - open) || !State1.Add((float)Rates[b].tick_volume / 1000.0f) ||
         !State1.Add(sTime.hour) || !State1.Add(sTime.day_of_week) || !State1.Add(sTime.mon) ||
         !State1.Add(rsi) || !State1.Add(cci) || !State1.Add(atr) || !State1.Add(macd) || !State1.Add(sign))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
     }

Tras cargar con éxito los datos históricos y generar una descripción de la situación del mercado, llamaremos al método de pasada directa de nuestro modelo y verificaremos el resultado.

No olvidemos que en la nueva implementación de la pasada directa del modelo, el método feedForward retornará la acción del agente, y, según el resultado obtenido, realizaremos una transacción comercial.

   switch(StudyNet.feedForward(GetPointer(State1), 12, true, true))
     {
      case 0:
         Trade.Buy(Symb.LotsMin(), Symb.Name());
         break;
      case 1:
         Trade.Sell(Symb.LotsMin(), Symb.Name());
         break;
      case 2:
         for(int i=PositionsTotal()-1;i>=0;i--)
            if(PositionGetSymbol(i)==Symb.Name())
              Trade.PositionClose(PositionGetInteger(POSITION_IDENTIFIER));
         break;
     }

Aquí cabe señalar que al construir el modelo, hablamos de cuatro acciones del agente. Aquí vemos el análisis de solo tres acciones y la ejecución de la transacción comercial correspondiente. El hecho es que la cuarta acción prevé la expectativa de una situación de mercado más adecuada y la ausencia de transacciones comerciales. Así que simplemente no vamos a gestionar esta acción.

Al final del método, llamamos al método de pasada inversa del modelo.

   StudyNet.backProp(Batch, DiscountFactor);
//---
  }

Probablemente haya notado que durante el proceso de entrenamiento nunca hemos guardado el modelo entrenado. El proceso de guardado del modelo entrenado se ha trasladado al método de desinicialización del asesor experto.

void OnDeinit(const int reason)
  {
//---
   StudyNet.Save(FileName + ".nnw", FileName + ".fwd", FileName + ".inv", true);
  }

Para poder entrenar el modelo en el modo de optimización del asesor, hemos repetido un proceso de guardado similar tras completar cada paso del optimizador.

void OnTesterPass()
  {
   StudyNet.Save(FileName + ".nnw", FileName + ".fwd", FileName + ".inv", true);
  }

Cabe señalar que el proceso de optimización deberá llevarse a cabo solo en el kernel activo, de lo contrario, los hilos paralelos eliminarán los datos de otros agentes, y esto eliminará por completo el uso de agentes múltiples.

Para entrenar al asesor experto, todos los modelos se han creado utilizando la herramienta NetCreator. Debemos añadir que para que el asesor experto funcione en el simulador de estrategias, los archivos del modelo deberán estar ubicados en el directorio común del terminal "Terminal\Common\Files", ya que cada agente trabajará en su propio sandbox, y el intercambio de datos solo será posible a través de la carpeta común de los terminales.

El entrenamiento en el simulador de estrategias ocupa un poco más de tiempo que el enfoque del entrenamiento virtual anterior. Por este motivo, hemos reducido el periodo de entrenamiento del modelo a 10 meses. El resto de los parámetros de la prueba se han mantenido sin cambios. Como de costumbre, hemos usado EURUSD en el marco temporal H1. Asimismo, hemos utilizado los parámetros del indicador por defecto.

Siendo honestos, esperábamos que el proceso de entrenamiento comenzara con la pérdida del depósito. Pero durante la primera pasada, el modelo ha mostrado un resultado cercano a "0", y con la segunda, incluso hemos obtenido beneficios. El modelo ha realizado 330 transacciones con un rendimiento superior al 98% de transacciones rentables.

Resultado de la prueba del modelo Resultado de la prueba del modelo


Conclusión

En este artículo, nos hemos familiarizado con el funcionamiento del módulo de curiosidad intrínseca. Esta tecnología hace posible entrenar con éxito modelos con métodos de aprendizaje por refuerzo en condiciones de raras recompensas externas, como por ejemplo el comercio de mercado. La tecnología de la curiosidad intrínseca permite que el modelo explore el entorno tanto como sea posible y encuentre las mejores formas de lograr el objetivo.  Esto funciona incluso cuando el entorno retorna una recompensa por múltiples acciones consecutivas.

En la parte práctica de este artículo, hemos implementado la tecnología presentada usando las herramientas de MQL5, y los experimentos realizados nos permiten sacar una conclusión sobre la posible efectividad de dicho enfoque en el trading.

Nos gustaría enfatizar que el asesor experto presentado en el artículo es capaz de realizar transacciones comerciales, sin embargo, no está listo para su uso en el comercio real. El asesor experto se presenta únicamente para mostrar la tecnología analizada. Antes de usar el asesor experto en cuentas reales, este deberá sufrir mejoras significativas y pruebas exhaustivas en varias condiciones.


Enlaces

  1. Redes neuronales: así de sencillo (Parte 26): Aprendizaje por refuerzo
  2. Redes neuronales: así de sencillo (Parte 27): Aprendizaje Q profundo (DQN)
  3. Redes neuronales: así de sencillo (Parte 28): Algoritmo de gradiente de políticas
  4. Redes neuronales: así de sencillo (Parte 32): Aprendizaje Q distribuido
  5. Redes neuronales: así de sencillo (Parte 33): Regresión cuantílica en el aprendizaje Q distribuido
  6. Redes neuronales: así de sencillo (Parte 34): Función cuantílica totalmente parametrizada
  7. Curiosity-driven Exploration by Self-supervised Prediction

Programas usados en el artículo

# Nombre Tipo Descripción
1 ICM-learning.mq5 Asesor Asesor para el entrenamiento del modelo. 
2 ICM.mqh Biblioteca de clases Biblioteca de clases para organizar el modelo
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/11833

Archivos adjuntos |
MQL5.zip (106.2 KB)
DoEasy. Elementos de control (Parte 30): Animando el elemento de control "ScrollBar" DoEasy. Elementos de control (Parte 30): Animando el elemento de control "ScrollBar"
En este artículo, continuaremos desarrollando el control ScrollBar y comenzaremos a crear la funcionalidad de interacción con el ratón. Además, ampliaremos las listas de banderas de estado y eventos de ratón.
Teoría de categorías en MQL5 (Parte 1) Teoría de categorías en MQL5 (Parte 1)
La teoría de categorías es un área diversa y en expansión de las matemáticas, relativamente inexplorada aún en la comunidad MQL. Esta serie de artículos tiene como objetivo destacar algunos de sus conceptos para crear una biblioteca abierta y seguir utilizando esta maravillosa sección para crear estrategias comerciales.
Trabajamos con matrices: ampliando la funcionalidad de la biblioteca estándar de matrices y vectores. Trabajamos con matrices: ampliando la funcionalidad de la biblioteca estándar de matrices y vectores.
Las matrices sirven de base a los algoritmos de aprendizaje automático y a las computadoras en general por su capacidad para procesar con eficacia grandes operaciones matemáticas. La biblioteca estándar tiene todo lo que necesitamos, pero también podemos ampliarla añadiendo varias funciones al archivo utils.
Algoritmos de optimización de la población: Búsqueda de bancos de peces (Fish School Search — FSS) Algoritmos de optimización de la población: Búsqueda de bancos de peces (Fish School Search — FSS)
La búsqueda de bancos de peces (FSS) es un nuevo algoritmo de optimización moderno inspirado en el comportamiento de los peces en un banco, la mayoría de los cuales, hasta el 80%, nadan en una comunidad organizada de parientes. Se ha demostrado que las asociaciones de peces juegan un papel importante a la hora de buscar alimento y protegerse contra los depredadores de forma eficiente.