Проверка корректности распределения градиента

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

Для проверки корректности распределения градиента воспользуемся тем фактом, что для определения производной функции у нас есть два варианта:

  • Аналитический — определение градиента функции на основе ее производной первого порядка. Этот метод и реализован в нашем методе обратного прохода.
  • Эмпирический — при прочих равных условиях изменяется значение одного показателя и оценивается его влияние на конечный результат функции.

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

Геометрический смысл градиента — это наклон касательной к графику функции в текущей точке

Геометрический смысл градиента — это наклон касательной к графику функции в текущей точке

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

Если этот способ настолько прост, то почему его не использовать на постоянной основе? Здесь все довольно просто. За простотой метода кроется большое количество операций:

  1. Осуществить прямой проход и сохранить его результат.
  2. Немного увеличить один параметр и повторить прямой проход с сохранением результата.
  3. Немного уменьшить один параметр и повторить прямой проход с сохранением результата.
  4. По найденным точкам построить прямую и определить ее наклон.

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

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

Для практической реализации данной идеи создадим скрипт check_gradient_percp.mq5. Данный скрипт получит три внешних параметра:

  • размер вектора исходных данных,
  • флаг использования технологии OpenCL,
  • функцию активации скрытого слоя.

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

//+------------------------------------------------------------------+
//| Внешние параметры для работы скрипта                             |
//+------------------------------------------------------------------+
// Размер вектора исходных данных
input int      BarsToLine    = 40;
// Использовать OpenCL
input bool     UseOpenCL     =  true;
// Функция активации скрытого слоя
input ENUM_ACTIVATION_FUNCTION HiddenActivation = AF_SWISH;

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

//+------------------------------------------------------------------+
//| Подключаем библиотеку нейронной сети                             |
//+------------------------------------------------------------------+
#include "..\..\..\Include\NeuroNetworksBook\realization\neuronnet.mqh"
CNet Net;

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

Напомню, для создания модели нейронной сети ранее мы создали метод CNet::Create. В параметрах данный метод принимает динамический массив описания архитектуры нейронной сети. Следовательно, нам необходимо организовать подобное описание новой модели. Описание каждого нейронного слоя соберем в отдельный экземпляр класса CLayerDescription. Объединим их в динамический массив CArrayObj. При добавлении описания нейронов в динамический массив следим, чтобы их последовательность строго соответствовала расположению нейронных слоев в нейронной сети. В своей практике я просто последовательно создаю описания слоев в порядке их расположения в нейронной сети и добавляю их в массив по мере создания.

bool CreateNet(CNet &net)
  {
   CArrayObj *layers = new CArrayObj();
   if(!layers)
     {
      PrintFormat("Error creating CArrayObj: %d"GetLastError());
      return false;
     }

Для проверки корректности работы реализованного алгоритма обратного распространения ошибки создадим трехслойную нейронную сеть. Все слои будут построены на базе созданного нами класса CNeuronBase. Размер первого нейронного слоя исходных данных пользователь указал во внешнем параметре BarsToLine. Его мы создадим без функции активации и метода обновления весовых коэффициентов. В принципе это базовый подход к созданию слоя исходных данных.

//--- слой исходных данных
   CLayerDescription *descr = new CLayerDescription();
   if(!descr)
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      delete layers;
      return false;
     }
   descr.type = defNeuronBase;
   descr.count = BarsToLine;
   descr.window = 0;
   descr.activation = AF_NONE;
   descr.optimization = None;
   if(!layers.Add(descr))
     {
      PrintFormat("Error adding layer: %d"GetLastError());
      delete layers;
      delete descr;
      return false;
     }

Количество нейронов во втором (скрытом) нейронном слое мы зададим в 10 раз больше слоя исходного данного. Однако непосредственно размер нейронного слоя не влияет на процесс анализа работы алгоритма. Этот слой уже получит функцию активации, которую пользователь укажет во внешнем параметре скрипта HiddenActivation. Для примера я взял Swish. Вам же я рекомендовал бы поэкспериментировать со всеми используемыми функциями активации, ведь на данном этапе мы хотим проверить корректность работы всех ранее написанных методов. Поэтому чем более разносторонним будет тестирование, тем больше мы сможем закрыть спорных вопросов на данном этапе и не отвлекаться на их поиск и устранение в процессе обучения нейронной сети.

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

//--- скрытый слой
   descr = new CLayerDescription();
   if(!descr)
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      delete layers;
      return false;
     }
   descr.type = defNeuronBase;
   descr.count = 10 * BarsToLine;
   descr.activation = HiddenActivation;
   descr.optimization = Adam;
   descr.activation_params[0] = (TYPE)1;
   descr.activation_params[1] = (TYPE)0;
   if(!layers.Add(descr))
     {
      PrintFormat("Error adding layer: %d"GetLastError());
      delete layers;
      delete descr;
      return false;
     }

