English Русский 中文 Deutsch 日本語 Português
preview
Redes neuronales: así de sencillo (Parte 46): Aprendizaje por refuerzo dirigido a objetivos (GCRL)

Redes neuronales: así de sencillo (Parte 46): Aprendizaje por refuerzo dirigido a objetivos (GCRL)

MetaTrader 5Sistemas comerciales | 18 octubre 2023, 16:38
426 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introducción

"Aprendizaje por refuerzo dirigido a objetivos" suena un poco inusual, se diría incluso que raro. Al fin y al cabo, el principio básico del aprendizaje por refuerzo pretende maximizar la recompensa total en la interacción del agente con el entorno, pero en este contexto, lo que buscamos es alcanzar un objetivo específico en una etapa o escenario concreto.

Ya hemos hablado de las ventajas de dividir la tarea global en subtareas y hemos explorado métodos para entrenar al agente en diversas habilidades que contribuyan al resultado global. En este artículo, le propongo abordar este problema desde una perspectiva diferente. Cómo entrenar a un agente para que seleccione por sí mismo una estrategia y una habilidad para ejecutar una subtarea específica.


1. Peculiaridades del GCRL

El aprendizaje por refuerzo dirigido a objetivos (GCRL) se refiere a un conjunto de tareas complejas de aprendizaje por refuerzo. Entrenamos al agente para que alcance distintos objetivos en determinados escenarios. Antes, entrenábamos al agente para que eligiera una u otra acción según el estado actual del entorno. En el contexto del GCRL, sin embargo, queremos entrenar al agente para que su acción esté dirigida no solo por el estado actual, sino también por la subtarea específica en la etapa actual. Es decir, además del vector que describe el estado actual, deberemos indicar de alguna forma al agente la subtarea a realizar en cada momento concreto. Estará de acuerdo conmigo en que se parece a la tarea de entrenamiento de habilidades, donde en cada momento especificamos una habilidad al agente. Al fin y al cabo, especificar que se utilice la habilidad "abrir una posición" o la tarea "abrir una posición" parece un juego de palabras, pero detrás de estas palabras se esconden diferencias en los enfoques de entrenamiento de los agentes.

En el aprendizaje por refuerzo, la función de recompensa supone siempre un punto crítico a tratar. En las tareas de aprendizaje de habilidades, como en el aprendizaje por refuerzo clásico, se usa una única función de recompensa objetivo durante todo el entrenamiento. Las indicaciones de la habilidad utilizadas para el agente deberán complementar el estado del entorno y ayudar al agente a orientarse en él.

En los enfoques de GCRL, introduciremos subtareas específicas, y sus logros deberán reflejarse en la recompensa obtenida por el agente. Resulta similar a la recompensa interna del discriminador, pero en su kernel contiene indicadores claros y medibles para lograr un objetivo específico (resolver una subtarea).

Para discernir esta delgada línea, veremos un ejemplo de apertura de una posición con ambos enfoques. En el entrenamiento de habilidades, suministrábamos al planificador el estado actual del entorno y el vector de estado de la cuenta con las posiciones abiertas ausentes. A partir de ahí, el planificador determinaba el vector de descripción de las habilidades, que pasaríamos al agente para que tomara una decisión. A modo de recompensa, como recordarán, utilizábamos el balance de la cuenta. Cabe destacar que aplicaremos la misma recompensa durante todo el entrenamiento del agente. Además, la apertura inmediata de una posición no afecta a la variación del balance. La excepción son las posibles comisiones por abrir una posición, pero, en general, por abrir una posición obtenemos una recompensa con retraso.

En el caso de GCRL, sin embargo, junto con la recompensa del objetivo global, introduciremos una recompensa adicional por lograr una subtarea específica. Por ejemplo, podemos introducir alguna recompensa por abrir una posición, o, de forma alternativa, penalizar hasta que el agente abra una posición. Y aquí deberemos adoptar un enfoque equilibrado para la formación de dicha recompensa. Esta no deberá superar los posibles beneficios y pérdidas de la propia operación comercial. Porque, de lo contrario, el agente se limitará a abrir posiciones y "sumar puntos", mientras que el balance de la cuenta tenderá a "0".

Una cosa más, la recompensa deberá depender de la tarea que se realice. Premiaremos la apertura de una posición y penalizaremos por no hacerlo solo cuando establezcamos la tarea de "abrir una posición". Y a la hora de buscar un punto de salida de una posición, podemos, por el contrario, introducir penalizaciones por una posición abierta adicionalmente, así como por mantener una posición durante mucho tiempo.

A la hora de formar un vector de descripción de tareas para el GCRL, resulta importante tener en cuenta ciertos requisitos. Este vector deberá indicar explícitamente la subtarea que el agente tendrá que realizar en un momento determinado.

El vector de descripción de la tarea puede incluir diferentes elementos, dependiendo del contexto y de las características específicas de la tarea. Por ejemplo, en el caso de la apertura de una posición, el vector de descripción puede contener información sobre el activo objetivo, el volumen comercial, los límites de precio u otros parámetros relacionados con la apertura de la posición. Estos elementos deberán ser claros y comprensibles para que el agente pueda interpretar correctamente la subtarea establecida.

Además, el vector de descripción de la tarea deberá ser lo suficientemente informativo como para permitir al agente tomar decisiones orientadas al máximo a la consecución de esta subtarea, lo cual puede requerir la inclusión de datos adicionales o información contextual para ayudar al agente a comprender con mayor precisión cómo proceder para alcanzar el objetivo.

Deberá existir una relación lógica explícita, pero no matemática, entre el vector con la descripción de la subtarea y el resultado deseado. Podemos utilizar un vector regular de un solo elemento, en el que cada elemento corresponda a una subtarea diferente, y transmitirlo al agente junto con una descripción del estado actual del entorno. Los importante es que el agente sea capaz de interpretar la subtarea con claridad y construya sus vínculos internos entre la subtarea y la recompensa. La recompensa también deberá examinarse a este respecto. La recompensa adicional introducida deberá corresponderse con una subtarea específica.

Pero hay otros enfoques para formar un vector de descripción de subtareas. Si necesitamos una combinación de muchos factores para describir una subtarea individual, podremos utilizar un modelo independiente para formar dicho vector, de forma similar a los métodos de aprendizaje de habilidades. Dicho modelo puede entrenarse usando diversos autocodificadores o con cualquier otro método disponible.

