Автор статьи: Виктория Ляликова

Всем привет! 

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

Например, мы хотим научить нейронную сеть отличать кошек от собак или может яблоко от апельсина. Тогда мы просто говорим ей, что есть что, и на основе представленных данных, такая нейронная сеть особенным образом находит закономерности и строит самое обычное уравнение, которое в зависимости от подаваемого ей набора данных, пытается классифицировать изображение. Если мы хотим научить сеть предсказывать какие-то значения (погода, курс валют, отток клиентов банка), то мы понимаем, что ей надо предоставить выборку прошлых лет на основе которой она сможет найти закономерности, также сформировать уравнение и спрогнозировать результат. 

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

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

Генерируем текст с помощью цепей Маркова

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

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

Рассмотрим такую скороговорку:

Саша машет Маше, Маша машет Саше, Маша Саши краше, Саша краше Паши

В теории цепей - это корпус, то есть исходный материал, по которому мы будем составлять взаимосвязи.

Для простоты опустим знаки препинания и большие буквы. Посчитаем сколько раз встречается каждое слово в тексте:

саша - 2
машет - 2
маше - 1
маша - 2
саше - 1
саши - 1
краше -2
паши -1

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

саша→(машет, краше).
машет → (маше, саше)
маше→ (маша)
маша →  (машет, саши)
саше → (маша)
саши→ (краше)
краше→ (саша, паши)

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

НАЧАЛО → саша
паши→ КОНЕЦ

То есть получается, что когда наш алгоритм дойдет до слов “саша” “машет”, “маша”, “краше” ему придется с какой-то долей вероятности выбрать одно из слов.

Посмотрим как считать эти вероятности. Возьмем пару 

краше→ (саша, паши)

При этом “саша” у нас встречается 2 раза, “паши” один раз. А всего вместе эти слова встречаются 3 раза. Получается, что после “краше” вероятность продолжения текста:

  • словом “саша” - 2\3

  • словом “паши” - 1\3

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

Например, такие результаты могут получиться из наших связей. 

Саша краше Паши
Саша краше Саша машет Маше Маша Саши краше Паши.
Саша машет Маше Маша машет Саше Маша машет Маше Маша Саши краше Паши

и так далее.

На самом деле серьезные алгоритмы используют не схемы, а матрицы, внутри которых и записаны все вероятности и переходы.

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

Готовим данные

Теперь попробуем генератор цепей Маркова в деле, а для этого нам нужен исходный текст (или корпус). В качестве набора данных возьмем датасет, содержащий стихотворения русских писателей, например, Лермонтова, Пушкина, Маяковского, Фета и т.д. Подключим библиотеки и посмотрим на данные с которыми будем работать:

import numpy as np
df = pd.read_csv('poems.csv')
df=df.dropna()
df.head()

Мы будем работать только с текстом, поэтому будем использовать только столбец “text”. Сначала проведем очистку текста. Для этого объединим все стихотворения в одну строку, а затем приведем текст к нижнему регистру и удалим знаки пунктуации (для упрощения модели), используя регулярные выражения. 

# объединяем все поэмы в один текст
combined_text = " ".join(df['text'])
# обработка текста
def preprocess_text(text):
   # перевод в нижний регистр и удаление пунктуации
   text = re.sub(r'[^\w\s]', '', text.lower())
   return text
processed_text = preprocess_text(combined_text)

Реализуем цепь Маркова

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

corpus = processed_text.split()
# функция-генератор
def make_pairs(corpus):
   # перебираем все слова в корпусе, кроме последнего
   for i in range(len(corpus) - 1):
       # генерируем новую пару 
       yield (corpus[i], corpus[i + 1])
# вызываем генератор и получаем все пары слов
pairs = make_pairs(corpus)
# словарь, на старте пока пустой
word_dict = {}
# перебираем все слова попарно из списка пар
for word_1, word_2 in pairs:
   # если первое слово уже есть в словаре
   if word_1 in word_dict.keys():
