Русский Português
preview
Redes neuronales en el trading: Pronóstico de series temporales con descomposición modal adaptativa (ACEFormer)

Redes neuronales en el trading: Pronóstico de series temporales con descomposición modal adaptativa (ACEFormer)

MetaTrader 5Sistemas comerciales |
303 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introducción

El mercado financiero es un sistema complejo y dinámico en el que cada movimiento de precios es el resultado de una interacción compleja entre muchos factores. Puede reflejar casi todo: desde flujos de información macroeconómica y noticias corporativas internas hasta los arrebatos emocionales de los inversores y los fríos cálculos de las estrategias comerciales algorítmicas. En esta diversidad de señales, ruido y distorsiones, la tarea de extraer información útil y reconocer la verdadera tendencia se vuelve no solo interesante, sino estratégicamente importante.

La capacidad de predecir con precisión la dirección del mercado puede ofrecer una ventaja sostenible. Un desafío particular es el llamado ruido de información: microfluctuaciones frecuentes y a menudo sin sentido en los precios causadas por operaciones en corto, titulares de noticias o acciones algorítmicas aleatorias. Son ellas las que a menudo impiden que los modelos analíticos capten la esencia de lo que está sucediendo.

Ya a finales del siglo XX se hicieron los primero intentos de construir modelos de predicción. Las arquitecturas de redes neuronales simples han demostrado que, en principio, es posible enseñar a los modelos a predecir los movimientos del mercado. No obstante, estos enfoques mostraban su incapacidad para retener información durante largos intervalos temporales y olvidaban rápidamente lo que había sucedido un poco antes.

Con la llegada de la arquitectura LSTM, la situación mejoró. Estos modelos, que poseen mecanismos de memoria, podían retener patrones importantes durante periodos temporales más largos. Se volvieron ampliamente utilizados en problemas de pronóstico de series temporales, pero incluso aquí no todo resultó sencillo. Las series financieras no son secuencias temporales ordinarias: son irregulares. A menudo carecen de uniformidad entre ticks, y contienen muchos picos de corto plazo que no aportan información significativa sobre la dirección de la tendencia.

El trading de alta frecuencia supone un problema particularmente grave. Esto crea el llamado ruido de mercado: múltiples fluctuaciones de los precios en intervalos temporales muy cortos. Estas fluctuaciones enmascaran tendencias reales, hacen que los datos resulten inestables y sobrecargan el modelo con eventos irrelevantes. Como resultado, incluso las arquitecturas complejas comienzan a centrarse no en lo que importa, sino en lo que simplemente distrae.

Para solucionar estos problemas, en el trabajo "An End-to-End Structure with Novel Position Mechanism and Improved EMD for Stock Forecasting", se propuso el framework ACEFormer, que es un algoritmo integrado para analizar series temporales de la bolsa de valores, especialmente adaptado a las condiciones de trading de alta frecuencia. No se trata simplemente de un modelo, sino de un sistema de componentes complementarios, cada uno de los cuales resuelve un problema específico: filtrado del ruido, consideración de los intervalos temporales y concentración de la atención en los cambios clave.

El primer paso en la arquitectura del ACEFormer es limpiar los datos del ruido. Aquí se utiliza un algoritmo modificado ACEEMD (Alias Complete Ensemble Empirical Mode Decomposition with Adaptive Noise). El método se basa en enfoques de descomposición modal empírica (EMD), pero se implementa en una forma mejorada. Esto permite eliminar las dos desventajas principales del EMD: el efecto de los extremos y la mezcla de componentes. Al eliminar la primera función modal interna (FMI), que contiene la mayor cantidad de fluctuaciones de alta frecuencia, el ACEEMD elimina eficazmente el ruido y preserva puntos de inflexión de tendencias importantes.

Tras el filtrado previo, los datos limpios se envían al módulo de reconocimiento temporal. Los eventos bursátiles ocurren a intervalos irregulares, y esto impone limitaciones al funcionamiento de los mecanismos de atención clásicos. Para considerar estas diferencias, los autores del framework integraron el mecanismo Time-Aware, un módulo que analiza los valores de las características teniendo en cuenta los intervalos temporales entre ellos. Esto permite que el modelo comprenda mejor la secuencia de eventos e identifique las relaciones de causa y efecto.

Los datos son luego procesados por la unidad de atención mejorada. A diferencia del módulo Attention estándar, el módulo propuesto está adaptado a las particularidades de los datos del mercado, en los que resulta importante poder identificar puntos clave de cambio, ignorando fluctuaciones menores. Al centrarse más en las partes significativas de la serie temporal, el modelo no se distrae con elementos ruidosos y se concentra en información potencialmente importante.

En la etapa final, se usa una red neuronal completamente conectada. Esta agrega las características extraídas y forma un pronóstico final sobre la dirección del movimiento de precios. De este modo, la arquitectura del ACEFormer abarca el ciclo completo de procesamiento de datos: desde la reducción de ruido y la consideración de la estructura temporal hasta el enfoque y la previsión.


El algoritmo del ACEFormer

