Умоляю перестань мне сниться
Я люблю тебя моя невеста
Белый иней на твоих ресницах
Поцелуй на теле бессловесном

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

Вернее, нейронная сеть нужна лишь для первого этапа — расстановки слов в правильном порядке. С рифмовкой справляются правила, применяемые поверх предсказаний нейронной сети. Хотите узнать подробнее, как мы это реализовывали? Тогда добро пожаловать под кат.

Языковые модели


Определение


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

Языковые модели определяют вероятность появления последовательности слов w_1, \ldots, w_n в данном языке: \mathbf{P}(w_1, \ldots, w_n). Перейдём от этой страшной вероятности к произведению условных вероятностей слова от уже прочитанного контекста:

\mathbf{P}(w_1, \ldots, w_n) = \prod_{i=1}^n \mathbf{P}(w_i | w_1, \ldots, w_{i-1}).

В жизни эти условные вероятности показывают, какое слово мы ожидаем увидеть дальше. Посмотрим, например, на всем известные слова из Пушкина:



Языковая модель, которая сидит у нас (во всяком случае, у меня) в голове, подсказывает: после честных навряд ли снова пойдёт мой. А вот и, или, конечно, правил — очень даже.

N-граммные языковые модели


Кажется, самым простым способом построить такую модель является использование N-граммной статистики. В этом случае мы делаем аппроксимацию вероятности \mathbf{P}(w_i | w_1, \ldots, w_{i-1}) \approx \mathbf{P}(w_i | w_{i - N}, \ldots, w_{i-1}) — отбрасывая слишком далекие слова, как не влияющие на вероятность появления данного.

Такая модель легко реализуется с помощью Counter’ов на Python — и оказывается весьма тяжелой и при этом не слишком вариативной. Одна из самых заметных её проблем — недостаточность статистики: большая часть 5-грамм слов, в том числе и допустимых языком, просто не встретится в сколько-то ни было большом корпусе.

Для решения такой проблемы используют обычно сглаживание Kneser–Ney или Katz’s backing-off. За более подробной информацией про методы сглаживания N-грамм стоит обратиться к известной книге Кристофера Маннинга “Foundations of Statistical Natural Language Processing”.

Хочу заметить, что 5-граммы слов я назвал не просто так: именно их (со сглаживанием, конечно) Google демонстрирует в статье “One Billion Word Benchmark for Measuring Progress in Statistical Language Modeling” — и показывает результаты, весьма сопоставимые с результатами у рекуррентных нейронных сетей — о которых, собственно, и пойдет далее речь.
Нейросетевые языковые модели

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



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

Рыжей стрелкой на картинке показано отображение слова в его эмбеддинг (embedding). Выходной слой (в простейшем случае) — полносвязный слой с размером, соответствующим размеру словаря, имеющий softmax активацию — для получения распределения вероятностей для слов словаря. Из этого распределения можно сэмплировать следующее слово (или просто брать максимально вероятное).

Уже по картинке виден минус такого слоя: его размер. При словаре в несколько сотен тысяч слов его он легко может перестать влезать на видеокарту, а для его обучения требуются огромные корпуса текстов. Это очень наглядно демонстрирует картинка из блога torch:



Для борьбы с этим было придумано весьма большое количество различных приемов. Наиболее популярными можно назвать иерархический softmax и noise contrastive estimation. Подробно про эти и другие методы стоит почитать в отличной статье Sebastian Ruder.

Оценивание языковой модели


Более-менее стандартной функцией потерь, оптимизируемой при многоклассовой классификации, является кросс-энтропийная (cross entropy) функция потерь. Вообще, кросс-энтропия между вектором y и предсказанным вектором \hat y записывается как H(y, \hat{y}) = - \sum_i y_i \log_2 \hat y_i. Она показывает близость распределений, задаваемый y и \hat y.
При вычислении кросс-энтропии для многоклассовой классификации \hat y_i — это вероятность i-ого класса, а y — вектор, полученный с one-hot-encoding (т.е. битовый вектор, в котором единственная единица стоит в позиции, соответствующей номеру класса). Тогда H(y, \hat y) = - \log_2 \hat y_k при некотором y_k = 1.

