Построение средствами MQL5

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

Начнем с создания нашего базового класса нейронного слоя CNeuronBase с наследованием от класса CObject. Затем определим внутренние переменные класса:

  • m_cOpenCL — указатель на экземпляр класса работы с технологией OpenCL;
  • m_cActivation — указатель на объект функций активаций;
  • m_eOptimization — тип метода оптимизации нейронов при обучении;
  • m_cOutputs — массив значений на выходе нейронов;
  • m_cWeights — массив весовых коэффициентов;
  • m_cDeltaWeights — массив для накопления невыполненных обновлений весовых коэффициентов (суммарный градиент ошибки для каждого весового коэффициента после последнего обновления);
  • m_cGradients — градиент ошибки на выходе нейронного слоя в результате последней итерации обратного прохода;
  • m_cMomenum — в отличие от остальных переменных это будет массив из двух элементов для записи указателей на массивы накопления моментов.

Для облегчения доступа к переменным из классов-наследников все переменные будут объявлены в блоке protected.

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

CNeuronBase::CNeuronBase(void)   : m_eOptimization(Adam)
  {
   m_cOpenCL = NULL;
   m_cActivation = new CActivationSwish();
   m_cOutputs = new CBufferType();
   m_cWeights = new CBufferType();
   m_cDeltaWeights = new CBufferType();
   m_cGradients = new CBufferType();
   m_cMomenum[0] = new CBufferType();
   m_cMomenum[1] = new CBufferType();
  }

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

CNeuronBase::~CNeuronBase(void)
  {
   if(!!m_cActivation)
      delete m_cActivation;
   if(!!m_cOutputs)
      delete m_cOutputs;
   if(!!m_cWeights)
      delete m_cWeights;
   if(!!m_cDeltaWeights)
      delete m_cDeltaWeights;
   if(!!m_cGradients)
      delete m_cGradients;
   if(!!m_cMomenum[0])
      delete m_cMomenum[0];
   if(!!m_cMomenum[1])
      delete m_cMomenum[1];
  }

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

Начинается метод с блока проверки входящих параметров. Прежде всего проверяем действительность указателя на объект. Затем проверяем тип создаваемого слоя и количество нейронов в слое: в каждом слое должен быть хотя бы один нейрон, ведь с логической точки зрения построения нейронной сети слой без нейронов блокирует прохождение сигнала и парализует всю сеть. Обратите внимание, при проверке типа создаваемого слоя мы используем виртуальный метод Type, а не возвращаемую им константу defNeuronBase. Это очень важный момент для будущего наследования класса. Дело в том, что при использовании константы вызов такого метода для классов-наследников всегда бы возвращал false при попытке создать слой отличный от базового. Использование виртуального метода позволяет нам получить константу-идентификатор конечного класса-наследника, а проверка даст истинный результат сравнения заданного типа нейронного слоя и создаваемого объекта.

