Генерация 5-строчного текста в стиле GPT на Python с помощью TensorFlow/Keras

Трансформеры, хотя и выпущенные в 2017 году, только в последние пару лет начали набирать популярность. С распространением технологии через такие платформы, как HuggingFace, НЛП и Большие языковые модели (LLM) стали доступнее, чем когда-либо.

Тем не менее – даже при всей шумихе вокруг них и с многих ориентированных на теорию руководств, в Интернете не так много пользовательских реализаций, а ресурсы не так легко доступны, как в некоторых других типах сетей, которые существуют дольше. Хотя вы можете упростить свой рабочий цикл, используя готовый Transformer от HuggingFace (тема другого руководства), вы можете получить чувствовать как это работает, создав его самостоятельно, прежде чем абстрагировать его через библиотеку. Здесь мы сосредоточимся на построении, а не на теории и оптимизации.

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

Типы LLM и GPT-Fyodor

Хотя категоризация может стать гораздо более сложной, вы можете широко разделить языковые модели на основе Transformer на три категории:

  • Модели на основе кодировщика – АЛЬБЕРТ, БЕРТ, ДистильБЕРТ, РОБЕРТа
  • на основе декодера – ГПТ, ГПТ-2, ГПТ-3, TransformerXL
  • Модели Seq2Seq – БАРТ, мБАРТ, Т5

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

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

Seq2Seq модели сочетают в себе как кодировщики, так и декодеры и отлично подходят для генерации текста, суммирования и, самое главное, перевода.

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

generate_text('the truth ultimately is')

Который под капотом передает это приглашение в модель, подобную GPT, и производит:

'the truth ultimately is really a joy in history, this state of life through which is almost invisible, superfluous  teleological...'

Это, по сути, небольшой спойлер с конца руководства! Еще один небольшой спойлер — это архитектура, которая создала этот текст:

inputs = layers.Input(shape=(maxlen,))
embedding_layer = keras_nlp.layers.TokenAndPositionEmbedding(vocab_size, maxlen, embed_dim)(inputs)
transformer_block = keras_nlp.layers.TransformerDecoder(embed_dim, num_heads)(embedding_layer)
outputs = layers.Dense(vocab_size, activation='softmax')(transformer_block)
    
model = keras.Model(inputs=inputs, outputs=outputs)

5 строк — это все, что нужно, чтобы построить модель преобразователя только для декодера — имитирующую небольшой GPT. Так как мы будем обучать модель на романах Фёдора Достоевского (которые можно заменить чем угодно, от Википедии до комментариев на Reddit) — условно назовём модель GPT-Фёдор.

КерасНЛП

Трюк с 5-строчным GPT-Федором заключается в КерасНЛП, который разработан официальной командой Keras как горизонтальное расширение для Keras, которое в истинном стиле Keras направлено на то, чтобы сделать NLP с новыми уровнями (кодировщики, декодеры, встраивание токенов, встраивание позиций, метрики и т. токенизаторы и др.).

KerasNLP — не модельный зоопарк. Это часть Keras (как отдельный пакет), который снижает входной барьер для разработки модели NLP, точно так же, как он снижает входной барьер для общей разработки глубокого обучения с основным пакетом.

Примечание: На момент написания KerasNLP все еще производится и находится на ранних стадиях. В будущих версиях могут быть небольшие отличия. В записи используется версия 0.3.0.

Чтобы иметь возможность использовать KerasNLP, вам необходимо установить его через pip:

$ pip install keras_nlp

И вы можете проверить версию с помощью:

keras_nlp.__version__

Реализация модели в стиле GPT с помощью Keras

Давайте начнем с импорта библиотек, которые мы будем использовать — TensorFlow, Keras, KerasNLP и NumPy:

import tensorflow as tf
from tensorflow import keras
import keras_nlp
import numpy as np

Загрузка данных

Давайте загрузим несколько романов Достоевского — один из них был бы слишком коротким для модели, без достаточной переобучения, начиная с ранних стадий. Мы будем изящно использовать необработанные текстовые файлы из Проект Гутенберг, за счет простоты работы с такими данными:

crime_and_punishment_url = 'https://www.gutenberg.org/files/2554/2554-0.txt'
brothers_of_karamazov_url = 'https://www.gutenberg.org/files/28054/28054-0.txt'
the_idiot_url = 'https://www.gutenberg.org/files/2638/2638-0.txt'
the_possessed_url = 'https://www.gutenberg.org/files/8117/8117-0.txt'

