English Русский 中文 Deutsch 日本語 Português
preview
Redes neuronales en el trading: Análisis de la situación del mercado usando el Transformador de patrones

Redes neuronales en el trading: Análisis de la situación del mercado usando el Transformador de patrones

MetaTrader 5Sistemas comerciales |
334 1
Dmitriy Gizlyk
Dmitriy Gizlyk

Introducción

En la última década, el aprendizaje profundo (DL) ha logrado avances significativos en diversos campos, y estos avances han atraído la atención de los investigadores de los mercados financieros. Inspirados por el éxito del DL, muchos han intentado utilizarlo para predecir tendencias de mercado y analizar relaciones complejas en los datos. Uno de los aspectos clave de este tipo de análisis es la forma de presentación de los datos de origen, que preservaría las relaciones internas y la estructura de los instrumentos analizados. La mayoría de los modelos existentes trabajan con gráficos homogéneos, lo cual limita su capacidad para dar cuenta de la rica información semántica asociada a los patrones de mercado. De forma similar a los N-gramas en el procesamiento del lenguaje natural, los patrones de mercado que aparecen con frecuencia pueden utilizarse para identificar con mayor precisión las relaciones y predecir tendencias.

Para resolver este problema, hemos decidido tomar prestados algunos enfoques del campo del análisis de elementos químicos. Al igual que los patrones de mercado, los motivos (subgrafos significativos) con frecuencia se encuentran en la estructura de las moléculas y pueden utilizarse para revelar propiedades moleculares. Le sugiero que se familiarice con el marco Molformer, presentado en el artículo "Molformer: Motif-based Transformer on 3D Heterogeneous Molecular Graphs".

Los autores del Molformer formulan un nuevo grafo molecular heterogéneo (Heterogeneous Molecular Graph — HMG), que consta de nodos tanto a nivel atómico como de motivos, como entradas del modelo. Este ofrece una interfaz limpia para combinar nodos de distintos niveles y evita la propagación de los errores causados por una segmentación semántica incorrecta de los átomos. En cuanto a los motivos, los autores del método usan diferentes estrategias para distintos tipos de moléculas. Por un lado, en el caso de las moléculas pequeñas, el vocabulario de los motivos está definido por grupos funcionales basados en el conocimiento del dominio químico. Por otro, en el caso de las proteínas compuestas por aminoácidos consecutivos, se introduce un método de minería de motivos basado en el aprendizaje por refuerzo (RL) para detectar las subsecuencias de aminoácidos más significativas.

Para alinearse mejor con el HMG, se introducido el marco Molformer, un modelo geométrico equivariante basado en la arquitectura del Transformer. El Molformer difiere de los modelos basados en transformadores que hemos analizado anteriormente en dos aspectos principales. En primer lugar, utiliza una Self-Attention heterogénea (HSA) para identificar las interacciones entre nodos de distintos niveles. En segundo lugar, se introduce el algoritmo (Attentive Farthest Point SamplingAFPS) para agregar las características de los nodos y obtener una representación completa de toda la molécula.

El artículo del autor presenta resultados experimentales que confirman la eficacia de la solución propuesta para resolver problemas de la industria química. Por nuestra parte, nosotros nos proponemos evaluar la posibilidad de utilizar los enfoques propuestos para resolver los problemas de previsión de tendencias de los mercados financieros.


1. Algoritmo Molformer

Los motivos son patrones subestructurales que aparecen con frecuencia y sirven como bloques de construcción de estructuras moleculares complejas. Poseen una gran expresividad de características bioquímicas de moléculas enteras. La comunidad química ha desarrollado una serie de criterios estándar que permiten reconocer motivos con una funcionalidad significativa en moléculas pequeñas. En las grandes moléculas proteicas, los motivos son regiones localizadas de estructuras tridimensionales o secuencias de aminoácidos comunes a las proteínas que influyen en su función. Cada motivo suele constar de pocos elementos y puede describir la relación entre elementos estructurales menores. Basándose en esta característica, los autores del marco Molformer han desarrollado un método para detectar heurísticamente motivos proteicos utilizando RL. En su artículo, proponen considerar los motivos de cuatro aminoácidos que componen el polipéptido más pequeño y poseen propiedades funcionales específicas en las proteínas. En esta fase, el objetivo principal consiste en encontrar el vocabulario 𝓥 más eficiente dentro de K matrices de aminoácidos cuaternarios. Dado que el objetivo es encontrar el vocabulario óptimo para un problema concreto, en la práctica solo es posible considerar los cuaterniones existentes en los conjuntos de datos descendentes, en lugar de todos los posibles.

El vocabulario aprendido 𝓥 se utiliza como plantillas para extraer motivos y crear HMG en tareas de nivel inferior. Y entonces, basándose en estos HMG se entrena el Molformer. Su eficacia se considera como recompensa r para actualizar los parámetros θ utilizando gradientes de política. Como resultado, el Agente puede seleccionar el vocabulario óptimo de motivos cuaternarios para una tarea concreta.

En particular, el proceso de extracción de motivos propuesto supone un juego de un solo paso, ya que la red de políticas πθ genera el diccionario 𝓥 una sola vez en cada iteración. Así, la trayectoria consta de una sola acción, mientras que el resultado del Molformer, basado en el vocabulario seleccionado 𝓥, supone una fracción de la recompensa total.

Los autores del marco separan los motivos y los átomos, tratando los motivos como nuevos nodos para formar HMG. De esta forma, se desentrañan las representaciones a nivel de motivo y átomo, lo cual facilita que los modelos extraigan correctamente los significados semánticos a nivel de motivo.