bool CNeuronBase::Init(const CLayerDescription *desc)
  {
//--- блок контроля исходных данных
   if(!desc || desc.type != Type() || desc.count <= 0)
      return false;

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

//--- создание буфера результатов
   if(!m_cOutputs)
      if(!(m_cOutputs = new CBufferType()))
         return false;
   if(!m_cOutputs.BufferInit(1desc.count0))
      return false;
//--- создание буфера градиентов ошибки
   if(!m_cGradients)
      if(!(m_cGradients = new CBufferType()))
         return false;
   if(!m_cGradients.BufferInit(1desc.count0))
      return false;

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

//--- удаление не используемых объектов для слоя исходных данных
   if(desc.window <= 0)
     {
      if(m_cActivation)
         delete m_cActivation;
      if(m_cWeights)
         delete m_cWeights;
      if(m_cDeltaWeights)
         delete m_cDeltaWeights;
      if(m_cMomenum[0])
         delete m_cMomenum[0];
      if(m_cMomenum[1])
         delete m_cMomenum[1];
      if(m_cOpenCL)
         if(!m_cOutputs.BufferCreate(m_cOpenCL))
            return false;
      m_eOptimization = desc.optimization;
      return true;
     }

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

//--- инициализация объекта функции активации
   VECTOR ar_temp = desc.activation_params;
   if(!SetActivation(desc.activationar_temp))
      return false;

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

//--- инициализация объекта матрицы весов
   if(!m_cWeights)
      if(!(m_cWeights = new CBufferType()))
         return false;
   if(!m_cWeights.BufferInit(desc.countdesc.window + 10))
      return false;
   double weights[];
   double sigma = (desc.activation == AF_LRELU ?
                  2.0 / (double)(MathPow(1 + desc.activation_params[0], 2)
                                                           * desc.window) :
                  1.0 / (double)desc.window);
   if(!MathRandomNormal(0MathSqrt(sigma), m_cWeights.Total(), weights))
      return false;
   for(uint i = 0i < m_cWeights.Total(); i++)
      if(!m_cWeights.m_mMatrix.Flat(i, (TYPE)weights[i]))
         return false;

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

//--- инициализация объекта накопления градиентов на уровне матрицы весов
   if(!m_cDeltaWeights)
      if(!(m_cDeltaWeights = new CBufferType()))
         return false;
   if(!m_cDeltaWeights.BufferInit(desc.countdesc.window + 10))
      return false;
//--- инициализация объектов моментов
   switch(desc.optimization)
     {
      case None:
      case SGD:
         for(int i = 0i < 2i++)
            if(m_cMomenum[i])
               delete m_cMomenum[i];
         break;

      case MOMENTUM:
      case AdaGrad:
      case RMSProp:
         if(!m_cMomenum[0])
            if(!(m_cMomenum[0] = new CBufferType()))
               return false;
         if(!m_cMomenum[0].BufferInit(desc.countdesc.window + 10))
            return false;
         if(m_cMomenum[1])
            delete m_cMomenum[1];
         break;

      case AdaDelta:
      case Adam:
         for(int i = 0i < 2i++)
           {
            if(!m_cMomenum[i])
               if(!(m_cMomenum[i] = new CBufferType()))
                  return(false);
            if(!m_cMomenum[i].BufferInit(desc.countdesc.window + 10))
               return false;
           }
         break;

      default:
         return false;
         break;
     }
//--- сохранение метода оптимизации параметров
   m_eOptimization = desc.optimization;
   return true;
  }

В заключение метода сохраним заданный способ оптимизации весовых коэффициентов.

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

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

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

bool CNeuronBase::SetOpenCL(CMyOpenCL *opencl)
  {
   if(!opencl)
     {
      if(m_cOutputs)
         m_cOutputs.BufferFree();
      if(m_cGradients)
         m_cGradients.BufferFree();
      if(m_cWeights)
         m_cWeights.BufferFree();
      if(m_cDeltaWeights)
         m_cDeltaWeights.BufferFree();
      for(int i = 0i < 2i++)
        {
         if(m_cMomenum[i])
            m_cMomenum[i].BufferFree();
        }
      if(m_cActivation)
         m_cActivation.SetOpenCL(m_cOpenCLRows(), Cols());
      m_cOpenCL = opencl;
      return true;
     }

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

   if(m_cOpenCL)
      delete m_cOpenCL;
   m_cOpenCL = opencl;
   if(m_cOutputs)
      m_cOutputs.BufferCreate(opencl);
   if(m_cGradients)
      m_cGradients.BufferCreate(opencl);
   if(m_cWeights)
      m_cWeights.BufferCreate(opencl);
   if(m_cDeltaWeights)
      m_cDeltaWeights.BufferCreate(opencl);
   for(int i = 0i < 2i++)
     {
      if(m_cMomenum[i])
         m_cMomenum[i].BufferCreate(opencl);
     }

   if(m_cActivation)
      m_cActivation.SetOpenCL(m_cOpenCLRows(), Cols());
//---
   return(!!m_cOpenCL);
  }

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

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

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

bool CNeuronBase::SetActivation(ENUM_ACTIVATION_FUNCTION functionVECTOR &params)
  {
   if(m_cActivation)
      delete m_cActivation;

   switch(function)
     {
      case AF_LINEAR:
         if(!(m_cActivation = new CActivationLine()))
            return false;
         break;

      case AF_SIGMOID:
         if(!(m_cActivation = new CActivationSigmoid()))
            return false;
         break;

      case AF_LRELU:
         if(!(m_cActivation = new CActivationLReLU()))
            return false;
         break;

      case AF_TANH:
         if(!(m_cActivation = new CActivationTANH()))
            return false;
         break;

      case AF_SOFTMAX:
         if(!(m_cActivation = new CActivationSoftMAX()))
            return false;
         break;

      case AF_SWISH:
         if(!(m_cActivation = new CActivationSwish()))
            return false;
         break;

      default:
         if(!(m_cActivation = new CActivation()))
            return false;
         break;
     }

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

   if(!m_cActivation.Init(params[0], params[1]))
      return false;
   m_cActivation.SetOpenCL(m_cOpenCLm_cOutputs.Rows(), m_cOutputs.Cols());
   return true;
  }

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

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

