English 中文 Español Deutsch 日本語 Português
preview
Нейросети — это просто (Часть 37): Разреженное внимание (Sparse Attention)

Нейросети — это просто (Часть 37): Разреженное внимание (Sparse Attention)

MetaTrader 5Интеграция | 5 апреля 2023, 09:04
1 901 7
Dmitriy Gizlyk
Dmitriy Gizlyk

Введение

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

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

1. Разреженное внимание

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

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

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

Вначале вычисляются 3 сущности: Query, Key и Value для каждого элемента последовательности. Для этого производится умножение вектора, описывающего элемент последовательности, на соответствующую матрицу весовых коэффициентов. Затем, умножаем матрицу Query на транспонированную матрицу Key, чтобы получить коэффициенты зависимостей между элементами последовательности. Далее эти коэффициенты нормализуются с помощью функции SoftMax.

Query * Key

Score

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

Out Self-Attention

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

В случае большого количества элементов последовательности это может привести к значительному увеличению времени вычислений и затратам вычислительных ресурсов. Для оптимизации алгоритма и уменьшения количества вычислений на каждом этапе могут быть применены различные методы, включая Sparse attention. Данный метод предложил Rewon Child в статье "Generating Long Sequences with Sparse Transformers", которая была опубликована в апреле 2019 года.

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

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

Одним из преимуществ метода Sparse attention является то, что он позволяет значительно сократить количество вычислений, необходимых для обработки элементов последовательности. Это особенно важно в случае обработки больших последовательностей, где количество вычислений может быть очень большим.

Кроме того, Sparse attention может помочь бороться с проблемой "внимательности на всё" (attention on everything), когда механизм внимания равномерно распределяет внимание на все элементы последовательности, что приводит к неэффективному использованию ресурсов и замедлению работы алгоритма.

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

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

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

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

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

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

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

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

Однако котировки финансовых рынков имеют изменчивую структуру, что не позволяет работать с фиксированными блоками элементов в анализируемой последовательности. В связи с этим, для ускорения процесса обучения модели мы можем использовать эвристику правила Парето "80/20", где мы берем только 20% наиболее значимых элементов из общей последовательности. Определение значимости элементов основывается на коэффициентах зависимости между элементами, которые вычисляются первыми двумя формулами, описанными ранее. Уже после первой итерации, до нормализации данных, можно точно выделить наиболее значимые элементы последовательности и затем исключить остальные элементы из дальнейших операций. Это уменьшает количество операций на этапе нормализации и определения результатов блока Self-Attention.

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

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

2. Реализация средствами MQL5

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

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

В параметрах кернела родительского класса передавались указатели на 2 буфера данных: конкатенированный тензор сущностей Query, Key и Value в качестве исходных данных и буфер для записи результатов операций в виде коэффициентов зависимости. Кроме буферов данных в кернел передавалась размерность внутренних сущностей. К уже указанным параметрам мы добавим коэффициент прореживания sparse. В него мы будем передавать значение в диапазоне от 0 до 1, которое укажет долю отбираемых элементов последовательности с максимальным влиянием на анализируемый элемент.