El algoritmo del ACEFormer es un sistema de procesamiento de series temporales de múltiples etapas para pronosticar con precisión los movimientos de precios en los mercados financieros. La esencia de su trabajo radica en la eliminación consistente y adaptativa del ruido del mercado, la identificación de características importantes y luego la construcción de un pronóstico considerando las tendencias a largo plazo. Este enfoque resulta especialmente útil en entornos comerciales de alta frecuencia, donde una señal útil está oculta entre una multitud de fluctuaciones aleatorias y ruido.

El proceso comienza con los datos de entrada en forma de una serie temporal 𝑆={𝑠1,𝑠2,…,𝑠𝑛}, donde cada vector 𝑠 𝑖 incluye el precio, el volumen y otros indicadores en el momento temporal 𝑖. Para preparar los datos de entrenamiento del modelo, se agrega una almohada adicional de ceros de longitud 𝑝 a la secuencia, creando una estructura para predecir los pasos futuros. Esto permite que el modelo haga predicciones para los próximos 𝑝 pasos, a pesar de la ausencia de información explícita sobre el futuro en los datos de origen 𝐷=[𝑠1,𝑠2,…,𝑠𝑛,0,0,…,0] ∈ 𝑅(𝑛+𝑝)×𝑑, donde 𝑑 es el número de características. Estos ceros ayudan al modelo a percibir la estructura y predecir los valores futuros, incluso si los datos históricos no muestran la historia completa.

El siguiente paso implica la suavización de la señal usando filtros convolucionales. Así, se aplican secuencialmente dos filtros convolucional 𝑓 y 𝑔, lo que reduce la cantidad de fluctuaciones aleatorias en los datos y estabiliza la serie. Este suavizado ayuda al modelo a eliminar picos aleatorios y mejorar la calidad de los datos de entrada para los posteriores pasos de procesamiento.

Después del suavizado previo, entra en juego el método de descomposición modal empírica adaptada (ACEEMD), que ayuda a eliminar eficazmente el ruido de alta frecuencia. El proceso comienza sumando y restando a cada elemento de la serie temporal el ruido gaussiano 𝑛𝑖(𝑡), lo que crea dos nuevas series 𝑝𝑒𝑖(𝑡)=𝑥(𝑡)+𝑛𝑖(𝑡) y 𝑝𝑚𝑖(𝑡)=𝑥(𝑡)−𝑛𝑖(𝑡).

Luego, cada una de estas series es procesada mediante el método de descomposición modal empírica (EMD), extrayendo la primera función modal intrínseca (IMF).

Tras extraer la IMF, se suman los componentes promediados de ambas series. Luego, el componente resultante se resta de la serie original, obteniéndose la secuencia temporal limpia 𝑟1(𝑡)=𝑥(𝑡)−IMF1(𝑡). La serie temporal limpia pasa a las siguientes etapas de procesamiento.

Para permitir que el modelo reconozca el orden de los eventos en el tiempo, se agrega codificación posicional que almacena la información sobre la disposición temporal de los datos en una serie. Y también se realiza una proyección lineal de los datos.

Una característica distintiva de la arquitectura del ACEFormer es el uso de un módulo de atención probabilística, que juega un papel importante en la mejora de la capacidad de generalización del modelo. La atención probabilística es una modificación de la Self-Attention clásica cuyo objetivo consiste en aumentar la eficiencia computacional y eliminar conexiones irrelevantes. La idea principal consiste en no prestar atención a todas las posiciones de la secuencia, centrándonos solo en los pasos temporales más significativos. Para ello se evalúa de forma preliminar el grado de importancia de cada posición. En el ACEFormer, esta medida se define como el valor máximo de la proyección de Querys sobre una muestra aleatoria de Keys. Acto seguido, los valores se normalizan, después de lo cual se seleccionan las posiciones más informativas. Precisamente para ellos se realiza el cálculo de la Self-Attention. Por consiguiente, la atención no se centra en toda la secuencia, sino en un subconjunto comprimido de ella, que con gran probabilidad contiene puntos clave.

El uso del módulo de atención probabilística en el ACEFormer no es solo un truco técnico, sino un movimiento estratégico que permite que el modelo se adapte de forma más flexible a las condiciones dinámicas del mercado, donde la importancia de cada dependencia individual puede cambiar con el tiempo. Este enfoque ayuda a crear pronósticos más fiables e informados frente a datos inestables.

Como resultado, la atención probabilística ayuda al modelo ACEFormer a centrarse en patrones verdaderamente significativos en los datos, mientras excluye relaciones irrelevantes y variaciones ruidosas. Este módulo mejora la capacidad del modelo para extraer patrones importantes y realizar pronósticos precisos, lo cual resulta especialmente importante para pronosticar la dirección de los futuros movimientos de precios en los mercados financieros.

Tras aplicar la atención probabilística, la salida pasa a través de una capa convolucional y un procedimiento de submuestreo (max-pooling), que mejora aún más las características locales y la comprensión del modelo de patrones importantes en los datos. La operación convolucional mejora la extracción de características locales al mejorar aquellas partes de la serie temporal que contienen la información más importante para futuros pronósticos.