Третий нейронный слой будет содержать только один нейрон для вывода результатов и линейную функцию активации.

//--- слой результатов
   descr = new CLayerDescription();
   if(!descr)
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      delete layers;
      return false;
     }
   descr.type = defNeuronBase;
   descr.count = 1;
   descr.activation = AF_LINEAR;
   descr.optimization = Adam;
   descr.activation_params[0] = 1;
   descr.activation_params[1] = 0;
   if(!layers.Add(descr))
     {
      PrintFormat("Error adding layer: %d"GetLastError());
      delete layers;
      delete descr;
      return false;
     }

Собрав полное описание нейронной сети в едином динамическом массиве, мы генерируем нейронную сеть. Для этого вызываем метод CNet::Create нашего базового класса нейронной сети, в котором осуществляется генерация нейронной сети в соответствии с переданным описанием. На каждом шаге проверяем корректность выполнения операций по возвращаемым результатам. Получение логического значения true соответствует корректному выполнению операций метода. При возникновении любой из ошибок метод вернет значение false.

Укажем флаг использования технологии OpenCL. Для полного тестирования нам предстоит проверить корректность работы метода обратного распространения ошибки в обоих режимах работы нейронной сети.

//--- инициализируем нейронную сеть
   if(!net.Create(layers, (TYPE)3.0e-4, (TYPE)0.9, (TYPE)0.999LOSS_MAE00))
     {
      PrintFormat("Error of init Net: %d"GetLastError());
      delete layers;
      return false;
     }
   delete layers;
   net.UseOpenCL(UseOpenCL);
   PrintFormat("Use OpenCL %s", (string)net.UseOpenCL());
//---
   return true;
  }

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