Al igual que sucede con las relaciones entre frases y palabras individuales en el lenguaje natural, los motivos en las moléculas conllevan significados semánticos de mayor nivel que los átomos. Así, desempeñan un papel esencial en la determinación de la funcionalidad de sus componentes atómicos. Los autores del Molformer tratan cada categoría de motivo como un nuevo tipo de nodo, y construyen HMG como entradas del modelo de forma que el HMG incluya tanto nodos a nivel de motivo como a nivel de átomo. La suma ponderada de las coordenadas 3D de sus componentes se usa como las posiciones de cada motivo. De manera similar a la segmentación de palabras, los HMG formados por nodos multinivel evitan la propagación de errores debidos a una segmentación semántica inadecuada usando la información de los átomos para aprender una representación molecular.

El Molformer modifica el Transformer con varios componentes nuevos diseñados específicamente para 3D HMG. Cada bloque codificador se compone de una HSA, una red FeedForward (FFN) y una normalización de dos niveles. A continuación, el AFPS genera de forma adaptativa una representación molecular que se introduce en un predictor completamente conectado para pronosticar propiedades en una amplia gama de tareas posteriores.

Tras formular un HMG con nodos N+M a nivel de átomos y motivos respectivamente, resulta importante dotar al modelo de la capacidad de separar las interacciones entre nodos de varios órdenes. Para ello, los autores del método usan la función φ(i,j)→Z, que define la relación entre dos nodos cualesquiera en tres tipos: átomo-átomo, átomo-motivo y motivo-motivo. A continuación, se introduce el escalar entrenado bφ(i,j) para servir de forma adaptativa a todos los nodos según sus relaciones jerárquicas dentro del HMG.

Además, los autores del método consideran la posibilidad de usar geometría molecular tridimensional. Dado que la robustez frente a cambios globales como las traslaciones y rotaciones 3D resulta fundamental para el aprendizaje de representaciones moleculares, pretenden satisfacer la invarianza de traslación rotacional y utilizan una operación de convolución de la matriz de distancia por pares 𝑫.

Además, el uso del contexto local ha resultado importante en el espacio 3D disperso. Sin embargo, se ha observado que la Self-Attention resulta positiva al captar patrones globales de datos, pero ignora el contexto local. Basándose en este hecho, los autores del método imponen una restricción de Self-Attention basada en la distancia para extraer patrones multiescala de contextos tanto locales como globales. Para ello, se ha desarrollado una metodología multiescala que permite capturar las piezas de forma fiable. En concreto, se realiza un enmascaramiento de los nodos situados más allá de una determinada distancia τs en cada escala s. A continuación, las características extraídas de las distintas escalas se combinan en una representación multiescala y se envían a FFN.

Más bajo le mostramos la visualización del marco Molformer por parte del autor.

2. Implementación con MQL5

Tras considerar los aspectos teóricos del marco Molformer, pasaremos a la parte práctica de este artículo, donde implementaremos nuestra visión de los enfoques propuestos usando MQL5. Y aquí, al igual que en el artículo anterior, dividiremos el proceso completo de implementación del marco en bloques separados que realizarán operaciones repetitivas.

2.1 Attention pooling


En primer lugar, separaremos en una clase aparte el algoritmo de agrupación basado en la dependencia propuesto por los autores del método R-MAT.

No se sorprenda de que comencemos nuestra implementación del marco Molformer implementando uno de los enfoques del método R-MAT, pues ambos métodos se han propuesto para resolver problemas similares en la industria química, y, en nuestra opinión, tienen algunos puntos en común que aprovecharemos. El algoritmo de agrupación basado en la dependencia es uno de ellos.

Organizaremos los procesos del algoritmo anterior en la clase CNeuronMHAttentionPooling, cuya estructura se muestra a continuación.

class CNeuronMHAttentionPooling  :  public   CNeuronBaseOCL
  {
protected:
   uint              iWindow;
   uint              iHeads;
   uint              iUnits;
   CLayer            cNeurons;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronMHAttentionPooling(void) {};
                    ~CNeuronMHAttentionPooling(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint units_count, uint heads,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override   const   {  return defNeuronMHAttentionPooling; }
   //---
   virtual bool      Save(int const file_handle) override;
   virtual bool      Load(int const file_handle) override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      SetOpenCL(COpenCLMy *obj) override;
  };

En esta clase, declaramos 3 variables internas y un array dinámico donde almacenaremos los punteros a los objetos internos en la secuencia en la que son llamados. Luego declararemos el array estáticamente, lo cual nos permitirá dejar el constructor y el destructor de la clase vacíos, mientras que la inicialización de todos los objetos heredados y recién declarados se realizará en el método Init, en cuyos parámetros obtendremos las constantes que definen inequívocamente la arquitectura del objeto que se está creando.

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

En el cuerpo del método de inicialización del objeto, llamaremos primero al método homónimo de la clase padre, en el que ya se ha implementado parte de los controles necesarios y el algoritmo de inicialización de los objetos heredados. Después guardaremos el valor de las constantes recibidas del programa externo en variables internas.

   iWindow = window;
   iUnits = units_count;
   iHeads = heads;

Prepararemos nuestro array dinámico.

   cNeurons.Clear();
   cNeurons.SetOpenCL(OpenCL);

Y empezaremos a crear la estructura de objetos anidados. Aquí creamos un MLP de dos capas en el que usaremos una tangente hiperbólica para crear no linealidad entre las capas neuronales.

   int idx = 0;
   CNeuronConvOCL *conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, iWindow*iHeads, iWindow*iHeads, 4*iWindow, iUnits, 1, optimization, iBatch) ||
      !cNeurons.Add(conv)
     )
      return false;
   idx++;
   conv.SetActivationFunction(TANH);
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, 4*iWindow, 4*iWindow, iHeads, iUnits, 1, optimization, iBatch) ||
      !cNeurons.Add(conv)
     )
      return false;