__kernel void MHSparseAttentionScore(__global float *qkv,    ///<[in] Matrix of Querys, Keys, Values
                                     __global float *score,  ///<[out] Matrix of Scores
                                     int dimension,          ///< Dimension of Key
                                     float sparse            ///< less than 1.0 coefficient of sparse
                                    )
  {
   int q = get_global_id(0);
   int h = get_global_id(1);
   int units = get_global_size(0);
   int heads = get_global_size(1);
//---

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

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

   int shift_q = dimension * (h + 3 * q * heads);
   int shift_s = units * (h + q * heads);
   int active_units = (int)max((float)(units * sparse), min((float)units, 3.0f));
//---
   float koef = sqrt((float)dimension);
   if(koef < 1)
      koef = 1;
   float sum = 0.0f;
   float min_s = 0.0f;
   float max_s = 0.0f;

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

Далее мы организуем цикл, в котором осуществим умножение вектора Query анализируемого элемента последовательности на матрицу Key. В теле данного цикла мы также определим максимальное и минимальное значение полученного вектора.

   for(int k = 0; k < units; k++)
     {
      float result = 0;
      int shift_k = dimension * (h + heads * (3 * k + 1));
      for(int i = 0; i < dimension; i++)
        {
         if((dimension - i) > 4)
           {
            result += dot((float4)(qkv[shift_q + i], qkv[shift_q + i + 1], qkv[shift_q + i + 2], qkv[shift_q + i + 3]),
                          (float4)(qkv[shift_k + i], qkv[shift_k + i + 1], qkv[shift_k + i + 2], qkv[shift_k + i + 3]));
            i += 3;
           }
         else
            result += (qkv[shift_q + i] * qkv[shift_k + i]);
        }
      score[shift_s + k] = result;
      if(k == 0)
         min_s = max_s = result;
      else
        {
         max_s = max(max_s, result);
         min_s = min(min_s, result);
        }
     }

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

   int count = units;
   float temp = max_s;
   while(count > active_units)
     {
      count = 0;
      for(int k = 0; k < units; k++)
        {
         float value = score[shift_s + k];
         if(value < min_s)
            continue;
         count++;
         if(value < temp && value > min_s)
            temp = value;
        }
      if(count > active_units)
         min_s = temp;
     }

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

   if(max_s == 0.0f)
      max_s = 1.0f;
   for(int k = 0; k < units; k++)
     {
      float value = score[shift_s + k];
      if(value < min_s)
        {
         score[shift_s + k] = 0.0f;
         continue;
        }
      value = exp(value / max_s / koef);
      score[shift_s + k] = value;
      sum += value;
     }
   for(int k = 0; (k < units && sum > 1); k++)
     {
      temp = score[shift_s + k];
      if(temp == 0.0f)
         continue;
      score[shift_s + k] = temp / sum;
     }
  }

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

Следующим этапом нам предстоит получить выход блока внимания. Для этого, согласно алгоритму Self-Attention, нам необходимо умножить матрицу нормализованных коэффициентов зависимости Score на матрицу сущностей Value. Эта операция осуществляется в кернеле MHSparseAttentionOut. В нем мы также организуем проверку нулевых коэффициентов зависимости для сокращения количества выполняемых операций.

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

__kernel void MHSparseAttentionOut(__global float *scores, ///<[in] Matrix of Scores
                                   __global float *qkv,    ///<[in] Matrix of Values
                                   __global float *out,    ///<[out] Output tensor
                                   int dimension           ///< Dimension of Value
                                  )
  {
   int u = get_global_id(0);
   int units = get_global_size(0);
   int h = get_global_id(1);
   int heads = get_global_size(1);

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

Далее мы определяем смещения в буферах данных.

   int shift_s = units * (h + heads * u);
   int shift_out = dimension * (h + heads * u);

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

   for(int d = 0; d < dimension; d++)
     {
      float result = 0;
      for(int v = 0; v < units; v ++)
        {
         float cur_score = scores[shift_s + v];
         if(cur_score == 0)
            continue;
         int shift_v = dimension * (h + heads * (3 * v + 2)) + d;
         result += cur_score * qkv[shift_v];
        }
      out[shift_out + d] = result;
     }
  }

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

Обратный проход блока Self-Attention родительского класса был организован в кернеле MHAttentionInsideGradients. Алгоритм построения данного построен таким образом, что позволяет добавить необходимые точки контроля по ходу существующего кернела без создания его дубликата. Предлагаю посмотреть на построенный алгоритм и добавленные в нем точки контроля.

