В этом материале мы поговорим об устройстве компонента‑декодера в системах машинного обучения, построенных по архитектуре «трансформер», уделив особое внимание отличию декодера от энкодера. Уникальной особенностью декодеров является то, что они похожи на циклы. Они, по своей природе, итеративны, что контрастирует с линейными принципами обработки данных, на которых основаны энкодеры. В центре декодера находятся две модифицированные формы механизма внимания: механизм множественного внимания с маскировкой (masked multi‑head attention) и механизм множественного внимания энкодера‑декодера (encoder‑decoder multi‑head attention).
![](https://habrastorage.org/getpro/habr/upload_files/37a/d05/803/37ad05803b6d9442bfccae43dc07ee94.png)
Слой множественного внимания с маскировкой в декодере обеспечивает последовательную обработку токенов. Благодаря такому подходу предотвращается воздействие последующих токенов на сгенерированные токены. Маскировка важна для поддержки порядка следования и согласованности сгенерированных данных. Взаимодействие между выходом декодера (из слоя множественного внимания с маскировкой) и выходом энкодера организовано с помощью механизма множественного внимания энкодера‑декодера. Этот последний шаг даёт декодеру доступ к входным данным.
Мы, кроме того, продемонстрируем реализацию этих концепций с использованием Python и NumPy. Мы создали простой пример перевода предложения с английского языка на португальский. Практическая демонстрация обсуждаемых здесь идей поможет проиллюстрировать работу внутренних механизмов декодера в трансформерах и позволит лучше понять роль декодеров в больших языковых моделях (Large Language Model, LLM).
Как всегда, код к статье можно найти на GitHub
Один большой цикл While
После описания внутренних процессов, происходящих в энкодере трансформерных архитектур, мы можем переходить к разбору следующей детали трансформеров — к декодеру. Сравнивая эти два компонента трансформера, мы исходим из предположения о том, что это будет полезным для выделения их основных сходств и различий. Механизм внимания — это центральный компонент и в том, и в другом случаях. В частности, этот механизм применяется в двух местах декодера. И в обоих этих местах механизм внимания подвергается важным модификациям, что отличает его от простейшей версии такого механизма, присутствующего в энкодере. Речь идёт о механизме множественного внимания с маскировкой и механизме множественного внимания энкодера‑декодера. Если говорить об отличиях энкодера и декодера, нужно отметить, что циклический аспект декодера контрастирует с линейной природой энкодера. Декодер — это, по сути, большой цикл while.
Механизм внутреннего внимания производит вычисления, выявляющие отношения между словами/токенами с использованием (взвешенного) скалярного произведения векторных представлений Q и K этих токенов. В процессе обучения сеть вычисляет вероятность того, что эти слова могут появиться в одном и том же предложении. Эти скалярные произведения формируют матрицу весов, которая умножается на представление V тех же токенов. В результате состояние вектора V обновляется с использованием весов. Делается это посредством простого матричного умножения, производимого так, что каждый элемент из набора векторов V получает информацию, идущую от каждого из других векторов в наборах Q и K. В этом заключается базовая идея, взятая из лингвистики и приписываемая Джону Руперту Фёрсу: «Лучший способ понять слово — это познакомиться с его соседями». Если выразить это всё более формально, то получится следующее:
А теперь, в декодере, нам нужно, чтобы алгоритм создавал бы за один раз по одному токену, принимая во внимание лишь предыдущие токены, которые уже сгенерированы. Для того чтобы этот механизм работал правильно, надо оградить токены от получения информации из правой части предложения. Делается это путём маскировки матрицы весов, проводимой таким образом, что матрица, в итоге, принимает треугольную форму. Все её компоненты, находящиеся выше диагонали, заполняются нулями. В результате можно записать следующее:
Здесь M — это маскировочная матрица с компонентами выше диагонали, которые дают нули в ходе возведения в степень в softmax
. Набор векторов V обновляется так, что первый из них получает информацию только от себя самого, второй — только от себя и от предыдущего вектора, и так далее.
Теперь поговорим о применении в декодере механизма множественного внимания энкодера‑декодера. Основное отличие этого механизма от вышеописанного заключается в том, что вектор Q — это выходной вектор слоя множественного внимания с маскировкой (после остаточного соединения), в то время как векторы K и V поступают из энкодера.
Всё это легче понять, если учесть главное отличие энкодера от декодера, уже упомянутое выше, а именно — то, что декодер похож на цикл while.
Пользователь не контролирует входные данные слоя множественного внимания с маскировкой. Эти данные составляются из векторов, ранее сгенерированных декодером в своём собственном цикле, начиная со специального токена <START>
и заканчивая ещё одним специальным токеном, указывающим на конец предложения — <EOS>
. Тут есть одна проблема: если этот процесс будет функционировать полностью самостоятельно, это значит, что у него совершенно не будет информации от пользователя, что делает задачу перевода с одного языка на другой невозможной. Поэтому слой множественного внимания энкодера‑декодера соединяет, во‑первых — эмбеддинги K и V от энкодера, несущие в себе исходную информацию, которую кто‑то хочет обработать, и во‑вторых — векторы Q, которые сгенерированы в цикле декодера.
![Архитектура трансформера Архитектура трансформера](https://habrastorage.org/getpro/habr/upload_files/ff3/412/8ea/ff34128ea15cd27e42b73d1acb260ac7.png)
Кроме того, стоит отметить, что именно эти рассуждения способны помочь понять причины, по которым используемые в трансформерах наборы векторов получили свои названия. Так, векторы Q генерируются в ходе циклической обработки данных, как настоящие запросы (Query), так как они находят соответствия для тех токенов, которые уже сгенерированы в цикле. Эти запросы соединяются с векторами K (Key, ключ) посредством скалярных произведений. В итоге получается результат — обновлённые векторы V (Value, значение).
Следуем за цифрами
Далее мы будем пользоваться тем же самым предложением, которое применялось в нашей предыдущей статье, но теперь мы передадим данные декодеру и предложим ему перевести предложение с английского на португальский.
Как мы уже видели, энкодер принимает изначальный эмбеддинг X предложения «Today is Sunday». В этот раз мы используем 5-мерный изначальный эмбеддинг (по причинам, которые скоро будут понятны):
Today — (1,0,0,0,0)
is — (0,1,0,0,0)
sunday — (0,0,1,0,0)
Это — три слова из нашего словаря, состоящего из четырёх слов. Вот ещё одно слово, которое в нём есть:
saturday — (0,0,0,1,0).
В результате входные данные для энкодера будут выглядеть так:
сопоставляется с 3 наборами векторов — Q, K и V посредством умножения матриц:
Здесь W — это матрицы, инициализированные случайными числами, значения в которых изменяются в ходе обучения модели. Кроме того, мы добавили тут индекс для указания на то, что речь идёт об энкодере (encoder). Результаты работы механизма множественного внимания приводят к появлению
.
А если говорить о декодере, то тут мы работаем с набором векторов и с Veupdated:
Как уже было сказано, работа декодера организована итеративно. Он начинает её с выбранного нами токена <START>
, который выглядит так:
![](https://habrastorage.org/getpro/habr/upload_files/51c/668/839/51c6688395336dd1c2ca0b7ad2fc2168.png)
Это будет вход для слоя множественного внимания с маскировкой после позиционного кодирования:
![](https://habrastorage.org/getpro/habr/upload_files/2b9/172/132/2b917213266a6edefe9bd34a533c7d14.png)
В слое множественного внимания с маскировкой генерируются другие три набора векторов, называющиеся теперь . Генерируются они на основе трёх других матриц Wm, составленных из параметров, модифицируемых в ходе обучения модели, инициированных случайными числами:
![](https://habrastorage.org/getpro/habr/upload_files/5a9/332/9b7/5a93329b7db7c8bff51b11d26ccacf05.png)
Это даёт нам 3 вектора:
![](https://habrastorage.org/getpro/habr/upload_files/0f6/9b3/82d/0f69b382d223ac090082d8766831ec4e.png)
Теперь вычисляется матрица оценки внимания. В данном случае оказалось так, что она представлена всего лишь одним числом:
Затем эта матрица добавляется к маскировочной матрице (она — это просто число 0), а затем «взвешивается» путём деления на квадратный корень числа, представляющего размерности Q и K (в данном случае dk=5):
В первой итерации применение функции softmax
всегда приводит к получению 1. Это значение используется в роли веса для вектора Vm:
Результат этих вычислений — единственный вектор , который затем подвергается операции остаточного соединения:
![](https://habrastorage.org/getpro/habr/upload_files/9a1/ca5/6cf/9a1ca56cf589a2983b8dda39eac9a214.png)
Всё это можно реализовать, воспользовавшись Python и NumPy. Например — так:
class One_Head_Masked_Attention:
def __init__(self, d_model, d_k, d_v):
self.d_model = d_model
self.W_mat = ENCODER.W_matrices(self.d_model, d_k, d_v)
def compute_QKV(self, X):
self.Q = np.matmul(X, self.W_mat.W_Q)
self.K = np.matmul(X, self.W_mat.W_K)
self.V = np.matmul(X, self.W_mat.W_V)
def print_QKV(self):
print('Q : \n', self.Q)
print('K : \n', self.K)
print('V : \n', self.V)
def compute_1_head_masked_attention(self):
Attention_scores = np.matmul(self.Q, np.transpose(self.K))
# print('Attention_scores before normalization : \n', Attention_scores)
if Attention_scores.ndim > 1:
M = np.zeros(Attention_scores.shape)
for i in range(Attention_scores.shape[0]):
for j in range(i+1, Attention_scores.shape[1]):
M[i,j] = -np.inf
else:
M = 0
Attention_scores += M
# print('Attention_scores after masking : \n', Attention_scores)
Attention_scores = Attention_scores / np.sqrt(self.d_model)
# print('Attention scores after Renormalization: \n ', Attention_scores)
if Attention_scores.ndim > 2:
Softmax_Attention_Matrix = np.apply_along_axis(lambda x: np.exp(x) / np.sum(np.exp(x)), 1, Attention_scores)
else:
Softmax_Attention_Matrix = np.exp(Attention_scores) / np.sum(np.exp(Attention_scores))
# print('result after softmax: \n', Softmax_Attention_Matrix)
if Attention_scores.ndim > 1:
if Softmax_Attention_Matrix.shape[1] != self.V.shape[0]:
raise ValueError("Incompatible shapes!")
result = np.matmul(Softmax_Attention_Matrix, self.V)
else:
result = Softmax_Attention_Matrix * self.V
# result = np.matmul(Softmax_Attention_Matrix, self.V)
# print('softmax result multiplied by V: \n', result)
return result
def backpropagate(self):
# сделать что-нибудь для обновления W_mat
pass
class Multi_Head_Masked_Attention:
def __init__(self, n_heads, d_model, d_k, d_v):
self.d_model = d_model
self.n_heads = n_heads
self.d_k = d_k
self.d_v = d_v
self.d_concat = self.d_v*self.n_heads
self.W_0 = np.random.uniform(-1, 1, size=(self.d_concat, self.d_v))
self.heads = []
i = 0
while i < self.n_heads:
self.heads.append(One_Head_Masked_Attention(d_model=d_model, d_k=d_k , d_v=d_v ))
i += 1
def print_W_0(self):
print('W_0 : \n', self.W_0)
def print_QKV_each_head(self):
i = 0
while i < self.n_heads:
print(f'Head {i}: \n')
self.heads[i].print_QKV()
i += 1
def print_W_matrices_each_head(self):
i = 0
while i < self.n_heads:
print(f'Head {i}: \n')
self.heads[i].W_mat.print_W_matrices()
i += 1
def compute(self, X):
self.heads_results = []
for head in self.heads:
head.compute_QKV(X)
self.heads_results.append(head.compute_1_head_masked_attention())
if X.ndim > 1:
multi_head_results = np.concatenate(self.heads_results, axis=1)
V_updated = np.matmul(multi_head_results, self.W_0)
else:
multi_head_results = np.concatenate(self.heads_results, axis=0)
print('Dimension of multihead_results:', multi_head_results.shape)
print('Dimension of W_0:', self.W_0.shape)
V_updated = np.matmul(multi_head_results, self.W_0)
return V_updated
def back_propagate(self):
# выполнить обратное распространение ошибки для W_0
# вызвать _backprop для каждой “головы”
pass
Теперь мы готовы переходить к следующей части нашей работы — к механизму множественного внимания энкодера-декодера:
![](https://habrastorage.org/getpro/habr/upload_files/f55/b97/eee/f55b97eee9861fdb09eb4b969dcca9a4.png)
Мы, как и прежде, начинаем с вычисления показателей оценки внимания. Теперь это — матрица 1x3:
![](https://habrastorage.org/getpro/habr/upload_files/450/72a/748/45072a748e635ef7a4ca0a76631ee10f.png)
Применяем «взвешивание» и softmax
:
И, наконец — получаем обновлённый вектор V — V*:
Всё это, снова, можно реализовать с помощью Python и NumPy:
class One_Head_Encoder_Decoder_Attention:
def __init__(self, d_k):
self.d_k = d_k
def print_QKV(self):
print('Q : \n', self.Q)
print('K : \n', self.K)
print('V : \n', self.V)
def compute_1_head_attention(self, Q, K, V):
self.Q = Q #from masked attention in decoder
self.K = K #from encoder
self.V = V #final result from encoder
Attention_scores = np.matmul(self.Q, np.transpose(self.K))
# print('Attention_scores before normalization : \n', Attention_scores)
Attention_scores = Attention_scores / np.sqrt(self.d_k)
# print('Attention scores after Renormalization: \n ', Attention_scores)
Softmax_Attention_Matrix = np.exp(Attention_scores - np.max(Attention_scores, axis=-1, keepdims=True))
Softmax_Attention_Matrix /= np.sum(Softmax_Attention_Matrix, axis=-1, keepdims=True)
# print('result after softmax: \n', Softmax_Attention_Matrix)
if Softmax_Attention_Matrix.ndim > 1:
if Softmax_Attention_Matrix.shape[1] != self.V.shape[0]:
raise ValueError("Incompatible shapes!")
result = np.matmul(Softmax_Attention_Matrix, self.V)
return result
Этот последний шаг даёт нам информацию, отражающую связь семантических взаимоотношений в предложении «Today is Sunday» с циклом декодера. Результирующий вектор V* подвергается ещё одному сеансу остаточного соединения:
Следующий фрагмент нашей системы — это сеть прямого распространения (Feed-Forward Network, FFN). В изначальной публикации используются 2 матрицы обучаемых параметров. За ними идёт ещё один экземпляр softmax
, который накладывается на распределение вероятностей, где, посредством прямого кодирования, производится идентификация токенов на португальском языке. В результате мы получим следующее:
Далее — получим связанные с результатом токены:
Заглянем в наш португальский словарь:
vocabulary = { 'Hoje': np.array([1,0,0,0,0]),
'é': np.array([0,1,0,0,0]),
'domingo': np.array([0,0,1,0,0]),
'sábado': np.array([0,0,0,1,0]),
'EOS': np.array([0,0,0,0,1]),
'START' : np.array([0.2,0.2,0.2,0.2,0.2])
}
Получается, что токен будет таким:
Учитывая то, какое предложение использовалось в нашем примере, мы ожидали, что результат будет «Hoje» (перевод «Today» на португальский). Но не будем забывать, что мы пока ещё не обучали модель (в ней нет механизма обратного распространения ошибки).
Это — конец цикла декодера. Теперь то, что получилось на выходе, используется для того, чтобы подать на вход новой итерации цикла новые данные. К X = <START>
присоединим вектор, связанный с «sábado». Мы решили, что это, в пространстве эмбеддингов португальского языка, будет (0,0,0,1,0)
.
Обратите внимание на то, что на этом шаге будет сгенерировано два токена, а не один. Количество сгенерированных векторов увеличивается на один на каждой итерации. Модель продолжит добавлять последний сгенерированный токен к предыдущим до тех пор, пока очередной токен не будет отмечен как <EOS>
.
Увеличение размеров слоя множественного внимания с маскировкой
Для того чтобы лучше разобраться в механизме множественного внимания с маскировкой — полезно будет посмотреть на то, что происходит, когда размер матрицы будет несколько больше, чем всего лишь одно число. Выполним третий шаг, исходя из предположения о том, что на первых двух шагах были сгенерированы следующие токены:
Это означает, что теперь входные данные для подсистемы множественного внимания с маскировкой представлены набором данных, в верхней части которого находится то, что получено ранее, а в нижней — новые данные. Всё выглядит именно так из-за того, что мы решили работать с тем, что расположено выше диагонали в маскировочной матрице. А в начале размещается токен <START>
:
Всё это подвергается позиционному кодированию:
![](https://habrastorage.org/getpro/habr/upload_files/925/4bc/433/9254bc433a40f610757e0955a30697b5.png)
В результате получаем следующие наборы Q, K и V:
![](https://habrastorage.org/getpro/habr/upload_files/899/193/cc2/899193cc2b4073c451820f9f0bf11692.png)
Вот матрица оценки внимания:
![](https://habrastorage.org/getpro/habr/upload_files/eea/9e4/035/eea9e4035a412fb312df74adeb5be701.png)
Теперь найдём сумму этой матрицы и маскировочной матрицы M:
![](https://habrastorage.org/getpro/habr/upload_files/aeb/c24/65e/aebc2465e537396300463f6aecb23db6.png)
Эта матрица, после «взвешивания», принимает следующую форму:
![](https://habrastorage.org/getpro/habr/upload_files/5e0/ab1/18d/5e0ab118d7fc42cb453e852a80e136c9.png)
Потом она проходит через softmax
:
![](https://habrastorage.org/getpro/habr/upload_files/f2b/e6a/6f5/f2be6a6f581cb34e5b05e898befa57d4.png)
И наконец, мы, чтобы получить , умножаем её на векторы V:
![](https://habrastorage.org/getpro/habr/upload_files/172/99f/b99/17299fb9983934f0b8687bc1f710416c.png)
Тут можно видеть, что первый вектор (строка) не поменялся. Вспомним, что он получен после умножения первой строки softmax(Scaled_Masked_Attention_Scores)3 на все столбцы набора векторов V. Но в этой строке находятся нули — на местах, соответствующих всем компонентам, кроме первого (там находится 1). Поэтому первый вектор в наборе подвергается воздействию, исходящему лишь их него самого, умноженному на вес, равный 1.
Второй обновлённый вектор подвергается воздействию первого и второго векторов, сумма весов, соответствующих которым, равна 1. Аналогично, третий обновлённый вектор из группы V, подвергается взвешенному воздействию себя самого и двух предыдущих векторов из этой группы.
Мы хотели бы подчеркнуть важность применения функции softmax
по отдельности к каждой строке матрицы Scaled_Masked_Attention_Scores
для правильного распределения весов.
Мы можем сделать 10 итераций этого процесса, или выполнять его до тех пор, пока не будет найден токен EOS
:
ENDCOLOR = '\033[0m'
RED = '\033[91m'
GREEN = '\033[92m'
BLUE = '\033[94m'
# энкодер конструирует векторы K и V. Предположим - в каждой группе имеется 3
# пятимерных вектора.
K = np.random.uniform(low=-1, high=1, size=(3, 5))
V = np.random.uniform(low=-1, high=1, size=(3, 5))
vocabulary = { 'Hoje': np.array([1,0,0,0,0]),
'é': np.array([0,1,0,0,0]),
'domingo': np.array([0,0,1,0,0]),
'sábado': np.array([0,0,0,1,0]),
'EOS': np.array([0,0,0,0,1]),
'START' : np.array([0.2,0.2,0.2,0.2,0.2])
}
START = vocabulary['START']
EOS = vocabulary['EOS']
INPUT_TOKEN = START
OUTPUT_TOKENS = START
LAST_TOKEN = START
X = INPUT_TOKEN
PE = Positional_Encoding()
multi_head_masked_attention = Multi_Head_Masked_Attention(n_heads=8, d_model=5, d_k=4, d_v=5)
encoder_decoder_attention = One_Head_Encoder_Decoder_Attention(d_k=4)
ffn = FFN(d_v=5, layer_sz=8, d_output=5)
count = 0
while (not np.array_equal(LAST_TOKEN, EOS)) and (count < 10):
X_PE = PE.compute(X)
print(BLUE + 'shape of X:', X.shape, ENDCOLOR)
print(BLUE+'X:\n', X, ENDCOLOR)
print(BLUE+'X_PE:\n', X_PE, ENDCOLOR)
output_masked_attention = multi_head_masked_attention.compute(X_PE)
Q_star = DECODER.ENCODER.add_and_norm(output_masked_attention, X_PE)
# остаточное соединение 1
output_encoder_decoder_attention = encoder_decoder_attention.compute_1_head_attention(Q=Q_star, K=K, V=V)
Rc2 = DECODER.ENCODER.add_and_norm(output_encoder_decoder_attention , Q_star) # остаточное соединение 2
ffn_result = ffn.compute(Rc2)
OUTPUT_TOKENS_before_softmax = DECODER.ENCODER.add_and_norm(ffn_result, Rc2) # -------> остаточное соединение 3
if OUTPUT_TOKENS_before_softmax.ndim == 1:
# последнее применение softmax:
OUTPUT_TOKENS = np.exp(OUTPUT_TOKENS_before_softmax) / np.sum(np.exp(OUTPUT_TOKENS_before_softmax))
position_of_max = np.argmax(OUTPUT_TOKENS)
OUTPUT_TOKENS = np.eye(OUTPUT_TOKENS.shape[0])[position_of_max]
LAST_TOKEN = OUTPUT_TOKENS
else:
OUTPUT_TOKENS = np.apply_along_axis(lambda x: np.exp(x) / np.sum(np.exp(x)), 1, OUTPUT_TOKENS_before_softmax)
position_of_max = np.argmax(OUTPUT_TOKENS, axis=1)
OUTPUT_TOKENS = np.eye(OUTPUT_TOKENS.shape[1])[position_of_max]
LAST_TOKEN = OUTPUT_TOKENS[-1,:]
X = np.vstack([X, LAST_TOKEN])
print('shape of OUTPUT_TOKENS:', OUTPUT_TOKENS.shape)
print(RED+'OUTPUT_TOKENS:\n', OUTPUT_TOKENS, ENDCOLOR)
print(RED+'LAST_TOKEN:\n', LAST_TOKEN, ENDCOLOR)
print(RED + '=====================================' + ENDCOLOR)
count = count + 1
#идентификация токенов в словаре:
OUTPUT_SENTENCE = []
output_sentence_str = ''
for token_pos in range(len(X[:,0])):
token = X[token_pos,:]
for name, array in vocabulary.items():
if np.array_equal(array, token):
OUTPUT_SENTENCE.append(name)
for token_name in OUTPUT_SENTENCE:
output_sentence_str += token_name + ' '
print(output_sentence_str)
После выполнения кода получится такое предложение:
<START> domingo Hoje domingo domingo é Hoje domingo domingo Hoje domingo
Не будем забывать о том, что эта нейросеть-трансформер не обучена, поэтому веса, используемые при выдаче результата — это всего лишь случайные числа.
Итоги
Наше подробное исследование компонента-декодера, входящего в состав трансформеров, позволило ознакомиться с тонкостями работы этого компонента, позволило узнать о том, как он, генерируя новую информацию, может пользоваться данными, получаемыми от энкодера. Итеративная, циклическая сущность декодера — это один из основных факторов, отличающих его от энкодера.
Главная особенность декодера, отличающая его от энкодера — это применение механизма множественного внимания с маскировкой. Этот механизм позволяет обеспечить отсутствие утечки данных в циклическом рабочем процессе декодера. Благодаря этому декодер обрабатывает каждый токен независимо, что необходимо для задач, напоминающих задачи машинного перевода текстов. Подобная архитектура отражает то, как предложения конструируют люди, учитывая контекстные сведения, которые имеются в словах, предшествующих текущему слову. Далее, механизм множественного внимания энкодера-декодера добавляет понимание контекста, выработанное энкодером, в выходные данные декодера. Это — тот самый слой сети, который обеспечивает интеграцию энкодера и декодера.
Практическая реализация системы, созданная с помощью Python и NumPy, демонстрирует то, как декодер обрабатывает данные, выполняя машинный перевод текста.
Материал написан в соавторстве с Рафаэлем Нарди (Rafael Nardi).
О, а приходите к нам работать? 🤗 💰
Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.
Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.
Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.