Русский Português
preview
Redes neuronales en el trading: Desentrañando las componentes estructuradas (Codificador)

Redes neuronales en el trading: Desentrañando las componentes estructuradas (Codificador)

MetaTrader 5Sistemas comerciales |
24 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introducción

Continuamos trabajando en la implementación de nuestra propia visión de los enfoques presentados por los autores del framework SCNN. Recordemos que SCNN (Structured Component Neural Network) propone un enfoque conceptualmente diferente: en lugar de intentar abarcar toda la serie temporal con un único mecanismo universal, la divide en cinco componentes clave: la componente de largo plazo, la componente estacional, la componente de corto plazo, la componente conjunta y la componente residual. Cada una de estas componentes se modela y se extrapola por separado, lo que nos proporciona no solo flexibilidad, sino también la posibilidad de obtener un resultado interpretable en cada etapa.

La principal ventaja de esta arquitectura es la transparencia. A diferencia de las cajas negras tradicionales, SCNN permite a analistas y traders ver qué parte concreta del modelo es responsable de cada tramo del pronóstico y hasta qué punto el modelo se comporta de forma fiable en las condiciones actuales. Esto es especialmente importante cuando se trabaja con datos financieros, donde la confianza en el modelo debe estar respaldada por la explicabilidad y la capacidad de control. Además, el enfoque individual para extrapolar cada componente permite utilizar tanto heurísticas estadísticas (para patrones de largo plazo y estacionales) como módulos neuronales entrenables (para modelar anomalías de corto plazo o interdependencias entre activos).

El framework también tiene en cuenta dependencias temporales complejas, incluidas las relaciones de autocorrelación, que a menudo se ignoran en los modelos simplificados. Esto resulta especialmente relevante para tareas relacionadas con el trading intradía o la predicción en la transición entre sesiones de trading. SCNN permite ajustarse dinámicamente al desplazamiento de las estadísticas en tiempo real, así como detectar puntos anómalos en los que la predicción puede ser poco fiable.

A continuación se presenta una visualización del framework SCNN realizada por el autor.

En la parte práctica del trabajo anterior, desarrollamos el objeto CNeuronPeriodNorm, diseñado para extraer las componentes periódicas de una serie temporal. Este componente constituyó el primer paso hacia la implementación del framework SCNN en el entorno MQL5 y se utilizará para extraer las componentes de largo plazo y de corto plazo. Gracias al uso de kernels de OpenCL, se garantiza un procesamiento paralelo eficiente de los datos y la compatibilidad con el mecanismo de propagación inversa del error, lo que hace que este módulo sea apto para su uso dentro de estructuras neuronales entrenables.

Más adelante mostraremos que, gracias a algunas transformaciones sencillas de los datos, CNeuronPeriodNorm también puede adaptarse para extraer la componente estacional, lo cual le confiere una mayor versatilidad. Hoy daremos el siguiente paso: comenzaremos a crear un objeto encargado de extraer la componente conjunta, que refleja los cambios interrelacionados entre varias variables de una serie temporal. Este módulo desempeñará un papel fundamental en el modelado de oscilaciones sincronizadas y movimientos conjuntos anómalos, lo cual resulta especialmente relevante en el contexto del análisis de mercado multivariante.



Extracción de la componente conjunta

La experiencia práctica que hemos adquirido nos indica que, para implementar correctamente el modelo, no basta con un mero análisis temporal. También resulta esencial considerar las dependencias espaciales, es decir, las relaciones entre las distintas variables en cada momento concreto. De eso precisamente se encarga el mecanismo de extracción de la componente conjunta, que ayuda a detectar movimientos en la misma dirección o en direcciones opuestas entre las señales de las distintas secuencias univariantes de la serie temporal multimodal analizada. Esta coevolución puede ser estable o cambiar dinámicamente, por lo que requiere un enfoque adaptativo para su normalización.

En el framework SCNN, esta tarea se resuelve mediante la incorporación de una normalización ponderada espacialmente con el uso de un mecanismo de atención. Pasamos ahora al siguiente paso de la implementación, en el que presentaremos nuestra propia visión del algoritmo propuesto, basándonos en las necesidades prácticas del modelado financiero. Delegamos, como es habitual, la mayor parte de los cálculos, incluidos el promediado ponderado y la estandarización adaptativa, al contexto de OpenCL. Esto permite no solo acelerar el procesamiento, sino también mantener la flexibilidad a la hora de configurar la arquitectura.

Para ello, crearemos un nuevo kernel llamado AdaptSpatialNorm. El algoritmo del kernel implementa una normalización adaptativa de los datos de entrada teniendo en cuenta la atención entre las variables, lo que proporciona un procesamiento más preciso y sensible de las interrelaciones espaciales. La idea consiste en calcular, para cada punto temporal, el valor medio y la desviación estándar, ponderados según la máscara de atención. Esto permite no solo promediar todas las variables, sino también tener en cuenta su importancia relativa; es decir, la influencia de cada variable sobre un punto concreto en un contexto espacio-temporal.

