English Русский Deutsch 日本語 Português
preview
Redes neuronales: así de sencillo (Parte 74): Predicción de trayectorias con adaptación

Redes neuronales: así de sencillo (Parte 74): Predicción de trayectorias con adaptación

MetaTrader 5Sistemas comerciales | 30 julio 2024, 10:27
165 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introducción

La elaboración de una estrategia de negociación es inseparable del análisis de la situación del mercado y de la previsión del movimiento más probable de un instrumento financiero. Este movimiento suele estar correlacionado con otros activos financieros e indicadores macroeconómicos. Esto puede compararse con el movimiento del transporte, donde cada vehículo sigue su propio destino individual. Sin embargo, sus acciones en la carretera están interconectadas hasta cierto punto y están estrictamente reguladas por las normas de tráfico. Además, debido a la percepción individual de la situación de la carretera por parte de los conductores de vehículos, sigue habiendo una parte de estocasticidad en las carreteras.

Del mismo modo, en el mundo de las finanzas, la formación de precios está sujeta a ciertas reglas. Sin embargo, la estocasticidad de la oferta y la demanda creada por los participantes en el mercado conduce a la estocasticidad del precio. Quizá por eso muchos métodos de previsión de trayectorias utilizados en el campo de la navegación dan buenos resultados a la hora de predecir futuros movimientos de precios.

En este artículo quiero presentaros un método para predecir conjuntamente de forma efectiva las trayectorias de todos los agentes en la escena con aprendizaje dinámico de pesos ADAPT, que fue propuesto para resolver problemas en el campo de la navegación de vehículos autónomos. El método se presentó por primera vez en el artículo "ADAPT: Efficient Multi-Agent Trajectory Prediction with Adaptation".


1. El algoritmo ADAPT

El método ADAPT analiza las trayectorias pasadas de todos los agentes en el mapa de la escena y predice sus trayectorias futuras. Una representación vectorizada de la escena modela distintos tipos de interacciones entre los agentes y el mapa para obtener la mejor representación posible de los agentes. De forma similar a los enfoques de fijación de objetivos, el algoritmo predice primero un posible conjunto de puntos finales. A continuación, cada punto final se refina para tener en cuenta el desplazamiento del agente en la escena. Después se predice la trayectoria completa determinada en los puntos finales.

Los autores del método estabilizan el entrenamiento del modelo separando la predicción del punto final y de la trayectoria con la detención del gradiente. El modelo presentado por los autores utiliza pequeños perceptrones multicapa para predecir los puntos finales y las trayectorias con el fin de mantener baja la complejidad del modelo.

El método propuesto por los autores utiliza una representación vectorizada para codificar el mapa y los agentes de forma estructurada. Esta representación crea un grafo conectado para cada elemento de la escena de forma independiente, dadas las trayectorias pasadas de los agentes y el mapa de la escena. Los autores del método proponen utilizar dos subgrafos distintos para los agentes y los objetos del mapa.

ADAPT permite simular varios tipos de interacciones entre elementos de la escena. Los autores propusieron modelar cuatro tipos de relaciones: agente-carril (AL), carril-carril (LL), carril-agente (LA) y agente-agente (AA).

Las interdependencias se analizan mediante bloques de atención de varias cabezas, de forma similar a AutoBots. Sin embargo, los bloques de autoatención (AA, LL) se complementan con bloques de relación cruzada (AL, LA) mediante un codificador de atención cruzada. Cada interacción se modela secuencialmente, y el proceso se repite L veces.

De este modo, las características intermedias pueden actualizarse en cada iteración y, a continuación, las características actualizadas se utilizan para calcular la atención en la siguiente iteración. Cada elemento de la escena puede ser informado por diferentes tipos de interacciones L veces.

Para predecir el punto final en el caso de utilizar una representación centrada en el agente, es posible utilizar MLP, que puede ser preferible debido a sus ventajas en la predicción de un solo agente. Pero cuando se utiliza una representación centrada en la escena, se recomienda utilizar un cabezal adaptativo con pesos dinámicos, que es más eficaz en la predicción multiagente de los puntos finales de la trayectoria.

Tras recibir el punto final de cada agente, el algoritmo interpola las coordenadas futuras entre el punto inicial y el punto final utilizando MLP. Aquí "desacoplamos" los puntos finales para garantizar que las actualizaciones de peso para la predicción de la trayectoria completa se desacoplan de la predicción de los puntos finales. Del mismo modo, predecimos la probabilidad de cada trayectoria utilizando puntos finales desacoplados.

Para entrenar los modelos, predecimos trayectorias K y aplicamos pérdida de variedad para capturar escenarios futuros multimodales. El gradiente de error se retropropaga sólo a través de la trayectoria más precisa. Dado que predecimos las trayectorias completas condicionados por los puntos finales, la precisión de la predicción de los puntos finales es esencial para la predicción de la trayectoria completa. Por lo tanto, los autores del método aplican una función de pérdida independiente para mejorar la predicción del punto final. El último elemento de la función de pérdida original es la pérdida de clasificación para orientar las probabilidades asignadas a las trayectorias.

A continuación se ofrece la visualización original del método presentada por los autores del artículo.

Visualización del método por sus autores


2. Implementación con MQL5

Lo anterior es una descripción teórica bastante condensada del método ADAPT, lo que se debe a la gran cantidad de trabajo que hay por delante y a las limitaciones del formato del artículo. Algunos aspectos se tratarán con más detalle durante nuestra aplicación de los planteamientos propuestos. Tenga en cuenta que nuestra aplicación diferirá en muchos aspectos del método original. He aquí las diferencias.

En primer lugar, no utilizaremos tensores separados para codificar agentes y polilíneas. Los agentes en nuestro caso son las características analizadas. Cada rasgo se caracteriza por 2 parámetros: valor y tiempo. Durante el periodo de tiempo analizado, se mueve en una trayectoria determinada. Aunque cada indicador tiene su propio rango de valores, en realidad no tenemos un mapa de la escena. Sin embargo, tenemos una instantánea de la escena en un único momento con todos los agentes en ella. Técnicamente, podemos sustituir una entidad por otra. Parece que no hay necesidad de crear un tensor separado para esto, ya que es una mirada a los mismos datos en otra dimensión. Por lo tanto, utilizaremos un tensor con diferentes acentos.

2.1 Bloque de relaciones cruzadas

Además, pensando en la forma de aplicar los planteamientos propuestos, me di cuenta de que no disponía de la aplicación del bloque de relaciones cruzadas. Anteriormente, nuestras tareas eran de naturaleza más autorregresiva. Para este tipo de tareas, el uso de un bloque de autoatención era bastante adecuado. Esta vez tenemos que analizar la relación entre varias entidades. Por lo tanto, implementaremos una nueva capa neuronal CNeuronMH2AttentionOCL. Los algoritmos de aplicación de la clase se toman prestados en gran medida del bloque de autoatención. La diferencia es que las entidades Query, Key y Value se formarán a partir de diferentes dimensiones del tensor de datos de origen. Esto requirió modificaciones sustanciales. Por lo tanto, decidí crear una nueva clase en lugar de modernizar la existente.

