Сравнительное тестирование моделей с использованием пакетной нормализации

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

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

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

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

Задача ясна, и мы переходим к практической реализации. Для создания скрипта возьмем за базу скрипт первого тестирования полносвязного перцептрона perceptron_test.mq5. Создадим его копию с именем perceptron_test_norm.mq5.

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

//+------------------------------------------------------------------+
//| Внешние параметры для работы скрипта                             |
//+------------------------------------------------------------------+
// Имя файла с обучающей выборкой
input string   StudyFileName = "study_data_not_norm.csv";
// Имя файла для записи динамики ошибки
input string   OutputFileName = "loss_study_vs_norm.csv";
// Количество исторических баров в одном паттерне
input int      BarsToLine     = 40;             
// Количество нейронов входного слоя на 1 бар
input int      NeuronsToBar   = 4;
// Использовать OpenCL
input bool     UseOpenCL      = false;
// Размер пакета для обновления матрицы весов
input int      BatchSize      = 10000;
// Коэффициент обучения
input double   LearningRate   = 3e-5;
// Количество скрытых слоев
input int      HiddenLayers   =  1;
// Количество нейронов в одном скрытом слое
input int      HiddenLayer    =  40;            
// Количество итераций обновления матрицы весов
input int      Epochs         =  1000;

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

bool CreateLayersDesc(CArrayObj &layers)
  {
   layers.Clear();
   CLayerDescription *descr;
//--- создаем слой исходных данных
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      return false;
     }

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

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

   descr.type         = defNeuronBase;
   int prev =
      descr.count     = NeuronsToBar * BarsToLine;
   descr.window       = 0;
   descr.activation   = AF_NONE;
   descr.optimization = None;

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

   if(!layers.Add(descr))
     {
      PrintFormat("Error adding layer: %d"GetLastError());
      delete descr;
      return false;
     }

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

//--- слой пакетной нормализации
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      return false;
     }

Вторым слоем модели будет слой пакетной нормализации. Об этом мы скажем модели, указав соответствующую константу в поле type. Количество нейронов и размер окна исходных данных укажем в соответствии с размером предыдущего нейронного слоя. Для слоя пакетной нормализации добавляется новый параметр — размер пакета batch. Для данного параметра мы укажем значение равное размеру пакета между обновлениями параметров пакета. Функцию активации мы не используем, но указываем метод оптимизации параметров Adam.

   descr.type = defNeuronBatchNorm;
   descr.count = prev;
   descr.window = descr.count;
   descr.batch = BatchSize;
   descr.activation = AF_NONE;
   descr.optimization = Adam;

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

   if(!layers.Add(descr))
     {
      PrintFormat("Error adding layer: %d"GetLastError());
      delete descr;
      return false;
     }

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

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

//--- блок скрытых слоев
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      return false;
     }

После создания объекта заполним его необходимыми значениями. Укажем базовый тип нейронного слоя defNeuronBase. Количество элементов в нейронном слое перенесем из внешнего параметра скрипта HiddenLayer. В качестве функции активации будем использовать Swish, а метода оптимизации параметров — Adam.

   descr.type         = defNeuronBase;
   descr.count        = HiddenLayer;
   descr.activation   = AF_SWISH;
   descr.optimization = Adam;
   descr.activation_params[0] = 1;

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

   for(int i = 0i < HiddenLayersi++)
      if(!layers.Add(descr))
        {
         PrintFormat("Error adding layer: %d"GetLastError());
         delete descr;
         return false;
        }

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

//---  слой результатов
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      return false;
     }

После создания объекта мы заполняем его описанием создаваемого нейронного слоя. Для слоя результатов используем базовый тип полносвязного нейронного слоя из двух элементов (по числу целевых значений для паттерна). Используем линейную функцию активации и метод оптимизации Adam, как и для других слоев модели.

   descr.type         = defNeuronBase;
   descr.count        = 2;
   descr.activation   = AF_LINEAR;
   descr.optimization = Adam;
   descr.activation_params[0] = 1;

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

   if(!layers.Add(descr))
     {
      PrintFormat("Error adding layer: %d"GetLastError());
      delete descr;
      return false;
     }
   return true;
  }

