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

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

Ну а сейчас приступим к работе. Для реализации нашего слоя Self-Attention мы создадим новый класс CNeuronAttention. Как всегда, наследоваться будем от нашего базового класса нейронного слоя CNeuronBase.

class CNeuronAttention    :  public CNeuronBase
  {
public:
                     CNeuronAttention(void);
                    ~CNeuronAttention(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 *prevLayeroverride;
   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(defNeuronAttention); }
  };

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

class CNeuronAttention    :  public CNeuronBase
  {
protected:
   CNeuronConv       m_cQuerys;
   CNeuronConv       m_cKeys;
   CNeuronConv       m_cValues;
   .....
  };

По этому алгоритму на следующем этапе мы определяем матрицу Score, перемножая матрицы Query и Key. Для записи данных матрицы создадим буфер данных — объект класса CBufferType.

class CNeuronAttention    :  public CNeuronBase
  {
protected:
   .....
   CBufferType       m_cScores;
   .....
  };

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

class CNeuronAttention    :  public CNeuronBase
  {
protected:
   .....
   CNeuronBase       m_cAttentionOut;
   .....
  };

Здесь следует обратить внимание на отличие выхода алгоритма Self-Attention от выхода всего нашего класса CNeuronAttention. Первый получаем после выполнения алгоритма Self-Attention корректировкой значений векторов Value и сохраняем в экземпляр объекта базового нейронного слоя m_cAttentionOut, а второй — после отработки в блоке Feed Forward, его сохраняем в буфере результатов нашего класса.

Поэтому дальше нам нужно организовать блок Feed Forward. Его мы создадим из двух последовательных сверточных слоев. Может показаться странным использование сверточного слоя, в то время как в описании архитектуры решения говорится о полносвязных слоях. Дело в том, что здесь ситуация аналогичная первому пункту алгоритма, когда мы определяли значение векторов Query, Key и Value. Рассматривая блок в рамках одного элемента последовательности, мы видим два полносвязных нейронных слоя. Но стоит посмотреть на всю таймсерию, как можно заметить применение одной матрицы весов поочередно к каждому элементу последовательности. При этом как последовательно идут исходные данные, в той же последовательности ложатся результаты. Разве это не напоминает работу сверточного слоя? Нам лишь надо взять сверточный слой и установить ширину окна исходных данных равной размеру вектора одного элемента последовательности. Шаг окна исходных данных устанавливаем равным ширине окна, а количество используемых фильтров определяется размером полносвязного слоя для одного элемента последовательности.

Таким образом, добавляем два сверточных слоя для организации блока Feed Forward.

class CNeuronAttention    :  public CNeuronBase
  {
protected:
   .....
   CNeuronConv       m_cFF1;
   CNeuronConv       m_cFF2;
   .....
  };

Мы определили объекты, необходимые нам для организации работы механизма Self-Attention в нашем классе. Для полноты картины добавим еще несколько переменных:

  • m_iWindow — ширина окна исходных данных (размер вектора одного элемента последовательности);
  • m_iUnits — количество элементов последовательности;
  • m_iKeysSize — ширина размера вектора результатов для Query и Key;
  • m_dStd — в процессе нормализации слоя мы будем делить значение на стандартное отклонение, значение будем сохранять для определения производной.

С учетом стандартного набора функций для переопределения структура класса будет иметь следующий вид.

class CNeuronAttention    :  public CNeuronBase
  {
protected:
   CNeuronConv       m_cQuerys;
   CNeuronConv       m_cKeys;
   CNeuronConv       m_cValues;
   CBufferType       m_cScores;
   int               m_cScoreGrad;
   int               m_cScoreTemp;
   CNeuronBase       m_cAttentionOut;
   CNeuronConv       m_cFF1;
   CNeuronConv       m_cFF2;
   //---
   int               m_iWindow;
   int               m_iUnits;
   int               m_iKeysSize;
   CBufferType       m_cStd;

public:
                     CNeuronAttention(void);
                    ~CNeuronAttention(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 *prevLayeroverride;
   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(defNeuronAttention); }
  };

В конструкторе класса мы лишь задаем начальные значения переменных.

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

CNeuronAttention::CNeuronAttention(void) :   m_iWindow(1),
                                             m_iUnits(0),
                                             m_iKeysSize(1)
  {
   m_cStd.BufferInit(121);
  }

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

CNeuronAttention::~CNeuronAttention(void)
  {
  }

 

Метод инициализации класса

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

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

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

  • CLayerDescription.window — содержит размер окна исходных данных, вектор исходных данных одного элемента последовательности (в нашем случае описание одного бара);
  • CLayerDescription.count — количество элементов в последовательности (количество анализируемых баров);
  • CLayerDescription.window_out — размер вектора результатов для Query и Key.

   m_iWindow   = desc.window;
   m_iUnits    = desc.count;
   m_iKeysSize = desc.window_out;

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

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

