5.Метод прямого прохода Self-Attention

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

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

Вернемся к нашему методу CNeuronAttention::FeedForward. В параметрах, как и метод родительского класса, он получает указатель на объект предыдущего слоя. Это соответствует принципам наследования и переопределения методов. Так как мы получаем указатель на объект, то и в начале метода мы обычно организуем блок проверки действительности полученного указателя. Однако в данном случае мы его опустим. Дело в том, что использование статических внутренних объектов позволяет нам отказаться от проверки их указателей. Что касается указателя на предыдущий нейронный слой, то мы его будем использовать для прямого прохода внутренних сверточных нейронных слоев m_cQuerys, m_cKeys и m_cValues. В них уже реализованы аналогичные контроли — нам нет необходимости дублировать их.

В соответствии с алгоритмом работы Self-Attention нам необходимо определить векторы Query, Key и Value для каждого элемента последовательности. Как вы помните, именно для выполнения этого функционала мы создавали три первых сверточных слоя. Поэтому для решения этой задачи нам достаточно вызвать методы прямого прохода FeedForward для названных внутренних слоев. При каждом вызове в параметрах передадим указатель на предыдущий нейронный слой, полученный в параметрах нашего метода прямого прохода CNeuronAttention::FeedForward.

   if(!m_cQuerys.FeedForward(prevLayer))
      return false;
   if(!m_cKeys.FeedForward(prevLayer))
      return false;
   if(!m_cValues.FeedForward(prevLayer))
      return false;

Далее по алгоритму Self-Attention нам надо определить коэффициенты зависимостей и заполнить матрицу Score. Тут наступает момент вспомнить о нашей парадигме создания классов, способных работать как на CPU, так и с использованием мощностей GPU. Каждый раз, выстраивая новый процесс, мы создаем разветвление алгоритма в зависимости от используемого вычислительного устройства. Этот метод не будет исключением, и мы продолжим работу в начатом ключе. Именно сейчас мы и создадим аналогичное разветвление процесса. Как всегда, сейчас мы рассмотрим создание процесса средствами MQL5. К ветке технологии OpenCL вернемся немного позже.

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

//--- разветвление алгоритма по вычислительному устройству
   MATRIX out;
   if(!m_cOpenCL)
     {
      MATRIX querys = m_cQuerys.GetOutputs().m_mMatrix;
      MATRIX keys = m_cKeys.GetOutputs().m_mMatrix;

После выполнения подготовительной работы нам предстоит «закатить рукава» и выстроить новый процесс. Напомню, что алгоритмом метода Self-Attention предусмотрена построчная нормализация матрицы зависимостей с помощью функции Softmax.

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

Посмотрим на реализацию данного процесса. Вначале мы организуем процесс вычисления коэффициентов зависимости в матрицу Score. Согласно алгоритму Self-Attention, каждый элемент матрицы представляет произведение векторов Query и Key. При этом строка матрицы указывает на положение вектора в матрице Querys, а столбец — в матрице Keys.

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

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

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

Затем мы возьмем построчную сумму значений матрицы и на полученный вектор разделим значения матрицы Scores. Матричные операции MQL5 не позволяют разделить матрицу на вектор. Поэтому мы организуем цикл, в теле которого поочередно разделим каждую строку на сумму ее значений.

      //--- определяем Scores
      MATRIX scores = MathExp(querys.MatMul(keys.Transpose()) / sqrt(m_iKeysSize));
      //--- нормализуем Scores
      VECTOR summs = scores.Sum(1);
      for(int r = 0r < m_iUnitsr++)
         if(!scores.Row(scores.Row(r) / summs[r], r))
            return false;
      m_cScores.m_mMatrix = scores;

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

На данном этапе мы получили посчитанные и нормализованные коэффициенты зависимостей между всеми элементами последовательности. Теперь, согласно алгоритму метода Self-Attention, нам необходимо посчитать взвешенную сумму значений векторов Values в разрезе каждого запроса Query. Для этого нам достаточно умножить матрицу коэффициентов зависимостей на матрицу результатов сверточного слоя m_cValues. Снова именно благодаря транспонированию работы сверточного слоя мы не транспонируем матрицу результатов слоя m_cValues.

      //--- выход блока внимания
      MATRIX values = m_cValues.GetOutputs().m_mMatrix;
      out = scores.MatMul(values);

Произведение матриц даст нам результат механизма Self-Attention. Но мы пойдем немного дальше и выстроим полностью блок Encoder трансформера. Согласно его алгоритму результаты Self-Attention складываются с буфером исходных данных. Полученные значения нормализуются в рамках нейронного слоя. Для нормализации данных используются следующие формулы.

Для осуществления этой операции мы сначала приведем формат матрицы результатов блока Self-Attention в соответствие с форматом матрицы исходных данных и сложим две матрицы. Результат нормализуем в специально выделенном методе NormlizeBuffer.

      //--- суммируем с исходными данными и нормализуем
      if(!out.Reshape(prevLayer.Rows(), prevLayer.Cols()))
         return false;
      m_cAttentionOut.GetOutputs().m_mMatrix = out + 
                                             prevLayer.GetOutputs().m_mMatrix;
      if(!NormlizeBuffer(m_cAttentionOut.GetOutputs(), GetPointer(m_cStd), 0))
         return false;
     }

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

   else // Блок OpenCL
     {
      return false;
     }

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