__kernel void AdaptSpatialNorm(__global const float* inputs,
                               __global const float* attention,
                               __global float2* mean_stdevs,
                               __global float* outputs
                              )
  {
   const size_t i = get_global_id(0);
   const size_t a = get_local_id(1);
   const size_t v = get_global_id(2);
   const size_t total_inputs = get_global_size(0);
   const size_t total_local = get_local_size(1);
   const size_t variables = get_global_size(2);

En el kernel de cálculo, las operaciones se organizan en tres dimensiones: tiempo, variables y flujos locales. Cada flujo se encarga de procesar un valor concreto: una variable en un momento determinado. En primer lugar, se determina el desplazamiento de los índices, lo que permite a cada flujo acceder correctamente a las áreas de memoria correspondientes.

   __local float Temp[LOCAL_ARRAY_SIZE];
   const int shift_v = v * total_inputs;
   const int shift_out = shift_v + i;

A continuación, comienza la parte principal del trabajo, en la que se extraen los valores del array original y los coeficientes de atención correspondientes para todas las variables. Cada valor se multiplica por su peso y, a continuación, se realiza una suma local: primero para calcular la media y, después, para determinar la varianza.

   float mean = 0, stdev = 0;
   for(uint l = 0; l < variables; l += total_local)
     {
      const int shift_at = v * variables + (a + l);
      float val = IsNaNOrInf(inputs[(a + l) * total_inputs + i], 0);
      float att = IsNaNOrInf(attention[shift_at], 0);
      mean += LocalSum(val * att, 1, Temp);
      BarrierLoc;
      stdev += LocalSum(val * val * att, 1, Temp);
      BarrierLoc;
     }

Para garantizar la corrección de los cálculos paralelos entre flujos, se utiliza una barrera de sincronización, lo que permite evitar conflictos de acceso al trabajar con memoria compartida.

En este punto conviene prestar especial atención al aspecto técnico de la organización de los cálculos. Para realizar correctamente las operaciones de suma de los valores procedentes de diferentes flujos, en OpenCL se crean los denominados grupos de trabajo. Los flujos dentro de estos grupos pueden intercambiar datos a través de la memoria local, que, a diferencia de la memoria global, es mucho más rápida y permite implementar de forma eficaz operaciones colectivas, como la reducción o la suma.

Sin embargo, esta arquitectura presenta una limitación inherente: el tamaño del grupo de trabajo no puede superar un determinado límite de hardware establecido por la tarjeta gráfica u otro dispositivo computacional. En la práctica, esto significa que el número de flujos que procesan simultáneamente las variables de un mismo grupo es limitado y no siempre se corresponde con la dimensionalidad del array de entrada que se está procesando.

Para sortear esta limitación, en el cuerpo del kernel se ha implementado un ciclo especial que permite recorrer paso a paso todas las variables, incluso si su número supera considerablemente el tamaño del grupo de trabajo. El espacio total de características se divide en bloques, que se procesan por lotes, y los valores se agregan secuencialmente. Este enfoque mantiene la eficiencia de los cálculos y garantiza la correcta ejecución de todas las operaciones matemáticas, independientemente del número de variables que intervengan en el modelo.

Esta técnica hace que la implementación sea robusta frente al escalado y apta para ejecutarse en distintos dispositivos con características diferentes, y que el propio modelo sea más flexible y portable.

Una vez procesados todos los valores, solo un flujo de cada grupo local se encarga de la normalización final. Calcula la varianza restando el cuadrado de la media de la suma de los cuadrados de los valores y, a continuación, extrae la raíz cuadrada, obteniendo así la desviación estándar. Para evitar la división por cero, se ha previsto una protección: si el valor de la varianza es demasiado pequeño, esta se sustituye por uno.

   if(a == 0)
     {
      stdev -= mean * mean;
      stdev = IsNaNOrInf(sqrt(stdev), 1);
      if(stdev <= 0)
         stdev = 1;
      mean_stdevs[shift_out] = (float2)(mean, stdev);
      outputs[shift_out] = IsNaNOrInf((inputs[shift_out] - mean) / stdev, 0);
     }
  }

A continuación, se calcula el valor normalizado de la entrada, que es el que se guarda en el array de salida. Al mismo tiempo, se guardan la media y la desviación estándar calculadas; serán útiles más adelante.

La lógica implementada combina la agregación de información sobre la distribución espacial de los datos y la normalización según dicha distribución. Esto permite que el modelo reaccione con sensibilidad ante las dependencias ocultas entre las variables y, por lo tanto, capte con mayor precisión la dinámica de los procesos temporales complejos, lo cual resulta especialmente relevante en la predicción financiera.

Para que el entrenamiento del modelo sea completo, no solo es necesaria una pasada directa con el cálculo de los valores normalizados, sino también una pasada inversa correcta que garantice la transmisión de los gradientes de error de vuelta a los datos de entrada y los parámetros. En el contexto de nuestro kernel AdaptSpatialNorm, para la normalización ponderada espacialmente, el siguiente paso lógico es implementar el kernel de pasada inversa AdaptSpatialNormGrad, que se encarga de distribuir los gradientes de error entre los datos originales y los coeficientes de atención.