Como podemos ver, ambos enfoques son bastante potentes y resuelven problemas diferentes, pero cada uno de ellos tiene sus defectos, y no es en absoluto casual que surjan varias sinergias entre ambos enfoques para construir un algoritmo aún más robusto. Después de todo, en el proceso de aprendizaje de habilidades, construimos dependencias entre el estado actual del entorno y la habilidad (política de acción) del agente, mientras que el uso de herramientas adicionales dirigidas a una subtarea específica ayudará a ajustar la estrategia del agente para obtener resultados óptimos.

El GCRL variacional adaptativo (aVGCRL) es uno de estos enfoques. La idea es que, en un entorno estocástico, la distribución de la representación de cada habilidad no será homogénea, además, puede cambiar según las condiciones del entorno. En determinados estados, existirá una correlación con ciertas habilidades para las que la varianza de la distribución sea mínima. Al mismo tiempo, la probabilidad de utilizar otras habilidades en los mismos estados no será tan clara, y la varianza de su distribución será mucho mayor. En otros estados del entorno, es probable que la dispersión de las distribuciones de la habilidad sea drásticamente diferente. Este efecto puede verse observando la representación latente de la varianza del autocodificador variacional que utilizamos en el artículo anterior para entrenar al planificador. Es una decisión del todo lógica centrarse en las dependencias explícitas, y los autores del método aVGCRL sugieren dividir el error de desviación de cada habilidad respecto al valor objetivo por la varianza de la distribución. Obviamente, cuanto menor sea la varianza, mayor será el impacto del error y los coeficientes de ponderación correspondientes cambiarán más durante el proceso de entrenamiento. Al mismo tiempo, la aleatoriedad de otras habilidades no desequilibrará significativamente el modelo general.


2. Implementación usando MQL5

Para lograr un conocimiento más detallado del método GCRL, le sugiero analizar una variante de aplicación del mismo. Permítanme decir de entrada que vamos a crear una especie de simbiosis entre los dos métodos comentados anteriormente, pero combinaremos todo en un solo modelo.

En el artículo anterior, creamos dos modelos: un planificador en forma de autocodificador variacional y un agente. A diferencia de los enfoques anteriores, el agente solo recibía como entrada el estado latente del autocodificador, que según nuestra lógica debería contener toda la información necesaria. La simulación del modelo mostró que entrenar al agente para alcanzar el estado predicho por el autocodificador no producía el resultado deseado, lo cual puede deberse a una calidad insuficiente de los estados predictivos.

Al mismo tiempo, el uso de enfoques de recompensa clásicos mejoró el aprendizaje de los agentes usando un planificador previamente entrenado.

En este artículo, hemos decidido renunciar al entrenamiento aparte del autocodificador variacional, y hemos incorporado su codificador directamente en el modelo del Agente. Hay que decir que este enfoque incumple en cierto modo los principios de entrenamiento de los autocodificadores. Al fin y al cabo, la idea básica detrás del uso de cualquier autocodificador es comprimir datos sin vincularse a una tarea específica, pero ahora no nos enfrentamos a la tarea de entrenar a un codificador para que resuelva múltiples problemas a partir de los mismos datos de entrada.

El segundo momento importante es que solo suministraremos el estado actual del entorno a la entrada del codificador. En nuestro caso, hablamos de los datos históricos del movimiento del precio del instrumento y de las métricas de los indicadores analizados. Es decir, excluiremos la información sobre el estado de la cuenta. Supongamos que, basándose en los datos históricos, el planificador (en este caso el codificador) generará la habilidad a utilizar. Puede ser la política de trabajo en un mercado alcista o bajista, o podría ser el comercio en un mercado plano.

Basándonos en la información sobre el estado de la cuenta, formaremos una subtarea para que el Agente busque un punto de entrada o salida de la posición.

Debemos decir que la división del modelo en Planificador y Agente es absolutamente condicional. Al fin y al cabo, formaremos un solo modelo. Pero, como ya hemos dicho, solo suministraremos la entrada del codificador datos históricos, lo cual significa que tendremos que añadir la información sobre la subtarea a la mitad del modelo. Es algo que nunca habíamos hecho antes. No puede decirse que sea una solución completamente nueva: ya nos hemos encontrado con este tipo de cosas antes. En estos casos, creábamos dos modelos.

La primera parte se resolvía con un modelo, luego combinábamos la salida del primer modelo con los nuevos datos y la suministrábamos a la entrada del segundo modelo. Esta solución es más fácil de organizar, pero tiene un gran inconveniente: se trata de un intercambio de datos redundante entre el programa principal y el contexto OpenCL. Así que deberemos recuperar los resultados del primer modelo del contexto y volver a cargarlos para el segundo modelo. Lo mismo ocurrirá con el gradiente de error en la pasada inversa. El uso de un modelo único eliminará estas operaciones, pero entonces surgirá la cuestión de añadir la nueva información en una fase distinta del modelo.

Para resolver este problema, crearemos el nuevo tipo de capa neuronal CNeuronConcatenate. Al igual que antes, empezaremos a trabajar en cada nueva clase de capa neuronal creando los kernels necesarios en el programa OpenCL. El primero que crearemos será el kernel de pasada directa Concat_FeedForward. Permítanme decir de inmediato que hemos creado todos los kernels basándonos en los kernels similares de la capa neuronal totalmente conectada básica.  La principal diferencia reside en la adición de los búferes y parámetros adicionales para los dos flujos de información.

En los parámetros del kernel Concat_FeedForward, vemos una única matriz de pesos, dos tensores de datos de entrada, un vector de resultados y tres parámetros numéricos (los tamaños de los tensores de datos de entrada y el identificador de la función de activación)

__kernel void Concat_FeedForward(__global float *matrix_w,
                                 __global float *matrix_i1,
                                 __global float *matrix_i2,
                                 __global float *matrix_o,
                                 int inputs1,
                                 int inputs2,
                                 int activation
                                )

