- 5.3.2.1 Метод прямого прохода GPT
- 5.3.2.2 Методы обратного прохода GPT
- 5.3.2.3 Методы работы с файлами
5.Методы обратного прохода GPT
В предыдущих разделах мы рассмотрели архитектуру модели GPT и даже реализовали методы для инициализации нашего нового класса и прямого прохода по алгоритму модели. Теперь настало время посмотреть на возможный вариант реализации обратного прохода данного алгоритма.
Как вы помните, для реализации обратного прохода в каждом новом классе мы переопределяем три метода:
- CalcHiddenGradient — метод расчета градиента ошибки через скрытый слой;
- CalcDeltaWeights — метод расчета градиента ошибки до уровня матрицы весов;
- UpdateWeights — метод обновления весовых коэффициентов.
Данный класс не будет исключением — переопределим все три метода. Начнем работу с первого по алгоритму обратного прохода и, наверное, всегда самого сложного метода — распределение градиента ошибки через скрытый слой. Именно в этом методе нам предстоит повторить весь алгоритм прямого прохода в обратном порядке.
В параметрах метод получает указатель на объект предыдущего слоя, в который нам предстоит передать градиент ошибки. И сразу в теле метода мы организовываем блок проверок. В нем мы, по уже сложившейся хорошей традиции, проверяем действительность указателей на все используемые в методе объекты. Такой подход позволяет исключить многие критические ошибки в процессе выполнения кода метода.
bool CNeuronGPT::CalcHiddenGradient(CNeuronBase *prevLayer)
|
Далее, по аналогии с методом прямого прохода, организуем цикл перебора внутренних нейронных слоев. Только в соответствии с принципами обратного прохода, цикл мы тоже организуем с обратным отсчетом итераций. Все дальнейшие итерации будут выполняться в теле цикла и повторяться для всех вложенных слоев нашей модели.
//--- запускаем цикл перебора всех внутренних слоев в обратном порядке
|
В теле цикла мы сначала извлекаем указатель на соответствующий нейронный слой выхода блока Feed Forward FF2 и корректируем его буфер градиентов ошибки на производную функции нормализации. Подробно причины такой операции обсуждались при построении аналогичного метода алгоритма Self-Attention.
После этого последовательно вызываем методы распределения градиента ошибки для внутренних слоев блока Feed Forward. Методы мы вызываем также в обратном порядке: сначала для второго слоя, а потом для первого.
//--- проводим градиент через блок Feed Forward
|
При прямом проходе мы складывали результаты работы блоков Multi-Heads Self-Attention и Feed Forward. Так же и сейчас нужно провести градиент ошибки по двум направлениям. Мы складываем градиенты ошибок на уровне выхода указанных блоков. Затем суммарный тензор корректируем на производную функции нормализации слоя.
CBufferType *attention_grad = W0.GetGradients();
|
Далее распределяем градиент ошибки по головам внимания, путем вызова метода распределения градиента ошибки внутреннего нейронного слоя W0.
//--- инициализируем Scores
|
До этих пор все было просто и прозрачно. Мы просто в обратном порядке вызывали соответствующие методы наших внутренних нейронных слоев. Но далее начинается тот блок алгоритма, который не покрывается методами внутренних нейронных слоев. Он был реализован внутри метода прямого прохода. Соответственно, и обратный проход градиента ошибки нам тоже предстоит воссоздать полностью. Но для начала выполним подготовительную работу и создадим локальные указатели на объекты Querys, Keys, Values. Не забываем сразу проверить действительность полученных указателей на объекты.
//--- получаем указатели на объекты Querys, Keys, Values
|
Далее мы вспоминаем о необходимости создания двух вариантов реализации алгоритма: стандартными средствами MQL5 и в режиме многопоточных операций с использованием технологии OpenCL. Создаем разветвление алгоритма в зависимости от выбранного устройства выполнения математических операций. Как обычно, в данном разделе мы рассмотрим реализацию алгоритма стандартными средствами MQL5, а к реализации блока многопоточных операций вернемся в последующих разделах.
Для организации вычислений стандартными средствами MQL5 мы подготовим динамические массивы. В один мы загрузим из буфера данные градиентов ошибок, другие заполним результатами прямого прохода, а третьи — нулевыми значениями для последующих операций накопления суммы градиентов ошибки.
//--- разветвление алгоритма по вычислительному устройству
|
Первым мы распределим градиент ошибки на тензор Value. Сразу скажу, что распределять градиент ошибки мы будем не на весь тензор, а только на текущий элемент. Это вполне объяснимо. Давайте подумаем, с какой целью мы распределяем градиент ошибки. Ведь это не просто работа ради работы. Мы хотим с этого что-то получить. По существу весь процесс обучения направлен на оптимизацию параметров модели. Мы хотим получить максимально правдоподобную модель. А распределяя градиент ошибки мы хотим получить ориентир для оптимизации параметров модели. Теперь смотрите, распределив градиент ошибки на тензор значений Value мы должны его передать по двум направлениям: на предыдущий слой и на матрицу весовых коэффициентов формирования данного тензора текущего слоя.
Передать на предыдущий слой мы можем только градиент ошибки для текущего состояния. Больше буфер предыдущего слоя просто не в состоянии принять, ведь при прямом проходе он нам передал только текущее состояние, для которого он и ожидает градиент ошибки.
Распределить на матрицу весовых коэффициентов мы тоже можем только градиент ошибки текущего состояния. Для распределения ошибки с предыдущих состояний нам нужны и исходные данные предыдущих состояний. А предыдущий слой их не предоставляет, и мы их не сохраняли в буферах нашего слоя.
Таким образом, распределение градиента на элементы тензора значений, разумеется, кроме текущего состояния, является тупиковой задачей и не имеет смысла.
Общий подход таков: при прямом проходе мы считаем только текущее состояние, а также дополнительно берем из памяти уже пересчитанные на предыдущих итерациях. При обратном проходе ситуация аналогичная: считается, что градиент ошибки предыдущих состояний уже учтен при работе методов обратного прохода на предыдущих итерациях. Это значительно сокращает количество операций при совершении каждой итерации прямого и обратного проходов.
Надеюсь, логика ясна. Возвращаемся к нашему методу обратного прохода. Мы остановились на передаче градиента ошибки тензору значений Value. Для выполнения этой итерации мы сначала создадим локальный указатель на вектор коэффициентов зависимости, затем организуем цикл.
Наш цикл будет перебирать активные головы внимания. Здесь мы сразу сохраним в локальную матрицу вектор коэффициентов зависимости, который соответствует анализируемой голове внимания. Умножим полученный от предшествующих итераций вектор градиента ошибки на коэффициент зависимости текущего элемента последовательности. Полученные значения сохраняем в матрицу градиентов ошибки на буфере Values.
for(int head = 0; head < m_iHeads; head++)
|
Далее нужно распределить градиент по второму направлению: через матрицу коэффициентов зависимости на тензоры запросов Query и ключей Key. Но вначале нам предстоит провести градиент через вектор коэффициентов зависимости. Умножаем матрицы градиентов ошибки на выходе блока внимания и матрицы значений Values и получаем градиент на уровне вектора коэффициентов зависимости.
Итак, у нас есть вектор градиентов ошибки для одной головы внимания. Но напомню, что при прямом проходе мы осуществляли нормализацию вектора коэффициентов зависимости функцией Softmax. Следовательно, полученные нами градиенты ошибки справедливы для нормализованных данных. Для дальнейшего распределения градиентов ошибки нам необходимо скорректировать градиенты ошибки на производную указанной функции.
Особенностью функции Softmax можно назвать необходимость полного набора значений тензора для вычисления значения каждого элемента. Аналогично и для вычисления производной одного элемента необходим полный набор значений результатов работы функции. В нашем случае результатами работы функции является нормированный вектор коэффициентов зависимости, который мы получили при прямом проходе. Вектор градиентов ошибки мы тоже уже получили. Таким образом, у нас есть все необходимые исходные данные для выполнения операций определения производной функции и корректировки градиента ошибки. Формула производной функции Softmax имеет следующий вид:
Практическую часть операций корректировки градиента ошибки мы осуществляем с помощью матричных операций MQL5. А после корректировки градиентов ошибки полученный вектор разделим на квадратный корень из размерности вектора ключа Key одного элемента последовательности. Такую же операцию мы совершали при прямом проходе для предотвращения бесконтрольного роста не нормированных коэффициентов зависимости.
//--- распределение градиента на Querys и Keys
|
В результате проделанных операций получаем скорректированный градиент ошибки для одного элемента вектора коэффициентов зависимости. Но мы не будем его сохранять в очередной буфер данных. Вместо этого мы сразу распределим его на соответствующие элементы тензоров запросов Query и ключей Key. Для этого нужно умножить данное значение на вектор противоположного тензора. И если для определения градиента ошибки на векторе запросов Qwery у нас есть полный набор элементов последовательности в тензоре ключей Key, то в тензоре запросов Qwery у нас есть только один элемент последовательности. Следовательно, градиент ошибки на тензор ключей Key мы будем проводить только для текущего элементу последовательности. Полученные значения градиентов ошибки сохраняем в подготовленные нами ранее матрицы.
Получением градиентов ошибки на уровнях тензоров запросов Query и ключей Key мы завершаем операции цикла перебора голов внимания.
После завершения полного цикла итераций в наших матрицах querys_grad, keys_grad и values_grad накопились градиенты ошибки к текущему элементу последовательности по всем головам внимания. Нам лишь остается перенести его значения в буфер градиентов ошибки нашего внутреннего слоя Querys.
if(!querys_grad.Reshape(1, m_iHeads * m_iKeysSize) ||
|
На этом завершается блок разделения операций алгоритма в зависимости от устройства выполнения операций. Далее мы продолжим выполнения алгоритма с использованием методов наших внутренних нейронных слоев.
Ранее мы уже получили конкатенированный тензор градиентов ошибки, который включает данные всех голов внимания и сразу со всех трех сущностей (Query, Key, Value). Теперь с помощью метода распределения градиента через скрытый слой нашего внутреннего нейронного слоя Querys.CalcHiddenGradient мы можем перенести градиент ошибки в буфер предыдущего слоя. Но прежде чем выполнить эту операцию, нужно определиться, в буфер какого объекта мы будем записывать градиенты ошибки. Дело в том, что данный класс мы создавали как многослойный блок, и все операции метода выполняются в цикле перебора активного слоя нашего блока. Следовательно, на объект предыдущего нейронного слоя, указатель которого мы получили в параметрах данного метода, мы передаем данные только с первого нейронного слоя нашего блока. У него будет индекс 0 в коллекции вложенных нейронных слоев нашего блока GPT. Все же остальные вложенные нейронные слои должны передать градиент ошибки в буфер внутреннего нейронного слоя FF2 предыдущего вложенного нейронного слоя. Напомню, что FF2 — это внутренний нейронный слой результатов блока Feed Forward.
Поэтому мы создадим локальный указатель на объект предыдущего нейронного слоя и запишем в него указатель на нужный объект в зависимости от индекса активного вложенного нейронного слоя нашего блока GPT. И только после получения верного указателя на объект корректного предыдущего слоя мы переносим в его буфер градиент ошибки.
//--- перенос градиента ошибки на предыдущий слой
|
Обратите внимание, что при построении аналогичных методов в классах реализации механизмов внимания в этом месте мы создавали целую процедуру сложения градиенты ошибок с четырех направлений. Сейчас же благодаря использованию конкатенированного буфера градиентов ошибки мы получили суммарный градиент ошибки сразу с трех направлений выполнением метода только одного нейронного слоя. Складывать градиенты все же нам придется, но только один раз. К полученному градиенту ошибки мы должны прибавить градиент ошибки на уровне результатов блока многоголового внимания. Вы ведь помните, что при прямом проходе мы также складывали исходные данные с тензором результатов работы блока многоголового внимания. Следовательно, градиент ошибки должен пройти все те шаги, которые проходит сигнал при прямом проходе, только в обратном порядке.
На этом заканчиваются операции в теле цикла перебора вложенных нейронных слоев нашего блока GPT, как и в целом операции нашего метода. Мы закрываем цикл и выходим из метода.
И еще раз хочу сказать: не забывайте контролировать каждый шаг выполнения операций. Это позволяет минимизировать риск критических ошибок и сделать работу программы более контролируемой и надежной.
Мы рассмотрели с вами организацию метода распределения градиента ошибки на предыдущий слой. Но это лишь один их трех методов обратного прохода который мы должны переопределить для данного класса. Поэтому после распределения градиента ошибки на предыдущий нейронный слой нам необходимо довести градиент ошибки до внутренних матриц весовых коэффициентов, которые содержатся в недрах довольно большого количества внутренних объектов нейронных слоев. В соответствии со структурой методов наших классов данный функционал выполняется в методе CalcDeltaWeights.
Для распределения градиента ошибки на матрицу весовых коэффициентов любого из ранее рассмотренных нами нейронного слоя необходимы две вещи:
- градиент ошибки на уровне результатов данного нейронного слоя до функции активации;
- исходные данные, которые предоставляются предыдущим нейронным слоем.
Для организации данного процесса у нас уже есть все необходимые данные. В предыдущем методе мы распределили градиент ошибки до каждого нейронного слоя. Указатель на предыдущий нейронный слой мы получаем в параметрах метода CNeuronGPT::CalcDeltaWeights.
В теле метода мы как обычно организовываем контрольный блок по проверке указателей всех используемых внутренних объектов. Надо сказать, что блок контролей должен быть минимальным и достаточным. Нужно исключить избыточные и явно повторяющиеся контроли, так как они не несут ценности для работы программы, но при этом тормозят ее выполнение. При этом каждая операция, в том числе и контрольная, требует ресурсов и времени. Давайте подумаем, матрицы весовых коэффициентов каких объектов мы должны обновить. Это:
- нейронный слой запросов Query, который возвращает конкатенированный тензор трех сущностей (Query, Key, Value);
- нейронный слой матрицы W0;
- два нейронных слоя блока Feed Forward.
Все указанные объекты объявлены статичными. В связи с этим, нет необходимости проверять их указатели, так как их наличие контролируется системой. Это позволяет нам исключить блок контролей из данного метода.
Далее все банально и просто. Организуем цикл по перебору всех вложенных нейронных слоев нашего блока GPT. В теле блока поочередно извлекаем все объекты указанных выше коллекций. Сначала проверяем указатель на объект, а затем вызываем его метод распределения градиента ошибки до уровня матрицы весовых коэффициентов.
bool CNeuronGPT::CalcDeltaWeights(CNeuronBase *prevLayer, bool read)
|
Следует сказать немного слов о выстраивании порядка вызова методов внутренних объектов. С точки зрения выполнения математических операций, порядок вызова методов не влияет на конечный результат. Но используемый в теле цикла порядок вызова методов неслучаен. Обратите внимание, что в теле цикла мы явно проверяем указатели только на два объекта, которые не выполняют роль исходных данных других внутренних слоев. Дело в том, что в вызываемых методах нейронных слоев также существует блок контролей, который проверяет поступающие данные, в том числе и получаемые указатели на объекты. Поэтому, чтобы исключить повторные проверки указателей на объекты, мы сначала передаем указатель на объект в качестве исходных данных другого объекта, проверяем результат выполнения операций вызванного метода, который среди прочего подтверждает и действительность переданного указателя, а затем уже смело обращаемся к объекту, так как его указатель был проверен во время работы метода предыдущего объекта. Таким образом, мы организовываем полную проверку всех указателей на объекты без явного контроля в теле метода и исключаем избыточные повторные проверки указателей, которые бы тормозили работу программы.
Следующим рассмотрим метод обновления параметров модели. Для выполнения этой функции не требуются данные внешних объектов. В параметрах метода нет ни одного указателя на объект, есть только значение параметров для выполнения заданного алгоритма оптимизации параметров.
В теле метода мы также организуем цикл по перебору вложенных нейронных слоев нашего блока GPT. В теле цикла будем извлекать по одному объекту из каждой коллекции, проверять действительность указателя и вызывать метод обновления матрицы весов каждого объекта.
bool CNeuronGPT::UpdateWeights(int batch_size, TYPE learningRate,
|
Так как вызываемые методы не обращаются к внешним объектам, то и наш прием с оптимизацией контролей здесь не будет работать ввиду отсутствия явно повторяющихся контролей. Поэтому нам нужно явно проверять каждый указатель на объект перед обращением к его методу.
Мы рассмотрели реализацию трех методов обратного прохода и на этом заканчиваем работу над реализацией алгоритма модели GPT в нашем классе CNeuronGPT. Для полной реализации функционала стандартными средствами MQL5 нам остается переопределить методы работы с файлами — мы не раз уже говорили о важности этих методов для эксплуатации моделей нейронных сетей.