__kernel void AdaptSpatialNormGrad(__global const float* inputs,
                                   __global float* inputs_gr,
                                   __global const float* attention,
                                   __global float* attention_gr,
                                   __global const float2* mean_stdevs,
                                   __global const float2* mean_stdevs_gr,
                                   __global const float* outputs_gr,
                                   const uint total_inputs
                                  )
  {
   const size_t i = get_global_id(0);              // principal
   const size_t loc = get_local_id(1);             // local a suma
   const size_t v = get_global_id(2);              // variable
   const size_t total_main = get_global_size(0);   // total
   const size_t total_loc = get_local_size(1);     // dimensión local
   const size_t variables = get_global_size(2);    // total de variables
//---
   __local float Temp[LOCAL_ARRAY_SIZE];

El algoritmo se basa en la distribución de la carga computacional entre flujos, donde cada flujo procesa una combinación concreta de índices correspondientes a un corte temporal y una variable. Para almacenar los valores intermedios se utiliza la memoria local, lo que acelera los procesos de suma y agregación de datos dentro de los grupos de trabajo.

En primer lugar, se calculan los gradientes con respecto a los datos de entrada. Para cada elemento del array de entrada se toman los parámetros de atención correspondientes y los gradientes de salida. A continuación, se calcula un gradiente que tiene en cuenta las derivadas parciales con respecto a los parámetros de normalización (la media y la desviación estándar), que también intervienen en la propagación inversa del error. Se acumulan todas las sumas parciales y, a continuación, el resultado se escribe en el array de gradientes de los datos de entrada.

//--- Inputs gradient
     {
      if(i < total_inputs)
        {
         float grad = 0;
         int shift_in = v * total_inputs + i;
         float x = IsNaNOrInf(inputs[shift_in], 0);
         for(int l = 0; l < variables; l += total_loc)
           {
            if((l + loc) >= variables)
               break;
            int shift_out = i + (l + loc) * total_inputs;
            float att = IsNaNOrInf(attention[(l + loc) * variables + v], 0);
            float out_gr = IsNaNOrInf(outputs_gr[shift_out], 0);
            float2 ms = mean_stdevs[shift_out];
            float2 ms_gr = mean_stdevs_gr[shift_out];
            float dy = (1 - att) * (1 / ms.y - (x - ms.x) * att * x / pow(ms.y, 3.0f));
            float dmean = IsNaNOrInf(ms_gr.x * att, 0);
            float dstd = IsNaNOrInf(ms_gr.y * x * (att - att * att) / ms.y, 0);
            grad += IsNaNOrInf(dy * out_gr + dmean + dstd, 0);
           }
         grad = LocalSum(grad, 1, Temp);
         if(loc == 0)
            inputs_gr[shift_in] = grad;
        }
      BarrierLoc;
     }

Anticipándonos un poco, conviene señalar un detalle importante: los parámetros de normalización que hemos guardado (la media y la desviación estándar) no son un producto secundario de los cálculos. Por el contrario, participan activamente en las operaciones posteriores del framework SCNN, formando una especie de rama auxiliar de procesamiento de datos. Por lo tanto, el gradiente de error acumulado en estos parámetros durante las etapas posteriores de la pasada directa también debe propagarse hasta el nivel de los datos originales.

Esto significa que, durante la pasada inversa, no nos limitamos únicamente al flujo de información principal: además, tenemos en cuenta la contribución asociada a las derivadas con respecto a las estadísticas de normalización almacenadas y la sumamos al gradiente final de la entrada. Este enfoque garantiza la integridad del grafo computacional y permite que el modelo ajuste de forma eficaz todos los parámetros que influyen en el resultado, incluidos aquellos que están relacionados indirectamente con la entrada a través de mecanismos de normalización.

A continuación, se calculan los gradientes con respecto a los parámetros de atención. Para cada elemento de los pesos de atención, el algoritmo recorre todos los puntos temporales correspondientes, utilizando los valores de los datos de entrada y los gradientes ya calculados con respecto a la salida. Los cálculos también tienen en cuenta la influencia de la atención en la normalización a través de la media y la desviación estándar. Los valores finales se acumulan utilizando la memoria local y se guardan en el array correspondiente de gradientes de atención.

//--- Gradiente de atención
     {
      if(i < variables)
        {
         float grad = 0;
         int shift_att = v * variables + i;
         float att = IsNaNOrInf(attention[shift_att], 0);
         for(int l = 0; l < total_inputs; l += total_loc)
           {
            if((l + loc) >= total_inputs)
               break;
            int shift_out = (l + loc) + v * total_inputs;
            int shift_in = (l + loc) + i * total_inputs;
            float x = IsNaNOrInf(inputs[shift_in], 0);
            float out_gr = IsNaNOrInf(outputs_gr[shift_out], 0);
            float2 ms = mean_stdevs[shift_out];
            float2 ms_gr = mean_stdevs_gr[shift_out];
            float dy = -x / ms.y - (x - ms.x) * x * x * (1 - 2 * att) / (2 * pow(ms.y, 3.0f));
            float dmean = IsNaNOrInf(ms_gr.x * x, 0);
            float dstd = IsNaNOrInf(ms_gr.y * x * x * (1 - 2 * att) / (2 * ms.y), 0);
            grad += IsNaNOrInf(dy * out_gr + dmean + dstd, 0);
           }
         grad = LocalSum(grad, 1, Temp);
         if(loc == 0)
            attention_gr[shift_att] = grad;
        }
     }
  }

Es importante señalar que la implementación tiene en cuenta la posibilidad de valores numéricos no válidos, como NaN o infinitos, y evita su propagación, lo que aumenta la robustez del algoritmo.

El enfoque general, basado en el procesamiento cíclico de los datos y el uso de memoria local, garantiza la escalabilidad y la eficiencia, lo que permite procesar grandes conjuntos de variables y pasos temporales, incluso cuando su tamaño supera el tamaño de los grupos de trabajo.

De este modo, el kernel AdaptSpatialNormGrad garantiza un cálculo preciso y eficiente de los gradientes de los parámetros clave de normalización, teniendo en cuenta los pesos de atención espaciales, lo que permite integrar este mecanismo en modelos complejos con entrenamiento mediante propagación inversa del error.

Para integrar en la arquitectura general el algoritmo de normalización ponderada espacialmente descrito anteriormente, en el programa principal se crea un objeto especializado CNeuronAdaptSpatialNorm. Esta clase hereda las interfaces básicas de CNeuronBaseOCL, lo que le permite integrarse de forma natural en la jerarquía de componentes neuronales. El objetivo principal del objeto es garantizar la correcta organización de los cálculos, tanto en la pasada directa como en la pasada inversa, así como procesar correctamente todos los componentes auxiliares relacionados con el mecanismo de atención.

A continuación se muestra la estructura de la nueva clase.