Normalizaremos los resultados del MLP generado con la función SoftMax en cuanto a los elementos individuales de la secuencia.

   idx++;
   conv.SetActivationFunction(None);
   CNeuronSoftMaxOCL *softmax = new CNeuronSoftMaxOCL();
   if(!softmax ||
      !softmax.Init(0, idx, OpenCL, iHeads * iUnits, optimization, iBatch) ||
      !cNeurons.Add(softmax)
     )
      return false;
   softmax.SetHeads(iUnits);
//---
   return true;
  }

Y finalizaremos el método transmitiendo el resultado lógico de las operaciones al programa que realiza la llamada.

Nótese que en este caso no sustituiremos los punteros a los búferes de datos. Esto se debe a que los objetos que estamos creando solo generan datos intermedios. El resultado del objeto creado, sin embargo, se formará multiplicando los resultados normalizados del MLP creado por el tensor de los datos de origen. Y serán los resultados de esta operación los que almacenaremos en el correspondiente búfer heredado de la clase padre. La situación será similar con el búfer de gradiente de error.

Después de terminar con el método de inicialización de la clase, pasaremos a construir el algoritmo de pasada directa en el método feedForward.

bool CNeuronMHAttentionPooling::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   CNeuronBaseOCL *current = NULL;
   CObject *prev = NeuronOCL;

En los parámetros del método, obtendremos el puntero al objeto de datos de origen. Y en el cuerpo del método crearemos 2 variables locales para el almacenamiento temporal de los punteros a los objetos. Y a uno de ellos le transmitiremos el puntero al objeto de datos de origen.

A continuación, organizaremos un ciclo de enumeración de los objetos del MLP interno con llamadas sucesivas a los métodos del modelo interno homónimo.

   for(int i = 0; i < cNeurons.Total(); i++)
     {
      current = cNeurons[i];
      if(!current ||
         !current.FeedForward(prev)
        )
         return false;
      prev = current;;
     }

Tras realizar todas las iteraciones del ciclo, hemos obtenido los coeficientes de influencia de las cabezas de atención en el resultado global para cada elemento individual de la secuencia. Y ahora, como ya hemos dicho, deberemos calcular la media ponderada de las cabezas de atención en los datos de origen multiplicando los coeficientes obtenidos por el tensor de los datos de origen. Escribiremos el producto de los tensores en el búfer de resultados de nuestro objeto.

   if(!MatMul(current.getOutput(), NeuronOCL.getOutput(), Output,
                                      1, iHeads, iWindow, iUnits))
      return false;
//---
   return true;
  }

Y ahora todo lo que deberemos hacer es retornar el resultado lógico de las operaciones al programa que realiza la llamada, después de lo cual finalizaremos el método.

Le sugiero estudiar los métodos de pasada inversa de esta clase por su cuenta. Encontrará el código completo de esta clase y todos sus métodos en el archivo adjunto.

2.2 Extracción de patrones


En el siguiente paso de nuestro trabajo, crearemos un objeto de extracción de patrones. Como ya hemos mencionado en la parte teórica, las incorporaciones de patrones se añaden al tensor de los datos de origen antes de introducirlas en el modelo. Hoy haremos algo distinto: introduciremos en el modelo el conjunto de datos habitual, extraeremos los patrones y concatenaremos sus incorporaciones con el tensor de datos iniciales en el cuerpo del modelo.

Nótese aquí que cada incorporación de patrón añadida a los datos de origen deberá tener la dimensionalidad de un elemento de la secuencia de datos original y encontrarse en el mismo subespacio. La primera cuestión se abordará con la ayuda de decisiones arquitectónicas, mientras que la segunda intentaremos resolverla durante el aprendizaje de las incorporaciones de los patrones.

Para resolver las tareas que nos ocupan, crearemos la nueva clase CNeuronMotifs. Su estructura la resumimos a continuación.

class CNeuronMotifs    :  public CNeuronBaseOCL
  {
protected:
   CNeuronConvOCL    cMotifs;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronMotifs(void) {};
                    ~CNeuronMotifs(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint dimension, uint window, uint step, uint units_count,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override   const   {  return defNeuronMotifs; }
   //---
   virtual bool      Save(int const file_handle) override;
   virtual bool      Load(int const file_handle) override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      SetOpenCL(COpenCLMy *obj) override;
   //---
   virtual void      SetActivationFunction(ENUM_ACTIVATION value) override;
  };

En esta clase, declararemos solo una capa de convolución interna, que realizará la funcionalidad de incorporación de patrones. Sin embargo, llama la atención la redefinición del método de especificación de la función de activación. Cabe destacar que nunca hemos redefinido este método. En este caso, se hará para sincronizar la función de activación de la capa interna y el objeto.

void CNeuronMotifs::SetActivationFunction(ENUM_ACTIVATION value)
  {
   CNeuronBaseOCL::SetActivationFunction(value);
   cMotifs.SetActivationFunction(activation);
  }

Inicializaremos la capa de convolución declarada, así como todos los objetos heredados, en el método Init. En los parámetros de este método obtendremos las constantes que nos permiten definir inequívocamente la arquitectura del objeto a crear.

bool CNeuronMotifs::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                         uint dimension, uint window, uint step, uint units_count,
                         ENUM_OPTIMIZATION optimization_type, uint batch
                        )
  {
   uint inputs = (units_count * step + (window - step)) * dimension;
   uint motifs = units_count * dimension;

Sin embargo, a diferencia de los métodos similares considerados anteriormente, no dispondremos de datos suficientes para llamar al método homónimo de la clase padre. Y, sobre todo, esto estará relacionado con el tamaño del búfer de resultados. Como hemos mencionado anteriormente, esperamos obtener un tensor concatenado de datos de origen e incorporaciones de patrones en la salida. Por lo tanto, primero determinaremos los tamaños de los tensores de los datos de origen y las incorporaciones de patrones a partir de los datos disponibles, y solo entonces llamaremos al método de inicialización de la clase padre para transmitir la suma de los tamaños obtenidos.

   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, inputs + motifs, optimization_type, batch))
      return false;