И конечно, не забываем проверить результат операции.

На этом мы заканчиваем работу по построению скрипта для проведения первого теста, так как остальной код скрипта перенесен без изменений. Запускать скрипт будем на ранее подготовленных данных обучающей выборки. Напомню, что для чистоты экспериментов все модели в рамках данной книги обучаются на одной обучающей выборке. Это касается как моделей созданных в среде MQL5, так и тех, что написаны на языке Python.

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

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

Пакетная нормализация исходных данных

Пакетная нормализация исходных данных

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

Пакетная нормализация исходных данных

Пакетная нормализация исходных данных

Аналогичный эксперимент мы провели и с моделями, созданными на языке Python. Этот эксперимент подтвердил ранее сделанные выводы.

Пакетная нормализация исходных данных (MSE)

Пакетная нормализация исходных данных (MSE)

Пакетная нормализация исходных данных (MSE)

Пакетная нормализация исходных данных (MSE)

Пакетная нормализация исходных данных (Accuracy)

Пакетная нормализация исходных данных (Accuracy)

Пакетная нормализация исходных данных (Accuracy)

Пакетная нормализация исходных данных (Accuracy)

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

Анализ графика метрики Accuracy позволяет сделать аналогичные выводы.

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

//--- Слой пакетной нормализации
   CLayerDescription *norm = new CLayerDescription();
   if(!norm)
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      return false;
     }
   norm.type = defNeuronBatchNorm;
   norm.count = prev;
   norm.window = descr.count;
   norm.batch = BatchSize;
   norm.activation = AF_NONE;
   norm.optimization = Adam;
//--- Скрытый слой
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      delete norm;
      return false;
     }
   descr.type         = defNeuronBase;
   descr.count        = HiddenLayer;
   descr.activation   = AF_SWISH;
   descr.optimization = Adam;
   descr.activation_params[0] = 1;
   for(int i = 0i < HiddenLayersi++)
     {
      if(!layers.Add(norm))
        {
         PrintFormat("Error adding layer: %d"GetLastError());
         delete descr;
         delete norm;
         return false;
        }
      CLayerDescription *temp = new CLayerDescription();
      if(!temp)
        {
         PrintFormat("Error creating CLayerDescription: %d"GetLastError());
         delete descr;
         return false;
        }
      temp.Copy(norm);
      norm = temp;
      norm.count = descr.count;
      if(!layers.Add(descr))
        {

         PrintFormat("Error adding layer: %d"GetLastError());
         delete descr;
         delete norm;
         return false;
        }
     }
   delete norm;

В остальном скрипт остался без изменений.

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

Пакетная нормализация перед скрытым слоем

Пакетная нормализация перед скрытым слоем

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

Пакетная нормализация перед скрытым слоем

Пакетная нормализация перед скрытым слоем

Пакетная нормализация перед скрытым слоем (MSE)

Пакетная нормализация перед скрытым слоем (MSE)

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

Динамика изменения значения метрики Accuracy также подтверждает ранее сделанные выводы.

Дополнительно мы проверили модели на тестовой выборке, чтобы оценить эффективность работы на новых данных. Полученные результаты продемонстрировали довольно ровную работу всех четырех моделей. Расхождение среднеквадратичной ошибки моделей не превысило 5*10-3. Лишь небольшое преимущество продемонстрировали модели с тремя скрытыми слоями.

Оценка моделей по метрике Accuracy показало схожие результаты.

Пакетная нормализация перед скрытым слоем (Accuracy)

Пакетная нормализация перед скрытым слоем (Accuracy)

Проверка эффективности пакетной нормализации на новых данных

Проверка эффективности пакетной нормализации на новых данных

Проверка эффективности пакетной нормализации на новых данных

Проверка эффективности пакетной нормализации на новых данных

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

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

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

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

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

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

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

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