Redes neuronales en el trading: Segmentación periódica adaptativa (Generación de tokens)
Introducción
En el artículo anterior, analizamos con detalle los fundamentos teóricos del framework LightGTS (Lightweight General Time Series Forecasting), uno de los enfoques más avanzados y mejor diseñados para la previsión de series temporales hasta la fecha, presentado en el artículo "LightGTS: A Lightweight General Time Series Forecasting Model". Su concepto se basa en una profunda comprensión de la naturaleza de la periodicidad característica de los datos financieros y económicos, así como en una cuidadosa revisión de la arquitectura del Transformer para las tareas específicas de procesamiento de estructuras temporales. En el artículo, prestamos especial atención a cómo LightGTS maneja los patrones periódicos, minimizando la sobrecarga de entrenamiento y asegurando una generalización sólida incluso en datos heterogéneos y ruidosos.
El framework comienza con el llamado "Period Patching" (parcheo periódico), un mecanismo en el que la serie temporal se divide en segmentos que se corresponden con la frecuencia interna de la señal en estudio. Esta frecuencia no se ajusta manualmente, sino que la determina el modelo basándose en un análisis del espectro de frecuencias obtenido con la transformada rápida de Fourier. Cada parche seleccionado representa un ciclo que contiene patrones locales significativos. Y precisamente ese fragmento se transforma en un token mediante la proyección. Y aquí aparece la primera innovación arquitectónica: la capa de proyección flexible, que permite procesar parches de longitud variable mediante una transformación de peso lineal flexible. Esta proyección no se limita a escalar los datos, sino que preserva la equivalencia de los tokens al pasar de una escala a otra y de una frecuencia determinada.
El bloque del Codificador utiliza la codificación posicional rotativa (Rotary Positional Encoding, RoPE), que ofrece una representación compacta y robusta de las posiciones relativas de los tokens. Esto resulta especialmente importante para las series financieras, donde la posición absoluta suele ser mucho menos significativa que las posiciones relativas de los elementos dentro de la secuencia. Posteriormente, los tokens son procesados por una pila clásica de bloques de Transformer, cada uno de los cuales consta de un módulo de Self-Attention de múltiples cabezas y un módulo Feed-Forward.
Una de las soluciones más originales propuestas por los autores de LightGTS es el Periodical Parallel Decoding, que resulta conceptualmente opuesto a las estrategias autorregresivas. En lugar de predecir la secuencia paso a paso, el modelo usa el último token de la representación oculta (que acumula toda la información sobre la serie anterior) y, basándose en él, genera simultáneamente toda la secuencia de salida, replicándola con la ponderación posicional correspondiente. Este enfoque no solo acelera la previsión, sino que también preserva la coherencia periódica en la estructura temporal de los resultados del modelo.
Finalmente, el framework aplica Flex-resize a la capa de proyección del Decodificador, asegurando así que las predicciones coincidan con la longitud real de la señal predicha. Todo el entrenamiento de modelos se reduce a minimizar la función MSE clásica entre los valores predichos y la serie objetivo.
Por lo tanto, LightGTS no supone simplemente un Transformer modificado. Se trata de una arquitectura bien pensada en la que cada componente está adaptado a las particularidades de las series temporales: desde el manejo de la periodicidad hasta el abandono de la autorregresión en favor de la generación totalmente paralela. Debido a esta profunda adaptación, el framework demuestra una alta precisión con un bajo costo computacional.
A continuación le presentamos la visualización del framework LightGTS por parte del autor.