A continuación, inicializaremos la capa convolucional interna de incorporación de patrones según los parámetros recibidos del programa externo.

   if(!cMotifs.Init(0, 0, OpenCL, dimension * window, dimension * step, dimension, units_count,
                                                                           1, optimization, iBatch))
      return false;

Tenga en cuenta que el tamaño de las incorporaciones retornadas será igual a la dimensionalidad de los datos de origen.

Aquí forzaremos la redefinición de la función de activación usando el método anteriormente redefinido.

   SetActivationFunction(None);
//---
   return true;
  }

Después finalizaremos el método y transmitiremos el resultado lógico de las operaciones al programa que realiza la llamada.

A la inicialización del objeto le seguirá la construcción de los procesos de pasada directa que implementaremos en el método feedForward. Aquí todo es bastante normal y cotidiano.

bool CNeuronMotifs::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;

En los parámetros del método obtendremos el puntero al objeto de datos de origen y comprobaremos inmediatamente la relevancia de este puntero. A continuación, sincronizaremos las funciones de activación de la capa de datos de origen y del objeto actual.

   if(NeuronOCL.Activation() != activation)
      SetActivationFunction((ENUM_ACTIVATION)NeuronOCL.Activation());

Esta operación nos permitirá sincronizar la zona de resultados de la capa de incorporación con los datos de origen.

Solo después de realizar los trabajos preparatorios se llevará a cabo la pasada directa de la capa interior.

   if(!cMotifs.FeedForward(NeuronOCL))
      return false;

Y luego concatenaremos el tensor de las incorporaciones obtenidas con los datos de origen.

   if(!Concat(NeuronOCL.getOutput(), cMotifs.getOutput(), Output, NeuronOCL.Neurons(), cMotifs.Neurons(), 1))
      return false;
//---
   return true;
  }

Luego escribiremos el tensor concatenado en el búfer de resultados heredado de la clase padre y finalizaremos el método pasando el resultado lógico de las operaciones al programa que realiza la llamada.

A continuación, pasaremos a analizar el funcionamiento de los métodos de pasada inversa. Y como habrá adivinado, su algoritmo es igual de sencillo. Por ejemplo, en el método de distribución del gradiente de error calcInputGradients, realizaremos solo una operación de desconcatenación en el búfer de gradiente de error heredado de la clase padre, distribuyendo los valores entre el objeto de datos de origen y la capa interna.

bool CNeuronMotifs::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;
   if(!DeConcat(NeuronOCL.getGradient(),cMotifs.getGradient(),Gradient,NeuronOCL.Neurons(),cMotifs.Neurons(),1))
      return false;
//---
   return true;
  }

Sin embargo, a esta aparente simplicidad hay que añadir un par de explicaciones. En primer lugar, no corregiremos el gradiente de error transmitido a los datos de origen y a la capa interna por la derivada de la función de activación de los objetos correspondientes. Esta operación sería redundante en este caso. Y esto se logra sincronizando el puntero de la función de activación de nuestro objeto, la capa interna y los datos de origen, que organizamos al construir el método de pasada directa. Una operación tan sencilla nos ha permitido obtener el gradiente de error corregido según la derivada de la función de activación deseada, a nivel del resultado del objeto. Como consecuencia, desconcatenaremos el gradiente de error ya corregido.

El segundo punto a considerar es que no transmitiremos el gradiente de error de la capa interna de extracción de patrones a los datos de origen. La razón de ello, curiosamente, es el problema de extracción de patrones de los datos de origen que estamos resolviendo. Al mismo tiempo, queremos encontrar patrones significativos, no "ajustar" los datos de origen a los patrones deseados. Sin embargo, como puede ver, los datos de origen obtienen su gradiente de error del flujo directo de datos.

Bien, podrá ver el código completo de esta clase y todos sus métodos en el archivo adjunto.

2.3 Atención multiescala


Otro "ladrillo" que deberemos crear es el objeto de atención multiescala. Y tengo que decir que aquí hemos implementado probablemente la mayor desviación del algoritmo Molformer respecto al autor. La cuestión aquí es que en este bloque los autores del marco realizaron el enmascaramiento de los objetos que se encuentran a más de una distancia especificada del objeto analizado. Y, de este modo, se acentúa la atención únicamente en un área determinada.

En nuestra aplicación, sin embargo, hemos hecho lo contrario. En primer lugar, en lugar del mecanismo de atención propuesto, utilizaremos el método de Self-Attention relativa analizado en el artículo anterior, que estudia el desplazamiento contextual además del posicional. En segundo lugar, para cambiar la escala de atención, aumentaremos el tamaño de un único elemento analizado a dos, tres y cuatro elementos de la secuencia original. Esto puede compararse con analizar el gráfico de un marco temporal más antiguo. La implementación de nuestra solución se presentará en la clase CNeuronMultiScaleAttention. A continuación se indicará la estructura de la nueva clase.

