English 中文 Español Deutsch 日本語 Português
preview
Нейросети — это просто (Часть 25): Практикум Transfer Learning

Нейросети — это просто (Часть 25): Практикум Transfer Learning

MetaTrader 5Интеграция | 17 августа 2022, 14:31
1 851 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Содержание


Введение

Мы продолжаем изучение технологии Transfer Learning. В предыдущих двух статьях мы создали инструмент для создания и редактирования моделей нейронных сетей. Именно этот инструмент и поможет нам перенести часть предварительно обученной модели в новую модель и дополнить её новыми слоями принятия решения. Потенциально этот подход должен нам помочь быстрее обучить созданную таким образом модель для решения новых задач. В данной статье я предлагаю Вам на практике оценить преимущества такого подхода. А заодно и проверить удобство использования нашего инструмента.


1. Общие вопросы подготовки тестирования

В данной статье мы хотим оценить преимущества использования технологии Transfer Learning. В таком случае нет ничего лучше, чем сравнить процесс обучения двух моделей для решения одной задачи. Для этого мы возьмем одну "чистую" модель, инициированную случайными весами. А вторую модель создадим с использованием технологии Transfer Learning.

И если в качестве задачи мы можем использовать поиск фракталов, как и при тестировании всех предыдущих моделей в методах обучения с учителем. То, что мы будем использовать в качестве модели-донора для технологии Transfer Learning? И тут можно вспомнить об автоэнкодерах. Именно их мы готовили в качестве доноров для Transfer Learning. При изучении автоэнкодеров мы создали и обучили 2-е модели вариационных автоэнкодеров. В первом энкодер был построен с использованием полносвязных нейронных слоёв. А во втором мы использовали энкодер на рекуррентных LSTM-блоках. И сейчас мы можем использовать обе эти модели в качестве доноров. И параллельно проверить эффективность использования каждого из упомянутых подходах.

Первое принципиальное решение в подготовке предстоящих тестов принято — в качестве моделей-доноров мы будем использовать вариационные автоэнкодеры, обученные при изучении соответствующих тем.

Второй концептуальный вопрос, каким образом мы будем проводить тестирование моделей. При этом мы должны создать максимально одинаковые условия для всех моделей. Только в таком случае мы сможем исключить влияние прочих факторов и по истине оценить влияние конструктивных особенностей моделей.

И здесь, наверное, ключевое "конструктивных особенностей". Как оценить преимущества Transfer Learning в, по существу, разных моделях? На самом деле ситуация не однозначная. Давайте вспомним чему обучается автоэнкодер. Его архитектура такова, что мы ожидаем на выходе модели получить исходные данные. При этом исходные данные энкодером сжимаются до "узкого горлышка" латентного состояния и затем восстанавливаются декодером. То есть мы просто сжимаем исходные данные. В таком случае мы можем считать модели с идентичными архитектурами, когда архитектура модели после заимствованного блока энкодера равна архитектуре контрольной модели.

С другой стороны, вместе с сжатием данных энкодер осуществляет предварительную обработку данных. Какие-то признаки выделяются. А какие-то наоборот обнуляются. И в такой трактовке для выравнивания архитектур двух моделей нам необходимо создать точную копию модели, но уже инициализированную случайными весовыми коэффициентами.

И так как есть разнотолки в понимании вопроса, мы проведем тесты с обоими подходами к решению вопроса.

И следующий вопрос о средстве тестирования. Если ранее мы создавали отдельный советник для тестирования каждой модели, а это объяснялось тем, что мы каждый раз описывали и создавали модель в блоке инициализации советника, сейчас же ситуация изменилась. Мы создали, можно сказать, универсальный инструмент для создания моделей. С его помощью мы можем создавать различные архитектуры моделей и сохранять их в файл. И затем мы можем загрузить созданную модель в любой советник для её обучения и/или использования.

Следовательно, теперь мы можем создать один советник, в котором и проведем обучение всех моделей. Тем самым мы обеспечим максимально одинаковые условия для тестирования моделей.

И остается вопрос среды тестирования. На каких данных мы будем тестировать наши модели. Здесь ответ однозначный, для обучения моделей мы будем использовать среду аналогичную той, которая использовалась для обучения автоэнкодеров. Ведь мы помним, что нейронные сети очень чувствительны к исходным данным. И корректно будут работать только на тех данных, на каких обучались. Следовательно, для использования технологии Transfer Learning мы должны использовать исходные данные аналогичными обучающей выборки модели-донора.

