Redes neuronales: así de sencillo (Parte 25): Practicando el Transfer Learning

Dmitriy Gizlyk | 22 noviembre, 2022

Contenido


Introducción

Continuamos con el estudio de la tecnología de Transfer Learning. En los dos artículos anteriores, creamos una herramienta para crear y editar modelos de redes neuronales. Precisamente esta herramienta nos ayudará a transferir parte del modelo previamente entrenado a un nuevo modelo, y también complementarlo con nuevas capas de decisión. Potencialmente, este enfoque debería ayudarnos a entrenar rápidamente un modelo creado de esta forma para resolver nuevos problemas. En este artículo, le sugerimos que evalúe los beneficios de este enfoque en la práctica. Al mismo tiempo, le animamos a comprobar la usabilidad de la herramienta.


1. Cuestiones generales sobre la preparación de la prueba

En este artículo, queremos evaluar los beneficios del uso de la tecnología de Transfer Learning. En este caso, lo mejor será comparar el proceso de aprendizaje de dos modelos al resolver un mismo problema. Para ello, tomaremos un modelo "puro", iniciado con pesos aleatorios, y luego crearemos un segundo modelo utilizando la tecnología de Transfer Learning.

Podemos usar la búsqueda de fractales como problema, tal como lo hicimos cuando probamos todos los modelos anteriores en los métodos de aprendizaje supervisado. Pero, ¿qué usaremos como modelo donante para el Transfer Learning? Aquí podrá recordar sobre los autocodificadores. Fueron ellos los que preparamos como donantes para el Transfer Learning. Al estudiar los autocodificadores, creamos y entrenamos 2 modelos de autocodificadores variacionales. En el primero, el codificador se construyó usando capas neuronales completamente conectadas. En el segundo, usamos un codificador con bloques LSTM recurrentes. Ahora podemos usar ambos modelos como donantes y, paralelamente, comprobar la efectividad del uso de cada uno de los enfoques mencionados.

Ya hemos tomado la primera decisión fundamental en la preparación de las próximas pruebas: como modelos donantes, usaremos autocodificadores variacionales entrenados en el estudio de los temas relevantes.

La segunda cuestión conceptual se relaciona con la forma en que pondremos los modelos a prueba. Al mismo tiempo, deberemos crear las condiciones más equitativas para todos los modelos. Solo en este caso, podremos excluir la influencia de otros factores y valorar verdaderamente la influencia de las características de diseño de los modelos.

Y aquí, probablemente, el momento clave sean las "características de diseño". ¿Cómo evaluar los beneficios del Transfer Learning en modelos esencialmente diferentes? En realidad, la situación no está tan clara. Recordemos lo que aprende el autocodificador. Su arquitectura está construida de tal forma que esperamos obtener los datos de origen a la salida del modelo. En este caso, el codificador comprime los datos de origen hasta el "cuello de botella" del estado latente y luego son restaurados por el decodificador. Es decir, simplemente comprimimos los datos de origen. En tal caso, podremos considerar modelos con arquitecturas idénticas, cuando la arquitectura del modelo después del bloque codificador prestado es igual a la arquitectura del modelo de control.

Por otro lado, junto con la compresión de datos, el codificador realiza el preprocesamiento de datos. Algunas características destacan, mientras que otras, por el contrario, quedan anuladas. En esta interpretación, para alinear las arquitecturas de los dos modelos, necesitaremos crear una copia exacta del modelo, pero ya inicializada con pesos aleatorios.

Y dado que existen diferencias en la comprensión de la cuestión, realizaremos pruebas con ambos enfoques para resolver el problema.

La siguiente cuestión se relaciona con la herramienta de prueba. Si antes creamos un asesor experto separado para probar cada modelo, y esto se explicaba por el hecho de que cada vez describíamos y creábamos el modelo en el bloque de inicialización del asesor experto, ahora la situación ha cambiado. Se podría decir que hemos creado una herramienta universal de creación de modelos. Con ella, podremos crear varias arquitecturas de modelo y guardarlas en un archivo. Luego podremos cargar el modelo creado en cualquier asesor experto para su entrenamiento y/o uso.

Por ello, ahora podremos crear un asesor experto con el que entrenaremos a todos los modelos. Así, ofreceremos condiciones lo más idénticas posibles para probar modelos.

Nos queda la cuestión del entorno de prueba. ¿Con qué datos probaremos nuestros modelos? Aquí la respuesta es inequívoca: para entrenar modelos usaremos un entorno similar al usado para entrenar los autodificadores. Después de todo, recordemos que las redes neuronales son muy sensibles a los datos de origen, y funcionarán correctamente solo con los datos en los que han sido entrenadas. Por consiguiente, para usar la tecnología de Transfer Learning, deberemos utilizar datos de origen similares a la muestra de entrenamiento del modelo donante.

Parece que ya hemos decidido todos los temas clave, así que podemos comenzar a prepararnos para las pruebas.


2. Creando un asesor experto para las pruebas