# добавляем второе слово как возможное продолжение 
первого
       word_dict[word_1].append(word_2)
   # если же первого слова у нас в словаре не было
   else:
       # создаём новую запись в словаре и указываем второе слово как продолжение первого
       word_dict[word_1] = [word_2]
# случайно выбираем первое слово для старта
first_word = np.random.choice(corpus)
# делаем наше первое слово первым звеном
chain = [first_word]
# сколько слов будет в готовом тексте
n_words = 30
# делаем цикл с заданным количеством слов
for i in range(n_words):
   # на каждом шаге добавляем следующее слово из словаря, выбирая его случайным образом из доступных вариантов
   chain.append(np.random.choice(word_dict[chain[-1]]))
# выводим результат
print(' '.join(chain))

Смотрим на результат.

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

Генерируем текст с помощью нейронной сети

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

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

Возьмем, например предложение “Мой дядя самых честных правил” и составим из него униграмму, биграмму и триграмму.

Униграмма → (Мой дядя самых честных правил)
Биграмма → (Мой, дядя), (дядя, самых), (самых, честных), (честных правил)
Триграмма → (Мой, дядя, самых) (самых, честных, правил)

Таким образом с помощью n-грамм мы можем предсказывать следующее слово после n-1 слов на основе вероятности их сочетания.

Готовим данные для обучения

Для генерации текста лучше всего подходят LSTM (Long Short Term Memory) нейронные сети. Опираясь на то, что было написано выше про генерацию текста,  получается, что наша нейросетевая модель будет учиться предсказывать следующее слово в последовательности столько раз, сколько мы ей скажем, получая таким образом новый текст, обучаясь на том наборе данных, который мы ей предоставим. Данные те же самые, с которыми мы работали с генератором Маркова. Датасет конечно большой и содержит аж 19000 небольших стихотворений (отрывков), но чтобы можно было увидеть результат, я буду использовать только его часть. 

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

'на серебряные шпоры\nя в раздумии гляжу\nза тебя скакун мой скорый\nза бока твои дрожу\nнаши предки их не знали\nи гарцуя средь степей\nтолстой плеткой погоняли\nнедоезжаных коней\nно с успехом просвещенья\nвместо грубой старины\nвведены изобретенья\nчужеземной стороны\nв наше время кормят холят\nберегут спинную честь\nпрежде били  нынче колют\nчто же выгодней  бог весть'

Теперь нам надо создать массив с индексами слов, где каждому слову в текстовых данных будет присваиваться уникальный индекс. Для этого будем использовать инструмент Tokenizer библиотеки keras и метод fit_on_texts().

from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences

# Токенизация
tokenizer = Tokenizer()
# подгонка текста
tokenizer.fit_on_texts([processed_text])

И выведем индексы слов, какие получились после токенизации, для фрагмента текста, представленного выше. Это можно сделать с помощью метода word_index.

