Чу Ву, Шиюнь Цзоу, Руофан Ван

Обзор

Идея создания генератора текстов мотивирована примером генерации текста keras Франсуа Шолле, который использует LSTM для генерации текста из произведений Ницше. Наш проект основан на LSTM Rap Lyric Generator на уровне персонажа, который создан Караном Джайсингом. Этот проект можно найти в репозитории GitHub Карана Джайсинга. Он использовал набор данных Kaggle с более чем 380 000 текстов песен.

Наш проект использует Rap Lyric Generator на уровне персонажа в качестве базовой модели и пытается внести некоторые улучшения. Мы используем набор данных, который собирает тексты для 57650 песен на английском языке от LyricsFreak. Набор данных можно найти здесь. Основное изменение заключается в реализации текстового генератора LSTM на уровне слов вместо генератора на уровне символов. При векторизации слов мы используем горячее кодирование и встраивание слов. Мы ссылаемся на эту запись в блоге при предварительной обработке данных и настройке генераторов данных. Мы меняем архитектуру модели, добавляя отсевающие слои, а также пытаемся улучшить производительность модели, используя коллбэки с ранней остановкой, меняя оптимизатор и добавляя разделение обучения и тестирования.

Запускаем скрипт на GPU. Мы используем Python 3, TensorFlow 2.0.0 и Keras 2.2.4. Полный код и блокнот можно найти в этом репозитории GitHub.

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

Что такое ЛСТМ

Этот блог дает четкое объяснение и потрясающие визуализации LSTM. Сети с долговременной кратковременной памятью (LSTM) представляют собой особый вид рекуррентных нейронных сетей, которые имеют дело с последовательными данными, такими как тексты и данные временных рядов. LSTM широко используется для обработки естественного языка, поскольку он способен изучать долгосрочную информацию. Он был представлен Hochreiter & Schmidhuber (1997).

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

Данные

Базовая модель (Генератор лирики рэпа) принимает последовательности символов в качестве входных данных и предсказывает следующий символ в качестве выходных данных, но наш проект имеет дело с последовательностями слов. Следовательно, мы должны изменить способ представления данных.

Используемый нами набор данных включает 57650 песен с информацией об исполнителях, названиях песен, ссылках и текстах текстов, но нас интересуют только тексты текстов. Мы удалили несколько дублированных песен, и осталось 57494 песни. Каждая лирика записывается так:

«Посмотрите на ее лицо, это чудесное лицо.\nИ оно значит для меня что-то особенное.\nПосмотрите, как она улыбается, когда видит меня.\nКакое счастье может быть у одного парня? \n \nОна как раз в моем вкусе, с ней мне хорошо\nКто бы мог поверить, что она может быть моей? \nОна как раз в моем вкусе, без нее мне грустно\nИ если она когда-нибудь бросит меня, что я буду делать, что я буду делать? \n \nА когда мы идем гулять в парке, \nИ она держит меня и сжимает мою руку,\nМы будем гулять часами и говорить\nОбо всем, что мы планируем\n\nОна как раз в моем вкусе , с ней мне хорошо \nКто мог поверить, что она может быть моей? \nОна как раз в моем вкусе, без нее мне грустно\nИ если она когда-нибудь бросит меня, что я могу сделать, что я могу сделать?\n\n”

Чтобы поместить последовательности слов в наши модели, мы должны разбить текст на слова и урезать все 57000+ текстов вместе в длинный отрывок. Мы не хотим, чтобы тренировочный процесс был слишком долгим, поэтому просто берем одну десятую или лирику. Для обучения достаточно 5700+ текстов. Мы переводим все в нижний регистр, рассматриваем «\n» (перевод строки) как слово и избавляемся от специальных символов, таких как «?», «!», «(» и «)».