Comenzaremos nuestro trabajo preparatorio creando un asesor experto con el que probar los modelos. Para ello, crearemos la plantilla de asesor experto "check_net.mq5". En ella, primero añadiremos las bibliotecas:

Asimismo, declararemos una enumeración para trabajar cómodamente con las señales.

//+------------------------------------------------------------------+
//| Includes                                                         |
//+------------------------------------------------------------------+
#include "..\..\NeuroNet_DNG\NeuroNet.mqh"
#include <Trade\SymbolInfo.mqh>
#include <Indicators\Oscilators.mqh>
//---
enum ENUM_SIGNAL
  {
   Sell = -1,
   Undefine = 0,
   Buy = 1
  };

El siguiente paso consistirá en declarar las variables globales de nuestro asesor. Aquí especificaremos el archivo del modelo, el marco temporal de trabajo y el periodo de entrenamiento del modelo. También mostraremos todos los parámetros de los indicadores usados. Al mismo tiempo, dividiremos los parámetros del indicador en grupos, lo cual hará que el menú de nuestro asesor experto resulte más legible.

//+------------------------------------------------------------------+
//|   input parameters                                               |
//+------------------------------------------------------------------+
input int                  StudyPeriod =  2;            //Study period, years
input string               FileName = "EURUSD_i_PERIOD_H1_test_rnn";
ENUM_TIMEFRAMES            TimeFrame   =  PERIOD_CURRENT;
//---
input group                "---- RSI ----"
input int                  RSIPeriod   =  14;            //Period
input ENUM_APPLIED_PRICE   RSIPrice    =  PRICE_CLOSE;   //Applied price
//---
input group                "---- CCI ----"
input int                  CCIPeriod   =  14;            //Period
input ENUM_APPLIED_PRICE   CCIPrice    =  PRICE_TYPICAL; //Applied price
//---
input group                "---- ATR ----"
input int                  ATRPeriod   =  14;            //Period
//---
input group                "---- MACD ----"
input int                  FastPeriod  =  12;            //Fast
input int                  SlowPeriod  =  26;            //Slow
input int                  SignalPeriod =  9;            //Signal
input ENUM_APPLIED_PRICE   MACDPrice   =  PRICE_CLOSE;   //Applied price

A continuación, declararemos los ejemplares de los objetos utilizados. En este caso, además, excluiremos en la medida de lo posible el uso de objetos dinámicos. Esto simplificará un poco el código, eliminando operaciones innecesarias de creación de objetos y verificación de su relevancia. Los nombres de los objetos se corresponden tanto como sea posible con su contenido. Esto minimizará el riesgo de confundir variables y mejorará la legibilidad del código de nuestro programa.

CSymbolInfo          Symb;
CNet                 Net;
CBufferFloat        *TempData;
CiRSI                RSI;
CiCCI                CCI;
CiATR                ATR;
CiMACD               MACD;
CBufferFloat         Fractals;

También declararemos las variables globales de nuestro asesor. Ahora no nos detendremos a describir la funcionalidad de cada una de ellas: nos familiarizaremos con su propósito al analizar los algoritmos de las funciones del asesor experto creado.

uint                 HistoryBars =  40;            //Depth of history
MqlRates             Rates[];
float                dError;
float                dUndefine;
float                dForecast;
float                dPrevSignal;
datetime             dtStudied;
bool                 bEventStudy;

Aquí puede ver la variable de tamaño de los datos de origen en barras; ya lo especificamos previamente en los parámetros externos de los asesores. Ocultar este parámetro y trasladarlo a variables globales resulta una medida forzosa. El asunto es que antes describíamos la arquitectura del modelo creado en la función de inicialización del asesor, y este parámetro era uno de los hiperparámetros del modelo que el usuario especificaba al iniciar el asesor experto. Ahora usaremos los modelos creados anteriormente, y el parámetro de la profundidad de la historia analizada deberá corresponder con el modelo cargado. No obstante, como el usuario puede usar el modelo "a ciegas" y no conocer este parámetro, correremos el riesgo de que el parámetro especificado y el modelo cargado no coincidan. Para evitar este riesgo, hemos decidido recalcular el parámetro partiendo del tamaño de la capa de datos de origen del modelo cargado.

Vamos a pasar al análisis de los algoritmos de las funciones del asesor experto. Comenzaremos este trabajo por el método de inicialización del asesor experto OnInit. En el cuerpo de este método, primero cargaremos el modelo desde el archivo especificado en los parámetros del asesor. Aquí puede prestar atención a 2 puntos que se distinguen de operaciones similares en los asesores expertos presentados anteriormente.

Primero, la renuncia a los punteros dinámicos hace innecesaria la creación de una nueva instancia del objeto de modelo, así como la comprobación de la validez del puntero.