La etapa final del procesamiento de datos es el clásico mecanismo de Self-Attention. Permite que cada elemento de una serie temporal tenga en cuenta el contexto global, además de identificar dependencias entre eventos separados por intervalos temporales significativos.

Para obtener valores de pronóstico para un horizonte de planificación determinado, se usa una red totalmente conectada.

Así, el algoritmo del ACEFormer actúa en varias etapas, desde la eliminación del ruido hasta la construcción de un pronóstico preciso. Cada uno de estos pasos ayuda al modelo a gestionar datos volátiles del mercado de forma más efectiva, identificando tendencias clave a largo plazo y pronosticando movimientos de precios con gran precisión.

A continuación le presentamos la visualización del framework ACEFormer por parte del autor.



Implementación con MQL5

Tras un estudio detallado de los aspectos teóricos del framework ACEFormer, vamos a pasar a su implementación práctica utilizando MQL5. Comenzaremos a trabajar con el módulo de atención probabilística. Este es uno de los bloques clave de la arquitectura, que garantiza una alta eficiencia computacional manteniendo la calidad de la representación de los datos.

Antes de profundizar en los detalles de implementación, vale la pena reiterar la ventaja conceptual de la atención probabilística. Este mecanismo supone un equilibrio entre precisión y eficiencia computacional. A diferencia de la atención clásica, que analiza toda la secuencia en su conjunto, aquí se aplica la selección de los elementos más informativos. Este enfoque permite reducir la carga de memoria y recursos computacionales sin perder calidad, especialmente en secuencias largas.

La implementación presentada en este artículo se divide en tres kernels ejecutados secuencialmente. Cada uno de ellos resuelve su propio problema: desde el cálculo de la significación, pasando por la selección de las mejores consultas, hasta el cálculo final de la presentación contextual. Veamos el proceso completo paso a paso.

En primer lugar se determina la importancia de cada consulta. Esto sucede en el kernel ProbAttentionQeuryImp. En los parámetros del kernel obtenemos:

  • la matriz de Consultas (querys),
  • la matriz combinada de Claves y Valores (keys_values),
  • el array de índices index_keys, que especifica los números de secuencia muestreados de las Claves asociadas con cada Solicitud.

En este contexto, hablamos de Claves seleccionadas aleatoriamente, en base a las cuales se evalúa la importancia de las Consultas. El muestreo no se usa para construir la atención final, sino únicamente para la evaluación estadística, es decir, para calcular con qué fuerza cada Consulta responde a la submuestra de Claves proporcionada.

__kernel void ProbAttentionQeuryImp(__global const float* querys,
                                    __global const float2* __attribute__((aligned(8))) keys_values,
                                    __global const float* index_keys,
                                    __global float* querys_imp,
                                    const int dimension
                                   )
  {
   const size_t id_q = get_global_id(0);
   const size_t total_q = get_global_size(0);
   const size_t ind_k = get_local_id(1);
   const size_t total_ind = get_local_size(1);
   const size_t id_h = get_global_id(2);
   const size_t total_h = get_global_size(2);

Planeamos ejecutar este kernel en un espacio de tareas tridimensional, donde cada dimensión juega un papel concreto en la organización de cálculos paralelos. La primera dimensión cubre la secuencia de Consultas. La segunda dimensión corresponde al número de Claves muestreadas asociadas con cada Consulta específica. Este número puede variar según los parámetros del modelo y la profundidad del análisis. La tercera dimensión especifica el número de cabezas de atención: los submódulos independientes que analizan simultánea y paralelamente diferentes aspectos de los datos de origen.

Debemos prestar especial atención al mecanismo de trabajo con cabezas de atención. Cada uno de ellos trabaja con su propio conjunto individual de Claves muestreadas. Este enfoque proporciona una variedad de perspectivas sobre la misma secuencia: cada cabeza se centra en su propia submuestra, identificando patrones y relaciones únicos. Esta distribución permite una mayor estabilidad en toda la arquitectura: incluso si una cabeza subestima un fragmento importante, la otra puede compensarlo. En conjunto, todas las cabezas forman una representación más rica y representativa de la señal original, lo que mejora significativamente la calidad de la atención y aumenta el contenido de información del contexto resultante.

Los flujos de trabajo se agrupan en grupos de trabajo a lo largo de la segunda dimensión del espacio de tareas. Para intercambiar información entre subprocesos de grupos de trabajo paralelos, crearemos un array de datos en la memoria local del dispositivo OpenCL.

__local float temp[LOCAL_ARRAY_SIZE][2];
const int ls = min((int)total_ind, (int)LOCAL_ARRAY_SIZE);

El siguiente paso consistirá en determinar los desplazamientos en los arrays de datos que corresponden al flujo actual de operaciones. En este caso, para determinar el desplazamiento en el búfer de Consultas, utilizaremos el identificador de flujo a lo largo de la primera dimensión. Para determinar el desplazamiento en el búfer de Claves, primero obtenemos el número de secuencia de Clave muestreada del búfer de indexación correspondiente y luego determinamos el desplazamiento en el búfer.

const int shift_q = dimension * (id_q * total_h + id_h);
const int id_k = index_keys[total_ind * id_q * total_h + ind_k * total_h + id_h];
const int shift_k = dimension * (id_k * total_h + id_h);

Para cada par Consulta-Clave, se calcula un producto escalar, que refleja el grado de su correspondencia. En el ciclo se realiza la multiplicación elemento por elemento y la acumulación del resultado.

   float sum = 0;
#pragma unroll
   for(int d = 0; d < dimension; d++)
      sum += IsNaNOrInf(querys[shift_q + d] * keys_values[shift_k + d].s0, 0);

Luego, utilizando un array en la memoria local, la suma y el máximo de estos productos se calculan en paralelo dentro del grupo de trabajo. Esto nos permite obtener con alto rendimiento una característica agregada para cada submuestra.

   int id_t = ind_k % ls;
#pragma unroll
   for(int i = 0; i < total_ind; i += ls)
     {
      if(i <= ind_k || (i + ls) > ind_k)
        {
         temp[id_t][0] = IsNaNOrInf((i == 0 ? 0 : temp[id_t][0]) + sum, 0);
         temp[id_t][1] = (i == 0 ? IsNaNOrInf(sum, MIN_VALUE) : fmax(temp[id_t][1], IsNaNOrInf(sum, MIN_VALUE)));
         barrier(CLK_LOCAL_MEM_FENCE);
        }
     }
   int count = ls;
#pragma unroll
   do
     {
      count = (count + 1) / 2;
      if(ind_k < count && (ind_k + count) < ls)
        {
         temp[ind_k][0] += temp[ind_k + count][0];
         temp[ind_k + count][0] = 0;
         temp[ind_k][1] = fmax(temp[ind_k + count][1], temp[ind_k][1]);
        }
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);

A continuación, se determina la diferencia entre los valores máximo y promedio de los productos escalares: esta será la medida de la importancia de la consulta actual. Cuanto mayor sea este valor, más informativa se considerará esta consulta. La significación resultante se almacena en el búfer de resultados querys_imp.

 if(ind_k == 0)
    querys_imp[id_q * total_h + id_h] = IsNaNOrInf(temp[0][1] - temp[0][0] / total_ind, MIN_VALUE);
}