corpus = sys.argv[1] # first command line arg
text = df[‘text’].str.lower().str.replace(‘ \n \n’,’ \n’)
text = text.str.replace(‘ \n’, ‘ \n ‘)
text = text.str.replace(‘\n\n’, ‘ \n ‘)
text = text.str.replace(‘?’,’’)
text = text.str.replace(‘!’,’’)
text = text.str.replace(‘,’, ‘ ,’)
text = text.str.replace(‘ ‘, ‘ ‘)
text = text.str.replace(‘(‘, ‘’)
text = text.str.replace(‘)’, ‘’)
text_in_words = [word for word in text.str.split(‘ ‘) ]
text_all = “”
for i in range(0, len(text_in_words)):
if(i % 10 == 0):
for j in range(0,len(text_in_words[i])):
text_all += str(text_in_words[i][j]) + “ “
corpus = sys.argv[1] # first command line arg
print(‘Corpus length in characters:’, len(text_all))
text_all_in_words = [w for w in text_all.split(‘ ‘) if w.strip() != ‘’ or w == ‘\n’]
print(‘Corpus length in words:’, len(text_all_in_words))

В отрывке 1516264 слова и 34656 уникальных слов. Нереально закодировать слова в такие многомерные векторы. Поэтому мы используем только те слова, которые часто используются. Мы устанавливаем параметр MIN_WORD_FREQUENCY равным 450, что означает, что только те слова, которые встречаются в корпусе более 450 раз, будут считаться закодированными в окончательном словаре слов. После игнорирования этих слов у нас осталось только 367 уникальных слов. Затем мы создаем словари для перевода из слова в индекс и из индекса в слово. Каждое уникальное слово связано с уникальным индексом от 0 до 366. Это точно так же, как пример keras-team и эта запись в блоге.

# Calculate word frequency
word_freq = {}
for word in text_all_in_words:
word_freq[word] = word_freq.get(word, 0) + 1
MIN_WORD_FREQUENCY=450
ignored_words = set()
for k, v in word_freq.items():
if word_freq[k] < MIN_WORD_FREQUENCY:
ignored_words.add(k)
words = set(text_all_in_words)
print(‘Unique words before ignoring:’, len(words))
print(‘Ignoring words with frequency <’, MIN_WORD_FREQUENCY)
words = sorted(set(words) — ignored_words)
print(‘Unique words after ignoring:’, len(words))
# create the dictionaries
word_indices = dict((c, i) for i, c in enumerate(words))
indices_word = dict((i, c) for i, c in enumerate(words))

Теперь мы можем перевести отрывок в несколько последовательностей слов. Мы определяем SEQUENCE_LEN равным 10, что означает, что последовательность состоит из 10 слов (включая \n и ,). Последовательности, содержащие игнорируемые слова, также следует игнорировать, потому что мы не хотим вводить бессвязные предложения в модели. Код тот же, что и для этот пост в блоге. После игнорирования этих последовательностей мы сохраняем оставшиеся 172 393 последовательности в предложениях и соответствующее следующее слово в следующие_слова.

Для обучения модели мы используем функцию для перемешивания и разделения обучающего набора. Разделение тренировки-теста по умолчанию составляет 98%-2%. Ставим пропорцию 90%-10%.

def shuffle_and_split_training_set(sentences_original, next_original, percentage_test=10):
# shuffle at unison
print(‘Shuffling sentences’)
tmp_sentences = []
tmp_next_word = []
for i in np.random.permutation(len(sentences_original)):
tmp_sentences.append(sentences_original[i])
tmp_next_word.append(next_original[i])
cut_index = int(len(sentences_original) * (1.-(percentage_test/100.)))
x_train, x_test = tmp_sentences[:cut_index], tmp_sentences[cut_index:]
y_train, y_test = tmp_next_word[:cut_index], tmp_next_word[cut_index:]
print(“Size of training set = %d” % len(x_train))
print(“Size of test set = %d” % len(y_test))
return (x_train, y_train), (x_test, y_test)
(sentences, next_words), (sentences_test, next_words_test) = shuffle_and_split_training_set(sentences, next_words, percentage_test=10)

Размер обучающего набора — 155153, а размер тестового набора — 17240. Вот как выглядят «предложения» и «следующие_слова»:

In [2]: sentences[2]:
Out [2]: [‘\n’, ‘i’, ‘need’, ‘to’, ‘be’, ‘right’, ‘by’, ‘your’, ‘side’, ‘\n’]
In [3]: next_words[2]:
Out [3]: ‘every’

Генератор данных

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

1. Горячее кодирование