Кажется, мы определились со всеми ключевыми вопросами и можем перейти к подготовке тестирования.


2. Создаем советник для тестирования

И начнем мы свою подготовительную работу с создания советника для тестирования моделей. Для этого мы создадим советник шаблон "check_net.mq5". В нем мы сначала добавим библиотеки:

  • NeuroNet.mqh — наша библиотека создания нейронных сетей;
  • SymbolInfo.mqh — стандартная библиотека доступа к данным торговых инструментов;
  • Oscilators.mqh — стандартная библиотека работы с осцилляторами.
И объявим перечисление для удобной работы с сигналами.

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

Следующим шагом мы объявим глобальные переменные нашего советника. Здесь мы укажем файл модели, рабочий таймфрейм и период обучения модели. А также выведем все параметры используемых индикаторов. При этом мы разделим параметры индикаторов на группы, что сделает меню нашего советника более читабельным.

//+------------------------------------------------------------------+
//|   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

Далее мы объявим экземпляры используемых объектов. При этом мы максимально исключили использование динамических объектов. Что позволит немного упростить код, убрав излишние операции создания объектов и проверки их актуальности. Наименования объектов максимально соответствуют их содержимому. Это позволяет минимизировать риск путаницы в переменных и повысить читабельность кода нашей программы.

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

Также мы объявим глобальные переменные нашего советника. Я сейчас не буду останавливаться на описании функционала каждой из них. Мы познакомимся с их назначением в процессе разбора алгоритмов функций создаваемого советника.

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

Здесь можно заметить переменную размера исходных данных в барах, которую мы раньше указывали во внешних параметрах советников. Скрытие данного параметра и перенос его в глобальные переменны вынужденная мера. Дело в том, что ранее мы описывали архитектуру создаваемой модели в функции инициализации советника. И данный параметр был одним из гиперпараметров модели, который пользователь указывал при запуске советника. Сейчас же мы будем использовать ранее созданные модели. И параметр глубины анализируемой истории должен соответствовать загруженной модели. Но так как пользователь может использовать модель "вслепую" и не знать данного параметра, то мы имеем риск несоответствия указанного параметра и загруженной модели. Для исключения данного риска было принято решения пересчета параметра из размера слоя исходных данных, загруженной модели.

Переходим к рассмотрению алгоритмов функций советника. Эту работу мы начнем с метода инициализации советника OnInit. В теле данного метода мы сначала загрузим модель из файла, указанного в параметрах советника. Здесь можно обратить на 2 момента, отличающихся от аналогичных операций в представленных ранее советниках.

Первое, отказ от динамических указателей делает не нужным создание нового экземпляра объекта модели. А вместе с тем и проверку действительности указателя.

Второе, при неудачном чтении модели из файла мы информируем пользователя и завершаем работу функции с результатом INIT_PARAMETERS_INCORRECT. А вместе с тем и завершаем работу советника. Как было сказано выше, мы создаем советник для работы с несколькими ранее созданными моделями. А следовательно, отсутствует модель по умолчанию. А при отсутствии модели нам нечего будет обучать. И дальнейшая работа советника не имеет смысла. Поэтому после информирования пользователя мы завершаем работу советника.

//+------------------------------------------------------------------+
//| 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;
     }

После успешной загрузки модели мы рассчитаем размер глубины анализируемой истории и сохраним полученное значение в переменную HistoryBars. Вместе с тем мы проверим размер слоя результатов. Он должен содержать 3 нейрона по числу возможных вариантов результатов работы модели.

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

После успешного прохождения вышеуказанных контролей мы инициализируем объекты работы с индикаторами.

   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;

Конечно, мы не забываем контролировать процесс выполнения каждой из операций.

После инициализации всех объектов мы сгенерируем пользовательское событие, которым передадим управление и метод обучения модели. Результат генерации пользовательского события мы запишем в переменную bEventStudy, которая будет выступать флагом запуска процесса обучениям модели.

Операции генерации пользовательского события позволяет нам завершить метод инициализации советника. И при этом параллельно инициализировать процесс обучения модели, не дожидаясь нового тика. Тем самым мы делаем начало процесса обучения модели независимым волатильности рынка.

   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);
  }