class CNeuronMH2AttentionOCL       :  public CNeuronBaseOCL
  {
protected:
   uint              iHeads;                                      ///< Number of heads
   uint              iWindow;                                     ///< Input window size
   uint              iUnits;                                      ///< Number of units
   uint              iWindowKey;                                  ///< Size of Key/Query window
   //---
   CNeuronConvOCL    Q_Embedding;
   CNeuronConvOCL    KV_Embedding;
   CNeuronTransposeOCL Transpose;
   int               ScoreIndex;
   CNeuronBaseOCL    MHAttentionOut;
   CNeuronConvOCL    W0;
   CNeuronBaseOCL    AttentionOut;
   CNeuronConvOCL    FF[2];
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   virtual bool      attentionOut(void);
   //---
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);
   virtual bool      AttentionInsideGradients(void);
public:
   /** Constructor */
                     CNeuronMH2AttentionOCL(void);
   /** Destructor */~CNeuronMH2AttentionOCL(void) {};
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                          uint window, uint window_key, uint heads, 
                          uint units_count, ENUM_OPTIMIZATION optimization_type, 
                          uint batch);
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer);
   //---
   virtual int       Type(void)   const   {  return defNeuronMH2AttentionOCL;   }
   //--- methods for working with files
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   virtual CLayerDescription* GetLayerInfo(void);
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

En el constructor de la clase, sólo establecemos valores iniciales para las variables locales.

CNeuronMH2AttentionOCL::CNeuronMH2AttentionOCL(void)  :  iHeads(0),
                                                         iWindow(0),
                                                         iUnits(0),
                                                         iWindowKey(0)
  {
   activation = None;
  }

El destructor de la clase permanece vacío.

La inicialización de los objetos de la clase CNeuronMH2AttentionOCL se implementa en el método Init. Al principio del método, llamamos a un método relevante de la clase padre, en el que se comprueban los datos recibidos del programa externo y se inicializan los objetos heredados.

bool CNeuronMH2AttentionOCL::Init(uint numOutputs, uint myIndex, 
                                  COpenCLMy *open_cl, uint window,
                                  uint window_key, uint heads, 
                                  uint units_count, 
                                  ENUM_OPTIMIZATION optimization_type, 
                                  uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count,
                                                       optimization_type, batch))
      return false;

Guardamos los valores de los parámetros principales.

   iWindow = fmax(window, 1);
   iWindowKey = fmax(window_key, 1);
   iUnits = fmax(units_count, 1);
   iHeads = fmax(heads, 1);
   activation = None;

Dado que analizaremos los datos de origen en diferentes dimensiones, tendremos que transponer el tensor de los datos de origen.

   if(!Transpose.Init(0, 0, OpenCL, iUnits, iWindow, optimization_type, batch))
      return false;
   Transpose.SetActivationFunction(None);

Para generar las entidades Query, Key y Value utilizaremos capas convolucionales. El número de filtros es igual a la dimensión del vector de una entidad. Query se generará a partir de una dimensión del tensor de datos original, mientras que Key y Value se generarán a partir de otra. Por lo tanto, crearemos 2 capas (una para cada dimensión).

   if(!Q_Embedding.Init(0, 0, OpenCL, iWindow, iWindow, iWindowKey * iHeads, iUnits, 
                                                                     optimization_type, batch))
      return false;
   Q_Embedding.SetActivationFunction(None);

   if(!KV_Embedding.Init(0, 0, OpenCL, iUnits, iUnits, 2 * iWindowKey * iHeads, iWindow, 
                                                                     optimization_type, batch))
      return false;
   KV_Embedding.SetActivationFunction(None);

Sólo necesitamos la matriz de coeficientes de dependencia en el lado del contexto OpenCL. Para ahorrar recursos, creamos un búfer sólo en el contexto. En el lado del programa principal, sólo se almacena un puntero a la memoria intermedia.

   ScoreIndex = OpenCL.AddBuffer(sizeof(float) * iUnits * iWindow * iHeads, CL_MEM_READ_WRITE);
   if(ScoreIndex == INVALID_HANDLE)
      return false;

A continuación vienen objetos similares al bloque de autoatención. Aquí creamos una capa de salida de atención múltiple.

//---
   if(!MHAttentionOut.Init(0, 0, OpenCL, iWindowKey * iUnits * iHeads, optimization_type, batch))
      return false;
   MHAttentionOut.SetActivationFunction(None);

Capa de reducción de la dimensionalidad.

   if(!W0.Init(0, 0, OpenCL, iWindowKey * iHeads, iWindowKey * iHeads, iWindow, iUnits, 
                                                                      optimization_type, batch))
      return false;
   W0.SetActivationFunction(None);

A la salida del bloque de atención, resumimos los resultados obtenidos con los datos originales en una capa separada.

   if(!AttentionOut.Init(0, 0, OpenCL, iWindow * iUnits, optimization_type, batch))
      return false;
   AttentionOut.SetActivationFunction(None);

Le sigue un bloque de MLP lineales.

   if(!FF[0].Init(0, 0, OpenCL, iWindow, iWindow, 4 * iWindow, iUnits, optimization_type, batch))
      return false;
   if(!FF[1].Init(0, 0, OpenCL, 4 * iWindow, 4 * iWindow, iWindow, iUnits, optimization_type, 
                                                                                          batch))
      return false;
   for(int i = 0; i < 2; i++)
      FF[i].SetActivationFunction(None);

Para evitar la copia innecesaria de gradientes de error desde el buffer de la clase padre al buffer de la capa interna durante el paso de retropropagación, sustituiremos los punteros por objetos.

   Gradient.BufferFree();
   delete Gradient;
   Gradient = FF[1].getGradient();
//---
   return true;
  }

Pasando a la descripción del pase de avance, tenga en cuenta que, a pesar del gran número de capas internas que implementan determinadas funciones, tenemos que analizar directamente las relaciones. Aunque matemáticamente esta funcionalidad es completamente idéntica al bloque de autoatención, nos enfrentamos al hecho de que el número de entidades Query será muy probablemente distinto del número de entidades Key y Value, lo que da lugar a una matriz Puntuación rectangular y viola la lógica de los núcleos creados anteriormente. Por lo tanto, vamos a crear nuevos núcleos.

Para el paso feed-forward, creamos el núcleo MH2AttentionOut. El núcleo recibirá en parámetros 4 punteros a búferes de datos y la dimensión vectorial de un elemento de entidad. Todas nuestras entidades tienen el mismo tamaño de elementos.