En segundo lugar, si el modelo no se lee correctamente desde el archivo, informaremos al usuario y saldremos de la función con el resultado INIT_PARAMETERS_INCORRECT. Al mismo tiempo, finalizaremos el funcionamiento del asesor. Como hemos mencionado antes, estamos creando un asesor experto para trabajar con varios modelos creados previamente. Por lo tanto, no habrá un modelo predeterminado, y en ausencia de un modelo, no tendremos nada que enseñar, por lo que el trabajo adicional del asesor no tendrá sentido. Por consiguiente, después de informar al usuario, finalizaremos el funcionamiento del asesor.

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   ResetLastError();
   if(!Net.Load(FileName + ".nnw", dError, dUndefine, dForecast, dtStudied, false))
     {
      printf("%s - %d -> Error of read %s prev Net %d", __FUNCTION__, __LINE__, FileName + ".nnw", GetLastError());
      return INIT_PARAMETERS_INCORRECT;
     }

Después de cargar con éxito el modelo, calcularemos el tamaño de la profundidad de la historia analizada y almacenaremos el valor resultante en la variable HistoryBars. Además, comprobaremos el tamaño de la capa de resultados. Esta deberá contener 3 neuronas según el número de posibles resultados del modelo.

   if(!Net.GetLayerOutput(0, TempData))
      return INIT_FAILED;
   HistoryBars = TempData.Total() / 12;
   Net.getResults(TempData);
   if(TempData.Total() != 3)
      return INIT_PARAMETERS_INCORRECT;

Después de pasar con éxito los controles anteriores, inicializaremos los objetos para trabajar con los indicadores.

   if(!Symb.Name(_Symbol))
      return INIT_FAILED;
   Symb.Refresh();
   if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice))
      return INIT_FAILED;
   if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice))
      return INIT_FAILED;
   if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod))
      return INIT_FAILED;
   if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice))
      return INIT_FAILED;

Obviamente, no nos olvidaremos de controlar el proceso de realización de cada una de las operaciones.

Tras inicializar todos los objetos, generaremos un evento personalizado al que transmitiremos el control y el método de entrenamiento del modelo. Después escribiremos el resultado de la generación del evento personalizado en la variable bEventStudy, que actuará como bandera para iniciar el proceso de entrenamiento del modelo.

La operación de generación de eventos personalizados nos permite completar el método de inicialización del asesor experto, y, paralelamente, inicializar el proceso de entrenamiento del modelo sin esperar un nuevo tick. Así, haremos que el inicio del proceso de aprendizaje del modelo sea independiente de la volatilidad del mercado.

   bEventStudy = EventChartCustom(ChartID(), 1, (long)MathMax(0, MathMin(iTime(Symb.Name(), PERIOD_CURRENT,
                                  (int)(100 * Net.recentAverageSmoothingFactor * (dForecast >= 70 ? 1 : 10))), dtStudied)),

                                  0, "Init");
//---
   return(INIT_SUCCEEDED);
  }

En el método de desinicialización del asesor, solo tendremos que eliminar el único objeto dinámico utilizado en el asesor. Aquí, una vez más, ha influido la renuncia al uso de objetos dinámicos.

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//---
   if(CheckPointer(TempData) != POINTER_INVALID)
      delete TempData;
  }

Todos los eventos del gráfico se procesarán en la función OnChartEvent, incluyendo nuestro evento personalizado. Por ello, en esta función esperaremos la aparición de un evento de usuario, que podrá ser identificado por su ID. La identificación de eventos de usuario comienza a partir de 1000. Al generar un evento personalizado, le asignaremos el identificador "1". Entonces, en esta función deberemos recibir un evento con el identificador "1001". Cuando ocurra tal evento, llamaremos al procedimiento de entrenamiento de nuestro modelo Train.

//+------------------------------------------------------------------+
//| ChartEvent function                                              |
//+------------------------------------------------------------------+
void OnChartEvent(const int id,
                  const long &lparam,
                  const double &dparam,
                  const string &sparam)
  {
//---
   if(id == 1001)
      Train(lparam);
  }

Veamos a profundizar en la organización del algoritmo, en concreto, en la función principal (probablemente) de nuestro asesor experto: el entrenamiento del modelo Train. En los parámetros, esta función obtiene un valor: la fecha de inicio del periodo de entrenamiento. Primero verificaremos si esta fecha está fuera de los límites del periodo de entrenamiento especificado por el usuario en los parámetros externos del asesor. Si la fecha obtenida no se corresponde con el periodo especificado por el usuario, desplazaremos la fecha hasta el inicio del periodo de entrenamiento indicado.