В методе деинициализации советника мы лишь удалим единственный динамический объект, используемый в советнике. Здесь, опять же, сказался отказ от использования динамических объектов.

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

Все события графика обрабатываются в функции OnChartEvent. В том числе и наше пользовательское событие. Поэтому, в данной функции мы ожидаем наступления пользовательского события, которое можно идентифицировать по его ID. Идентификация пользовательских событий начинается с 1000. При генерации пользовательского события мы дали ему идентификатор "1". Значит в данной функции мы должны получить событие с идентификатором "1001". При наступлении такого события мы вызываем процедуру обучения нашей модели Train.

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

Давайте подробнее посмотрим на организацию алгоритма, наверное, основной функции нашего советника — обучения модели Train. В параметрах данная функция получает одно значение — дату начала периода обучения. И мы сначала проверяем не выходит ли эта дата за границы периода обучения, указанного пользователем во внешних параметрах советника. Если полученная дата не соответствует периоду, заданному пользователем, то мы смещаем дату до начала заданного периода обучения.

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;

Затем мы подготовим локальные переменные.

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

И загрузим исторические данные. При этом мы загружаем данные котировок и индикаторов. Здесь важно сохранить синхронность буферов индикаторов и загруженных котировок. Поэтому, мы сначала загружаем котировки за указанный период. Определяем количество загруженных баров. И осуществляем загрузку аналогичного периода по всем используемым индикаторам.

   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);

После загрузки обучающей выборки мы от общего числа элементов обучающей выборки возьмем 300 последних элементов для валидации после каждой эпохи обучения. И затем создаем систему циклов процесса обучения. Здесь внешний цикл будет отсчитывать эпохи обучения и контролировать необходимость продолжения процесса обучения модели. В теле цикла мы обновим значения флагов:

  • prev_er — ошибка модели на предыдущей эпохе;
  • stop — генерация события завершения программы пользователем.

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

Во вложенным цикле мы будем перебирать элементы обучающей выборки и по очереди подавать их на вход нейронной сети. Так как мы планируем использовать рекуррентные модели, которые чувствительны к последовательности подаваемых исходных данных, то мы вынуждены отказаться от выбора случайного следующего элемента последовательности. Вместо этого мы будем использовать исторически сложившуюся последовательность элементов.

Тут же мы сразу проверяем достаточность данных от текущего элемента для составления паттерна. Если данных недостаточно, переходим к следующему элементу.

      for(int it = total; it > 1 && !stop; t--)
        {
         TempData.Clear();
         int i = it + 299;
         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);

К результатам модели применим функцию SortMax для перевода полученных значений в область вероятностей. 

         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;
           }

После этого выведем на график информацию о процессе обучения.

         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();
           }

За прямым проходом в процессе обучения модели идет обратный проход. И тут мы сначала создадим целевые значения и передадим их в метод обратного прохода нашей модели. И сразу посчитаем статистику процесса обучения.

         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;
              }
           }
        }

На этом завершается вложенный цикл перебора элементов обучающей выборки в рамках одной эпохи обучения модели. И далее мы организуем валидацию, чтобы оценить поведение модели на данных, не входящих в обучающую выборку. Для этого на последних 300 элементах мы организуем аналогичный цикл, но только с прямым проходом. В процессе валидации мы не будем осуществлять обратный проход и обновлять матрицы весовых коэффициентов.

      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;
           }

После осуществления прямого прохода на валидационных мы отобразим на графике сигналы нашей модели для визуальной оценки её работы.

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

И в завершении каждой эпохи мы сохраним текущее состояние модели. Здесь мы также добавим текущую ошибку модели в файл для контроля динамики процесса обучения.

      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);

Далее мы оцениваем изменение ошибки модели за последнюю эпоху обучения и принимаем решение о целесообразности продолжения обучения. Если принято решение продолжить обучения, то мы повторяем цикличные итерации новой эпохи обучения.

После завершения процесса обучения модели мы очищаем область комментариев на графике и инициализируем завершение работы советника, так как свою задачу обучения модели он выполнил и его дальнейшее нахождение в памяти не имеет смысла.

   Comment("");
   ExpertRemove();
  }

Вспомогательные функции отображения меток на графике и их удаления полностью перекачивали из ранее рассмотренных советников, и я сейчас не буду повторять их алгоритм. С полным содержанием всех функций советника вы можете познакомиться во вложении.