bool CNeuronBase::FeedForward(CNeuronBase * prevLayer)
  {
//--- блок контролей
   if(!prevLayer || !m_cOutputs || !m_cWeights ||
      !prevLayer.GetOutputs() || !m_cActivation)
      return false;
   CBufferTypeinput_data = prevLayer.GetOutputs();

Затем проверим указатель на объект работы с OpenCL. Если указатель действительный — перейдем к блоку использования данной технологии. О нем мы поговорим чуть позже при рассмотрении организации процесса параллельных вычислений. При недействительном указателе на объект или его отсутствии перейдем к блоку вычислений стандартными средствами MQL5. Здесь мы сначала проверим соответствие размеров матриц и переформатируем матрицу исходных данных в вектор, добавив единичный элемент для bias-смещения. Выполним операцию матричного умножения на матрицу весовых коэффициентов. Результат запишем в буфер исходящего потока. Перед выходом из метода не забудем вычислить значения функции активации на выходе нейронного слоя.

//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      if(m_cWeights.Cols() != (input_data.Total() + 1))
         return false;
      //---
      MATRIX m = input_data.m_mMatrix;
      if(!m.Reshape(1input_data.Total() + 1))
         return false;
         m[0m.Cols() - 1] = 1;
         m_cOutputs.m_mMatrix = m.MatMul(m_cWeights.m_mMatrix.Transpose());
        }

   else
     {
      //--- Здесь будет код обращения к OpenCL-программе
      return false;
     }
//---
   return m_cActivation.Activation(m_cOutputs);
  }

За прямым проходом идет обратный проход. Эту процедуру обучения нейронной сети мы разбиваем на составные части и создаем четыре метода:

  • метод расчета градиента ошибки на выходе нейронной сети CalcOutputGradient,
  • метод распространения градиента через скрытый слой CalcHiddenGradient,
  • метод необходимого расчета корректирующих значений для весовых коэффициентов CalcDeltaWeights,
  • метод обновления матрицы весовых коэффициентов UpdateWeights.

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

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

bool CNeuronBase::CalcOutputGradient(CBufferType * target, ENUM_LOSS_FUNCTION loss)
  {
//--- блок контролей
   if(!target || !m_cOutputs || !m_cGradients ||
      target.Total() < m_cOutputs.Total() ||
      m_cGradients.Total() < m_cOutputs.Total())
      return false;

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

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

Но говоря о функции потерь, мы с вами рассматривали и другие варианты, обсуждали их преимущества и недостатки. Но как нам воспользоваться их преимуществами? Ответ тут довольно прост. Нужно рассматривать функцию потерь и обучаемую модель как единую сложную функцию. В этом случае мы должны минимизировать не отклонение на каждом нейроне выходного слоя, а непосредственно значение функции потерь. И так же, как и при проведении градиента ошибки через нейронную сеть, мы определяем производную функции потерь и умножаем ее на отклонение значения функции потерь от нуля. При этом если для MAE и MSE мы можем взять в качестве ошибки только производную функции потерь и пренебречь умножением на значение функции потерь, так как это линейное масштабирование будет компенсировано коэффициентом обучения, то при использовании кросс-энтропии мы вынуждены осуществить умножение на значение функции потерь. Дело в том, что при равенстве целевого и расчетного значений функция потерь даст 0, а ее производная будет равна −1. И если мы производную не умножим на ошибку, то продолжим корректировать параметры модели при отсутствии ошибки.

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

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

//--- разветвление алгоритма в зависимости от устройства выполнения операций
  if(!m_cOpenCL)
    {
    switch(loss)
       {
        case LOSS_MAE:
          m_cGradients.m_mMatrix = target.m_mMatrix - m_cOutputs.m_mMatrix;
          break;
        case LOSS_MSE:
          m_cGradients.m_mMatrix = (target.m_mMatrix - m_cOutputs.m_mMatrix) * 2;
          break;
        case LOSS_CCE:
          m_cGradients.m_mMatrix = target.m_mMatrix /
          (m_cOutputs.m_mMatrix + FLT_MIN) * MathLog(m_cOutputs.m_mMatrix) * (-1);
          break;

        case LOSS_BCE:
          m_cGradients.m_mMatrix = (target.m_mMatrix-m_cOutputs.m_mMatrix) /
              (MathPow(m_cOutputs.m_mMatrix, 2) - m_cOutputs.m_mMatrix + FLT_MIN);
          break;
        default:
          m_cGradients.m_mMatrix = target.m_mMatrix - m_cOutputs.m_mMatrix;
          break;
       }
    }
  else
    return false;
//---
  return true;
 }

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

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

