- 5.1.2.1 Метод прямого прохода Self-Attention
- 5.1.2.2 Методы обратного прохода Self-Attention
- 5.1.2.3 Методы работы с файлами
5.Методы обратного прохода Self-Attention
В предыдущем разделе мы рассмотрели метод прямого прохода блока Encoder архитектурного решения Трансформер. Данный блок включает в себя механизм внимания Self-Attention (само-внимание) с последующей обработкой двумя полносвязными нейронными слоями. Особенность механизма Self-Attention заключается в определении зависимостей между элементами последовательности. При этом каждый элемент последовательности представлен в виде вектора свойств фиксированной длины. Каждый элемент последовательности в рамках одного нейронного слоя обрабатывается блоком Encoder с одним набором весовых коэффициентов. Это позволило нам использовать ранее разработанные сверточные слои для решения ряда задач. Организация прямого прохода очень важная часть алгоритма работы нейронных сетей. Мы используем его как при обучении наших моделей нейронных сетей, так и в процессе промышленной эксплуатации. Но обучение нейронной сети невозможно без обратного прохода. Поэтому сейчас мы посмотрим на организацию обратного прохода в нашем классе механизма внимания.
Напомню, что создавали мы свой класс наследником от базового класса нейронного слоя. За организацию обратного прохода в нем отвечает несколько методов:
- CNeuronBase::CalcOutputGradient — метод расчета градиента ошибки слоя результатов;
- CNeuronBase::CalcHiddenGradient — метод расчета градиента ошибки через скрытый слой;
- CNeuronBase::CalcDeltaWeights — метод расчета градиента ошибки до уровня матрицы весов;
- CNeuronBase::UpdateWeights — метод обновления весовых коэффициентов.
Все методы были созданы виртуальными для возможности переопределения в классах-наследниках. В нашем классе мы не будем переопределять только первый метод.
Работу над методами мы осуществим в соответствии с логикой метода обратного распространения градиента ошибки. Первым мы переопределим метод расчета градиента ошибки через скрытый слой CNeuronAttention::CalcHiddenGradient. Из трех переопределяемых методов этот, наверное, наиболее сложный в понимании и организации. Ведь именно в этом методе нам нужно будет повторить весь путь прямого прохода, но в обратном порядке. При этом нам предстоит найти производные от всех операций, используемых при прямом проходе.
В параметрах метода мы получаем указатель на объект предыдущего слоя, в буфер которого нам предстоит сохранить результат операций. Следом в теле метода организовываем блок проверок актуальности указателей на объекты. Но здесь я решил не останавливаться на проверке всех объектов, а сделал лишь проверку тех объектов, которые не проверяются при вызове методов внутренних классов. Такое решение было принято в попытке исключить повторные проверки актуальности объектов в процессе выполнения операций метода.
bool CNeuronAttention::CalcHiddenGradient(CNeuronBase *prevLayer)
|
Далее начинается самое интересное — распределение градиента ошибки в обратном порядке алгоритма прямого прохода. Посмотрим на алгоритм прямого прохода. Он завершается нормализацией результатов, которая осуществляется по формулам.
Что же такое процесс нормализации? Это процесс изменения статистических показателей выборки, приближение ее к каким-то заданным параметрам. Чаще всего это среднее значение и среднеквадратическое отклонение, как и в нашем случае. Среднее значение мы приравниваем к нулю, а среднеквадратическое отклонение приводим к единице. В результате такой операции график функции смещается и масштабируется, как показано на рисунке.