3. Создаем модели для тестирования

После создания инструмента для тестирования моделей мы должны подготовить базу для тестирования. То есть создать те модели, которые будем обучать. И здесь мы уже не будем программировать. Так как вся работа по программированию создания моделей уже выполнена в предыдущих 2-х статьях. Теперь мы воспользуемся её результатами и будем создавать модели с помощью нашего инструмента.

Для этого мы запустим ранее созданный советник "NetCreator". В нем мы откроем модель предварительно обученного автоэнкодера с использование рекуррентного энкодера на базе LSTM-блоков. Ранее мы сохранили его в файле "EURUSD_i_PERIOD_H1_rnn_vae.nnw". Из данной модели мы возьмем только энкодер. Поэтому в левом блоке предварительно обученной модели мы найдем слой латентного состояния вариационного автоэнкодера VAE. В моем случае он восьмой. Поэтому я буду копировать только первых 7 нейронных слоёв модели-донора.

Выбрать нужное количество слоёв для копирования в нашем инструменте возможно 3-мя способами. Вы можете воспользоваться кнопками области "Transfer Layers" или же кнопками "↑" и "↓" на клавиатуре. А можете просто щелкнуть мышкой по описанию последнего копируемого слоя в описании модели-донора.

Одновременно с изменением количества копируемых слоёв изменяется и описание создаваемой модели в правом блоке нашего инструмента. На мой взгляд это вполне удобно и информативно. Вы сразу видите, как ваши действия влияют на архитектуру создаваемой модели.

Далее нам предстоит дополнить нашу новую модель несколькими нейронными слоями принятия решения для конкретной обучаемой задачи. Здесь я не стал сильно усложнять, так как в данных тестах основная задача оценить результативность подходов. Были добавлены 2 полносвязных нейронных слоя из 500 элементов и гиперболическим тангенсом в качестве функции активации.

Добавление новых нейронных слоев оказалось довольно простой задачей. Вначале мы выбираем тип нейронного слоя. Полносвязному нейронному слою соответствует "Dense". Указываем количество нейронов в слое, функцию активации и метод обновления параметров. При выборе другого типа нейронного слоя вы будете заполнять релевантные для него поля. После указания всех необходимых данных нажимаем кнопку "ADD LAYER".

И ещё один удобный момент. Если Вам необходимо добавить несколько одинаковых нейронных слоёв, то нет необходимости повторно вводить данные. Достаточно лишь повторно нажать кнопку "ADD LAYER". Этим я и воспользовался. Для добавление 2-го нейронного слоя я уже не вводил данные, а просто нажал кнопку добавления нового слоя.

Слой результатов так же полносвязный и содержит 3 элемента, в соответствии с требованиями созданного выше советника. В качестве функции активации для слоя результатов используется сигмоида.

Предыдущие нейронные слои у нас тоже были полносвязные. Поэтому мы можем только поменять количество нейронов и функцию активации. После чего добавим слой в нашу модель.

Теперь нам остается лишь сохранить нашу новую модель в файл. Для этого мы нажимает кнопку "SAVE MODEL" и указываем имя файла новой модели "EURUSD_i_PERIOD_H1_test_rnn.nnw". Обратите внимание, вы можете указать имя файла без расширения. И оно будет добавлено автоматически.

Весь процесс создания модели вы можете наблюдать на gif ниже.

Использование инструмента создания модели

Первая модель создана. И мы переходим к созданию второй модели. Для неё в качестве донора мы загружаем вариационный автоэнкодер с полносвязным энкодером из файла "EURUSD_i_PERIOD_H1_vae.nnw". И тут нас ждал ещё один сюрприз. После загрузки новой модели-донора мы не удалили добавляемый нейронные слои. А значит они автоматически добавились к загруженной модели. Нам лишь остаётся выбрать количество нейронных слоёв для копирования из модели-донора в новую модель. И наша очередная модель готова.

Надо сказать, что на базе последней модели автоэнкодера я создал не одну, а две модели. Первую модель я создал по аналогии с предыдущей. Взял энкодер из модели-донора и добавил ранее созданные 3 слоя. Для второй модели от донора я взял только слой исходных данных и слой пакетной нормализации. К ним я добавил все те же 3 полносвязных нейронных слоя. Последняя модель нам будет в качестве ориентира обучения новой модели. Я решил, что предварительно обученный слой пакетной нормализации нам послужит в качестве подготовки сырых исходных данных. А это должно повысить сходимость новой модели. При этом мы исключили сжатие данных. И можно считать, что последняя модель является полностью заполнена случайными весами.