В параметрах кернела мы будем передавать указатели на 5 буферов данных:

  • Конкатенированный тензор сущностей Query, Key и Value (qkv)
  • Конкатенированный тензор для записи градиентов ошибки сущностей Query, Key и Value (qkv_g)
  • Матрицу коэффициентов зависимости (scores)
  • Матрицу для записи градиентов ошибки на уровне матрицы коэффициентов зависимости (scores_g)
  • Тензор градиентов ошибки на уровне выхода блока текущей головы внимания.

__kernel void MHAttentionInsideGradients(__global float *qkv, __global float *qkv_g,
                                         __global float *scores, __global float *scores_g,
                                         __global float *gradient, int dimension)
  {
   int u = get_global_id(0);
   int h = get_global_id(1);
   int units = get_global_size(0);
   int heads = get_global_size(1);
   float koef = sqrt((float)dimension);
   if(koef < 1)
      koef = 1;

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

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

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

//--- Calculating score's gradients
   uint shift_s = units * (h + u * heads);
   for(int v = 0; v < units; v++)
     {
      float s = scores[shift_s + v];
      if(s <= 0)
         continue;
      float sg = 0;
      int shift_v = dimension * (h + heads * (3 * v + 2));
      int shift_g = dimension * (h + heads * v);
      for(int d = 0; d < dimension; d++)
         sg += qkv[shift_v + d] * gradient[shift_g + d];
      scores_g[shift_s + v] = sg * (s < 1 ? s * (1 - s) : 1) / koef;
     }
   barrier(CLK_GLOBAL_MEM_FENCE);

На следующем этапе мы осуществляем распределение градиента ошибки до внутренних сущностей Query, Key и Value. В нем мы сначала определяем смещение в буферах данных. А затем организуем систему циклов для сбора градиентов ошибки в соответствии.

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

//--- Calculating gradients for Query, Key and Value
   uint shift_qg = dimension * (h + 3 * u * heads);
   uint shift_kg = dimension * (h + (3 * u + 1) * heads);
   uint shift_vg = dimension * (h + (3 * u + 2) * heads);
   for(int d = 0; d < dimension; d++)
     {
      float vg = 0;
      float qg = 0;
      float kg = 0;
      for(int l = 0; l < units; l++)
        {
         float sg = scores[shift_s + l];
         if(sg <= 0)
            continue;
         uint shift_q = dimension * (h + 3 * l * heads) + d;
         uint shift_k = dimension * (h + (3 * l + 1) * heads) + d;
         uint shift_g = dimension * (h + heads * l) + d;
         //---
         vg += gradient[shift_g] * sg;
         sg = scores_g[shift_s + l];
         kg += sg * qkv[shift_q];
         qg += sg * qkv[shift_k];
        }
      qkv_g[shift_qg + d] = qg;
      qkv_g[shift_kg + d] = kg;
      qkv_g[shift_vg + d] = vg;
     }
  }

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

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

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

#define def_k_MHSparseAttentionScore    44 ///< Index of the kernel of the multi-heads sparse attention neuron 
                                           //   to calculate score matrix (#MHSparseAttentionScore)
#define def_k_mhas_sparse                3  ///< less than 1.0 coefficient of sparse
//---
#define def_k_MHSparseAttentionOut      45 ///< Index of the kernel of the multi-heads sparse attention neuron 
                                           //   to calculate multi-heads out matrix (#MHSparseAttentionOut)

