Redes neuronales en el trading: Desentrañando las componentes estructuradas (Final)
Introducción
En este artículo pasamos a la fase final de la implementación de nuestra propia visión de los enfoques propuestos por los autores del framework SCNN usando MQL5. SCNN (Seasonal Convolutional Neural Network) es una arquitectura especializada, desarrollada para el análisis de series temporales con una estructura bien definida. Su objetivo principal es desglosar los datos de partida en varios componentes clave: la tendencia a largo plazo, el componente estacional y el componente a corto plazo, así como capturar la interdependencia espacial entre las variables. Esta descomposición permite no solo mejorar la calidad del pronóstico, sino también garantizar la interpretabilidad del modelo, una cualidad poco común pero sumamente valiosa en las tareas de trading algorítmico.
SCNN se basa en principios clásicos de análisis, como la normalización por periodos y el trabajo con la transformación estacional, pero los combina con métodos modernos de procesamiento de la información: el mecanismo de atención, la proyección paramétrica de características y la agregación convolucional de los resultados. A continuación se muestra una visualización propia del framework SCNN.

En trabajos anteriores hemos analizado de forma sistemática la estructura y la función de todos los componentes internos del modelo, implementándolos mediante clases especializadas y diseñando minuciosamente cada elemento de la futura arquitectura. El trabajo no ha sido fácil, pero el resultado ha merecido la pena: ahora tenemos ante nosotros un conjunto de módulos completos, cada uno de los cuales desempeña una función bien definida dentro del sistema.
El siguiente paso lógico es integrarlos en una estructura única y coherente. Es precisamente en esta etapa cuando se configura el armazón estructural SCNN, que determina cómo se organizará exactamente el flujo de información dentro del modelo, desde los datos de entrada hasta la predicción final. Pasamos de la fase de preparación a la fase de funcionamiento: los componentes cobran vida, interactúan y, finalmente, forman un mecanismo analítico único.
Una vez finalizado el montaje técnico, pasaremos a la parte más esperada: la prueba del modelo con datos históricos. Esto permitirá evaluar no solo la corrección de la implementación, sino también la robustez práctica del enfoque ante los distintos regímenes del mercado. SCNN no es simplemente otra arquitectura de red neuronal más. Se trata de un intento de combinar la precisión de los cálculos con la transparencia en la toma de decisiones.
Codificador SCNN
En el artículo anterior concluimos el análisis de la arquitectura del codificador SCNN, creado en el objeto CNeuronSCNNEncoder, y examinamos en detalle el procedimiento de inicialización de todos sus componentes internos. Cada uno de los módulos, ya sea de normalización, transposición o adaptación espacial, se preparó para su funcionamiento y se configuró para recibir y procesar los datos de entrada. A continuación se muestra la estructura del objeto.
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 defNeuronSCNNEncoder; } virtual void TrainMode(bool flag) override; virtual void SetOpenCL(COpenCLMy *obj) override; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; virtual void SetActivationFunction(ENUM_ACTIVATION value) override { } };
Hoy continuamos el trabajo iniciado y pasamos a la construcción de un elemento clave: el algoritmo de pasada directa. Es este algoritmo el que determina cómo los datos pasan sucesivamente por todos los niveles del modelo, se transforman, se agregan y, en última instancia, dan lugar a la representación de salida.
La implementación de la pasada directa en el método feedForward pone de manifiesto el funcionamiento coordinado de todo el sistema.
bool CNeuronSCNNEncoder::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cLongNorm.FeedForward(NeuronOCL)) return false;
Los datos de entrada pasan primero por un bloque de normalización a largo plazo, donde se eliminan el desplazamiento y las diferencias de escala acumuladas a lo largo de un periodo prolongado. Esto proporciona una base alineada para el análisis posterior.
A continuación, los datos procesados se envían secuencialmente al bloque de extracción del componente estacional. Aquí, tal y como comentamos en el artículo anterior, se lleva a cabo una transposición con un paso especificado, correspondiente al periodo de estacionalidad. Gracias a ello, los elementos de la serie temporal se agrupan en secuencias según las fases del ciclo, lo que permite identificar de forma más eficaz los patrones estacionales estables. A la estructura obtenida se le aplica una normalización por periodos, lo que aumenta la expresividad de las fluctuaciones estacionales. Por su parte, la transposición inversa prepara los datos para las etapas posteriores de procesamiento.
if(!cSeasonTransp.FeedForward(cLongNorm.AsObject())) return false; if(!cSeasonNorm.FeedForward(cSeasonTransp.AsObject())) return false; if(!cUnSeasonTransp.FeedForward(cSeasonNorm.AsObject())) return false;
Tras el procesamiento estacional, llega la fase de extracción del componente de corto plazo. En este paso, extraemos en la práctica oscilaciones locales rápidas que pueden contener información importante sobre los cambios recientes en la dinámica del mercado.
if(!cShortNorm.FeedForward(cUnSeasonTransp.AsObject())) return false;
El toque final de la fase de preprocesamiento es la aplicación de la normalización espacial. Esta fase resulta fundamental para alinear la información extraída de los distintos componentes: el de largo plazo, el estacional y el de corto plazo. La normalización espacial permite adaptar la escala y la influencia mutua de las variables de entrada, eliminando los desajustes que surgen debido a las diferencias de dinámica entre las características. La particularidad de este paso radica en que se ha implementado teniendo en cuenta las dependencias espaciales y utiliza un mecanismo de atención, lo que hace que la representación resultante sea especialmente expresiva e informativa.
if(!cAdaptSpatNorm.FeedForward(cShortNorm.AsObject())) return false;
Antes de pasar a la siguiente fase, es necesario combinar los resultados de todos los módulos, incluidos los datos normalizados y los parámetros estadísticos calculados, en un único tensor coherente. Se trata de un paso crucial que garantiza la integridad posterior del flujo de información en el modelo.
Cabe destacar especialmente lo siguiente: aunque la dimensionalidad de las propias secuencias temporales se conserva tras la normalización, hemos optado deliberadamente por no expandir los parámetros estadísticos (medias y desviaciones estándar) hasta abarcar toda la longitud de la secuencia. Esta decisión se tomó con el fin de ahorrar memoria, ya que repetir el mismo valor a lo largo de toda la secuencia no aporta información adicional. Sin embargo, esta optimización da lugar a una diferencia de dimensionalidad entre las series temporales y las estadísticas correspondientes.
A pesar de ello, mantenemos una estructura común en cuanto al número de secuencias unitarias analizadas, y este es un aspecto clave. Precisamente en esta estructura nos basamos durante la concatenación. Todas las operaciones se realizan de forma secuencial a lo largo de estos segmentos unitarios, lo que garantiza la corrección del ensamblaje final y permite evitar distorsiones en la estructura de los datos.
Al comienzo de la etapa de combinación, formamos el primer bloque concatenado, que incluye los datos de entrada sin procesar y el resultado del módulo de normalización a largo plazo. Esto permite conservar el contexto de la serie inicial, al tiempo que lo complementa con información sobre las tendencias a largo plazo identificadas durante la normalización. Además, a estos datos añadimos los parámetros estadísticos calculados (valores medios y desviaciones estándar), que actúan como una especie de marcadores de escala y variabilidad.
uint windows[3] = {NeuronOCL.Neurons() / iWindow, cLongNorm.GetPeriod()*cLongNorm.GetUnits(), 2 * cLongNorm.GetUnits() }; if(!Concat(NeuronOCL.getOutput(), cLongNorm.getOutput(), cLongNorm.GetMeanSTDevs().getOutput(), cConcatenated.getOutput(), windows[0], windows[1], windows[2], iWindow)) return false;
En el siguiente paso, ampliamos nuestro tensor con la información extraída de la componente estacional.
windows[0] = windows[0] + windows[1] + windows[2]; windows[1] = cSeasonNorm.GetPeriod() * cSeasonNorm.GetUnits(); windows[2] = 2 * cSeasonNorm.GetUnits(); if(!cConcatenated.SwapOutputs() || !Concat(cConcatenated.getPrevOutput(), cSeasonNorm.getOutput(), cSeasonNorm.GetMeanSTDevs().getOutput(), cConcatenated.getOutput(), windows[0], windows[1], windows[2], iWindow)) return false;
Tras la información estacional, se añaden sucesivamente al tensor los datos de la componente a corto plazo y de la componente espacial asociada. En esta etapa seguimos aplicando la lógica de combinación por secuencias unitarias, ampliando sucesivamente el tensor con nuevos atributos.
windows[0] = windows[0] + windows[1] + windows[2]; windows[1] = cShortNorm.GetPeriod() * cShortNorm.GetUnits(); windows[2] = 2 * cShortNorm.GetUnits(); if(!cConcatenated.SwapOutputs() || !Concat(cConcatenated.getPrevOutput(), cShortNorm.getOutput(), cShortNorm.GetMeanSTDevs().getOutput(), cConcatenated.getOutput(), windows[0], windows[1], windows[2], iWindow)) return false; windows[0] = windows[0] + windows[1] + windows[2]; windows[1] = cAdaptSpatNorm.GetUnits(); windows[2] = 2 * cAdaptSpatNorm.GetUnits(); if(!cConcatenated.SwapOutputs() || !Concat(cConcatenated.getPrevOutput(), cAdaptSpatNorm.getOutput(), cAdaptSpatNorm.GetMeanSTDevs().getOutput(), cConcatenated.getOutput(), windows[0], windows[1], windows[2], iWindow)) return false;
La componente a corto plazo aporta al modelo un contexto reciente: fluctuaciones locales, picos y micropatrones, que resultan especialmente importantes para lograr una alta sensibilidad de la predicción. La normalización espacial, por su parte, completa la fase de preparación, aportando estabilidad adicional y suavizando los datos de entrada en el conjunto de todas las variables.
Como resultado, formamos una representación significativa y equilibrada de la serie temporal, en la que cada componente está representada de forma coherente. Este tensor combinado se pasa a la capa de proyección, cuya tarea es generar una representación ponderada de los datos de entrada teniendo en cuenta el horizonte de predicción. En esta etapa, los datos se ajustan a la dimensionalidad de salida requerida y se adaptan a las tareas de análisis posteriores. Es precisamente aquí donde se sientan las bases para generar una predicción significativa, capaz de tener en cuenta tanto la profundidad histórica como la estructura de los cambios esperados.
if(!cProjection.FeedForward(cConcatenated.AsObject())) return false;
Las representaciones de las secuencias unitarias individuales, obtenidas en las etapas anteriores, se alinean para formar la estructura de una serie temporal multimodal en el módulo Fusion. Aquí se produce una transición de la representación por secuencias unitarias a los pasos temporales: el tensor se transpone y el procesamiento posterior se organiza ya a lo largo del eje temporal.
En esta etapa se utilizan dos capas convolucionales consecutivas. La primera de ellas utiliza la función de activación TANH, lo que permite obtener una representación rica y no lineal de cada característica; en esencia, se trata de una versión normalizada de la señal característica. La segunda capa, que utiliza SIGMOID, actúa como una puerta: determina el grado de importancia de cada característica en el contexto temporal, ponderando suavemente los valores obtenidos.
El resultado final se obtiene mediante la multiplicación elemento por elemento de las salidas de las dos capas, lo que permite filtrar eficazmente el ruido y resaltar las características más significativas de la serie temporal, ya en el espacio armonizado de la tarea de predicción.
if(!cTranspose.FeedForward(cProjection.AsObject())) return false; for(uint i = 0; i < caFusion.Size(); i++) if(!caFusion[i].FeedForward(cTranspose.AsObject())) return false; if(!ElementMult(caFusion[0].getOutput(), caFusion[1].getOutput(), cFusionOut.getOutput())) return false; //--- return CNeuronTransposeOCL::feedForward(cFusionOut.AsObject()); }
En la etapa final de la pasada directa, los datos se someten a una transposición inversa, pasando de la representación temporal a la estructura original de secuencias unitarias. Esto permite mantener la coherencia entre los formatos de entrada y salida del codificador.
Por lo tanto, el método feedForward constituye una arquitectura minuciosamente organizada y bien concebida, en la que cada componente desempeña una función estrictamente definida. Desde la normalización y la descomposición hasta las proyecciones y la agregación multimodal, todo el proceso tiene como objetivo generar una descripción rica, estructurada e informativa de la serie temporal.
Una vez finalizado el análisis del algoritmo de pasada directa, lo lógico es pasar a una etapa no menos importante: la propagación inversa del error. Es precisamente en ese momento cuando comienza el entrenamiento del modelo: los valores de los gradientes, calculados en la salida, se transmiten gradualmente hacia atrás a lo largo de toda la cadena de operaciones, lo que permite ajustar los parámetros internos de cada módulo. El método calcInputGradients lleva a cabo este proceso, garantizando un orden estricto y la coherencia con la arquitectura de la pasada directa.
bool CNeuronSCNNEncoder::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false; //--- if(!CNeuronTransposeOCL::calcInputGradients(cFusionOut.AsObject())) return false; if(!ElementMultGrad(caFusion[0].getOutput(), caFusion[0].getGradient(), caFusion[1].getOutput(), caFusion[1].getGradient(), cFusionOut.getGradient(), caFusion[0].Activation(), caFusion[1].Activation())) return false; if(!cTranspose.CalcHiddenGradients(caFusion[0].AsObject())) return false;
La pasada inversa comienza en el nivel superior del modelo, donde se utilizan como entrada los gradientes obtenidos de la siguiente capa del modelo (o de la función de pérdida, si se trata de la propia salida). En primer lugar se procesan los bloques de convolución utilizados en el módulo Fusion. En este punto, es importante comprender que, en la etapa de la pasada directa, utilizamos dos canales convolucionales paralelos: uno con la función de activación TANH para generar una característica no lineal, y otro con SIGMOID, que actúa como puerta y determina el grado de relevancia de la característica. En la salida, estos canales se multiplicaban elemento por elemento, formando la representación final.
En la pasada inversa, esta operación de multiplicación requiere un tratamiento especial. El método ElementMultGrad vuelve a distribuir correctamente los gradientes entre ambas ramas, teniendo en cuenta las características específicas de cada función de activación. Esto es fundamental para la precisión de los cálculos, ya que es aquí donde se decide cómo ajustar los pesos dentro de las convoluciones.
A continuación, pasamos a la transmisión de los gradientes al nivel del módulo Transpose, que, como ya se ha señalado, en la pasada directa se encargaba de la reorientación de los ejes del tensor, pasando de la representación de secuencias unitarias a los pasos temporales. Los datos se introducían precisamente de esta forma en las dos capas de convolución del módulo Fusion, por lo que ahora la tarea de la pasada inversa consiste en combinar correctamente los gradientes procedentes de ambas ramas.
Como primer paso, propagamos hacia abajo el gradiente del error desde una de las capas convolucionales. En lugar de copiar la información de todo el tensor, utilizamos la reasignación del puntero al búfer de datos, lo que permite cambiar de área de memoria de forma eficaz y guardar los valores obtenidos sin un gasto innecesario de recursos. A continuación, con el puntero ya reasignado, iniciamos la pasada inversa de la segunda capa convolucional.
CBufferFloat* temp = cTranspose.getGradient(); if(!cTranspose.SetGradient(cTranspose.getPrevOutput(), false) || !cTranspose.CalcHiddenGradients(caFusion[1].AsObject()) || !SumAndNormilize(temp, cTranspose.getGradient(), temp, cTranspose.GetCount(), false, 0, 0, 0, 1) || !cTranspose.SetGradient(temp, false)) return false;
A continuación, los dos flujos de gradientes se acumulan mediante una suma elemento por elemento, lo que refleja la influencia combinada de ambas ramas en el error final. Al finalizar, los punteros a los búferes vuelven a su estado inicial, lo que garantiza que la estructura de datos sea correcta para la posterior propagación de los gradientes. Este enfoque garantiza la coherencia del modelo y un uso óptimo de la memoria durante el entrenamiento.
Luego el error se transmite al bloque Projection, encargado de ponderar la información antes de los módulos de convolución. El gradiente de este bloque se transmite hacia abajo al bloque Concatenated, donde comienza una de las etapas técnicamente más complejas: la desconcatenación inversa.
if(!cProjection.CalcHiddenGradients(cTranspose.AsObject())) return false; if(!cConcatenated.CalcHiddenGradients(cProjection.AsObject())) return false;
Recordemos que, en la etapa de la pasada directa, fuimos combinando gradualmente los datos procedentes de distintos normalizadores: a largo plazo, estacional, a corto plazo y espacial. Además, junto con los datos normalizados, también se incluían en el tensor combinado los parámetros estadísticos (valores medios, desviaciones estándar).
Ahora la tarea es la contraria: descomponer de nuevo el tensor combinado en sus partes constituyentes. Para ello se utiliza el procedimiento secuencial DeConcat, en el que los tamaños de las ventanas de cada componente se calculan siguiendo estrictamente la disposición anterior. Esta etapa requiere una precisión especial, ya que el más mínimo error en el tamaño de la ventana puede provocar un desplazamiento o la pérdida de datos.
uint windows[3] = {0}; windows[1] = cAdaptSpatNorm.GetUnits(); windows[2] = 2 * cAdaptSpatNorm.GetUnits(); windows[0] = cConcatenated.Neurons() / iWindow - windows[1] - windows[2]; if(!DeConcat(cConcatenated.getPrevOutput(), cAdaptSpatNorm.getGradient(), cAdaptSpatNorm.GetMeanSTDevs().getGradient(), cConcatenated.getGradient(), windows[0], windows[1], windows[2], iWindow)) return false; windows[1] = cShortNorm.GetPeriod() * cShortNorm.GetUnits(); windows[2] = 2 * cShortNorm.GetUnits(); windows[0] = windows[0] - windows[1] - windows[2]; if(!DeConcat(cConcatenated.getGradient(), cShortNorm.getPrevOutput(), cShortNorm.GetMeanSTDevs().getGradient(), cConcatenated.getPrevOutput(), windows[0], windows[1], windows[2], iWindow)) return false; windows[1] = cSeasonNorm.GetPeriod() * cSeasonNorm.GetUnits(); windows[2] = 2 * cSeasonNorm.GetUnits(); windows[0] = windows[0] - windows[1] - windows[2]; if(!DeConcat(cConcatenated.getPrevOutput(), cSeasonNorm.getPrevOutput(), cSeasonNorm.GetMeanSTDevs().getGradient(), cConcatenated.getGradient(), windows[0], windows[1], windows[2], iWindow)) return false; windows[1] = cLongNorm.GetPeriod() * cLongNorm.GetUnits(); windows[2] = 2 * cLongNorm.GetUnits(); windows[0] = windows[0] - windows[1] - windows[2]; if(!DeConcat(NeuronOCL.getPrevOutput(), cLongNorm.getPrevOutput(), cLongNorm.GetMeanSTDevs().getGradient(), cConcatenated.getPrevOutput(), windows[0], windows[1], windows[2], iWindow)) return false;
En esta fase, conviene destacar por separado un aspecto técnico importante. Con excepción del módulo de normalización espacial, los módulos de normalización no constituían el punto final de la cadena de preparación de datos. Sus resultados se utilizaron como datos de entrada para las siguientes etapas de procesamiento. Esto significa que, en el momento de la pasada inversa, cada uno de estos módulos recibe el gradiente de error no de una sola fuente, sino de varias. Para tener en cuenta correctamente la contribución de todas las operaciones posteriores, no podemos limitarnos a sobrescribir el gradiente, como se suele hacer en una arquitectura más sencilla. En su lugar, se utiliza un búfer de reserva: cada módulo guarda allí su gradiente actual antes de recibir uno nuevo.
De este modo, la pasada inversa por la cadena de normalizaciones se lleva a cabo mediante la acumulación de gradientes en búferes especiales. Esto garantiza una reconstrucción precisa de la influencia de cada componente del modelo y evita la pérdida de información.
Tras una desconcatenación correcta, cada uno de los módulos de normalización —empezando por el de corto plazo cShortNorm, seguido del estacional cSeasonNorm y, por último, el de largo plazo cLongNorm— recibe la parte correspondiente del gradiente de error, que refleja la influencia de la cadena de procesamiento de datos en la pasada directa. Pero, como ya hemos señalado, cada uno de estos normalizadores no participaba solo en el proceso de preparación de los datos. Por eso, limitarse a transmitir hacia atrás el gradiente por una sola ruta no sería suficiente.
En la práctica, esto se lleva a cabo de la siguiente manera. Una vez que el normalizador recibe el gradiente de error del objeto que utiliza sus datos, lo sumamos a los valores ya disponibles, obtenidos en la fase de desconcatenación. De este modo, en cada etapa sumamos cuidadosamente la información procedente de varias fuentes. Solo tras este proceso de agregación, el gradiente resultante se transmite hacia abajo por la cadena, hasta la capa anterior.
if(!cShortNorm.CalcHiddenGradients(cAdaptSpatNorm.AsObject()) || !SumAndNormilize(cShortNorm.getGradient(), cShortNorm.getPrevOutput(), cShortNorm.getGradient(), cShortNorm.GetPeriod(), false, 0, 0, 0, 1)) return false; if(!cUnSeasonTransp.CalcHiddenGradients(cShortNorm.AsObject())) return false; if(!cSeasonNorm.CalcHiddenGradients(cUnSeasonTransp.AsObject()) || !SumAndNormilize(cSeasonNorm.getGradient(), cSeasonNorm.getPrevOutput(), cSeasonNorm.getGradient(), cSeasonNorm.GetPeriod(), false, 0, 0, 0, 1)) return false; if(!cSeasonTransp.CalcHiddenGradients(cSeasonNorm.AsObject())) return false; if(!cLongNorm.CalcHiddenGradients(cSeasonTransp.AsObject()) || !SumAndNormilize(cLongNorm.getGradient(), cLongNorm.getPrevOutput(), cLongNorm.getGradient(), cLongNorm.GetPeriod(), false, 0, 0, 0, 1)) return false;
Este enfoque garantiza que ninguna conexión de información establecida en la pasada directa se pierda ni se altere durante la propagación inversa del error. Precisamente este rigor y esta precisión en la transmisión de los gradientes son uno de los factores que contribuyen a la estabilidad y a la alta interpretabilidad del framework SCNN en condiciones reales de mercado.
La pasada inversa concluye con el cálculo de los gradientes en la propia entrada: el objeto NeuronOCL. Este paso es de vital importancia, ya que es aquí donde confluyen todos los flujos de información que han pasado por una compleja cadena de transformaciones y normalizaciones.
if(!NeuronOCL.CalcHiddenGradients(cLongNorm.AsObject()) || !SumAndNormilize(NeuronOCL.getGradient(), NeuronOCL.getPrevOutput(), NeuronOCL.getGradient(), cLongNorm.GetPeriod(), false, 0, 0, 0, 1)) return false; //--- return true; }
Es importante señalar que en este punto confluyen dos flujos clave: uno procedente de un complejo sistema de normalizaciones y otro procedente de la capa de proyección, encargada de formar el espacio de características para la predicción. Su correcta agregación garantiza la continuidad del flujo de gradientes y la correcta actualización de los pesos del modelo durante el entrenamiento.
De este modo, el método calcInputGradients no se limita a realizar un simple retroceso técnico a lo largo de la cadena, sino que implementa un procedimiento de propagación inversa del error preciso, modular y estructurado. Cada acción que se lleva a cabo en él se corresponde claramente con los pasos anteriores de la pasada directa, lo que hace que la arquitectura del codificador SCNN sea coherente, simétrica y fácil de interpretar.
La optimización de los parámetros entrenables del codificador CNeuronSCNNEncoder se lleva a cabo delegando la responsabilidad a los módulos internos correspondientes, cada uno de los cuales contiene sus propios pesos y aplica su propia lógica de actualización. El método updateInputWeights pasa secuencialmente el control a estos módulos, lo que garantiza una gestión del entrenamiento centralizada pero modular.
bool CNeuronSCNNEncoder::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { if(!cAdaptSpatNorm.UpdateInputWeights(cShortNorm.AsObject())) return false; if(!cProjection.UpdateInputWeights(cConcatenated.AsObject())) return false; for(uint i = 0; i < caFusion.Size(); i++) if(!caFusion[i].UpdateInputWeights(cTranspose.AsObject())) return false; //--- return true; }
Gracias a su estructura modular, el algoritmo de actualización de los pesos sigue siendo sencillo y lógico, sin sobrecargar el código con dependencias innecesarias.
El código fuente completo de la clase CNeuronSCNNEncoder, que incluye la implementación de todos los métodos clave, se encuentra en el archivo adjunto. Gracias a su estructura clara y a su descomposición detallada en módulos, este código puede servir de base para futuros experimentos y modificaciones.
Módulo de nivel superior
Los autores del framework SCNN proponen usar una arquitectura jerárquica en la que varios codificadores se organizan en una misma pila. Esta solución está orientada a aumentar la expresividad del modelo y, en consecuencia, mejorar la calidad de la predicción. Cada codificador sucesivo de dicha cadena no funciona de forma aislada, sino que utiliza los resultados del anterior como datos de entrada. Además, el uso de conexiones residuales proporciona un contexto adicional para el análisis. De este modo, el modelo es capaz de tener en cuenta un contexto más amplio, lo que permite profundizar en el análisis y detectar tanto las dependencias locales como las a largo plazo en la serie temporal.
Sin embargo, esa flexibilidad tiene su precio. Al pasar de una capa a otra surge un problema muy concreto: la diferencia en el tamaño del tensor entre la entrada y la salida. El caso es que, a la salida de cada codificador SCNN, obtenemos secuencias unitarias en un formato comparable, pero ampliadas en un horizonte de predicción determinado.
Y es aquí donde surge un problema complejo. Por un lado, para la siguiente capa es necesario proporcionar datos sin incluir en ellos valores predichos; de lo contrario, estaríamos transmitiendo información del futuro, lo cual es inadmisible. Por otro lado, no podemos descartar sin más estos valores predichos, ya que son necesarios para la suma final de las salidas de todas las capas. En pocas palabras, debemos tener en cuenta al mismo tiempo tanto lo que el modelo ya ha observado como lo que ha predicho, sin alterar por ello la estructura temporal de los datos.
Para resolver este problema, crearemos un objeto de nivel superior: CNeuronSCNN. Se trata de un elemento de control generalizado que encapsula toda la estructura apilada de codificadores. Su función no es simplemente invocar codificadores uno tras otro, sino garantizar el funcionamiento correcto de toda la arquitectura: transmitir datos entre capas, alinear sus tamaños, acumular predicciones, gestionar el entrenamiento y la propagación inversa del error. Precisamente este objeto actúa como nexo de unión entre la lógica computacional y la coherencia arquitectónica del modelo.
A continuación se muestra la estructura del nuevo objeto.
class CNeuronSCNN : public CNeuronBaseOCL { protected: CLayer cLayers; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronSCNN(void) {}; ~CNeuronSCNN(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint variables, uint forecast, uint season_period, uint short_period, uint layers, 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 defNeuronSCNN; } virtual void TrainMode(bool flag) override; virtual void SetOpenCL(COpenCLMy *obj) override; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; virtual void SetActivationFunction(ENUM_ACTIVATION value) override { } };
Dentro de CNeuronSCNN se encuentra un objeto de tipo CLayer, que, en esencia, es un contenedor para todos los codificadores anidados. El único objeto interno de la clase se declara de forma estática, lo que nos permite dejar vacíos el constructor y el destructor de la clase, delegando así la gestión de la memoria al sistema.
Para poner en funcionamiento todo el sistema, es necesario montar correctamente la arquitectura a partir de sus componentes. Esta tarea se resuelve en el método Init, donde se crea y se configura toda la estructura dinámica del objeto CNeuronSCNN.
bool CNeuronSCNN::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint variables, uint forecast, uint season_period, uint short_period, uint layers, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, (units_count + forecast)*variables, optimization_type, batch)) return false; SetActivationFunction(None);
Aquí todo comienza con una inicialización básica a través del método del mismo nombre de la clase padre, donde se crean las interfaces básicas de la capa neuronal. A continuación, se desactiva la función de activación, ya que en este nivel no es necesaria, puesto que los codificadores incorporan por sí mismos toda la no linealidad y todo el preprocesamiento de datos necesarios.
A continuación, el contenedor cLayers se vacía y se prepara para alojar los objetos necesarios.
cLayers.Clear(); cLayers.SetOpenCL(OpenCL); CNeuronSCNNEncoder* encoder = NULL; CNeuronBaseOCL* residual = NULL; for(uint l = 0; l < layers; l++) { encoder = new CNeuronSCNNEncoder(); if(!encoder) return false; if(!encoder.Init(0, l, OpenCL, units_count, variables, forecast, season_period, short_period, optimization, iBatch) || !cLayers.Add(encoder)) return false; encoder.SetActivationFunction(None); if((l + 1) == layers) break;
El ciclo que recorre el número de capas (parámetro layers) comienza a crear, una tras otra, codificadores CNeuronSCNNEncoder. Para cada capa se crea un objeto codificador y se inicializa con los siguientes parámetros: dimensionalidad de las entradas, número de variables, longitud de la predicción y periodos de estacionalidad. Si la inicialización se realiza correctamente, el objeto se añade al contenedor de capas.
Pero el trabajo no termina ahí. Para conservar las conexiones residuales entre las capas, se insertan capas adicionales entre los codificadores, que actúan como una especie de búferes de alineación. Estos bloques intermedios toman las salidas de los codificadores anteriores y descartan los valores predichos, lo que garantiza la continuidad del flujo de datos entre niveles. Aquí vuelve a desactivarse la función de activación, ya que estos bloques desempeñan una función más bien auxiliar que computacional.
residual = new CNeuronBaseOCL(); if(!residual) return false; if(!residual.Init(0, l, OpenCL, units_count * variables, optimization, iBatch) || !cLayers.Add(residual)) return false; residual.SetActivationFunction(None); } if(!SetGradient(encoder.getGradient(), true)) return false; //--- return true; }
Debemos señalar que el codificador final de la pila no recibe ninguna capa auxiliar posterior. Su gradiente se utiliza como principal para toda la capa superior, lo que permite evitar una operación superflua de copia de datos.
De este modo, el método Init pasa de ser un simple inicializador a convertirse en una especie de constructor de objetos que, sobre la marcha, ensambla toda la estructura apilada de codificadores, ajustando cuidadosamente cada detalle y teniendo en cuenta la alineación de las dimensiones, los horizontes de predicción y la gestión del flujo de gradientes. Gracias a esta arquitectura cuidadosamente diseñada, no solo es posible realizar un entrenamiento eficaz, sino también escalar el modelo para tareas reales, ya sea la predicción de precios a corto plazo o el análisis a largo plazo de las tendencias estacionales.
Una vez finalizada la fase de inicialización, cuando la estructura del modelo SCNN multicapa está montada y lista para funcionar, llega el momento clave: la ejecución de la pasada directa. Es precisamente aquí donde el modelo comienza a desempeñar su función principal: procesar los datos de entrada, generar predicciones y acumular información en cada nivel.
bool CNeuronSCNN::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false; if(!Output.Fill(0)) return false; CNeuronBaseOCL* inputs = NeuronOCL; CNeuronSCNNEncoder* current = NULL; CNeuronBaseOCL* residual = NULL; int layers = cLayers.Total();
Todo comienza con la comprobación de que el puntero recibido a los datos de entrada es correcto. Si faltan, se detiene la ejecución. A continuación, se borra el búfer de resultados para evitar la acumulación de artefactos de iteraciones anteriores. A continuación, la variable inputs se inicializa con un puntero a una fuente de datos externa: aquella que pasamos como entrada al codificador. A continuación comienza el recorrido por todas las capas.
La arquitectura del modelo está diseñada de tal forma que las capas se alternan: en las posiciones pares se encuentran los codificadores SCNN, y en las impares, los búferes de conexiones residuales, implementados como objetos neuronales básicos. Precisamente por eso, en el ciclo el paso es igual a dos: una iteración abarca tanto el codificador como el bloque residual correspondiente.
for(int l = 0; l < layers; l += 2) { current = cLayers[l]; if(!current || !current.FeedForward(inputs) || !SumAndNormilize(Output, current.getOutput(), Output, current.GetCount(), false, 0, 0, 0, 1)) return false; if((l + 1) == layers) break;
En cada iteración del ciclo, recuperamos el codificador actual, llamamos a su método FeedForward y, a continuación, sumamos la salida del módulo a la predicción resultante. De este modo, ya durante la pasada directa comienza a formarse el resultado acumulado de todas las predicciones de las capas.
Pero si aún no es la última capa, queda trabajo adicional por realizar. Extraemos el siguiente objeto (impar): el búfer de conexiones residuales. Su tarea consiste en ajustar las dimensiones de los datos de entrada para el siguiente codificador, eliminando la redundancia derivada de la incorporación de valores predichos. Esta tarea se resuelve mediante el método DeConcat, que separa la parte de predicción de la historia limpia y devuelve los datos a su forma original. Después de esto, las nuevas entradas se suman a las anteriores, lo que proporciona el efecto de las conexiones residuales, y luego se normalizan. Cada capa no funciona de forma aislada, sino teniendo en cuenta los resultados de las anteriores, con lo que obtiene una representación más rica de la serie temporal.
uint variables = current.GetWindow(); uint dimension = inputs.Neurons() / variables; uint forecast = current.GetCount() - dimension; residual = cLayers[l + 1]; if(!residual) return false; if(!DeConcat(residual.getOutput(), current.getPrevOutput(), current.getOutput(), dimension, forecast, variables) || !SumAndNormilize(residual.getOutput(), inputs.getOutput(), residual.getOutput(), dimension, true, 0, 0, 0, 1)) return false; inputs = residual; } //--- return true; }
Los datos obtenidos tras la desconcatenación y la normalización se transmiten al siguiente codificador, y así sucesivamente hasta completar el paso por todos los niveles. A la salida se genera una predicción agregada, resultado del trabajo conjunto de todos los codificadores del bloque.
De este modo, el método feedForward constituye un proceso cuidadosamente estructurado, en el que cada paso está orientado a enriquecer la información, eliminar distorsiones y preparar una predicción equilibrada. Pone en práctica toda la filosofía de la arquitectura apilada: una profundización progresiva del análisis sin perder el contexto histórico y teniendo en cuenta las contribuciones de cada nivel.
Una vez finalizada la pasada directa, pasamos a una fase no menos importante: la propagación inversa del error. Aquí se implementa la transmisión de gradientes desde la capa de salida de vuelta hacia los datos de entrada, lo que permite optimizar los parámetros del modelo. El proceso está organizado en el método calcInputGradients.
bool CNeuronSCNN::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false; if(!PrevOutput.Fill(0)) return false; //--- CNeuronBaseOCL* inputs = NULL; CNeuronSCNNEncoder* current = cLayers[-1]; CNeuronBaseOCL* residual = NULL; int layers = cLayers.Total() - 2;
El procedimiento comienza con comprobaciones básicas: la presencia de un puntero al objeto externo NeuronOCL y la puesta a cero del búfer auxiliar PrevOutput. A continuación, se determina el número de capas que intervienen en el entrenamiento. Aquí es importante entender que el recuento se realiza en orden inverso, empezando por el último codificador, ya que la pasada inversa requiere desplazarse desde la salida hacia la entrada del modelo.
El ciclo con paso «-1» abarca todas las capas, incluidos los objetos de conexiones residuales, y para cada una se ejecuta la lógica correspondiente en función del tipo de capa.
for(int l = layers; l >= 0; l--) switch(cLayers[l].Type()) { case defNeuronBaseOCL: inputs = cLayers[l]; if(!inputs || !inputs.CalcHiddenGradients(current)) return false; if(!!residual) if(!SumAndNormilize(inputs.getGradient(), residual.getGradient(), inputs.getGradient(), current.GetWindow(), false, 0, 0, 0, 1)) return false; residual = inputs; break;
Si se trata de un objeto base de tipo CNeuronBaseOCL, realizamos dos acciones. En primer lugar, calculamos los gradientes de la capa oculta a partir del codificador actual. En segundo lugar, si existe un bloque residual anterior (variable residual), sumamos sus gradientes a los actuales, ajustando el valor transmitido. Esto garantiza la continuidad de la información entre las capas y permite evitar las pérdidas provocadas por la estructura residual de la arquitectura.
Sin embargo, si la capa actual es un codificador SCNN, la lógica se vuelve más compleja. En primer lugar, nos aseguramos de que el objeto residual ya esté definido. Si no es así, simplemente seguimos adelante. De lo contrario, extraemos el objeto situado dos capas más adelante, es decir, aquel que proporcionó datos a la entrada del codificador actual durante la pasada directa. Esto permite reconstruir con precisión la estructura de los datos.
case defNeuronSCNNEncoder: current = cLayers[l]; if(!residual) break; inputs = cLayers[l + 2]; if(!inputs) return false; if(!Concat(residual.getGradient(), PrevOutput, current.getGradient(), residual.Neurons() / current.GetWindow(), current.GetCount() - residual.Neurons() / current.GetWindow(), current.GetWindow()) || !SumAndNormilize(current.getGradient(), inputs.getGradient(), current.getGradient(), current.GetWindow(), false, 0, 0, 0, 1)) return false; break; default: return false; break; }
A continuación, mediante la función Concat, reconstruimos la forma del gradiente: al gradiente actual de la capa residual se le añaden los valores nulos de su PrevOutput, correspondientes a la parte de predicción. Esto es necesario porque, durante la pasada directa, los datos se dividieron en una parte histórica y una parte de predicción, y ahora hay que reproducir exactamente esa estructura en el gradiente del error. A continuación, se suman al gradiente del codificador posterior; este es un punto importante, ya que la información puede circular por dos flujos de información, y estos deben fusionarse en una única forma.
Una vez completada la iteración por todas las capas, se lleva a cabo el paso final: la transferencia del gradiente al nivel más externo. Aquí, NeuronOCL recibe el gradiente del codificador SCNN inferior y, si hay una capa residual, se lleva a cabo la suma final de los gradientes.
if(!NeuronOCL.CalcHiddenGradients(current)) return false; if(!!residual) if(!SumAndNormilize(NeuronOCL.getGradient(), residual.getGradient(), NeuronOCL.getGradient(), current.GetWindow(), false, 0, 0, 0, 1)) return false; //--- return true; }
De este modo, el método calcInputGradients pone en práctica la idea clave de la arquitectura apilada: cada capa tiene la posibilidad de ajustar sus parámetros no solo teniendo en cuenta su propio error, sino también la forma en que influye en el modelo en su conjunto. Esto permite un ajuste fino y una alta sensibilidad a las características de las series temporales.
Una vez finalizado el cálculo de los gradientes en todos los niveles de la arquitectura apilada, llega la fase final del entrenamiento: la actualización de los pesos. Es precisamente aquí donde se lleva a la práctica todo el trabajo realizado anteriormente: transformamos la información acumulada sobre el error en ajustes de los parámetros entrenables del modelo.
El método updateInputWeights se encarga de actualizar por etapas los pesos de todos los bloques entrenables dentro del modelo. Todo comienza cuando la variable inputs se inicializa con un puntero a una fuente de datos externa: el objeto NeuronOCL.
bool CNeuronSCNN::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { CNeuronBaseOCL* inputs = NeuronOCL; CNeuronBaseOCL* current = NULL; //--- for(int l = 0; l < cLayers.Total(); l++) { current = cLayers[l]; if(!current) return false; if(current.Type() == defNeuronSCNNEncoder) if(!current.UpdateInputWeights(inputs)) return false; inputs = current; } //--- return true; }
A continuación, se inicia una iteración por todas las capas del modelo. En cada paso del ciclo se comprueba el tipo de capa. Solo nos interesan los codificadores SCNN, ya que son los únicos que contienen parámetros entrenables. Si la capa actual corresponde a este tipo, se invoca el método UpdateInputWeights, al que se pasan los datos de entrada. Esto permite reajustar correctamente los pesos en función del gradiente obtenido. Si la actualización se realiza correctamente, sustituimos el puntero en la variable inputs para que se convierta en la entrada de la siguiente capa. De este modo, se mantiene la coherencia lógica característica de la pasada directa y la propagación inversa del error.
El método updateInputWeights pone fin al ciclo de entrenamiento, permitiendo que cada codificador se adapte a los errores del modelo y contribuya al proceso general de optimización. Todo está implementado de manera sobria y pragmática, sin complejidad innecesaria, pero respetando plenamente la lógica de la arquitectura.
El código completo de esta clase CNeuronSCNN y de todos sus métodos se incluye en el archivo adjunto.
Arquitectura del modelo
Una vez completada la descripción de la lógica de los objetos que construyen el framework SCNN, el siguiente paso natural es profundizar en la arquitectura del propio modelo, ya que es precisamente esta la que determina con qué precisión y estabilidad es capaz el sistema de extraer patrones de los datos. Y aquí se despliega ante nosotros el proceso de construcción de la configuración del pipeline de la red neuronal, que se lleva a cabo en el método CreateDescriptions.
Este método se encarga de crear y rellenar las descripciones de las capas: los bloques de construcción a partir de los cuales se forma posteriormente el modelo computacional. En pocas palabras, aquí se van disponiendo, ladrillo a ladrillo, las capas de la futura red neuronal: desde la capa de datos de entrada hasta los codificadores y las ramas de predicción. Al inicio, vemos cómo se crean e inicializan seis contenedores: uno para el codificador principal del estado del entorno, tres variantes de modelos de predicción, así como las ramas Actor y Critic, que se utilizan en tareas de aprendizaje por refuerzo.
El propio codificador del estado del entorno parte de una capa básica totalmente conectada que recibe un vector de datos de entrada generado a partir de barras históricas.
bool CreateDescriptions(CArrayObj *&encoder, CArrayObj *&forecast1, CArrayObj *&forecast2, CArrayObj *&forecast3, CArrayObj *&actor, CArrayObj *&critic ) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; } if(!forecast1) { forecast1 = new CArrayObj(); if(!forecast1) return false; } if(!forecast2) { forecast2 = new CArrayObj(); if(!forecast2) return false; } if(!forecast3) { forecast3 = new CArrayObj(); if(!forecast3) return false; } if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; } //--- Codificador encoder.Clear(); //--- Capa de entrada if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; uint prev_count = descr.count = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
Luego se añade una capa de normalización por lotes con incorporación de ruido durante la fase de entrenamiento, que actúa como regularizador y aumenta la robustez del modelo frente al ruido presente en los datos.
//--- capa 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormWithNoise; descr.count = prev_count; descr.batch = BatchSize; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } //--- capa 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatDiff; prev_count = descr.count = HistoryBars; descr.layers = BarDescr; descr.step = 1; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
A continuación, se añade la capa de adición de características de primera diferencia CNeuronConcatDiff, que genera características derivadas y ayuda a la red a captar mejor los cambios locales.
Merece una atención especial la capa CMamba4CastEmbeding, que constituye un elemento arquitectónico moderno. Extrae características ocultas considerando varias ventanas temporales (en este caso, la diaria y la mensual), creando así incorporaciones que tienen en cuenta los armónicos temporales. Es precisamente aquí donde la red neuronal empieza a considerar por primera vez la estacionalidad y las tendencias a largo plazo.
//--- capa 3 if(!(descr = new CLayerDescription())) return false; descr.type = defMamba4CastEmbeding; prev_count = descr.count = HistoryBars; descr.window = 2 * BarDescr; uint prev_out = descr.window_out = NSkills; { uint temp[] = {PeriodSeconds(PERIOD_D1), PeriodSeconds(PERIOD_MN1)}; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } //--- capa 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; prev_count = descr.window = prev_out; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } prev_out = descr.count;
Después las incorporaciones se transponen a representaciones de secuencias unitarias.
Y la culminación de toda la estructura del codificador del estado del entorno es la integración del módulo descrito anteriormente: la pila de codificadores SCNN. Es precisamente este bloque, estructurado en cuatro niveles de anidación y cuidadosamente configurado para detectar patrones estacionales y de corto plazo, el que constituye el núcleo del procesamiento inteligente de la información.
//--- capa 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSCNN; descr.variables = prev_count; { uint temp[]={prev_out,NForecast,SeasonPeriod,ShortPeriod}; if(ArrayCopy(descr.windows,temp)<(int)temp.Size()) return false; } descr.count=descr.windows[0]+descr.windows[1]; descr.layers=4; descr.batch = BatchSize; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } uint variables=descr.variables; uint count=descr.count;
Aquí ocurre algo más que una simple transformación de datos. Este módulo asume la carga analítica fundamental: no solo extrae características, sino que las estructura en forma de representaciones ocultas multicapa, cada una de las cuales generaliza las regularidades observadas a su propia escala. Como resultado, obtenemos vectores de predicción que ya están enriquecidos con conexiones residuales y la memoria de los niveles anteriores.
En esencia, es precisamente en este elemento donde se produce la transformación de los datos de entrada brutos en una descripción de alto nivel del estado del mercado. Aquí, los patrones abstractos de comportamiento se convierten en estructuras numéricas, listas para ser interpretadas bien por módulos de predicción, bien por las ramas de control del modelo. Y es precisamente a partir de ese momento cuando la arquitectura alcanza su plenitud, pasando de la preparación y la normalización al análisis, la interpretación y la toma de decisiones.
En este punto conviene destacar especialmente un aspecto técnico importante. A la salida de la pila de codificadores SCNN obtenemos un array de secuencias unitarias, lo cual resulta práctico y lógico para los modelos de predicción posteriores. Es ideal para generar predicciones para un horizonte de predicción dado. Sin embargo, no resulta del todo adecuado en el contexto del análisis que llevarán a cabo los módulos Actor y Critic.
La cuestión es que, para tomar decisiones, el agente no solo debe conocer cada predicción por separado, sino comprenderlas como una estructura temporal coherente: una especie de dinámica multimodal que abarca tanto los aspectos a corto plazo como los aspectos a largo plazo del comportamiento del sistema. Para ello se requiere una organización diferente de los datos: una ordenación secuencial de los pasos temporales que conserve sus interrelaciones.
Precisamente por eso añadimos otra capa de transposición. Este paso transforma nuestro tensor de un conjunto de predicciones unitarias al formato correspondiente a los pasos temporales de la secuencia multimodal. Cada punto temporal tiene acceso a todas las variables de predicción recopiladas de los distintos canales. Gracias a ello, Actor y Critic pueden percibir la dinámica del entorno a lo largo del tiempo como una visión de conjunto, y no como fragmentos aislados.
//--- capa 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = variables; prev_count = descr.window = count; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
De este modo, esta capa de transformación final completa la arquitectura del codificador y desempeña un papel clave a la hora de garantizar una interfaz adecuada entre el mecanismo de predicción y los módulos de toma de decisiones.
La arquitectura de los modelos de predicción y toma de decisiones se transfirió desde nuestros desarrollos anteriores prácticamente sin cambios. Estos componentes ya han demostrado su eficacia, por lo que en este artículo no vamos a entrar en detalles sobre ellos. A quienes estén interesados en comprender mejor las decisiones arquitectónicas, les recomendamos consultar los materiales incluidos en el archivo adjunto.
Además, en el archivo adjunto se incluyen los códigos fuente de los programas que permiten entrenar y probar los modelos. Estos materiales permitirán no solo seguir la lógica interna de construcción del sistema, sino también reproducir el ciclo completo del experimento.
Pruebas
El proceso de entrenamiento del modelo se estructura en dos fases consecutivas. En la primera fase offline, el entrenamiento se llevó a cabo con datos históricos del par de divisas EURUSD en el marco temporal H1, correspondientes a todo el año 2024. Este periodo abarcó una amplia gama de escenarios de mercado. La diversidad de los datos permitió que el modelo aprendiera a manejar tanto situaciones de mercado típicas como poco frecuentes.
Una vez finalizado el entrenamiento offline, pasamos a la segunda fase: un ajuste fino online, realizado en condiciones lo más cercanas posible al mercado real. El entrenamiento se llevó a cabo en el Simulador de estrategias de MetaTrader 5, donde el modelo analizaba paso a paso los datos de velas en streaming. Esto permitió no solo comprobar la resistencia del modelo frente al ruido y las distorsiones del mercado, sino también garantizar su capacidad de adaptación a las condiciones cambiantes. Este enfoque aumentó considerablemente la robustez del modelo, minimizó el sobreajuste y mejoró la capacidad de generalización.
El proceso concluyó con la comprobación del modelo en datos completamente nuevos: las cotizaciones correspondientes al periodo comprendido entre enero y marzo de 2025. Todos los parámetros y ajustes obtenidos durante el entrenamiento se conservaron sin modificaciones. De este modo, los resultados obtenidos ofrecen una evaluación objetiva tanto de la precisión como de la fiabilidad práctica del método propuesto. A continuación se presentan los resultados de las pruebas.