class CNeuronAdaptSpatialNorm :  public CNeuronBaseOCL
  {
protected:
   uint                    iVariables;
   uint                    iCount;
   //---
   CParams                 cEn;
   CNeuronTransposeOCL     cEnT;
   CNeuronBaseOCL          cEnEnT;
   CNeuronSoftMaxOCL       cAttan;
   CNeuronBaseOCL          cMeanSTDevs;
   //---
   virtual bool      AdaptSpatialNorm(CNeuronBaseOCL *NeuronOCL);
   virtual bool      AdaptSpatialNormGrad(CNeuronBaseOCL *NeuronOCL);
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronAdaptSpatialNorm(void) : iCount(0), iVariables(1) {};
                    ~CNeuronAdaptSpatialNorm(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint units_count, uint variables,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual bool      Save(const int file_handle) override;
   virtual bool      Load(const int file_handle) override;
   //---
   virtual int       Type(void) override const  {  return defNeuronAdaptSpatialNorm; }
   virtual void      SetOpenCL(COpenCLMy *obj) override;
   //---
   CNeuronBaseOCL*   GetMeanSTDevs(void) { return cMeanSTDevs.AsObject(); }
   virtual uint      GetVariables(void) const { return iVariables; }
   virtual uint      GetUnits(void) const { return iCount; }
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
  };

Dentro de la clase vemos una serie de miembros protegidos, cada uno de los cuales desempeña un papel importante en el funcionamiento del componente. Las variables iVariables e iCount establecen las dimensiones del tensor de datos de entrada y definen los límites espaciales del procesamiento. Los objetos internos cEn, cEnT, cEnEnT y cAttan forman y entrenan sucesivamente la matriz de atención. El componente cMeanSTDevs cierra esta cadena y se encarga de almacenar y actualizar los parámetros de normalización que se utilizan para escalar los datos de entrada teniendo en cuenta los pesos de atención calculados.

Todos los objetos internos se declaran de forma estática, lo que permite simplificar considerablemente la gestión de la memoria y la inicialización. Gracias a ello, la solución resulta más fiable y su comportamiento es más predecible: no es necesario asignar ni liberar recursos manualmente. El constructor y el destructor de la clase permanecen vacíos, ya que los objetos ya existen en el momento de crear una instancia de la clase y se destruyen automáticamente al finalizar su ciclo de vida.

La inicialización de todos los componentes internos de la clase se lleva a cabo de forma centralizada en el método Init. Este método recibe como entrada unos parámetros clave que permiten determinar de forma unívoca la arquitectura del objeto que se va a crear, incluida la dimensionalidad de los datos de entrada. Todo el proceso se desarrolla de forma secuencial y lógica, con una clara correspondencia con la estructura interna del modelo.

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

En la primera etapa se invoca el método homónimo de la clase base CNeuronBaseOCL, en el que se lleva a cabo la inicialización primaria del nodo neuronal. El tamaño del búfer de resultados se calcula multiplicando el número de variables por la longitud de la secuencia. Si la inicialización del nivel base se ha realizado correctamente, se definen los parámetros del tensor de datos de entrada y se guardan en las variables locales iVariables e iCount.

   iVariables = variables;
   iCount = units_count;
//---
   uint dimension = (iVariables + 1) / 2;
   uint index = 0;
   if(!cEn.Init(0, index, OpenCL, iVariables * dimension, optimization, iBatch))
      return false;
   cEn.SetActivationFunction(None);

A continuación, comienza la configuración secuencial de los componentes internos responsables de la formación de la matriz de atención. El objeto cEn se inicializa en primer lugar. Representa un tensor de parámetros entrenables. Su dimensión se define como el producto del número de variables por la mitad de dicho número. Esta reducción de dimensionalidad permite destacar las características más significativas para su posterior procesamiento. La función de activación se desactiva explícitamente en este caso, ya que en esta fase se requiere una transformación lineal pura sin distorsión de la señal de salida.

A continuación, se inicializa el objeto de transposición cEnT, que permite obtener una copia transpuesta del tensor de parámetros entrenables.

   index++;
   if(!cEnT.Init(0, index, OpenCL, iVariables, dimension, optimization, iBatch))
      return false;
   cEnT.SetActivationFunction(None);
   index++;
   if(!cEnEnT.Init(0, index, OpenCL, iVariables * iVariables, optimization, iBatch))
      return false;
   cEnEnT.SetActivationFunction(None);

El objeto cEnEnT desempeña un papel fundamental en la construcción del mecanismo de atención dentro del framework SCNN. Está destinado a almacenar los resultados de la multiplicación matricial del tensor de parámetros entrenables por su copia transpuesta. Esta operación genera una matriz simétrica que refleja las interdependencias y la intensidad de la correlación entre las variables dentro de un mismo paso temporal. La estructura obtenida permite establecer explícitamente cuáles de las características analizadas ejercen la mayor influencia unas sobre otras.

El objeto cAttan completa la construcción del mecanismo de atención. Toma como entrada una matriz de correlación y le aplica una normalización SoftMax, distribuyendo la atención entre las variables. El número de cabezas de atención se corresponde con el número de filas de la matriz de correlación, lo que permite adaptarse con flexibilidad a la estructura de los datos.

   index++;
   if(!cAttan.Init(0, index, OpenCL, iVariables * iVariables, optimization, iBatch))
      return false;
   cAttan.SetHeads(iVariables);

Para terminar, se configura el objeto cMeanSTDevs, destinado a almacenar pares de valores (media y desviación estándar) calculados mediante la normalización ponderada espacialmente. La dimensionalidad de este objeto es el doble del número de neuronas de salida, ya que hay que almacenar dos parámetros por cada elemento de los resultados.

   index++;
   if(!cMeanSTDevs.Init(0, index, OpenCL, 2 * Neurons(), optimization, iBatch))
      return false;
   cMeanSTDevs.SetActivationFunction(None);
//---
   return true;
  }

De este modo, el método Init crea y configura todos los componentes necesarios para el correcto funcionamiento del mecanismo de normalización ponderada espacialmente en el framework SCNN. La estructura del código refleja el rigor y la modularidad de la arquitectura, en la que cada bloque tiene una función bien definida e interactúa con los demás siguiendo una secuencia determinada.

Una vez inicializados correctamente todos los componentes internos, pasamos a la construcción del mecanismo de pasada directa, implementado en el método feedForward. Aquí comienza la etapa clave del funcionamiento de la capa, en la que se forma la matriz de atención y se lleva a cabo la normalización ponderada espacialmente de los datos de entrada.

bool CNeuronAdaptSpatialNorm::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(bTrain)
     {
      if(!cEn.FeedForward())
         return false;
      if(!cEnT.FeedForward(cEn.AsObject()))
         return false;
      if(!MatMul(cEn.getOutput(), cEnT.getOutput(), cEnEnT.getOutput(),
                 iVariables, cEnT.GetWindow(), iVariables, 1, false))
         return false;
      if(!cAttan.FeedForward(cEnEnT.AsObject()))
         return false;
     }
//---
   return AdaptSpatialNorm(NeuronOCL);
  }