__kernel void MH2AttentionOut(__global float *q,      ///<[in] Matrix of Querys
                              __global float *kv,     ///<[in] Matrix of Keys
                              __global float *score,  ///<[out] Matrix of Scores
                              __global float *out,    ///<[out] Matrix of Scores
                              int dimension           ///< Dimension of Key
                             )
  {
//--- init
   const int q_id = get_global_id(0);
   const int k = get_global_id(1);
   const int h = get_global_id(2);
   const int qunits = get_global_size(0);
   const int kunits = get_global_size(1);
   const int heads = get_global_size(2);

Lanzaremos el núcleo en un espacio de tareas de hasta 3 dimensiones para elementos Query, Key y cabezas de atención. Además, todos los hilos dentro de un elemento Query y una cabeza de atención se combinarán en grupos, lo que se debe a la necesidad de normalizar la matriz Puntuación con la función SoftMax dentro de los grupos especificados.

En el cuerpo del núcleo, primero identificamos cada hilo y determinamos el desplazamiento en los búferes de datos globales.

   const int shift_q = dimension * (q_id + qunits * h);
   const int shift_k = dimension * (k + kunits * h);
   const int shift_v = dimension * (k + kunits * (heads + h));
   const int shift_s = q_id * kunits * heads + h * kunits + k;

También definimos otras constantes y declaramos un array local.

   const uint ls = min((uint)get_local_size(1), (uint)LOCAL_ARRAY_SIZE);
   float koef = sqrt((float)dimension);
   if(koef < 1)
      koef = 1;
   __local float temp[LOCAL_ARRAY_SIZE];

Después calculamos la matriz del coeficiente de dependencia.

//--- sum of exp
   uint count = 0;
   if(k < ls)
      do
        {
         if((count * ls) < (kunits - k))
           {
            float sum = 0;
            for(int d = 0; d < dimension; d++)
               sum = q[shift_q + d] * kv[shift_k + d];
            sum = exp(sum / koef);
            if(isnan(sum))
               sum = 0;
            temp[k] = (count > 0 ? temp[k] : 0) + sum;
           }
         count++;
        }
      while((count * ls + k) < kunits);
   barrier(CLK_LOCAL_MEM_FENCE);
   count = min(ls, (uint)kunits);
//---
   do
     {
      count = (count + 1) / 2;
      if(k < ls)
         temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : 0);
      if(k + count < ls)
         temp[k + count] = 0;
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
//--- score
   float sum = temp[0];
   float sc = 0;
   if(sum != 0)
     {
      for(int d = 0; d < dimension; d++)
         sc = q[shift_q + d] * kv[shift_k + d];
      sc = exp(sc / koef);
      if(isnan(sc))
         sc = 0;
     }
   score[shift_s] = sc;
   barrier(CLK_LOCAL_MEM_FENCE);

También calculamos nuevos valores de la entidad Query teniendo en cuenta los coeficientes de dependencia para cada elemento del vector por separado.

//--- out
   for(int d = 0; d < dimension; d++)
     {
      uint count = 0;
      if(k < ls)
         do
           {
            if((count * ls) < (kunits - k))
              {
               float sum = q[shift_q + d] * kv[shift_v + d] * 
                                (count == 0 ? sc : score[shift_s + count * ls]);
               if(isnan(sum))
                  sum = 0;
               temp[k] = (count > 0 ? temp[k] : 0) + sum;
              }
            count++;
           }
         while((count * ls + k) < kunits);
      barrier(CLK_LOCAL_MEM_FENCE);
      //---
      count = min(ls, (uint)kunits);
      do
        {
         count = (count + 1) / 2;
         if(k < ls)
            temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : 0);
         if(k + count < ls)
            temp[k + count] = 0;
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      while(count > 1);
      //---
      out[shift_q + d] = temp[0];
     }
  }

A continuación, creamos un nuevo núcleo para implementar la funcionalidad de retropropagación MH2AttentionInsideGradients. También ejecutaremos este núcleo en un espacio de tareas tridimensional.

En los parámetros del núcleo, pasamos 6 punteros a búferes de datos. Incluyen tampones de gradiente de error para todas las entidades.

__kernel void MH2AttentionInsideGradients(__global float *q, __global float *q_g,
                                          __global float *kv, __global float *kv_g,
                                          __global float *scores,
                                          __global float *gradient,
                                          int kunits)
  {
//--- init
   const int q_id = get_global_id(0);
   const int d = get_global_id(1);
   const int h = get_global_id(2);
   const int qunits = get_global_size(0);
   const int dimension = get_global_size(1);
   const int heads = get_global_size(2);

En el cuerpo del núcleo, como siempre, identificamos el hilo y creamos las constantes necesarias.

   const int shift_q = dimension * (q_id + qunits * h) + d;
   const int shift_k = dimension * (q_id + kunits * h) + d;
   const int shift_v = dimension * (q_id + kunits * (heads + h)) + d;
   const int shift_s = q_id * kunits * heads + h * kunits;
   const int shift_g = h * qunits * dimension + d;
   float koef = sqrt((float)dimension);
   if(koef < 1)
      koef = 1;

Primero calculamos los gradientes de error para la entidad Value. Para ello, simplemente multiplicamos el vector de gradientes de error de la salida del bloque de atención por los coeficientes de dependencia correspondientes.

//--- Calculating Value's gradients
   int step_score = q_id * kunits * heads;
   for(int v = q_id; v < kunits; v += qunits)
     {
      int shift_score = h * kunits + v;
      float grad = 0;
      for(int g = 0; g < qunits; g++)
         grad += gradient[shift_g + g * dimension] * scores[shift_score + g * step_score];
      kv_g[shift_v + v * dimension]=grad;
     }

A continuación, calculamos los gradientes de error para la entidad Query. Esta vez necesitamos primero calcular el gradiente de error sobre los elementos de la matriz de coeficientes de dependencia, teniendo en cuenta la derivada de la función SoftMax. A continuación, debe multiplicarse por el elemento correspondiente del tensor Key.

//--- Calculating Query's gradients
   float grad = 0;
   float out_g = gradient[shift_g + q_id * dimension];
   int shift_val = (heads + h) * kunits * dimension + d;
   int shift_key = h * kunits * dimension + d;
   for(int k = 0; k < kunits; k++)
     {
      float sc_g = 0;
      float sc = scores[shift_s + k];
      for(int v = 0; v < kunits; v++)
         sc_g += scores[shift_s + v] * out_g * kv[shift_val + v * dimension] * 
                                                        ((float)(k == v) - sc);
      grad += sc_g * kv[shift_key + k * dimension];
     }
   q_g[shift_q] = grad / koef;

Del mismo modo, calculamos el gradiente de error para la entidad Key. Sin embargo, esta vez calculamos los gradientes de error de los coeficientes de dependencia a lo largo de la columna del tensor correspondiente.

//--- Calculating Key's gradients
   for(int k = q_id; k < kunits; k += qunits)
     {
      int shift_score = h * kunits + k;
      int shift_val = (heads + h) * kunits * dimension + d;
      grad = 0;
      float val = kv[shift_v];
      for(int scr = 0; scr < qunits; scr++)
        {
         float sc_g = 0;
         int shift_sc = scr * kunits * heads;
         float sc = scores[shift_sc + k];
         for(int v = 0; v < kunits; v++)
            sc_g += scores[shift_sc + v] * gradient[shift_g + scr * dimension] * val * 
                                                                ((float)(k == v) - sc);
         grad += sc_g * q[shift_q + scr * dimension];
        }
      kv_g[shift_k + k * dimension] = grad / koef;
     }
  }

Después de construir el algoritmo en el lado del contexto OpenCL, volvemos a nuestra clase para organizar el proceso en el lado del programa principal. En primer lugar, veamos el método de avance (feed-forward). De forma similar a los métodos correspondientes para otras capas neuronales, en los parámetros recibimos un puntero a la capa neuronal anterior, que proporciona los datos de origen.

bool CNeuronMH2AttentionOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
//---
   if(!Q_Embedding.FeedForward(NeuronOCL))
      return false;

Sin embargo, no comprobamos la pertinencia del puntero recibido. En su lugar, llamamos al método feed-forward de la capa interna Q_Embedding para crear un tensor de entidades Query, pasándole el puntero resultante. En el cuerpo del método especificado, todos los controles necesarios ya están implementados y no necesitamos implementarlos de nuevo.