paths = [crime_and_punishment_url, brothers_of_karamazov_url, the_idiot_url, the_possessed_url]
names = ['Crime and Punishment', 'Brothers of Karamazov', 'The Idiot', 'The Possessed']
texts = ''
for index, path in enumerate(paths):
    filepath = keras.utils.get_file(f'{names[index]}.txt', origin=path)
    text = ''
    with open(filepath, encoding='utf-8') as f:
        text = f.read()
        
        
        
        texts += text[10000:]

Мы просто скачали все файлы, просмотрели их и объединили один поверх другого. Это включает в себя некоторое разнообразие используемого языка, но при этом сохраняет его отчетливо Федор! Для каждого файла мы пропустили первые 10 500 символов, что соответствует средней длине предисловия и вступления Гутенберга, поэтому у нас остается почти неповрежденный текст книги для каждой итерации. Давайте взглянем на некоторые случайные XNUMX символов в texts строка сейчас:


texts[25000:25500]
'nd that was whynI addressed you at once. For in unfolding to you the story of my life, Indo not wish to make myself a laughing-stock before these idle listeners,nwho indeed know all about it already, but I am looking for a mannof feeling and education. Know then that my wife was educated in anhigh-class school for the daughters of noblemen, and on leaving shendanced the shawl dance before the governor and other personages fornwhich she was presented with a gold medal and a certificate of merit.n'

Давайте разделим строку на предложения, прежде чем выполнять какую-либо другую обработку:

text_list = texts.split('.')
len(text_list) 

У нас есть 69 тысяч предложений. Когда вы замените n символы с пробелами и считать слова:

len(texts.replace('n', ' ').split(' ')) 

Примечание: Как правило, вам нужно иметь в наборе данных не менее миллиона слов, а в идеале — гораздо больше. Мы работаем с несколькими мегабайтами данных (~ 5 МБ), в то время как языковые модели чаще обучаются на десятках гигабайтах текста. Это, естественно, упростит переоснащение ввода текста и затруднит его обобщение (высокое недоумение без переобучения или низкое недоумение с большим переоснащением). Воспринимайте результаты с недоверием.

Тем не менее, давайте разделим их на обучение, тестXNUMX и Проверка установлен. Во-первых, давайте удалим пустые строки и перемешаем предложения:


text_list = list(filter(None, text_list))

import random
random.shuffle(text_list)

Затем мы сделаем разделение 70/15/15:

length = len(text_list)
text_train = text_list[:int(0.7*length)]
text_test = text_list[int(0.7*length):int(0.85*length)]
text_valid = text_list[int(0.85*length):]

Это простой, но эффективный способ разделения обучения, тестирования и проверки. Давайте взглянем на text_train:

[' It was a dull morning, but the snow had ceased',
 'nn"Pierre, you who know so much of what goes on here, can you really havenknown nothing of this business and have heard nothing about it?"nn"What? What a set! So it's not enough to be a child in your old age,nyou must be a spiteful child too! Varvara Petrovna, did you hear what hensaid?"nnThere was a general outcry; but then suddenly an incident took placenwhich no one could have anticipated', ...

Время стандартизации и векторизации!

Векторизация текста

Сети не понимают слов — они понимают числа. Мы хотим токенизировать слова:

...
sequence = ['I', 'am', 'Wall-E']
sequence = tokenize(sequence)
print(sequence) # [4, 26, 472]
...

Кроме того, поскольку предложения различаются по длине, отступы обычно добавляются слева или справа, чтобы обеспечить одинаковую форму для всех вводимых предложений. Скажем, наше самое длинное предложение состоит из 5 слов (токенов). В этом случае предложение Wall-E будет дополнено двумя нулями, поэтому мы обеспечим одинаковую форму ввода:

sequence = pad_sequence(sequence)
print(sequence) # [4, 26, 472, 0, 0]

Традиционно это делалось с помощью TensorFlow. Tokenizer и Керас pad_sequences() методы — однако, гораздо более удобный слой, TextVectorization, можно использовать, что токенизирует и дополняет ваш ввод, позволяя извлекать словарный запас и его размер, даже не зная словарного запаса заранее!

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

Давайте адаптируем и подгоним TextVectorization слой:

from tensorflow.keras.layers import TextVectorization

def custom_standardization(input_string):
    sentence = tf.strings.lower(input_string)
    sentence = tf.strings.regex_replace(sentence, "n", " ")
    return sentence

maxlen = 50



vectorize_layer = TextVectorization(
    standardize = custom_standardization,
    output_mode="int",
    output_sequence_length=maxlen + 1,
)

vectorize_layer.adapt(text_list)
vocab = vectorize_layer.get_vocabulary()

Ассоциация custom_standardization() метод может получить намного дольше, чем это. Мы просто перевели все входные данные в нижний регистр и заменили n " ". Здесь вы действительно можете выполнить большую часть предварительной обработки текста и передать ее слою векторизации через необязательный standardize аргумент. Как только вы adapt() слой к тексту (массив NumPy или список текстов) — оттуда можно получить словарь, а также его размер:

vocab_size = len(vocab)
vocab_size 

Наконец, чтобы де-токенизировать слова, мы создадим index_lookup Словарь:

index_lookup = dict(zip(range(len(vocab)), vocab))    
index_lookup[5] 

Он сопоставляет все токены ([1, 2, 3, 4, ...]) к словам в словаре (['a', 'the', 'i', ...]). Передав ключ (индекс токена), мы можем легко вернуть слово. Теперь вы можете запустить vectorize_layer() на любом входе и наблюдайте за векторизованными предложениями:

vectorize_layer(['hello world!'])

Что приводит к:

<tf.Tensor: shape=(1, 51), dtype=int64, numpy=
array([[   1, 7509,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0]], dtype=int64)>

Hello имеет индекс 1 в то время как мир имеет индекс 7509! Остальное - дополнение к maxlen мы вычислили.

У нас есть средства для векторизации текста — теперь давайте создадим наборы данных из text_train, text_test и text_valid, используя наш слой векторизации в качестве среды преобразования между словами и векторами, которые можно передать в GPT-Fyodor.

Создание набора данных

Мы будем создавать tf.data.Dataset для каждого из наших наборов, используя from_tensor_slices() и предоставление списка тензорных срезов (предложений):

batch_size = 64

train_dataset = tf.data.Dataset.from_tensor_slices(text_train)
train_dataset = train_dataset.shuffle(buffer_size=256)
train_dataset = train_dataset.batch(batch_size)

test_dataset = tf.data.Dataset.from_tensor_slices(text_test)
test_dataset = test_dataset.shuffle(buffer_size=256)
test_dataset = test_dataset.batch(batch_size)

valid_dataset = tf.data.Dataset.from_tensor_slices(text_valid)
valid_dataset = valid_dataset.shuffle(buffer_size=256)
valid_dataset = valid_dataset.batch(batch_size)

После создания и перемешивания (опять же, на всякий случай) мы можем применить функцию предварительной обработки (векторизации и разделения последовательности):

def preprocess_text(text):
    text = tf.expand_dims(text, -1)
    tokenized_sentences = vectorize_layer(text)
    x = tokenized_sentences[:, :-1]
    y = tokenized_sentences[:, 1:]
    return x, y


train_dataset = train_dataset.map(preprocess_text)
train_dataset = train_dataset.prefetch(tf.data.AUTOTUNE)

test_dataset = test_dataset.map(preprocess_text)
test_dataset = test_dataset.prefetch(tf.data.AUTOTUNE)

valid_dataset = valid_dataset.map(preprocess_text)
valid_dataset = valid_dataset.prefetch(tf.data.AUTOTUNE)

Ассоциация preprocess_text() функция просто расширяется по последнему измерению, векторизует текст с помощью нашего vectorize_layer и создает входы и цели, смещенные одним токеном. Модель будет использовать [0..n] сделать вывод n+1, что дает прогноз для каждого слова с учетом всех предшествующих слов. Давайте посмотрим на одну запись в любом из наборов данных:

for entry in train_dataset.take(1):
    print(entry)

Исследуя возвращенные входные данные и цели в пакетах по 64 (с длиной по 30), мы можем ясно увидеть, как они смещены на единицу:

(<tf.Tensor: shape=(64, 50), dtype=int64, numpy=
array([[17018,   851,     2, ...,     0,     0,     0],
       [  330,    74,     4, ...,     0,     0,     0],
       [   68,   752, 30273, ...,     0,     0,     0],
       ...,
       [    7,    73,  2004, ...,     0,     0,     0],
       [   44,    42,    67, ...,     0,     0,     0],
       [  195,   252,   102, ...,     0,     0,     0]], dtype=int64)>, <tf.Tensor: shape=(64, 50), dtype=int64, numpy=
array([[  851,     2,  8289, ...,     0,     0,     0],
       [   74,     4,    34, ...,     0,     0,     0],
       [  752, 30273,  7514, ...,     0,     0,     0],
       ...,
       [   73,  2004,    31, ...,     0,     0,     0],
       [   42,    67,    76, ...,     0,     0,     0],
       [  252,   102,  8596, ...,     0,     0,     0]], dtype=int64)>)

Наконец – пришло время построить модель!

Определение модели

Здесь мы будем использовать слои KerasNLP. После Input, мы будем кодировать ввод через TokenAndPositionEmbedding слой, проходящий в нашем vocab_size, maxlen и embed_dim, Такой же embed_dim что этот слой выводит и вводит в TransformerDecoder будет сохраняется в декодере. На момент написания статьи декодер автоматически поддерживает входную размерность и не позволяет вам проецировать ее на другой выход, но позволяет определить скрытые размерности через intermediate_dim аргумент.

Мы умножим размеры встраивания на два для скрытого представления, но вы можете оставить его таким же или использовать число, отдельное от размеров встраивания:

embed_dim = 128
num_heads = 4

def create_model():
    inputs = keras.layers.Input(shape=(maxlen,), dtype=tf.int32)
    embedding_layer = keras_nlp.layers.TokenAndPositionEmbedding(vocab_size, maxlen, embed_dim)(inputs)
    decoder = keras_nlp.layers.TransformerDecoder(intermediate_dim=embed_dim, 
                                                            num_heads=num_heads, 
                                                            dropout=0.5)(embedding_layer)
    
    outputs = keras.layers.Dense(vocab_size, activation='softmax')(decoder)
    
    model = keras.Model(inputs=inputs, outputs=outputs)
    
    model.compile(
        optimizer="adam", 
        loss='sparse_categorical_crossentropy',
        metrics=[keras_nlp.metrics.Perplexity(), 'accuracy']
    )
    return model

model = create_model()
model.summary()

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

Model: "model_5"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 input_6 (InputLayer)        [(None, 30)]              0         
                                                                 
 token_and_position_embeddin  (None, 30, 128)          6365824   
 g_5 (TokenAndPositionEmbedd                                     
 ing)                                                            
                                                                 
 transformer_decoder_5 (Tran  (None, 30, 128)          132480    
 sformerDecoder)                                                 
                                                                 
 dense_5 (Dense)             (None, 30, 49703)         6411687   
                                                                 
=================================================================
Total params: 13,234,315
Trainable params: 13,234,315
Non-trainable params: 0
_________________________________________________________________

GPT-2 складывает много декодеров — GPT-2 Small имеет 12 сложенных декодеров (117 миллионов параметров), а GPT-2 Extra Large имеет 48 сложенных декодеров (1.5 миллиарда параметров). Наша модель с одним декодером со скромными параметрами 13M должна работать достаточно хорошо для учебных целей. С LLM масштабирование оказалось чрезвычайно хорошей стратегией, а Transformers обеспечивают хорошее масштабирование, что делает возможным обучение очень больших моделей.

ГПТ-3 имеет «скудный» 175В параметры. Команда Google Brain обучила модель параметров 1.6T для проведения исследований разреженности, сохраняя при этом вычисления на том же уровне, что и гораздо меньшие модели.

Собственно говоря, если бы мы увеличили количество декодеров с 1 до 3:

def create_model():
    inputs = keras.layers.Input(shape=(maxlen,), dtype=tf.int32)
    x = keras_nlp.layers.TokenAndPositionEmbedding(vocab_size, maxlen, embed_dim)(inputs)
    for i in range(4):
        x = keras_nlp.layers.TransformerDecoder(intermediate_dim=embed_dim*2, num_heads=num_heads,                                                             dropout=0.5)(x)
    do = keras.layers.Dropout(0.4)(x)
    outputs = keras.layers.Dense(vocab_size, activation='softmax')(do)
    
    model = keras.Model(inputs=inputs, outputs=outputs)

Наше количество параметров будет увеличено на 400 тыс.:

Total params: 13,631,755
Trainable params: 13,631,755
Non-trainable params: 0

Большинство параметров в нашей сети поступают из TokenAndPositionEmbedding и Dense слои!

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

Пользовательский обратный вызов

class TextSampler(keras.callbacks.Callback):
    def __init__(self, start_prompt, max_tokens):
        self.start_prompt = start_prompt
        self.max_tokens = max_tokens
        
    
    
    def sample_token(self, logits):
        logits, indices = tf.math.top_k(logits, k=5, sorted=True)
        indices = np.asarray(indices).astype("int32")
        preds = keras.activations.softmax(tf.expand_dims(logits, 0))[0]
        preds = np.asarray(preds).astype("float32")
        return np.random.choice(indices, p=preds)

    def on_epoch_end(self, epoch, logs=None):
        decoded_sample = self.start_prompt
        
        for i in range(self.max_tokens-1):
            tokenized_prompt = vectorize_layer([decoded_sample])[:, :-1]
            predictions = self.model.predict([tokenized_prompt], verbose=0)
            
            
            
            
            sample_index = len(decoded_sample.strip().split())-1
            
            sampled_token = self.sample_token(predictions[0][sample_index])
            sampled_token = index_lookup[sampled_token]
            decoded_sample += " " + sampled_token
            
        print(f"nSample text:n{decoded_sample}...n")


random_sentence = ' '.join(random.choice(text_valid).replace('n', ' ').split(' ')[:4])
sampler = TextSampler(random_sentence, 30)
reducelr = keras.callbacks.ReduceLROnPlateau(patience=10, monitor='val_loss')

Обучение модели

Наконец, время тренироваться! Давайте бросим в наш train_dataset и validation_dataset с обратными вызовами на месте:

model = create_model()
history = model.fit(train_dataset, 
                    validation_data=valid_dataset,
                    epochs=10, 
                    callbacks=[sampler, reducelr])

Сэмплер выбрал неудачное предложение, которое начинается с конечной кавычки и начальной кавычки, но все же дает интересные результаты при обучении:

# Epoch training
Epoch 1/10
658/658 [==============================] - ETA: 0s - loss: 2.7480 - perplexity: 15.6119 - accuracy: 0.6711
# on_epoch_end() sample generation
Sample text:
”  “What do you had not been i had been the same man was not be the same eyes to been a whole man and he did a whole man to the own...
# Validation
658/658 [==============================] - 158s 236ms/step - loss: 2.7480 - perplexity: 15.6119 - accuracy: 0.6711 - val_loss: 2.2130 - val_perplexity: 9.1434 - val_accuracy: 0.6864 - lr: 0.0010
...
Sample text:
”  “What do you know it is it all this very much as i should not have a great impression  in the room to be  able of it in my heart...

658/658 [==============================] - 149s 227ms/step - loss: 1.7753 - perplexity: 5.9019 - accuracy: 0.7183 - val_loss: 2.0039 - val_perplexity: 7.4178 - val_accuracy: 0.7057 - lr: 0.0010

Он начинается с:

«Каким бы ты не был, я был таким же»…

Что на самом деле не имеет особого смысла. К концу десяти коротких эпох он производит что-то вроде:

«Что вы имеете в виду, что это самый обычный мужчина из мужчин, конечно»…

Хотя второе предложение по-прежнему не имеет особого смысла — оно гораздо более осмысленно, чем первое. Более длительное обучение на большем количестве данных (с более сложными этапами предварительной обработки) даст лучшие результаты. Мы обучили его только на 10 эпохах с высоким отсевом, чтобы справиться с небольшим размером набора данных. Если бы его тренировали намного дольше, он бы выдал текст, очень похожий на Федора, потому что запомнил бы его большие куски.

Примечание: Поскольку вывод довольно подробный, вы можете настроить verbose аргумент при подгонке модели, чтобы уменьшить количество текста на экране.

Вывод модели

Для выполнения логического вывода нам нужно воспроизвести интерфейс TextSampler – метод, который принимает затравку и response_length (max_tokens). Мы будем использовать те же методы, что и в сэмплере:

def sample_token(logits):
        logits, indices = tf.math.top_k(logits, k=5, sorted=True)
        indices = np.asarray(indices).astype("int32")
        preds = keras.activations.softmax(tf.expand_dims(logits, 0))[0]
        preds = np.asarray(preds).astype("float32")
        return np.random.choice(indices, p=preds)

def generate_text(prompt, response_length=20):
    decoded_sample = prompt
    for i in range(response_length-1):
        tokenized_prompt = vectorize_layer([decoded_sample])[:, :-1]
        predictions = model.predict([tokenized_prompt], verbose=0)
        sample_index = len(decoded_sample.strip().split())-1

        sampled_token = sample_token(predictions[0][sample_index])
        sampled_token = index_lookup[sampled_token]
        decoded_sample += " " + sampled_token
    return decoded_sample

Теперь вы можете запустить метод на новых образцах:

generate_text('the truth ultimately is')


generate_text('the truth ultimately is')

Улучшение результатов?

Итак, как можно улучшить результаты? Есть несколько довольно действенных вещей, которые вы могли бы сделать:

  • Очистка данных (более тщательная очистка входных данных, мы просто обрезали примерное число с самого начала и убрали символы новой строки)
  • Получите больше данных (мы работали только с несколькими мегабайтами текстовых данных)
  • Масштабируйте модель вместе с данными (суммировать декодеры несложно!)

Заключение

Хотя конвейер предварительной обработки является минималистичным и может быть улучшен, конвейер, описанный в этом руководстве, создал достойную модель в стиле GPT, всего 5 строк кода, необходимых для создания собственного преобразователя только для декодера с использованием Keras!

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

Отметка времени:

Больше от Стекабьюс