Для входного слоя LSTM требуется формат 3D (len(предложения), SEQUENCE_LEN, len(слова)). Ось 0 указывает количество последовательностей. Ось 1 представляет временные шаги. Здесь временные шаги равны количеству слов в последовательности (которое равно 10); это означает, что мы рассматриваем только одно слово в последовательности за шаг. Ось 2 — размерность вектора слов. Если мы используем метод горячего кодирования, каждое слово представляется в виде вектора 367*1. Все записи равны 0, кроме одной записи, которая представляет это конкретное слово. Например, индекс слова "a" в словаре равен 6. Слово "a" будет закодировано как вектор 367*1 [0 ,0,0,0,0,0,1,0,…,0]. Выходные данные представлены в формате 2D (len(предложения), len(слова)).

Однако мы не можем одновременно передать в модель все 155153 предложения, потому что это приводит к ошибкам нехватки памяти. Вместо этого нам нужно указать размер пакета, чтобы заменить общее количество предложений. Мы определяем batch_size = 128, что означает одновременное обучение 128 последовательностей для каждого шага.

def generator(sentence_list, next_word_list, batch_size):
index = 0
while True:
x = np.zeros((batch_size, SEQUENCE_LEN, len(words)), dtype=np.bool)
y = np.zeros((batch_size, len(words)), dtype=np.bool)
for i in range(batch_size):
for t, w in enumerate(sentence_list[index]):
x[i, t, word_indices[w]] = 1
y[i, word_indices[next_word_list[index]]] = 1
index = index + 1
if index == len(sentence_list):
index = 0
yield x, y

2. Встраивание слов

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

def generator_embedding(sentence_list, next_words_list, batch_size):
index = 0
while True:
x = np.zeros((batch_size, SEQUENCE_LEN), dtype=np.bool)
y = np.zeros((batch_size), dtype=np.bool)
for i in range(batch_size):
for t, w in enumerate(sentence_list[index % len(sentence_list)]):
x[i, t] = word_indices[w]
y[i] = next_words_list[index % len(sentence_list)]
index = index + 1
yield x, y

Пример keras text Tgeneration Франсуа Шолле предоставляет вспомогательную функцию sample для выборки индекса из массива вероятностей. Функция генерирует полиномиальное распределение прогнозов. Цель состоит в том, чтобы добавить некоторую случайность, чтобы не всегда выбиралось наиболее вероятное слово.

def sample(preds, temperature=1.0):
preds = np.asarray(preds).astype(‘float64’)
preds = np.log(preds) / temperature
exp_preds = np.exp(preds)
preds = exp_preds / np.sum(exp_preds)
probas = np.random.multinomial(1, preds, 1)
return np.argmax(probas)

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

print_callback = LambdaCallback(on_epoch_end=on_epoch_end)

Тренировочные модели

Сначала мы используем базовую модель для обучения новому набору данных, чтобы увидеть, как он работает. В модели используется LSTM на уровне символов. Он не имеет набора проверки/тестирования. Структура модели и вывод следующие:

model = Sequential()
model.add(LSTM(128, input_shape=(maxlen, len(chars))))
model.add(Dense(len(chars)))
model.add(Activation(‘softmax’))
optimizer = RMSprop(lr=0.01)
model.compile(loss=’categorical_crossentropy’, optimizer=optimizer, metrics=[‘accuracy’])
model.fit(x, y,
batch_size=128,
epochs=100,
callbacks=[print_callback])
Output[]:
 — — — Diversity: 1.0 — — -
Life is shark but as difffdef nio you in a riosame

Мы даем начальное значение "жизнь есть" и генерируем 50 символов. Базовая модель на уровне символов иногда генерирует тексты, не являющиеся английскими словами.

Затем мы вносим некоторые улучшения, используя входные данные на уровне слов, и добавляем набор проверки (sentences_test, next_words_test). Количество единиц LSTM составляет 128. Вход модели в формате 3D, как указано выше. Выходной слой применяет функцию активации softmax и возвращает вектор вероятности 367*1. Предсказание определяется индексом слова, где вероятность наибольшая. Исходная модель использует categorical_crossentropy в качестве функции потерь и использует RMSprop в качестве оптимизатора.