void Train(datetime StartTrainBar = 0)
  {
   int count = 0;
//---
   MqlDateTime start_time;
   TimeCurrent(start_time);
   start_time.year -= StudyPeriod;
   if(start_time.year <= 0)
      start_time.year = 1900;
   datetime st_time = StructToTime(start_time);
   dtStudied = MathMax(StartTrainBar, st_time);
   ulong last_tick = 0;

Luego prepararemos las variables locales

   double prev_er = DBL_MAX;
   datetime bar_time = 0;
   bool stop = IsStopped();

y cargaremos los datos históricos. Al mismo tiempo, cargaremos los datos de las cotizaciones e indicadores. Aquí resulta vital mantener sincronizados los búferes de indicador y las cotizaciones cargadas. Por consiguiente, primero descargaremos las cotizaciones para el periodo especificado. Luego determinaremos el número de barras cargadas y cargaremos el mismo periodo para todos los indicadores utilizados.

   int bars = CopyRates(Symb.Name(), TimeFrame, st_time, TimeCurrent(), Rates);
   if(!RSI.BufferResize(bars) || !CCI.BufferResize(bars) || !ATR.BufferResize(bars) || !MACD.BufferResize(bars))
     {
      ExpertRemove();
      return;
     }
   if(!ArraySetAsSeries(Rates, true))
     {
      ExpertRemove();
      return;
     }
   RSI.Refresh(OBJ_ALL_PERIODS);
   CCI.Refresh(OBJ_ALL_PERIODS);
   ATR.Refresh(OBJ_ALL_PERIODS);
   MACD.Refresh(OBJ_ALL_PERIODS);

Después de cargar la muestra de entrenamiento, tomaremos los últimos 300 elementos del número total de elementos de la muestra de entrenamiento para validarlos después de cada época de entrenamiento. Luego crearemos un sistema de ciclos para el proceso de entrenamiento. Aquí, el ciclo externo contará hacia atrás las épocas de entrenamiento y controlará si el proceso de entrenamiento del modelo debe continuar. En el cuerpo del ciclo, actualizaremos los valores de las banderas:

   MqlDateTime sTime;
   int total = (int)(bars - MathMax(HistoryBars, 0) - 300);
   do
     {
      prev_er = dError;
      stop = IsStopped();

En un ciclo anidado, iteraremos por los elementos de la muestra de entrenamiento y los suministraremos a su vez a la entrada de la red neuronal. Como planeamos usar modelos recurrentes que son sensibles a la secuencia de datos de entrada, nos veremos obligados a renunciar a la elección de un elemento subsiguiente aleatorio en la secuencia. En su lugar, utilizaremos la secuencia histórica de elementos.

Aquí comprobaremos de inmediato si los datos del elemento actual son suficientes para componer el patrón. Si no tenemos datos suficientes, pasaremos al siguiente elemento.

      for(int it = total; it > 1 && !stop; t--)
        {
         TempData.Clear();
         int i = it + 299;
         int r = i + (int)HistoryBars;
         if(r > bars)
            continue;

Si la información es suficiente, formaremos un patrón que suministrar a la entrada del modelo. En este caso, además, controlaremos la disponibilidad de los datos en los búferes de indicador. Si los valores del indicador no han sido definidos, pasaremos al siguiente elemento.

         for(int b = 0; b < (int)HistoryBars; b++)
           {
            int bar_t = r - b;
            float open = (float)Rates[bar_t].open;
            TimeToStruct(Rates[bar_t].time, sTime);
            float rsi = (float)RSI.Main(bar_t);
            float cci = (float)CCI.Main(bar_t);
            float atr = (float)ATR.Main(bar_t);
            float macd = (float)MACD.Main(bar_t);
            float sign = (float)MACD.Signal(bar_t);
            if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
               continue;
            //---
            if(!TempData.Add((float)Rates[bar_t].close - open) || !TempData.Add((float)Rates[bar_t].high - open) ||
               !TempData.Add((float)Rates[bar_t].low - open) || !TempData.Add((float)Rates[bar_t].tick_volume / 1000.0f) ||
               !TempData.Add(sTime.hour) || !TempData.Add(sTime.day_of_week) || !TempData.Add(sTime.mon) ||
               !TempData.Add(rsi) || !TempData.Add(cci) || !TempData.Add(atr) || !TempData.Add(macd) || !TempData.Add(sign))
               break;
           }
         if(TempData.Total() < (int)HistoryBars * 12)
            continue;

Después de la formar el patrón con éxito, llamaremos al método de pasada directa de nuestro modelo, y luego solicitaremos el resultado de la pasada directa.

         Net.feedForward(TempData, 12, true);
         Net.getResults(TempData);

A continuación, aplicaremos la función SortMax a los resultados del modelo para convertir los valores obtenidos a probabilidades. 

         float sum = 0;
         for(int res = 0; res < 3; res++)
           {
            float temp = exp(TempData.At(res));
            sum += temp;
            TempData.Update(res, temp);
           }
         for(int res = 0; (res < 3 && sum > 0); res++)
            TempData.Update(res, TempData.At(res) / sum);
         //---
         switch(TempData.Maximum(0, 3))
           {
            case 1:
               dPrevSignal = (TempData[1] != TempData[2] ? TempData[1] : 0);
               break;
            case 2:
               dPrevSignal = -TempData[2];
               break;
            default:
               dPrevSignal = 0;
               break;
           }

Después de ello, mostraremos la información sobre el proceso de entrenamiento en el gráfico.

         if((GetTickCount64() - last_tick) >= 250)
           {
            string s = StringFormat("Study -> Era %d -> %.2f -> Undefine %.2f%% foracast %.2f%%\n %d of %d -> %.2f%% \n
                                     Error %.2f\n%s -> %.2f ->> Buy %.5f - Sell %.5f - Undef %.5f", count, dError, 
                                     dUndefine, dForecast, total - it - 1, total, 
                                     (double)(total - it - 1.0) / (total) * 100, Net.getRecentAverageError(),
                                      EnumToString(DoubleToSignal(dPrevSignal)), dPrevSignal, TempData[1], TempData[2], TempData[0]);
            Comment(s);
            last_tick = GetTickCount64();
           }

En el proceso de entrenamiento del modelo, la pasada directa es seguida por una pasada inversa. Aquí, primero crearemos los valores objetivo y los transmitiremos al método de pasada inversa de nuestro modelo. Luego calcularemos de inmediato las estadísticas del proceso de entrenamiento.

         stop = IsStopped();
         if(!stop)
           {
            TempData.Clear();
            bool sell = (Rates[i - 1].high <= Rates[i].high && Rates[i + 1].high < Rates[i].high);
            bool buy = (Rates[i - 1].low >= Rates[i].low && Rates[i + 1].low > Rates[i].low);
            TempData.Add(!(buy || sell));
            TempData.Add(buy);
            TempData.Add(sell);
            Net.backProp(TempData);
            ENUM_SIGNAL signal = DoubleToSignal(dPrevSignal);
            if(signal != Undefine)
              {
               if((signal == Sell && sell) || (signal == Buy && buy))
                  dForecast += (100 - dForecast) / Net.recentAverageSmoothingFactor;
               else
                  dForecast -= dForecast / Net.recentAverageSmoothingFactor;
               dUndefine -= dUndefine / Net.recentAverageSmoothingFactor;
              }
            else
              {
               if(!(buy || sell))
                  dUndefine += (100 - dUndefine) / Net.recentAverageSmoothingFactor;
              }
           }
        }

Esto completará el ciclo anidado iterando sobre los elementos de la muestra de entrenamiento dentro de la época de entrenamiento del modelo. Luego organizaremos la validación para evaluar el comportamiento del modelo con datos que no están incluidos en el conjunto de entrenamiento. Para ello, organizaremos un ciclo similar sobre los últimos 300 elementos, pero solo con la pasada directa. Durante el proceso de validación, no realizaremos la pasada inversa ni actualizaremos las matrices de coeficientes de peso.

      count++;
      for(int i = 0; i < 300; i++)
        {
         TempData.Clear();
         int r = i + (int)HistoryBars;
         if(r > bars)
            continue;
         //---
         for(int b = 0; b < (int)HistoryBars; b++)
           {
            int bar_t = r - b;
            float open = (float)Rates[bar_t].open;
            TimeToStruct(Rates[bar_t].time, sTime);
            float rsi = (float)RSI.Main(bar_t);
            float cci = (float)CCI.Main(bar_t);
            float atr = (float)ATR.Main(bar_t);
            float macd = (float)MACD.Main(bar_t);
            float sign = (float)MACD.Signal(bar_t);
            if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
               continue;
            //---
            if(!TempData.Add((float)Rates[bar_t].close - open) || !TempData.Add((float)Rates[bar_t].high - open) ||
               !TempData.Add((float)Rates[bar_t].low - open) || !TempData.Add((float)Rates[bar_t].tick_volume / 1000.0f) ||
               !TempData.Add(sTime.hour) || !TempData.Add(sTime.day_of_week) || !TempData.Add(sTime.mon) ||
               !TempData.Add(rsi) || !TempData.Add(cci) || !TempData.Add(atr) || !TempData.Add(macd) || !TempData.Add(sign))
               break;
           }
         if(TempData.Total() < (int)HistoryBars * 12)
            continue;
         Net.feedForward(TempData, 12, true);
         Net.getResults(TempData);
         //---
         float sum = 0;
         for(int res = 0; res < 3; res++)
           {
            float temp = exp(TempData.At(res));
            sum += temp;
            TempData.Update(res, temp);
           }
         for(int res = 0; (res < 3 && sum > 0); res++)
            TempData.Update(res, TempData.At(res) / sum);
         //---
         switch(TempData.Maximum(0, 3))
           {
            case 1:
               dPrevSignal = (TempData[1] != TempData[2] ? TempData[1] : 0);
               break;
            case 2:
               dPrevSignal = (TempData[1] != TempData[2] ? -TempData[2] : 0);
               break;
            default:
               dPrevSignal = 0;
               break;
           }

Después de realizar una pasada directa en la validación, mostraremos las señales de nuestro modelo en el gráfico para evaluar visualmente su desempeño.

         if(DoubleToSignal(dPrevSignal) == Undefine)
            DeleteObject(Rates[i].time);
         else
            DrawObject(Rates[i].time, dPrevSignal, Rates[i].high, Rates[i].low);
        }

Al final de cada época, guardaremos el estado actual del modelo. Aquí también añadiremos el error del modelo actual al archivo para controlar la dinámica del proceso de entrenamiento.

      if(!stop)
        {
         dError = Net.getRecentAverageError();
         Net.Save(FileName + ".nnw", dError, dUndefine, dForecast, Rates[0].time, false);
         printf("Era %d -> error %.2f %% forecast %.2f", count, dError, dForecast);
         int h = FileOpen(FileName + ".csv", FILE_READ | FILE_WRITE | FILE_CSV);
         if(h != INVALID_HANDLE)
           {
            FileSeek(h, 0, SEEK_END);
            FileWrite(h, eta, count, dError, dUndefine, dForecast);
            FileFlush(h);
            FileClose(h);
           }
        }
     }
   while(!(dError < 0.01 && (prev_er - dError) < 0.01) && !stop);

A continuación, evaluaremos el cambio en el error del modelo durante la última época de entrenamiento y decidiremos si continuar con el mismo. Si tomamos la decisión de continuar el entrenamiento, repetiremos las iteraciones cíclicas de la nueva época de entrenamiento.

Después de completar el proceso de entrenamiento del modelo, limpiaremos el área de comentarios en el gráfico e inicializaremos la finalización del asesor experto, ya que este habrá completado su tarea de entrenamiento del modelo y su posterior presencia en la memoria no tendría sentido.

   Comment("");
   ExpertRemove();
  }