A continuación, generaremos las entidades Key y Value. Como ya se ha mencionado, para ellos utilizamos una dimensión diferente del tensor de datos original. Por lo tanto, primero transponemos la matriz de datos de origen y, a continuación, llamamos al método feed-forward de la capa interna correspondiente.

   if(!Transpose.FeedForward(NeuronOCL) || !KV_Embedding.FeedForward(NeuronOCL))
      return false;

Las llamadas al núcleo MH2AttentionOut se implementarán en un método independiente attentionOut.

   if(!attentionOut())
      return false;

Comprimimos el tensor de resultados de atención multicabezal hasta el tamaño de los datos originales.

   if(!W0.FeedForward(GetPointer(MHAttentionOut)))
      return false;

A continuación, añadimos los valores obtenidos a los datos originales y los normalizamos. El método SumAndNormilize se hereda de la clase padre.

//---
   if(!SumAndNormilize(W0.getOutput(), NeuronOCL.getOutput(), AttentionOut.getOutput(), iWindow))
      return false;

Al final del bloque de atención, pasamos los datos por MLP.

   if(!FF[0].FeedForward(GetPointer(AttentionOut)))
      return false;
   if(!FF[1].FeedForward(GetPointer(FF[0])))
      return false;

Vuelve a sumar los valores y normalízalos.

   if(!SumAndNormilize(FF[1].getOutput(), AttentionOut.getOutput(), Output, iWindow))
      return false;
//---
   return true;
  }

Para completar la imagen del algoritmo feed-forward, consideremos el método attentionOut. El método no recibe parámetros y sólo funciona con objetos internos de la clase. Por lo tanto, en el cuerpo del método sólo comprobamos la relevancia del puntero al contexto OpenCL.

bool CNeuronMH2AttentionOCL::attentionOut(void)
  {
   if(!OpenCL)
      return false;

A continuación, crearemos las matrices de espacio de tareas y offset. Como se ha comentado al construir el núcleo, creamos un espacio de problemas tridimensional con un grupo local a lo largo de la segunda dimensión.

   uint global_work_offset[3] = {0};
   uint global_work_size[3] = {iUnits, iWindow, iHeads};
   uint local_work_size[3] = {1, iWindow, 1};

Pasamos los parámetros necesarios al núcleo.

   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_q, 
                                                       Q_Embedding.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, 
                                                            GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_kv, 
                                                       KV_Embedding.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, 
                                                             GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_score, ScoreIndex))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, 
                                                             GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_out, 
                                                       MHAttentionOut.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, 
                                                              GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_MH2AttentionOut, def_k_mh2ao_dimension, (int)iWindowKey))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, 
                                                              GetLastError(), __LINE__);
      return false;
     }

A continuación, coloque el núcleo en la cola de ejecución.

   if(!OpenCL.Execute(def_k_MH2AttentionOut, 3, global_work_offset, global_work_size, 
                                                                    local_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }
//---
   return true;
  }

Hemos implementado el proceso de paso hacia adelante (feed-forward), tanto en el lado del programa principal como en el lado del contexto de <i>OpenCL</i>. A continuación, tenemos que organizar el proceso de retropropagación. Para implementar el algoritmo en el lado del contexto OpenCL, ya hemos creado el núcleo MH2AttentionInsideGradients. Ahora necesitamos crear el método AttentionInsideGradients para llamar a este núcleo. No pasaremos nada en los parámetros al método, de forma similar al método feed-forward correspondiente.

bool CNeuronMH2AttentionOCL::AttentionInsideGradients(void)
  {
   if(!OpenCL)
      return false;

En el cuerpo del método comprobamos la relevancia del puntero al contexto OpenCL. A continuación, creamos matrices que indican la dimensión del espacio de tareas y los desplazamientos en él.

   uint global_work_offset[3] = {0};
   uint global_work_size[3] = {iUnits, iWindowKey, iHeads};

Pasa los parámetros necesarios al núcleo.

   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_q, 
                                                            Q_Embedding.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                 __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_qg, 
                                                            Q_Embedding.getGradientIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                 __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_kv, 
                                                            KV_Embedding.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                  __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_kvg, 
                                                           KV_Embedding.getGradientIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                  __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_score, 
                                                                                ScoreIndex))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                  __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_outg,
                                                         MHAttentionOut.getGradientIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                  __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_MH2AttentionInsideGradients, def_k_mh2aig_kunits, (int)iWindow))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), 
                                                                                  __LINE__);
      return false;
     }

Coloca el núcleo en la cola de ejecución.

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

En general, se trata de un algoritmo estándar para este tipo de tareas. Y todo el algoritmo para distribuir el gradiente de error dentro de nuestra capa está descrito por el método calcInputGradients. En los parámetros, el método recibe un puntero al objeto de la capa anterior al que debe pasarse el gradiente de error.

bool CNeuronMH2AttentionOCL::calcInputGradients(CNeuronBaseOCL *prevLayer)
  {
   if(!FF[1].calcInputGradients(GetPointer(FF[0])))
      return false;

En el cuerpo del método, propagamos alternativamente el gradiente de error desde la salida del bloque a la capa anterior. Como recordarás, al inicializar la clase, sustituimos el puntero por el búfer del gradiente de error. Y la capa posterior escribía el gradiente de error directamente a la última capa del MLP interno. A partir de ahí propagaremos el gradiente de error al nivel de salida del bloque de atención.

   if(!FF[0].calcInputGradients(GetPointer(AttentionOut)))
      return false;

En este nivel, añadimos los resultados del bloque de atención a los datos iniciales. Del mismo modo, recogemos un gradiente de 2 direcciones.

   if(!SumAndNormilize(FF[1].getGradient(), AttentionOut.getGradient(), W0.getGradient(), 
                                                                           iWindow, false))
      return false;

A continuación, propagamos el gradiente de error a través de las cabezas de atención.

   if(!W0.calcInputGradients(GetPointer(MHAttentionOut)))
      return false;

Propagar el gradiente de error a las entidades.

   if(!AttentionInsideGradients())
      return false;

Propagamos el gradiente de error de Key y Value a la capa de transposición. En el paso de avance, transponemos la matriz de datos de origen. Con el gradiente de error, tenemos que hacer la operación inversa.

   if(!KV_Embedding.calcInputGradients(GetPointer(Transpose)))
      return false;

A continuación tenemos que transferir el gradiente de error de todas las entidades a la capa anterior.

   if(!Q_Embedding.calcInputGradients(prevLayer))
      return false;

Tenga en cuenta aquí que el gradiente de error va a la capa anterior de 4 hilos:

  • Query (Consulta)
  • Key (Clave)
  • Value (Valor)
  • Eludir el bloqueo de la atención.

Sin embargo, nuestros métodos de capa interna, al pasar el gradiente de error, borran los datos registrados anteriormente. Por lo tanto, habiendo recibido el gradiente de error de Query, lo añadimos al gradiente de error a la salida del bloque de atención en el buffer de la capa interna.

   if(!SumAndNormilize(prevLayer.getGradient(), W0.getGradient(), AttentionOut.getGradient(), 
                                                                              iWindow, false))
      return false;

Y tras recibir los datos de Key y Value, sumamos todos los hilos.

   if(!Transpose.calcInputGradients(prevLayer))
      return false;
   if(!SumAndNormilize(prevLayer.getGradient(), AttentionOut.getGradient(), 
                                                      prevLayer.getGradient(), iWindow, false))
      return false;
//---
   return true;
  }