class CNeuronMultiScaleAttention :  public CNeuronBaseOCL
  {
protected:
   uint              iWindow;
   uint              iUnits;
   //---
   CNeuronBaseOCL    cWideInputs;
   CNeuronRelativeSelfAttention  cAttentions[4];
   CNeuronBaseOCL    cConcatAttentions;
   CNeuronMHAttentionPooling cPooling;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronMultiScaleAttention(void) {};
                    ~CNeuronMultiScaleAttention(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key, uint units_count, uint heads,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override   const   {  return defNeuronMultiScaleAttention; }
   //---
   virtual bool      Save(int const file_handle) override;
   virtual bool      Load(int const file_handle) override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      SetOpenCL(COpenCLMy *obj) override;
  };

Aquí definiremos explícitamente el número de escalas declarando un array fijo de objetos de atención relativa. Además, se declararán 3 objetos más en la estructura de la clase, cuyo propósito conoceremos durante la implementación de los métodos de la clase.

Declararemos todos los objetos internos como estáticos, lo cual nos permitirá dejar vacíos el constructor y el destructor de la clase, mientras que la inicialización de todos los objetos declarados y heredados se realizará en el método Init.

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

En los parámetros del método, como es habitual, obtendremos constantes que definirán inequívocamente la arquitectura del objeto que se va a crear. Y en el cuerpo del método llamamos directamente al método homónimo de la clase padre. Creo que no hace falta repetir que en él ya se han implementado los controles y algoritmos necesarios para la inicialización de los objetos heredados.

Después de ejecutar con éxito el método de la clase padre, almacenaremos algunas constantes en las variables internas.

   iWindow = window;
   iUnits = units_count;

Y antes de inicializar los objetos recién declarados, deberemos prestar atención al hecho de que en esta fase no conocemos el tamaño del tensor de los datos de origen. Tanto más que desconocemos la multiplicidad de su tamaño respecto a nuestra escala de análisis. Además, el tensor obtenido como entrada podría no ser un múltiplo de nuestras escalas. Sin embargo, necesitaremos suministrar un tensor del tamaño correcto a la entrada de los objetos de atención a gran escala. Para cumplir este requisito, crearemos un objeto interno en el que copiaremos los datos de origen y añadiremos valores nulos en lugar de los valores que faltan. Pero antes, definiremos el tamaño de búfer necesario como el máximo del múltiplo mayor más próximo de nuestra escala.

   uint units1 = (iUnits + 1) / 2;
   uint units2 = (iUnits + 2) / 3;
   uint units3 = (iUnits + 3) / 4;
   uint wide = MathMax(MathMax(iUnits, units1 * 2), MathMax(units2 * 3, units3 * 4));

Y luego inicializaremos el objeto para copiar los datos de origen del tamaño requerido.

   int idx = 0;
   if(!cWideInputs.Init(0, idx, OpenCL, wide * iWindow, optimization, iBatch))
      return false;
   CBufferFloat *temp = cWideInputs.getOutput();
   if(!temp || !temp.Fill(0))
      return false;

Rellenaremos el búfer de resultados de esta capa con valores nulos.

A continuación, inicializaremos los objetos de atención interna de diferentes escalas conservando otros parámetros.

   idx++;
   if(!cAttentions[0].Init(0, idx, OpenCL, iWindow, window_key, iUnits, heads, optimization, iBatch))
      return false;
   idx++;
   if(!cAttentions[1].Init(0, idx, OpenCL, 2 * iWindow, window_key, units1, heads, optimization, iBatch))
      return false;
   idx++;
   if(!cAttentions[2].Init(0, idx, OpenCL, 3 * iWindow, window_key, units2, heads, optimization, iBatch))
      return false;
   idx++;
   if(!cAttentions[3].Init(0, idx, OpenCL, 4 * iWindow, window_key, units3, heads, optimization, iBatch))
      return false;

Nótese aquí que a pesar de las diferentes escalas de los objetos de atención, esperamos obtener tensores de tamaños comparables en la salida. Al fin y al cabo, todas ellos utilizan esencialmente una única fuente de datos de origen. Por lo tanto, declararemos un objeto de 4 veces el tamaño de los datos de origen para concatenar los resultados de la atención.

   idx++;
   if(!cConcatAttentions.Init(0, idx, OpenCL, 4 * iWindow * iUnits, optimization, iBatch))
      return false;

Y para promediar los resultados de la atención, usaremos la clase de agrupación basada en la dependencia anterior.

   idx++;
   if(!cPooling.Init(0, idx, OpenCL, iWindow, iUnits, 4, optimization, iBatch))
      return false;

Al final del método de inicialización, sustituiremos los punteros de los búferes de resultados y gradientes de error del objeto creado por los punteros de los búferes de las capas de agrupación correspondientes.

   SetActivationFunction(None);
   if(!SetOutput(cPooling.getOutput()) ||
      !SetGradient(cPooling.getGradient()))
      return false;
//---
   return true;
  }

Al final del método, transmitiremos el resultado lógico de las operaciones al programa que realiza la llamada.

Nótese que en esta clase no hemos organizado los objetos para implementar los enlaces residuales inherentes a las unidades de atención anteriormente comentadas. La cuestión es que los bloques internos de atención relativa que usamos ya tienen enlaces residuales. Por consiguiente, el promedio de los resultados de la atención ya considerará las conexiones residuales. Y aquí no serán necesarias operaciones adicionales.

Después de inicializar el objeto, construiremos los procesos de pasada directa, que implementaremos en el método feedForward.

bool CNeuronMultiScaleAttention::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
//--- Attention
   if(!cAttentions[0].FeedForward(NeuronOCL))
      return false;

