Базовый класс нейронной сети и организация процессов прямого и обратного проходов

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

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

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

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

Давайте определимся с функционалом класса CNet. Первое, что должен выполнять данный класс — это непосредственно собрать нейронную сеть с полученной от пользователя архитектурой. Это можно сделать в конструкторе класса, а можно создать отдельный метод Create. Я предпочел второй вариант. Использование базового конструктора класса без параметров позволит нам создавать «пустой» экземпляр класса, к примеру, для загрузки ранее обученной нейронной сети. Также это облегчит наследование класса для возможного последующего развития.

И раз уж мы затронули вопрос загрузки предварительно обученной сети, то отсюда вытекает следующий функционал класса: сохранение (Save) и загрузка (Load) нашей модели.

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

Посмотрим в сторону непосредственной организации работы нейронной сети. Здесь мы должны реализовать алгоритмы прямого (FeedForward) и обратного прохода (Backpropagation). Выведем отдельным методом процесс обновления весовых коэффициентов UpdateWeights.

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

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

Давайте вспомним методы оптимизации нейронов. Практически все методы используют коэффициент обучения, а некоторые требуют дополнительные параметры, такие как коэффициенты затухания. Мы также должны предоставить возможность пользователю указать их. При этом пользователь указывает один раз, а нам они потребуются на каждой итерации. Значит, нам их где-то надо сохранить. Добавим метод для указания параметров обучения (SetLearningRates) и переменные для хранения данных (m_dLearningRate и m_adBeta). Для коэффициентов затухания создадим вектор из двух элементов, что, на мой взгляд, сделает код более читабельным.

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

Кроме того, в процессе обучения и эксплуатации нейронной сети нам будет необходимо контролировать процесс точности и корректности данных прямого прохода. Основным показателем корректной работы нейронной сети является значение функции потерь. Непосредственно расчет функции потерь будет осуществляться в методе обратного прохода Backpropagation. Посчитанное значение функции потерь сохраним в переменную m_dNNLoss. Добавим метод GetRecentAverageLoss для вывода значения переменной по запросу пользователя.

Кстати о функции потерь. Выбор функции потерь для использования предоставляется пользователю. Значит, нам нужен метод для возможности получения ее от пользователя (LossFunction). Непосредственно расчет значения функции потерь будет осуществляться стандартными средствами матричных операций в MQL5. Здесь же мы создадим переменную для хранения типа функции потерь (m_eLossFunction).

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

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

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

Есть еще один момент, касающийся варианта работы нейронной сети, а точнее, выбора инструмента проведения вычислительных операций. Мы уже обсуждали тему использования технологии OpenCL для параллельных вычислений. Это позволит производить параллельное вычисление математических операций на GPU и ускорить вычисления в процессе работы нейронной сети. Для работы с OpenCL в MQL5 предлагается класс COpenCL в стандартной библиотеке OpenCL.mqh.

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

Для использования класса CMyOpenCL добавим указатель на экземпляр класса m_cOpenCL. Также добавим флаг m_bOpenCL, который подскажет, включен ли функционал в нашей нейронной сети. Также добавим методы для инициализации функционала и управления им (InitOpenCL, UseOpenCL).

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

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

К нашему списку добавим еще метод идентификации класса и получим нижеследующую структуру класса CNet.