Como antes, ejecutaremos el kernel en un espacio de tareas unidimensional según el número de neuronas de nuestra capa, que será idéntico al tamaño del búfer de resultados. En el cuerpo del kernel, definiremos el identificador del flujo y declararemos las variables locales necesarias. Aquí es también donde determinamos el desplazamiento en el búfer de coeficientes de peso. Obsérvese que para cada neurona de la capa de salida, definiremos un número de pesos igual al tamaño total de dos búferes de datos brutos y una neurona de desplazamiento bayesiano.

  {
   int i = get_global_id(0);
   float sum = 0;
   float4 inp, weight;
   int shift = (inputs1 + inputs2 + 1) * i;

A continuación, organizaremos un ciclo para calcular la suma ponderada del búfer de los datos de origen. Este proceso es completamente idéntico al del kernel de una capa neuronal totalmente conectada.

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

Una vez completadas las iteraciones del ciclo, ajustaremos el desplazamiento en la matriz de pesos según el tamaño 1 del búfer de datos de origen, y crearemos un ciclo similar para los dos búferes de datos de origen.

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

Al final del kernel, añadiremos un elemento de desplazamiento bayesiano y ejecutaremos la activación de la suma resultante. A continuación, guardamos el valor obtenido en el elemento correspondiente del búfer de resultados.

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

Hemos usado exactamente el mismo método para modificar los kernels de pasada inversa y la actualización de la matriz de pesos. Podrá familiarizarse con ellos en el archivo "NeuroNet_DNG\NeuroNet.cl" (añadido al artículo).

Tras crear los kernels, comenzaremos a trabajar en el código de la clase CNeuronConcatenate del programa principal. El conjunto de métodos de la clase se ciñe bastante a los estándares, tenemos:

  • Un constructor CNeuronConcatenate y un destructor ~CNeuronConcatenate
  • La inicialización de la capa neuronal Init
  • La pasada directa feedForward
  • La distribución del gradiente de error calcHiddenGradients
  • La actualización de la matriz de pesos updateInputWeights
  • La identificación del objeto Type
  • El uso de los archivos Save y Load.

class CNeuronConcatenate   :  public CNeuronBaseOCL
  {
protected:
   int               i_SecondInputs;
   CBufferFloat     *ConcWeights;
   CBufferFloat     *ConcDeltaWeights;
   CBufferFloat     *ConcFirstMomentum;
   CBufferFloat     *ConcSecondMomentum;

public:
                     CNeuronConcatenate(void);
                    ~CNeuronConcatenate(void);
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                          uint inputs1, uint inputs2, ENUM_OPTIMIZATION optimization_type, uint batch);
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput);
   virtual bool      calcHiddenGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput);
   //--- methods for working with files
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual int       Type(void)        const                      {  return defNeuronConcatenate; }
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

Además, declararemos una variable en la clase para registrar el tamaño de los datos de origen adicionales y 4 búferes de datos: las matrices de pesos y los momentos para diferentes métodos de optimización de los coeficientes de peso. Permítanme decir de entrada que los nuevos búferes se usarán para organizar el proceso de conexión con la capa neuronal anterior y los nuevos datos de origen. La organización del proceso de transmisión de datos a la capa neuronal posterior se organizará mediante la clase padre de la capa neuronal totalmente conectada CNeuronBaseOCL.

En el constructor de la clase, inicializaremos los búferes de datos,

CNeuronConcatenate::CNeuronConcatenate(void) : i_SecondInputs(0)
  {
   ConcWeights = new CBufferFloat();
   ConcDeltaWeights = new CBufferFloat();
   ConcFirstMomentum = new CBufferFloat();
   ConcSecondMomentum = new CBufferFloat;
  }

mientras que en el destructor de la clase limpiaremos los datos y eliminaremos los objetos.

CNeuronConcatenate::~CNeuronConcatenate()
  {
   if(!!ConcWeights)
      delete ConcWeights;
   if(!!ConcDeltaWeights)
      delete ConcDeltaWeights;
   if(!!ConcFirstMomentum)
      delete ConcFirstMomentum;
   if(!!ConcSecondMomentum)
      delete ConcSecondMomentum;
  }

La indicación de la dimensionalidad de todos los búferes de datos necesarios se organizará en el método de inicialización del objeto Init. En los parámetros, el método obtendrá los datos de entrada necesarios:

  • numOutputs — número de neuronas en la capa siguiente
  • open_cl — puntero al objeto de trabajo con el contexto OpenCL
  • numNeurons — número de neuronas en la capa actual
  • numInputs1 — número de elementos de la capa anterior
  • numInputs2 — número de elementos en el búfer adicional de datos de origen
  • optimisation_type — identificador del método de optimización de parámetros.
bool CNeuronConcatenate::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                              uint numInputs1, uint numInputs2, ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch))
      return false;

En el cuerpo del método, en lugar del bloque de control, llamaremos al método similar de la clase padre y comprobaremos el resultado de las operaciones. En la clase padre ya se han implementado los controles básicos, por lo que no deberemos repetirlos. Además, el método de la clase padre implementa la inicialización de todos los objetos y variables heredados, y por consiguiente, en el cuerpo de este método solo tendremos que organizar el proceso de inicialización de los objetos añadidos.

En primer lugar, crearemos e inicializaremos con valores aleatorios una matriz de coeficientes de peso para organizar el intercambio de datos con la capa neuronal anterior. Obsérvese que el tamaño de la matriz de pesos se ajustará lo suficiente para organizar el trabajo con la capa precedente y el búfer adicional de datos de origen. Este es exactamente el enfoque que previmos al crear el kernel de pasada directa, y ahora nos ceñiremos a este al crear los métodos de clase en el lado del programa principal.

   i_SecondInputs = (int)numInputs2;
   if(!ConcWeights)
     {
      ConcWeights = new CBufferFloat();
      if(!ConcWeights)
         return false;
     }
   int count = (int)((numInputs1 + numInputs2 + 1) * numNeurons);
   if(!ConcWeights.Reserve(count))
      return false;
   float k = (float)(1.0 / sqrt(numNeurons + 1.0));
   for(int i = 0; i < count; i++)
     {
      if(!ConcWeights.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier))
         return false;
     }
   if(!ConcWeights.BufferCreate(OpenCL))
      return false;