En los parámetros del método de pasada directa, como es habitual, obtendremos el puntero al objeto de datos de origen, que pasaremos inmediatamente al método homónimo de la capa de atención interna de la escala original (de unidad). En el cuerpo del método llamado del objeto interno, además de las operaciones básicas, se comprobará la relevancia del puntero recibido. En consecuencia, tras ejecutar con éxito las operaciones del método de la clase interna, podremos utilizar con seguridad el puntero recibido del programa externo. Y en el siguiente paso, transferiremos los datos de origen al búfer de la capa interna correspondiente. Después sincronizaremos las funciones de activación.

   if(!Concat(NeuronOCL.getOutput(), NeuronOCL.getOutput(), cWideInputs.getOutput(), iWindow, 0, iUnits))
      return false;
   if(cWideInputs.Activation() != NeuronOCL.Activation())
      cWideInputs.SetActivationFunction((ENUM_ACTIVATION)NeuronOCL.Activation());

Tenga en cuenta que, en este caso, para copiar los datos de origen utilizaremos el método de concatenación, en cuyos parámetros especificaremos dos veces el puntero al búfer de resultados del objeto de datos de origen. En este caso, especificaremos el tamaño de la ventana de datos de origen para el primer búfer y "0" para el segundo búfer. Obviamente, cuando los parámetros se especifican de esta manera, obtendremos una copia de los datos de origen en el búfer de resultados especificado. En este caso, no se realizará la operación de adición de valores nulos para los datos que faltan, de la que hablamos al inicializar el objeto.

Sin embargo, la adición de valores nulos se realizará de forma implícita. Recordemos que cuando inicializamos el objeto de datos de origen interno, rellenamos su búfer de resultados con valores nulos. Durante el entrenamiento y la explotación, esperamos obtener tensores de los datos de origen del mismo tamaño. En consecuencia, cada vez que copiemos los datos de origen, cambiaremos los valores de los mismos elementos, mientras que los demás permanecerán a cero.

Tras formar un objeto de datos de origen ampliado, organizaremos un ciclo para realizar operaciones de atención multiescala. En el cuerpo de este ciclo, llamaremos secuencialmente a los métodos de pasada directa de los objetos de mayor escala, transmitiéndoles el puntero al objeto de datos de origen ampliado.

//--- Multi scale attentions
   for(int i = 1; i < 4; i++)
      if(!cAttentions[i].FeedForward(cWideInputs.AsObject()))
         return false;

Luego concatenaremos los resultados de la atención de todas las escalas en un único tensor. Y aquí debemos decir que, a pesar de las diferentes escalas de los datos analizados, obtendremos tensores comparables a la salida, y cada elemento de la secuencia original permanecerá en su lugar. Por lo tanto, también realizaremos la concatenación de tensores en la sección de elementos de la secuencia inicial.

//--- Concatenate Multi-Scale Attentions
   if(!Concat(cAttentions[0].getOutput(), cAttentions[1].getOutput(), cAttentions[2].getOutput(),
              cAttentions[3].getOutput(), cConcatAttentions.getOutput(), 
              iWindow, iWindow, iWindow, iWindow, iUnits))
      return false;

Y después realizaremos la agrupación ponderada de los resultados de la atención multiescala de la misma manera en cuanto a los elementos de la secuencia original, teniendo en cuenta las dependencias.

//--- Attention pooling
   if(!cPooling.FeedForward(cConcatAttentions.AsObject()))
      return false;
//---
   return true;
  }

Cuando el método finalice, devolveremos el resultado lógico de las operaciones al programa que realiza la llamada.

Recordemos que en la fase de inicialización de los objetos, hemos sustituido los punteros a los objetos de las búferes de resultados y gradientes de error. Por lo tanto, los resultados de la puesta en común se introducirán inmediatamente en los búferes de las interfaces de transferencia de información entre las capas neuronales del modelo. Por lo tanto, omitiremos la operación redundante de copiado de datos.

Le sugiero que se familiarice con los métodos de pasada inversa de esta clase. En el archivo adjunto se incluye el código completo de la clase y todos sus métodos.

2.4 Construcción del marco Molformer


Más arriba hemos trabajado intensamente para construir los bloques individuales del marco Molformer. Y ahora es el momento de construir los bloques individuales en una arquitectura marco coherente. Para ello, crearemos una nueva clase CNeuronMolformer. Como objeto padre en este caso utilizaremos CNeuronRMAT, que implementará un mecanismo de modelo lineal simple. A continuación, le mostraremos la estructura de la nueva clase.

class CNeuronMolformer  :  public CNeuronRMAT
  {
public:
                     CNeuronMolformer(void) {};
                    ~CNeuronMolformer(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key,
                          uint units_count, uint heads, uint layers,
                          uint motif_window, uint motif_step,
                          ENUM_OPTIMIZATION optimization_type, uint batch); //Molformer
   //---
   virtual int       Type(void) override   const   {  return defNeuronMolformer; }
  };

Nótese que, a diferencia de los objetos implementados anteriormente, aquí redefinimos solo el método de inicialización de un nuevo objeto de la clase Init. Esto ha sido posible gracias a la organización de la estructura lineal de la clase padre. Y ahora solo tendremos que rellenar el array dinámico heredado de la clase padre con el conjunto necesario de objetos secuenciales. Todo su algoritmo de interacción ya está construido en los métodos de la clase padre.

En los parámetros del único método redefinido, obtendremos una serie de constantes que nos permitirán interpretar sin ambigüedades la arquitectura del objeto que se está creando, tal y como la concibe el usuario.

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

Y en el cuerpo del método llamaremos inmediatamente al método homónimo de la clase básica de la capa neuronal totalmente conectada.

Tenga en cuenta que es al método de la capa neuronal básica al que estamos llamando, no al objeto padre directo. Al fin y al cabo, en el cuerpo del método tendremos que crear una arquitectura completamente nueva. Y no necesitaremos recrear la arquitectura de la clase padre.

