RNN: может ли нейронная сеть писать как Лев Толстой? (Спойлер: нет)
При изучении технологий Deep Learning я столкнулся с нехваткой относительно простых примеров, на которых можно относительно легко потренироваться и двигаться дальше.
В данном примере мы построим рекуррентную нейронную сеть, которая получив на вход текст романа Толстого «Анна Каренина», будет генерировать свой текст, чем-то напоминающий оригинал, предсказывая, какой должен быть следующий символ.
Структуру изложения я старался делать такой, чтобы можно было повторить все шаги новичку, даже не понимая в деталях, что именно происходит внутри этой сети. Профессионалы Deep Learning скорее всего не найдут тут ничего интересного, а тех, кто только изучает эти технологии, прошу под кат.
Введение
За основу этого мини-проекта были взяты статьи Andrej Karpathy (ссылки ниже по тексту) и учебные материалы udacity. Самый простой путь повторить все описанное ниже:
- установить у себя на ПК дистрибутив anaconda с версией Python 3.6
- создать рабочий conda environment
- установить в этот environment библиотеки tensorflow, numpy, jupyter
- писать и исполнять код в Jupyter Notebook, что дает нам нужную интерактивность текст романа в txt формате
1. Создаем папку, в которой будем работать, копируем туда текст под именем «anna.txt»
2. Запускаем Anaconda Promt, переходим в созданную папку, создаем там нужный environment «tolstoy» с необходимыми библиотеками и активируем его:
3. Когда все библиотеки установятся, запускаем jupyter notebook, в котором будем работать:
4. В браузере открывается меню notebook, там идем в «New» и выбираем Notebook -> Python 3, как показано на картинке:
После чего открывается уже сам notebook, где мы будем вбивать код и любоваться результатом его работы. Например, вбив код в ячейку «In», мы можем его выполнить нажатием Shift+Enter и сразу получить результат:
К этому моменту мы разобрались с базовыми вещами, теперь можно приступать к самой задаче. Ниже приведена общая архитектура рекуррентной нейронной сети (RNN), предсказывающей следующий символ (взято отсюда):
На схеме видна ключевая особенность RNN — информация может обрабатываться циклично при движении от input к output, обеспечивая (в отличие от традиционных нейронных сетей) эффект памяти и позволяя обрабатывать связанные последовательности.
Инициализируем и готовим данные
Импортируем нужные библиотеки:
Загружаем текст романа, создаем словарь символов vocab, объекты dictionary для трансляции символ -> код, код -> символ и кодируем весь текст романа (массив encoded):
Проверяем начало, знаменитая фраза на месте, все в порядке:
Смотрим, как это выглядит в закодированном виде (именно в таком виде данные будут обрабатываться в сети):
Поскольку наша сеть работает с отдельными символами, мы имеем дело с проблемой классификации, когда мы пытаемся предсказать следующий символ из предыдущего текста. Длина словаря это по сути количество классов, из которых наша сеть будет делать выбор:
Символов в словаре многовато, но нужно учитывать, что заглавные и строчные буквы — это разные символы, а также помним про большое количество текста на французском, т.е. у нас по сути два алфавита.
Делим данные на пакеты
Для эффективного обучения нашей сети необходимо разбить данные на пакеты (mini-batches). Во-первых это экономит оперативную память. Если мы попытаемся загнать в сеть все данные целиком за один раз, памяти может просто не хватить. Во-вторых при дроблении данных на пакеты сеть будет обучаться значительно быстрее — мы можем обновлять веса в нейронной сети после прохождения каждого пакета данных, а также параллелить загрузку пакетов, как показано на картинке:
Создаем процедуру получения исходного пакета, который будет подаваться на вход нейронной сети (feature) и контрольного пакета, с которым будет сравниваться предсказание сети (target):
Функция работает как генератор, каждое обращение к которому позволяет получить следующую пару «x» и «y», например:
В выводе виден сдвиг пакета «y» по отношению к пакету «х».
Строим модель
Ниже приведена схема нашей RNN модели:
Основная магия обучения происходит в ячейке LSTM (Long Short Term Memory). Вот здесь лежит замечательная статья, в которой простым и понятным английским языком описывается логика работы таких ячеек и нейронных сетей, основанных на LSTM.
При построении модели сначала определяем входящие параметры:
Надо напомнить, что данные в Tensorflow хранятся в тензорах. "placeholder'ы" — вид тензоров, который определяет тип и формат данных (например размерность матрицы), а сами данные реально будут загружены в нужный момент в будущем. Что касается drop out — это механизм противодействия эффекту «переобучения» нашей сети, когда в процессе мы случайным образом исключаем часть вершин нашего графа из расчетов:
Дальше мы строим структуру LTSM ячейки.
Далее будем строить выходной слой. Определяемся с размерностью. Если входные данные имели размерность M (batch size), N (sequence length) и проходили через скрытые слои размером L юнитов, то на выходе получаем 3D тензор размерностью MxNxL. Чтобы упростить задачу, сделаем reshape 3D -> 2D и приведем тензор к виду (M∗N)×L. Таким образом, у нас будет одна строка для каждой последовательности и каждого «шага» и значения каждой строки — выход из LSTM юнитов. Данную матрицу перемножаем на матрицу весов выходного уровня и прибавляем смещение выходного уровня.
При этом инициализируем веса случайными величинами с усеченным нормальным распределением (в диапазоне 2 стандартных отклонений), а смещения (bias) инициализируем нулями, что является рекомендуемой практикой в нейронных сетях.
Результат выходного слоя пропускаем через функцию активации softmax (более подробно о функциях активации здесь), используя результат работы этой функции в качестве предсказателя.
Дальше мы определяем функцию потери (то есть измеряем, насколько мы ошиблись). Для этого вычисляем softmax cross entropy между значениями logit-функции и label (которые в свою очередь являются целевыми значениями, прошедшими через one-hot кодирование). В deep learning часто используют one-hot кодирование для представления категорийных переменных в виде бинарных векторов, чтобы их было удобнее использовать в дальнейших вычислениях. Например, последовательность данных:
[red, yellow, green] мы можем закодировать в integer (как мы сделали выше в переменной encoded) в:
[0, 1, 2] а после one-hot кодирования это будет выглядеть так:
[[1, 0, 0], [0, 1, 0], [0, 0, 1]] Функцию потери в deep learning считают по разному. Для задач классификации объектов, которые относятся к взаимоисключающим классам (в нашем случае следующий символ не может являться одновременно «а» и «б»), функцию потери считают через функцию softmax cross entropy with logits и возвращаем среднее значение этой функции всех элементов по всем измерениям тензора.
Дальше строим оптимизатор, который основан на методе градиентного спуска. При этом мы защищаемся от двух проблем (подробнее — здесь):
- «исчезновение» градиента (защита встроена в логику работы LSTM ячеек);
- «взрыв» градиента (для этого мы здесь используем gradient clipping).
Теперь собираем все детали пазла вместе и строим класс, описывающий нашу сеть. Ключевой оператор, формирующий RNN сеть — tf.nn.dynamic_rnn. Он возвращает вывод каждой LSTM ячейки на каждом шаге, для каждой последовательности, в каждом пакете (mini-batch). Кроме этого, он возвращает финальный статус LSTM ячеек, который мы сохраняем и передаем на вход в первую LSTM ячейку при загрузке следующего пакета данных. На вход tf.nn.dynamic_rnn мы подаем ячейку (cell), начальный статус, который мы получаем из build_lstm и входную последовательность данных.
Подбираем гиперпараметры
Далее задаем гиперпараметры для нашей модели. Тут есть большое пространство для творчества, поскольку меняя эти параметры можно «выжимать» из сети больше. Подробно останавливаться на стратегии настройки не буду, так как это отдельная большая тема, которой посвящено множество статей и исследований.
Обучаем модель
Теперь приступаем к обучению нашей модели. Запускаем входные и целевые данные в сеть, запускаем оптимизацию. Для каждого пакета (mini-batch) сохраняем окончательный LSTM статус, который отдаем на вход в сеть при следующем пакете, обеспечивая преемственность. Периодически (определяется переменной save_every_n) сохраняем состояние нашей модели (со всеми переменными, весами и т.д.) в checkpoint. Здесь есть еще один параметр — количество эпох (полных циклов обучения модели). Также необходимо напомнить, что вся работа с данными в Tensorflow ведется в рамках открытой сессии, что обычно начинается с кода with tf.Session() as sess: .
Дальше наблюдаем процесс обучения:
Мы видим постепенное уменьшение training loss.
На моем ПК этот процесс обучения занял порядка 6 часов. При наличии машины с хорошим GPU этот срок может быть уменьшен в разы.
Проверяем наши сохраненные чекпоинты:
Генерируем текст
Теперь можем приступать к сэмплированию, то есть к генерации текста. Идея в том, что подавая на вход в сеть один символ, мы получаем на выходе предсказанный символ, который мы добавим в сгенеренный текст и подадим его опять на вход в сеть на следующей итерации и т.д. Исключение — это текст для «разогрева» модели, подаваемый на вход в параметре prime.
Функцию pick_top_n используем для уменьшения «шума» предсказаний, оставляя для выбора только заданное количество (по умолчанию 5) вариантов символов, отбрасывая все остальные варианты.
Теперь генерируем текст и смотрим что получилось.
Для начала — раннее состояние модели (после 200 итераций).
С одной стороны получилась какая-то ерунда. С другой стороны мы видим, что у нейронной сети начинает формироваться понимание слов как набора символов, разделенных пробелами и даже использование некоторых знаков препинания.
Идем дальше (после 600-й итерации).
Здесь мы видим и «слова» стали подлиннее, наметились какие-то зачатки диалогов. В какой-то момент сетка даже ругнулась матом :)
В общем положительная динамика налицо.
Ну и результат последней итерации.
Здесь мы видим, что слова, в основном, правильно складываются из букв. Размечены диалоги, неплохо расставлены знаки препинания и т.д. Если смотреть издалека и не вчитываться в текст, выглядит достойно.
Заключение
Очевидно, что писать как Лев Толстой наша сеть все-таки не научилась, но прогресс по мере обучения налицо. При этом чтобы двигаться в сторону большей осмысленности, нужно использовать другие методы (например word embedding), поскольку с помощью char-wise RNN можно относительно легко получить хорошую грамматику, но добиться смысла от текста видимо непросто.
Тем не менее этот пример иллюстрирует то, какая магия может происходить внутри нейронной сети, при том, что никакие правила, никакая грамматика языка на вход ей не подается и до всего этого ей приходится додумываться самой.
Разумеется на вход можно подавать другой текст (желательно не менее объемный), на любом языке, играться с гиперпараметрами и получать какие-то другие результаты. Надеюсь, даже простое повторение описанных шагов может кого-то побудить разобраться в том, как тут все устроено и я гарантирую, что на этом пути у вас будет много интересных открытий :)