5.Метод прямого прохода GPT

Мы продолжаем нашу работу по реализации алгоритма GPT, предложенного командой OpenAI. Мы уже создали базовый скелет класса из объектов для реализации алгоритма. Сейчас же мы приступаем непосредственно к его реализации. Да, в классе будет использован уже знакомый нам алгоритм Self-Attention, но с некоторыми особенностями реализации.

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

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

bool CNeuronGPT::FeedForward(CNeuronBase *prevLayer)
  {
//--- проверяем актуальность всех объектов
   if(!prevLayer || !prevLayer.GetOutputs())
      return false;

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

//--- увеличиваем указатель на текущий объект в стеке данных
   m_iCurrentPosition++;
   if(m_iCurrentPosition >= m_iUnits)
      m_iCurrentPosition = 0;

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

   CNeuronBase *prevL = prevLayer;

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

//--- запускаем цикл перебора всех внутренних слоев
   for(int layer = 0layer < m_iLayerslayer++)
     {
      CNeuronBase *Querys = m_cQuerys.At(layer);
      if(!Querys || !Querys.FeedForward(prevL))
         return false;

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

      CNeuronBase *Querys = m_cQuerys.At(layer);
      if(!Querys || !Querys.FeedForward(prevL))
         return false;
      CNeuronBase *Keys = m_cKeys.At(layer);
      if(!Keys)
         return false;
      CNeuronBase *Values = m_cValues.At(layer);
      if(!Values)
         return false;
      //--- инициализируем Scores
      CNeuronBase *Scores = m_cScores.At(layer);
      if(!Scores)
         return false;
      //--- инициализируем AttentionOut
      CNeuronBase *AttentionOut = m_cAttentionOut.At(layer);
      if(!AttentionOut)
         return false;

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

      //--- разветвление алгоритма по вычислительному устройству
      if(!m_cOpenCL)
        {
         MATRIX array[];
         if(!Querys.GetOutputs().m_mMatrix.Vsplit(3array))
            return false;
         if(!Keys.GetOutputs().Row(array[1].Row(0), m_iCurrentPosition))
            return false;
         if(!Values.GetOutputs().Row(array[2].Row(0), m_iCurrentPosition))
            return false;

Как вы помните, в ходе выполнения прямого прохода указанного объекта мы формируем сразу все векторы для тензоров Query, Key и Value для всех голов внимания. На следующем шаге перенесем векторы двух последних тензоров в соответствующие стеки. Для этого разделим буфер результатов слоя Querys на 3 равные части: query, key, value. Скопируем данные в соответствующие буферы данных. При копировании данных воспользуемся переменной m_iCurrentPosition для определения смещения в буферах.

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

         MATRIX out;
         if(!out.Init(m_iHeadsm_iKeysSize))
            return false;
         MATRIX array_keys[], array_values[];
         MATRIX array_querys[];
         MATRIX keys = Keys.GetOutputs().m_mMatrix;
         MATRIX values = Values.GetOutputs().m_mMatrix;

Аналогично построению алгоритма прямого прохода в ранее рассмотренной реализации многоголового внимания мы разделим матрицы данных в соответствии с головами внимания.

         if(!array[0].Vsplit(m_iHeadsarray_querys))
            return false;
         if(!keys.Reshape(m_iUnitsm_iHeads * m_iKeysSize))
            return false;
         if(!keys.Vsplit(m_iHeadsarray_keys))
            return false;
         if(!values.Reshape(m_iUnitsm_iHeads * m_iKeysSize))
            return false;
         if(!values.Vsplit(m_iHeadsarray_values))
            return false;

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

         //--- определяем Scores
         for(int head = 0head < m_iHeadshead++)
           {
            MATRIX score=array_querys[head].MatMul(array_keys[head].Transpose())/
                                                               sqrt(m_iKeysSize);
            //--- нормализуем Scores
            if(!score.Activation(score,AF_SOFTMAX))
               return false;
            if(!Scores.GetOutputs().Row(score.Row(0), head))
               return false;

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

После расчета и нормализации вектора коэффициентов зависимости у нас есть все необходимые данные для расчета значений выхода блока Self-Attention. Умножим нормализованной вектор Score на тензор значений Value. Полученный вектор копируем в локальную матрицу результатов.

         //--- выход блока внимания
            MATRIX o = score.MatMul(array_values[head]);
            if(!out.Row(o.Row(0), head))
               return false;
           }

В результате выполнения всех итераций системы циклов в нашей матрице out будут собраны данные конкатенированного выхода блока Multi-Heads Self-Attention. Их мы переносим в буфер результатов нейронного слоя AttentionOut для последующего использования в нашем алгоритме.

         if(!out.Reshape(1m_iHeads * m_iKeysSize))
            return false;
         AttentionOut.GetOutputs().m_mMatrix = out;
        }
      else // Блок OpenCL
        {
         return false;
        }

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

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

      //--- взвешенный выход всех голов внимания
      CNeuronBase *W0 = m_cW0.At(layer);
      if(!W0 || !W0.FeedForward(AttentionOut))
         return false;

Мы приближаемся к завершению реализации алгоритма блока Multi-Heads Self-Attention. Согласно алгоритму модели GPT, нам необходимо сложить полученный результат с исходными данными и нормализовать результат по формулам.

Вначале вызываем метод суммирования двух буферов CBufferType::SumArray. Затем нормализуем данные с помощью метода CNeuronGPT::NormlizeBuffer. Его алгоритм полностью повторяет одноименный метод класса CNeuronAttention.

      //--- суммируем с исходными данными и нормализуем
      if(!W0.GetOutputs().SumArray(prevL.GetOutputs()))
         return false;
      if(!NormlizeBuffer(W0.GetOutputs(), GetPointer(m_dStd[layer]), 0))
         return false;
 

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

      //--- прямой проход блока Feed Forward
      CNeuronBase *FF1 = m_cFF1.At(layer);
      if(!FF1 || !FF1.FeedForward(W0))
         return false;
      CNeuronBase *FF2 = m_cFF2.At(layer);
      if(!FF2 || !FF2.FeedForward(FF1))
         return false;

В заключение мы складываем полученный результат блока Feed Forward с результатом работы блока Multi-Heads Self-Attention. Полученные значения нормализуем.

      //--- суммируем с выходом внимания и нормализуем
      CBufferType *prev = FF2.GetOutputs();
      if(!prev.SumArray(W0.GetOutputs()))
         return false;
      if(!NormlizeBuffer(prevGetPointer(m_dStd[layer]), 1))
         return false;

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

      prevL = FF2;
     }
//---
   return true;
  }

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

На этом завершаем работу над методом прямого прохода и переходим к организации процесса обратного прохода.