El siguiente paso será preparar un array dinámico en el que almacenaremos los punteros a los objetos creados.

   cLayers.Clear();
   cLayers.SetOpenCL(OpenCL);

Ahora pasaremos a las operaciones directas de creación e inicialización de los objetos necesarios. Primero crearemos e inicializaremos el objeto de extracción de patrones. Después añadiremos al array dinámico el puntero al nuevo objeto.

   int idx = 0;
   CNeuronMotifs *motif = new CNeuronMotifs();
   uint motif_units = units_count - MathMax(motif_window - motif_step, 0);
   motif_units = (motif_units + motif_step - 1) / motif_step;
   if(!motif ||
      !motif.Init(0, idx, OpenCL, window, motif_window, motif_step, motif_units, optimization, iBatch) ||
      !cLayers.Add(motif)
     )
      return false;

A continuación crearemos las variables locales para almacenar temporalmente los punteros a los objetos y organizaremos un ciclo de creación de capas internas del Codificador, cuyo número vendrá determinado por una constante en los parámetros del método.

   idx++;
   CNeuronMultiScaleAttention *msat = NULL;
   CResidualConv *ff = NULL;
   uint units_total = units_count + motif_units;
   for(uint i = 0; i < layers; i++)
     {
      //--- Attention
      msat = new CNeuronMultiScaleAttention();
      if(!msat ||
         !msat.Init(0, idx, OpenCL, window, window_key, units_total, heads, optimization, iBatch) ||
         !cLayers.Add(msat)
        )
         return false;
      idx++;

En el cuerpo del ciclo, primero crearemos e inicializaremos el objeto de atención multiescala. Y detrás añadiremos un bloque de convolución con enlace residual.

      //--- FeedForward
      ff = new CResidualConv();
      if(!ff ||
         !ff.Init(0, idx, OpenCL, window, window, units_total, optimization, iBatch) ||
         !cLayers.Add(ff)
        )
         return false;
      idx++;
     }

Luego añadiremos a los objetos creados los punteros a un array dinámico de objetos internos.

A continuación, cabe señalar que la salida del bloque de atención multiescala será un tensor concatenado de datos de origen e incorporaciones de patrones, enriquecido con información sobre dependencias internas. Sin embargo, a la salida de la clase necesitaremos devolver un tensor de datos de origen enriquecidos. Pero en lugar de simplemente "trocear" las incorporaciones de patrones, utilizaremos la función de escalado de datos dentro de secuencias unitarias individuales. Para ello, primero transpondremos los resultados de la capa anterior.

//--- Out
   CNeuronTransposeOCL *transp = new CNeuronTransposeOCL();
   if(!transp ||
      !transp.Init(0, idx, OpenCL, units_total, window, optimization, iBatch) ||
      !cLayers.Add(transp)
     )
      return false;
   idx++;

A continuación, añadiremos una capa de convolución que realizará la función de escalado de las secuencias unitarias individuales.

   CNeuronConvOCL *conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, idx, OpenCL, units_total, units_total, units_count, window, 1, optimization, iBatch) ||
      !cLayers.Add(conv)
     )
      return false;
   idx++;

Y retornaremos los resultados a la representación original de los datos.

   idx++;
   transp = new CNeuronTransposeOCL();
   if(!transp ||
      !transp.Init(0, idx, OpenCL, window, units_count, optimization, iBatch) ||
      !cLayers.Add(transp)
     )
      return false;

Después de eso, solo tendremos que sustituir los punteros a los búferes de datos y devolver el resultado lógico de las operaciones al programa que realiza la llamada.

   if(!SetOutput(transp.getOutput()) ||
      !SetGradient(transp.getGradient()))
     return false;
//---
   return true;
  }

Con esto concluirá nuestra consideración de las clases de construcción del marco Molformer. Podrá ver el código completo de las clases presentadas y todos sus métodos en el archivo adjunto. Allí encontrará también el código completo de todos los programas usados en la elaboración de este artículo. Debemos decir de entrada que todos los programas de interacción con el entorno y de entrenamiento de los modelos se han transferido íntegramente de artículos anteriores sin ninguna modificación. Solo se han realizado modificaciones puntuales en la arquitectura del Codificador del entorno, con la que le sugiero que se familiarice. En el anexo también se presenta una descripción completa de la arquitectura de todos los modelos entrenados. Ahora pasaremos a la parte final de este artículo: el entrenamiento de los modelos y la comprobación de los resultados.


3. Simulación

En este artículo, hemos implementado el marco Molformer utilizando herramientas MQL5 y ahora estamos procediendo a la etapa final: el entrenamiento de modelos y la prueba de la política de comportamiento del Actor entrenado. Seguiremos el algoritmo de aprendizaje descrito en trabajos anteriores y entrenaremos tres modelos simultáneamente: El Codificador del estado de la cuenta, el Actor y el Crítico. El Codificador analizará la situación del mercado, el Actor realizará transacciones comerciales basadas en la política estudiada, y el Crítico evaluará las acciones del Actor e indicará el rumbo a seguir para corregir la política de comportamiento.

El entrenamiento se realizará con datos históricos reales de EURUSD y el marco temporal H1 para todo el año 2023, utilizando los parámetros estándar de los indicadores analizados.

El proceso de entrenamiento será iterativo e implicará la actualización periódica de la muestra de entrenamiento.

Se utilizarán datos históricos de enero de 2024 para comprobar la eficacia de la política entrenada. A continuación resumimos los resultados de las pruebas.