model_orig = Sequential()
model_orig.add(LSTM(128, input_shape=(SEQUENCE_LEN, len(words))))
model_orig.add(Dense(len(words)))
model_orig.add(Activation(‘softmax’))
model_orig.compile(loss=’categorical_crossentropy’, optimizer=RMSprop(lr=0.01), metrics=[‘accuracy’])
model_orig.summary()
history_orig = model_orig.fit(generator(sentences, next_words, batch_size=128),
steps_per_epoch=int(len(sentences)/batch_size) + 1,
epochs=50,
callbacks=[print_callback_orig],
validation_data=generator(sentences_test, next_words_test, batch_size),
validation_steps=int(len(sentences_test)/batch_size) + 1)

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

loss = history_orig.history[‘loss’]
val_loss = history_orig.history[‘val_loss’]
epochs = range(len(loss))
plt.figure()
plt.plot(epochs, loss, ‘bo’, label=’Training loss’)
plt.plot(epochs, val_loss, ‘b’, label=’Validation loss’)
plt.title(‘Training and validation loss’)
plt.legend()
plt.show()

Теперь давайте посмотрим тексты песен, сгенерированные этой моделью LSTM мирового уровня с семенем «жизнь есть». Параметр разнообразие используется функцией выборки, которая случайным образом выбирает следующее наиболее вероятное слово из вывода softmax.

print(‘ — — — Generating text — — -’)
for diversity in [0.2, 0.5, 1.0]:
print()
sentence = [‘life’,’is’]
original = “ “.join(sentence)
generated = sentence
window = sentence
finalText = ‘’
print(‘ — — — Diversity:’, diversity, ‘ — — -\n’)
for i in range(50):
x_pred = np.zeros((1, SEQUENCE_LEN, len(words)))
for t, word in enumerate(window):
x_pred[0, t, word_indices[word]] = 1.0
preds = model_orig.predict(x_pred, verbose=0)[0]
next_index = sample(preds, diversity)
next_word = indices_word[next_index]
finalText += “ “+next_word
window = window[1:] + [next_word]
print(original + finalText)
print(‘ — — — Text generation complete! — — -’)

Мы можем видеть, когда разнообразие низкое, есть много повторяющихся слов. Разнообразие выше 0,5 могло бы быть лучше. Использование ввода на уровне слов не генерирует странные «слова», как в модели на уровне символов. Но лирика по-прежнему не имеет смысла.

— — — Generating text — — -
— — — Diversity: 0.2 — — -
life is ’cause well well cause well well well well well well well well well well well ’cause well well well [chorus] well well well well well cause [chorus] cause well well yes well well well well well well [chorus] [chorus] well well well well cause well well well ’cause well well
— — — Diversity: 0.5 — — -
life is cause words cause people let’s in yes once cause crazy ’cause while we’ll well once [chorus] yes while but well well nobody yes well cause we’ll dead well [chorus] [chorus] we’ll cause once well ’cause then well woman well well then yes well way well well living ’cause its well
— — — Diversity: 1.0 — — -
life is woman you’re his end turn where side yes dreams people while in girl girl he friends if [chorus] out while let’s but well take ’cause what’s then if yes still stay well i’ve don’t let’s girl well ’cause we’ll god we’re ’cause hard [chorus] cause woman alone ’cause till that’s
— — — Text generation complete! — — -

Предыдущая модель является переоснащением, поэтому мы добавляем выпадающие слои. Показатель отсева установлен на 20%, что означает, что один из пяти входных данных будет случайным образом исключен из каждого цикла обновления. Выпадающие слои могут уменьшить переоснащение, потому что они заставляют каждый нейрон усваивать как можно больше информации. Мы также добавляем обратный вызов Early Stopping. Если точность проверки не становится выше зарегистрированной максимальной точности проверки после 5 эпох (пациент = 5), процесс обучения останавливается. Оптимизатор заменен на «Adam», что обычно дает более высокую производительность, чем «RMSProp».