Влияние нормализации на график функции
По сути, в рамках алгоритма Self-Attention процесс нормализации данных используется в качестве функции активации нейронного слоя. Только, в отличие от последней, не изменяет структуру данных.
Но мы не будем сейчас вдаваться в подробности вывода производной сложной функции нормализации данных. Процесс корректировки градиента ошибки мы вынесли в отдельный метод.
//--- корректировка градиента на нормализацию
|
Далее мы можем воспользоваться методами наших внутренних слоев блока FeedForward и провести градиент ошибки до внутреннего слоя хранения результатов блока внимания.
//--- проводим градиент через слои блока Feed Forward
|
На этом этапе нужно вспомнить, что в методе прямого прохода перед нормализацией слоя результатов мы складывали значение двух буферов (результатов блоков FeedForward и Self-Attention). Следовательно, и градиент ошибки нужно провести по обоим веткам алгоритма. Поэтому сложим два буфера градиента. Для облегчения доступа к буферу внутреннего слоя хранения результатов Self-Attention создадим локальный указатель на объекты.
CBufferType *attention_grad = m_cAttentionOut.GetGradients();
|
Скорректируем градиент ошибки на величину среднеквадратического отклонения.
//--- корректировка градиента на нормализацию
|
После сложения двух тензоров градиента ошибки нам предстоит распределить градиент ошибки между внутренними слоями m_cQuerys, m_cKeys и m_cValues. Напомню, что при прямом проходе блок алгоритма потока данных от указанных нейронных слоев до буфера результатов Self-Attention мы воссоздавали полностью. Следовательно, нам предстоит создать и обратный процесс. И как всегда, здесь мы создадим разветвление алгоритма в зависимости от вычислительного устройства. Сейчас мы рассмотрим построение алгоритма стандартными средствами MQL5. К реализации механизма многопоточных вычислений средствами OpenCL мы вернемся чуть позже.
//--- разветвление алгоритма по вычислительному устройству
|
В начале блока MQL5 мы создадим две матрицы для хранения промежуточных данных values и gradients.
Первым мы перенесем градиент ошибки на нейронный слой значений m_cValues. Именно значения буфера результатов этого нейронного слоя мы умножали на коэффициенты зависимостей матрицы Score для определения результатов блока Self-Attention при прямом проходе. Сейчас мы выполняем обратную операцию. Как мы уже говорили, производная от операции умножения равна второму множителю. В нашем случае это коэффициенты матрицы Score.
Сразу напомню размерности тензоров данных:
- матрица Score квадратная со стороной равной количеству элементов в последовательности;
- буферы нейронных слоев m_cValues и m_cAttentionOut имеют количество строк равное количеству элементов последовательности и количество элементов в каждой строке равно размеру вектора описания одного элемента последовательности.
С целью предотвращения возможных несоответствий размеров матриц мы приведем матрицу градиентов ошибки к необходимому формату.
if(attention_grad.GetData(gradients, false) < (int)m_cOutputs.Total())
|
Каждый элемент последовательности из m_cValues влияет на все элементы последовательности m_cAttentionOut с соответствующим коэффициентом из матрицы m_cScores.
Для организации процесса переноса градиента ошибки в буфер нейронного слоя m_cValues нам необходимо умножить транспонированную матрицу коэффициентов зависимости m_cScores на матрицу градиентов ошибки gradients.
//--- распределение градиента на Values
|
Далее мы распределим градиенты ошибки на m_cQuerys и m_cKeys. Оба нейронных слоя участвовали в создании матрицы коэффициентов зависимостей m_cScores. Следовательно, вначале нам надо определить градиент ошибки на матрице коэффициентов зависимости.
При прямом проходе для получения результата Self-Attention мы умножали матрицу m_cScores на тензор результатов нейронного слоя m_cValues. Мы уже определили градиент ошибки для нейронного слоя. Теперь нам надо провести градиент ошибки по второй ветке алгоритма и распределить его на значения матрицы коэффициентов зависимостей. Поэтому нам нужно будет умножить градиент ошибки на транспонированный буфер результатов нейронного слоя m_cValues.
gradients = gradients.MatMul(values.Transpose()); |
Напомню, что при прямом проходе значения матрицы были нормализованы функцией Softmax в рамках запросов Query. Сложность вычисления данной функции и ее производной заключается в необходимости вычислений сразу по всему массиву нормализации. В отличие от других функций, производной вектора значений будет матрица. Это связано с природой самой функции Softmax. Изменение одного элемента вектора исходный данных ведет к изменению всей последовательности нормализованного результата, ведь сумма всех элементов вектора результатов всегда равна единице. Следовательно, для корректного распределения градиента ошибки нам необходимо работать в разрезе запросов Query.
Математическая формула производной функции Softmax имеет вид:
Мы же воспользуемся ее матричным представлением:
где E — единичная квадратная матрица, размер которой равен количеству элементов последовательности.
Реализация данного подхода приведена ниже. В цикле мы определяем производную каждой отдельной строки матрицы коэффициентов зависимости. После умножения полученной матрицы на вектор градиентов соответствующей строки мы получаем вектор скорректированных градиентов ошибки. Не забываем, что перед нормализацией матрицы коэффициентов зависимостей Score мы делили ее значения на квадратный корень из размерности вектора описания одного элемента в тензоре Key. Соответственно, повторим эту процедуру и для градиента ошибки. Логика данной операции проста: деление на константу воспринимается как умножение на обратное константе число, а производная от операции умножения равна ее второму множителю.
Результат вышеуказанных операций заменит анализируемую строку матрицы градиентов.
for(int r = 0; r < m_iUnits; r++)
|
После получения скорректированного градиента ошибки для каждого отдельного коэффициента зависимости мы распределяем его на соответствующие векторы тензоров Query и Key. С этой целью умножим матрицу скорректированных градиентов коэффициентов зависимости на противоположную матрицу.
m_cQuerys.GetGradients().m_mMatrix =
|
else // Блок OpenCL
|
На этом завершается блок разделения алгоритма по вычислительному устройству. В блоке работы с технологией OpenCL мы пока оставим возврат отрицательного результата и вернемся к нему чуть позже. А сейчас идем дальше по нашему алгоритму обратного распространения ошибки. После получения градиента ошибки на выходе внутренних нейронных слоев, нам остается довести его до предыдущего слоя.
Как вы помните, при прямом проходе исходные данные используются в четырех ветках алгоритма:
- подаются на вход внутреннего слоя m_cQuerys;
- подаются на вход внутреннего слоя m_cKeys;
- подаются на вход внутреннего слоя m_cValues;
- суммируются с выходом блока Self-Attention перед нормализацией слоя.
Следовательно, в буфер градиентов ошибки предыдущего слоя мы должны собрать градиент ошибки со всех 4-х направлений. Алгоритм работы аналогичен построенному ранее процессу сложения буферов в рекуррентном LSTM-блоке. Только мы не будем создавать отдельный буфер для накопления данных, а воспользуемся уже имеющимся. Градиент ошибки на выходе блока Self-Attention у нас уже посчитан в буфере нейронного слоя m_cAttentionOut. В нем и будем накапливать промежуточные градиенты ошибки.
Мы будем поочередно вызывать метод передачи градиента на предыдущий слой CalcHiddenGradient для каждого внутреннего слоя с передачей ему указателя на предыдущий нейронный слой. После успешного выполнения метода сложим полученный результат с предварительно накопленным градиентом ошибки в буфере градиентов нейронного слоя m_cAttentionOut.
//--- перенос градиента ошибки на предыдущий слой
|
Обратите внимание, что в первых двух случаях мы записывали сумму двух буферов градиентов ошибки в буфер внутреннего нейронного слоя. А последний раз, наоборот, сохранили сумму двух буферов в буфер градиентов предыдущего нейронного слоя. Все дело в том, что метод CalcHiddenGradient внутреннего нейронного слоя перезаписывает значения в буфере градиентов нейронного слоя, указанного в параметрах. Поэтому нам потребовалось накапливать промежуточные градиенты в другом буфере. Но в конце метода нам надо передать градиент ошибки на предыдущий слой. Поэтому при последнем суммировании буферов мы сразу записываем сумму в буфер предыдущего нейронного слоя, тем самым избегая излишнее копирование данных.
Выше был анонсирован метод корректировки градиента ошибки на процесс нормализации данных NormlizeBufferGradient. Что же представляет из себя процесс нормализации и в чем сложность определения производной функции? На первый взгляд, мы от каждого элемента нормализуемого массива отнимаем значение средней арифметической, а полученную разницу делим на среднеквадратическое отклонение.
Если бы отнимали и делили на константы, не было бы никаких сложностей. При вычитании константы производная не меняется.
Производная из деления на константу равна отношению 1 к константе.
Но проблема в том, что обе средние являются функциями. При изменении любого одного значения в тензоре исходных данных изменяется значение средних и, как следствие, все значения тензора на выходе блока нормализации. Это значительно усложняет вычисление производной всей функции. Мы не будем сейчас выводить их, а воспользуемся готовым результатом.
Реализуем приведенные формулы в коде с помощью матричных операций MQL5. В параметрах метод получает указатели на 3 буфера данных:
- output — буфер результатов нормализации данных прямого прохода;
- gradient — буфер градиентов ошибки. Используется как для получения исходных данных, так и для записи результатов;
- std — буфер среднеквадратических отклонений, вычисленных при прямом проходе.
Как можно заметить, в параметрах нет буфера данных до нормализации и значения средней арифметической, вычисленной при прямом проходе. Мы просто заменили разницу ненормализованных данных и средней арифметической на произведение нормализованных данных и среднеквадратического отклонения.
И, конечно, мы не ожидаем нулевое среднеквадратическое отклонение. Добавим проверку для предотвращения критической ошибки деления на ноль.
bool CNeuronAttention::NormlizeBufferGradient(CBufferType *output,
|
Кроме метода распределения градиента через скрытый слой, алгоритм обратного распределения градиента ошибки во всех ранее рассмотренных нейронных слоя обычно представлен еще двумя методами:
- CalcDeltaWeights — метод расчета градиента ошибки до уровня матрицы весов;
- UpdateWeights — метод обновления весовых коэффициентов.
Рассматриваемый нами класс CNeuronAttention не будет исключением. В нем мы также переопределим два указанных метода. Алгоритм их прост и тривиален: мы просто будем вызывать поочередно одноименные методы всех внутренних нейронных слоев. И конечно, проверим результат выполнения операций.
bool CNeuronAttention::CalcDeltaWeights(CNeuronBase *prevLayer)
|
bool CNeuronAttention::UpdateWeights(int batch_size, TYPE learningRate,
|
Таким образом мы реализовали три метода, составляющие алгоритм обратного прохода нашего блока внимания.