En la parte práctica del artículo anterior, comenzamos a construir un algoritmo de parcheo periódico adaptativo, uno de los elementos clave de la arquitectura LightGTS. Asimismo, analizamos con detalle las limitaciones asociadas a la imposibilidad de utilizar la asignación dinámica de memoria en el entorno de ejecución típico de MQL5 y OpenCL. Estas limitaciones nos obligaron a abandonar la idea de un número arbitrario de tokens en la salida.
En cambio, tomamos una decisión estratégicamente calculada: fijar el número de parches y usar su superposición como herramienta para compensar el cambio en la longitud de los segmentos individuales. De esta forma, conseguimos encontrar un punto intermedio entre la adaptabilidad (el modelo sigue siendo sensible a la periodicidad real de la serie temporal) y la estabilidad computacional necesaria para el uso eficiente de los recursos de hardware. Este enfoque nos permitió preservar tanto la precisión al reflejar las estructuras cíclicas de los datos analizados como la previsibilidad en la gestión de la memoria, lo que resulta fundamental para los modelos de trading de alta frecuencia y su implementación cuando el contexto de ejecución es limitado.
Ya hemos implementado el algoritmo de selección de frecuencia dominante; este extrae de manera eficiente la periodicidad subyacente para cada secuencia unitaria de la serie temporal de entrada. Esto nos permitirá especificar una escala básica para la segmentación de datos posterior. Hoy continuaremos con el trabajo que comenzamos y daremos el siguiente paso: la implementación del algoritmo de generación de tokens en el contexto OpenCL.
Nuestra tarea consiste en dividir cada secuencia temporal en un número fijo de fragmentos, donde el tamaño del segmento se establece según la frecuencia dominante identificada, y la superposición regula la adaptación a la longitud de la ventana. Todo esto debe realizarse en estricto paralelismo usando código compatible con GPU, donde cada flujo será responsable de formar un token en uno de los componentes unitarios de la secuencia analizada.
Compilación de kernels OpenCL
Tras haber identificado la frecuencia dominante mediante la transformada rápida de Fourier, nos encontramos ahora cerca de la etapa clave: la construcción de un mecanismo para generar tokens, es decir, fragmentos de la serie temporal que corresponden a la periodicidad identificada. A modo de recordatorio, en la sección anterior nos centramos en una tarea importante: combinar la adaptación del modelo a la frecuencia actual del mercado con la necesidad de un número fijo de parches de salida, lo cual resulta especialmente importante para trabajar en el entorno MQL5, que está limitado en términos de asignación dinámica de memoria.
Nuestro enfoque se basa en el principio de que el número de tokens (parches) permanece constante, pero su longitud se adapta a la frecuencia actual, mientras que la superposición se ajusta para garantizar una cobertura completa de la serie temporal analizada. Esto permite una combinación eficaz de flexibilidad y control de recursos. Sin embargo, existe un problema técnico: si la ventana de convolución cambia, la matriz de pesos, lógicamente, también deberá adaptarse, ya sea reconstruyéndose cada vez o proyectándose en un nuevo espacio.
En el artículo original, los autores del framework LightGTS proponen una solución: la matriz de pesos se entrena en un tamaño de ventana fijo (determinado por las estadísticas de la muestra de entrenamiento) y luego, en cada nuevo tamaño de ventana, los pesos se proyectan utilizando la matriz pseudoinversa de Moore-Penrose. Matemáticamente elegante, pero engorroso en la práctica.
Los mercados financieros reales son implacables: el ciclo de hoy no es el ciclo de mañana. La frecuencia fluctúa y la adaptación requiere flexibilidad. Calcular una matriz pseudoinversa sobre la marcha, sobre todo en entornos de procesamiento online o de negociación de alta frecuencia, implica sacrificar velocidad y eficiencia de recursos en aras del rigor formal. Y este es un lujo inasumible.
Nuestra solución es mucho más práctica. En lugar de reestructurar constantemente los pesos, adoptamos un enfoque diferente: usamos una matriz del tamaño máximo permitido, en la que simplemente ignoramos los pesos innecesarios mediante el uso de rellenado con ceros. Es decir, si un fragmento de datos no cae dentro de la ventana activa, este se multiplica por cero y el peso correspondiente no afecta al resultado. Sencillo y eficaz.
Este enfoque nos permite:
- mantener fija la estructura de la matriz de pesos, evitando operaciones costosas;
- variar dinámicamente el tamaño de la ventana y el paso entre parches;
- adaptarnos a las fluctuaciones de la frecuencia del mercado sobre la marcha, sin detener el modelo ni recalcular los parámetros.
Todo esto se implementa en el contexto de OpenCL, en el kernel FeedForwardAdaptConv, donde cada flujo es responsable de un par segmento-filtro específico para una de las secuencias unitarias.
__kernel void FeedForwardAdaptConv(__global const float *matrix_w, __global const float *matrix_i, __global float *matrix_o, __global const float *main_freq, const int inputs, const int window_in, const int activation ) { const size_t u = get_global_id(0); const size_t f = get_global_id(1); const size_t v = get_global_id(2); const size_t units = get_global_size(0); const size_t filters = get_global_size(1); const size_t variables = get_global_size(2);
En el cuerpo del kernel, primero definimos el número de flujos de operaciones en el espacio de tareas tridimensional. Luego, usando el identificador de la secuencia unitaria v, extraemos la frecuencia dominante de la variable que se está analizando del búfer global main_freq.
const int freq = main_freq[v]; int window = (inputs / variables + freq - 1) / freq;
En función de ello, se calcula el tamaño de la ventana, con ajustes para los límites de paso y fragmentación. Aquí determinamos el tamaño del paso de la ventana de análisis según el tamaño del tensor de datos de origen y del número de tokens creados. La tarea consiste en cubrir toda la secuencia de forma uniforme, sin pérdidas.
const int step = (int)(inputs / variables + units + 1) / (units + 2); if(window < step) window = (int)((step + window - 1) / window) * window; if(window > window_in) window = window_in;
El punto clave viene a continuación. Para nosotros es importante que la ventana no sea menor que el paso, de lo contrario, resulta posible que algunos datos no queden cubiertos. Por lo tanto, si el periodo hallado resulta ser demasiado pequeño, aumentamos la ventana varias veces, hasta alcanzar un valor no inferior al paso, pero sin exceder el máximo permitido.
Posteriormente, se determinan los desplazamientos en los arrays de entrada y salida, así como en la matriz de pesos. Esto es necesario para el correcto direccionamiento de los datos dentro de los búferes globales.
const int shift_in = (u < (units - 1) ? u * step : inputs / variables - window); const int shift_in_var = v * inputs / variables; const int shift_out = (u + v * units) * filters + f; const int shift_weight = (v * filters + f) * (window_in + 1);
Hay un matiz importante que debemos destacar: debemos abarcar toda la secuencia de entrada, incluyendo su final. Si la segmentación se realiza con un paso fijo y la longitud de cada ventana se determina de forma dinámica, es posible que los últimos elementos queden fuera del análisis. Por consiguiente, para garantizar una cobertura completa de toda la secuencia de entrada, calculamos el punto de inicio del último segmento como la diferencia entre la longitud total de la secuencia analizada y el tamaño de la ventana. Esto permite que la ventana final se desplace de forma que capture definitivamente los datos finales de la secuencia, incluso si su tamaño se ha determinado dinámicamente y ha resultado ser menor que el máximo permitido.
A continuación, comienza la parte principal del cálculo: la operación de convolución, que ofrece como resultado el valor final para cada token. En el primer paso, tomamos el valor básico correspondiente al componente de desplazamiento, que se extrae de la matriz de pesos utilizando el desplazamiento precalculado. Este elemento actúa como punto de partida para acumular la contribución de cada elemento de la ventana de análisis.
float sum = matrix_w[shift_weight + window_in]; for(int i = 0; i < window; i++) if((shift_in + i) < (inputs / variables)) sum += IsNaNOrInf(matrix_i[shift_in_var + shift_in + i], 0) * matrix_w[shift_weight + i];
A continuación, comienza a iterar cada elemento de la ventana de análisis. Aquí es donde entra en juego la característica clave de nuestra implementación: la estrategia de rellenado con ceros. Si un elemento de la secuencia está fuera de la ventana de cálculo real, simplemente se excluirá de los cálculos. Esto evita las distorsiones de la señal que podrían producirse al incluir datos irrelevantes. Dicha técnica garantiza la estabilidad de los resultados y permite realizar cálculos correctos, independientemente del tamaño de la ventana actual. Además, el rellenado con ceros facilita el mantenimiento de una dimensionalidad fija de la matriz de pesos, ya que podemos garantizar que todas las posiciones vacías se rellenarán con ceros que no afectan la suma final.
Finalmente, aplicamos la función de activación seleccionada y guardamos el resultado en el búfer de valores de salida.
matrix_o[shift_out] = Activation(sum, activation); }
De esta manera, obtenemos incorporaciones adaptadas a la frecuencia actual del mercado, con un contexto local claro y listas para ser introducidas en los bloques del Transformer. Todo ello sin operaciones costosas, con un consumo mínimo de recursos y en completa armonía con las limitaciones del entorno MQL5.
Antes de iniciar los tokens resultantes en el conjunto de bloques Transformer, debemos asegurarnos de que el modelo en sí no se rinda en el primer paso y sea capaz de aprender. Para ello, necesitamos una pasada inversa completa (propagación inversa), la etapa en la que se calculan los gradientes que nos permiten ajustar los filtros adaptativos convolucionales. Sin ello, todo lo que hayamos hecho para avanzar se convertirá en estancamiento: los filtros quedarán congelados en estados aleatorios y el modelo simplemente no podrá adaptarse a las nuevas fluctuaciones del mercado, quedando congelado en sus errores.
Activar una pasada inversa es como dar retroalimentación a una orquesta: si un instrumento se desafina, la onda sonora necesita ser retroalimentada para indicarle exactamente qué corregir. En el mundo de la convolución adaptativa con ventanas variables, esta no resulta una tarea sencilla: cada valor de origen podría participar en varios tokens a la vez, y todos ellos requieren la propagación de errores de vuelta a sus orígenes.
Es con este propósito que creamos el kernel de OpenCL CalcHiddenGradientAdaptConv. Funciona en un espacio bidimensional: a lo largo del eje inp tenemos posiciones en las secuencias unitarias originales, y a lo largo del eje v tenemos distintos canales (secuencias unitarias). Esta disposición garantiza que cada elemento de los datos analizados reciba su propio gradiente preciso.
__kernel void CalcHiddenGradientAdaptConv(__global const float *matrix_w, __global const float *matrix_i, __global float *matrix_ig, __global const float *matrix_og, __global const float *main_freq, const int outputs, const int window_in, const int window_out, const int activation ) { const size_t inp = get_global_id(0); const size_t v = get_global_id(1); const size_t inputs = get_global_size(0); const size_t variables = get_global_size(1);
Dentro del kernel, primero activamos el radar: identificamos el flujo actual a través de todas las dimensiones del espacio de tareas. Y luego, de forma similar al kernel de pasada directa, determinamos el tamaño del segmento individualmente para cada secuencia unitaria y su paso.
const int units = outputs / (window_out * variables); const int freq = main_freq[v]; int window = (inputs / variables + freq - 1) / freq; const int step = (int)(inputs + units + 1) / (units + 2); if(window < step) window = (int)((step + window - 1) / window) * window; if(window > window_in) window = window_in;
Y luego determinamos los desplazamientos en los búferes de datos hacia los elementos requeridos.
const int shift_in = v * inputs + inp; int u = inp / step; int shift_out_var = v * (outputs / variables); int shift_weight_var = (v * window_out) * (window_in + 1);
A continuación, pasamos al proceso principal de distribución del gradiente de error. Aquí primero determinamos qué token se ha generado usando el elemento actual del búfer de datos de origen y recopilamos el gradiente de error de todos los elementos del token resultante, considerando la contribución del elemento que se está analizando.
Sin embargo, cabe señalar que el uso de segmentos superpuestos da lugar a la posibilidad de usar un elemento de los datos de origen al formar varios tokens en diferentes posiciones. Por ello, encapsulamos la operación de recopilación de gradientes de error en un ciclo.
float sum = 0; while(u * step <= inp && u < (units - 1)) { int pos = inp - u * step; if(pos >= window) { u++; continue; } int shift_out = u * window_out; int shift_weight = pos + shift_weight_var; for(int out = 0; out < window_out; out++) { if((shift_out + out) >= (outputs / variables)) continue; sum += IsNaNOrInf(matrix_og[shift_out_var + shift_out + out] * matrix_w[shift_weight + out * (window_in + 1)], 0); } u++; }
No debemos olvidar las peculiaridades de la formación del último segmento. En el algoritmo de distribución del gradiente de error, lo colocaremos en un bloque aparte.
if(inp >= (inputs - window)) { int pos = inp + window - inputs; int shift_out = (units - 1) * window_out; int shift_weight = pos + shift_weight_var; for(int out = 0; out < window_out; out++) { if((shift_out + out) >= (outputs / variables)) continue; sum += IsNaNOrInf(matrix_og[shift_out_var + shift_out + out] * matrix_w[shift_weight + out * (window_in + 1)], 0); } }
Y el toque final: corregimos la suma acumulada de los gradientes de error usando la suma de las derivadas de la activación y la guardamos en el elemento correspondiente del búfer de datos global.
matrix_ig[shift_in] = Deactivation(sum, matrix_i[shift_in], activation); }
Este enfoque token por token garantiza que cada bit de los datos de origen obtenga el gradiente de error adecuado, incluso si su voz se ha escuchado en varios tokens a la vez. Esto permite que nuestros filtros adaptativos aprendan no de frases aisladas, sino de lotes completos de datos de mercado.
No obstante lo dicho, la distribución del gradiente de error supone solo la mitad del camino. La verdadera magia ocurre a continuación, al usar estos gradientes para actualizar los parámetros del modelo. Imagine a un jardinero que, tras la cosecha, decide qué árboles podar y cuáles abonar para que las ramas den aún más fruto la próxima temporada. De la misma forma, nuestros algoritmos de optimización aprovechan los gradientes calculados para ajustar los pesos y minimizar el error de predicción global.
En nuestro caso, la actualización de los parámetros del modelo se implementa dentro del kernel OpenCL UpdateWeightsAdaptConvAdam. Esto no supone solo un paso técnico, sino la culminación de todo el proceso de propagación inversa: el momento en que el modelo aprende de sus errores y da un paso hacia la mejora.
__kernel void UpdateWeightsAdaptConvAdam(__global float *matrix_w, __global const float *matrix_og, __global const float *matrix_i, __global float *matrix_m, __global float *matrix_v, __global float *main_freq, const int inputs, const int outputs, const float l, const float b1, const float b2 ) { const size_t id_in = get_global_id(0); // input shift const size_t id_out = get_global_id(1); // filter shift const size_t id_v = get_global_id(2); // variable const size_t window_in = get_global_size(0) - 1; const size_t window_out = get_global_size(1); const size_t variables = get_global_size(2);
El algoritmo operativo del kernel está estructurado como una escena multinivel con tres ejes de coordenadas, cada uno de los cuales desempeña un papel claramente definido en el proceso computacional. El primer eje es la posición dentro de la ventana (segmento) analizada de los datos de origen (id_in), el segundo es el número de filtro o, en otras palabras, la posición específica dentro del token de salida del objeto (id_out), y el tercero es el índice de la secuencia unitaria (id_v), que significa un canal separado de los datos de origen.
Esta distribución tridimensional de la computación no supone solo una conveniencia arquitectónica, sino un diseño estratégico. Esta ofrece una descomposición completa del problema: cada parámetro entrenable de la matriz de pesos se aplica estrictamente en el contexto del fragmento correspondiente de la secuencia de entrada y está vinculado a un filtro y canal específicos. Imagine que cada filtro es un analista independiente que trabaja con su propio diagrama y no mezcla sus documentos con los de nadie más. Esto evita que las señales se mezclen entre series temporales, previniendo distorsiones por correlación cruzada que son especialmente importantes al trabajar con datos financieros, donde el ruido y la volatilidad son una realidad cotidiana.
Este enfoque crea una especie de microscopio con lentes independientes: cada filtro se enfoca en una pieza de datos única, lo cual ofrece una sintonización muy precisa a las fluctuaciones de señal más pequeñas. Si un canal contiene fluctuaciones violentas y rápidas, mientras que el otro contiene una tendencia estable pero lenta, el algoritmo podrá hacer frente a cada una de ellas sin perder la agudeza de percepción. De hecho, cada peso se entrena individualmente para su tarea específica, como si fuera un modelo independiente dentro de un sistema más amplio.
En el cuerpo del kernel, identificamos directamente el flujo en el espacio de tareas tridimensional, lo que nos permitirá seleccionar un elemento de la matriz de parámetros entrenables para realizar operaciones posteriores.
Inmediatamente después, basándonos en la frecuencia dominante main_freq [ id_v ], calculamos el tamaño actual del segmento window sobre el que se aplicará este peso.
const int units = outputs / (window_out * variables); const int freq = main_freq[id_v]; int window = (inputs / variables + freq - 1) / freq; const int step = (int)(inputs / variables + units + 1) / (units + 2); if(window < step) window = (int)((step + window - 1) / window) * window; if(window > window_in) window = window_in;
Este enfoque garantiza:
- el aislamiento de los parámetros — cada ponderación funciona únicamente con los datos de origen a los que está asociada;
- el paralelismo total — los flujos no interfieren entre sí, ya que trabajan con diferentes elementos de la matriz de pesos;
- la precisión — el filtro se sincroniza con la frecuencia de los datos y procesa solo los segmentos relevantes.
No debemos olvidar que todos nuestros cálculos se realizan sobre un marco universal: una matriz de pesos diseñada para la mayor ventana posible de datos analizados. Pero en realidad, cada segmento específico suele resultar ser más pequeño que este tamaño máximo. Para evitar malgastar valioso tiempo y energía de la GPU en tareas inútiles, comprobamos al inicio de cada flujo si el parámetro pertenece al segmento actual e inmediatamente finalizamos el trabajo de los flujos innecesarios.
if(id_in != window_in && id_in >= window) return;
Como resultado, el rendimiento general aumenta drásticamente y el modelo funciona notablemente más rápido, como un piloto orientado a un objetivo que descarta de inmediato todos los giros innecesarios y toma la ruta más directa hacia la línea de meta.
El siguiente paso consiste en determinar los desplazamientos dentro de los búferes de datos globales; sin esto, ningún flujo podrá encontrar los elementos necesarios.
const int shift_in_var = id_v * inputs / variables; const int shift_out_var = id_v * outputs / variables; const int shift_weight = (id_v * window_out + id_out) * (window_in + 1) + id_in; const bool bias = (id_in == window_in);
Esta técnica sencilla pero crucial garantiza que cada parámetro del filtro actúe únicamente sobre su propia porción de datos a la vez, lo cual aumenta la eficiencia y la predictibilidad de todo el modelo.
A continuación, pasamos a una de las partes más importantes: el cálculo del gradiente de error para el parámetro seleccionado. El gradiente no es un valor abstracto, sino una señal real que indica en qué dirección y en qué medida se debe ajustar el peso para que el modelo prediga con mayor precisión.
Para obtener la verdadera contribución del parámetro analizado, iteramos toda la secuencia unitaria y acumulamos todas sus respuestas en tokens de salida.
float grad = 0; for(int u = 0; u < (units - 1); u++) { const int shift_in_loc = id_in + u * step; if(shift_in_loc >= (inputs / variables)) continue; float inp = (bias ? 1 : IsNaNOrInf(matrix_i[shift_in_var + shift_in_loc], 0)); grad += IsNaNOrInf(inp * matrix_og[shift_out_var + u * window_out + id_out], 0); }
No olvidemos las peculiaridades de la formación del último segmento. Lo hemos introducido como un bloque aparte.
{
const int shift_in_loc = id_in + inputs / variables - window;
if(shift_in_loc < (inputs / variables))
{
float inp = (bias ? 1 : IsNaNOrInf(matrix_i[shift_in_var + shift_in_loc], 0));
grad += IsNaNOrInf(inp * matrix_og[shift_out_var + (units - 1) * window_out + id_out], 0);
}
}
Este método de recolección de gradientes es minucioso y exhaustivo. No omitimos ninguna respuesta de los datos de origen y, por lo tanto, obtenemos el vector de dirección más preciso para el ajuste de pesos. Precisamente este minucioso rastreo inverso garantiza que el modelo no se quede atascado en errores locales, sino que se adapte de forma fiable y fluida al ritmo cambiante del mercado financiero.
A continuación, se activa el algoritmo Adam, un método de optimización adaptativa que combina las ventajas del suavizado de gradiente (Momentum) y la normalización de la varianza (RMSProp). Utiliza dos arrays auxiliares: matrix_m para el primer momento (el gradiente acumulado) y matrix_v para el segundo momento (el error cuadrático acumulado).
float mt = IsNaNOrInf(clamp(b1 * matrix_m[shift_weight] + (1 - b1) * grad, -1.0e5f, 1.0e5f), 0); float vt = IsNaNOrInf(clamp(b2 * matrix_v[shift_weight] + (1 - b2) * pow(grad, 2), 1.0e-6f, 1.0e6f), 1.0e-6f); float weight = clamp(matrix_w[shift_weight] + IsNaNOrInf(l * mt / sqrt(vt), 0), -MAX_WEIGHT, MAX_WEIGHT);
El valor del parámetro se ajusta considerando tanto la dirección (mt) como la estabilidad (vt) del cambio. Los valores actualizados se vuelven a escribir en los búferes de datos globales.
matrix_w[shift_weight] = weight; matrix_m[shift_weight] = mt; matrix_v[shift_weight] = vt; }
De esta forma, cada parámetro del filtro se adapta en función del rico contexto de sus actualizaciones anteriores. Esto permite que el modelo no solo responda rápidamente a los errores locales, sino que también evite fluctuaciones repentinas en los parámetros, lo cual garantiza una adaptación fluida y estable a los datos. Este ajuste preciso resulta especialmente importante en el contexto de series temporales financieras inestables, donde cualquier impulso adicional puede conducir a un sobreajuste o a la pérdida de la capacidad de generalización.
Con esto concluimos el trabajo del lado del programa OpenCL. Su código completo se presenta en el archivo adjunto al artículo.
Creación de un objeto
Ya hemos examinado cómo se implementan paso a paso la convolución adaptativa con una ventana variable, la generación dinámica de tokens, el cálculo del gradiente y la actualización de pesos en los kernels de programas OpenCL. Sin embargo, la lógica computacional por sí sola es solo una parte de la historia. Para que esta arquitectura funcione en tiempo real y dentro del marco de un modelo completo, deberá integrarse adecuadamente en el programa principal.
Y aquí es donde comienza la parte más interesante: la integración. Como en una buena orquesta, no solo es importante el talento de los solistas (kernels), sino también el trabajo preciso del director, quien inicia los procesos adecuados en el momento preciso, gestiona su interacción y garantiza la integridad de toda la composición.
En nuestro caso, el conductor es la clase CNeuronAdaptConv. Es él quien coordina todo: desde el análisis de las frecuencias dominantes hasta el inicio de la convolución adaptativa, desde la propagación inversa de errores hasta la actualización de los pesos mediante el optimizador Adam. No se trata simplemente de una capa de abstracción sobre un programa OpenCL, sino de un módulo de control completo que toma decisiones, enlaza las etapas de cálculo y garantiza la preservación del estado entre iteraciones.
Más abajo resumimos la estructura del nuevo objeto:
class CNeuronAdaptConv : public CNeuronConvOCL { protected: CBufferFloat bMainFreq; //--- virtual bool FFT(CBufferFloat *inp_re, CBufferFloat *inp_im, CBufferFloat *out_re, CBufferFloat *out_im, uint variables, bool reverse = false); virtual bool PeriodsFinding(CBufferFloat *inp_re, CBufferFloat *inp_im, CBufferFloat *main_freq, uint variables); virtual bool AdaptiveConvolution(CNeuronBaseOCL *NeuronOCL, CBufferFloat *main_freq); //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronAdaptConv(void) {}; ~CNeuronAdaptConv(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_out, uint units_count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronAdaptConv; } //--- methods for working with files virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual void SetOpenCL(COpenCLMy *obj) override; };
Como podemos ver, dentro de CNeuronAdaptConv solo se declara un único búfer propio: bMainFreq, para almacenar las frecuencias dominantes. Todos los demás objetos necesarios para el funcionamiento se heredan de la clase de capa convolucional principal CNeuronConvOCL, lo cual garantiza la reutilización de la lógica común y reduce la duplicación de código.
El búfer bMainFreq se declara de forma estática, lo cual nos permite dejar vacíos el constructor y el destructor de la clase. El proceso de inicialización de este búfer y de todos los objetos heredados se organiza en el método Init, en cuyos parámetros recibimos una serie de constantes que nos permiten interpretar de forma inequívoca la arquitectura del objeto creado.
bool CNeuronAdaptConv::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_out, uint units_count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronConvOCL::Init(numOutputs, myIndex, open_cl, window, window, window_out, units_count, variables, optimization_type, batch)) return false;
El algoritmo del método es bastante simple: al principio, delegamos todas las comprobaciones e inicialización a la lógica central de la clase padre, como si confiáramos en un mentor que ya sabe con qué parámetros y búferes trabajar. Esto libera nuestro código de rutinas innecesarias. Y entonces lo único que nos queda por hacer es inicializar el búfer de frecuencia dominante.
bMainFreq.BufferFree(); if(!bMainFreq.BufferInit(iVariables, 1) || !bMainFreq.BufferCreate(OpenCL)) return false; //--- return true; }
Y retornamos el resultado lógico de la operación del método al programa que realiza la llamada.
Cabe destacar que la mayoría de los métodos del nuevo objeto son solo envoltorios para organizar el trabajo de puesta en cola de la ejecución de los kernels correspondientes, construidos según el algoritmo que ya conocemos:
- FFT — descomposición de una serie temporal en componentes de frecuencia mediante la transformada rápida de Fourier;
- PeriodsFinding — búsqueda de la frecuencia dominante;
- AdaptiveConvolution — pasada hacia adelante de la convolución adaptativa.
Dado que la transformada rápida de Fourier y el algoritmo de búsqueda de frecuencia dominante no utilizan parámetros entrenables y no requieren la distribución del gradiente de error, los métodos de pasada inversa también se transforman en los envoltorios correspondientes. El método de pasada directa feedForward, que combina varias iteraciones, destaca claramente en este contexto.
bool CNeuronAdaptConv::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false;
En los parámetros del método obtenemos el puntero al objeto de datos de origen, cuya relevancia comprobamos de inmediato. A continuación, descomponemos los datos obtenidos en componentes de frecuencia mediante el método FFT.
if(!FFT(NeuronOCL.getOutput(), NULL, Output, PrevOutput, iVariables, false)) return false;
A partir del espectro obtenido, seleccionamos las frecuencias dominantes para cada secuencia unitaria.
if(!PeriodsFinding(Output, PrevOutput, GetPointer(bMainFreq), iVariables)) return false;
Finalmente, aplicamos el método de convolución adaptativa, que generará los tokens que necesitamos.
return AdaptiveConvolution(NeuronOCL, GetPointer(bMainFreq)); }
El resultado lógico de las operaciones se retorna al programa que realiza la llamada.
De este modo, la clase CNeuronAdaptConv actúa como un verdadero "maestro" del proceso computacional: no tiene implementaciones engorrosas, sino que posee la capacidad perfecta para orquestar y sincronizar todas las etapas del trabajo, transfiriendo el control donde realmente se necesita.
Conclusión
En este artículo, hemos completado el desarrollo de un sistema integral de procesamiento de series temporales que combina el análisis espectral y la convolución adaptativa en un único algoritmo integrado. Asimismo, hemos demostrado cómo, utilizando la transformada rápida de Fourier (FFT) y la detección de frecuencia dominante, podemos determinar el ritmo de cada canal de datos y, a partir de esta información, ajustar de forma flexible la anchura del segmento manteniendo un número fijo de tokens en la salida del objeto.
Hemos prestado especial atención a la implementación práctica en el entorno MQL5 y OpenCL. Recorrimos todas las etapas: desde el análisis del espectro y el recorte de parches, hasta la propagación inversa de errores y la actualización de pesos mediante el optimizador Adam. Cada fase está diseñada como un kernel pequeño pero independiente, y la clase de control CNeuronAdaptConv disciplina y sincroniza su trabajo, actuando como el director de la orquesta computacional.
Gracias a una arquitectura cuidadosamente diseñada, donde cada flujo de la GPU procesa únicamente su propio peso y su propio fragmento de datos, pudimos lograr un paralelismo impresionante sin interbloqueos. El rellenado de ceros y un sistema de desplazamiento rígido garantizan que no se pierda ni se mezcle información entre los canales. Además, el optimizador adaptativo Adam, que tiene en cuenta cuidadosamente los momentos de primer y segundo orden, garantiza un entrenamiento del modelo fluido y estable.
En el próximo artículo, hablaremos sobre cómo utilizar los tokens recibidos en una pila de Transformer modificada.
Enlaces
Programas usados en el artículo
| # | Nombre | Tipo | Descripción |
|---|---|---|---|
| 1 | Study.mq5 | Asesor | Asesor de entrenamiento de modelos offline |
| 2 | StudyOnline.mq5 | Asesor | Asesor de entrenamiento de modelos online |
| 3 | Test.mq5 | Asesor | Asesor para la prueba de modelos |
| 4 | Trajectory.mqh | Biblioteca de clases | Estructura de descripción del estado del sistema y la arquitectura del modelo |
| 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/18629
Advertencia: todos los derechos de estos materiales pertenecen a MetaQuotes Ltd. Queda totalmente prohibido el copiado total o parcial.
Este artículo ha sido escrito por un usuario del sitio web y refleja su punto de vista personal. MetaQuotes Ltd. no se responsabiliza de la exactitud de la información ofrecida, ni de las posibles consecuencias del uso de las soluciones, estrategias o recomendaciones descritas.
Utilizando redes neuronales en MetaTrader
Particularidades del trabajo con números del tipo double en MQL4
Guía de aprendizaje automático para MetaTrader 5 (Parte 1): Correcciones relacionadas con la fuga de datos y las marcas de tiempo
- Aplicaciones de trading gratuitas
- 8 000+ señales para copiar
- Noticias económicas para analizar los mercados financieros
Usted acepta la política del sitio web y las condiciones de uso