Как мы уже обсуждали выше, можно по-разному оценивать понятия влияния архитектуры предварительно обученной модели. Поэтому, еще для одного теста я создал с ещё одну модель. Я взял архитектуры вновь созданной модели с использованием энкодера с LSTM-блоками и полностью повторил её в новой модели. Но уже без копирования энкодера из модели донора. Таким образом я получил полностью идентичную архитектуру модели, но уже инициализированную случайными весами.


4. Результаты тестов

И теперь, когда мы создали все модели, необходимые для наших тестов мы модем перейти к их обучению.

Обучение моделей мы осуществляли методом обучения с учителем с сохранением используемых ранее параметров обучения. Так обучение осуществляется на временном отрезке в последние 2 года. Мы использовали инструмент EURUSD и таймфрейм H1. Параметры всех индикаторов использовались установленные в советнике по умолчанию.

Для чистоты эксперимента мы проводили обучение всех моделей одновременно в одном терминале на разных графиках.

Надо сказать, что одновременное обучение нескольких моделей не желательно. Так как это значительно снижает скорость обучения каждой из них. Как вы знаете, в своих моделях мы используем технологию OpenCL, чтобы распараллелить вычислительный процесс и максимально использовать доступные нам ресурсы. А при параллельном обучении нескольких моделей нам придется разделить имеющиеся ресурсы между всеми моделями. А значит урезать ресурсы доступные каждой из них. И, как следствие, увеличение времени обучения каждой из них. Но мы пошли на этот шаг намеренно, чтобы обеспечить максимально одинаковые условия для обучения всех сравниваемых моделей.

Тест 1

Для первого теста мы использовали две модели с предварительно обученными энкодерами и одну малую полносвязную модель с заимствованным слоем пакетной нормализации и 2-мя полносвязными скрытыми слоями.

Результаты тестирования моделей представлены на графике ниже.

Сравнение динамики обучения моделей

Как можно заметить на представленном графике, бесспорное преимущество показала модель с предварительно обученным рекуррентным энкодером. Практически с первых эпох обучения её ошибка снижалась со значительно опережающими темпами.

Модель с полносвязным энкодером также показала тенденцию к снижению ошибки в процессе обучения, но с меньшими темпами.

Полносвязная модель с 2-мя скрытыми слоями, инициализированная случайными значениями на их фоне кажется вообще не обучаемой. Судя по представленному графику, создается впечатление, что её ошибка застыла на месте.

Динамика ошибки полносвязной модели

Но при детальном рассмотрении можно заметить тенденцию к снижению ошибки. Хотя это снижение происходит и значительно меньшими темпами. Очевидно, что такая модель слишком проста для решения подобных задач.

Из этого можно сделать вывод, что на результативность модели все же большое влияние оказывает обработка исходных данных предварительно обученным энкодером. И архитектура такого энкодера оказывает значительное влияние на работу всей модели.

Отдельно надо сказать о скорости обучения моделей. Конечно, минимальное время прохождения одной эпохи показала наиболее простая модель. Но стоит заметить, что скорость обучения модели с рекуррентным энкодером оказалась очень близкой. На мой взгляд, на это оказало влияние ряд факторов.

Прежде всего, архитектура рекуррентной модели позволила снизить нам в 4 раза окно анализируемых данных. А вместе с тем, и число межнейронных связей. Как следствие, снижение затрат на их обработку. В то же время рекуррентная архитектура подразумевает дополнительные затраты ресурсов на обратный проход распределения градиента ошибки. Мы же отключили обратный проход для предварительно обученных нейронных слоёв. Все это вместе нам позволило значительно снизить расходы на дообучение модели.

Модель с полносвязным энкодером немного отстала от них по скорости обучения.

Тест 2

Во втором тесте мы решили минимизировать архитектурные различия моделей и провести обучение двух рекуррентных моделей с одинаковой архитектурой. Только одна модель использует предварительно обученный рекуррентный энкодер. А вторая модель полностью инициализирована случайными весами. Обучение проводилось с сохранением всех параметров тестирования, указанных в первом тесте.