Las funciones auxiliares para mostrar etiquetas en el gráfico y eliminar estas las tomamos por completo de los asesores expertos anteriormente analizados, por lo que no repetiremos su algoritmo ahora. Podrá familiarizarse con el contenido completo de todas las funciones del asesor en el archivo adjunto.


3. Creando los modelos para las pruebas

Después de crear la herramienta para probar los modelos, deberemos preparar la base para la prueba, es decir, para crear esos mismos modelos que vamos a entrenar. Aquí ya no vamos a programar, dado que todo el trabajo de programación y creación de modelos ya se ha implementado en los 2 artículos anteriores. Ahora aprovecharemos sus resultados y crearemos modelos usando nuestra herramienta.

Para ello, iniciaremos el asesor experto "NetCreator" creado anteriormente. En él, abriremos el modelo del autocodificador preentrenado utilizando un codificador recurrente basado en bloques LSTM. Lo guardamos antes en el archivo "EURUSD_i_PERIOD_H1_rnn_vae.nnw". De este modelo, tomaremos tan solo el codificador. Por ello, en el bloque izquierdo del modelo preentrenado, encontraremos la capa de estado latente del autocodificador variacional VAE. En nuestro caso, será la octava. Por consiguiente, copiaremos solo las primeras 7 capas neuronales del modelo donante.