Далее нам предстоит организовать создание кернелов в контексте OpenCL. Нам необходимо увеличить общее количество активных кернелов в контексте до 46 и вызвать методы создания кернела.

   opencl.SetKernelsCount(46);
   if(!opencl.KernelCreate(def_k_MHSparseAttentionScore, "MHSparseAttentionScore"))
     {
      PrintFormat("Error of create kernell: %d line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!opencl.KernelCreate(def_k_MHSparseAttentionOut, "MHSparseAttentionOut"))
     {
      PrintFormat("Error of create kernell: %d line %d", GetLastError(), __LINE__);
      return false;
     }

Здесь надо сказать, что указанные выше операции по созданию кернелов в контексте OpenCL нам предстоит повторить в трех методах диспетчерского класса нейронной сети CNet. Согласен, что это не совсем удобно. В будущем планирую вынести данные операции в отдельный метод.

   bool              Create(CArrayObj *Description);
   bool              Load(string file_name, float &error, float &undefine, float &forecast, datetime &time, 
                          bool common = true);
   ///< Load method. @param[in] file_name File name to save @param[out] error Average error 
   ///< @param[out] undefine Undefined percent @param[out] Forecast percent 
   ///< @param[out] time Last study time @param[in] common Common flag
   virtual bool      Load(const int file_handle);

На следующем этапе нашей работы мы переходим непосредственно к созданию методов нашего нового класса. Функционал нашего нового класса нейронной сети CNeuronMLMHSparseAttention во многом повторяет функционал родительского класса CNeuronMLMHAttentionOCL. Поэтому и методы мы в большей степени будем использовать унаследованные. Основные отличия связаны созданием разреженности внимания. В этой части мы создадим новую внутреннюю переменную m_dSparse для хранения уровня разреженности.

Я решил не усложнять работу излишним переписыванием методов и оставил конструктор и деструктор класса пустыми. Ведь в новом классе мы не создаем новые объекты, а для работы с параметром разреженности мы создадим перегруженные методы Sparse. Возможность перегрузки методов позволяет использовать одноименные методы для разного функционала: с указанием значения в параметрах — передаем значение параметра в метод; без указания параметров метод вернет ранее сохраненное значение.

class CNeuronMLMHSparseAttention  : public CNeuronMLMHAttentionOCL
  {
protected:
   float             m_dSparse;
   //---
   virtual bool      AttentionScore(CBufferFloat *qkv, CBufferFloat *scores, bool mask = true);
   ///< \brief Multi-heads attention scores method of calling kernel ::MHAttentionScore().
   virtual bool      AttentionOut(CBufferFloat *qkv, CBufferFloat *scores, CBufferFloat *out);
   ///< \brief Multi-heads attention out method of calling kernel ::MHAttentionOut().

public:
                     CNeuronMLMHSparseAttention(void)   :  m_dSparse(0.3f) {};
                    ~CNeuronMLMHSparseAttention(void) {};
   //---
   void              Sparse(float value)  { m_dSparse = value;}
   float             Sparse(void)         { return m_dSparse; }
   virtual int       Type(void)   const   {  return defNeuronMLMHSparseAttentionOCL;   }
                     ///< Identificatory of class.@return Type of class
   //--- methods for working with files
   virtual bool      Save(int const file_handle);  
                     ///< Save method @param[in] file_handle handle of file @return logical result of operation
   virtual bool      Load(int const file_handle);  
                     ///< Load method @param[in] file_handle handle of file @return logical result of operation
  };

Не забываем переопределить виртуальный метод идентификации объекта Type.

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

bool CNeuronMLMHSparseAttention::Save(const int file_handle)
  {
   if(!CNeuronMLMHAttentionOCL::Save(file_handle))
      return false;
   if(FileWriteFloat(file_handle, m_dSparse) < sizeof(float))
      return false;
//---
   return true;
  }

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

Я немного отступлю от обычной структуры обсуждения методов при описании функционала нейронных сетей — начну не с прямого, а обратного проходов. Выше мы не создавали новых кернелов функционала обратного прохода. Мы лишь внесли изменения в существующий кернел, который использовался родительским классом. Наследуя функционал родительского класса, мы получили и алгоритмы вызова, рассмотренного выше кернела MHAttentionInsideGradients. А значит, сейчас мы можем просто воспользоваться методом обратного прохода calcInputGradients родительского класса  для распределения градиентов ошибки. В части же функционала обновления обучаемых параметров мы не делали никаких изменений и тоже можем использовать метод родительского класса updateInputWeights.

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

bool CNeuronMLMHAttentionOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;
//---
   for(uint i = 0; (i < iLayers && !IsStopped()); i++)
     {
      //--- Calculate Queries, Keys, Values
      CBufferFloat *inputs = (i == 0 ? NeuronOCL.getOutput() : FF_Tensors.At(6 * i - 4));
      CBufferFloat *qkv = QKV_Tensors.At(i * 2);
      if(IsStopped() || !ConvolutionForward(QKV_Weights.At(i * (optimization == SGD ? 2 : 3)),
                                            inputs, qkv, iWindow, 3 * iWindowKey * iHeads, None))
         return false;
      //--- Score calculation
      CBufferFloat *temp = S_Tensors.At(i * 2);
      if(IsStopped() || !AttentionScore(qkv, temp, true))
         return false;
      //--- Multi-heads attention calculation
      CBufferFloat *out = AO_Tensors.At(i * 2);
      if(IsStopped() || !AttentionOut(qkv, temp, out))
         return false;
      //--- Attention out calculation
      temp = FF_Tensors.At(i * 6);
      if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9)), 
                                            out, temp, iWindowKey * iHeads, iWindow, None))
         return false;
      //--- Sum and normilize attention
      if(IsStopped() || !SumAndNormilize(temp, inputs, temp))
         return false;
      //--- Feed Forward
      inputs = temp;
      temp = FF_Tensors.At(i * 6 + 1);
      if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 1), 
                                            inputs, temp, iWindow, 4 * iWindow, LReLU))
         return false;
      out = FF_Tensors.At(i * 6 + 2);
      if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 2), 
                                            temp, out, 4 * iWindow, iWindow, activation))
         return false;
      //--- Sum and normilize out
      if(IsStopped() || !SumAndNormilize(out, inputs, out))
         return false;
     }