En este punto conviene destacar especialmente un aspecto arquitectónico importante: la generación de los parámetros de atención se realiza exclusivamente en modo de entrenamiento. Esto se hace deliberadamente, ya que la propia matriz de atención es un componente estático del modelo: no se adapta a los datos de entrada concretos durante su uso. En otras palabras, entrenamos coeficientes de ponderación universales que reflejan relaciones estables entre las variables dentro de la serie temporal. Gracias a este enfoque se consigue que el comportamiento del modelo sea estable con datos nuevos, y se puede omitir el cálculo de la atención durante la inferencia, lo que acelera considerablemente el proceso sin perder calidad en la normalización.

A continuación, en la cadena de la pasada directa, se invoca el método AdaptSpatialNorm, que actúa como envoltorio del kernel del mismo nombre. Es precisamente este paso el que transfiere el control al contexto de OpenCL, donde tienen lugar los cálculos principales: la normalización de los datos de entrada teniendo en cuenta los pesos de atención.

Conviene decir unas palabras sobre la organización del funcionamiento del método envoltorio para el kernel de OpenCL. Aunque la lógica general del algoritmo no ha cambiado, se han introducido mejoras de diseño en la implementación con el fin de aumentar la legibilidad y la fiabilidad del código. Esto atañe, ante todo, a la organización de las llamadas a las funciones de OpenCL.

Se han introducido macros de sustitución que permiten simplificar y estandarizar la configuración de los argumentos del kernel y su ejecución. Por ejemplo, la macro setBuffer encapsula la llamada a OpenCL.SetArgumentBuffer con gestión automática de errores y generación de información de depuración, incluidos el nombre del kernel, el código de error y la línea en la que se produjo el fallo. De forma similar funciona setArgument para establecer valores escalares.

#define setBuffer(kernel, id, buffer)  if(!OpenCL.SetArgumentBuffer(kernel, id, buffer)) { \
                                          printf("Error of set parameter kernel %s: %d; line %d",
                                                 OpenCL.GetKernelName(kernel), GetLastError(), __LINE__); \
                                          return false; }
#define setArgument(kernel, id, value) if(!OpenCL.SetArgument(kernel, id, value)) { \
                                          printf("Error of set parameter kernel %s: %d; line %d",
                                                 OpenCL.GetKernelName(kernel), GetLastError(), __LINE__); \
                                          return false; }

El kernel se lanza mediante las macros kernelExecute y kernelExecuteLoc, que ocultan toda la infraestructura técnica relacionada con la inicialización de la rejilla de ejecución y, en caso de fallo, muestran automáticamente una descripción textual detallada del error, asociada al nombre de la función llamada.

#define kernelExecute(kernel,offset,global)  if(!OpenCL.Execute(kernel, global.Size(), offset, global)) { \
                                                string error; \
                                                CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error); \
                                                printf("Error of execution kernel %s %s: %s", __FUNCSIG__,
                                                        OpenCL.GetKernelName(kernel), error); \
                                                return false; }
#define kernelExecuteLoc(kernel,offset,global,local)  if(!OpenCL.Execute(kernel, global.Size(), offset,
                                                                         global, local)) {  string error; \
                                                         CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION,
                                                                                                          error); \
                                                         printf("Error of execution kernel %s %s: %s", __FUNCSIG__,
                                                                 OpenCL.GetKernelName(kernel), error); \
                                                         return false; }

Gracias a esta estructura, el código del método resulta más compacto, más claro desde el punto de vista lógico y más fácil de ampliar cuando se añaden nuevos kernels o parámetros.

Además, como ya se ha mencionado al describir los kernels, antes de ejecutarlos hay que tener en cuenta las limitaciones técnicas del dispositivo de cómputo; en concreto, el tamaño máximo del grupo de trabajo que admite la plataforma OpenCL. Estos parámetros varían en función del modelo de tarjeta gráfica y pueden influir considerablemente en la correcta ejecución del código.

En el cuerpo del método AdaptSpatialNorm, esto se lleva a cabo mediante una llamada a CLGetDeviceInfo, con la que se solicita el tamaño admisible del grupo de trabajo para cada dimensión. Los valores devueltos se almacenan en la estructura union sizes, lo que permite acceder a ellos cómodamente como un array de números enteros, sin tener que preocuparse por las particularidades de la representación interna de los datos.

bool CNeuronAdaptSpatialNorm::AdaptSpatialNorm(CNeuronBaseOCL *NeuronOCL)
  {
   if(!OpenCL || !NeuronOCL)
      return false;
   uint global_work_offset[3] = { 0 };
   union sizes
     {
      long data[3];
      uchar cdata[24];
     } max_workgroup_size;
   uint size = 0;
   if(!CLGetDeviceInfo(OpenCL.GetContext(), CL_DEVICE_MAX_WORK_ITEM_SIZES, max_workgroup_size.cdata, size))
      return false;

Una vez obtenidos estos parámetros, formamos el array global_work_size, que define la cuadrícula global de cálculo. Además, en la segunda dimensión (que es la que determina la anchura del grupo local), nos limitamos explícitamente al valor mínimo entre el número de variables iVariables y el tamaño máximo permitido por el dispositivo. Esto garantiza que el ciclo definido en el kernel se ejecute correctamente incluso con un gran volumen de datos.

   uint global_work_size[] = { iCount, MathMin(iVariables, uint(max_workgroup_size.data[1])), iVariables};
   uint local_work_size[] = { 1, global_work_size[1], 1};
//---
   uint kernel = def_k_AdaptSpatialNorm;
   setBuffer(kernel, def_k_asn_inputs, NeuronOCL.getOutputIndex())
   setBuffer(kernel, def_k_asn_mean_stdevs, cMeanSTDevs.getOutputIndex())
   setBuffer(kernel, def_k_asn_attention, cAttan.getOutputIndex())
   setBuffer(kernel, def_k_asn_outputs, getOutputIndex())
   kernelExecuteLoc(kernel, global_work_offset, global_work_size, local_work_size)
//---
   return true;
  }

Al mismo tiempo, se crea el array local_work_size, en el que se fija de forma explícita el número de flujos en la segunda dimensión. Este enfoque permite utilizar eficazmente la memoria local al procesar vectores de características.