Durante los tres meses de prueba, nuestro modelo registró un aumento del capital de 100 dólares a aproximadamente 430 dólares, mientras que la ganancia media por operación (13,17 dólares) superó la pérdida media (11,71 dólares). El porcentaje de operaciones ganadoras se acerca al 48 %, y el factor de beneficio, de aproximadamente 1,04, indica que el sistema opera con un ligero margen positivo.
No obstante, la reducción máxima superó el 82 %, y la reducción más profunda se produjo en la segunda decena de marzo. Ese fue el periodo de mayor alejamiento respecto a la muestra de entrenamiento. Esto pone de manifiesto que el modelo tiene dificultades al enfrentarse a nuevas condiciones de mercado y no es lo suficientemente resistente ante picos inesperados de volatilidad.
En general, la prueba demostró que la arquitectura SCNN es capaz de generar beneficios y mantener un equilibrio entre riesgo y rentabilidad, pero que para su aplicación práctica se necesitan mecanismos adicionales de gestión de riesgos junto con una ampliación del periodo de entrenamiento. Esto permitirá que el modelo no solo funcione de forma estable en un mercado conocido, sino que también se adapte con mayor seguridad a nuevas condiciones impredecibles.
Conclusión
En este artículo hemos finalizado el desarrollo y la validación práctica del modelo SCNN para la predicción de series temporales. Se ha realizado un gran trabajo: desde el análisis de la idea teórica de la descomposición de series temporales hasta una pila de codificadores plenamente entrenado y la integración de los enfoques propuestos en la arquitectura Actor-Critic.
La prueba realizada con datos del par de divisas EURUSD, desde enero hasta marzo de 2025, confirmó la capacidad del modelo para generar beneficios y generar predicciones equilibradas; sin embargo, también puso de manifiesto su vulnerabilidad ante cambios bruscos del mercado fuera de la muestra de entrenamiento. Los drawdowns pronunciados registrados en marzo ponen de relieve la necesidad de seguir trabajando en la optimización del modelo.
Referencias
- Disentangling structured components: towards adaptive, interpretable and scalable time series forecasting
- Otros artículos de la serie
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 de descripción del estado del sistema y de 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/19022
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.
Características del Wizard MQL5 que debe conocer (Parte 73): Uso de patrones de Ichimoku y del ADX-Wilder
Red neuronal cuántica en MQL5 (Parte III): Procesador cuántico virtual con qubits
Características del Wizard MQL5 que debe conocer (Parte 74): Uso de patrones de Ichimoku y del ADX-Wilder con aprendizaje supervisado
Redes neuronales en el trading: Desentrañando las componentes estructuradas (Codificador)
- 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