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

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

Подвыборочный слой

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

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

  • m_iWindow — размер окна на входе нейронного слоя;
  • m_iStep — размер шага входного окна;
  • m_iNeurons — размер выхода одного фильтра;
  • m_iWindowOut — количество фильтров;
  • m_eActivation — функция активации.

Обратите внимание, что в отличие от базового класса CNeuronBase мы не использовали отдельный класс функции активации CActivation, а ввели новую переменную m_eActivation. Дело в том, что подвыборочный слой не использует функцию активации в ранее рассмотренном виде. Здесь ее функционал немного другой. Обычно результатом работы подвыборочного слоя является максимальное или среднеарифметическое значение анализируемого окна. Поэтому мы реализуем новый функционал внутри методов данного класса и создадим новое перечисление из двух элементов:

  • AF_AVERAGE_POOLING — среднеарифметическое входного окна данных,
  • Af_MAX_POOLING — максимальное значение входного окна данных.

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

//--- функции активации подвыборочного слоя
enum ENUM_PROOF
  {
   AF_MAX_POOLING,
   AF_AVERAGE_POOLING
  };

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

  • CalcOutputGradient — всегда возвращает false, т.к. не предполагается использования слоя в качестве выходного для нейронной сети.
  • CalcDeltaWeights и UpdateWeights — всегда возвращают true. Отсутствие матрицы весов делает данные методы излишними, но для корректной работы всей модели необходим возврат положительного результата от методов.
  • GetWeights и GetDeltaWeights — всегда возвращают NULL. Методы переопределены для предотвращения ошибки доступа к несуществующему объекту.

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