El siguiente paso consiste en seleccionar las consultas más significativas. Esta tarea la realiza el kernel TopKImportanceToIndex. En lugar de usa algoritmos de clasificación complejos, aquí se implementa un método de clasificación simple y fiable.

Para cada Solicitud se organiza un cálculo paralelo del número de las más significativas. Si es menor que el umbral top_k indicado, la Consulta actual se incluye en la lista final de índices. Este método, a pesar de su sencillez, resulta adecuado para su ejecución en la GPU, ya que requiere un mínimo de sincronización y no requiere estructuras de datos adicionales.

__kernel void TopKImportanceToIndex(__global const float* importance,
                                   __global float* indexes,
                                   const int top_k
                                  )
  {
   const size_t id_q = get_global_id(0);
   const size_t total_q = get_global_size(0);
   const size_t id_h = get_global_id(1);
   const size_t total_h = get_global_size(1);
//---
   float imp = importance[id_q * total_h + id_h];
   int pos = 0;
#pragma unroll
   for(int i = 0; i < total_q; i++)
     {
      if(i == id_q)
         continue;
      float val = importance[i * total_h + id_h];
      if(val > imp || (i < id_q && val >= imp))
         pos++;
      if(pos >= top_k)
         break;
     }
//---
   if(pos < top_k)
      indexes[pos * total_h + id_h] = (float)id_q;
  }

Y finalmente, la tercera etapa clave es el cálculo de la atención directa. Aquí es donde entra en juego el kernel QIndexAttention. Su tarea consiste en formar una representación contextual final para cada Consulta seleccionada.

A la entrada de este kernel se suministra un conjunto completo de entidades Consultas (Query), Claves (Key) y Valores (Value). Como hemos discutido antes, la solución fundamental consiste en evitar la creación de copias adicionales de datos para el subconjunto seleccionado, lo cual es fundamental para ahorrar memoria y acelerar los cálculos. En su lugar, se utiliza un búfer de índice que contiene los punteros a las Consultas más significativas seleccionadas en las etapas anteriores.

Vale la pena señalar que los tokens de las Claves y Valores se combinan en un único búfer de datos. Esto simplifica la organización del acceso a los datos y mejora la eficiencia del almacenamiento en la caché. En este caso se utiliza el tipo de vector float2, en el que el primer elemento se corresponde con la Clave, mientras que el segundo se corresponde con el Valor. Esta estructura permite que los pares Clave-Valor se procesen como una única unidad lógica, lo que reduce la sobrecarga del acceso a la memoria y facilita una implementación más compacta y comprensible de las operaciones computacionales.