A continuación, en función del método de actualización de los coeficientes de peso especificado en los parámetros, inicializaremos los búferes de momentos. Recordemos que para el SGD utilizaremos un único búfer de momentos. Y si se utiliza el método Adam, se inicializarán los dos búferes de momentos. Los objetos no utilizados se eliminarán, lo cual permitirá un uso más eficiente de los recursos disponibles.

   if(optimization == SGD)
     {
      if(!ConcDeltaWeights)
        {
         ConcDeltaWeights = new CBufferFloat();
         if(!ConcDeltaWeights)
            return false;
        }
      if(!ConcDeltaWeights.BufferInit(count, 0))
         return false;
      if(!ConcDeltaWeights.BufferCreate(OpenCL))
         return false;
      if(!!ConcFirstMomentum)
         delete ConcFirstMomentum;
      if(!!ConcSecondMomentum)
         delete ConcSecondMomentum;
     }
   else
     {
      if(!!ConcDeltaWeights)
         delete ConcDeltaWeights;
      //---
      if(!ConcFirstMomentum)
        {
         ConcFirstMomentum = new CBufferFloat();
         if(CheckPointer(ConcFirstMomentum) == POINTER_INVALID)
            return false;
        }
      if(!ConcFirstMomentum.BufferInit(count, 0))
         return false;
      if(!ConcFirstMomentum.BufferCreate(OpenCL))
         return false;
      //---
      if(!ConcSecondMomentum)
        {
         ConcSecondMomentum = new CBufferFloat();
         if(!ConcSecondMomentum)
            return false;
        }
      if(!ConcSecondMomentum.BufferInit(count, 0))
         return false;
      if(!ConcSecondMomentum.BufferCreate(OpenCL))
         return false;
     }
//---
   return true;
  }

Ya hemos terminado con los métodos de inicialización de la clase, ahora podemos empezar a organizar la funcionalidad principal. Lo primero que haremos será crear un método feedForward. A diferencia de los métodos de pasada directa de todas las clases comentadas anteriormente, este método recibirá en sus parámetros dos punteros a objetos: la capa neuronal anterior y un búfer adicional de datos de origen. Aquí no vemos nada sorprendente, porque esta es la principal diferencia de la clase creada, pero este enfoque requiere trabajo adicional por parte del programa principal fuera de la clase creada. Hablaremos de ello un poco más adelante.

bool CNeuronConcatenate::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput)
  {
   if(!OpenCL || !NeuronOCL || !SecondInput)
      return false;

En el cuerpo del método, primero comprobaremos la relevancia de los punteros recibidos, y junto con ellos, comprobaremos la presencia del puntero al objeto de contexto OpenCL. Si falta al menos un puntero, finalizaremos el método con un resultado negativo.

A continuación, comprobaremos el tamaño del búfer de datos adicionales. Como mínimo, deberá contener un número suficiente de elementos. Tenga en cuenta que permitimos especificar un tamaño de búfer mayor. Pero durante trabajo solo se utilizarán los primeros elementos del búfer en la cantidad que se haya especificado al inicializar la clase.

   if(SecondInput.Total() < i_SecondInputs)
      return false;
   if(SecondInput.GetIndex() < 0 && !SecondInput.BufferCreate(OpenCL))
      return false;

A continuación, comprobaremos si existe un puntero al búfer de datos en el contexto OpenCL y crearemos un nuevo búfer si fuera necesario.

Tenga en cuenta que solo creamos un nuevo búfer si no hay ningún puntero al búfer de datos en el contexto. Si lo hay, no volveremos a cargar los datos en el contexto: consideraremos que la presencia de un puntero indicará la presencia de datos en el contexto. Por ello, cuando se cambie el contenido del búfer en el lado del programa principal, deberemos copiar los datos al contexto. El propio usuario deberá encargarse de controlar la pertinencia de los datos de la memoria contextual.

A continuación, transmitiremos los punteros a los búferes de datos y las constantes necesarias a los parámetros del kernel. Este procedimiento resulta idéntico para todos los kernels. Solo se modificarán los identificadores de los kernels, los parámetros y los punteros a los búferes de datos correspondientes. Todas las operaciones matemáticas deberán especificarse en el cuerpo del propio kernel en la parte del programa OpenCL.

   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_w, ConcWeights.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_i1, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_i2, SecondInput.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_o, Output.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_inputs1, (int)NeuronOCL.Neurons()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_inputs2, (int)i_SecondInputs))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_activation, (int)activation))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

Al final de las operaciones del método, indicaremos los espacios de tareas para ejecutar el kernel y ponerlo en cola para su ejecución.

   uint global_work_offset[1] = {0};
   uint global_work_size[1];
   global_work_size[0] = Output.Total();
   if(!OpenCL.Execute(def_k_ConcatFeedForward, 1, global_work_offset, global_work_size))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
//---
   return true;
  }

Aquí resulta muy importante controlar en cada etapa que el kernel llamado se especifique correctamente, al igual que el identificador del búfer y su contenido. Y, por supuesto, no deberemos olvidarnos de controlar que las operaciones en cada etapa sean correctas.

Los métodos para distribuir los gradientes de error y actualizar la matriz de pesos se basan en un algoritmo similar, y podrá verlos en el archivo adjunto. Solo hay que tener en cuenta que la distribución del gradiente de error añadirá otro búfer de gradientes de error al nivel de los datos de origen adicionales. En este documento no descargaremos ni usaremos sus datos, pero esto podría ser necesario en el futuro si el segundo modelo genera un vector de datos de origen adicionales.

Tras crear los métodos de nuestra clase CNeuronConcatenate, deberemos ocuparnos de organizar el proceso de transmisión del búfer adicional de datos de origen del usuario desde el programa principal a una capa neuronal concreta. Permítanme recordarles que en general el proceso está organizado de tal manera que después de crear un modelo el usuario en su programa trabaja con solo dos métodos: el de pasada directa y el de pasada inversa del modelo en su conjunto. El usuario no tiene control sobre el proceso de transmisión de datos entre las capas neuronales. El proceso completo tiene lugar, por así decirlo, "bajo el capó" de nuestra biblioteca. Por lo tanto, el usuario debería poder llamar al método de pasada directa y especificar los dos búferes de datos en sus parámetros. A continuación, el modelo deberá distribuir independientemente los datos por los flujos de información correspondientes.

En esta fase, tenemos previsto utilizar solo una capa con reabastecimiento de datos. Y, para no complicar el proceso monitoreando a qué capa neuronal transmitir los datos de origen adicionales, hemos decidido transmitir el puntero al búfer a todas las capas neuronales: la decisión de utilizarlo o no se tomará a nivel de la propia clase.

