Построение Multi-Head Self-Attention средствами MQL5

Приступая к реализации блока многоголового внимания Multi-Head Self-Attention мы можем отметить его сильное сходство с ранее рассмотренным блоком Self-Attention. Это и не удивительно, ведь технология Multi-Head Self-Attention является логическим развитием технологии Self-Attention. Поэтому вполне логично будет при создании нового класса наследоваться не от базового класса нейронного слоя CNeuronBase, а от класса блока внимания CNeuronAttention.

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

  • m_cQuerys — сверточный слой формирования тензора запросов Query;
  • m_cKeys — сверточный слой формирования тензора ключей Key;
  • m_cValues — сверточный слой формирования тензора значений Value;
  • m_cScores — буфер матрицы коэффициентов зависимости;
  • m_cAttentionOut — базовый слой исходных данных для записи результатов работы блока Self-Attention;
  • m_cFF1 и m_cFF2 — сверточные слои блока Feed Forward.

Как мы определились в разделе описания архитектурного решения, все объекты будут использоваться по своему прямому назначению. Мы только увеличим их размер пропорционально количеству голов внимания. Таким образом, для реализации алгоритма Multi-Head Self-Attention нам остается добавить внутренний слой матрицы W0 и переменную для записи количества голов внимания.

class CNeuronMHAttention    :  public CNeuronAttention
  {
protected:
   CNeuronConv       m_cW0;
 
   int               m_iHeads;
 
public:
                     CNeuronMHAttention(void);
                    ~CNeuronMHAttention(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 bool      Save(const int file_handleoverride;
   virtual bool      Load(const int file_handleoverride;
   //--- метод идентификации объекта
   virtual int       Type(voidoverride const { return(defNeuronMHAttention);  }
  };

Касательно методов класса мы будем переопределять ставший уже стандартным набор методов:

  • Init — метод инициализации класса;
  • SetOpenCL — метод указания хендла используемого контекста OpenCL;
  • FeedForward — метод прямого прохода;
  • CalcHiddenGradient — метод распределения градиента ошибка через скрытый слой;
  • CalcDeltaWeights — метод распределения градиента ошибки до уровня матрицы весовых коэффициентов текущего нейронного слоя;
  • UpdateWeights — метод обновления матрицы весов коэффициентов текущего нейронного слоя;
  • Save — метод сохранения данных нейронного слоя в файл;
  • Load — метод загрузки данных нейронного слоя из файла;
  • Type — метод идентификация типа нейронного слоя.

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

CNeuronMHAttention::CNeuronMHAttention(void) :  m_iHeads(8)
  {
  }

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

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

  • Тип создаваемого нейронного слоя в описании конфигурации должен соответствовать типу класса (параметр type).
  • В создаваемом слое должен быть хотя бы один элемент анализируемой последовательности (параметр count).
  • Размер вектора описания одного элемента исходных данных должен быть больше нуля (параметр window).
  • Размер вектора ключа одного элемента последовательности должен быть больше нуля (параметр window_out).
  • Должна быть как минимум одна голова внимания (параметр step).

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

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

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

//--- сохраняем константы
   m_iWindow = desc.window;
   m_iUnits = desc.count;
   m_iKeysSize = desc.window_out;
   m_iHeads = desc.step;

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

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

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

Для исключения создания ненужных объектов укажем размер окна исходных данных равным нулю и отключим функцию активации.

Тип нейронного слоя оставляем тот, что получили в описании от пользователя.

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

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

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

//--- инициализируем AttentionOut
   temp.type = defNeuronBase;
   temp.count = (int)(m_iUnits * m_iKeysSize * m_iHeads);
   if(!m_cAttentionOut.Init(temp))
     {
      delete temp;
      return false;
     }
   if(!m_cAttentionOut.GetOutputs().m_mMatrix.Reshape(m_iUnitsm_iKeysSize * m_iHeads) ||
      !m_cAttentionOut.GetGradients().m_mMatrix.Reshape(m_iUnitsm_iKeysSize * m_iHeads))
      return false;

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

Далее нам предстоит создать внутренние сверточные нейронные слои. Первыми мы будем создавать внутренние нейронные слои для формирования тензоров Query, Key и Value. Все они на вход получают последовательность исходных данных. Поэтому в параметрах window и step мы укажем размер вектора описания одного элемента последовательности исходных данных.

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

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

Функция активации для создаваемых нейронных слоев архитектурой Multi-Head Self-Attention не предусмотрена. Поэтому в параметре activation оставим константу AF_NONE.

Метод оптимизации параметров всех нейронных слоев один, данный параметр оставляем без изменений.

//--- создаем описание для внутренних нейронных слоев
   if(!temp)
      return false;
   temp.type = defNeuronConv;
   temp.window = m_iWindow;
   temp.window_out = (int)(m_iKeysSize * m_iHeads);
   temp.step = m_iWindow;
   temp.count = m_iUnits;

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

//--- инициализируем Querys
   if(!m_cQuerys.Init(temp))
     {
      delete temp;
      return false;
     }
   m_cQuerys.SetTransposedOutput(true);

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

Аналогично инициализируем объекты сверточных нейронных слоев для создания тензоров Key и Value.

//--- инициализируем Keys
   if(!m_cKeys.Init(temp))
     {
      delete temp;
      return false;
     }
   m_cKeys.SetTransposedOutput(true);

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

//--- инициализируем Values
   if(!m_cValues.Init(temp))
     {
      delete temp;
      return false;
     }
   m_cValues.SetTransposedOutput(true);

После инициализации первой группы внутренних сверточных слоев, следуя по алгоритму механизма Multi-Head Self-Attention, мы инициализируем буфер матрицы коэффициентов зависимости m_cScores. Заполняем его нулевыми значениями, указав требуемый размер буфера. И опять проведем параллель с классом CNeuronAttention. Если ранее мы создавали квадратную матрицу со стороной равной количеству элементов последовательности, то теперь нам надо столько таких матриц, сколько голов внимания. В то же время мы с вами договорились использовать конкатенированную матрицу. Поэтому увеличим объем буфера пропорционально количеству используемых голов внимания. К сожалению, MQL5 не поддерживает трехмерные матрицы. В рамках двумерной матрицы мы будем использовать строки для распределения буфера по головам внимания.

//--- инициализируем Scores
   if(!m_cScores.BufferInit(m_iHeadsm_iUnits * m_iUnits))
     {
      delete temp;
      return false;
     }

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

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

Размер окна исходных данных определяем как произведение размера вектора описания одного элемента последовательности в тензоре Values на количество голов внимания. Напомню, что в данной реализации мы изменили размер указанного вектора до аналогичного в тензоре ключей Key. Таким образом, размер окна исходных данных определяется как произведение размера вектора ключей одного элемента последовательности на количество голов внимания (m_iKeysSize * m_iHeads).

Размер шага окна исходных данных мы приравняем к размеру самого окна.

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

Алгоритмом Multi-Head Self-Attention функция активации для данной матрицы не предусмотрена. Поэтому, в соответствующем поле оставим константу AF_NONE.

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

//--- инициализируем W0
   temp.window = (int)(m_iKeysSize * m_iHeads);
   temp.step = temp.window;
   temp.window_out = m_iWindow;
   if(!m_cW0.Init(temp))
     {
      delete temp;
      return false;
     }
   m_cW0.SetTransposedOutput(true);

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

В заключение блока инициализации сверточного слоя m_cW0 установим флаг транспонирования тензора результатов.

На этом завершается работа по инициализации объектов блока Multi-Head Self-Attention. Далее переходим к работе над блоком Feed Forward. Функционал и архитектура данного блока полностью перенесены из класса CNeuronAttention. Но так как нам пришлось полностью переопределять метод инициализации класса, то повторим действия по инициализации внутренних слоев m_cFF1 и m_cFF2.

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

  • Размер окна исходных данных (window) — равен размеру вектора описания одного элемента последовательности тензора исходных данных, подаваемого на вход нашего блока Multi-Head Self-Attention. Данный параметр мы получаем от внешней программы и сохраняем в переменной m_iWindow. Следовательно, можем передать в параметр значение указанной переменной.
  • Размер шага окна исходных данных (step) мы приравняем к размеру самого окна исходных данных.
  • Количество используемых фильтров (window_out) — согласно архитектуре трансформера, предложенной авторами, размер выхода первого слоя блока Feed Forward в четыре раза превышает размер исходных данных. Воспользуемся этим коэффициентом. Но при реализации своих практических задач вы всегда можете изменить данный коэффициент или даже добавить его в описание конфигурации создаваемого нейронного слоя и провести практические тесты для выбора наиболее подходящего коэффициента для решения ваших конкретных задач.
  • Функция активации (activation) — для данного слоя авторы предлагают использовать ReLU в качестве функции активации. Мы же заменили ее на близкую функцию Swish. График данной функции очень близок к графику функции, предложенной авторами. Но при этом он не содержит изломов и дифференцируем на всем протяжении значений.
  • Параметры оптимизации матрицы весов остаются без изменений.

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

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

И только при успешной инициализации объекта сверточного нейронного слоя установим флаг транспонирования тензора результатов.

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

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

//--- инициализируем FF2
   temp.window = temp.window_out;
   temp.window_out = temp.step;
   temp.step = temp.window;
   temp.activation = desc.activation;
   temp.activation_params = desc.activation_params;
   if(!m_cFF2.Init(temp))
     {
      delete temp;
      return false;
     }
   m_cFF2.SetTransposedOutput(true);
   delete temp;

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

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

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

//--- для исключения копирования буферов осуществим их подмену
   if(!SetOutputs(m_cFF2.GetOutputs()))
      return false;
   if(m_cGradients)
      delete m_cGradients;
   m_cGradients = m_cFF2.GetGradients();
//---
   SetOpenCL(m_cOpenCL);
//---
   return true;
  }

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

На этом мы завершаем работу над методом инициализации класса. Но надо отметить один незакрытый вопрос. В конце метода инициализации мы вызывали метод передачи указателя на контекст OpenCL. Мы еще не переопределили его, и в таком виде будет вызван аналогичный метод родительского класса. Он вполне функционален, но не распространяется на объекты, объявленные в теле этого класса. А среди них всего лишь один объект — сверточный слой m_cW0. Следовательно, и метод будет небольшим.

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

bool CNeuronMHAttention::SetOpenCL(CMyOpenCL *opencl)
  {
//--- вызов метода родительского класса
   CNeuronAttention::SetOpenCL(opencl);
//--- вызываем аналогичный метод для внутреннего слоя
   m_cW0.SetOpenCL(m_cOpenCL);
//---
   return(!!m_cOpenCL);
  }

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

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