Кросс-энтропийные потери целого предложения w_1, \ldot, w_n получаются усреднением значений по всем словам. Их можно записать так: H(w_1, \ldot, w_n) = - \frac 1 n \sum_k \log_2 \mathbf{P}(w_k | w_1, \ldots, w_{k-1}). Видно, что это выражение соответствует тому, чего мы и хотим достичь: вероятность реального предложения из языка должна быть как можно выше.

Кроме этого, уже специфичной для языкового моделирования метрикой является перплексия (perplexity):

PP(w_1, \ldot, w_n) = 2^{H(w_1, \ldot, w_n)} = 2^{- \frac 1 n \sum_k \log_2 \mathbf{P}(w_k | w_1, \ldots, w_{k-1})}.

Чтобы понять её смысл, посмотрим на модель, предсказывающую слова из словаря равновероятно вне зависимости от контекста. Для неё \mathbf{P}(w) = \frac 1 N, где N — размер словаря, а перплексия будет равна размеру словаря — N. Конечно, это совершенно глупая модель, но оглядываясь на неё, можно трактовать перплексию реальных моделей как уровень неоднозначности генерации слова.

Скажем, в модели с перплексией 100 выбор следующего слова также неоднозначен, как выбор из равномерного распределения среди 100 слов. И если такой перплексии удалось достичь на словаре в 100 000, получается, что удалось сократить эту неоднозначность на три порядка по сравнению с “глупой” моделью.

Реализация языковой модели для генерации стихов


Построение архитектуры сети


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

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

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

Очевидных варианта тут два: использовать many-to-many вариант (для каждого слова пытаться предсказать следующее) или же many-to-one (предсказывать слово по последовательности предшествующих слов).

Чтобы лучше понимать суть проблемы, посмотрим на картинку:



Здесь изображен many-to-many вариант со словарем, в котором не нашлось места слову “чернил”. Логичным шагом является подстановка вместо него специального токена <unk> — незнакомое слово. Проблема в том, что модель радостно выучивает, что вслед за любым словом может идти незнакомое слово. В итоге, выдаваемое ею распределение оказывается смещено в сторону именно этого незнакомого слова. Конечно, это легко решается: нужно всего лишь сэмплировать из распределение без этого токена, но всё равно остается ощущение, что полученная модель несколько кривовата.

Альтернативным вариантом является использование many-to-one архитектуры:



При этом приходится нарезать всевозможные цепочки слов из обучающей выборки — что приведет к заметному её разбуханию. Зато все цепочки, для которых следующее слов — неизвестное, мы сможем просто пропускать, полностью решая проблему с частым предсказанием <unk> токена.

Такая модель имела у нас следующие параметры (в терминах библиотеки keras):



Как видно, в неё включено 60000 + 1 слово: плюс первый токен это тот самый <unk>.

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

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

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

Попробуем пошаманить немного с моделью. Недостаток сети, судя по всему — в слишком большом количестве параметров. Сеть просто-напросто не дообучается. Чтобы исправить это, следует поработать с входным и выходным слоями — самыми тяжелыми элементами модели.

Доработка входного слоя


Очевидно, имеет смысл сократить размерность входного слоя. Этого можно добиться, просто уменьшив размерность словоформенных эмебедингов — но интереснее пойти другим путём.

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



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

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

Грамматическое значение мы представляли в формате Universal Dependencies, благо у меня как раз была под рукой модель, обученная для Диалога-2017.

При подаче грамматического значения на вход модели оно переводится в битовую маску: для каждой грамматической категории выделяются позиции по числу граммем в этой категории — плюс одна позиция для отсутствия данной категории в грамматическом значении (Undefined). Битовые вектора для всех категорий склеиваются в один большой вектор.

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

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

Доработка выходного слоя


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