No vamos a analizar ahora con detalle la adición de un único parámetro en varios métodos en la cadena. Encontrará el código completo de todos los métodos y funciones en el archivo adjunto. Nos detendremos en un detalle: a pesar de que los métodos de pasada directa de todas las clases tienen nombres idénticos y se declaran como virtuales, la adición de un parámetro en unas clases y su ausencia en otras no permitirá redefinir completamente los métodos de las clases heredadas. Para preservar la herencia, tendríamos que rehacer los métodos de pasada directa e inversa de todas las clases creadas anteriormente. He decidido no implementar esto. En su lugar, solo hemos añadido el control adicional a los métodos de gestión de la capa neuronal básica. Tomaremos como ejemplo el método de pasada directa.

En los parámetros del método de gestión CNeuronBaseOCL::FeedForward, añadiremos el puntero al búfer de datos y le asignaremos un valor por defecto. Este truco nos permitirá seguir usando el método con solo un puntero a la capa neuronal anterior, cosa que resultará útil al utilizar la biblioteca para modelos creados previamente, permitiendo además compilar programas creados previamente sin ningún cambio.

A continuación, comprobaremos el tipo de la capa neuronal actual. Y si estamos en una clase de fusión de datos de dos flujos, llamaremos al método de pasada directa correspondiente. En caso contrario, utilizaremos el algoritmo creado anteriormente. A continuación le mostramos solo una parte del código del método con modificaciones. Después, el código del método no se ha modificado. El código completo del método CNeuronBaseOCL::FeedForward se puede encontrar en el archivo adjunto. Allí también verá los métodos de gestión de pasada inversa modificados. También hemos añadido búferes adicionales con punteros vacíos por defecto.