//---
   return true;
  }

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

Метод AttentionScore в параметрах получает указатель на объекты двух буферов: конкатенированного тензора сущностей Query, Key, Value и матрицу коэффициентов зависимости. Кроме того, в параметрах метода передается флаг mask. Данный флаг нами не используется, он оставлен в параметрах по причинам, указанным выше.

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

Напомню, что все созданные нами кернелы планировались к использованию в 2 мерном пространстве задач. И сейчас нам предстоит создать массивы описания пространства задач global_work_size и смещения в пространстве задач global_work_offset. Размерность обоих массивов должна соответствовать пространству задач. Для создания 2 мерного пространства задач мы создаем оба массива из 2 элементов.

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

Элементы второго массива мы заполним нулевыми значениями, так как не предполагаем смещения в пространстве задач.

bool CNeuronMLMHSparseAttention::AttentionScore(CBufferFloat *qkv, CBufferFloat *scores, bool mask = true)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(qkv) == POINTER_INVALID ||
      CheckPointer(scores) == POINTER_INVALID)
      return false;
//---
   if(qkv.GetIndex() < 0)
      return false;
   if(scores.GetIndex() < 0)
      return false;
//---
   uint global_work_offset[2] = {0, 0};
   uint global_work_size[2];
   global_work_size[0] = iUnits;
   global_work_size[1] = iHeads;
   OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionScore, def_k_mhas_qkv, qkv.GetIndex());
   OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionScore, def_k_mhas_score, scores.GetIndex());
   OpenCL.SetArgument(def_k_MHSparseAttentionScore, def_k_mhas_dimension, (int)iWindowKey);
   OpenCL.SetArgument(def_k_MHSparseAttentionScore, def_k_mhas_sparse, (float)m_dSparse);
   if(!OpenCL.Execute(def_k_MHSparseAttentionScore, 2, global_work_offset, global_work_size))
     {
      string error;
      CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error);
      printf("Error of execution kernel %s: %s", __FUNCSIG__, error);
      return false;
     }
//---
   return true;
  }

Следующим этапам нам предстоит передать параметры кернелу. Для этого мы воспользуемся методами SetArgumentBuffer и SetArgument. Первый используется для передачи указателей на буферы данных. Второй для передачи дискретных значений. В параметрах методов мы указываем идентификатор кернела, порядковый номер передаваемого параметра (соответствует последовательности параметров кернела в программе OpenCL начиная с "0" значения) и непосредственно передаваемое значение.

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

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