void OnStart()
  {
//--- создание модели
   if(!CreateNet(Net))
      return;

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

//--- создаем буфер для считывания исходных данных
   CBufferType *pattern = new CBufferType();
   if(!pattern)
     {
      PrintFormat("Error creating Pattern data array: %d"GetLastError());
      return;
     }

В цикле заполним весь буфер случайными значениями.

//--- генерируем случайные исходные данные
   if(!pattern.BufferInit(1BarsToLine))
      return;
   for(int i = 0i < BarsToLinei++)
      pattern.m_mMatrix[0i] = (TYPE)MathRand() / (TYPE)32767;

Теперь информации достаточно для проведения прямого прохода нейронной сети. Его мы и осуществим путем вызова метода FeedForward нашей нейронной сети. Результаты прямого прохода сохраним в отдельном буфере данных эталонных значений. Наверно странно звучит название «эталонный» для случайно полученного результата. Но в рамках нашего тестирования это будет тот эталон, отклонение от которого мы будем считать при изменении исходных данных или весовых коэффициентов.

//--- делаем прямой и обратный проход для получения аналитических градиентов
   const TYPE delta = (TYPE)1.0e-5;
   TYPE dd = 0;
   CBufferType *init_pattern = new CBufferType();
   init_pattern.m_mMatrix.Copy(pattern.m_mMatrix);
   if(!Net.FeedForward(pattern))
     {
      PrintFormat("Error in FeedForward: %d"GetLastError());
      return;
     }
   CBufferType *etalon_result = new CBufferType();
   if(!Net.GetResults(etalon_result))
     {
      PrintFormat("Error in GetResult: %d"GetLastError());
      return;
     }

На следующем этапе прибавим к результату прямого прохода небольшую константу и запустим обратный проход нашей нейронной сети для расчета градиентов ошибки аналитическим путем. В приведенном примере в качестве отклонения я использовал константу 1*10-5.

//--- создаем буфер результатов
   CBufferType *target = new CBufferType();
   if(!target)
     {
      PrintFormat("Error creating Pattern Target array: %d"GetLastError());
      return;
     }
//--- сохраняем в отдельные буферы полученные данные
   target.m_mMatrix.Copy(etalon_result.m_mMatrix);
   target.m_mMatrix[00] = etalon_result.m_mMatrix[00] + delta;
   if(!Net.Backpropagation(target))
     {
      PrintFormat("Error in Backpropagation: %d"GetLastError());
      delete target;
      delete etalon_result;
      delete pattern;
      delete init_pattern;
      return;
     }
   CBufferType *input_gradient = Net.GetGradient(0);
   CBufferType *weights = Net.GetWeights(1);
   CBufferType *weights_gradient = Net.GetDeltaWeights(1);
   if(UseOpenCL)
     {
      input_gradient.BufferRead();
      weights.BufferRead();
      weights_gradient.BufferRead();
     }

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

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

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

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

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

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

//--- в цикле поочередно изменяем элементы исходных данных и сравниваем
//--- эмпирический результат со значением аналитического метода
   for(int k = 0k < BarsToLinek++)
     {
      pattern.m_mMatrix.Copy(init_pattern.m_mMatrix);
      pattern.m_mMatrix[0k] = init_pattern.m_mMatrix[0k] + delta;
      if(!Net.FeedForward(pattern))
        {
         PrintFormat("Error in FeedForward: %d"GetLastError());
         return;
        }
      if(!Net.GetResults(target))
        {
         PrintFormat("Error in GetResult: %d"GetLastError());
         return;
        }
      TYPE d = target.At(0) - etalon_result.At(0);

      pattern.m_mMatrix[0k] = init_pattern.m_mMatrix[0k] - delta;
      if(!Net.FeedForward(pattern))
        {
         PrintFormat("Error in FeedForward: %d"GetLastError());
         return;
        }
      if(!Net.GetResults(target))
        {
         PrintFormat("Error in GetResult: %d"GetLastError());
         return;
        }
      d -= target.At(0) - etalon_result.At(0);
      d /= 2;
      dd += input_gradient.At(k) - d;
     }
   delete pattern;

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

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

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

Описанные выше операции повторим для всех параметров исходного паттерна.

Есть еще один аспект, который нам следует учесть. Градиент указывает изменение значения функции при изменении показателя на 1. Наша же константа намного меньше. Следовательно, полученный нами эмпирический градиент сильно занижен. Для компенсации этого разделим полученное эмпирическим путем значение на нашу константу и выведем суммарный результат в журнал MetaTrader 5.

//--- выводим в журнал суммарное значение отклонений на уровне исходных данных
   PrintFormat("Delta at input gradient between methods %.5e"dd / delta);

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

Суммарный результат сравнения эмпирических и аналитических градиентов на уровне матрицы весов также выведем в журнал MetaTrader 5.

//--- обнуляем значение суммы и повторяем цикл для градиентов весовых коэффициентов
   dd = 0;
   CBufferType *initial_weights = new CBufferType();
   if(!initial_weights)
     { 
      PrintFormat("Error creating reference weights buffer: %d"GetLastError());
      return;
     }
   if(!initial_weights.m_mMatrix.Copy(weights.m_mMatrix))
     {
      PrintFormat("Error copying weights to initial weights buffer: %d",
                                                              GetLastError());
      return;
     }

   for(uint k = 0k < weights.Total(); k++)
     {
      if(k > 0)
         weights.Update(k - 1initial_weights.At(k - 1));
      weights.Update(kinitial_weights.At(k) + delta);
      if(UseOpenCL)
         if(!weights.BufferWrite())
            return;
      if(!Net.FeedForward(init_pattern))
        {
         PrintFormat("Error in FeedForward: %d"GetLastError());
         return;
        }
      if(!Net.GetResults(target))
        {
         PrintFormat("Error in GetResult: %d"GetLastError());
         return;
        }
      TYPE d = target.At(0) - etalon_result.At(0);

      weights.Update(kinitial_weights.At(k) - delta);
      if(UseOpenCL)
         if(!weights.BufferWrite())
            return;
      if(!Net.FeedForward(init_pattern))
        {
         PrintFormat("Error in FeedForward: %d"GetLastError());
         return;
        }
      if(!Net.GetResults(target))
        {
         PrintFormat("Error in GetResult: %d"GetLastError());
         return;
        }
      d -= target.At(0) - etalon_result.At(0);
      d /= 2;
      dd += weights_gradient.At(k) - d;
     }
//--- выводим в журнал суммарное значение отклонений на уровне весовых коэффициентов
   PrintFormat("Delta at weights gradient between methods %.5e"dd / delta);

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

//--- перед выходом из скрипта очищаем память
   delete init_pattern;
   delete etalon_result;
   delete initial_weights;
   delete target;
  }

Результаты моего тестирования приведены на скриншоте ниже. По результатам тестирования я получил отклонение в 11–12-м знаке после запятой. Для сравнения в разных источниках считаются приемлемыми отклонения в 8–9-м знаке после запятой. И не стоит обращать внимание, что при использовании OpenCL отклонение получилось на порядок меньше. Это не преимущество использования технологии, а скорее влияние фактора случайности. При каждом запуске заново генерировалась случайная матрица весовых коэффициентов и исходных данных. В результате этого сравнение проводилось на разных участках функции нейронной сети с различной кривизной.

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

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

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