tokenizer.word_index
{'в': 1,
 'за': 2,
 'на': 3,
 'серебряные': 4,
 'шпоры': 5,
 'я': 6,
 'раздумии': 7,
 'гляжу': 8,
 'тебя': 9,
 'скакун': 10,
 'мой': 11,
 'скорый': 12,
 'бока': 13,
 'твои': 14,
 'дрожу': 15,
 'наши': 16,
 'предки': 17,
 'их': 18,
 'не': 19,
 'знали': 20,
 'и': 21,
 'гарцуя': 22,
 'средь': 23,
 'степей': 24,
.......

Далее нам необходимо перебрать все строки (каждую строку будем рассматривать как отдельное предложение) и создать входные последовательности для нейронной сети путем постепенного добавления токенов (индекса слова). Далее из каждой строки (последовательности) создаем n-грамму последовательностей и добавляем ее в список.

input_sequences = []
for line in processed_text.split('\n'):
	token_list = tokenizer.texts_to_sequences([line])[0]
	for i in range(1, len(token_list)):
    	n_gram_sequence = token_list[:i+1]
    	input_sequences.append(n_gram_sequence)

Результат применения метода text_to_sequences, который переводит текст в последовательность целых чисел путем замены каждого слова на  его номер в словаре.

[3, 4, 5]
[6, 1, 7, 8]
[2, 9, 10, 11, 12]
[2, 13, 14, 15]

Исходный текст был

На серебряные шпоры
Я в раздумии гляжу
За тебя скакун мой скорый
За бока твои дрожу
.......

После получения n-грамм посмотрим как выглядит наша последовательность

input_sequences
[[3, 4],
 [3, 4, 5],
 [6, 1],
 [6, 1, 7],
 [6, 1, 7, 8],
 [2, 9],
 [2, 9, 10],
 [2, 9, 10, 11],
 [2, 9, 10, 11, 12],
 [2, 13],
 [2, 13, 14],
 [2, 13, 14, 15]]
.......

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

Далее нам необходимо обеспечить одинаковую длину для всех последовательностей, для этого дополним нулями каждую последовательность до максимальной длины. Здесь поможет функция pad_sequences().

max_sequence_len = max([len(x) for x in input_sequences])
input_sequences = np.array(pad_sequences(input_sequences, maxlen=max_sequence_len, padding='pre'))
array([[ 0,  0,  0,  3,  4],
       [ 0,  0,  3,  4,  5],
       [ 0,  0,  0,  6,  1],
       [ 0,  0,  6,  1,  7],
       [ 0,  6,  1,  7,  8],
       [ 0,  0,  0,  2,  9],
       [ 0,  0,  2,  9, 10],
       [ 0,  2,  9, 10, 11],
       [ 2,  9, 10, 11, 12],
       [ 0,  0,  0,  2, 13],
       [ 0,  0,  2, 13, 14],
       [ 0,  2, 13, 14, 15]

Все, выборка для обучения сети готова. 

Строим модель

Разделим теперь выборку на вектор признаков X и вектор меток Y. Вектор Х будет содержать дополненные входные последовательности без последнего слова. Вектор меток Y будет содержать последнее слово каждой дополненной последовательности.

x= input_sequences[:,:-1]
labels = input_sequences[:,-1]

Определим размер входного вектора, который будет равен общему количеству уникальных слов в нашем словаре + еще одно слово для учета дополненного слова:

# считаем количество слов
total_words = len(tokenizer.word_index) + 1

Нейронная сеть будет состоять из трех уровней.

  1. Embedding слой, на вход поступает последовательность из количества уникальных слов, на выходе получаем векторы размером 200 для каждого слова во входной последовательности.

  2. LSTM уровень состоит из 150 модулей LSTM. 

  3. Dense является полносвязным слоем с функцией активации softmax. Принимает входные данные слоя LSTM и прогнозирует распределение вероятностей по всем словам в словаре, которые равны числу классов. 

Общее количество обучаемых параметров составляет 10 668 996 для датасета из 500 отрывков стихотворений.

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Embedding, LSTM, Dense
model = Sequential()
model.add(Embedding(total_words, 200))
model.add(LSTM(150))
model.add(Dense(total_words, activation='softmax'))

Компилируем нейронную сеть, используя оптимизатор Адама и разреженную категориальную кросс-энтропию, так как у нас много классов и чтобы избежать методов преобразования вектора Y в категориальный формат, так как это требует много ресурсов.

model.compile(optimizer='adam',
              	loss='sparse_categorical_crossentropy',
              	metrics=['accuracy'])

Будем обучать модель в течение 100 эпох

history = model.fit(x,labels, epoch=100, verbose=1)

Оцениваем модель

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

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

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

Теперь вопрос, а как рассчитать это показатель перплексии? Если кратко, то для определения перплексии в обработке естественного языка используется такая формула

q(x_i) - это предсказания сделанные нашей моделью на основании обучающего множества

Создадим функцию для вычисления перплексии:

def calculate_perplexity(model, xs):
	# вычисление вероятностей для каждого слова в последовательности
	predictions = model.predict(xs)
	cross_entropy = 0.0
    
	for i in range(len(predictions)):
    	    cross_entropy += -np.log(np.max(predictions[i]))
	avg_cross_entropy = cross_entropy / len(predictions)
    
	# вычисляем перплексию
	perplexity = np.exp(avg_cross_entropy)
	return perplexity

# вычисляем перплексию для нашей модели
perplexity = calculate_perplexity(model, x)
print("Perplexity:", perplexity)

Генерируем текст

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

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

Создаем функцию для генерации текста:

def generate_text(seed_text, model, tokenizer, max_sequence_len, num_words):
	generated_text = seed_text
	for _ in range(num_words):
      # токенизация текста
    	token_list = tokenizer.texts_to_sequences([seed_text])[0]
      # заполнение токенизированной последовательности
    	token_list = pad_sequences([token_list], maxlen=max_sequence_len-1, padding='pre')
     # получаем вероятности предсказаний для каждого слова
    	predictions = model.predict(token_list, verbose=0)
   	 
    	# Получаем индекс для следующего слова, используя случайное слово из распределения вероятностей
    	predicted_index = np.random.choice(len(predictions[0]), p=predictions[0])
   	# Сопоставляем индекс с соответствующим словом
    	output_word = ""
    	for word, index in tokenizer.word_index.items():
        	if index == predicted_index:
            	output_word = word
            	break	 
    	# Добавляем предсказанное слово в сгенерированный текст
    	generated_text += " " + output_word
     	# Обновляем исходный текст
    	seed_text = " ".join(seed_text.split()[1:]) + " " + output_word
    	return generated_text

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

# Входной текст
seed_text = "Я любил"
generated_text = generate_text(seed_text, model, tokenizer, max_sequence_len, num_words=35)
print("Generated Text:")

Также мы можем сохранить токенизатор и обученную языковую модель в отдельные файлы:

# сохранение обученной модели
model.save('my_model.h5')
# сохранение токенизатора в файл
with open('tokenizer.pickle', 'wb') as handle:
    pickle.dump(tokenizer, handle, protocol=pickle.HIGHEST_PROTOCOL)

Результаты

После 10 эпох обучения на наборе в 100 отрывков стихотворений:

Perplexity: 7.058923777230348

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

После 50 эпох обучения на наборе в 100 отрывков стихотворений:

Perplexity: 1.7203394007635984

Generated Text:
Я любил но был под бурей тягостных не излечит горе над озером видал я тебя с душой безнадежной иногда презирая на бале быть проводником к мелькнет на коней будут метель число коня кинул медленно грызет от дневных русский весь в траве почти в тебе доныне как всякий биться рад так вдруг в

После 100 эпох обучения на наборе в 100 отрывков стихотворений:

Perplexity: 1.5875433038328622

Generated Text:
Я любил но был она как смерть темна и видят ктото на влажной взор и вдруг знакомый измаил как она на каменной скале сырой два спокойно родная род и в глубине пещеры своих зачем своих ей или тот на борзом чудной уж забыты сходятся венцом и девы бледные черты его недвижный трон

После 30 эпох обучения на наборе в 500 отрывков

Perplexity: 1.9448061951154562

Generated Text:
Я любил тебя тот рок моей любви не знает но она сама придет но ты не обвиняй страдальца молодого кустов отгоняя светлей мгновенно блеск земля ее язык скрывался я писал с презреньем гордым кинешь за порог и в

После 150 эпох обучения на наборе в 500 отрывков

Perplexity: 1.5106264558327417

Generated Text:
Я любил послушать ни тихих ласк ночных им до два глаз твоей статую иолая и отметил этеокла не глядит и с каждой ветром над тихим висела приближаться страстей между веку скал раздор да хром лгут царей чаще

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

Про генерацию текста и другие инструменты и возможности машинного обучения эксперты OTUS рассказывают в рамках практических онлайн-курсов. С полным каталогом курсов можно ознакомиться по ссылке.