El método de actualización del peso es bastante sencillo. Basta con llamar a los métodos correspondientes de las capas internas.

bool CNeuronMH2AttentionOCL::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   if(!Q_Embedding.UpdateInputWeights(NeuronOCL))
      return false;
   if(!KV_Embedding.UpdateInputWeights(GetPointer(Transpose)))
      return false;
   if(!W0.UpdateInputWeights(GetPointer(MHAttentionOut)))
      return false;
   if(!FF[0].UpdateInputWeights(GetPointer(AttentionOut)))
      return false;
   if(!FF[1].UpdateInputWeights(GetPointer(FF[0])))
      return false;
//---
   return true;
  }

Con esto concluye nuestro examen de los métodos para organizar el proceso de relaciones cruzadas. Puedes encontrar el código completo de la clase y todos sus métodos en el archivo adjunto. Pasamos a construir los Asesores Expertos para entrenar y probar los modelos.

2.2 Arquitectura modelo

Como se desprende de la descripción teórica del método ADAPT, el enfoque propuesto tiene una estructura jerárquica bastante compleja. Para nosotros, esto se traduce en un gran número de modelos entrenados. Dividiremos la descripción de su arquitectura en 2 métodos. En primer lugar, crearemos 3 modelos relacionados con el proceso de predicción del punto final.

bool CreateTrajNetDescriptions(CArrayObj *encoder, CArrayObj *endpoints, CArrayObj *probability)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
   if(!endpoints)
     {
      endpoints = new CArrayObj();
      if(!endpoints)
         return false;
     }
   if(!probability)
     {
      probability = new CArrayObj();
      if(!probability)
         return false;
     }

El codificador de estado ambiental recibe datos de entrada brutos que describen 1 estado.

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

 Como siempre, normalizamos los datos recibidos.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = MathMax(1000, GPTBars);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

También generamos una incrustación, que añadimos al buffer de acumulación de secuencias históricas.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronEmbeddingOCL;
     {
      int temp[] = {prev_count};
      ArrayCopy(descr.windows, temp);
     }
   prev_count = descr.count = GPTBars;
   int prev_wout = descr.window_out = EmbeddingSize;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

A continuación, introducimos la codificación posicional.

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronPEOCL;
   descr.count = prev_count;
   descr.window = prev_wout;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Le siguen los bloques de atención integral. Para facilitar la gestión de la arquitectura del modelo, crearemos un bucle basado en el número de iteraciones del bloque.

   for(int l = 0; l < Lenc; l++)
     {
      //--- layer 4
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronTransposeOCL;
      descr.count = prev_count;
      descr.window = prev_wout;
      if(!encoder.Add(descr))
        {
         delete descr;
         return false;
        }

Según el algoritmo propuesto por los autores del método ADAPT, primero comprobamos las relaciones entre polilíneas (en nuestro caso, estados) y agentes. Antes de utilizar nuestro bloque de relaciones cruzadas en esta dirección, tenemos que transponer la cantidad de información resultante. A continuación, añadimos nuestra nueva capa.

      //--- layer 5
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronMH2AttentionOCL;
      descr.count = prev_wout;
      descr.window = prev_count;
      descr.step = 8;
      descr.window_out = 16;
      descr.optimization = ADAM;
      if(!encoder.Add(descr))
        {
         delete descr;
         return false;
        }

Luego viene el bloque de autoatención a la trayectoria.

      //--- layer 6
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronMLMHAttentionOCL;
      descr.count = prev_wout;
      descr.window = prev_count;
      descr.step = 8;
      descr.window_out = 16;
      descr.layers = 1;
      descr.optimization = ADAM;
      if(!encoder.Add(descr))
        {
         delete descr;
         return false;
        }

A continuación, analizamos la relación en un plano diferente. Para ello, transponemos los datos y repetimos los bloques de atención.

      //--- layer 7
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronTransposeOCL;
      descr.count = prev_wout;
      descr.window = prev_count;
      if(!encoder.Add(descr))
        {
         delete descr;
         return false;
        }
      //--- layer 8
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronMH2AttentionOCL;
      descr.count = prev_count;
      descr.window = prev_wout;
      descr.step = 8;
      descr.window_out = 16;
      descr.layers = 1;
      descr.optimization = ADAM;
      if(!encoder.Add(descr))
        {
         delete descr;
         return false;
        }
      //--- layer 9
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronMLMHAttentionOCL;
      descr.count = prev_count;
      descr.window = prev_wout;
      descr.step = 8;
      descr.window_out = 16;
      descr.layers = 1;
      descr.optimization = ADAM;
      if(!encoder.Add(descr))
        {
         delete descr;
         return false;
        }
     }

Como se mencionó anteriormente, envolvimos el bloque del codificador en un bucle. El número de iteraciones del bucle se proporciona en constantes.

#define        Lenc                    3             //Number ADAPT Encoder blocks

Así, cambiar una constante nos permite modificar rápidamente el número de bloques de atención en el codificador.

Los resultados del codificador se utilizan para predecir varios conjuntos de puntos finales. El número de estos conjuntos viene determinado por la constante NForecast.

#define        NForecast               5             //Number of forecast

Utilizaremos un MLP simple para el modelo de predicción del punto final. En este modelo, los datos recibidos del codificador pasan por capas totalmente conectadas.

//--- Endpoints
   endpoints.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = (prev_count * prev_wout);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!endpoints.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!endpoints.Add(descr))
     {
      delete descr;
      return false;
     }

El estado latente se normaliza mediante la función SoftMax.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = LatentCount;
   descr.step = 1;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!endpoints.Add(descr))
     {
      delete descr;
      return false;
     }

A continuación, generamos puntos finales en la capa totalmente conectada.

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 3 * NForecast;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!endpoints.Add(descr))
     {
      delete descr;
      return false;
     }

El modelo para predecir las probabilidades de elegir trayectorias también utiliza los resultados del codificador como datos de entrada.

//--- Probability
   probability.Clear();
//--- Input layer
   if(!probability.Add(endpoints.At(0)))
      return false;

Pero en ella se analizan teniendo en cuenta los puntos finales previstos.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = 3 * NForecast;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }

Las operaciones con cantidades probabilísticas nos permiten utilizar la capa SoftMax a la salida del modelo.

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = NForecast;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = NForecast;
   descr.step = 1;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Ahora llegamos al punto en el que realizamos cambios fundamentales en el algoritmo del método ADAPT. Nuestros cambios vienen exigidos por las especificidades de los mercados financieros. Sin embargo, en mi opinión, no contradicen en absoluto los planteamientos propuestos por los autores del método.

Los autores propusieron su propio algoritmo para resolver problemas relacionados con la navegación de vehículos autónomos. Aquí la calidad de la predicción de la trayectoria es de gran importancia. Porque una colisión de 2 o más vehículos en cualquier parte de la trayectoria puede tener consecuencias críticas.

En el caso de la negociación en los mercados financieros, se presta más atención a los puntos de control. No nos interesa tanto la trayectoria del movimiento del precio y sus pequeñas fluctuaciones en el rango de la tendencia general. Lo más importante para nosotros son los extremos de los máximos beneficios y detracciones posibles en el marco de este movimiento.