И сразу мы проверяем результат выполнения метода постановки кернела в очередь. В случае возникновения ошибки постановки кернела в очередь, запрашиваем информацию об ошибки и выводим её в журнал терминала.

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

Аналогичный алгоритм повторяем в методе AttentionOut для вызова второго кернела.

bool CNeuronMLMHSparseAttention::AttentionOut(CBufferFloat *qkv, CBufferFloat *scores, CBufferFloat *out)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(qkv) == POINTER_INVALID || 
      CheckPointer(scores) == POINTER_INVALID || CheckPointer(out) == POINTER_INVALID)
      return false;
   uint global_work_offset[2] = {0, 0};
   uint global_work_size[2];
   global_work_size[0] = iUnits;
   global_work_size[1] = iHeads;
   if(qkv.GetIndex() < 0)
      return false;
   if(scores.GetIndex() < 0)
      return false;
   if(out.GetIndex() < 0)
      return false;
//---
   OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionOut, def_k_mhao_qkv, qkv.GetIndex());
   OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionOut, def_k_mhao_score, scores.GetIndex());
   OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionOut, def_k_mhao_out, out.GetIndex());
   OpenCL.SetArgument(def_k_MHSparseAttentionOut, def_k_mhao_dimension, (int)iWindowKey);
   if(!OpenCL.Execute(def_k_MHSparseAttentionOut, 2, global_work_offset, global_work_size))
     {
      string error;
      CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error);
      printf("Error of execution kernel %s: %s", __FUNCSIG__, error);
      return false;
     }
//---
   return true;
  }

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

Сначала мы добавляем блок создания нового типа нейронного слоя в методе CNet::Create.

            case defNeuronMLMHSparseAttentionOCL:
               neuron_sparseattention = new CNeuronMLMHSparseAttention();
               if(CheckPointer(neuron_sparseattention) == POINTER_INVALID)
                 {
                  delete temp;
                  return false;
                 }
               if(!neuron_sparseattention.Init(outputs, 0, opencl, desc.window, desc.window_out, desc.step, 
                                                               desc.count, desc.layers, desc.optimization, desc.batch))
                 {
                  delete neuron_sparseattention;
                  delete temp;
                  return false;
                 }
               neuron_sparseattention.SetActivationFunction(desc.activation);
               neuron_sparseattention.Sparse(desc.probability);
               if(!temp.Add(neuron_sparseattention))
                 {
                  delete neuron_mlattention_ocl;
                  delete temp;
                  return false;
                 }
               neuron_sparseattention = NULL;
               break;

Добавляем новый тип слоя в метод CLayer::CreateElement.

         case  defNeuronMLMHSparseAttentionOCL:
            if(CheckPointer(OpenCL) == POINTER_INVALID)
               return false;
            temp_mlat_ocl = new CNeuronMLMHSparseAttention();
            if(CheckPointer(temp_mlat_ocl) == POINTER_INVALID)
               result = false;
            if(temp_mlat_ocl.Init(iOutputs, index, OpenCL, 1, 1, 1, 1, 0, ADAM, 1))
              {
               m_data[index] = temp_mlat_ocl;
               return true;
              }
            break;

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

bool CNeuronBaseOCL::FeedForward(CObject *SourceObject)
  {
   if(CheckPointer(SourceObject) == POINTER_INVALID)
      return false;
//---
   CNeuronBaseOCL *temp = NULL;
   switch(SourceObject.Type())
     {
      case defNeuronBaseOCL:
      case defNeuronProofOCL:
      case defNeuronConvOCL:
      case defNeuronAttentionOCL:
      case defNeuronMHAttentionOCL:
      case defNeuronMLMHAttentionOCL:
      case defNeuronMLMHSparseAttentionOCL:
      case defNeuronDropoutOCL:
      case defNeuronBatchNormOCL:
      case defNeuronVAEOCL:
      case defNeuronLSTMOCL:
      case defNeuronSoftMaxOCL:
         temp = SourceObject;
         return feedForward(temp);
         break;
     }
//---
   return false;
  }