//--- вызываем методы прямого прохода слоев блока Feed Forward
   if(!m_cFF1.FeedForward(GetPointer(m_cAttentionOut)))
      return false;
   if(!m_cFF2.FeedForward(GetPointer(m_cFF1)))
      return false;

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

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

//--- суммируем с выходом внимания и нормализуем
   if(!m_cOutputs.SumArray(m_cAttentionOut.GetOutputs()))
      return false;
//--- нормализуем
   if(!NormlizeBuffer(m_cOutputsGetPointer(m_cStd), 1))
      return false;
//---
   return true;
  }

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

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

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

class CNeuronConv    :  public CNeuronProof
  {
protected:
   bool              m_bTransposedOutput;
 
public:
   bool              SetTransposedOutput(const bool value);
   ....
  }

Для управления значением флага создадим метод SetTransposedOutput. Функционал метода довольно прост. Мы просто изменяем размеры матриц результатов и градиентов ошибки.

bool CNeuronConv::SetTransposedOutput(const bool value)
  {
   m_bTransposedOutput = value;
   if(value)
     {
      if(!m_cOutputs.BufferInit(m_iNeuronsm_iWindowOut0))
         return false;
      if(!m_cGradients.BufferInit(m_iNeuronsm_iWindowOut0))
         return false;
     }
   else
     {
      if(!m_cOutputs.BufferInit(m_iWindowOutm_iNeurons0))
         return false;
      if(!m_cGradients.BufferInit(m_iWindowOutm_iNeurons0))
         return false;
     }
//---
   return true;
  }

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

bool CNeuronConv::FeedForward(CNeuronBase *prevLayer)
  {
//--- блок контролей
    ....
//--- разветвление алгоритма в зависимости от устройства выполнения операций
   if(!m_cOpenCL)
     {
    ....
      //--- Вычисление взвешенной суммы элементов входного окна
      if(m_bTransposedOutput)
         m = m.MatMul(m_cWeights.m_mMatrix.Transpose());
      else
         m = m_cWeights.m_mMatrix.MatMul(m.Transpose());
      m_cOutputs.m_mMatrix = m;
     }
   else  // Блок OpenCL
     {
    ....
     }
//---
   if(!m_cActivation.Activation(m_cOutputs))
      return false;
//---
   return true;
  }

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

bool CNeuronConv::CalcHiddenGradient(CNeuronBase *prevLayer)
  {
//--- блок контролей
    ....
//--- корректировка градиентов ошибки на производную функции активации
    ....
//--- разветвление алгоритма в зависимости от устройства выполнения операций
   CBufferTypeinput_gradient = prevLayer.GetGradients();
   if(!m_cOpenCL)
     {
      MATRIX g = m_cGradients.m_mMatrix;
      if(m_bTransposedOutput)
        {
         if(!g.Reshape(m_iNeuronsm_iWindowOut))
            return false;
        }
      else
        {
         if(!g.Reshape(m_iWindowOutm_iNeurons))
            return false;
         g = g.Transpose();
        }
    ....
     }
   else  // Блок OpenCL
     {
    ....
     }
//---
   return true;
  }

А затем — и в метод распределение градиента до уровня матрицы весов CNeuronConv::CalcDeltaWeights.

bool CNeuronConv::CalcDeltaWeights(CNeuronBase *prevLayer)
  {
//--- блок контролей
    ....
//--- разветвление алгоритма в зависимости от устройства выполнения операций
   CBufferType *input_data = prevLayer.GetOutputs();
   if(!m_cOpenCL)
     {
    ....
      //---
      MATRIX g = m_cGradients.m_mMatrix;
      if(m_bTransposedOutput)
        {
         if(!g.Reshape(m_iNeuronsm_iWindowOut))
            return false;
         g = g.Transpose();
        }
      else
        {
         if(!g.Reshape(m_iWindowOutm_iNeurons))
            return false;
        }
      m_cDeltaWeights.m_mMatrix += g.MatMul(inp);
     }
   else  // Блок OpenCL
     {
    ....
     }
//---
   return true;
  }

Как видите, изменения не такие глобальные, но они дают нам больше свободы настроек.