Hay 3 formas de seleccionar el número necesario de capas para copiar en nuestra herramienta. Podemos usar los botones en el área "Transfer Layers" o los botones "↑" y "↓" en el teclado. O simplemente podemos clicar en la descripción de la última capa copiada, en la descripción del modelo donante.

Simultáneamente con el cambio en el número de capas copiadas, también cambiaremos la descripción del modelo creado en el bloque derecho de nuestra herramienta. A nuestro juicio, resulta bastante cómodo e informativo. Verá de inmediato cómo sus acciones influyen en la arquitectura del modelo creado.

A continuación, deberemos complementar nuestro nuevo modelo con varias capas neuronales de toma de decisiones para la tarea de aprendizaje específica. Aquí no lo hemos hecho mucho más complicado, ya que en estas pruebas la tarea principal consiste en evaluar la efectividad de los enfoques. Hemos añadido 2 capas neuronales completamente conectadas con 500 elementos y la tangente hiperbólica como función de activación.

La adición de nuevas capas neuronales ha resultado una tarea bastante simple. Primero, seleccionamos el tipo de capa neuronal. La capa neuronal completamente conectada se corresponde con "Dense". Después especificamos el número de neuronas en la capa, la función de activación y el método de actualización de los parámetros. Si seleccionamos un tipo diferente de capa neuronal, completaremos los campos correspondientes. Después de indicar todos los datos necesarios, presionamos el botón "ADD LAYER".

Y otro punto bastante cómodo. Si necesitamos añadir varias capas neuronales idénticas, no será necesario volver a introducir los datos. Simplemente presionaremos de nuevo el botón "ADD LAYER". Esto precisamente hemos aprovechado. Para añadir la segunda capa neuronal, ya no ingresamos los datos, sino que simplemente presionamos el botón para añadir una nueva capa.

La capa de resultados también está completamente conectada y contiene 3 elementos según los requisitos del asesor experto creado anteriormente. Como función de activación para la capa de resultados, se usa una sigmoide.