A continuación, configuramos de forma secuencial los búferes de argumentos del kernel mediante las macros setBuffer definidas anteriormente, lo que garantiza una inicialización uniforme y segura de los parámetros. La macro kernelExecuteLoc completa el proceso lanzando el kernel AdaptSpatialNorm teniendo en cuenta todas las restricciones establecidas previamente y la estructura de la malla de cálculo.

De este modo, no solo implementamos un envoltorio para la llamada al kernel, sino también un mecanismo flexible que permite adaptar el algoritmo a las capacidades técnicas concretas del equipo de destino, lo que garantiza la compatibilidad y la estabilidad operativa en una amplia gama de dispositivos.

El siguiente paso importante es la propagación del gradiente del error, implementada en el método calcInputGradients. Es precisamente aquí donde tiene lugar la propagación inversa de la señal a través de los objetos internos. Los errores acumulados se transforman de forma secuencial, volviendo a los parámetros entrenables del nivel inicial. Esta parte del algoritmo es especialmente importante, ya que permite actualizar los pesos basándose en información actual sobre la discrepancia entre los valores previstos y los valores reales.

bool CNeuronAdaptSpatialNorm::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!AdaptSpatialNormGrad(NeuronOCL))
      return false;

El proceso comienza con la llamada al método AdaptSpatialNormGrad, que, al igual que su homólogo de la pasada directa, es un envoltorio del kernel de OpenCL correspondiente. Se encarga de propagar el gradiente desde las salidas del bloque de normalización hacia abajo, hasta los datos de entrada y los parámetros entrenables de la matriz de atención.

La lógica de construcción del método AdaptSpatialNormGrad reproduce prácticamente por completo la estructura utilizada anteriormente al poner en cola de ejecución el kernel de la pasada directa. También definimos los tamaños de trabajo globales y locales, configuramos los búferes mediante macros y pasamos los parámetros al kernel en el orden adecuado. Sin embargo, hay una diferencia importante que se deriva de la propia naturaleza de la operación de propagación inversa del error.

Si en la pasada directa cada elemento se procesaba de forma independiente, al calcular los gradientes la situación cambia: para cada elemento, a nivel de los datos de entrada, es necesario agregar la contribución del gradiente de todas las secuencias univariantes, a través de los pesos de atención. Esto requiere un acceso simultáneo a numerosos componentes diferentes del tensor de salida, lo que implica un esquema de suma más complejo.

Los coeficientes de atención también requieren un enfoque específico. En este caso, es necesario integrar con precisión el gradiente a lo largo de la dimensión temporal, es decir, recopilar información sobre todas las posiciones de la secuencia en las que se ha aplicado el coeficiente correspondiente. Se trata de una operación que consume bastantes recursos, sobre todo en el caso de tensores de entrada de gran tamaño, por lo que se presta especial atención al tamaño del grupo de trabajo.

En consecuencia, para plantear la tarea de forma correcta y eficiente, el tamaño del grupo de trabajo se toma como el máximo entre los ejes a lo largo de los cuales es necesario agregar los gradientes, teniendo siempre en cuenta la restricción del valor máximo admisible que admite un dispositivo OpenCL concreto. Este enfoque permite no solo cumplir los requisitos técnicos, sino también optimizar el rendimiento, evitando desbordamientos innecesarios y conflictos de acceso a la memoria.

Una vez finalizados los cálculos en OpenCL, el método continúa su ejecución de la forma habitual. Propagamos de forma secuencial el gradiente de error por los objetos encargados de formar la matriz de atención.

   if(!cEnEnT.CalcHiddenGradients(cAttan.AsObject()))
      return false;
   if(!MatMulGrad(cEn.getOutput(), cEn.getPrevOutput(),
                  cEnT.getOutput(), cEnT.getGradient(),
                  cEnEnT.getGradient(), iVariables,
                  cEnT.GetWindow(), iVariables, 1, false))
      return false;
   if(!cEn.CalcHiddenGradients(cEnT.AsObject()) ||
      !SumAndNormilize(cEn.getGradient(), cEn.getPrevOutput(), cEn.getGradient(),
                       cEnT.GetWindow(), false, 0, 0, 0, 1))
      return false;
   if(cEn.Activation() != None)
      if(!DeActivation(cEn.getOutput(), cEn.getGradient(), cEn.getGradient(), cEn.Activation()))
         return false;
//---
   return true;
  }

De este modo, todo el método calcInputGradients constituye una secuencia bien estructurada de pasos que garantiza la propagación secuencial y correcta del error inversa a lo largo de la red.

El método de optimización de parámetros se ha implementado aquí de forma muy concisa y, al mismo tiempo, de manera sumamente focalizada. Toda la tarea se reduce a transferir el control a un único objeto interno: cEn, que contiene los parámetros entrenables de atención. Este objeto constituye el núcleo del bloque paramétrico, a partir del cual se construye la matriz de correlación. Este objeto es el único que asume toda la responsabilidad de la parte entrenable del módulo.

bool CNeuronAdaptSpatialNorm::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   return cEn.UpdateInputWeights();
  }

El código completo de la clase CNeuronAdaptSpatialNorm y de todos sus métodos se incluye en el archivo adjunto para su estudio independiente.



Codificador SCNN

Una vez que hemos creado los objetos encargados de aislar los componentes individuales de la serie temporal, el siguiente paso lógico es integrarlos en una estructura única y coherente: el codificador SCNN. En esta fase, los bloques formados previamente se ensamblan siguiendo una secuencia claramente definida, lo que garantiza un procesamiento fiable, estable y, al mismo tiempo, flexible de los datos analizados.

La característica clave del codificador SCNN radica en su arquitectura modular. Se basa en cuatro ramas paralelas, cada una de las cuales se encarga de su propia escala de análisis. Una detecta las tendencias a largo plazo; otra, los patrones estacionales; una tercera, las fluctuaciones a corto plazo; y una cuarta normaliza los datos de forma adaptativa, reforzando las relaciones entre las distintas características.

Todos estos algoritmos se implementan cuidadosamente dentro del objeto especializado CNeuronSCNNEncoder. Su estructura plasma la lógica descrita y sienta las bases para formar una representación generalizada de la secuencia: comprimida, significativa y apta para la predicción posterior.