bool CNeuronBaseOCL::FeedForward(CObject *SourceObject, CBufferFloat *SecondInput = NULL)
  {
   if(CheckPointer(SourceObject) == POINTER_INVALID)
      return false;
//---
   CNeuronBaseOCL *temp = NULL;
   if(Type() == defNeuronConcatenate)
     {
      temp = SourceObject;
      CNeuronConcatenate *concat = GetPointer(this);
      return concat.feedForward(temp, SecondInput);
     }

Hay mucha información y el tamaño del artículo es limitado. Así que hemos abarcado los métodos de la nueva clase CNeuronConcatenate de forma bastante sucinta. Espero que esto no influya negativamente en su comprensión de las ideas y planteamientos. En cualquier caso, el algoritmo de estos difiere poco de los métodos similares de las clases comentadas anteriormente. El código completo de todos los métodos y clases se ofrece en el archivo adjunto, pero si tiene alguna pregunta, estoy dispuesto a responderla en el foro y en los mensajes privados de este sitio web, como más le convenga.

Por nuestra parte, vamos a mirar más de cerca el método de aprendizaje por refuerzo GCRL que nos ocupa, analizando los procesos de construcción y entrenamiento de los modelos. Como antes, crearemos tres asesores:

  • uno para la recopilación inicial de ejemplos "GCRL\Research.mq5"
  • otro para el entrenamiento del agente "GCRL\StudyActor.mq5"
  • y un tercero para comprobar el rendimiento del modelo "GCRL\Test.mq5

Indicaremos la arquitectura del modelo en el archivo de inclusión "GCRL\Trajectory.mqh".

Como ya hemos dicho, ensamblaremos todo el modelo en un único agente. Por ello, solo tendremos un modelo para describir la arquitectura. En el cuerpo del método CreateDescriptions, comprobaremos primero si el puntero al objeto array dinámico es válido, y luego crearemos un nuevo objeto si fuera necesario. A continuación, nos aseguraremos de limpiar la matriz dinámica antes de añadir nuevos objetos de descripción de la capa neuronal.

bool CreateDescriptions(CArrayObj *actor)
  {
//---
   CLayerDescription *descr;
//---
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
//--- Actor
   actor.Clear();

Como siempre, lo primero que haremos es crear la capa de datos de origen, y justo después le seguirá una capa de normalización. Ya hemos mencionado antes que, para el codificador, los datos de origen serán únicamente los datos históricos y los valores de los indicadores. Esto se reflejará en el tamaño de dichas capas neuronales.

//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.window = 0;
   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;
     }

A continuación, repetiremos íntegramente la arquitectura del codificador del artículo anterior. Constará de un bloque de convolución, al que seguirán tres capas completamente conectadas, y terminará con la capa codificadora de la representación latente del autocodificador variacional. Es una solución poco habitual para un modelo holístico, pero ya hemos hablado de la convención de dividir algoritmos y modelos. Veamos los resultados prácticos.

//--- 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 = 4;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronProofOCL;
   prev_count = descr.count = prev_count;
   descr.window = 4;
   descr.step = 4;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   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 = 4;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronProofOCL;
   prev_count = descr.count = prev_count;
   descr.window = 4;
   descr.step = 4;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.optimization = ADAM;
   descr.activation = TANH;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 128;
   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 = 2 * NSkills;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NSkills;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

La descripción del codificador está completa. Ahora crearemos nuestro Agente. Su arquitectura comenzará con una capa que combinará dos flujos de información. El primer flujo será igual al tamaño de los resultados del codificador; el segundo, al tamaño del vector de descripción de la tarea en cuestión. Nosotros utilizaremos la descripción del estado del balance como vector de la descripción de la tarea.

En la parte teórica hablamos de la necesidad de separar las subtareas. En nuestro esquema simplificado, utilizaremos solo dos subtareas:

  • la búsqueda del punto de entrada en la posición
  • la búsqueda del punto de salida de la posición

En la estructura de la descripción del estado de la cuenta, especificaremos las posiciones abiertas. En consecuencia, si el volumen de las posiciones abiertas es igual a "0", la tarea será la apertura de una posición. Si no, buscaremos un punto de salida. La idea es sencilla y se parece al uso de un vector one-hot. La única diferencia es el volumen de las posiciones abiertas, que rara vez será igual a "1". Al fin y al cabo, utilizamos un lote mínimo y permitimos la apertura simultánea de varias posiciones.

//--- layer 10
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = 256;
   descr.window=prev_count;
   descr.step=AccountDescr;
   descr.optimization = ADAM;
   descr.activation = TANH;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

En la descripción del estado de la cuenta, usamos unidades relativas. Esperamos que su valor se aproxime al de los datos normalizados. Por ello, no utilizaremos aquí la capa de normalización por lotes.

A continuación vendrá un bloque de decisión de dos capas totalmente conectadas y un bloque de función cuantil totalmente parametrizado FQF. Como podemos ver, utilizamos un bloque de toma de decisiones similar en el agente del artículo anterior, donde hablamos de las principales propiedades y características de las soluciones de cada capa neuronal.

//--- layer 11
   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 12
   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 13
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFQF;
   descr.count = NActions;
   descr.window_out = 32;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Tras describir la arquitectura del modelo, procederemos a crear el robot encargado de recopilar la base de datos primaria de ejemplos "GCRL\Research.mq5". El algoritmo de este asesor experto es prácticamente el mismo respecto al artículo anterior, así que nos permitiremos dejar análisis detallado fuera del marco de este artículo. Encontrará el código completo de este asesor en el archivo adjunto. Solo destacaremos brevemente los cambios generados por el método GCRL.

En primer lugar, debemos recordar que una de las deficiencias de los modelos recientes era el mantenimiento prolongado de las posiciones abiertas. Pensando un poco, podemos ver que el vector de descripción del estado de nuestra cuenta incluye el volumen de las posiciones abiertas y los beneficios acumulados para cada dirección, pero no se indica el momento de apertura de las posiciones. Si queremos entrenar a un agente para que controle un proceso concreto, deberemos proporcionarle un punto de referencia adecuado.

El espectro de acciones de nuestro agente solo incluye la opción de cierre de todas las posiciones. Por lo tanto, no veo necesidad de destacar el momento de apertura de las posiciones largas y cortas abiertas. Vamos a introducir una métrica común para todas las posiciones. Al mismo tiempo, querríamos crear un indicador que no solo dependa del tiempo, sino también del volumen de la posición y de las ganancias o pérdidas acumuladas.

Como indicador proponemos usar la suma de los valores absolutos de los beneficios/pérdidas acumulados ponderados en el periodo de la posición abierta. Esto nos permitirá adaptar el indicador a la hora de apertura de la posición, al volumen y a la volatilidad del mercado (indirectamente a través de los beneficios), mientras que el uso del valor absoluto del beneficio nos permitirá excluir la influencia mutuamente absorbente de las posiciones rentables y no rentables. 

 Teniendo en cuenta lo dicho, vamos a ajustar el proceso de descripción del estado de la cuenta que se lleva a cabo en el método OnTick del asesor.

En los 2 primeros elementos de la descripción del estado de la cuenta, almacenaremos el balance y la equidad de la cuenta. Para reducir el volumen de información y mejorar su calidad, no especificaremos los indicadores de margen, debido a su escasa informatividad en el contexto de la tarea actual. Dicho esto, no descartamos su posible incorporación en futuros trabajos.

La hora de apertura de las posiciones se tiene en cuenta en segundos: nosotros trabajaremos con el marco temporal H1. Asimismo, definiremos directamente un multiplicador para ajustar el tiempo de acción de la posición en horas. Aquí también añadiremos una variable para calcular la penalización por mantener una posición utilizando la fórmula anterior. Sin embargo, no queremos que la penalización por mantenimiento supere los ingresos de dicha posición. Para ello, determinaremos que cada hora penalizaremos con 1/10 del beneficio acumulado, mientras que el uso del valor absoluto del beneficio en la fórmula anterior nos permitirá penalizar tanto las posiciones rentables como las no rentables.

Luego guardaremos la hora actual en una variable local e iniciaremos el ciclo de iteración de las posiciones abiertas. En el cuerpo del ciclo calcularemos el volumen de las posiciones abiertas, las ganancias/pérdidas acumuladas para cada dirección, así como la penalización acumulada por mantener una posición.

   sState.account[0] = (float)AccountInfoDouble(ACCOUNT_BALANCE);
   sState.account[1] = (float)AccountInfoDouble(ACCOUNT_EQUITY);
//---
   double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0;
   double position_discount = 0;
   double multiplyer = 1.0 / (60.0 * 60.0 * 10.0);
   int total = PositionsTotal();
   datetime current = TimeCurrent();
   for(int i = 0; i < total; i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      switch((int)PositionGetInteger(POSITION_TYPE))
        {
         case POSITION_TYPE_BUY:
            buy_value += PositionGetDouble(POSITION_VOLUME);
            buy_profit += PositionGetDouble(POSITION_PROFIT);
            break;
         case POSITION_TYPE_SELL:
            sell_value += PositionGetDouble(POSITION_VOLUME);
            sell_profit += PositionGetDouble(POSITION_PROFIT);
            break;
        }
      position_discount -= (current - PositionGetInteger(POSITION_TIME)) * multiplyer*MathAbs(PositionGetDouble(POSITION_PROFIT));
     }
   sState.account[2] = (float)buy_value;
   sState.account[3] = (float)sell_value;
   sState.account[4] = (float)buy_profit;
   sState.account[5] = (float)sell_profit;
   sState.account[6] = (float)position_discount;

Una vez completadas las iteraciones del bucle, almacenaremos los valores resultantes en los elementos correspondientes del array para escribirlos en la base de datos de ejemplos.

Antes de transferir los datos a nuestro modelo, los pasaremos a un campo de unidades relativas.

   State.AssignArray(sState.state);
   Account.Clear();
   float PrevBalance = (Base.Total <= 0 ? sState.account[0] : Base.States[Base.Total - 1].account[0]);
   float PrevEquity = (Base.Total <= 0 ? sState.account[1] : Base.States[Base.Total - 1].account[1]);
   Account.Add((sState.account[0] - PrevBalance) / PrevBalance);
   Account.Add(sState.account[1] / PrevBalance);
   Account.Add((sState.account[1] - PrevEquity) / PrevEquity);
   Account.Add(sState.account[2]);
   Account.Add(sState.account[3]);
   Account.Add(sState.account[4] / PrevBalance);
   Account.Add(sState.account[5] / PrevBalance);
   Account.Add(sState.account[6] / PrevBalance);

Recordemos que, al describir el método de pasada directa, hacíamos hincapié en que el usuario es el responsable de la relevancia de los datos del búfer adicional de datos de origen en la memoria contextual OpenCL. Por lo tanto, tras actualizar el búfer de información del estado de la cuenta, transmitiremos su contenido a la memoria de contexto. Y solo después de eso llamaremos al método de pasada directa de nuestro agente transmitiendo los punteros a ambos búferes de datos.

   if(Account.GetIndex()>=0)
      if(!Account.BufferWrite())
         return;
   if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
      return;

El bloque de muestreo y ejecución de acciones de agentes ha sido transferido de asesores similares sin ningún cambio y omitiremos su descripción aquí.

Al final de la descripción de los cambios en la función OnTick del asesor de recopilación de la base de ejemplos, deberemos decir unas palabras sobre la función de recompensa. Al igual que antes, la base de nuestra función de recompensa será el valor relativo de la variación del balance de la cuenta, pero el método GCRL ofrece recompensas adicionales por alcanzar objetivos locales. En nuestro caso, utilizaremos penalizaciones. Para la tarea de cierre de posiciones, restaremos cada vez el indicador calculado anteriormente de la suma ponderada de los valores absolutos de pérdidas y ganancias acumuladas. De esta forma, penalizaremos al máximo el mantenimiento de posiciones con pérdidas o ganancias significativas acumuladas. Esto debería estimular al agente a cerrar posiciones. Al mismo tiempo, las posiciones con pequeños beneficios acumulados no generarán una gran penalización, y permitirá al agente esperar la acumulación de beneficios.

   float reward = Account[0];
   if((buy_value+sell_value)>0)
     reward+=(float)position_discount;
   else
     reward-=atr;
   if(!Base.Add(sState, act, reward))
      ExpertRemove();
//---
  }