Nuestras capas neuronales anteriores también estaban completamente conectadas, por lo tanto, solo podremos cambiar el número de neuronas y la función de activación. Luego añadiremos una capa a nuestro modelo.

Ahora solo deberemos guardar nuestro nuevo modelo en un archivo. Para ello, pulsaremos el botón "SAVE MODEL" e indicaremos el nombre del archivo del nuevo modelo "EURUSD_i_PERIOD_H1_test_rnn.nnw". Tenga en cuenta que podrá especificar el nombre del archivo sin la extensión, y este se añadirá automáticamente.

En el siguiente gif podrá ver el proceso completo de creación de un modelo.

Usando la herramienta de creación de modelos

Ya hemos creado el primer modelo. Ahora, procederemos a crear el segundo. Para ello, como donante, cargaremos un autocodificador variacional con un codificador completamente conectado desde el archivo "EURUSD_i_PERIOD_H1_vae.nnw". Y aquí nos espera nuevamente una sorpresa. Después de cargar el nuevo modelo donante, no hemos eliminado las capas neuronales añadidas. Por ello, estas se han añadido de forma automática al modelo cargado. Solo tendremos que elegir el número de capas neuronales para copiar del modelo donante al nuevo modelo, y nuestro próximo modelo está listo.

Debemos decir que, basándonos en el último modelo de autocodificador, hemos creado no uno, sino dos modelos. El primero lo hemos creado por analogía con el anterior. Para ello, hemos tomado el codificador del modelo donante y hemos añadido las 3 capas creadas previamente. Para el segundo modelo donante, hemos tomado solo la capa de datos de origen y la capa de normalización por lotes. Luego le hemos añadido a estas capas las 3 mismas capas neuronales completamente conectadas. Utilizaremos el último modelo como guía para entrenar el nuevo. Hemos decidido que la capa de normalización por lotes preentrenada nos sirva para preparar los datos de origen sin procesar. Esto debería aumentar la convergencia del nuevo modelo. Al mismo tiempo, hemos excluido la compresión de datos, y podemos suponer que el último modelo estará completamente lleno de pesos aleatorios.

Como hemos discutido anteriormente, existen diferentes formas de evaluar los conceptos del impacto de la arquitectura de un modelo preentrenado. Por ello, para realizar una prueba más, crearemos otro modelo adicional. Así, tomaremos las arquitecturas del modelo recién creado usando el codificador con bloques LSTM y las replicaremos por completo en el nuevo modelo, pero sin copiar el codificador del modelo donante. De esta forma, obtendremos una arquitectura de modelo completamente idéntica, pero ya inicializada con pesos aleatorios.


4. Resultados de la prueba

Ahora que hemos creado todos los modelos necesarios para nuestras pruebas, vamos a proceder a entrenarlos.

Entrenaremos los modelos usando el aprendizaje supervisado y conservando los parámetros de entrenamiento utilizados anteriormente. Así, el entrenamiento se realizará en un segmento temporal que abarca los últimos 2 años. Utilizaremos el instrumento EURUSD y el marco temporal H1. Los parámetros de todos los indicadores han sido establecidos en el asesor experto por defecto.

Para que el experimento sea más puro, entrenaremos todos los modelos simultáneamente en un terminal y con diferentes gráficos.

Hay que decir que el entrenamiento simultáneo de varios modelos no es algo deseable, pues esto reduce significativamente la tasa de aprendizaje de cada uno de ellos. Como ya sabrá, en nuestros modelos utilizamos la tecnología OpenCL para paralelizar el proceso computacional y aprovechar al máximo los recursos disponibles, y con entrenamiento en paralelo de varios modelos, tendremos que repartir los recursos disponibles entre todos los modelos. Eso implicará recortar los recursos a disposición para cada uno de ellos, por lo que también aumentará el tiempo de entrenamiento para cada uno. No obstante, daremos este paso intencionalmente para ofrecer las condiciones más parecidas al entrenar todos los modelos comparados.

Prueba 1

Para la primera prueba, usaremos dos modelos con codificadores preentrenados y un pequeño modelo completamente conectado con una capa de normalización por lotes prestada y 2 capas ocultas completamente conectadas.

Podemos ver los resultados de la prueba en el gráfico a continuación.

Comparando las dinámicas de aprendizaje de los modelos

Como podemos ver en el gráfico presentado, el modelo con un codificador recurrente preentrenado ha dominado de forma indiscutible. Prácticamente desde las primeras épocas de entrenamiento, su error ha disminuido a un ritmo significativamente más rápido.

El modelo con un codificador completamente conectado también ha mostrado una tendencia a reducir el error durante el proceso de aprendizaje, pero a un ritmo más lento.

El modelo completamente conectado con 2 capas ocultas, e inicializado con valores aleatorios en su fondo, parece no haber sido entrenado en absoluto. A juzgar por el gráfico presentado, parece que su error está congelado.

Dinámica de error del modelo completamente conectado