Эта проблема легко исправляется двумя способами. Честный путь — сэмплировать именно слово из действительно реализуемых пар лемма + грамматическое значение (вероятностью этого слова, конечно, будет произведение вероятностей леммы и грамматического значения). Более быстрый альтернативный способ — это выбирать наиболее вероятное грамматическое значение среди возможных для сэмплированной леммы.

Кроме того, softmax-слой можно было заменить иерархическим softmax’ом или вообще утащить реализацию noise contrastive estimation из tensorflow. Но нам, с нашим размером словаря, оказалось достаточно и обыкновенного softmax. По крайней мере, вышеперечисленные ухищрения не принесли значительного прироста качества модели.

Итоговая модель


В итоге у нас получилась следующая модель:



Обучающие данные


До сих пор мы никак не обсудили важный вопрос — на чём учимся. Для обучения мы взяли большой кусок stihi.ru и добавили к нему морфологическую разметку. После этого отобрали длинные строки (не меньше пяти слов) и обучались на них.

Каждая строка рассматривалась как самостоятельная — таким образом мы боролись с тем, что соседние строки зачастую слабо связаны по смыслу (особенно на stihi.ru). Конечно, можно обучаться сразу на полном стихотворении, и это могло дать улучшение качества модели. Но мы решили, что перед нами стоит задача построить сеть, которая умеет писать грамматически связный текст, а для такой цели обучаться лишь на строках вполне достаточно.

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

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

Схематично предобработка текстов может быть изображена так:



Стрелки означают направление, в котором модель читает предложение.

Реализация генератора


Правила-фильтры


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

Метрические правила определяют последовательность ударных и безударных слогов в строке. Записываются они обычно в виде шаблона из плюсов и минусов: плюс означает ударный слог, а минусу соответствует безударный. Например, рассмотрим метрический шаблон + — + — + — + — (в котором можно заподозрить четырёхстопный хорей):


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

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



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

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

Лучевой поиск


В результате работы фильтров вполне могло не остаться ни одного слова. Для решения этой проблемы мы делаем лучевой поиск (beam search), выбирая на каждом шаге вместо одного сразу N путей с наивысшими вероятностями.



Итого, входные параметры генератора — языковая модель, метрический шаблон, шаблон рифмы, N в лучевом поиске, параметры эвристики рифмовки. На выходе же имеем готовое стихотворение. В качестве языковой модели в этом же генераторе можно использовать и N-граммную модель. Система фильтров легко кастомизируется и дополняется.

Примеры стихов


Так толку мне теперь грустить
Что будет это прожито
Не суждено кружить в пути
Почувствовав боль бомжика

Затерялся где то на аллее
Где же ты мое воспоминанье
Я люблю тебя мои родные
Сколько лжи предательства и лести
Ничего другого и не надо
За грехи свои голосовые

Скучаю за твоим окном
И нежными эфирами
Люблю тебя своим теплом
Тебя стенографируя

Ссылки



Пост был написан совместно с Гусевым Ильёй. В проекте также принимали участие Ивашковская Елена, Карацапова Надежда и Матавина Полина.

Работа над генератором была проделана в рамках курса “Интеллектуальные системы” кафедры Компьютерной лингвистики ФИВТ МФТИ. Хотелось бы поблагодарить автора курса, Константина Анисимовича, за советы, которые он давал в процессе.

Большое спасибо atwice за помощь в вычитке статьи.
Поделиться с друзьями
-->

