Построение LSTM-блока средствами MQL5

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

Как и ранее, для создания нового типа нейронного слоя мы создадим новый класс CNeuronLSTM. Для сохранения наследственности новый класс создадим на основе нашего базового класса нейронного слоя CNeuronBase.

class CNeuronLSTM    :  public CNeuronBase
  {
public:
                     CNeuronLSTM(void);
                    ~CNeuronLSTM(void);
   //--- method of identifying the object
   virtual int       Type(void)               const { return(defNeuronLSTM); }
  };

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

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

class CNeuronLSTM    :  public CNeuronBase
  {
protected:
   CNeuronBase*       m_cForgetGate;
   CNeuronBase*       m_cInputGate;
   CNeuronBase*       m_cNewContent;
   CNeuronBase*       m_cOutputGate;

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

  • m_cMemorys — состояние памяти;
  • m_cHiddenStates — скрытое состояние;
  • m_cInputs — конкатенированный массив исходных данных и скрытого состояния;
  • m_cForgetGateOuts — состояние врат забвения;
  • m_cInputGateOuts — состояние входных врат;
  • m_cNewContentOuts — новый контент;
  • m_cOutputGateOuts — состояние врат выходного сигнала.

class CNeuronLSTM    :  public CNeuronBase
  {
protected:
   ....   
   CArrayObj*       m_cMemorys;
   CArrayObj*       m_cHiddenStates;
   CArrayObj*       m_cInputs;
   CArrayObj*       m_cForgetGateOuts;
   CArrayObj*       m_cInputGateOuts;
   CArrayObj*       m_cNewContentOuts;
   CArrayObj*       m_cOutputGateOuts;

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

class CNeuronLSTM    :  public CNeuronBase
  {
protected:
   ....   
   int               m_iDepth;

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

class CNeuronLSTM    :  public CNeuronBase
  {
protected:
   ....   
   CBufferDouble*       m_cInputGradient;

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

CNeuronLSTM::CNeuronLSTM(void)   : m_iDepth(2)
  {
   m_cForgetGate = new CNeuronBase();
   m_cInputGate = new CNeuronBase();
   m_cNewContent = new CNeuronBase();
   m_cOutputGate = new CNeuronBase();
   m_cMemorys = new CArrayObj();
   m_cHiddenStates = new CArrayObj();
   m_cInputs = new CArrayObj();
   m_cForgetGateOuts = new CArrayObj();
   m_cInputGateOuts = new CArrayObj();
   m_cNewContentOuts = new CArrayObj();
   m_cOutputGateOuts = new CArrayObj();
   m_cInputGradient = new CBufferType();
  }

Сразу же создаем деструктор класса CNeuronLSTM::~CNeuronLSTM, в котором осуществляется обратная операция — очистка памяти после окончания работы класса. Тут важно проследить за полной очисткой памяти, чтобы ничего не упустить.

CNeuronLSTM::~CNeuronLSTM(void)
  {
   if(m_cForgetGate)
      delete m_cForgetGate;
   if(m_cInputGate)
      delete m_cInputGate;
   if(m_cNewContent)
      delete m_cNewContent;
   if(m_cOutputGate)
      delete m_cOutputGate;
   if(m_cMemorys)
      delete m_cMemorys;
   if(m_cHiddenStates)
      delete m_cHiddenStates;
   if(m_cInputs)
      delete m_cInputs;
   if(m_cForgetGateOuts)
      delete m_cForgetGateOuts;
   if(m_cInputGateOuts)
      delete m_cInputGateOuts;
   if(m_cNewContentOuts)
      delete m_cNewContentOuts;
   if(m_cOutputGateOuts)
      delete m_cOutputGateOuts;
   if(m_cInputGradient)
      delete m_cInputGradient;
  }

 

Инициализация объекта

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

class CNeuronLSTM    :  public CNeuronBase
  {
protected:
   ....   
public:
                     CNeuronLSTM(void);
                    ~CNeuronLSTM(void);
   //---
   virtual bool      Init(const CLayerDescription *descoverride;

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

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

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

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

  • Все внутренние нейронные слои являются полносвязными. Значит, мы создаем объекты базового класса. Поэтому в параметре type укажем тип defNeuronBase.
  • Все они получают на вход один тензор, являющийся объединением вектором исходных данных и скрытого состояния. Размер вектора исходных данных мы получаем в параметрах метода (параметр CLayerDescription.window). Размер вектора скрытого состояния равен размеру буфера результатов текущего слоя. Это значение мы тоже получаем в параметрах метода (параметр CLayerDescription.count). Сумму двух указанных значений запишем в параметр window.
  • Если внимательно посмотреть на схему LSTM-блока, приведенную в предыдущем разделе, то можно заметить: все внутренние потоки информации имеют одинаковый размер. Вектор результатов врат забвения поэлементно умножается на поток памяти. Значит, их размеры равны. Аналогично вектор результатов входных врат поэлементно умножается на результат слоя нового контента. Потом это произведение поэлементно суммируется с потоком памяти. В заключение все атомарно умножается на врата контроля выходных данных. Становится очевидно, что все потоки равны размеру выходного буфера текущего блока. Поэтому в параметр count перенесем значение аналогичного элемента из внешних параметров метода.
  • Функция активации определена архитектурой LSTM блока. Все врата активируются сигмоидой, а слой нового контента — гиперболическим тангенсом. Вместе с функцией активации укажем соответствующие ей параметры.
  • Метод оптимизации перенесем указанный пользователем.

//--- создаем описание для внутренних нейронных слоев
   CLayerDescription *temp = new CLayerDescription();
   if(!temp)
      return false;
   temp.type = defNeuronBase;
   temp.window = desc.window + desc.count;
   temp.count = desc.count;
   temp.activation = AF_SIGMOID;
   temp.activation_params[0] = 1;
   temp.activation_params[1] = 0;
   temp.optimization = desc.optimization;

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

//--- вызываем метод инициализации родительского класса
   CLayerDescription *temp2=new CLayerDescription();
   if(!temp2 || !temp2.Copy(desc))
     return false;
   temp2.window = 0;
   if(!CNeuronBase::Init(temp2))
      return false;
   delete temp2;

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

   if(!InsertBuffer(m_cHiddenStatesm_cOutputsfalse))
      return false;
   m_iDepth = (int)fmax(desc.window_out2);

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

//--- инициализируем ForgetGate
   if(!m_cForgetGate)
     {
      if(!(m_cForgetGate = new CNeuronBase()))
         return false;
     }
   if(!m_cForgetGate.Init(temp))
      return false;
   if(!InsertBuffer(m_cForgetGateOutsm_cForgetGate.GetOutputs(), false))
      return false;

Аналогичные итерации осуществляем и для двух других врат.

//--- инициализируем InputGate
   if(!m_cInputGate)
     {
      if(!(m_cInputGate = new CNeuronBase()))
         return false;
     }
   if(!m_cInputGate.Init(temp))
      return false;
   if(!InsertBuffer(m_cInputGateOutsm_cInputGate.GetOutputs(), false))
      return false;

//--- инициализируем OutputGate
   if(!m_cOutputGate)
     {
      if(!(m_cOutputGate = new CNeuronBase()))
         return false;
     }
   if(!m_cOutputGate.Init(temp))
      return false;
   if(!InsertBuffer(m_cOutputGateOutsm_cOutputGate.GetOutputs(), false))
      return false;

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

//--- инициализируем NewContent
   if(!m_cNewContent)
     {
      if(!(m_cNewContent = new CNeuronBase()))
         return false;
     }
   temp.activation = AF_TANH;
   if(!m_cNewContent.Init(temp))
      return false;
   if(!InsertBuffer(m_cNewContentOutsm_cNewContent.GetOutputs(), false))
      return false;

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

//--- инициализируем буфер InputGradient
   if(!m_cInputGradient)
     {
      if(!(m_cInputGradient = new CBufferType()))
         return false;
     }
   if(!m_cInputGradient.BufferInit(1temp.window0))
      return false;
   delete temp;

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

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

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

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

//--- инициализируем Memory
   CBufferType *buffer =  CreateBuffer(m_cMemorys);
   if(!buffer)
      return false;
   if(!InsertBuffer(m_cMemorysbufferfalse))
     {
      delete buffer;
      return false;
     }

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

Повторим все итерации для буфера скрытого состояния.

//--- инициализируем HiddenStates
   if(!(buffer =  CreateBuffer(m_cHiddenStates)))
      return false;
   if(!InsertBuffer(m_cHiddenStatesbufferfalse))
     {
      delete buffer;
      return false;
     }

Напоследок передадим всем внутренним объектам текущий указатель на объект OpenCL и выйдем из метода.

//---
   SetOpenCL(m_cOpenCL);
//---
   return true;
  }

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

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

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

class CNeuronLSTM    :  public CNeuronBase
  {
protected:
   ....   
   CBufferDouble*     CreateBuffer(CArrayObj *&array);

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

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

CBufferType *CNeuronLSTM::CreateBuffer(CArrayObj *&array)
  {
   if(!array)
     {
      array = new CArrayObj();
      if(!array)
         return NULL;
     }

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

   CBufferType *buffer = new CBufferType();
   if(!buffer)
      return NULL;

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

   if(array.Total() <= 0)
     {
      if(!buffer.BufferInit(m_cOutputs.Rows(), m_cOutputs.Cols(), 0))
        {
         delete buffer;
         return NULL;
        }
     }

   else
     {
      CBufferType *temp = array.At(0);
      if(!temp)
        {
         delete buffer;
         return NULL;
        }
      buffer.m_mMatrix = temp.m_mMatrix;
     }
//---
   if(m_cOpenCL)
     {
      if(!buffer.BufferCreate(m_cOpenCL))
         delete buffer;
     }
//---
   return buffer;
  }

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

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

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

class CNeuronLSTM    :  public CNeuronBase
  {
protected:
   ....   
public:
   ....   
   virtual bool      SetOpenCL(CMyOpenCL *opencloverride;

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

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

bool CNeuronLSTM::SetOpenCL(CMyOpenCL *opencl)
  {
//--- вызов метода родительского класса
   CNeuronBase::SetOpenCL(opencl);
//--- вызываем аналогичный метод для всех внутренних слоев
   m_cForgetGate.SetOpenCL(m_cOpenCL);
   m_cInputGate.SetOpenCL(m_cOpenCL);
   m_cOutputGate.SetOpenCL(m_cOpenCL);
   m_cNewContent.SetOpenCL(m_cOpenCL);
   m_cInputGradient.BufferCreate(m_cOpenCL);
   for(int i = 0i < m_cMemorys.Total(); i++)
     {
      CBufferType *temp = m_cMemorys.At(i);
      temp.BufferCreate(m_cOpenCL);
     }
   for(int i = 0i < m_cHiddenStates.Total(); i++)
     {
      CBufferType *temp = m_cHiddenStates.At(i);
      temp.BufferCreate(m_cOpenCL);
     }
//---
   return(!!m_cOpenCL);
  }

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