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

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

Приступая к реализации нашего варианта данной модели, давайте вкратце повторим алгоритм:

  1. На вход блока Multi-Head Self-Attention подается тензор исходных данных, где каждый элемент последовательности представлен неким токеном (вектором значений).

Одна последовательность для всех голов (потоков). Далее действия в пунктах 2–5 идентичны для каждой головы внимания.

  1. Для каждого токена рассчитываются три вектора (Query, Key, Value) умножением вектора токена на соответствующую обучаемую матрицу весовых коэффициентов W.
  2. Перемножая векторы Query и Key, определяем попарные зависимости между элементами последовательности. На данном этапе вектор Query каждого элемента последовательности умножается на векторы Key текущего и всех предшествующих элементов последовательности.
  3. Матрица полученных коэффициентов зависимости нормализуется с использованием функции Softmax в разрезе каждого запроса (Query). При этом для последующих элементов последовательности устанавливается нулевой коэффициент внимания.
  4. В результате выполнения пунктов 3 и 4 получаем квадратную матрицу Score размерностью равной количеству элементов в последовательности, где сумма всех элементов в разрезе каждого Query равна единице.
  5. Перемножаем нормализованные коэффициенты внимания на векторы Value соответствующих элементов последовательности, складываем полученные векторы и получаем скорректированное на внимание значение для каждого элемента последовательности.
  6. Далее определяем взвешенный результат внимания. Для этого конкатенированный тензор результатов всех голов внимания умножается на обучаемую матрицу W0.
  7. Полученный тензор складывается с входной последовательностью и нормализуется.
  8. За механизмом Multi-Heads Self-Attention следуют два полносвязных слоя блока Feed Forward. Первый (скрытый) слой содержит в четыре раза больше нейронов, чем входная последовательность с функцией активации ReLU (мы вместо нее использовали функцию Swish). Размерность второго слоя равна размерности входной последовательности, а нейроны не используют функцию активации.
  9. Результат отработки полносвязных слоев суммируем с тензором, подаваемым на вход блока Feed Forward, и нормализуем полученный тензор.

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

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

  • m_iLayers — количество нейронных слоев в блоке;
  • m_iCurrentPosition — номер текущего элемента в последовательности.