bool CNeuronBase::CalcHiddenGradient(CNeuronBase *prevLayer)
  {
//--- корректировка входящего градиента на производную функции активации
   if(!m_cActivation.Derivative(m_cGradients))
      return false;

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

//--- проверка буферов предыдущего слоя
   if(!prevLayer)
      return false;
   CBufferType *input_data = prevLayer.GetOutputs();
   CBufferType *input_gradient = prevLayer.GetGradients();
   if(!input_data || !input_gradient ||
      input_data.Total() != input_gradient.Total())
      return false;
//--- проверка соответствия размера буфера исходных данных и матрицы весов
   if(!m_cWeights || m_cWeights.Cols() != (input_data.Total() + 1))
      return false;

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

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

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

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

//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      MATRIX grad = m_cGradients.m_mMatrix.MatMul(m_cWeights.m_mMatrix);
      if(!grad.Reshape(input_data.Rows(), input_data.Cols()))
         return false;
      input_gradient.m_mMatrix = grad;
     }
   else
      return false;
//---
   return true;
  }

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

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

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

bool CNeuronBase::CalcDeltaWeights(CNeuronBase *prevLayerbool read);
  {
//--- блок контролей
   if(!prevLayer || !m_cDeltaWeights || !m_cGradients)
      return false;
   CBufferType *Inputs = prevLayer.GetOutputs();
   if(!Inputs)
      return false;

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

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

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

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

//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      MATRIX m = Inputs.m_mMatrix;
      if(!m.Reshape(1Inputs.Total() + 1))
         return false;
      m[0Inputs.Total()] = 1;
      m = m_cGradients.m_mMatrix.Transpose().MatMul(m);
      m_cDeltaWeights.m_mMatrix += m;
     }
   else
      return false;
//---
   return true;
  }

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

bool CNeuronBase::UpdateWeights(int batch_sizeTYPE learningRate,
                                VECTOR &BetaVECTOR &Lambda)
  {
//--- блок контролей
   if(!m_cDeltaWeights || !m_cWeights ||
       m_cWeights.Total() < m_cDeltaWeights.Total() || batch_size <= 0)
      return false;
//---
   bool result = false;
   switch(m_eOptimization)
     {
      case None:
         result = true;
         break;

      case SGD:
         result = SGDUpdate(batch_sizelearningRateLambda);
         break;
      case MOMENTUM:
         result = MomentumUpdate(batch_sizelearningRateBetaLambda);
         break;
      case AdaGrad:
         result = AdaGradUpdate(batch_sizelearningRateLambda);
         break;
      case RMSProp:
         result = RMSPropUpdate(batch_sizelearningRateBetaLambda);
         break;
      case AdaDelta:
         result = AdaDeltaUpdate(batch_sizeBetaLambda);
         break;
      case Adam:
         result = AdamUpdate(batch_sizelearningRateBetaLambda);
         break;
     }
//---
   return result;
  }

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

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

  • m_cOpenCL — указатель на экземпляр класса работы с технологией OpenCL, который отвечает за отдельный функционал, но не содержит дополнительной информации. Не подлежит записи в файл.
  • m_cActivation — указатель на объект функций активаций. Тип функции активации задается пользователем при конструировании нейронной сети. Использование другой функции активации может привести к искажению результатов работы всей сети. Сохраняем.
  • m_eOptimization — тип метода оптимизации нейронов при обучении, который задается пользователем при конструировании нейронной сети. Влияет на процесс обучения. Сохраняем.
  • m_cOutputs — массив значений на выходе нейронов. Количество элементов задается архитектором нейронной сети. Содержимое перезаписывается при каждом прямом проходе. Достаточно сохранить количество нейронов в слое и не сохранять весь массив.
  • m_cWeights — матрица весовых коэффициентов. Значение элементов формируется в процессе обучения нейронной сети. Сохраняем.
  • m_cDeltaWeights — матрица для накопления не выполненных обновлений весовых коэффициентов (суммарный градиент ошибки для каждого весового коэффициента после последнего обновления). Значения накапливаются между обновлениями матрицы весов и обнуляются после корректировки весов. Размер массива равен матрице весовых коэффициентов. Не подлежит записи в файл.
  • m_cGradients — градиент ошибки на выходе нейронного слоя в результате последней итерации обратного прохода. Содержимое перезаписывается при каждом обратном проходе. Размер массива равен буферу выходного сигнала. Не подлежит записи в файл.
  • m_cMomenum — в отличие от остальных переменных, это будет массив из двух элементов для записи указателей на массивы накопления моментов. Использование буферов зависит от метода оптимизации. Содержимое накапливается в процессе обучения нейронной сети. Сохраняем.