И повторим операцию в аналогичном методе обратного прохода CNeuronBaseOCL::calcHiddenGradients(CObject *TargetObject).

      case defNeuronMLMHAttentionOCL:
      case defNeuronMLMHSparseAttentionOCL:
         mlat = TargetObject;
         if(!bTrain && !mlat.TrainMode())
            return true;
         temp = GetPointer(this);
         return mlat.calcInputGradients(temp);

С полным кодом всех классов и их методом можно познакомиться во вложении.


3. Тестирование

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

Напомню, в предыдущей статье мы тестировали реляционную модель обучения с подкреплением, в которой использовался алгоритм полностью параметризированной квантильной функции с использование блока внутреннего любопытства. Для реализации подобной модели мы создавали комбинацию из 3 моделей: Model, Forward и Inverse. Блок внимания мы использовали в первой модели. В её архитектуру мы и внесем изменения. Архитектура 2 других моделей осталась без изменений.

Архитектура моделей описывается в функции CreateDescriptions. Надо сказать, что с целью облегчения модели было решено отказаться от рекурсивных LSTM-блоков. И их место заняли полносвязные слои. Таким образом, обучаемая модель получила следующую архитектуру.

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

//--- Model
   Description.Clear();
   CLayerDescription *descr;
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (int)(HistoryBars * 12 + 9);
   descr.window = 0;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }

За ним следует слой нормализации данных.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = 1000;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }

После чего следует 2 последовательных блока из сверточного и полносвязного слоёв.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = prev_count - 2;
   descr.window = 3;
   descr.step = 1;
   descr.window_out = 6;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 100;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = 50;
   descr.window = 2;
   descr.step = 2;
   descr.window_out = 4;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 100;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }

Сжатые данные анализируются блоком внимания. Здесь мы используем новый слой разряженного внимания. Мы разбиваем всю последовательность сжатых данных на 20 блоком по 5 элементов. Каждый блок представляет один элементом анализируемой последовательности. Для анализа данных мы будем использовать 4 головы внимания с отбором 30% наиболее значимых элементов последовательности в каждой голове внимания. Анализ будет осуществляться в 2 последовательных слоях с аналогичными параметрами. Это мы укажем в параметре layers.  

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMLMHSparseAttentionOCL;
   descr.count = 20;
   descr.window = 5;
   descr.step = 4;
   descr.window_out = 8;
   descr.layers = 2;
   descr.probability = 0.3f;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }

Решение по совершению торговой операции советником принимается в блоке полностью параметризированной квантильной функции. Советник может принять решение о совершении одного из 4 действий:

  • купить, 
  • продать, 
  • закрыть все сделки
  • не совершать торговых операций.

//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFQF;
   descr.count = 4;
   descr.window_out = 32;
   descr.optimization = ADAM;
   if(!Description.Add(descr))
     {
      delete descr;
      return false;
     }

С полным кодом советника "SparseRL-learning.mq5" можно познакомиться во вложении.

Мы обучали модель и тестировали советника на исторических данных EURUSD таймфрейма H1 за март 2023 года. В процессе обучения мы смогли заработать прибыль за тестовый период. Стоит отметить, что прибыль была получена благодаря тому, что размер средней прибыльной сделки был больше, чем размер средней убыточной сделки. При этом количество выигрышных и проигрышных позиций было примерно одинаковым. Как результат, профит-фактор составил 1.12, а фактор восстановления — 1.01.

График тестирования
Таблица результатов тестирования


Заключение

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

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

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

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