class CNeuronGPT    :  public CNeuronBase
  {
protected:
   CArrayLayers      m_cQuerys;
   CArrayLayers      m_cKeys;
   CArrayLayers      m_cValues;
   CArrayLayers      m_cScores;
   CArrayLayers      m_cAttentionOut;
   CArrayLayers      m_cW0;
   CArrayLayers      m_cFF1;
   CArrayLayers      m_cFF2;
   //---
   int               m_iLayers;
   int               m_iWindow;
   int               m_iUnits;
   int               m_iKeysSize;
   int               m_iHeads;
   CBufferType       m_dStd[];
   int               m_iCurrentPosition;
   int               m_iScoreTemp;
 
   virtual bool      NormlizeBuffer(CBufferType *bufferCBufferType *std,
                                                              uint std_shift);
   virtual bool      NormlizeBufferGradient(CBufferType *output,
                     CBufferType *gradient, CBufferType *stduint std_shift);
public:
                     CNeuronGPT(void);
                    ~CNeuronGPT(void);
   //---
   virtual bool      Init(const CLayerDescription *descoverride;
   virtual bool      SetOpenCL(CMyOpenCL *opencloverride;
   virtual bool      FeedForward(CNeuronBase *prevLayeroverride;
   virtual bool      CalcHiddenGradient(CNeuronBase *prevLayeroverride;
   virtual bool      CalcDeltaWeights(CNeuronBase *prevLayerbool readoverride;
   virtual bool      UpdateWeights(int batch_sizeTYPE learningRate,
                                   VECTOR &BetaVECTOR &Lambdaoverride;
   //---
   virtual int       GetUnits(voidconst { return m_iUnits;   }
   virtual int       GetLayers(voidconst { return m_iLayers; }
   //--- методы работы с файлами
   virtual bool      Save(const int file_handleoverride;
   virtual bool      Load(const int file_handleoverride;
   //--- метод идентификации объекта
   virtual int       Type(voidoverride  const { return(defNeuronGPT);  }
  };

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

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

Но обо всем по порядку. Работу над методами класса мы, как обычно, начнем с конструктора класса. В нем мы инициализируем переменные начальными значения. Как и в рассмотренных ранее классах механизмов внимания мы используем статические объекты, которые не требуют создания в конструкторе класса. Деструктор класса остается пустым.

CNeuronGPT::CNeuronGPT(void) :   m_iHeads(8),
                                 m_iWindow(0),
                                 m_iKeysSize(0),
                                 m_iUnits(0),
                                 m_iLayers(0),
                                 m_iCurrentPosition(0)
  {
  }

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

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

bool CNeuronGPT::Init(const CLayerDescription *desc)
  {
//--- проверяем исходные данные
   if(!desc || desc.type != Type() || desc.count <= 0 || desc.window <= 0 ||
      desc.window_out <= 0 || desc.step <= 0 || desc.layers <= 0)
      return false;

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

//--- сохраняем константы
   m_iWindow   = desc.window;
   m_iUnits    = desc.count;
   m_iKeysSize = desc.window_out;
   m_iHeads    = desc.step;
   m_iLayers   = desc.layers;
   if(!ArrayResize(m_dStdm_iLayers))
      return false;
   for(int l = 0l < m_iLayersl++)
      if(!m_dStd[l].BufferInit(121))
         return false;

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

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

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

//--- запускаем цикл для создания объектов внутренних слоев
   for(int layer = 0layer < m_iLayerslayer++)
     {

Операции в теле цикла очень похожи на выполняемые операции в методах инициализации классов с использованием механизма Self-Attention, но все же отличия есть.

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

Далее мы вспоминаем, что в модели используется механизм Multi-Head Self-Attention, поэтому нам предстоит создать из одного вектора исходных данных три вектора (Query, Key, Value) для каждой головы внимания. Сразу напомню еще один момент: при реализации механизма Multi-Head Self-Attention мы использовали конкатенированные векторы. Сейчас мы пошли еще дальше — создаем один тензор не только для всех голов внимания, но и сразу объединяем все три вышеупомянутые сущности (Query, Key, Value). Но так как он будет содержать только один элемент последовательности, то и его размер будет не таким уж и большим. В поле count укажем размер равный трем векторам одного элемента последовательности тензора ключей для каждой головы внимания. Функции активации, как и прежде, у создаваемого слоя не будет. Метод оптимизации параметров возьмем тот, что пользователем указал в описании нейронного слоя из параметров метода.

      temp = new CLayerDescription();
      if(!temp)
         return false;
      temp.type = defNeuronBase;
      temp.window = m_iWindow;
      temp.count = (int)(3 * m_iKeysSize * m_iHeads);
      temp.activation = AF_NONE;
      temp.optimization = desc.optimization;

После создания объекта описания нейронного слоя и указания всех необходимых параметров создадим первый внутренний нейронный слой Querys. Инициализируем с помощью предварительно созданного объекта описания нейронного слоя. Обязательно не забываем контролировать процесс выполнения операций. После успешного выполнения двух первых операций добавляем слой в соответствующую коллекцию.

      //--- инициализируем Querys
      CNeuronBase *Querys = new CNeuronBase();
      if(!Querys)
        {
         delete temp;
         return false;
        }
      if(!Querys.Init(temp))
        {
         delete Querys;
         delete temp;
         return false;
        }
      if(!m_cQuerys.Add(Querys))
        {
         delete Querys;
         delete temp;
         return false;
        }

Несмотря на создание конкатенированного тензора, мы оставили название нейронного слоя Querys, что сохраняет преемственность с созданными ранее классами механизмов внимания. Впрочем, внутренние нейронные слои Keys и Values мы тоже создадим, хотя уже с другими параметрами.

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

      //--- инициализируем Keys
      CNeuronBase *Keys = new CNeuronBase();
      if(!Keys)
        {
         delete temp;
         return false;
        }
      temp.window = 0;
      temp.count = (int)(m_iUnits * m_iKeysSize * m_iHeads);
      if(!Keys.Init(temp))
        {
         delete Keys;
         delete temp;
         return false;
        }
      if(!Keys.GetOutputs().Reshape(m_iUnitsm_iKeysSize * m_iHeads))
         return false;
      if(!m_cKeys.Add(Keys))
        {
         delete Keys;
         delete temp;
         return false;
        }

В остальном алгоритм создания внутреннего нейронного слоя аналогичен созданию слоя Querys:

  • создаем новый экземпляр объекта нейронного слоя,
  • инициализируем нейронный слой,
  • добавляем нейронный слой в соответствующую коллекцию.

      //--- инициализируем Values
      CNeuronBase *Values = new CNeuronBase();
      if(!Values)
        {
         delete temp;
         return false;
        }
      if(!Values.Init(temp))
        {
         delete Values;
         delete temp;
         return false;
        }
      if(!Values.GetOutputs().Reshape(m_iUnitsm_iKeysSize * m_iHeads))
         return false;
      if(!m_cValues.Add(Values))
        {
         delete Values;
         delete temp;
         return false;
        }

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

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

      //--- инициализируем Scores
      CNeuronBase *Scores = new CNeuronBase();
      if(!Scores)
        {
         delete temp;
         return false;
        }
      temp.count = (int)(m_iUnits * m_iHeads);
      if(!Scores.Init(temp))
        {
         delete Scores;
         delete temp;
         return false;
        }
      if(!Scores.GetOutputs().Reshape(m_iHeadsm_iUnits))
         return false;
      if(!m_cScores.Add(Scores))
        {
         delete Scores;
         delete temp;
         return false;
        }

Следующий объект, который мы будем создавать, — это нейронный слой для конкатенированного выхода результатов работы голов внимания AttentionOut. Здесь ситуация аналогична матрице коэффициентов зависимости. Мы уже обсудили причины вырождения матрицы коэффициентов зависимости в вектор, а для получения результата работы головы внимания согласно алгоритму Self-Attention нам необходимо умножить матрицу коэффициентов зависимости на тензор значений Value.

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

      //--- инициализируем AttentionOut
      CNeuronBase *AttentionOut = new CNeuronBase();
      if(!AttentionOut)
        {
         delete temp;
         return false;
        }
      temp.count = (int)(m_iKeysSize * m_iHeads);
      if(!AttentionOut.Init(temp))
        {
         delete AttentionOut;
         delete temp;
         return false;
        }
      if(!AttentionOut.GetOutputs().Reshape(m_iHeadsm_iKeysSize))
         return false;
      if(!m_cAttentionOut.Add(AttentionOut))
        {
         delete AttentionOut;
         delete temp;
         return false;
        }

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

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

      //--- инициализируем W0
      CNeuronBase *W0 = new CNeuronBase();
      if(!W0)
        {
         delete temp;
         return false;
        }

В объект описания нейронного слоя вносим необходимые параметры:

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

И инициализируем нейронный слой с помощью объекта описания нейронного слоя.

      temp.window = temp.count;
      temp.count = m_iWindow;
      temp.activation = AF_NONE;
      if(!W0.Init(temp))
        {
         delete W0;
         delete temp;
         return false;
        }
      if(!m_cW0.Add(W0))
        {
         delete W0;
         delete temp;
         return false;
        }

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

На этом мы завершаем работу по инициализации объектов механизма Multi-Head Self-Attention, и нам остается создать два нейронных слоя блока Feed Forward. Первый нейронный слой содержит на выходе в 4 раза больше нейронов, чем получает тензор на входе, и активируется функцией Swish.

      //--- инициализируем FF1
      CNeuronBase *FF1 = new CNeuronBase();
      if(!FF1)
        {
         delete temp;
         return false;
        }
      temp.window = m_iWindow;
      temp.count = temp.window * 4;
      temp.activation = AF_SWISH;
      temp.activation_params[0] = 1;
      temp.activation_params[1] = 0;
      if(!FF1.Init(temp))
        {
         delete FF1;
         delete temp;
         return false;
        }
      if(!m_cFF1.Add(FF1))
        {
         delete FF1;
         delete temp;
         return false;
        }

Второй нейронный слой блока Feed Forward уже без функции активации. Он возвращает размер тензора до размера исходных данных. Здесь также используем базовый полносвязный нейронный слой. Внесем необходимые правки в объект описания нейронного слоя и инициализируем нейронный слой.

      //--- инициализируем FF2
      CNeuronBase *FF2 = new CNeuronBase();
      if(!FF2)
        {
         delete temp;
         return false;
        }
      temp.window = temp.count;
      temp.count = m_iWindow;
      temp.activation = AF_NONE;
      if(!FF2.Init(temp))
        {
         delete FF2;
         delete temp;
         return false;
        }
      if(!m_cFF2.Add(FF2))
        {
         delete FF2;
         delete temp;
         return false;
        }
      delete temp;
     }

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

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

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

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

//--- для исключения копирования буферов осуществим их подмену
   if(m_cFF2.Total() < m_iLayers)
      return false;
   if(!m_cOutputs)
      delete m_cOutputs;
   CNeuronBase *neuron = m_cFF2.At(m_iLayers - 1);
   if(!neuron)
      return false;
   m_cOutputs = neuron.GetOutputs();
   if(!m_cGradients)
      delete m_cGradients;
   m_cGradients = neuron.GetGradients();

В заключение метода инициализации вызовем метод распределения между объектами класса указателя на контекст OpenCL и выйдем из метода инициализации.

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

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

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

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

bool CNeuronGPT::SetOpenCL(CMyOpenCL *opencl)
  {
   CNeuronBase::SetOpenCL(opencl);
   m_cQuerys.SetOpencl(m_cOpenCL);
   m_cKeys.SetOpencl(m_cOpenCL);
   m_cValues.SetOpencl(m_cOpenCL);
   m_cScores.SetOpencl(m_cOpenCL);
   m_cAttentionOut.SetOpencl(m_cOpenCL);
   m_cW0.SetOpencl(m_cOpenCL);
   m_cFF1.SetOpencl(m_cOpenCL);
   m_cFF2.SetOpencl(m_cOpenCL);
   if(m_cOpenCL)
     {
      uint size = sizeof(TYPE) * m_iUnits * m_iHeads;
      m_iScoreTemp = m_cOpenCL.AddBuffer(sizeCL_MEM_READ_WRITE);
      for(int l = 0l < m_iLayersl++)
         m_dStd[l].BufferCreate(m_cOpenCL);
     }
   else
     {
      for(int l = 0l < m_iLayersl++)
         m_dStd[l].BufferFree();
     }
//---
   return(!!m_cOpenCL);
  }

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