__kernel void QIndexAttention(__global const float *q,
                              __global const float2* kv,
                              __global float *scores,
                              __global const float *indexes,
                              __global float *out,
                              const int dimension,
                              const int heads_kv
                             )
  {
//--- init
   const int ind_q = get_global_id(0);
   const int k = get_local_id(1);
   const int h = get_global_id(2);
   const int total_q = get_global_size(0);
   const int total_k = get_local_size(1);
   const int heads = get_global_size(2);

En este kernel usamos nuevamente un espacio de problemas tridimensional. Solo la primera dimensión de Consultas opera con una muestra limitada de los tokens más significativos. La segunda dimensión de Claves, por el contrario, corresponde a la secuencia completa. Y también combinamos los flujos de trabajo en grupos de trabajo a lo largo de la segunda dimensión.

En el cuerpo del kernel, identificamos el flujo actual de operaciones en todas las dimensiones del espacio de tareas. Y luego, en base a los valores obtenidos, determinamos el desplazamiento en los búferes de datos.

const int h_kv = h % heads_kv;
const int q_id = (int)(indexes[ind_q * heads + h] + 0.001f);
const int shift_q = dimension * (q_id * heads + h);
const int shift_kv = dimension * (heads_kv * k + h_kv);
const int shift_s = total_k * (ind_q *  heads + h) + k;

Tenga en cuenta que antes de determinar el desplazamiento en el búfer de datos de Consultas, primero recuperaremos el puntero al token deseado del búfer de índices de los elementos más importantes.

Aquí creamos un array de datos en la memoria local para transferir información entre subprocesos del grupo de trabajo.

__local float temp[LOCAL_ARRAY_SIZE];
const uint ls = min((uint)total_k, (uint)LOCAL_ARRAY_SIZE);

El primer paso consiste en calcular los productos escalares de los tokens de Consulta y Clave, que forman un array de valores intermedios, la denominada Raw Score. Estas calificaciones reflejan el grado de relevancia mutua del par Consulta-Clave y se convierten en la base para el procesamiento posterior.

//--- Score
   float score = 0;
   if(q_id >= 0)
     {
#pragma unroll
      for(int d = 0; d < dimension; d++)
         score += IsNaNOrInf(q[shift_q + d] * kv[shift_kv + d].s0, 0);
     }

Para estabilizar los cálculos y aumentar la estabilidad numérica, se realiza la normalización usando el mecanismo SoftMax en una forma modificada. Dentro de cada grupo de trabajo (work group) se determina inicialmente el máximo entre todos los puntajes Scores.

//--- max of score
#pragma unroll
   for(int i = 0; i < total_k; i += ls)
     {
      if(k >= i && k < (i + ls))
         temp[k % ls] = (i == 0 ? score : fmax(temp[k % ls], score));
      barrier(CLK_LOCAL_MEM_FENCE);
     }
//---
   uint count = ls;
#pragma unroll
   do
     {
      count = (count + 1) / 2;
      if(k < count && (k + count) < ls)
         temp[k] = fmax(temp[k + count], temp[k]);
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);

A continuación, cada Score se ajusta restando el máximo encontrado. Esto evita el desbordamiento del exponente y garantiza que los valores por debajo del exponente sean menores o iguales a cero. Por consiguiente, el resultado de la función exponencial está entre 0 y 1.

score = IsNaNOrInf(exp(score - temp[0]), 0);

Luego se suman los exponentes y cada uno de ellos se divide por esta suma, convirtiendo la puntuación en el peso final.

//--- sum of exp
#pragma unroll
   for(int i = 0; i < total_k; i += ls)
     {
      if(k >= i && k < (i + ls))
         temp[k % ls] = (i == 0 ? 0 : temp[k % ls]) + score;
      barrier(CLK_LOCAL_MEM_FENCE);
     }
//---
   count = ls;
#pragma unroll
   do
     {
      count = (count + 1) / 2;
      if(k < count && (k + count) < ls)
        {
         temp[k] += temp[k + count];
         temp[k + count] = 0;
        }
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
//--- score
   if(temp[0] > 0)
      score /= temp[0];
   scores[shift_s] = score;

Los pesos resultantes se aplican luego al tensor de Valores. Todos estos vectores ponderados se acumulan y combinan en un único vector de contexto que describe la esencia semántica de la información original desde el punto de vista de una Consulta determinada.

//--- out
#pragma unroll
   for(int d = 0; d < dimension; d++)
     {
      float val = kv[shift_kv + d].s1 * score;
#pragma unroll
      for(int i = 0; i < total_k; i += ls)
        {
         if(k >= i && k < (i + ls))
            temp[k % ls] = (i == 0 ? 0 : temp[k % ls]) + val;
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      //---
      uint count = ls;
#pragma unroll
      do
        {
         count = (count + 1) / 2;
         if(k < count && (k + count) < ls)
           {
            temp[k] += temp[k + count];
            temp[k + count] = 0;
           }
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      while(count > 1);
      //---
      if(k == 0)
         out[dimension * (ind_q * heads + h) + d] = temp[0];
      barrier(CLK_LOCAL_MEM_FENCE);
     }
  }

Todo el mecanismo descrito implementa un esquema de atención probabilístico coherente y altamente eficiente. En primer lugar, evaluamos de forma rápida y aproximada la importancia de las Consultas. Luego, seleccionamos las más prometedoras y, finalmente, realizamos cálculos completos y precisos en un subconjunto limitado e informativo. Este enfoque no solo acelera el procesamiento de secuencias largas, sino que también mantiene un alto nivel de precisión del modelo. Con esto se logra una reducción significativa del volumen de datos intermedios y de accesos a la memoria global.

Sin embargo, el proceso descrito anteriormente solo abarca la pasada directa, la fase en la que el modelo genera sus predicciones basadas en los datos de entrada. Para que el modelo pueda aprender, deberemos organizar la propagación inversa de errores: el proceso de ajuste de los parámetros de aprendizaje de todos los componentes, considerando su impacto en el resultado final del funcionamiento del modelo.

En este caso, tomaremos una decisión arquitectónica consciente y bien fundada: propagar el gradiente de error exclusivamente a través del mecanismo de atención, excluyendo el paso de selección de las Consultas más significativas de la cadena de pasada inversa. A primera vista, esto puede parecer una simplificación, pero detrás de ello se esconde un cálculo preciso y una comprensión concreta de la estructura interna de los cálculos.

Ambas etapas anteriores (la selección de Consultas relevantes y la atención en sí) utilizan la misma operación básica: hacer coincidir los tokens de Consultas y de Claves. En el primer caso, se trata del análisis de un subconjunto de Claves muestreadas en el contexto de toda la secuencia de Consultas, evaluando la significación de cada elemento en función de su respuesta. En el segundo caso, sucede al revés: nos centramos en las Consultas más significativas que ya han sido seleccionadas y las evaluamos en el contexto de la secuencia completa de Claves. En otras palabras, en ambos casos se comparan las mismas entidades, pero desde proyecciones distintas. Esto nos permite evitar la duplicación de cálculos y organizar una retroalimentación efectiva, ajustando los parámetros del modelo a través de un solo flujo de información.

Este enfoque permite alcanzar varios objetivos a la vez. En primer lugar, reduce la carga computacional ya que el gradiente se propaga a lo largo de una sola ruta de información. En segundo lugar, aumenta la estabilidad numérica del modelo, ya que se elimina la posibilidad de conflictos entre dos fuentes de gradiente paralelas. En tercer lugar, la arquitectura se vuelve más simple y elegante: se reduce el número de dependencias y se simplifican la implementación y las pruebas. Y lo más importante: toda la información necesaria sobre la significación ya está contenida en las señales de gradiente. Esto garantiza el efecto de reutilización de los conocimientos, en el que un ajuste de parámetros proporciona una doble ganancia: mejora tanto el mecanismo de atención como el procedimiento de selección.

El kernel QIndexAttentionGradients implementa la propagación inversa a través de un mecanismo de atención, responsable de distribuir con precisión los gradientes entre tres componentes clave: Consultas (Query), Claves (Key) y Valores (Value). El espacio de trabajo está organizado en tres dimensiones:

  • las Consultas más significativas;
  • la dimensionalidad del token;
  • las cabezas de atención.
Esto garantiza un alto grado de paralelismo y un uso máximo de los recursos informáticos de laGPU.

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

En la etapa inicial, cada flujo identifica sus coordenadas en el espacio de tareas. El ID de Consulta real se recupera del array indexes, lo cual es necesario para la coincidencia correcta con los elementos de los arrays globales. También se calculan todos los cambios necesarios para acceder a las áreas de memoria correspondientes.

const int h_kv = h % heads_kv;
const int q_id = (int)(indexes[ind_q * heads + h] + 0.001f);
const int shift_q = dimension * (q_id * heads + h) + d;
const int shift_s = (ind_q * heads + h) * kunits;
const int shift_g = h * dimension + d;

A continuación, comienza la primera etapa de los cálculos: la propagación del gradiente por los Valores (Value). Esta implementación ofrece la capacidad de utilizar menos cabezas de atención para las Claves y Valores (heads_kv) que el número total de Cabezas (heads) que procesan las Consultas. Esto nos permite reducir la huella de memoria y acelerar los cálculos sin perder la flexibilidad estructural del modelo. Sin embargo, esta solución necesita un enfoque especial para la propagación inversa del gradiente de error.

Dado que los Valores (Value) se pueden usar conjuntamente entre múltiples cabezas de atención, resulta necesario agregar la contribución de todas las cabezas cuya salida ha sido influenciada por esos valores. Esto garantiza que la información de error se propague correctamente a los valores de los que dependen múltiples rutas de información para calcular la atención.

Para cada posición de Valores se realiza una pasada por todas las cabezas que potencialmente tienen acceso a los elementos analizados. Dentro de esta pasada, se calcula la contribución ponderada de cada cabeza: el producto del gradiente de error en el nivel de salida y el coeficiente de atención normalizado (score) obtenido en la pasada directa. Los resultados de las operaciones se acumulan y finalmente se almacenan en el segundo componente de la estructura float2 del array de gradiente de error kv_g.

Este mecanismo garantiza la consistencia y precisión de la propagación inversa en condiciones cuando hay menos cabezas para las Claves y Valores que para las Consultas. Como resultado, el modelo aprende correctamente a pesar de la asimetría estructural entre los componentes de la atención.

//--- Calculating Value's gradients
   int step_score = kunits * heads;
   if(h < heads_kv)
     {
#pragma unroll
      for(int v = ind_q; v < kunits; v += qunits)
        {
         float grad = 0;
         for(int hq = h; hq < heads; hq += heads_kv)
           {
            int shift_score = hq * kunits + v;
            for(int g = 0; g < qunits; g++)
               grad += IsNaNOrInf(gradient[shift_g + dimension * (hq - h + g * heads)], 0) *
                       scores[shift_score + g * step_score];
           }
         int shift_v = dimension * (heads_kv * v + h) + d;
         kv_g[shift_v].s1 = IsNaNOrInf(grad, 0);
        }
     }

En la siguiente etapa pasaremos al cálculo de gradientes para las Consultas. Aquí la situación es más interesante, ya que está involucrada la derivada de la función SoftMax, lo que requiere cálculos adicionales. Para cada Consulta, se toma el gradiente de error en el nivel de resultado correspondiente a la posición actual y luego se realiza un doble ciclo por las Claves: primero para calcular la contribución de cada peso de atención y luego para tener en cuenta la influencia de cada Clave en términos de su valor normalizado. Esto da como resultado una propagación ordenada de la señal de error a través de los pesos SoftMax, considerando toda la estructura probabilística de la atención. Finalmente, el gradiente acumulado para un elemento de consulta específico se escribe en el array q_g utilizando un desplazamiento precalculado.

//--- Calculating Query's gradients
   float grad = 0;
   float out_g = IsNaNOrInf(gradient[shift_g + ind_q * dimension], 0);
   int shift_kv = h_kv * dimension + d;
#pragma unroll
   for(int k = 0; (k < kunits && out_g != 0); k++)
     {
      float sc_g = 0;
      float sc = scores[shift_s + k];
      if(sc == 0)
         continue;
      for(int v = 0; v < kunits; v++)
         sc_g += scores[shift_s + v] * out_g * kv[shift_kv + v * heads_kv * dimension].s1 *
                 ((float)(k == v) - sc);
      grad += sc_g * kv[shift_kv + k * heads_kv * dimension].s0;
     }
   q_g[shift_q] = grad;

A continuación pasamos al cálculo de gradientes según las Claves, una de las etapas más sutiles de la pasada inversa. Aquí resulta importante determinar con precisión cómo cada clave ha influido en el resultado final del modelo a través del mecanismo de atención.

En primer lugar, debemos considerar que en una implementación dada se puede utilizar un número diferente de cabezas de atención para las Consultas y las Claves. Por lo tanto, el gradiente de una clave se forma teniendo en cuenta la contribución de todas las cabezas de atención para las que se ha utilizado esta Clave en los cálculos de atención.

Durante la pasada directa, cada par Consulta-Clave produce un valor escalar normalizado por la función SoftMax. El resultado se almacena en el búfer scores. Sin embargo, esto no es suficiente para calcular correctamente el gradiente. SoftMax es una función no lineal y, por lo tanto, al propagar inversamente un error, se debe tener en cuenta su derivada. Aunque los valores SoftMax ya se han almacenado, para cada logit de entrada es necesario calcular la sensibilidad de toda la función respecto a su cambio. Esto se implementa usando la fórmula derivada SoftMax, que incluye tanto los elementos diagonales como los externos. Por lo tanto, al calcular el gradiente de una clave, el algoritmo debe recorrer todas las Consultas con las que se ha correspondido la Clave dada y acumular su contribución.

La clave aquí es usar los índices de las Consultas más significativas para reconstruir la cadena de dependencia correcta. Sin esto, la distribución del gradiente será incorrecta.

El algoritmo itera sobre todos los pares relevantes, calcula los elementos requeridos de la derivada SoftMax y los multiplica por el gradiente de error en el nivel de salida. Los valores resultantes luego se acumulan en un gradiente para la clave dada y se escriben en el primer búfer kv_g, que se utiliza para acumular los gradientes de error de las Claves y Valores.

//--- Calculating Key's gradients
   if(h < heads_kv)
     {
#pragma unroll
      for(int k = ind_q; k < kunits; k += qunits)
        {
         int shift_k = dimension * (heads_kv * k + h_kv) + d;
         grad = 0;
         for(int hq = h; hq < heads; hq++)
           {
            int shift_score = hq * kunits + k;
            float val = kv[shift_k + heads_kv * dimension].s1;
            for(int scr = 0; scr < qunits; scr++)
              {
               float sc_g = 0;
               int shift_sc = scr * kunits * heads;
               float sc = scores[shift_sc + k];
               if(sc == 0)
                  continue;
               for(int v = 0; v < kunits; v++)
                  sc_g += scores[shift_sc + v] * gradient[shift_g + scr * dimension] *
                          val * ((float)(k == v) - sc);
               grad += IsNaNOrInf(sc_g * 
                                  q[(hq + (int)(indexes[scr * heads + hq] + 0.001f) * heads) * dimension + d], 0);
              }
           }
         kv_g[shift_k].s0 = IsNaNOrInf(grad, 0);
        }
     }
  }

Con esto concluye nuestro análisis de los algoritmos para construir los procesos de atención probabilísticos en el lado del programa OpenCL. Hemos cubierto todas las etapas clave paso a paso, desde la evaluación de la importancia de las Consultas y la selección de los elementos más informativos hasta el cálculo de la atención y la organización de la propagación inversa de errores. Cada kernel ha sido cuidadosamente adaptado a las características específicas de la arquitectura del ACEFormer y optimizado para una ejecución eficiente en dispositivos GPU.

El código fuente completo para la implementación, incluidos todos los kernels descritos, está disponible como archivo adjunto a este artículo.

La siguiente etapa de nuestro trabajo será la implementación de los algoritmos de atención probabilística en el lado del programa principal. Aquí es donde tiene lugar la integración del programa OpenCL con la lógica del modelo, la gestión del búfer y la sincronización de los cálculos. Sin embargo, el volumen del artículo actual ya ha alcanzado un límite razonable, por lo que le sugerimos tomar un breve descanso y continuar trabajando en el próximo artículo.



Conclusión

En este artículo, hemos presentado el concepto del framework ACEFormer, una arquitectura enfocada en el trabajo altamente eficiente con datos secuenciales en condiciones de recursos computacionales limitados. Sus puntos fuertes (modularidad, adaptabilidad y eficiencia computacional) se han convertido en la base de todas las implementaciones posteriores.

El ACEFormer ofrece una solución elegante al problema del escalamiento de la atención cuando se trabaja con secuencias largas. En lugar de procesar completamente todo el flujo de datos de origen, se usa un mecanismo probabilístico para seleccionar los elementos más significativos, lo que permite una reducción significativa de la carga computacional sin una pérdida significativa de calidad. Esto resulta especialmente relevante en entornos donde cada microsegundo y cada megabyte de memoria cuentan, como en las plataformas comerciales.

En la parte práctica de este artículo, hemos analizado con detalle la implementación de todos los componentes clave de la atención probabilística en el lado del programa OpenCL. El siguiente paso será la implementación de algoritmos de atención probabilística a nivel del programa principal. Sin embargo, para no sobrecargar al lector con el material actual, haremos una breve pausa y continuaremos nuestro trabajo en el próximo artículo. Nos espera una etapa de integración igualmente interesante e intensa.


Enlaces


Programas usados en el artículo

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

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

Archivos adjuntos |
MQL5.zip (2720.44 KB)
Simulación de mercado (Parte 22): Iniciando el SQL (V) Simulación de mercado (Parte 22): Iniciando el SQL (V)
Antes de que tires la toalla y decidas abandonar el estudio sobre cómo usar SQL, déjame recordarte, mi querido lector, que aquí todavía estamos usando solo lo más básico de lo básico. Aún no hemos explorado algunas cosas que es posible hacer en SQL. En cuanto las exploremos, verás que SQL es mucho más práctico de lo que parece. Aunque, muy probablemente, yo termine cambiando la dirección de lo que estamos creando. Esto se debe a que el proceso de creación es dinámico. Voy a mostrar un poco más sobre cómo hacer las cosas en SQL. Esto se debe a que, de hecho, es algo que necesitas entender y conocer. Simplemente pensar que eres más capaz que toda una comunidad de programadores y desarrolladores solo te hará perder tiempo y oportunidades. Ten calma, porque esto se va a volver aún más interesante.
Trading de arbitraje en Forex: Sistema comercial matricial para retornar al valor justo con limitación del riesgo Trading de arbitraje en Forex: Sistema comercial matricial para retornar al valor justo con limitación del riesgo
El artículo contiene una descripción detallada del algoritmo de cálculo de tipos cruzados, una visualización de la matriz de desequilibrios y recomendaciones para configurar de manera óptima los parámetros MinDiscrepancy y MaxRisk para un trading efectivo. El sistema calcula automáticamente el "valor justo" de cada par de divisas usando tipos de cambio cruzados, generando señales de compra para las desviaciones negativas y señales de venta para las desviaciones positivas.
El componente View para tablas en el paradigma MQL5 MVC: Elemento gráfico base El componente View para tablas en el paradigma MQL5 MVC: Elemento gráfico base
El artículo trata sobre el proceso de desarrollo de un elemento gráfico básico para el componente View como parte de la implementación de tablas en el paradigma MVC (Modelo-Vista-Controlador) en MQL5. Este es el primer artículo sobre el componente View y el tercero de una serie de artículos sobre la creación de tablas para el terminal cliente MetaTrader 5.
Superando las limitaciones del aprendizaje automático (Parte 1): Falta de métricas interoperables Superando las limitaciones del aprendizaje automático (Parte 1): Falta de métricas interoperables
Existe una fuerza poderosa y omnipresente que corrompe silenciosamente los esfuerzos colectivos de nuestra comunidad por desarrollar estrategias comerciales fiables que empleen la IA en cualquiera de sus formas. Este artículo establece que parte de los problemas a los que nos enfrentamos tienen su origen en la adhesión ciega a las «mejores prácticas». Al proporcionar al lector pruebas sencillas basadas en el mercado real, le explicaremos por qué debemos abstenernos de tal conducta y adoptar, en su lugar, las mejores prácticas específicas del ámbito si nuestra comunidad quiere tener alguna posibilidad de recuperar el potencial latente de la IA.