Ссылки

  1. Generating Long Sequences with Sparse Transformers
  2. Attention Is All You Need
  3. Нейросети — это просто (Часть 8): Механизмы внимания
  4. Нейросети — это просто (Часть 10): Multi-Head Attention (многоголовое внимание)
  5. Нейросети — это просто (Часть 11): Вариации на тему GPT
  6. Нейросети — это просто (Часть 35): Модуль внутреннего любопытства (Intrinsic Curiosity Module)
  7. Нейросети — это просто (Часть 36): Реляционные модели обучения с подкреплением (Relational Reinforcement Learning)

Программы, используемые в статье

# Имя Тип Описание
1 SparseRL-learning.mq5 Советник Советник для обучения модели
2 ICM.mqh Библиотека класса Библиотека класса организации работы модели
3 NeuroNet.mqh Библиотека класса Библиотека классов для создания нейронной сети
4 NeuroNet.cl Библиотека Библиотека кода программы OpenCL

Прикрепленные файлы |
MQL5.zip (207.29 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (7)
Shah Yahya
Shah Yahya | 12 апр. 2023 в 01:42

I encounter the following error

2023.04.12 07:35:20.755 Core 01 2023.03.01 00:00:00 invalid pointer access in 'NeuroNet.mqh' (2913,18)
2023.04.12 07:35:20.755 Core 01 OnInit critical error
2023.04.12 07:35:20.755 Core 01 tester stopped because OnInit failed

Intel UHD 730
Metatrader build 3661


Tabata Voegele
Tabata Voegele | 12 апр. 2023 в 07:22
This error is caused by the fact that your GPU does not support fp64 as you can see in your error-log
star-ik
star-ik | 13 апр. 2023 в 10:53

А у меня какая причина?

2023.04.13 11:46:35.381 Core 1 2023.01.02 12:00:00   Error of execution kernel bool CNeuronMLMHAttentionOCL::SumAndNormilize(CBufferFloat*,CBufferFloat*,CBufferFloat*) MatrixSum: unknown OpenCL error 132640


Tabata Voegele
Tabata Voegele | 14 апр. 2023 в 06:34
If you use an Nvidia GPU this is probably the reason, unfortunately the author so far as no Nvidia GPU so far and is so unable to sort this error out, on his GPU the code seems to work.
Dmitriy Gizlyk
Dmitriy Gizlyk | 16 апр. 2023 в 13:29
star-ik #:

А у меня какая причина?

2023.04.13 11:46:35.381 Core 1 2023.01.02 12:00:00   Error of execution kernel bool CNeuronMLMHAttentionOCL::SumAndNormilize(CBufferFloat*,CBufferFloat*,CBufferFloat*) MatrixSum: unknown OpenCL error 132640


Попробуйте использовать эту библиотеку

Мультибот в MetaTrader: запуск множества роботов с одного графика Мультибот в MetaTrader: запуск множества роботов с одного графика
В этой статье мы рассмотрим простой шаблон для создания универсального робота в MetaTrader, который можно использовать на нескольких графиках, но прицепив его лишь к одному графику, без необходимости настройки каждого экземпляра робота на каждом отдельном графике.
Пример ансамбля ONNX-моделей в MQL5 Пример ансамбля ONNX-моделей в MQL5
ONNX (Open Neural Network eXchange) — открытый стандарт представления нейронных сетей. В данной статье мы покажем возможность одновременного использования двух ONNX-моделей в одном эксперте.
Эксперименты с нейросетями (Часть 5): Нормализация входных параметров для передачи в нейросеть Эксперименты с нейросетями (Часть 5): Нормализация входных параметров для передачи в нейросеть
Нейросети наше все. Проверяем на практике, так ли это. MetaTrader 5 как самодостаточное средство для использования нейросетей в трейдинге. Простое объяснение.
Торговая стратегия на индикаторе улучшенного распознавания свечей Доджи Торговая стратегия на индикаторе улучшенного распознавания свечей Доджи
Индикатор на метабарах обнаруживал больше свечей чем классический. Проверим, дает ли это реальную пользу в автоматической торговле.