Трансформеры объяснили

С момента своего появления в 2017 году трансформеры произвели революцию в мире обработки естественного языка. До Transformers LSTM и RNN были современными технологиями. Причина, по которой трансформеры постоянно превосходят LSTM и RNN, заключается в том, что последние могут интерпретировать предложения только слева направо. Например, предположим, что у нас есть следующие предложения:

  • На берегу реки
  • На берегу реки

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

Архитектура, описанная в статье Внимание — это все, что вам нужно, состоит из кодера и декодера.

Входные вложения

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

Позиционное кодирование

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

Как следует из названия, позиционное кодирование кодирует положение слов в последовательности.

Формула для расчета позиционного кодирования:

Позиционное кодирование работает, потому что абсолютное положение менее важно, чем относительное. Например, нам не нужно знать, что слово «хороший» находится в индексе 6, а слово «выглядит» — в индексе 5. Достаточно помнить, что слово «хороший» имеет тенденцию следовать за словом «выглядит».

Вот график, созданный с использованием длины последовательности 100 и пространства для встраивания 512 измерений:

Для первого измерения, если значение равно 1, это нечетное слово, если значение равно 0, это четное слово. Для измерения d/2, если значение равно 1, мы знаем, что слово находится во второй половине предложения, а если значение равно 0, то оно находится в первой половине предложения. Модель может использовать эту информацию для определения относительного положения токенов.

Вход энкодера

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

Кодер

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

Масштабированное скалярное произведение внимания

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

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

После масштабирования значений мы применяем функцию softmax для получения значений от 0 до 1.

Наконец, мы умножаем полученную матрицу на вектор значений.

Многоголовое внимание

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

Например, учитывая слово «the», первая голова будет уделять больше внимания слову «банк», тогда как вторая голова уделит больше внимания слову «река».

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

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

Остаточные соединения, нормализация уровня и сеть прямого распространения

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

Вывод завершается прохождением через точечную сеть прямой связи.

Декодер

Задача декодера — генерировать текст. Декодер имеет такие же скрытые слои, что и кодировщик. Однако, в отличие от кодировщика, выходные данные декодера отправляются на уровень softmax, чтобы вычислить вероятность следующего слова в последовательности.

Вложения декодера и позиционное кодирование

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

Маскировка

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

Выход

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

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

питон

Учебник Google Модель Transformer для понимания языка уже отлично демонстрирует, как кодировать Transformer с нуля с использованием TensorFlow Keras. Таким образом, вместо этого мы увидим, как мы можем загрузить и использовать одну из предварительно обученных моделей.

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

! pip install -q -U "tensorflow-text==2.8.*" tf-models-official==2.7.0
import tensorflow as tf
import tensorflow_hub as hub
import tensorflow_text as text
from official.nlp import optimization  # to create AdamW optimizer
import matplotlib.pyplot as plt
import os
import shutil

Мы загружаем набор данных IMDB с помощью служебной функции Keras.

dataset = tf.keras.utils.get_file('aclImdb_v1.tar.gz', 'https://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz',
                                  untar=True, cache_dir='.',
                                  cache_subdir='')
dataset_dir = os.path.join(os.path.dirname(dataset), 'aclImdb')
train_dir = os.path.join(dataset_dir, 'train')
remove_dir = os.path.join(train_dir, 'unsup')
shutil.rmtree(remove_dir)

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

AUTOTUNE = tf.data.AUTOTUNE
batch_size = 32
seed = 42
raw_train_ds = tf.keras.utils.text_dataset_from_directory(
    'aclImdb/train',
    batch_size=batch_size,
    validation_split=0.2,
    subset='training',
    seed=seed)
class_names = raw_train_ds.class_names
train_ds = raw_train_ds.cache().prefetch(buffer_size=AUTOTUNE)
val_ds = tf.keras.utils.text_dataset_from_directory(
    'aclImdb/train',
    batch_size=batch_size,
    validation_split=0.2,
    subset='validation',
    seed=seed)
val_ds = val_ds.cache().prefetch(buffer_size=AUTOTUNE)
test_ds = tf.keras.utils.text_dataset_from_directory(
    'aclImdb/test',
    batch_size=batch_size)
test_ds = test_ds.cache().prefetch(buffer_size=AUTOTUNE)

Мы печатаем несколько записей, чтобы лучше понять, с чем мы работаем.

for text_batch, label_batch in train_ds.take(1):
  for i in range(3):
    print(f'Review: {text_batch.numpy()[i]}')
    label = label_batch.numpy()[i]
    print(f'Label : {label} ({class_names[label]})')
Review: b'"Pandemonium" is a horror movie spoof that comes off more stupid than funny. Believe me when I tell you, I love comedies. Especially comedy spoofs....'
Label : 0 (neg)
Review: b"David Mamet is a very interesting and a very un-equal director. His first movie 'House of Games' was the one I liked best, and it set a series of films with characters whose perspective of life changes as they get into complicated situations, and so does the perspective of the viewer...."
Label : 0 (neg)
Review: b'Great documentary about the lives of NY firefighters during the worst terrorist attack of all time....'
Label : 1 (pos)

Мы будем загружать и использовать предварительно обученные модели BERT из TensorFlow Hub.

tfhub_handle_encoder = 'https://tfhub.dev/tensorflow/small_bert/bert_en_uncased_L-4_H-512_A-8/1'
tfhub_handle_preprocess = 'https://tfhub.dev/tensorflow/bert_en_uncased_preprocess/3'