Комментарии (26)


  1. frees2
    25.07.2017 09:07
    +3

    Гениально, даже не верится, может создатели кода подправили машине строки?

    Жизнь — форма времени. Карп и лещ — сгустки его. И товар похлеще — сгустки. Включая волну и твердь
    суши. Включая смерть, (106; 361)
    Бродский

    Ещё до 21 века попробовал через машину писать, только перебором тропов (словари тропов), а не синонимов как у вас.
    Стих ящика для доносов, куда учащиеся должны были кидать послания.

    Я мудрый бокс,
    А не какой то глупый мопс.
    Моя стихия — стихи,
    Я не люблю протянутой руки,
    В которой мятая бумага,
    Мои квадратные бока
    Довольны вами лишь тогда — Когда заветные приветы
    Отлиты в звонкие сонеты,
    Рондо, буриме и триолеты.
    Я красоту, гармонию люблю,
    Все письма, тайны сохраню!


    1. DanAnastasyev
      25.07.2017 17:33

      Гениально, даже не верится, может создатели кода подправили машине строки?

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


      1. nckma
        26.07.2017 09:37

        А мне кажется, что ваш поэт в своем уме никогда и не был.
        Стихи интересные, но бред.
        Вам к Грефу нужно обратиться: http://www.rbc.ru/business/23/07/2017/5974b7a69a79477896b6708d


  1. robert_ayrapetyan
    25.07.2017 09:32
    +7

    Шуму этому господину


  1. alekssamos
    25.07.2017 09:33

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


    1. Muxto
      25.07.2017 10:23
      +2

      Это уже было в Футураме)


      1. alekssamos
        25.07.2017 10:33

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


        1. Kirhgoff
          26.07.2017 03:41
          +3

          Я генератора музыки даже слушаю иногда, когда хочется чего-то фонового. Его зовут мьюберт и он классный:
          http://play.mubert.com/en/


  1. madroot
    25.07.2017 09:45
    +2

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


  1. Analitik_Telecom
    25.07.2017 09:46
    +5

    Это круче, чем автопоэт Яндекса!


    1. zhikin2207
      25.07.2017 10:34
      +6

      Кто-то из Яндекса минусанул)


      1. Subrisk
        25.07.2017 11:59
        +9

        Сам автопоэт минуснул.


  1. frees2
    25.07.2017 10:19

    1 Тропы *( Метафоры и прочее. Всё уже написано до нас, как в шахматах, машина лучше играет и выбирает ходы, чем человек, плюс машина сочинит свои. На то и нейросети.)

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

    Практическое применение в голосовых программах, вопрос-ответных системах вроде Сири.


  1. olekl
    25.07.2017 11:47
    +3

    Скучаю за твоим окном
    И нежными эфирами
    Люблю тебя своим теплом
    Тебя стенографируя

    Если это машина написала, то реально круть! :)

    А еще интересно — какой процент сгенерированных стихов читабельный, а какой — сразу в мусорку?


    1. Falseclock
      25.07.2017 12:15
      +4

      там и позабористей есть

      Не могу ответить мне поверьте
      Я не знаю. Каждый день морозный
      Душу гложет. У меня на сердце
      Страх тревожит разум мой колхозный


      Так толку мне теперь грустить
      Что будет это прожито
      Не суждено кружить в пути
      Почувствовав боль бомжика


      1. Kirhgoff
        26.07.2017 03:43

        С бомжиком так себе, а вот «разум колхозный» — ого-го, прямо мысль глубокая


  1. bagrintsev
    25.07.2017 12:18
    +1

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


  1. analvikingur
    25.07.2017 12:18

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


  1. lggswep
    25.07.2017 16:38
    +1

    Такое ощущение, что с помощью такой нейросети, пишутся большинство современных песен ;)


  1. kurganski
    25.07.2017 17:24

    Не показывайте это Филиппу Киркорову!!!


    1. Alex_Sa
      26.07.2017 14:23
      +1

      Ну да, добавить сюда нейросеть по сочинению музыки и прикрутить все к вокалайзеру. Открываем ИИ Радио.


  1. lxsmkv
    25.07.2017 22:50

    жду поетри-слэм между нейросетями :)


  1. PavelSandovin
    25.07.2017 23:14

    Дайте роботу Введенского и Хармса. Он все равно не понимает смысла слов. Хотя бы смешно будет

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


    1. lggswep
      28.07.2017 17:53

      Прям как песни Эдуарда Сурового)))


  1. pantlmn
    26.07.2017 21:29

    Хорошо бы про ударения поподробнее. Это реально важная прикладная задача.


  1. rama
    02.08.2017 20:10

    Спасибо!
    Установил, играюсь )
    Как бы теперь заменить базу стихиры на что-нибудь более поэтическое.