class CNet  : public CObject
  {
protected:
   bool               m_bTrainMode;
   CArrayLayers*      m_cLayers;
   CMyOpenCL*         m_cOpenCL;
   bool               m_bOpenCL;
   TYPE               m_dNNLoss;
   int                m_iLossSmoothFactor;
   CPositionEncoder*  m_cPositionEncoder;
   bool               m_bPositionEncoder;
   ENUM_LOSS_FUNCTION m_eLossFunction;
   VECTOR             m_adLambda;
   TYPE               m_dLearningRate;
   VECTOR             m_adBeta;

public:
                      CNet(void);
                     ~CNet(void);
   //--- Методы создания объекта
   bool               Create(........);
   //--- Организация работы с OpenCL
   void               UseOpenCL(bool value);
   bool               UseOpenCL(void)          const { return(m_bOpenCL);          }
   bool               InitOpenCL(void);

   //--- Методы работы с позиционным кодированием
   void               UsePositionEncoder(bool value);
   bool               UsePositionEncoder(voidconst { return(m_bPositionEncoder); }
   //--- Организация основных алгоритмов работы модели
   bool               FeedForward(........);
   bool               Backpropagation(........);
   bool               UpdateWeights(........);
   bool               GetResults(........);
   void               SetLearningRates(TYPE learning_rateTYPE beta1 = defBeta1,
                                                           TYPE beta2 = defBeta2);
   //--- Методы функции потерь
   bool               LossFunction(ENUM_LOSS_FUNCTION loss_function,
                          TYPE lambda1 = defLambdaL1TYPE lambda2 = defLambdaL2);
   ENUM_LOSS_FUNCTION LossFunction(void)       const { return(m_eLossFunction);    }
   ENUM_LOSS_FUNCTION LossFunction(TYPE &lambda1TYPE &lambda2);

   TYPE               GetRecentAverageLoss(voidconst { return(m_dNNLoss);        }
   void               LossSmoothFactor(int value)   { m_iLossSmoothFactor = value; }
   int                LossSmoothFactor(void)   const { return(m_iLossSmoothFactor);}
   //--- Управление режимом работы модели
   bool               TrainMode(void)          const { return m_bTrainMode;        }
   void               TrainMode(bool mode);
   //--- Методы работы с файлами
   virtual bool       Save(........);
   virtual bool       Load(........);
   //--- Метод идентификации объекта
   virtual int        Type(void)               const { return(defNeuronNet);       }
   //--- Получение указателей на внутренние объекты
   virtual CBufferTypeGetGradient(uint layer)     const;
   virtual CBufferTypeGetWeights(uint layer)      const;
   virtual CBufferTypeGetDeltaWeights(uint layerconst;
  };

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

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

CNet::CNet(void)     :  m_bTrainMode(false),
                        m_bOpenCL(false),
                        m_bPositionEncoder(false),
                        m_dNNLoss(-1),
                        m_iLossSmoothFactor(defLossSmoothFactor),
                        m_dLearningRate(defLearningRate),
                        m_eLossFunction(LOSS_MSE)
  {
   m_adLambda.Init(2);
   m_adBeta.Init(2);
   m_adLambda[0] = defLambdaL1;
   m_adLambda[1] = defLambdaL2;
   m_adBeta[0]   = defBeta1;
   m_adBeta[1]   = defBeta2;
   m_cLayers     = new CArrayLayers();
   m_cOpenCL     = new CMyOpenCL();
   m_cPositionEncoder = new CPositionEncoder();
  }

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

CNet::~CNet(void)
  {
   if(!!m_cLayers)
      delete m_cLayers;
   if(!!m_cPositionEncoder)
      delete m_cPositionEncoder;
   if(!!m_cOpenCL)
      delete m_cOpenCL;
  }

Рассмотрим метод создания нейронной сети Create. Выше я опустил параметры данного метода, а теперь предлагаю их обсудить.

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

А если посмотреть на вопрос со стороны пользователя: насколько удобно использовать три метода для указания всех необходимых параметров при инициализации нейронной сети? На самом деле это вопрос личных привычек и предпочтений пользователя. Одни предпочитают использовать несколько методов с указанием одного или двух параметров и на каждом шаге контролировать процесс. Другие предпочтут в одной строчке кода «закинуть» все параметры в один метод, один раз проверить результат и идти дальше.

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

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

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

bool CNet::Create(CArrayObj *descriptions)
  {
//--- Блок контролей
   if(!descriptions)
      return false;
//--- Проверяем количество создаваемых слоев
   int total = descriptions.Total();
   if(total < 2)
      return false;

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

//--- Инициализируем объекты OpenCL
   if(m_bOpenCL)
      m_bOpenCL = InitOpenCL();
   if(!m_cLayers.SetOpencl(m_cOpenCL))
      m_bOpenCL = false;

Чтобы все объекты нашей нейронной сети работали в одном контексте OpenCL, передадим указатель на экземпляр класса CMyOpenCL в массив хранения нейронных слоев. Оттуда в последующем он будет передан каждому нейронному слою.  

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

После этого вызовем метод для создания соответствующего слоя. Стоит отметить, что непосредственно создание нейронного слоя мы поручим методу создания элемента CreateElement динамического массива хранения нейронных слоев m_cLayers.

//--- Организовываем цикл для создания нейронных слоев
   for(int i = 0i < totali++)
     {
      CLayerDescription *temp = descriptions.At(i);
      if(!temp)
         return false;
      if(i == 0)
        {
         if(temp.type != defNeuronBase)
            return false;
         temp.window = 0;
        }

      else
        {
         CLayerDescription *prev = descriptions.At(i - 1);
         if(temp.window <= 0 || temp.window > prev.count ||
            temp.type == defNeuronBase)
           {
            switch(prev.type)
              {
               case defNeuronConv:
               case defNeuronProof:
                  temp.window = prev.count * prev.window_out;
                  break;
               case defNeuronAttention:
               case defNeuronMHAttention:
                  temp.window = prev.count * prev.window;
                  break;
               case defNeuronGPT:
                  temp.window = prev.window;
                  break;
               default:
                  temp.window = prev.count;
                  break;
              }

            switch(temp.type)
              {
               case defNeuronAttention:
               case defNeuronMHAttention:
               case defNeuronGPT:
                  break;
               default:
                  temp.step = 0;
              }
           }
        }
      if(!m_cLayers.CreateElement(itemp))
         return false;
     }

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

//--- Инициализируем объекты позиционного кодирования
   if(m_bPositionEncoder)
     {
      if(!m_cPositionEncoder)
        {
         m_cPositionEncoder = new CPositionEncoder();
         if(!m_cPositionEncoder)
            m_bPositionEncoder = false;
         return true;
        }
      CLayerDescription *temp = descriptions.At(0);
      if(!m_cPositionEncoder.InitEncoder(temp.counttemp.window))
         UsePositionEncoder(false);
     }
//---
   return true;
  }

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

bool CNet::Create(CArrayObj *descriptions,
                  TYPE learning_rate,
                  TYPE beta1,TYPE beta2,
                  ENUM_LOSS_FUNCTION loss_function,
                  TYPE lambda1,TYPE lambda2)
  {
   if(!Create(descriptions))
      return false;
   SetLearningRates(learning_rate,beta1,beta2);
   if(!LossFunction(loss_function,lambda1,lambda2))
      return false;
//---
   return true;
  }

bool CNet::Create(CArrayObj *descriptions,
                  ENUM_LOSS_FUNCTION loss_function,
                  TYPE lambda1,TYPE lambda2)
  {
   if(!Create(descriptions))
      return false;
   if(!LossFunction(loss_function,lambda1,lambda2))
      return false;
//---
   return true;
  }

bool CNet::Create(CArrayObj *descriptions,
                  TYPE learning_rate,
                  TYPE beta1,TYPE beta2)
  {
   if(!Create(descriptions))
      return false;
   SetLearningRates(learning_rate,beta1,beta2);
//---
   return true;
  }

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

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

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

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

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

bool CNet::FeedForward(const CBufferType *inputs)
  {
//--- Блок контролей
   if(!inputs)
      return false;
   CNeuronBase *InputLayer = m_cLayers.At(0);
   if(!InputLayer)
      return false;

Следующим шагом при необходимости проведем позиционирование исходных значений.

   CBufferType *Inputs = InputLayer.GetOutputs();
   if(!Inputs)
      return false;
   if(Inputs.Total() != inputs.Total())
      return false;
//--- Переносим исходные данные в нейронный слой
   Inputs.m_mMatrix = inputs.m_mMatrix;
//--- Применяем позиционное кодирование
   if(m_bPositionEncoder && !m_cPositionEncoder.AddEncoder(Inputs))
      return false;
   if(m_bOpenCL)
      Inputs.BufferCreate(m_cOpenCL);

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

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

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

//--- Организовываем цикл с полным перебором всех нейронных слоев
//--- и вызовом метода прямого прохода для каждого из них
   CNeuronBase *PrevLayer = InputLayer;
   int total = m_cLayers.Total();
   for(int i = 1i < totali++)
     {
      CNeuronBase *Layer = m_cLayers.At(i);
      if(!Layer)
         return false;
      if(!Layer.FeedForward(PrevLayer))
         return false;
      PrevLayer = Layer;
     }

Здесь надо обратить внимание, что при использовании технологии OpenCL во время отправки кернела на выполнение осуществляется его постановка в очередь. И чтобы «подтолкнуть» его выполнение, необходимо инициировать считывание результатов операции. Ранее мы уже обсуждали необходимость минимизации процессов обмена данными между ОЗУ и контекстом OpenCL. Поэтому мы не будем считывать данные после постановки каждого кернела в очередь. Вместо этого отправим всю цепочку операций в очередь и только после завершения цикла перебора всех нейронных слоев запросим результат операций последнего нейронного слоя. Так как у нас данные передаются последовательно от одного слоя к другому, то потянется и вся очередь операций. Но не забывайте, что загрузка данных необходима только при использовании технологии OpenCL.

   if(m_bOpenCL)
      if(!PrevLayer.GetOutputs().BufferRead())
         return false;
//---
   return true;
  }

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

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

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

bool CNet::Backpropagation(CBufferType *target)
  {
//--- Блок контролей
   if(!target)
      return false;
   int total = m_cLayers.Total();
   CNeuronBase *Output = m_cLayers.At(total - 1);
   if(!Output ||Output.Total()!=target.Total())
      return false;
//--- Расчет значения функции потерь
   TYPE loss = Output.GetOutputs().m_mMatrix.Loss(target.m_mMatrix,
                                                  m_eLossFunction);

   if(loss == FLT_MAX)
      return false;
   m_dNNLoss = (m_dNNLoss < 0 ? loss :
                m_dNNLoss + (loss - m_dNNLoss) / m_iLossSmoothFactor);

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

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

//--- Расчет градиента ошибки на выходе нейронной сети
   CBufferTypegrad = Output.GetGradients();
   grad.m_mMatrix = target.m_mMatrix;
   if(m_cOpenCL)
     {
      if(!grad.BufferWrite())
         return false;
     }
   if(!Output.CalcOutputGradient(grad, m_eLossFunction))
      return false;
//--- Организовываем цикл с перебором всех нейронных слоев в обратном порядке
   for(int i = total - 2i >= 0i--)
     {
      CNeuronBase *temp = m_cLayers.At(i);
      if(!temp)
         return false;
      //--- Вызываем метода распределения градиента ошибки через скрытый слой
      if(!Output.CalcHiddenGradient(temp))
         return false;
      //--- Вызываем метод распределения градиента ошибки до матрицы весов
      if(!Output.CalcDeltaWeights(tempi == 0))
         return false;
      Output = temp;
     }

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

   if(m_cOpenCL)
     {
      for(int i = 1i < m_cLayers.Total(); i++)
        {
         Output = m_cLayers.At(i);
         if(!Output.GetDeltaWeights() || !Output.GetDeltaWeights().BufferRead())
            continue;
         break;
        }
     }
//---
   return true;
  }

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

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

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

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

bool CNet::UpdateWeights(uint batch_size = 1)
  {
//--- Блок контролей
   if(batch_size <= 0)
      return false;
//--- Организовываем цикл перебора всех скрытых слоев
   int total = m_cLayers.Total();
   for(int i = 1i < totali++)
     {
      //--- Проверяем действительность указателя на объект нейронного слоя
      CNeuronBase *temp = m_cLayers.At(i);
      if(!temp)
         return false;
      //--- Вызываем метод обновления матрицы весов внутреннего слоя
      if(!temp.UpdateWeights(batch_sizem_dLearningRatem_adBetam_adLambda))
         return false;
     }
//---
   return true;
  }

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

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

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

bool CNet::GetResults(CBufferType *&result)
  {
   int total = m_cLayers.Total();
   CNeuronBase *temp = m_cLayers.At(total - 1);
   if(!temp)
      return false;
   CBufferType *output = temp.GetOutputs();
   if(!output)
      return false;
   if(!result)
     {
      if(!(result = new CBufferType()))
         return false;
     }
   if(m_cOpenCL)
      if(!output.BufferRead())
         return false;
   result.m_mMatrix = output.m_mMatrix;
//---
   return true;
  }

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

За сохранение обученной нейронной сети отвечает метод Save. Этот виртуальный метод создан в базовом классе CObject, он переопределяется в каждом новом классе. Я осознанно не стал сразу переписывать параметры метода из родительского класса. Дело в том, что там предполагается получение в параметрах хендла файла для записи объекта. Т.е. файл предварительно должен быть открыт во внешней программе, а после сохранения данных внешняя программа закрывает файл.

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

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

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

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

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

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

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

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

bool CNet::Save(const int file_handle)
  {
   if(file_handle == INVALID_HANDLE ||
      !m_cLayers)
      return false;

Далее сохраним указанные выше параметры.

//--- Сохраняем константы
   if(!FileWriteInteger(file_handle, (int)m_bOpenCL) ||
      !FileWriteDouble(file_handlem_dNNLoss) ||
      !FileWriteInteger(file_handlem_iLossSmoothFactor) ||
      !FileWriteInteger(file_handle, (int)m_bPositionEncoder) ||
      !FileWriteDouble(file_handle, (double)m_dLearningRate) ||
      !FileWriteDouble(file_handle, (double)m_adBeta[0]) ||
      !FileWriteDouble(file_handle, (double)m_adBeta[1]) ||
      !FileWriteDouble(file_handle, (double)m_adLambda[0]) ||
      !FileWriteDouble(file_handle, (double)m_adLambda[1]) ||
      !FileWriteInteger(file_handle, (int)m_eLossFunction))
      return false;

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

//--- Сохраняем объект позиционного кодирования при необходимости
   if(m_bPositionEncoder)
     {
      if(!m_cPositionEncoder ||
         !m_cPositionEncoder.Save(file_handle))
         return false;
     }
//--- Вызываем метод сохранения данных динамического массива нейронных слоев
   return m_cLayers.Save(file_handle);
  }

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

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

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

bool CNet::Save(string file_name = NULL)
  {
   if(file_name == NULL || file_name == "")
      file_name = defFileName;
//---
   int handle = FileOpen(file_nameFILE_WRITE | FILE_BIN);
//---
   bool result = Save(handle);
   FileClose(handle);
//---
   return result;
  }

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

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

bool CNet::Load(const int file_handle)
  {
   if(file_handle == INVALID_HANDLE)
      return false;

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

//--- Считываем константы
   m_bOpenCL = (bool)FileReadInteger(file_handle);
   m_dNNLoss = FileReadDouble(file_handle);
   m_iLossSmoothFactor = FileReadInteger(file_handle);
   m_bPositionEncoder = (bool)FileReadInteger(file_handle);
   m_dLearningRate = (TYPE)FileReadDouble(file_handle);
   m_adBeta[0] = (TYPE)FileReadDouble(file_handle);
   m_adBeta[1] = (TYPE)FileReadDouble(file_handle);
   m_adLambda[0] = (TYPE)FileReadDouble(file_handle);
   m_adLambda[1] = (TYPE)FileReadDouble(file_handle);
   m_eLossFunction = (ENUM_LOSS_FUNCTIONFileReadInteger(file_handle);

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

//--- Загружаем объект позиционного кодирования
   if(m_bPositionEncoder)
     {
      if(!m_cPositionEncoder)
        {
         m_cPositionEncoder = new CPositionEncoder();
         if(!m_cPositionEncoder)
            return false;
        }
      if(!m_cPositionEncoder.Load(file_handle))
         return false;
     }

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

//--- Инициализируем объект работы с OpenCL
   if(m_bOpenCL)
     {
      if(!InitOpenCL())
         m_bOpenCL = false;
     }
   else
      if(!!m_cOpenCL)
        {
         m_cOpenCL.Shutdown();
         delete m_cOpenCL;
        }

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

//--- Инициализируем и загружаем данные динамического массива нейронных слоев
   if(!m_cLayers)
     {
      m_cLayers = new CArrayLayers();
      if(!m_cLayers)
         return false;
     }
   if(m_bOpenCL)
      m_cLayers.SetOpencl(m_cOpenCL);
//---
   return m_cLayers.Load(file_handle);
  }

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

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

class CNet  : public CObject
  {
protected:
   bool               m_bTrainMode;
   CArrayLayers*      m_cLayers;
   CMyOpenCL*         m_cOpenCL;
   bool               m_bOpenCL;
   TYPE               m_dNNLoss;
   int                m_iLossSmoothFactor;
   CPositionEncoder*  m_cPositionEncoder;
   bool               m_bPositionEncoder;
   ENUM_LOSS_FUNCTION m_eLossFunction;
   VECTOR             m_adLambda;
   TYPE               m_dLearningRate;
   VECTOR             m_adBeta;

public:
                      CNet(void);
                     ~CNet(void);
   //--- Методы создания объекта
   bool               Create(CArrayObj *descriptions);
   bool               Create(CArrayObj *descriptionsTYPE learning_rate,
                                                      TYPE beta1TYPE beta2);
   bool               Create(CArrayObj *descriptions
                 ENUM_LOSS_FUNCTION loss_functionTYPE lambda1TYPE lambda2);
   bool               Create(CArrayObj *descriptionsTYPE learning_rate
                             TYPE beta1TYPE beta2,
                 ENUM_LOSS_FUNCTION loss_function, TYPE lambda1TYPE lambda2);

   //--- Организация работы с OpenCL
   void               UseOpenCL(bool value);
   bool               UseOpenCL(void)          const { return(m_bOpenCL);         }
   bool               InitOpenCL(void);
   //--- Методы работы с позиционным кодированием
   void               UsePositionEncoder(bool value);
   bool               UsePositionEncoder(voidconst { return(m_bPositionEncoder);}
   //--- Организация основных алгоритмов работы модели
   bool               FeedForward(const CBufferType *inputs);
   bool               Backpropagation(CBufferType *target);
   bool               UpdateWeights(uint batch_size = 1);
   bool               GetResults(CBufferType *&result);
   void               SetLearningRates(TYPE learning_rateTYPE beta1 = defBeta1,
                                                           TYPE beta2 = defBeta2);
   //--- Методы функции потерь
   bool               LossFunction(ENUM_LOSS_FUNCTION loss_function,
                          TYPE lambda1 = defLambdaL1TYPE lambda2 = defLambdaL2);
   ENUM_LOSS_FUNCTION LossFunction(void)       const { return(m_eLossFunction);    }
   ENUM_LOSS_FUNCTION LossFunction(TYPE &lambda1TYPE &lambda2);

   TYPE               GetRecentAverageLoss(voidconst { return(m_dNNLoss);        }
   void               LossSmoothFactor(int value)    { m_iLossSmoothFactor = value;}
   int                LossSmoothFactor(void)   const { return(m_iLossSmoothFactor);}
   //--- Управление режимом работы модели
   bool               TrainMode(void)          const { return m_bTrainMode;        }
   void               TrainMode(bool mode);
   //--- Методы работы с файлами
   virtual bool       Save(string file_name = NULL);
   virtual bool       Save(const int file_handle);
   virtual bool       Load(string file_name = NULLbool common = false);
   virtual bool       Load(const int file_handle);
   //--- Метод идентификации объекта
   virtual int        Type(void)               const { return(defNeuronNet);      }
   //--- Получение указателей на внутренние объекты
   virtual CBufferTypeGetGradient(uint layer)     const;
   virtual CBufferTypeGetWeights(uint layer)      const;
   virtual CBufferTypeGetDeltaWeights(uint layerconst;
  };