Por lo tanto, excluimos el bloque de predicción de trayectoria y lo sustituimos por un modelo actor, que generará los parámetros de la operación. Al mismo tiempo, hemos mantenido el planteamiento general de entrenamiento de los modelos. Volveremos a ello un poco más tarde.

Nuestro actor utiliza 4 fuentes de datos para tomar una decisión:

  • Inclusión de estados
  • Descripción del estado de la cuenta
  • Conjuntos de puntos finales previstos
  • Probabilidades de cada conjunto previsto de puntos finales

Anteriormente, creamos un mecanismo para combinar sólo 2 flujos de información. Para combinar 4 flujos, construiremos una cascada de modelos.

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

Combinamos conjuntos de puntos finales predichos y sus probabilidades en la incrustación de puntos finales.

//--- Endpoints Encoder
   end_encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = 3 * NForecast;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!end_encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = NForecast;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!end_encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Combinamos la incrustación del estado medioambiental con parámetros de equilibrio y posiciones abiertas.

//--- State Encoder
   state_encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = GPTBars * EmbeddingSize;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!state_encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = AccountDescr;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!state_encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Pasamos los resultados del trabajo de los 2 modelos especificados al actor para la toma de decisiones.

//--- Actor
   actor.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = LatentCount;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = LatentCount;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Dentro del actor, utilizamos capas totalmente conectadas.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Generamos su comportamiento estocástico.

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NActions;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Como puede ver, tenemos previsto utilizar las arquitecturas de modelos más sencillas posibles. Esta es una de las ventajas del método ADAPT.

En este artículo, he decidido no detenerme en una descripción detallada de los Asesores Expertos para la interacción con el entorno. La estructura de los datos recogidos y los métodos de interacción con el entorno no han cambiado. Por supuesto, se han introducido cambios en la secuencia de llamada a los modelos para la toma de decisiones. Te sugiero que estudies el código para ver la secuencia. El código EA completo se encuentra en el archivo adjunto. Pero el EA de entrenamiento del modelo tiene varios aspectos únicos.

2.3 Modelo de entrenamiento

A diferencia de los últimos artículos, esta vez entrenaremos todos los modelos dentro de un EA "...\Experts\ADAPT\Study.mq5". Esto se debe a que necesitamos transferir el gradiente de error de casi todos los modelos al codificador ambiental.

El método de inicialización del EA se construye según un esquema estándar. Primero cargamos el conjunto de datos de entrenamiento.

int OnInit()
  {
//---
   ResetLastError();
   if(!LoadTotalBase())
     {
      PrintFormat("Error of load study data: %d", GetLastError());
      return INIT_FAILED;
     }

A continuación, en 2 etapas, cargamos los modelos creados anteriormente y, si es necesario, creamos otros nuevos.

//--- load models
   float temp;
   if(!ADAPTEncoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true) ||
      !ADAPTEndpoints.Load(FileName + "Endp.nnw", temp, temp, temp, dtStudied, true) ||
      !ADAPTProbability.Load(FileName + "Prob.nnw", temp, temp, temp, dtStudied, true)
     )
     {
      CArrayObj *encoder = new CArrayObj();
      CArrayObj *endpoint = new CArrayObj();
      CArrayObj *prob = new CArrayObj();
      if(!CreateTrajNetDescriptions(encoder, endpoint, prob))
        {
         delete endpoint;
         delete prob;
         delete encoder;
         return INIT_FAILED;
        }
      if(!ADAPTEncoder.Create(encoder) ||
         !ADAPTEndpoints.Create(endpoint) ||
         !ADAPTProbability.Create(prob))
        {
         delete endpoint;
         delete prob;
         delete encoder;
         return INIT_FAILED;
        }
      delete endpoint;
      delete prob;
      delete encoder;
     }
   if(!StateEncoder.Load(FileName + "StEnc.nnw", temp, temp, temp, dtStudied, true) ||
      !EndpointEncoder.Load(FileName + "EndEnc.nnw", temp, temp, temp, dtStudied, true) ||
      !Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *endpoint = new CArrayObj();
      CArrayObj *encoder = new CArrayObj();
      if(!CreateDescriptions(actor, endpoint, encoder))
        {
         delete actor;
         delete endpoint;
         delete encoder;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || 
         !StateEncoder.Create(encoder) || 
         !EndpointEncoder.Create(endpoint))
        {
         delete actor;
         delete endpoint;
         delete encoder;
         return INIT_FAILED;
        }
      delete actor;
      delete endpoint;
      delete encoder;
      //---
     }

Transferimos todos los modelos a un único contexto OpenCL.

   OpenCL = Actor.GetOpenCL();
   StateEncoder.SetOpenCL(OpenCL);
   EndpointEncoder.SetOpenCL(OpenCL);
   ADAPTEncoder.SetOpenCL(OpenCL);
   ADAPTEndpoints.SetOpenCL(OpenCL);
   ADAPTProbability.SetOpenCL(OpenCL);

Control de la arquitectura del modelo.

   Actor.getResults(Result);
   if(Result.Total() != NActions)
     {
      PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", 
                                                                NActions, Result.Total());
      return INIT_FAILED;
     }
//---
   ADAPTEndpoints.getResults(Result);
   if(Result.Total() != 3 * NForecast)
     {
      PrintFormat("The scope of the Endpoints does not match forecast endpoints (%d <> %d)", 
                                                            3 * NForecast, Result.Total());
      return INIT_FAILED;
     }
//---
   ADAPTEncoder.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Encoder doesn't match state description (%d <> %d)", 
                                                Result.Total(), (HistoryBars * BarDescr));
      return INIT_FAILED;
     }

Crear un búfer auxiliar.

   if(!bGradient.BufferInit(MathMax(AccountDescr, NForecast), 0) ||
      !bGradient.BufferCreate(OpenCL))
     {
      PrintFormat("Error of create buffers: %d", GetLastError());
      return INIT_FAILED;
     }

Generar un evento personalizado para el inicio del entrenamiento del modelo.

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

El propio proceso de entrenamiento se organiza mediante el método Train.

void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);

En el cuerpo del método, primero creamos un vector de probabilidades para elegir trayectorias del búfer de repetición de experiencias. A continuación, creamos las variables locales necesarias.

   vector<float> result, target;
   matrix<float> targets, temp_m;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

El entrenamiento, como es habitual, se implementa en un sistema de bucles anidados. En el cuerpo del bucle externo, muestreamos la trayectoria y el paquete de estados de aprendizaje en ella.

   for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++)
     {
      int tr = SampleTrajectory(probability);
      int batch = GPTBars + 48;
      int state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * 
                             (Buffer[tr].Total - 2 - PrecoderBars - batch));
      if(state <= 0)
        {
         iter--;
         continue;
        }
      ADAPTEncoder.Clear();
      int end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars);