Обратите внимание, что для получения общего количества элементов на выходе нейронного слоя количество элементов в последовательности (количество анализируемых баров) мы умножаем на размер исходного окна (элементов, описывающих 1 бар), а не размер окна результатов. Дело в том, что размер окна результатов мы будем использовать только для тензоров Query и Key. Размер вектора результатов для тензоров Value и второго слоя блока Feed Forward будет равен размеру окна исходных данных. Это сделано для того, чтобы выровнять размерности исходных данных и результатов. Ведь алгоритмом предусмотрено сложение тензоров исходных данных с результатами работы блока Self-Attention, а потом еще и сложение тензоров результатов блока Feed Forward и Self-Attention. Таким образом, в результате сложения тензоров на выходе нашего нейронного слоя последовательность не может быть меньше исходных данных. Да и увеличивать ее нет никакого смысла. Следовательно, мы выравниваем размерности векторов.

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

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

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

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

//--- инициализируем AttentionOut
   temp.type = defNeuronBase;
   temp.activation=AF_NONE;
   if(!m_cAttentionOut.Init(temp))
     {
      delete temp;
      return false;
     }

Далее, чтобы инициализировать наши внутренние нейронные слои, нам необходимо создать для них описание. Заполним ранее созданный экземпляр класса CLayerDescription необходимыми данными. Почти все наши внутренние нейронные слои являются сверточными, в параметре type укажем defNeuronConv. Остальные параметры перенесем без изменений из полученного внешнего описания.

//--- создаем описание для внутренних нейронных слоев
   temp.type = defNeuronConv;
   temp.window = desc.window;
   temp.window_out = m_iKeysSize;
   temp.step = desc.window;
   temp.count = desc.count;

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

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

Обратите внимание, что после инициализации сверточного нейронного слоя мы используем новый метод CNeuronConv::SetTransposedOutput. Причины его появления и функционал мы разберем чуть позже.

По аналогичному алгоритму инициализируем слой ключей Keys.

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

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

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

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

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

  • Количество элементов последовательности — это количество анализируемых баров;
  • Длина вектора одного элемента последовательности (окно входа / выхода) — количество элементов описывающих 1 бар;
  • Общее количество элементов на входе / выходе нейронного слоя — произведение первых двух величин.

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

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

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

Нам остается лишь инициализировать блок Feed Forward. Как уже было сказано, он будет состоять из двух сверточных нейронных слоев. Согласно предложенной авторами архитектуры, в первом нейронном слое тензор результатов в 4 раза превышает исходные данные. Кроме того, в первом нейронном слое авторы использовали функцию активации ReLU. Мы же заменим ее на Swish. Внесем указанные правки в описание нейронного слоя и проведем его инициализацию.

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

Для инициализации второго нейронного слоя блока Feed Forward нам наоборот нужно увеличить размер окна исходных данных и его шага. Размер окна результатов нужно вернуть в соответствие с размером тензора результатов блока внимания. Он же будет соответствовать размеру тензора предыдущего слоя.

Для второго нейронного слоя блока Feed Forward мы возьмем функцию активации, указанную пользователем при инициализации нашего класса.

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

//--- инициализируем 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) || !m_cFF2.SetTransposedOutput(true))
     {
      delete temp;
      return false;
     }
   delete temp;

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

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

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

//--- для исключения копирования буферов осуществим их подмену
   if(m_cOutputs)
      delete m_cOutputs;
   m_cOutputs = m_cFF2.GetOutputs();
   if(m_cGradients)
      delete m_cGradients;
   m_cGradients = m_cFF2.GetGradients();

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

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

//--- передаем указатель на объект работы с OpenCL до всех внутренних объектов
   SetOpenCL(m_cOpenCL);
//---
   return true;
  }

Метод SetOpenCL, вызываемый в конце метода инициализации, призван распределить указатель на объект работы с контекстом OpenCL между всеми внутренними объектами. Это необходимо для обеспечения работы всех объектов в едином пространстве. Данный метод был создан виртуальным в базовом классе нейронного слоя. При необходимости он переопределяется в каждом новом классе.

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

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

bool CNeuronAttention::SetOpenCL(CMyOpenCL *opencl)
  {
   CNeuronBase::SetOpenCL(opencl);
   m_cQuerys.SetOpenCL(m_cOpenCL);
   m_cKeys.SetOpenCL(m_cOpenCL);
   m_cValues.SetOpenCL(m_cOpenCL);
   m_cAttentionOut.SetOpenCL(m_cOpenCL);
   m_cFF1.SetOpenCL(m_cOpenCL);
   m_cFF2.SetOpenCL(m_cOpenCL);
   if(m_cOpenCL)
     {
      m_cScores.BufferCreate(m_cOpenCL);
      ulong size = sizeof(TYPE) * m_cScores.Total();
      m_cScoreGrad = m_cOpenCL.AddBuffer((uint)sizeCL_MEM_READ_WRITE);
      m_cScoreTemp = m_cOpenCL.AddBuffer((uint)sizeCL_MEM_READ_WRITE);
      m_cStd.BufferCreate(m_cOpenCL);
     }
   else
     {
      m_cScores.BufferFree();
      m_cStd.BufferFree();
     }
//---
   return(!!m_cOpenCL);
  }

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

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