Si no hay posiciones abiertas, incentivaremos al agente para que realice operaciones. En este caso se producirá una penalización igual a la magnitud del valor actual del indicador ATR.

Por lo demás, el algoritmo del asesor experto no ha sufrido ningún cambio. Podrá ver su código completo en el archivo adjunto.

Una vez hemos finalizado el asesor experto encargado de recopilar la base de datos de ejemplos "GCRL\Research.mq5", lo ejecutaremos en el modo de optimización lenta del simulador de estrategias. Vamos a trabajar ahora en el asesor experto "GCRL\StudyActor.mq5".

En este artículo, entrenaremos al agente solo con las acciones y recompensas almacenadas en la base de datos de ejemplos. No calcularemos las recompensas predictivas para otras acciones, como hicimos en el artículo anterior. En su lugar, nos centraremos en entrenar al agente para que construya una política según la tarea que tenga entre manos. Aprovecharemos que nuestra base de ejemplos recoge pasadas de un mismo periodo histórico. Sin embargo, debido a una serie de acciones seleccionadas aleatoriamente durante la fase de recopilación de bases de ejemplos, en cada pasada para un momento histórico obtendremos un conjunto diferente de posiciones abiertas y ganancias/pérdidas acumuladas con diferentes acciones del agente y las recompensas correspondientes. Esto significa que podremos realizar varias pasadas directas e inversas del modelo a partir de un único momento histórico con diferentes tareas locales para el agente. Con esto lograremos el efecto de repetir un momento varias veces y explorar el entorno.

No gastaremos recursos ni tiempo buscando estados históricos idénticos, y aprovecharemos la estacionariedad de los datos históricos. Después de todo, se ve con facilidad que todos nuestros agentes de prueba han partido del mismo momento histórico, "superando" el mismo número de pasos (velas). La excepción podría ser la detención de las pruebas por stop-out. Pero siempre cada paso N en todas las pasadas se corresponderá con un único momento histórico. En eso vamos a basar nuestro entrenamiento de agentes.

Como siempre, el entrenamiento del modelo se realizará en la función Train del asesor experto "GCRL\StudyActor.mq5". Al principio de la función, definiremos el número de pasadas de nuestra base de datos de ejemplos. A continuación, organizaremos el primer ciclo en el que encontraremos la pasada con el máximo número de pasos. No almacenaremos una pasada específica, solo el número de pasos. La utilizaremos al muestrear un momento histórico concreto para el entrenamiento.

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   int total_steps = 0;
   for(int tr = 0; tr < total_tr; tr++)
     {
      if(Buffer[tr].Total > total_steps)
         total_steps = Buffer[tr].Total;
     }

A continuación, organizaremos un sistema de dos bucles anidados. El primero según el número de iteraciones de entrenamiento del modelo. En el cuerpo de este ciclo, muestrearemos un momento histórico para una iteración de entrenamiento determinada. En el ciclo anidado, iteraremos todas las pasadas de las que dispongamos y comprobaremos en ellas el estado muestreado.

   uint ticks = GetTickCount();