El proceso de entrenamiento de modelos sobre una secuencia de datos históricos se construye en el bucle anidado.

      for(int i = state; i < end; i++)
        {
         bState.AssignArray(Buffer[tr].States[i].state);

Tomamos un estado ambiental y lo pasamos al codificador.

         //--- Trajectory
         if(!ADAPTEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, 
                                                              (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

A continuación, generamos conjuntos de puntos finales previstos y sus probabilidades.

         if(!ADAPTEndpoints.feedForward((CNet*)GetPointer(ADAPTEncoder), -1, 
                                                             (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         if(!ADAPTProbability.feedForward((CNet*)GetPointer(ADAPTEncoder), -1, 
                                               (CNet*)GetPointer(ADAPTEndpoints)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

A continuación, para organizar el proceso de entrenamiento de los extremos, necesitamos generar valores objetivo. Tomamos los estados posteriores del búfer de repetición de experiencias hasta una profundidad de planificación determinada.

         targets = matrix<float>::Zeros(PrecoderBars, 3);
         for(int t = 0; t < PrecoderBars; t++)
           {
            target.Assign(Buffer[tr].States[i + 1 + t].state);
            if(target.Size() > BarDescr)
              {
               matrix<float> temp(1, target.Size());
               temp.Row(target, 0);
               temp.Reshape(target.Size() / BarDescr, BarDescr);
               temp.Resize(temp.Rows(), 3);
               target = temp.Row(temp.Rows() - 1);
              }
            targets.Row(target, t);
           }

Pero en ellos no utilizamos el último estado, como podría pensarse a partir de la definición de puntos finales. En su lugar, buscamos los extremos más cercanos. En primer lugar, calculamos el total acumulado de la desviación del precio de cierre de cada vela con respecto al estado analizado. Y a los valores obtenidos le sumamos los intervalos hasta High y Low de cada barra. Guardamos los resultados del cálculo en una matriz.

         target = targets.Col(0).CumSum();
         targets.Col(target, 0);
         targets.Col(target + targets.Col(1), 1);
         targets.Col(target + targets.Col(2), 2);

En la matriz resultante encontramos el extremo más cercano.

         int extr = 1;
         if(target[0] == 0)
            target[0] = target[1];
         int direct = (target[0] > 0 ? 1 : -1);
         for(int i = 1; i < PrecoderBars; i++)
           {
            if((target[i]*direct) < 0)
               break;
            extr++;
           }

Formar un vector a partir de los extremos más próximos encontrados.

         targets.Resize(extr, 3);
         if(direct >= 0)
           {
            target = targets.Max(AXIS_HORZ);
            target[2] = targets.Col(2).Min();
           }
         else
           {
            target = targets.Min(AXIS_HORZ);
            target[1] = targets.Col(1).Max();
           }

Entre los conjuntos de puntos finales predichos, determinamos el vector con la desviación mínima y lo sustituimos por valores objetivo.

         ADAPTEndpoints.getResults(result);
         targets.Reshape(1, result.Size());
         targets.Row(result, 0);
         targets.Reshape(NForecast, 3);
         temp_m = targets;
         for(int i = 0; i < 3; i++)
            temp_m.Col(temp_m.Col(i) - target[i], i);
         temp_m = MathPow(temp_m, 2.0f);
         ulong pos = temp_m.Sum(AXIS_VERT).ArgMin();
         targets.Row(target, pos);

Utilizamos la matriz resultante para entrenar un modelo de predicción de puntos objetivo.

         Result.AssignArray(targets);
         //---
         if(!ADAPTEndpoints.backProp(Result, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Propagamos el gradiente de error al modelo del codificador y actualizamos sus parámetros.

         if(!ADAPTEncoder.backPropGradient((CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Aquí también entrenamos un modelo para predecir las probabilidades de trayectoria. Pero sus gradientes de error no se propagan a otros modelos.

         bProbs.AssignArray(vector<float>::Zeros(NForecast));
         bProbs.Update((int)pos, 1);
         bProbs.BufferWrite();
         if(!ADAPTProbability.backProp(GetPointer(bProbs), GetPointer(ADAPTEndpoints)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Tras actualizar los parámetros de los modelos de predicción de puntos finales, pasamos a entrenar la política de nuestro actor. Para ejecutar las operaciones feed-forward de nuestro actor en esta etapa, sólo necesitamos un tensor que describa el estado de la cuenta y las posiciones abiertas. Formemos este tensor.

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

A continuación, llamamos secuencialmente a los métodos feed-forward de nuestra cascada de modelos de actor.

         //--- State embedding
         if(!StateEncoder.feedForward((CNet *)GetPointer(ADAPTEncoder), -1, 
                                      (CBufferFloat*)GetPointer(bAccount)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Cabe señalar aquí que, en lugar de valores predictivos de conjuntos de puntos finales y sus probabilidades, utilizamos tensores de valores objetivo, que usamos anteriormente para entrenar los modelos correspondientes.

         //--- Endpoint embedding
         if(!EndpointEncoder.feedForward(Result, -1, false, (CBufferFloat*)GetPointer(bProbs)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         //--- Actor
         if(!Actor.feedForward((CNet *)GetPointer(StateEncoder), -1, 
                                                          (CNet*)GetPointer(EndpointEncoder)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Después del paso feed-forward, tenemos que actualizar los parámetros del modelo. Para ello necesitamos valores objetivo. Según el método ADAPT se debe entrenar un modelo para predecir trayectorias sobre datos reales del buffer de repetición de experiencias. Podríamos, como antes, tomar acciones del agente del búfer de repetición de la experiencia. Pero en este caso, no disponemos de un mecanismo para evaluar y priorizar tales acciones.

En esta situación, decidí adoptar un enfoque diferente. Puesto que ya disponemos de valores finales objetivo basados en datos reales de movimientos de precios posteriores del conjunto de datos de entrenamiento, ¿por qué no los utilizamos para generar la operación "óptima" en las condiciones analizadas? Determinamos la dirección y los niveles de negociación de la operación "óptima". Tomamos el volumen de posición teniendo en cuenta el riesgo del 1% del capital por operación.

         result = vector<float>::Zeros(NActions);
         double value = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE_LOSS);
         double risk = AccountInfoDouble(ACCOUNT_EQUITY) * 0.01;
         if(direct > 0)
           {
            float tp = float(target[1] / _Point / MaxTP);
            result[1] = tp;
            int sl = int(MathMax(MathMax(target[1] / 3, -target[2]) / _Point, MaxSL/10));
            result[2] = float(sl) / MaxSL;
            result[0] = float(MathMax(risk / (value * sl), 0.01))+FLT_EPSILON;
           }
         else
           {
            float tp = float((-target[2]) / _Point / MaxTP);
            result[4] = tp;
            int sl = int(MathMax(MathMax((-target[2]) / 3, target[1]) / _Point, MaxSL/10));
            result[5] = float(sl) / MaxSL;
            result[3] = float(MathMax(risk / (value * sl), 0.01))+FLT_EPSILON;
           }

Al calcular el volumen de posiciones, utilizamos la equidad, ya que en el momento de la operación la cuenta puede tener ya posiciones abiertas, cuyo beneficio (pérdida) no se tiene en cuenta en el saldo de la cuenta.

La posición "óptima" generada de este modo se utiliza para entrenar modelos de actor.

         Result.AssignArray(result);
         if(!Actor.backProp(Result, (CNet *)GetPointer(EndpointEncoder)) ||
            !StateEncoder.backPropGradient(GetPointer(bAccount), 
                                  (CBufferFloat *)GetPointer(bGradient)) ||
            !EndpointEncoder.backPropGradient(GetPointer(bProbs), 
                                  (CBufferFloat *)GetPointer(bGradient))
           )
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Utilizamos el gradiente de error del entrenamiento del modelo actor para actualizar los parámetros del codificador.

         if(!ADAPTEncoder.backPropGradient((CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

Tenga en cuenta que en esta fase no estamos actualizando los parámetros del modelo de predicción de puntos finales. Esta limitación fue introducida por los autores del método ADAPT y está diseñada para aumentar la estabilidad del entrenamiento del modelo.

Una vez actualizados los parámetros de todos los modelos, basta con informar al usuario del progreso del proceso de entrenamiento y pasar a la siguiente iteración del sistema de bucles.

         //---
         if(GetTickCount() - ticks > 500)
           {
            double percent = (double(i - state) / ((end - state)) + iter) * 100.0 / (Iterations);
            string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Actor", percent, 
                                                                  Actor.getRecentAverageError());
            str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Endpoints", percent, 
                                                         ADAPTEndpoints.getRecentAverageError());
            str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Probability", percent, 
                                                       ADAPTProbability.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

Al final del método, borramos el campo de comentarios del gráfico. Registra los resultados del entrenamiento del modelo en el diario. A continuación, inicie la terminación del EA.

   Comment("");
//---
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, 
                                                       "Actor", Actor.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, 
                                          "Endpoints", ADAPTEndpoints.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, 
                                      "Probability", ADAPTProbability.getRecentAverageError());
   ExpertRemove();
//---
  }

Esto concluye la descripción de la implementación MQL5 de nuestra visión del algoritmo. En el archivo adjunto encontrará el código completo de todos los programas utilizados en el artículo.


3. Pruebas

Hemos trabajado bastante para implementar el método ADAPT utilizando MQL5. Nuestra aplicación dista mucho del algoritmo original. No obstante, sigue el espíritu de los enfoques propuestos y explota la idea original relacionada con el análisis exhaustivo de las relaciones entre los objetos de la escena analizada. Ahora es el momento de probar los resultados de nuestro trabajo con datos históricos reales en el probador de estrategias.

Los modelos se entrenaron utilizando datos históricos de EURUSD, H1, para los 7 primeros meses de 2023. Todos los indicadores se utilizan con los parámetros por defecto.

Los modelos entrenados se probaron respetando plenamente los parámetros de entrenamiento. Sólo cambiamos el intervalo de tiempo de los datos históricos. En esta fase hemos utilizado datos históricos de agosto de 2023.

Como la estructura de los datos recogidos en el proceso de interacción con el entorno no ha cambiado, no he recogido nuevos datos de entrenamiento en mi experimento. Para entrenar los modelos, utilizo los pases recogidos al entrenar modelos anteriores. Además, el enfoque propuesto para calcular el "comercio óptimo" nos permite evitar el cálculo de pases adicionales que refinan y complementan el espacio de datos de entrenamiento.

Aquí puede parecer que una pasada es suficiente para entrenar el modelo. Sin embargo, durante el proceso de entrenamiento, debemos proporcionar al modelo información lo más diversa posible, incluida información sobre el estado de la cuenta y las posiciones abiertas.

Basándonos en los resultados de las pruebas, podemos llegar a una conclusión sobre la eficacia del método considerado. La simplicidad de los modelos permite un entrenamiento más rápido de los mismos. La eficacia de los enfoques propuestos queda confirmada por los resultados del modelo entrenado, que mostró la capacidad de generar beneficios tanto en el conjunto de datos de entrenamiento como en el de prueba.


Conclusión

El método ADAPT analizado en este artículo es un enfoque innovador para predecir las trayectorias de los agentes en diversos escenarios complejos. Este enfoque es eficaz, requiere pocos recursos informáticos y proporciona predicciones de alta calidad para cada agente de la escena.

Las mejoras introducidas en el método ADAPT incluyen un cabezal adaptativo que aumenta la capacidad del modelo sin incrementar su tamaño, y el uso del aprendizaje dinámico de los pesos para adaptarse mejor a las situaciones individuales de cada agente. Estas innovaciones contribuyen en gran medida a una predicción eficaz de la trayectoria.

En la parte práctica del artículo, implementamos nuestra visión de los enfoques propuestos utilizando MQL5. Entrenamos y probamos modelos utilizando datos históricos reales. En base a los resultados obtenidos, podemos hacer una conclusión sobre la eficacia del método ADAPT y la posibilidad de utilizar sus variaciones para construir un modelo y operarlo en los mercados financieros.

Sin embargo, me gustaría recordarle que los programas presentados en el artículo sólo pretenden demostrar la tecnología y no están listos para su uso en el comercio financiero del mundo real.


Referencias

  • ADAPT: Efficient Multi-Agent Trajectory Prediction with Adaptation
  • Otros artículos de esta serie

  • Programas utilizados en el artículo

    # Nombre Tipo Descripción
    1 Research.mq5 Asesor experto Colección de ejemplos
    2 ResearchRealORL.mq5
    Asesor experto
    EA para la recogida de ejemplos mediante el método Real-ORL
    3 Study.mq5  Asesor experto Modelo de entrenamiento
    4 Test.mq5 Asesor experto Pruebas del modelo
    5 Trajectory.mqh Biblioteca de clases Estructura de descripción del estado del sistema
    6 NeuroNet.mqh Biblioteca de clases Una biblioteca de clases para crear una red neuronal
    7 NeuroNet.cl Código base Biblioteca de código de programa OpenCL


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

    Archivos adjuntos |
    MQL5.zip (3615.14 KB)
    Introducción a MQL5 (Parte 4): Estructuras, clases y funciones de tiempo Introducción a MQL5 (Parte 4): Estructuras, clases y funciones de tiempo
    En esta serie, seguiremos desvelando los secretos de la programación. En nuestro nuevo artículo, aprenderemos los fundamentos de las estructuras, las clases y las funciones de tiempo y adquiriremos nuevas habilidades para lograr una programación eficiente. Esta guía será probablemente útil no solo para los principiantes, sino también para los desarrolladores experimentados, ya que simplifica conceptos complejos, ofreciendo información valiosa para dominar MQL5. Así que hoy podrá seguir aprendiendo cosas nuevas, mejorando sus conocimientos de programación y dominando el mundo del trading algorítmico.
    Desarrollo de un sistema de repetición (Parte 58): Volvemos a trabajar en el servicio Desarrollo de un sistema de repetición (Parte 58): Volvemos a trabajar en el servicio
    Después de haber tomado un descanso en el desarrollo y perfeccionamiento del servicio usado en la repetición/simulación, retomaremos el trabajo en él. Ahora que no utilizaremos algunos recursos, como las variables globales del terminal, es necesario reestructurar por completo algunas partes de él. No se preocupen, este proceso se explicará adecuadamente para que todos puedan seguir el desarrollo del servicio.
    Inferencia causal en problemas de clasificación de series temporales Inferencia causal en problemas de clasificación de series temporales
    En este artículo, examinaremos la teoría de la inferencia causal utilizando el aprendizaje automático, así como la implementación del enfoque personalizado en Python. La inferencia causal y el pensamiento causal tienen sus raíces en la filosofía y la psicología y desempeñan un papel importante en nuestra comprensión de la realidad.
    Desarrollo de un sistema de repetición (Parte 57): Diseccionamos el servicio de prueba Desarrollo de un sistema de repetición (Parte 57): Diseccionamos el servicio de prueba
    Un último detalle: Aunque no se incluye en este artículo, explicaré el código del servicio que se estará utilizando en el próximo, ya que usaremos este mismo código como trampolín para lo que realmente estamos desarrollando. Así que ten un poco de paciencia y espera el próximo artículo, pues las cosas se están poniendo cada día más interesantes.