Результаты тестирования представлены на графике ниже. Как можно заметить, предварительно обученная модель стартовала с меньшей ошибкой. Но вскоре обе модели выровнялись и далее их значения были довольно близки. Это подтверждает сделанный ранее вывод, что архитектура энкодера оказывает значительное влияние на результативность всей модели.

Сравнение динамики обучения рекуррентных моделейй

Также стоит отметить и скорость обучения. В процессе тестирования предварительно обученная модель показала в 6 раз меньшее время на прохождение одной эпохи. Конечно, здесь мы учитывали чистое время без учета затрат на обучение автоэнкодера.


Заключение

Проведенная работа позволяет сделать вывод о ряде преимуществ использования технологии Transfer Learning. Прежде всего, данная технология действительно работает. Её применение позволяет повторно использовать уже обученные блоки моделей для решения новых задач. Единственное условие — единство исходных данных. Использование предварительно обученных блоках на не свойственных исходных данных не даст результата.

Использование технологии позволяет сократить время на обучения новой модели. Правда в процессе тестирования измерялось чистое время без учета затрат на обучения автоэнкодера. Вероятно, если прибавить затраты времени на обучение автоэнкодера затраты сравняются. А может даже из-за более сложной архитектуры декодера обучение "чистой" модели окажется быстрее. Поэтому использование Transfer Learning может быть оправдано, когда предполагается использование одного блока для решения различных задач, или обучение модели целиком не представляется возможным по каким-либо причинам. К примеру, модель может быть сильно сложная и в процессе обучения градиент ошибки затухает и не доходит до всех слоёв.

Также использование технологии может быть оправдано в процессе поиска оптимальной модели, когда мы постепенно усложняем модель в поиске оптимального значения ошибки.


Ссылки

  1. Нейросети — это просто (Часть 20): Автоэнкодеры
  2. Нейросети — это просто (Часть 21): Вариационные автоэнкодеры (VAE)
  3. Нейросети — это просто (Часть 22): Обучение без учителя рекуррентных моделей
  4. Нейросети — это просто (Часть 23): Создаём инструмент для Transfer Learning
  5. Нейросети — это просто (Часть 24): Совершенствуем инструмент для Transfer Learning

Программы, используемые в статье

# Имя Тип Описание
1 check_net.mq5  Советник Советник для дообучения моделей. 
2 NetCreator.mq5 Советник Инструмент создания моделей
3 NetCreatotPanel.mqh Библиотека класса Библиотека класса для создания инструмента
4 NeuroNet.mqh Библиотека класса Библиотека классов для создания нейронной сети
5 NeuroNet.cl Библиотека Библиотека кода программы OpenCL


Прикрепленные файлы |
MQL5.zip (78.84 KB)
Разработка торговой системы на основе индикатора Williams PR Разработка торговой системы на основе индикатора Williams PR
Новая статья из серии, в которой мы учимся создавать торговые системы по показателям самых популярных технических индикаторов. Пишем системы на языке MQL5 для использования в MetaTrader 5. В этой статье мы будем изучать индикатор Процентного диапазона Уильямса (Williams' %R).
Разработка торговой системы на основе индикатора Ишимоку Разработка торговой системы на основе индикатора Ишимоку
Эта статья продолжает серию, в которой мы учимся строить торговые системы на основе самых популярных индикаторов. На этот раз мы поговорим об индикаторе Ишимоку и создадим торговую систему по его показателям.
Рыночная математика: прибыль, убыток, издержки Рыночная математика: прибыль, убыток, издержки
В данной статье я покажу вам, как считать полную прибыль или убыток любого трейда, включая комиссию и своп. Составим точнейшую математическую модель, напишем по ней код и сравним ее с эталоном, а также попытаемся залезть под капот основной функции MQL5 для вычисления прибыли и докопаемся до сути всех необходимых величин из спецификации.
Машинное обучение и Data Science (Часть 05): Деревья решений на примере погодных условий для игры в теннис Машинное обучение и Data Science (Часть 05): Деревья решений на примере погодных условий для игры в теннис
Деревья решений классифицируют данные, имитируя то, каким образом размышляют люди. В этой статье посмотрим, как строить деревья и использовать их для классификации и прогнозирования данных. Основная цель алгоритма деревьев решений состоит в том, чтобы разделить выборку на данные с "примесями" и на "чистые" или близкие к узлам.