Sin embargo, tras un examen más detenido, podemos notar una tendencia a la reducción del error, aunque este descenso se produce a un ritmo mucho más lento. Obviamente, este modelo es demasiado simple para resolver tales problemas.

De esto podemos concluir que el rendimiento del modelo todavía está muy influenciado por el procesamiento de los datos de origen por parte del codificador preentrenado, y la arquitectura de dicho codificador tiene un impacto significativo en el funcionamiento de todo el modelo.

Mención aparte merece la tasa de aprendizaje de los modelos. Por supuesto, el modelo más simple mostraba un tiempo mínimo para pasar una época, pero vale la pena señalar que la tasa de aprendizaje del modelo con un codificador recurrente resultó estar muy próxima. A nuestro juicio, en este hecho han influido varios factores.

En primer lugar, la arquitectura del modelo recurrente nos ha permitido reducir 4 veces la ventana de datos analizados, y con ello, el número de conexiones interneuronales. Como resultado, se ha reducido su coste de procesamiento. Al mismo tiempo, la arquitectura recurrente implica un gasto de recursos adicionales para la pasada inversa de la distribución del gradiente de error. Por ello, hemos desactivado la pasada inversa para las capas neuronales preentrenadas. Todo esto en conjunto nos ha permitido reducir significativamente el coste de reentrenamiento del modelo.

El modelo con un codificador completamente conectado se encuentra ligeramente por detrás de ellos en cuanto a la tasa de aprendizaje.

Prueba 2

En la segunda prueba, hemos decidido minimizar las diferencias de arquitectura entre los modelos y entrenar dos modelos recurrentes con la misma arquitectura. Solo un modelo utilizará un codificador recurrente preentrenado, mientras que el segundo modelo estará completamente inicializado con pesos aleatorios. El entrenamiento se ha realizado preservando todos los parámetros de prueba indicados en la primera prueba.

Podemos ver los resultados de la prueba en el siguiente gráfico. Como podemos ver, el modelo preentrenado ha comenzado con un error menor, pero pronto ambos modelos se estabilizan y además sus valores se aproximan bastante. Esto confirma la conclusión anterior: la arquitectura del codificador tiene un impacto significativo en el rendimiento de todo el modelo.

Comparando las dinámicas de aprendizaje de los modelos recurrentes

También debemos destacar la tasa de aprendizaje. Durante las pruebas, el modelo preentrenado ha necesitado 6 veces menos tiempo para pasar una época. Obviamente, aquí hemos tomado en cuenta el tiempo puro, sin considerar el coste de entrenamiento del autocodificador.


Conclusión

Tras reflexionar sobre el trabajo realizado, podemos concluir que el uso de la tecnología de Transfer Learning comporta una serie de ventajas. En primer lugar, esta tecnología realmente funciona. Su aplicación nos permite reutilizar bloques de modelos ya entrenados para resolver nuevos problemas. La única condición requerida es la unidad de los datos iniciales. El uso de bloques preentrenados con datos de origen no adecuados no funcionará.

El uso de la tecnología nos permite reducir el tiempo de entrenamiento del nuevo modelo. Es cierto que durante el proceso de prueba, hemos medido el tiempo puro, sin tener en cuenta el coste de entrenamiento del autocodificador. Probablemente, si sumamos el tiempo dedicado al entrenamiento del autocodificador, los costes serían iguales, y tal vez incluso, debido a la arquitectura de decodificador más compleja, entrenar un modelo "puro" resulte más rápido. Por consiguiente, el uso de Transfer Learning puede estar justificado cuando suponemos que un bloque se va a usar para resolver varios problemas, o cuando, por alguna razón, no podemos entrenar el modelo como un todo. Por ejemplo, el modelo puede resultar muy complejo y durante el proceso de entrenamiento el gradiente de error decaerá y no llegará a todas las capas.

Además, el uso de la tecnología puede estar justificado cuando buscamos un modelo óptimo, al complicar gradualmente el modelo al buscar el valor de error óptimo.


Enlaces

  1. Redes neuronales: así de sencillo (Parte 20): Autocodificadores
  2. Redes neuronales: así de sencillo (Parte 21): Autocodificadores variacionales (VAE)
  3. Redes neuronales: así de sencillo (Parte 22): Aprendizaje no supervisado de modelos recurrentes
  4. Redes neuronales: así de sencillo (Parte 23): Creamos una herramienta para el Transfer Learning
  5. Redes neuronales: así de sencillo (Parte 24): Mejorando la herramienta para el Transfer Learning

Programas usados en el artículo.

# Nombre Tipo Descripción
1 check_net.mq5  Asesor Asesor para la formación adicional de modelos. 
2 NetCreator.mq5 Asesor Herramienta de construcción de modelos
3 NetCreatotPanel.mqh Biblioteca de clases Biblioteca de clases para crear una herramienta
4 NeuroNet.mqh Biblioteca de clases Biblioteca de clases para crear una red neuronal
5 NeuroNet.cl Biblioteca Biblioteca de código de programa OpenCL