После определения объема данных для записи в файл приступим к созданию метода записи в файл Save. Этот виртуальный метод существует во всех классах-наследниках класса CObject. В параметрах метод получает хендл файла для записи.

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

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

bool CNeuronBase::Save(const int file_handle)
  {
//--- блок контролей
   if(file_handle == INVALID_HANDLE)
      return false;
//--- запись данных буфера результатов
   if(!m_cOutputs)
      return false;
   if(FileWriteInteger(file_handleType()) <= 0 ||
      FileWriteInteger(file_handlem_cOutputs.Total()) <= 0)
      return false;

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

//--- проверка и запись флага слоя исходных данных
   if(!m_cActivation || !m_cWeights)
     {
      if(FileWriteInteger(file_handle1) <= 0)
         return false;
      return true;
     }
   if(FileWriteInteger(file_handle0) <= 0)
      return false;

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

   int momentums = 0;
   switch(m_eOptimization)
     {
      case SGD:
         momentums = 0;
         break;
      case MOMENTUM:
      case AdaGrad:
      case RMSProp:
         momentums = 1;
         break;
      case AdaDelta:
      case Adam:
         momentums = 2;
         break;
      default:
         return false;
         break;
     }

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

   for(int i = 0i < momentumsi++)
      if(!m_cMomenum[i])
         return false;

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

//--- сохранение матрицы весовых коэффициентов, моментов и функции активации
   if(FileWriteInteger(file_handle, (int)m_eOptimization) <= 0 ||
      FileWriteInteger(file_handlemomentums) <= 0)
      return false;
   if(!m_cWeights.Save(file_handle) || !m_cActivation.Save(file_handle))
      return false;
   for(int i = 0i < momentumsi++)
      if(!m_cMomenum[i].Save(file_handle))
         return false;
//---
   return true;
  }

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

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

bool CNeuronBase::Load(const int file_handle)
  {
//--- блок контролей
   if(file_handle == INVALID_HANDLE)
      return false;

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

//--- загрузка буфера результатов
   if(!m_cOutputs)
      if(!(m_cOutputs = new CBufferType()))
         return false;
   int outputs = FileReadInteger(file_handle);
   if(!m_cOutputs.BufferInit(1outputs0))
      return false;

И сразу создадим буфер градиентов аналогичного размера.

//--- создание буфера градиентов ошибки
   if(!m_cGradients)
      if(!(m_cGradients = new CBufferType()))
         return false;
   if(!m_cGradients.BufferInit(1outputs0))
      return false;

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

//--- проверка флага слоя исходных данных
   int input_layer = FileReadInteger(file_handle);
   if(input_layer == 1)
     {
      if(m_cActivation)
         delete m_cActivation;
      if(m_cWeights)
         delete m_cWeights;
      if(m_cDeltaWeights)
         delete m_cDeltaWeights;
      if(m_cMomenum[0])
         delete m_cMomenum[0];
      if(m_cMomenum[1])
         delete m_cMomenum[1];
      if(m_cOpenCL)
         if(!m_cOutputs.BufferCreate(m_cOpenCL))
            return false;
      m_eOptimization = None;
      return true;
     }

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

   m_eOptimization = (ENUM_OPTIMIZATION)FileReadInteger(file_handle);
   int momentums = FileReadInteger(file_handle);

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

//--- создание объектов перед загрузкой данных
   if(!m_cWeights)
      if(!(m_cWeights = new CBufferType()))
         return false;
//--- загрузка данный из файла
   if(!m_cWeights.Load(file_handle))
      return false;

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

//--- функция активации
   if(FileReadInteger(file_handle) != defActivation)
      return false;
   ENUM_ACTIVATION_FUNCTION activation = 
                         (ENUM_ACTIVATION_FUNCTION)FileReadInteger(file_handle);
   if(!SetActivation(activation,VECTOR::Zeros(2)))
      return false;
   if(!m_cActivation.Load(file_handle))
      return false;

Аналогичным образом загрузим данные буферов моментов.

//---
   for(int i = 0i < momentumsi++)
     {
      if(!m_cMomenum[i])
         if(!(m_cMomenum[i] = new CBufferType()))
            return false;
      if(!m_cMomenum[i].Load(file_handle))
         return false;
     }

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

Сначала проверим указатель на объект и при необходимости создадим новый. Затем запишем во все элементы буфера 0.

//--- инициализация оставшихся буферов
   if(!m_cDeltaWeights)
      if(!(m_cDeltaWeights = new CBufferType()))
         return false;
   if(!m_cDeltaWeights.BufferInit(m_cWeights.m_mMatrix.Rows(),
                                  m_cWeights.m_mMatrix.Cols(), 0))
      return false;

В заключение метода передадим текущий указатель m_cOpenCL во все внутренние объекты. Здесь мы не ставим проверку на действительность указателя. Напомню, все объекты нейронной сети работают в одном контексте OpenCL, поэтому даже недействительный указатель передаем в объекты.

//--- передача указателя на контекст OpenCL в объекты
   SetOpenCL(m_cOpenCL);
//---
   return true;
  }

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