Según los datos presentados, se deduce que el modelo entrenado ha realizado 25 transacciones comerciales durante el periodo de prueba, de las cuales 17 se han cerrado con beneficio. Esto supone el 68% de su número total. Al mismo tiempo, la media y el máximo de las transacciones rentables duplican los indicadores correspondientes de las transacciones perdedoras.

El potencial del modelo propuesto también queda confirmado por el gráfico de balance, que muestra una clara tendencia al alza. No obstante, el breve periodo de prueba y el número limitado de transacciones solo permiten hablar de un cierto potencial, sin conclusiones definitivas al respecto.


Conclusión

El método Molformer supone un avance significativo en el análisis y la predicción de los datos de mercado. El uso de gráficos de mercado heterogéneos, que incluyen tanto activos individuales como sus combinaciones en forma de patrones de mercado, permite al modelo considerar relaciones y estructuras de datos más complejos, lo cual mejora notablemente la precisión de la previsión del próximo movimiento de precios.

En la parte práctica del artículo, hemos implementado nuestra visión de los enfoques Molformer usando las herramientas MQL5. Asimismo, hemos implementado las soluciones propuestas en el modelo y lo hemos entrenado con datos históricos reales. El resultado es un modelo capaz de generalizar los conocimientos adquiridos a nuevas situaciones de mercado y generar beneficios. Los resultados de las pruebas lo confirman. Creemos que el planteamiento propuesto puede servir de base para nuevas investigaciones y aplicaciones en el análisis financiero, proporcionando a tráders y analistas nuevas herramientas para tomar decisiones más informadas en condiciones de incertidumbre.



Enlaces

Programas usados en el artículo

#NombreTipoDescripción
1Research.mq5AsesorAsesor de recopilación de datos
2ResearchRealORL.mq5
Asesor
Asesor de recopilación de ejemplos con el método Real-ORL
3Study.mq5AsesorAsesor de entrenamiento de Modelos
4Test.mq5AsesorAsesor para la prueba de modelos
5Trajectory.mqhBiblioteca de clasesEstructura de descripción del estado del sistema.
6NeuroNet.mqhBiblioteca de clasesBiblioteca de clases para crear una red neuronal
7NeuroNet.clBibliotecaBiblioteca de código de programa OpenCL

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

Archivos adjuntos |
MQL5.zip (2001.44 KB)
Rezus666
Rezus666 | 22 oct 2024 en 12:41

Buenos días, no consigo colocar órdenes con el Asesor Experto test.mq5.

if(temp[0] >= temp[3])
     {
      temp[0] -= temp[3];
      temp[3] = 0;
     }
   else
     {
      temp[3] -= temp[0];
      temp[0] = 0;
     }
//--- comprar control
   if(temp[0] < min_lot || (temp[1] * MaxTP * Symb.Point()) <= 2 * stops || (temp[2] * MaxSL * Symb.Point()) <= stops)
     {
     ...
     }
   else
     {
      ...
     }
//--- vender control
   if(temp[3] < min_lot || (temp[4] * MaxTP * Symb.Point()) <= 2 * stops || (temp[5] * MaxSL * Symb.Point()) <= stops)
     {
...
     }
   else...

La cosa es que los elementos del array temp[0] y temp[3] son siempre menores que min_lot, ¿dónde puede estar mi error?

Implementación de un algoritmo de trading de negociación rápida utilizando SAR Parabólico (Stop and Reverse, SAR) y Media Móvil Simple (Simple Moving Average, SMA) en MQL5 Implementación de un algoritmo de trading de negociación rápida utilizando SAR Parabólico (Stop and Reverse, SAR) y Media Móvil Simple (Simple Moving Average, SMA) en MQL5
En este artículo, desarrollamos un Asesor Experto de trading de ejecución rápida en MQL5, aprovechando los indicadores SAR Parabólico (Stop and Reverse, SAR) y Media Móvil Simple (Simple Moving Average, SMA) para crear una estrategia de trading reactiva y eficiente. Detallamos la implementación de la estrategia, incluyendo el uso de los indicadores, la generación de señales y el proceso de prueba y optimización.
Creación de un asesor experto integrado de MQL5 y Telegram (Parte 5): Envío de comandos desde Telegram a MQL5 y recepción de respuestas en tiempo real Creación de un asesor experto integrado de MQL5 y Telegram (Parte 5): Envío de comandos desde Telegram a MQL5 y recepción de respuestas en tiempo real
En este artículo, creamos varias clases para facilitar la comunicación en tiempo real entre MQL5 y Telegram. Nos centramos en recuperar comandos de Telegram, decodificarlos e interpretarlos y enviar respuestas apropiadas. Al final, nos aseguramos de que estas interacciones se prueben eficazmente y estén operativas dentro del entorno comercial.
Redes neuronales en el trading: Transformador contrastivo de patrones Redes neuronales en el trading: Transformador contrastivo de patrones
El transformador contrastivo de patrones analiza la situación del mercado tanto a nivel de velas individuales como de patrones completos, lo cual contribuye a mejorar la calidad de modelado de las tendencias del mercado, mientras que el uso del aprendizaje por contraste para emparejar las representaciones de velas y patrones conduce a la autorregulación y a la mejora de la precisión de la predicción.
Redes neuronales en el trading: Transformador con codificación relativa Redes neuronales en el trading: Transformador con codificación relativa
El aprendizaje autosupervisado puede ser una forma eficaz de analizar grandes cantidades de datos no segmentados. El principal factor de éxito es la adaptación de los modelos a las particularidades de los mercados financieros, lo cual contribuye a mejorar el rendimiento de los métodos tradicionales. Este artículo le presentará un mecanismo alternativo de atención que permitirá considerar las dependencias y relaciones relativas entre los datos de origen.