Модель предварительной обработки берет предложение и токенизирует его. Обратите внимание, как он также добавляет заполнение, чтобы гарантировать, что последовательность имеет длину 128 (требуется моделью BERT).

bert_preprocess_model = hub.KerasLayer(tfhub_handle_preprocess)
text_test = ['what a great movie!']
text_preprocessed = bert_preprocess_model(text_test)
print(f'Keys       : {list(text_preprocessed.keys())}')
print(f'Shape      : {text_preprocessed["input_word_ids"].shape}')
print(f'Word Ids   : {text_preprocessed["input_word_ids"][0, :12]}')
print(f'Input Mask : {text_preprocessed["input_mask"][0, :12]}')
print(f'Type Ids   : {text_preprocessed["input_type_ids"][0, :12]}')
Keys       : ['input_mask', 'input_type_ids', 'input_word_ids']
Shape      : (1, 128)
Word Ids   : [ 101 2054 1037 2307 3185  999  102    0    0    0    0    0]
Input Mask : [1 1 1 1 1 1 1 0 0 0 0 0]
Type Ids   : [0 0 0 0 0 0 0 0 0 0 0 0]

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

def build_classifier_model():
  text_input = tf.keras.layers.Input(shape=(), dtype=tf.string, name='text')
  preprocessing_layer = hub.KerasLayer(tfhub_handle_preprocess, name='preprocessing')
  encoder_inputs = preprocessing_layer(text_input)
  encoder = hub.KerasLayer(tfhub_handle_encoder, trainable=True, name='BERT_encoder')
  outputs = encoder(encoder_inputs)
  net = outputs['pooled_output']
  net = tf.keras.layers.Dropout(0.1)(net)
  net = tf.keras.layers.Dense(1, activation=None, name='classifier')(net)
  return tf.keras.Model(text_input, net)

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

classifier_model = build_classifier_model()
classifier_model.summary()

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

epochs = 5
steps_per_epoch = tf.data.experimental.cardinality(train_ds).numpy()
num_train_steps = steps_per_epoch * epochs
num_warmup_steps = int(0.1*num_train_steps)
init_lr = 3e-5
optimizer = optimization.create_optimizer(init_lr=init_lr, num_train_steps=num_train_steps, num_warmup_steps=num_warmup_steps, optimizer_type='adamw')

Мы компилируем модель, используя бинарную кроссэнтропию для функции потерь и AdamW для оптимизатора.

classifier_model.compile(optimizer=optimizer,
                   loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),
                         metrics=tf.metrics.BinaryAccuracy())

Обучаем модель.

history = classifier_model.fit(x=train_ds,
                               validation_data=val_ds,
                               epochs=epochs)
Epoch 1/5
625/625 [==============================] - 137s 203ms/step - loss: 0.5083 - binary_accuracy: 0.7452 - val_loss: 0.3831 - val_binary_accuracy: 0.8364
Epoch 2/5
625/625 [==============================] - 122s 195ms/step - loss: 0.3284 - binary_accuracy: 0.8520 - val_loss: 0.3700 - val_binary_accuracy: 0.8450
Epoch 3/5
625/625 [==============================] - 121s 194ms/step - loss: 0.2530 - binary_accuracy: 0.8949 - val_loss: 0.3833 - val_binary_accuracy: 0.8522
Epoch 4/5
625/625 [==============================] - 121s 194ms/step - loss: 0.1967 - binary_accuracy: 0.9232 - val_loss: 0.4424 - val_binary_accuracy: 0.8534
Epoch 5/5
625/625 [==============================] - 121s 193ms/step - loss: 0.1612 - binary_accuracy: 0.9385 - val_loss: 0.4716 - val_binary_accuracy: 0.8504

Мы оцениваем точность нашей модели на тестовом наборе данных.

loss, accuracy = classifier_model.evaluate(test_ds)
print(f'Loss: {loss}')
print(f'Accuracy: {accuracy}')
Loss: 0.4483765959739685
Accuracy: 0.8543199896812439

Мы строим потери и точность нашей модели с течением времени.

history_dict = history.history
acc = history_dict['binary_accuracy']
val_acc = history_dict['val_binary_accuracy']
loss = history_dict['loss']
val_loss = history_dict['val_loss']
epochs = range(1, len(acc) + 1)
fig = plt.figure(figsize=(10, 6))
fig.tight_layout()
plt.subplot(2, 1, 1)
plt.plot(epochs, loss, 'r', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.ylabel('Loss')
plt.legend()
plt.subplot(2, 1, 2)
plt.plot(epochs, acc, 'r', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend(loc='lower right')

Для понимания проведем вывод на нескольких примерах.

examples = [
    'this is such an amazing movie!',
    'The movie was great!',
    'The movie was meh.',
    'The movie was okish.',
    'The movie was terrible...'
]
results = tf.sigmoid(classifier_model(tf.constant(examples)))
result_for_printing = \
    [f'input: {examples[i]:<30} : score: {results[i][0]:.6f}'
                         for i in range(len(examples))]
print(*result_for_printing, sep='\n')
input: this is such an amazing movie! : score: 0.999392
input: The movie was great!           : score: 0.991764
input: The movie was meh.             : score: 0.515988
input: The movie was okish.           : score: 0.009715
input: The movie was terrible...      : score: 0.001295

Как мы видим, модель довольно хорошо классифицирует предложения как положительные или отрицательные. При этом «okish», вероятно, должно было быть ближе к «meh», чем к «ужасно».