class CNeuronSCNNEncoder   :  public CNeuronTransposeOCL
  {
protected:
   CNeuronPeriodNorm       cLongNorm;
   CNeuronTransposeVRCOCL  cSeasonTransp;
   CNeuronPeriodNorm       cSeasonNorm;
   CNeuronTransposeVRCOCL  cUnSeasonTransp;
   CNeuronPeriodNorm       cShortNorm;
   CNeuronAdaptSpatialNorm cAdaptSpatNorm;
   CNeuronBaseOCL          cConcatenated;
   CNeuronSwiGLUOCL        cProjection;
   CNeuronTransposeOCL     cTranspose;
   CNeuronConvOCL          caFusion[2];
   CNeuronBaseOCL          cFusionOut;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)  override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL)  override;

public:
                     CNeuronSCNNEncoder(void) {};
                    ~CNeuronSCNNEncoder(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint units_count, uint variables, uint forecast,
                          uint season_period, uint short_period,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual bool      Save(const int file_handle) override;
   virtual bool      Load(const int file_handle) override;
   //---
   virtual int       Type(void) override const  {  return defNeuronGinAR; }
   virtual void      TrainMode(bool flag) override;
   virtual void      SetOpenCL(COpenCLMy *obj);
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
  };

En la estructura de la nueva clase observamos toda una serie de objetos internos, cada uno de los cuales desempeña una función estrictamente definida dentro del codificador. Conoceremos su función un poco más adelante, a medida que se vaya desvelando la lógica del algoritmo. Ahora bien, conviene destacar un aspecto arquitectónico importante: todos estos componentes se declaran de forma estática. Esto significa que se reserva memoria para ellos de antemano y que su ciclo de vida se ajusta estrictamente al ciclo de vida del propio objeto CNeuronSCNNEncoder. Esta solución permite prescindir de gestión adicional: el constructor y el destructor de la clase permanecen vacíos.

La inicialización de todos los objetos internos declarados y heredados se lleva a cabo en el método Init, cuyos parámetros contienen todas las constantes que definen la arquitectura del objeto.

bool CNeuronSCNNEncoder::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                              uint units_count, uint variables, uint forecast,
                              uint season_period, uint short_period,
                              ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronTransposeOCL::Init(numOutputs, myIndex, open_cl, units_count + forecast, variables,
                                                                       optimization_type, batch))
      return false;
   SetActivationFunction(None);

Todo comienza con la llamada al método base homónimo de la clase padre, donde se definen los parámetros clave de la arquitectura. Tras una inicialización correcta, se activa la función de activación neutra, lo que evita que se produzcan distorsiones no lineales en esta fase.

A continuación, se crean sucesivamente los módulos encargados del preprocesamiento de la serie temporal. En primer lugar, se pone en marcha el bloque de normalización de la componente a largo plazo.

   uint index = 0;
   if(!cLongNorm.Init(0, index, OpenCL, 1, units_count, variables, optimization, iBatch))
      return false;
   cLongNorm.SetActivationFunction(None);

El siguiente paso importante es aislar la componente estacional. En la concepción original de los autores del framework SCNN, este paso se interpreta como una normalización de los datos con un paso correspondiente al periodo de estacionalidad. A primera vista, podría parecer que para ello se necesita algún tipo de módulo especializado. Sin embargo, abordamos la resolución del problema de una forma más racional, siguiendo los principios clásicos de la ingeniería de sistemas.

Hemos desarrollado un objeto de normalización universal, diseñado para procesar datos según periodos fijos. Por sí solo, no contiene información sobre la estructura estacional; esta aparece en cuanto cambiamos la representación del array analizado. Basta con transponer la secuencia de pasos temporales por el valor del periodo de estacionalidad para que los datos se agrupen automáticamente en secuencias con el intervalo necesario. Estas secuencias recién formadas se ajustan perfectamente al formato que espera nuestro módulo de normalización por periodos.

De este modo, con un esfuerzo mínimo, obtenemos un potente mecanismo para aislar la componente estacional, sin introducir cambios adicionales en la estructura del propio objeto normalizador. Lo único que se requiere es reorganizar adecuadamente los datos de entrada; a partir de ahí, ya entra en funcionamiento el algoritmo probado y depurado.

   index++;
   if(!cSeasonTransp.Init(0, index, OpenCL, variables, units_count / season_period, season_period,
                                                                            optimization, iBatch))
      return false;
   cSeasonTransp.SetActivationFunction(None);
   index++;
   if(!cSeasonNorm.Init(0, index, OpenCL, cSeasonTransp.GetCount(), season_period, variables,
                                                                       optimization, iBatch))
      return false;
   cSeasonNorm.SetActivationFunction(None);
   index++;
   if(!cUnSeasonTransp.Init(0, index, OpenCL, variables, season_period, cSeasonTransp.GetCount(),
                                                                           optimization, iBatch))
      return false;
   index++;

Tras aislar la componente estacional, devolvemos los datos a su representación original mediante una transposición inversa y activamos el bloque de normalización a corto plazo.

   if(!cShortNorm.Init(0, index, OpenCL, units_count / short_period, short_period, variables, optimization, iBatch))
      return false;
   cSeasonNorm.SetActivationFunction(None);
   index++;
   if(!cAdaptSpatNorm.Init(0, index, OpenCL, units_count, variables, optimization, iBatch))
      return false;
   cAdaptSpatNorm.SetActivationFunction(None);

A continuación, se aplica una normalización espacial adaptativa que tiene en cuenta las relaciones entre las variables de la secuencia de entrada.