model = Sequential()
model.add(LSTM(128, input_shape=(SEQUENCE_LEN, len(words))))
model.add(Dropout(0.2))
model.add(layers.Flatten())
model.add(Dense(len(words)+50, activation = ‘softmax’))
model.add(Dropout(0.2))
model.add(Dense(len(words), activation = ‘softmax’))
model.compile(loss=’categorical_crossentropy’, optimizer=’Adam’, metrics=[‘accuracy’])
model.summary()
history = model.fit_generator(generator(sentences, next_words, batch_size=128),
steps_per_epoch=int(len(sentences)/batch_size) + 1,
epochs=100,
callbacks=[print_callback,early_stopping],
validation_data=generator(sentences_test, next_words_test, batch_size),
validation_steps=int(len(sentences_test)/batch_size) + 1)

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

— — — Generating text — — -
— — — Diversity: 0.2 — — -
life is well [chorus] well well well well well well cause well well well ’cause well well well well well [chorus] cause well well cause well well well well well well well well well well well well well cause well well well well well well well well well well well well well
— — — Diversity: 0.5 — — -
life is let’s well far crazy well once [chorus] well give find well well cause well cause when yes [chorus] ah well well yes ’cause baby cause maybe what’s well cause but well when [chorus] time well cause blue there’s or an well there’s let’s yes well while well yes we’ll let’s
— — — Diversity: 1.0 — — -
life is maybe please when we’ll you’ll ’cause crazy hit sometimes hard face if i’ve we’ll maybe maybe well feeling [chorus] pain let’s but well i’ve living can’t while , then well sometimes wrong last she’s there’s cause crazy while ‘cause
you’re girl hey well kiss living if old when boy
— — — Text generation complete! — — -

Кодирование слов в многомерные векторы разреженных слов может быть причиной больших потерь. Использование softmax для поиска следующего слова с наивысшей вероятностью из 367 слов может иметь большую ошибку, потому что каждое слово получает очень похожую вероятность. Наконец, мы используем слой встраивания слов, чтобы увидеть, могут ли более плотные векторы слов с 64 единицами дать нам лучшие результаты. Мы также пытаемся заменить LSTM на GRU, который представляет собой архитектуру NLP, аналогичную LSTM. Объяснение ГРУ можно найти в этом блоге.

model2 = Sequential()
model2.add(Embedding(len(words), 64))
model2.add(Dropout(0.2))
model2.add(GRU(64))
model2.add(Dropout(0.2))
model2.add(Dense(len(words)))
model2.add(Activation(‘softmax’))
model2.compile(loss=’sparse_categorical_crossentropy’, optimizer=’Adam’, metrics=[‘accuracy’])
model2.summary()

— — — Generating text — — -
— — — Diversity: 0.5 — — -
life is life
best woman place
crazy touch still crazy
crazy girl hand touch face
sing rain fall real life ever
woman stop best life call
remember end light
woman world music
fight heart world
turn when matter song end
— — — Diversity: 0.8 — — -
life is sweet
even in happy run when dreams end left
play had sing world
light watch call done rain
world old still wrong
light thought boy did pain
crazy happy dreams heart play
it’s looking fool
just pain last without more

Хотя предложения не имеют смысла, эти тексты читаются как тексты песен. Такие слова, как «левый», «дождь», «боль» и «играть» в конце строк, рифмуются.

Как насчет того, чтобы изменить семя, сказав "Я люблю"? Вот некоторые примеры:

— — — Diversity: 0.6 — — -
i love blue world
crazy find heart world thought best world
face left live kiss world
more fall things find best made
girl start real woman sing fight
find wrong turn didn’t crazy
fall time enough very
dream girl hope crazy
hear an fall try give woman still dreams
— — — Diversity: 0.7 — — -
i love really happy
still lose wish bring feeling
heart fall hope fall face change woman heart fall
two left wrong stand done think call
there’s life came close
man light stay like morning fight
run hand day help only life
once play

Кажется, наш генератор текстов улавливает правила рифмовки. Наши тексты не имеют четкого смысла, но звучат рифмованно.

Будущие работы

Создавать собственные тексты — это весело! Мы определенно хотим узнать больше о генераторе текстов.

В этом проекте мы тренировались только на 5700+ песнях. На следующих шагах мы можем попытаться обучить весь набор данных и найти предварительно обученные модели для переноса обучения.

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