class CNeuronProof    :  public CNeuronBase
  {
protected:
   uint              m_iWindow;             //Размер окна на входе нейронного слоя
   uint              m_iStep;               //Размер шага входного окна
   uint              m_iNeurons;            //Размер выхода одного фильтра
   uint              m_iWindowOut;          //Количество фильтров
   ENUM_PROOF        m_eActivation;         //Функция активации
public:
                     CNeuronProof(void);
                    ~CNeuronProof(void) {};
   //---
   virtual bool      Init(const CLayerDescription *descoverride;
   virtual bool      FeedForward(CNeuronBase *prevLayeroverride;
   virtual bool      CalcOutputGradient(CBufferType *targetoverride;
                                                               { return false;}
   virtual bool      CalcHiddenGradient(CNeuronBase *prevLayeroverride;
   virtual bool      CalcDeltaWeights(CNeuronBase *prevLayer)  { return true; }
   virtual bool      UpdateWeights(int batch_sizeTYPE learningRate,
                                         VECTOR &BetaVECTOR &Lambdaoverride
                                                               { return true; }
   //---
   virtual CBufferType     *GetWeights(void)       const {  return(NULL);     }
   virtual CBufferType     *GetDeltaWeights(void)  const {  return(NULL);     }
   virtual uint      GetNeurons(void)              const {  return m_iNeurons;}
   //--- Методы работы с файлами
   virtual bool      Save(const int file_handleoverride;
   virtual bool      Load(const int file_handleoverride;
   //--- Метод идентификации объекта
   virtual int       Type(voidoverride      const { return(defNeuronProof); }
  };

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

CNeuronProof::CNeuronProof(void) :  m_eActivation(AF_MAX_POOLING),
                                    m_iWindow(2),
                                    m_iStep(1),
                                    m_iWindowOut(1),
                                    m_iNeurons(0)
  {
  }

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

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

bool CNeuronProof::Init(const CLayerDescription *description)
  {
//--- блок контролей
   if(!description || description.type != Type() ||
      description.count <= 0)
      return false;

После успешного прохождения первого контроля сохраним и проверим параметры создаваемого слоя:

  • размер входного окна,
  • шаг входного окна,
  • количество фильтров,
  • количество элементов на выходе одного фильтра.

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

//--- Сохраняем константы
   m_iWindow = description.window;
   m_iStep = description.step;
   m_iWindowOut = description.window_out;
   m_iNeurons = description.count;
   if(m_iWindow <= 0 || m_iStep <= 0 || m_iWindowOut <= 0 || m_iNeurons <= 0)
      return false;

Также проверим указанную функцию активации. Для подвыборочного слоя мы можем использовать только два варианта функции активации AF_AVERAGE_POOLING и AF_MAX_POOLING. В остальных случаях будем выходить из метода с результатом false.

//--- Проверка функции активации
   switch((ENUM_PROOF)description.activation)
     {
      case AF_AVERAGE_POOLING:
      case AF_MAX_POOLING:
         m_eActivation = (ENUM_PROOF)description.activation;
         break;
      default:
         return false;
         break;
     }

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

//--- Инициализируем буфер результатов
   if(!m_cOutputs)
      if(!(m_cOutputs = new CBufferType()))
         return false;
   if(!m_cOutputs.BufferInit(m_iWindowOutm_iNeurons0))
      return false;

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

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

//--- Инициализируем буфер градиентов ошибки
   if(!m_cGradients)
      if(!(m_cGradients = new CBufferType()))
         return false;
   if(!m_cGradients.BufferInit(m_iWindowOutm_iNeurons0))
      return false;

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

//---
   m_eOptimization = None;
//--- Удаляем неиспользуемые объекты
   if(!!m_cActivation)
      delete m_cActivation;
   if(!!m_cWeights)
      delete m_cWeights;
   if(!!m_cDeltaWeights)
      delete m_cDeltaWeights;
   for(int i = 0i < 2i++)
      if(!!m_cMomenum[i])
         delete m_cMomenum[i];
//---
   return true;
  }

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

bool CNeuronProof::FeedForward(CNeuronBase *prevLayer)
  {
//--- Блок контролей
   if(!prevLayer || !m_cOutputs ||
      !prevLayer.GetOutputs())
      return false;
   CBufferType *input_data = prevLayer.GetOutputs();

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

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

//---  Разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      MATRIX inputs = input_data.m_mMatrix;
      if(inputs.Rows() != m_iWindowOut)
        {
         ulong cols = (input_data.Total() + m_iWindowOut - 1) / m_iWindowOut;
         if(!inputs.Reshape(m_iWindowOutcols))
            return false;
        }

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

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

//--- Создаем локальную матрицу для сбора данных одного фильтра
      MATRIX array = MATRIX::Zeros(m_iNeuronsm_iWindow);
      m_cOutputs.m_mMatrix.Fill(0);
//--- Цикл перебора фильтров
      for(uint f = 0f < m_iWindowOutf++)
        {
//--- Цикл перебора элементов буфера результатов
         for(uint o = 0o < m_iNeuronso++)
           {
            uint shift = o * m_iStep;
            for(uint i = 0i < m_iWindowi++)
               array[oi] = ((shift + i) >= inputs.Cols() ? 0 :
                              inputs[fshift + i]);
           }

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

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

//--- Сохраняем текущий результат в соответствии с функцией активации
         switch(m_eActivation)
           {
            case AF_MAX_POOLING:
               if(!m_cOutputs.Row(array.Max(1), f))
                  return false;;
               break;
            case AF_AVERAGE_POOLING:
               if(!m_cOutputs.Row(array.Mean(1), f))
                  return false;
               break;
            default:
               return false;
           }
        }
     }

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

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

   else
     {
//--- Блок многопоточных вычислений будет добавлен в следующей главе
      return false;
     }
//--- Успешное завершение метода
   return true;
  }

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

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

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

bool CNeuronProof::CalcHiddenGradient(CNeuronBase *prevLayer)
  {
//--- Блок контролей
   if(!prevLayer || !m_cOutputs ||
      !m_cGradients || !prevLayer.GetOutputs() ||
      !prevLayer.GetGradients())
      return false;
   CBufferType *input_data = prevLayer.GetOutputs();
   CBufferType *input_gradient = prevLayer.GetGradients();
   if(!input_gradient.BufferInit(input_data.Rows(), input_data.Cols(), 0))
      return false;

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

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

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

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

//---  Разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      MATRIX inputs = input_data.m_mMatrix;
      ulong cols = (input_data.Total() + m_iWindowOut - 1) / m_iWindowOut;
      if(inputs.Rows() != m_iWindowOut)
        {
         if(!inputs.Reshape(m_iWindowOutcols))
            return false;
        }
//--- Создаем локальную матрицу для сбора данных одного фильтра
      MATRIX inputs_grad = MATRIX::Zeros(m_iWindowOutcols);

//--- Цикл перебора фильтров
      for(uint f = 0f < m_iWindowOutf++)
        {
//--- Цикл перебора элементов буфера результатов
         for(uint o = 0o < m_iNeuronso++)
           {
            uint shift = o * m_iStep;
            TYPE out = m_cOutputs.m_mMatrix[fo];
            TYPE gradient = m_cGradients.m_mMatrix[fo];
 //--- Передача градиента в соответствии с функцией активации
            switch(m_eActivation)
              {
               case AF_MAX_POOLING:
                  for(uint i = 0i < m_iWindowi++)
                    {
                     if((shift + i) >= cols)
                        break;
                     if(inputs[fshift + i] == out)
                       {
                        inputs_grad[fshift + i] += gradient;
                        break;
                       }
                    }
                  break;
               case AF_AVERAGE_POOLING:
                  gradient /= (TYPE)m_iWindow;
                  for(uint i = 0i < m_iWindowi++)
                    {
                     if((shift + i) >= cols)
                        break;
                     inputs_grad[fshift + i] += gradient;
                    }
                  break;
               default:
                  return false;
              }
           }
        }
//--- копирование матрицы градиентов в буфер предыдущего нейронного слоя
      if(!inputs_grad.Reshape(input_gradient.Rows(), input_gradient.Cols()))
         return false;
      input_gradient.m_mMatrix = inputs_grad;
     }

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

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

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

   else
     {
//--- Блок многопоточных вычислений будет добавлен в следующей главе
      return false;
     }
//--- Успешное завершение метода
   return true;
  }

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

  • m_iWindow — размер окна на входе нейронного слоя;
  • m_iStep — размер шага входного окна;
  • m_iNeurons — размер выхода одного фильтра;
  • m_iWindowOut — количество фильтров;
  • m_eActivation — функция активации.

bool CNeuronProof::Save(const int file_handle)
  {
//--- Блок контролей
   if(file_handle == INVALID_HANDLE)
      return false;
//--- Сохраняем константы
   if(FileWriteInteger(file_handleType()) <= 0)
      return false;
   if(FileWriteInteger(file_handle, (int)m_iWindow) <= 0)
      return false;
   if(FileWriteInteger(file_handle, (int)m_iStep) <= 0)
      return false;
   if(FileWriteInteger(file_handle, (int)m_iWindowOut) <= 0)
      return false;
   if(FileWriteInteger(file_handle, (int)m_iNeurons) <= 0)
      return false;
   if(FileWriteInteger(file_handle, (int)m_eActivation) <= 0)
      return false;
//--- Успешное завершение метода
   return true;
  }

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

bool CNeuronProof::Load(const int file_handle)
  {
//--- Блок контролей
   if(file_handle == INVALID_HANDLE)
      return false;
//--- Загружаем константы
   m_iWindow = (uint)FileReadInteger(file_handle);
   m_iStep = (uint)FileReadInteger(file_handle);
   m_iWindowOut = (uint)FileReadInteger(file_handle);
   m_iNeurons = (uint)FileReadInteger(file_handle);
   m_eActivation = (ENUM_PROOF)FileReadInteger(file_handle);
//--- Инициализируем буфер результатов
   if(!m_cOutputs)
     {
      m_cOutputs = new CBufferType();
      if(!m_cOutputs)
         return false;
     }
   if(!m_cOutputs.BufferInit(m_iWindowOutm_iNeurons0))
      return false;
//--- Инициализируем буфер градиентов ошибки
   if(!m_cGradients)
     {
      m_cGradients = new CBufferType();
      if(!m_cGradients)
         return false;
     }
   if(!m_cGradients.BufferInit(m_iWindowOutm_iNeurons0))
      return false;
//---
   return true;
  }

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

Сверточный слой

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

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

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

  • UpdateWeights — полностью удовлетворяет алгоритм метода базового класса CNeuronBase, поэтому вызовем его выполнение.
  • GetWeights и GetDeltaWeights — вернем указатели на соответствующие буферы данных.

В результате структура класса примет нижеследующий вид.

class CNeuronConv    :  public CNeuronProof
  {
public:
                     CNeuronConv(void) {};
                    ~CNeuronConv(void) {};
   //---
   virtual bool      Init(const CLayerDescription *descoverride;
   virtual bool      FeedForward(CNeuronBase *prevLayer);
   virtual bool      CalcHiddenGradient(CNeuronBase *prevLayer);
   virtual bool      CalcDeltaWeights(CNeuronBase *prevLayer);
   virtual bool      UpdateWeights(int batch_sizeTYPE learningRate,
                                   VECTOR &BetaVECTOR &Lambda)
     {
      return CNeuronBase::UpdateWeights(batch_sizelearningRate,
                                        BetaLambda);
     }
   //---
   virtual CBufferType*  GetWeights(void)      const { return(m_cWeights);     }
   virtual CBufferType*  GetDeltaWeights(voidconst { return(m_cDeltaWeights);}
   bool              SetTransposedOutput(const bool value);
   //--- методы работы с файлами
   virtual bool      Save(const int file_handle);
   virtual bool      Load(const int file_handle);
   //--- метод идентификации объекта
   virtual int       Type(void)       const { return(defNeuronConv); }
  };

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

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

bool CNeuronConv::Init(const CLayerDescription *desc)
  {
//--- блок контролей
   if(!desc || desc.type != Type() || desc.count <= 0 || desc.window <= 0)
      return false;

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

//--- сохраняем константы
   m_iWindow = desc.window;
   m_iStep = desc.step;
   m_iWindowOut = desc.window_out;
   m_iNeurons = desc.count;
//--- сохраняем метод оптимизации параметров
   m_eOptimization = desc.optimization;

Сначала инициализируем буфер результатов m_cOutputs. Количество строк и столбцов матрицы буфера по аналогии с подвыборочным слоем устанавливаем равным количеству фильтров и количеству элементов в одном фильтре соответственно. Буфер инициализируется нулевыми значениями.

Следом инициализируем нулевыми значениями буфер градиентов ошибки m_cGradients. Его размер устанавливаем равным размеру буфера результатов m_cOutputs.

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

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

//--- инициализируем класс функции активации
   VECTOR params=desc.activation_params;
   if(!SetActivation(desc.activationparams))
      return false;

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

//--- инициализируем буфер матрицы весов
   if(!m_cWeights)
      if(!(m_cWeights = new CBufferType()))
         return false;
   if(!m_cWeights.BufferInit(desc.window_outdesc.window + 1))
      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.window_outdesc.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.window_outdesc.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.window_outdesc.window + 10))
               return false;
           }
         break;
      default:
         return false;
         break;
     }
   return true;
  }

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

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

bool CNeuronConv::FeedForward(CNeuronBase *prevLayer)
  {
//--- блок контролей
   if(!prevLayer || !m_cOutputs || !m_cWeights || !prevLayer.GetOutputs())
      return false;
   CBufferType *input_data = prevLayer.GetOutputs();
   ulong total = input_data.Total();

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

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

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

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

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

//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
      MATRIX m;
      if(m_iWindow == m_iStep && total == (m_iNeurons * m_iWindow))
        {
         m = input_data.m_mMatrix;
         if(!m.Reshape(m_iNeuronsm_iWindow))
            return false;
        }
      else
        {
         if(!m.Init(m_iNeuronsm_iWindow))
            return false;
         for(ulong r = 0r < m_iNeuronsr++)
           {
            ulong shift = r * m_iStep;
            for(ulong c = 0c < m_iWindowc++)
              {
               ulong k = shift + c;
               m[rc] = (k < total ? input_data.At((uint)k) : 0);
              }
           }
        }

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

//--- добавляем bias-столбец
      if(!m.Resize(m.Rows(), m_iWindow + 1) ||
         !m.Col(VECTOR::Ones(m_iNeurons), m_iWindow))
         return false;
//--- Вычисление взвешенной суммы элементов входного окна
      m_cOutputs.m_mMatrix = m_cWeights.m_mMatrix.MatMul(m.Transpose());
     }

В завершение вызовем метод Activation класса функции активации и завершим работу метода.

   else
     {
//--- Блок многопоточных вычислений будет добавлен в следующей главе
      return false;
     }
   if(!m_cActivation.Activation(m_cOutputs))
      return false;
//--- Успешное завершение метода
   return true;
  }

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

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

bool CNeuronConv::UpdateWeights(int batch_sizeTYPE learningRate,
                                   VECTOR &BetaVECTOR &Lambda)
     {
      return CNeuronBase::UpdateWeights(batch_sizelearningRateBetaLambda);
     }

Но вернемся к логической цепочке алгоритма обратного распространения и посмотрим на метод распределения градиента через скрытый слой CNeuronConv::CalcHiddenGradient.

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

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

bool CNeuronConv::CalcHiddenGradient(CNeuronBase *prevLayer)
  {
//--- блок контролей
   if(!prevLayer || !prevLayer.GetOutputs() || !prevLayer.GetGradients() ||
      !m_cGradients || !m_cWeights)
      return false;

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

//--- корректировка градиентов ошибки на производную функции активации
   if(m_cActivation)
     {
      if(!m_cActivation.Derivative(m_cGradients))
         return false;
     }

Далее идет разветвление алгоритма в зависимости от используемого вычислительного устройства. Сейчас мы рассматриваем ветку MQL5.

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

//--- разветвление алгоритма в зависимости от устройства выполнения операций
   CBufferTypeinput_gradient = prevLayer.GetGradients();
   if(!m_cOpenCL)
     {
      MATRIX g = m_cGradients.m_mMatrix;
      if(!g.Reshape(m_iWindowOutm_iNeurons))
         return false;
      g = g.Transpose();
      g = g.MatMul(m_cWeights.m_mMatrix);
      if(!g.Resize(m_iNeuronsm_iWindow))
         return false;

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

      if(m_iWindow == m_iStep && input_gradient.Total() == (m_iNeurons * m_iWindow))
        {
         if(!g.Reshape(input_gradient.Rows(), input_gradient.Cols()))
            return false;
         input_gradient.m_mMatrix = g;
        }
      else
        {
         input_gradient.m_mMatrix.Fill(0);
         ulong total = input_gradient.Total();
         for(ulong r = 0r < m_iNeuronsr++)
           {
            ulong shift = r * m_iStep;
            for(ulong c = 0c < m_iWindowc++)
              {
               ulong k = shift + c;
               if(k >= total)
                  break;
               if(!input_gradient.m_mMatrix.Flat(k,
                                  input_gradient.m_mMatrix.Flat(k) + g[rc]))
                  return false;
              }
           }
        }
     }

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

   else
     {
//--- Блок многопоточных вычислений будет добавлен в следующей главе
      return false;
     }
//--- Успешное завершение метода
   return true;
  }

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

Работа по распределению градиента ошибки по элементам матрицы весов осуществляется в методе CalcDeltaWeights. Данный метод у нас также является виртуальным и переопределяется в каждом классе. В параметрах метод получает указатель на объект предыдущего слоя. И сразу в начале метода проверяем корректность полученного указателя и наличие рабочих буферов данных в текущем и предыдущем нейронных слоях. Для расчета градиента на матрице весов нам потребуется буфер входящего градиента, буфер исходных данных (результатов предыдущего слоя) и буфер для записи полученных результатов (m_cDeltaWeights). Напомню, что нашим алгоритмом предусмотрено распределение градиентов на каждой итерации обратного прохода, а обновление матрицы — по запросу из внешней программы. Поэтому в буфере m_cDeltaWeights мы будем накапливать значение градиента ошибки. А при обновлении разделим накопленное значение на количество совершенных итераций. Тем самым получим среднюю ошибку по каждому весовому коэффициенту.

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

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

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

//--- разветвление алгоритма в зависимости от устройства выполнения операций
   CBufferType *input_data = prevLayer.GetOutputs();
   if(!m_cOpenCL)
     {

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

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

      MATRIX inp;
      uint input_total = input_data.Total();
      if(m_iWindow == m_iStep && input_total == (m_iNeurons * m_iWindow))
        {
         inp = input_data.m_mMatrix;
         if(!inp.Reshape(m_iNeuronsm_iWindow))
            return false;
        }
      else
        {
         if(!inp.Init(m_iNeuronsm_iWindow))
            return false;
         for(ulong r = 0r < m_iNeuronsr++)
           {
            ulong shift = r * m_iStep;
            for(ulong c = 0c < m_iWindowc++)
              {
               ulong k = shift + c;
               inp[rc] = (k < input_total ? input_data.At((uint)k) : 0);
              }
           }
        }
      //--- добавляем bias-столбец
      if(!inp.Resize(inp.Rows(), m_iWindow + 1) ||
         !inp.Col(VECTOR::Ones(m_iNeurons), m_iWindow))
         return false;

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

      MATRIX g = m_cGradients.m_mMatrix;
      if(!g.Reshape(m_iWindowOutm_iNeurons))
         return false;
      m_cDeltaWeights.m_mMatrix += g.MatMul(inp);
     }

Полученный результат прибавим к накопленным ранее градиентам ошибки в матрице m_cDeltaWeights.

   else
     {
//--- Блок многопоточных вычислений будет добавлен в следующей главе
      return false;
     }
//--- Успешное завершение метода
   return true;
  }

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

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

bool CNeuronConv::Save(const int file_handle)
  {
//--- вызов метода родительского класса
   if(!CNeuronBase::Save(file_handle))
      return false;
//--- сохранение значений констант
   if(FileWriteInteger(file_handle, (int)m_iWindow) <= 0)
      return false;
   if(FileWriteInteger(file_handle, (int)m_iStep) <= 0)
      return false;
   if(FileWriteInteger(file_handle, (int)m_iWindowOut) <= 0)
      return false;
   if(FileWriteInteger(file_handle, (int)m_iNeurons) <= 0)
      return false;
   if(FileWriteInteger(file_handle, (int)m_bTransposedOutput) <= 0)
      return false;
//---
   return true;
  }

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

bool CNeuronConv::Load(const int file_handle)
  {
//--- вызов метода родительского класса
   if(!CNeuronBase::Load(file_handle))
      return false;
//--- считывание значений констант
   m_iWindow = (uint)FileReadInteger(file_handle);
   m_iStep = (uint)FileReadInteger(file_handle);
   m_iWindowOut = (uint)FileReadInteger(file_handle);
   m_iNeurons = (uint)FileReadInteger(file_handle);
   m_eActivation = -1;
//---
   if(!m_cOutputs.Reshape(m_iWindowOutm_iNeurons))
      return false;
   if(!m_cGradients.Reshape(m_iWindowOutm_iNeurons))
      return false;
//---
   return true;
  }

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