Una vez finalizada la preparación de los datos procedentes de diversas fuentes, se crea un módulo de combinación que acumula las salidas de todos los bloques de normalización en una única matriz de características. El tamaño de este módulo se calcula de forma dinámica, teniendo en cuenta tanto las salidas principales como los valores estadísticos asociados a cada bloque de normalización.

   index++;
   uint concatSize = units_count * variables;                                         //entradas
   concatSize += cLongNorm.Neurons() + cLongNorm.GetMeanSTDevs().Neurons();           // a largo plazo
   concatSize += cSeasonNorm.Neurons() + cSeasonNorm.GetMeanSTDevs().Neurons();       // temporadas
   concatSize += cShortNorm.Neurons() + cShortNorm.GetMeanSTDevs().Neurons();         // corto plazo
   concatSize += cAdaptSpatNorm.Neurons() + cAdaptSpatNorm.GetMeanSTDevs().Neurons(); // espacial
   if(!cConcatenated.Init(0, index, OpenCL, concatSize, optimization, iBatch))
      return false;
   cConcatenated.SetActivationFunction(None);

El siguiente elemento lógico es el bloque de proyección, que permite comprimir y reestructurar eficazmente el espacio multidimensional de características.

   index++;
   if(!cProjection.Init(0, index, OpenCL, concatSize / variables, concatSize / variables, 
                             units_count + forecast, 1, variables, optimization, iBatch))
      return false;

Su salida se transmite al módulo de transposición, que realiza la conversión de los datos entre las representaciones temporal y de características, en función de los requisitos del procesamiento posterior.

   index++;
   if(!cTranspose.Init(0, index, OpenCL, variables, units_count + forecast, optimization, iBatch))
      return false;
   index++;
   if(!caFusion[0].Init(0, index, OpenCL, variables, variables, variables, units_count + forecast,
                                                                            optimization, iBatch))
      return false;
   caFusion[0].SetActivationFunction(TANH);
   index++;
   if(!caFusion[1].Init(0, index, OpenCL, variables, variables, variables, units_count + forecast,
                                                                            optimization, iBatch))
      return false;
   caFusion[1].SetActivationFunction(SIGMOID);
   index++;
   if(!cFusionOut.Init(0, index, OpenCL, caFusion[0].Neurons(), optimization, iBatch))
      return false;
//---
   return true;
  }

La etapa final incluye dos capas convolucionales paralelas que implementan distintos mecanismos de filtrado de la señal. Una de ellas utiliza la tangente hiperbólica, lo que confiere a la salida una saturación suave; la otra, la función sigmoide, lo que proporciona una limitación logística de los valores. Sus resultados convergen en el módulo de convolución final, que completa la construcción de la arquitectura.

Hemos realizado un trabajo considerable y la extensión del artículo ha aumentado notablemente. A continuación, nos espera el análisis del complejo algoritmo de la pasada directa y la pasada inversa de nuestro codificador, una etapa que requiere especial atención y concentración. Por eso, es lógico hacer una pequeña pausa para tener tiempo de asimilar y reflexionar sobre lo ya expuesto. En el próximo artículo continuaremos el trabajo, lo llevaremos hasta su conclusión lógica y comprobaremos la eficacia de las soluciones implementadas con datos históricos reales.



Conclusión

En este artículo hemos analizado en detalle la siguiente fase de la implementación del framework SCNN: la creación e integración del objeto de normalización espacial adaptativa, así como la combinación de los componentes principales en un único Codificador. Hemos demostrado cómo una arquitectura bien diseñada y el uso de tecnologías de computación modernas como OpenCL permiten identificar de forma eficaz los componentes estructurales de las series temporales y garantizar una preparación de datos de calidad para el pronóstico posterior.

En el próximo artículo continuaremos con el estudio, centrándonos en las pruebas y la evaluación práctica del funcionamiento del framework SCNN con datos reales, lo que nos permitirá valorar la relevancia aplicada de los métodos implementados y su eficacia en tareas de pronóstico financiero.


Referencias


Programas utilizados en el artículo

# Nombre Tipo Descripción
1 Study.mq5 asesor experto Asesor experto para el entrenamiento offline de modelos
2 StudyOnline.mq5 asesor experto Asesor experto para el entrenamiento online de modelos
3 Test.mq5 asesor experto Asesor experto para probar el modelo
4 Trajectory.mqh Biblioteca de clases Estructura para describir el estado del sistema y la arquitectura de los modelos
5 NeuroNet.mqh Biblioteca de clases Biblioteca de clases para crear una red neuronal
6 NeuroNet.cl Biblioteca Biblioteca de código del programa OpenCL

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

Archivos adjuntos |
MQL5.zip (2931.75 KB)
Red neuronal cuántica en MQL5 (Parte III): Procesador cuántico virtual con qubits Red neuronal cuántica en MQL5 (Parte III): Procesador cuántico virtual con qubits
Creamos un sistema de trading con un auténtico simulador cuántico en lugar de análogos matemáticos. El sistema utiliza tres qubits virtuales, puertas cuánticas y el principio de superposición para analizar los mercados. Se ha implementado como un asesor experto para MetaTrader 5 en MQL5. El principal logro es la transición de la simulación a principios cuánticos reales de procesamiento de la información financiera.
Optimización por comunidad de científicos — Community of Scientist Optimization (CoSO): Práctica Optimización por comunidad de científicos — Community of Scientist Optimization (CoSO): Práctica
Continuación del tema de la optimización mediante una comunidad de científicos. CoSO no debe considerarse una solución lista para usar, sino una plataforma de investigación prometedora. Con los ajustes adecuados, CoSO podría encontrar su nicho en problemas en los que la adaptabilidad y la robustez ante los cambios son importantes, y en los que el tiempo de cálculo no es un factor crítico.
Particularidades del trabajo con números del tipo double en MQL4 Particularidades del trabajo con números del tipo double en MQL4
En estos apuntes hemos reunido consejos para resolver los errores más frecuentes al trabajar con números del tipo double en los programas en MQL4.
Redes neuronales en el trading: Desentrañando las componentes estructuradas (SCNN) Redes neuronales en el trading: Desentrañando las componentes estructuradas (SCNN)
Le invitamos a conocer el innovador framework SCNN, que lleva el análisis de series temporales a un nuevo nivel gracias a una separación clara de los datos en componentes de largo plazo, estacionales, de corto plazo y residuales. Este enfoque mejora considerablemente la precisión de la predicción y permite que el modelo se adapte a la compleja y cambiante dinámica del mercado.