4.Методы обратного прохода LSTM-блока

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

За процесс обратного прохода в базовом классе нейронного слоя у нас отвечают три метода:

  • CalcHiddenGradient — распределение градиента через скрытый слой;
  • CalcDeltaWeights — распределение градиента до матрицы весовых коэффициентов;
  • UpdateWeights — метод обновления весовых коэффициентов.

class CNeuronLSTM    :  public CNeuronBase
  {
protected:
   ....   
public:
   ....   
   virtual bool      CalcHiddenGradient(CNeuronBase *prevLayer)  override;
   virtual bool      CalcDeltaWeights(CNeuronBase *prevLayer)    override 
                                                          { return true; }
   virtual bool      UpdateWeights(int batch_sizeTYPE learningRate,
                                   VECTOR &BetaVECTOR &Lambdaoverride;

Их нам и предстоит переопределить.

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

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

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

bool CNeuronLSTM::CalcHiddenGradient(CNeuronBase *prevLayer)
  {
//--- проверяем актуальность всех объектов
   if(!prevLayer || !prevLayer.GetGradients() ||
      !m_cGradients || !m_cForgetGate || !m_cForgetGateOuts ||
      !m_cInputGate || !m_cInputGateOuts || !m_cOutputGate ||
      !m_cOutputGateOuts || !m_cNewContent || !m_cNewContentOuts)
      return false;

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

//--- проверяем наличие данных прямого прохода
   int total = (int)fmin(m_cMemorys.Total(), m_cHiddenStates.Total()) - 1;
   if(total <= 0)
      return false;

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

//--- делаем указатели на буферы градиентов и результатов внутренних слоев
   CBufferType *fg_grad = m_cForgetGate.GetGradients();
   if(!fg_grad)
      return false;
   CBufferType *fg_out = m_cForgetGate.GetOutputs();
   if(!fg_out)
      return false;

   CBufferType *ig_grad = m_cInputGate.GetGradients();
   if(!ig_grad)
      return false;
   CBufferType *ig_out = m_cInputGate.GetOutputs();
   if(!ig_out)
      return false;

   CBufferType *og_grad = m_cOutputGate.GetGradients();
   if(!og_grad)
      return false;
   CBufferType *og_out = m_cOutputGate.GetOutputs();
   if(!og_out)
      return false;

   CBufferType *nc_grad = m_cNewContent.GetGradients();
   if(!nc_grad)
      return false;
   CBufferType *nc_out = m_cNewContent.GetOutputs();
   if(!nc_out)
      return false;

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

   uint out_total = m_cOutputs.Total();

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

//--- цикл перебора накопленной истории
   for(int i = 0i < totali++)
     {
      //--- получаем указатели на буферы из стека
      CBufferType *fg = m_cForgetGateOuts.At(i);
      if(!fg)
         return false;
      CBufferType *ig = m_cInputGateOuts.At(i);
      if(!ig)
         return false;
      CBufferType *og = m_cOutputGateOuts.At(i);
      if(!og)
         return false;
      CBufferType *nc = m_cNewContentOuts.At(i);
      if(!nc)
         return false;
      CBufferType *memory = m_cMemorys.At(i + 1);
      if(!memory)
         return false;
      CBufferType *hidden = m_cHiddenStates.At(i);
      if(!hidden)
         return false;
      CNeuronBase *inputs = m_cInputs.At(i);
      if(!inputs)
         return false;

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

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

Узел результатов LSTM-блока

Узел результатов LSTM-блока

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

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

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

      //--- разветвление алгоритма по вычислительному устройству
      if(!m_cOpenCL)
        {
         //--- посчитаем градиент на выходе каждого внутреннего слоя
         MATRIX m = hidden.m_mMatrix / (og.m_mMatrix + 1e-8);
         //--- OutputGate градиент
         MATRIX grad = m_cGradients.m_mMatrix;
         og_grad.m_mMatrix = grad * m;
         //--- градиент памяти
         grad *= og.m_mMatrix;

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

         //--- скорректируем градиент на производную
         grad *= MathPow(m2) * (-1) + 1;

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

Распределение градиента ошибки внутри LSTM блока

Распределение градиента ошибки внутри LSTM блока

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

MQL5-код описанных операций представлен ниже.

         //--- InputGate градиент
         ig_grad.m_mMatrix = grad * nc.m_mMatrix;
         //--- NewContent градиент
         nc_grad.m_mMatrix = grad * ig.m_mMatrix;
         //--- ForgetGates градиент
         fg_grad.m_mMatrix = grad * memory.m_mMatrix;
        }

На этом завершается блок разделения потоков по устройству вычислительных операций, и мы объединяем потоки алгоритма. Мы лишь установим «заглушку» для ответвления OpenCL и пойдем дальше.

      else
        {
         return false;
        }

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

      //--- скопируем соответствующие исторические данные в буфера внутренних слоев
      if(!m_cForgetGate.SetOutputs(fgfalse))
         return false;
      if(!m_cInputGate.SetOutputs(igfalse))
         return false;
      if(!m_cOutputGate.SetOutputs(ogfalse))
         return false;
      if(!m_cNewContent.SetOutputs(ncfalse))
         return false;

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

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

      //--- проведем градиент через внутренние слои
      if(!m_cForgetGate.CalcHiddenGradient(inputs))
         return false;
      if(!m_cInputGradient)
        {
         m_cInputGradient = new CBufferType();
         if(!m_cInputGradient)
            return false;
         m_cInputGradient.m_mMatrix = inputs.GetGradients().m_mMatrix;
         m_cInputGradient.BufferCreate(m_cOpenCL);
        }
      else
        {
         m_cInputGradient.Scaling(0);
         if(!m_cInputGradient.SumArray(inputs.GetGradients()))
            return false;
        }

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

      if(!m_cInputGate.CalcHiddenGradient(inputs))
         return false;
      if(!m_cInputGradient.SumArray(inputs.GetGradients()))
         return false;
      if(!m_cOutputGate.CalcHiddenGradient(inputs))
         return false;
      if(!m_cInputGradient.SumArray(inputs.GetGradients()))
         return false;
      if(!m_cNewContent.CalcHiddenGradient(inputs))
         return false;
      if(!inputs.GetGradients().SumArray(m_cInputGradient))
         return false;

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

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

  • довести градиент ошибки до предыдущего слоя;
  • довести градиент ошибки до матриц весов внутренних нейронных слоев.

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

      //--- спроецируем градиент на матрицы весов внутренних слоев
      if(!m_cForgetGate.CalcDeltaWeights(inputs))
         return false;
      if(!m_cInputGate.CalcDeltaWeights(inputs))
         return false;
      if(!m_cOutputGate.CalcDeltaWeights(inputs))
         return false;
      if(!m_cNewContent.CalcDeltaWeights(inputs))
         return false;

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

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

   //--- если посчитан градиент текущего состояния, то передаем на предыдущий слой
   //--- и запишем градиент скрытого состояния в буфер градиентов для новой итерации
      if(!inputs.GetGradients().Split((i == 0 ? prevLayer.GetGradients() :
                                                inputs.GetGradients()), m_cGradients,
                                                prevLayer.GetOutputs().Total()))
         return false;
     }
//---
   return true;
  }

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

Мы с вами разобрали два наиболее сложных и запутанных метода построения рекуррентного алгоритма LSTM-блока. Оставшаяся часть будет намного проще. К примеру, метод CalcDeltaWeights. Функционал этого метода предусматривает передачу градиента ошибки на уровень матрицы весов. Какой-либо отдельной матрицы весов LSTM-блок не имеет. Все параметры расположены внутри вложенных нейронных слоев, а градиент ошибки до уровня их матриц весов мы уже довели в предыдущем методе. Поэтому метод переписываем пустой заглушкой с положительным результатом.

   virtual bool      CalcDeltaWeights(CNeuronBase *prevLayer) { return true; }

Еще один метод обратного прохода UpdateWeights — метод обновления матрицы весов. Метод также наследуется из базового класса нейронного слоя и переопределяется при необходимости. LSTM-блок в отличие от ранее рассмотренных типов нейронных слоев не имеет единой матрицы весов. Вместо этого используются внутренние нейронные слои со своими матрицами весов. Поэтому мы не можем просто воспользоваться методом родительского класса и вынуждены переопределить его.

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

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

bool CNeuronLSTM::UpdateWeights(int batch_sizeTYPE learningRateVECTOR &Beta,
                                                                   VECTOR &Lambda)
  {
//--- проверяем состояние объектов
   if(!m_cForgetGate || !m_cInputGate || !m_cOutputGate ||
      !m_cNewContent || m_iDepth <= 0)
      return false;

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

   int batch = batch_size * m_iDepth;

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

//--- обновляем матрицы весов внутренних слоев
   if(!m_cForgetGate.UpdateWeights(batchlearningRateBetaLambda))
      return false;
   if(!m_cInputGate.UpdateWeights(batchlearningRateBetaLambda))
      return false;
   if(!m_cOutputGate.UpdateWeights(batchlearningRateBetaLambda))
      return false;
   if(!m_cNewContent.UpdateWeights(batchlearningRateBetaLambda))
      return false;
//---
   return true;
  }

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

На этом мы заканчиваем рассмотрение методов обратного прохода LSTM-блока. Можем двигаться дальше в построении нашей системы.