//---
   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (total_steps - 2));
      for(int tr = 0; tr < total_tr; tr++)
        {
         if(i >= (Buffer[tr].Total - 1))
            continue;

Si se da esta condición, entrenaremos al Agente utilizando los datos almacenados, y pasaremos a la siguiente pasada.

         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);
         //---
         if(Account.GetIndex()>=0)
            Account.BufferWrite();
         if(!Actor.feedForward(GetPointer(State), 1, false,GetPointer(Account)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            ExpertRemove();
            break;
           }
         //---
      ActorResult = vector<float>::Zeros(NActions);
      ActorResult[Buffer[tr].Actions[i]] = Buffer[tr].Revards[i];
      Result.AssignArray(ActorResult);
      if(!Actor.backProp(Result, 0, NULL, 1, false,GetPointer(Account),GetPointer(Gradient)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }
         if(GetTickCount() - ticks > 500)
           {
            string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Actor", 
                                       iter * 100.0 / (double)(Iterations),
                                       Actor.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

Así, cada estado individual será reproducido por nuestro agente según el número de pasadas con diferente configuración local de la subtarea. Con ello, queremos mostrar al agente que sus acciones deben tener en cuenta no solo el estado del entorno, sino también la subtarea local. Querría recordar al lector que al recopilar la base de ejemplos, hemos añadido una penalización por no completar la tarea local en cada paso. Y ahora en cada pasada tendremos diferentes recompensas para el mismo momento histórico, que se corresponderán con las subtareas locales de las pasadas.

En todo lo demás, el código del asesor permanecerá inalterado. El código completo de todos los programas utilizados en el artículo se encuentra en el archivo adjunto.


3. Simulación

Una vez finalizado el trabajo con los asesores, vamos a entrenar el modelo y comprobar los resultados. No cambiaremos los parámetros de entrenamiento del modelo. Como antes, el modelo se entrenará con datos históricos de EURUSD y el marco temporal H1. Los parámetros de los indicadores se utilizarán por defecto. Nuestro agente ha sido entrenado con 4 meses de datos de 2023. Hemos probado la calidad del entrenamiento y la capacidad del Agente para operar con los nuevos datos en el intervalo del 1 al 18 de junio de 2023.

Los resultados de la prueba se muestran en las siguientes capturas de pantalla. Como podemos ver, el modelo ha sido capaz de obtener beneficios durante la prueba. En el gráfico de balance, hay fases alcistas y movimientos laterales. La ausencia de fallos es un punto agradable. En total, durante los 12 días de negociación, el factor de beneficio ha sido de 2,2 y el factor de recuperación de 1,47. El asesor ha realizado 220 transacciones. Más del 53% se han cerrado con beneficios. Al mismo tiempo, la posición media rentable es casi dos veces superior a la posición media no rentable. Desafortunadamente, el asesor solo ha abierto posiciones largas. Ya hemos visto antes este tipo de efecto, y el planteamiento utilizado no ha resuelto este problema.

Gráfico de pruebas

Resultados de las pruebas

Tiempo de mantenimiento de la posición

Entre los aspectos positivos de la utilización del método GCRL tenemos la reducción del tiempo de mantenimiento de la posición. Durante la prueba, el tiempo máximo de mantenimiento de la posición ha sido de 21 horas y 15 minutos. El tiempo medio de mantenimiento de una posición ha sido de 5 horas y 49 minutos. Permítame recordarle que hemos establecido una penalización de 1/10 del beneficio acumulado por cada hora de mantenimiento por no cerrar la posición. Es decir, tras 10 horas de mantenimiento, la penalización superaba los ingresos de la posición.


Conclusión

En este artículo, hemos introducido el aprendizaje por refuerzo dirigido a objetivos (Goal-conditioned reinforcement learning, GCRL). Una característica de este método es la introducción de subtareas locales y recompensas por su consecución. Esto nos permite dividir una tarea global en varias más pequeñas e ir paso a paso hacia su consecución.

Este enfoque presenta una serie de ventajas. Reduce la complejidad del aprendizaje dividiendo la tarea en componentes más pequeños y manejables. Esto simplifica el proceso de toma de decisiones y mejora la velocidad de aprendizaje del agente.

Además, el GCRL aumenta la capacidad del agente de generalizar. A medida que el agente aprenda a resolver distintas subtareas localizadas, desarrollará un conjunto de habilidades y estrategias que podrá aplicar en distintos contextos.

Y por último, el GCRL ofrece flexibilidad a la hora de definir metas y objetivos para el agente. Podemos seleccionar y modificar las subtareas locales según nuestras necesidades y las condiciones del entorno. Esto permite al agente adaptarse a distintas situaciones y utilizar sus habilidades con eficacia para alcanzar sus objetivos.

Hemos implementado el método presentado usando MQL5. Asimismo, hemos entrenado el modelo y comprobado los resultados del entrenamiento con datos ajenos a la muestra de entrenamiento. Los resultados de las pruebas han mostrado que todavía hay problemas sin resolver, en particular, el asesor experto ha abierto posiciones solo en una dirección. Al mismo tiempo, eso no le ha impedido obtener beneficios durante la prueba.

También cabe destacar la disminución del tiempo de mantenimiento de la posición, lo cual confirma el trabajo del Agente en la resolución de dos tareas locales: abrir y cerrar una posición.

En general, los resultados de las pruebas son positivos y permiten utilizar el método para encontrar nuevas soluciones.


Enlaces

  • Variational Empowerment as Representation Learning for Goal-Based Reinforcement Learning
  • Redes neuronales: así de sencillo (Parte 43): Dominando las habilidades sin función de recompensa
  • Redes neuronales: así de sencillo (Parte 44): Estudiamos las habilidades de forma dinámica
  • Redes neuronales: así de sencillo (Parte 45): Entrenando habilidades de exploración de estados

  • Programas usados en el artículo

    # Nombre Tipo Descripción
    1 Research.mq5 Asesor Asesor de recopilación de datos
    StudyActor.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 FQF.mqh Biblioteca de clases Biblioteca de clases de organización de modelos completamente parametrizada
    6 NeuroNet.mqh Biblioteca de clases Biblioteca de clases para crear una red neuronal
    7 NeuroNet.cl Biblioteca Biblioteca de código de programa OpenCL
    8 VAE.mqh
    Biblioteca de clases
    Biblioteca de clases de capa latente del autocodificador variacional

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

    Archivos adjuntos |
    MQL5.zip (602.06 KB)
    Evaluación de modelos ONNX usando métricas de regresión Evaluación de modelos ONNX usando métricas de regresión
    La regresión es una tarea que consiste en predecir un valor real a partir de un ejemplo sin etiquetar. Para evaluar la precisión de las predicciones de los modelos de regresión, se usan las llamadas métricas de regresión.
    Iniciamos MetaTrader VPS por primera vez: instrucciones paso a paso Iniciamos MetaTrader VPS por primera vez: instrucciones paso a paso
    Todo aquel que utilice asesores comerciales o suscripciones a señales, tarde o temprano necesitará un hosting 24/7 fiable para su plataforma comercial. Le recomendamos utilizar MetaTrader VPS por varios motivos. Podrá pagar y gestionar el servicio a través de su cuenta en MQL5.community.
    Posibilidades de ChatGPT de OpenAI en el marco de desarrollo de MQL4 y MQL5 Posibilidades de ChatGPT de OpenAI en el marco de desarrollo de MQL4 y MQL5
    En este artículo, experimentaremos y analizaremos la inteligencia artificial ChatGPT de OpenAI para comprender sus capacidades y reducir el tiempo y la intensidad del trabajo en el desarrollo de nuestros asesores, indicadores y scripts. Asimismo, repasaremos rápidamente esta tecnología e intentaremos ver cómo usarla correctamente para programar en MQL4 y MQL5.
    Estrategia comercial de reversión a la media simple Estrategia comercial de reversión a la media simple
    La reversión a la media es una técnica de negociación de contratendencia en la que el tráder espera que el precio regrese a algún tipo de equilibrio, que generalmente se mide usando una media u otro indicador estadístico de la tendencia promediada.