class CNeuronBase    :  public CObject
  {
protected:
   bool              m_bTrain;
   CMyOpenCL*        m_cOpenCL;
   CActivation*      m_cActivation;
   ENUM_OPTIMIZATION m_eOptimization;
   CBufferType*      m_cOutputs;
   CBufferType*      m_cWeights;
   CBufferType*      m_cDeltaWeights;
   CBufferType*      m_cGradients;
   CBufferType*      m_cMomenum[2];

   //---
   virtual bool      SGDUpdate(int batch_sizeTYPE learningRate,
                                                    VECTOR &Lambda);
   virtual bool      MomentumUpdate(int batch_sizeTYPE learningRate,
                                                    VECTOR &BetaVECTOR &Lambda);
   virtual bool      AdaGradUpdate(int batch_sizeTYPE learningRate,
                                                    VECTOR &Lambda);
   virtual bool      RMSPropUpdate(int batch_sizeTYPE learningRate,
                                                    VECTOR &BetaVECTOR &Lambda);
   virtual bool      AdaDeltaUpdate(int batch_size,
                                                    VECTOR &BetaVECTOR &Lambda);
   virtual bool      AdamUpdate(int batch_sizeTYPE learningRate,
                                                    VECTOR &BetaVECTOR &Lambda);
   virtual bool      SetActivation(ENUM_ACTIVATION_FUNCTION function,
                                                    VECTOR &params);

public:
                     CNeuronBase(void);
                    ~CNeuronBase(void);
   //---
   virtual bool      Init(const CLayerDescription *description);
   virtual bool      SetOpenCL(CMyOpenCL *opencl);
   virtual bool      FeedForward(CNeuronBase *prevLayer);
   virtual bool      CalcOutputGradient(CBufferType *target,
                                                    ENUM_LOSS_FUNCTION loss);
   virtual bool      CalcHiddenGradient(CNeuronBase *prevLayer);
   virtual bool      CalcDeltaWeights(CNeuronBase *prevLayer);
   virtual bool      UpdateWeights(int batch_sizeTYPE learningRate,
                                                    VECTOR &BetaVECTOR &Lambda);
   virtual void      TrainMode(bool flag)         {  m_bTrain = flag;            }
   virtual bool      TrainMode(void)        const {  return m_bTrain;            }
   //---
   CBufferType       *GetOutputs(void)      const {  return(m_cOutputs);         }
   CBufferType       *GetGradients(void)    const {  return(m_cGradients);       }
   CBufferType       *GetWeights(void)      const {  return(m_cWeights);         }
   CBufferType       *GetDeltaWeights(voidconst {  return(m_cDeltaWeights);    }

   virtual bool      SetOutputs(CBufferTypebufferbool delete_prevoius = true);
   //--- methods for working with files
   virtual bool      Save(const int file_handle);
   virtual bool      Load(const int file_handle);
   //--- method of identifying the object
   virtual int       Type(void)             const { return(defNeuronBase);       }
   virtual ulong     Rows(void)             const { return(m_cOutputs.Rows());   }
   virtual ulong     Cols(void)             const { return(m_cOutputs.Cols());   }
   virtual ulong     Total(void)            const { return(m_cOutputs.Total());  }
  };