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


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


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


./pa_send.sh "Иди уже спать!"


image


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


Первым был вариант, когда в качестве такого переводчика использовался ChatScript. Он предназначен немного не для того — работа с ChatScript это "подбор ответа по вопросу", а мне надо "подбор перевода по вопросу", но кто мешает использовать инструмент не по назначению? Кроме ChatScript я смотрел на RiveScript и в итоге остановился именно на нем.


$ ./human2pa.py aragaer Напомни мне сделать коммит, пожалуйста
remind aragaer to "сделать коммит"

Каких-то начальных фраз мне хватило, поэтому я переключился на другие куски бота — в частности "мозг", который я решил писать на common lisp. Мозг пробрасывает человеческую речь в переводчик, а потом работает с фразами на ботовском языке. Затем он конструирует ответ на ботовском, переводит в человеческую через того же переводчика, а потом отдает ответ.


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


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


Я сегодня сделал 10 маки-ути

маки-ути?
Что такое маки-ути и почему их надо делать каждый день — не совсем та тема, которую я тут описываю. Просто это одна из тех команд, которые я реализовал в качестве proof-of-concept. Зато она содержит некоторый параметр, который боту требуется вычленить.

maki-uchi log 10


Я стал думать о том, как бы мне можно было сделать что-то более удобное. Например, чтобы парсить было проще:


maki-uchi report count: 10


Если считать двоеточия частью слова (такой особый синтаксис), то можно было бы сделать что-то похожее на p-list:


:maki-uchi report :count 10


Но потом я вдруг понял, что тот же самый p-list я в лиспе могу получить значительно более простым способом. Все взаимодействие между модулями моего бота реализовано через json. А значит можно сразу переводить из человеческой речи в json. В качестве примера того, как это все должно парситься, у меня перед глазами обычно возникал Dialogflow. К сожалению, мне он был не очень интересен — во-первых, я хочу реализовать этот перевод самостоятельно, а во-вторых… есть все-таки вопрос приватности. Мне бы хотелось, чтобы моим ботом могли пользоваться люди, которые не доверяют облачным сервисам. А так поставили и у себя локально пользуются.


Может ли нейросеть сгенерировать валидный json?


Я решил начать с эксперимента. Среди всех фраз, которые бот должен уметь получить, это фраза от переводчика "я не понял, что именно человек тут пытался выразить". Для этого достаточно было бы уметь произвольную строчку, например "hello, world" превратить в {"unknown": "hello, world"}


image


На RiveScript такая задача решается тривиально.


+ *
- {"unknown": "<star>"}

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


Я написал скрипт, который брал 39 слов, которые я просто не сильно задумываясь набрал, и собирал все возможные сочетания от одного до четырех слов. Затем брал 200 тысяч из них, перемешивал, 120 тысяч отправлял для тренировки, по 40 уходило на вычисление ошибки и на кросс-валидацию. Затем я запустил nmt на всем этом деле обучаться.


И это был… провал.


Во-первых, с точки зрения nmt hello и "hello это совершенно разные слова. Во-вторых, отдельными словами являются также любые сочетания непробельных символов. Моя целевая строка стала выглядеть как { "unknown" : " hello world test " } – с кучей пробелов в разных местах. И при этом nmt стабильно… не писала закрывающие кавычку и фигурную скобку. Методом проб и ошибок я обнаружил, что если исходная фраза выглядит как hello world test END, то в таком случае ответ наконец получается такой, какой надо.


human last dog black END -> { "unknown" : " human last dog black " }

Но любая незнакомая ситуация сбивала сетку с толку:


teach human rest green stack END -> { "unknown" : " teach human rest bag " }
stack white light green mirror END -> { "unknown" : " stack white light word " }

И это просто добавление пятого слова, которое тоже было в словаре. Беда.


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


Отступление: таймер


Одним из потенциальных сценариев работы бота предполагался такой диалог:


- Ты уже перечитал свои заметки за сегодня?
- Еще нет, напомни через полчаса.
- Хорошо

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


Таймер принимает команды трех видов:


  • {"command": "add", "name": "строка", "what": <любой кусок json>, "delay": число, "repeat": число} — repeat и delay являются опциональными, остальное обязательно. Ненулевой repeat означает, что событие будет периодичным.
  • {"command": "modify", "name": "строка", "what": <любой кусок json>, "delay": число, "repeat": число} — тут необязательными являются repeat, delay и what, если они не указаны, то в событии name соответствующий параметр не изменяется.
  • {"command": "cancel", "name": "строка"} — тут вроде все очевидно.

Эксперимент номер два


Фразы, которые требовалось переводить, выглядели примерно так:


print "hello" after 30 seconds every 7 seconds
modify "asdf" to print every 10 seconds
cancel "qwerty"

Этим фразам должны были соответствовать команды


{ "command": "add" , "name": "hello" , "delay": 30 , "repeat": 7 }
{ "command": "modify" , "name": "asdf" , "repeat": 10 }
{ "command": "cancel" , "name": "qwerty" }

Упс, я забыл про what. Впрочем, на эксперименте это сказалось несильно.


Сначала я написал скрипт, который генерил файлы для обучения:


  • Случайное действие — add, modify или cancel
  • Каждой команде соответствует несколько слов, которые означают эту команду. Например для add это print, emit, send
  • Для команды modify после указания имени добавлено to и одно из слов, соответствующих add
  • Если это add или modify, то случайно может быть или не быть delay и/или repeat. Для modify требуется наличие хотя бы одного из двух
  • поле name это случайный набор символов
  • поля repeat и delay, если есть, это случайные числа (порядка от 0 до 200)
  • В словаре присутствуют открывающая и закрывающая фигурные скобки, запятая. Слова, которые в json будут именами полей, в словарь попадают вместе с двоеточиями

Результат получился так себе. Мне пришлось опять добавлять маркер конца строки для английского текста. Если значения поля name включать в словарь, то размер словаря получается таким же, сколько строк в тренировочных файлах. При этом эти имена не пересекаются между training set и cross-validation set, поэтому результат вроде бы получается не очень хорошим. Ну и при реальном переводе все равно если будет слово, которого в словаре не было, то в ответ пойдет .

Если же не включать name в словарь, но включать туда числа delay и repeat, то все получается не так плохо. Результат перевода примерно такой же, но числа он по крайней мере "переводит" адекватно.


Выводы из первых двух экспериментов


Я понимаю, что основная проблема этого подхода именно в том, что часть фразы надо "переводить", а часть надо обрабатывать по совсем другому принципу (в данном случае — не обрабатывать никак). Это решается с помощью улучшенного nmt, но к сожалению, я не нашел простого туториала, как этим пользоваться. Возможно я плохо искал. Более вероятно то, что я не знаю, что именно надо искать — я не разбираюсь в машинном обучении и NLP и поэтому не могу даже сформулировать корректный запрос в гугл. Я снова мысленно возвращаюсь к Dialogflow — там можно выделить внутри фразы часть, которая должна быть обработана отдельно. Я пытаюсь искать по словам вроде "neural network to extract data from sentence" и натыкаюсь на sequence tagging.


Я смотрю на примеры и понимаю, что это именно то, что мне нужно.


Эксперимент третий


Я планирую делать "перевод" в несколько шагов. На первом шаге я прогоняю исходное предложение через теггер. Затем я строю новое предложение, где все слова, для которых теги отличаются от O (заглавная латинская буква o — Other), заменяю на собственно сами теги.
исходная фраза
print "hello" after 30 seconds every 7 seconds
теги
O data_sched_name O data_sched_delay O O data_sched_repeat O
фраза с плейсхолдерами
print data_sched_name after data_sched_delay seconds every data_sched_repeat seconds


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


command add name data_sched_name delay data_sched_delay repeat data_sched_repeat


Сконструировать из этого json это уже вопрос техники.


Это то, как будет происходить "перевод". Но еще предстоит обучение. Все примерно так же, как и раньше, но теперь я генерю тройку английский+теги+перевод. Пара английский+теги нужна для обучения теггера, а пара английский-с-заменой+перевод нужна для обучения nmt.


Результат — победа.


Тут я наконец замечаю, что в предыдущем эксперименте я потерял what. Я его добавляю равным name. Теггер переучивать не требуется, переучиваю nmt… и оно работает!


Наконец, финальный штрих. Я решил добавить к английским фразам please — в начале, в конце, или не добавлять вовсе. Если please присутствует, то я хочу видеть его в готовом json в поле "tone" — боту полезно знать, когда к нему обращаются вежливо. Небольшая правка в коде генерации учебных данных, переучиваем теггер и nmt. Работает!


$ ./translate.py 'please start printing "hello" every 20 seconds after 50 seconds'
<куча дебажной информации о загрузке модели nmt>
{'delay': 50, 'name': 'hello', 'tone': 'please', 'command': 'add', 'what': 'hello', 'repeat': 20}

Окончательные выводы


Наверно можно обойтись и без nmt. Я знаю класс фразы, и конструирую ее самостоятельно из тех данных, которые теггер выковырял из исходного предложения. По идее достаточно было бы просто использовать классификатор. Здесь я нарвусь на проблему с "Не говори мне, какая будет погода завтра в Москве", но это лечится путем правильного переучивания.


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


Самое главное — теперь я, кажется, знаю, как мне переводить фразы на язык, который мой бот поймет.


Скрипты, которыми я пользовался, доступны здесь. Для работы теггера надо скачивать словарь в пару гигабайт (make glove в каталоге sequence_tagging). Есть подозрение, что можно обойтись без него, если создать такой словарь самостоятельно. Но я это еще пока не умею.

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


  1. atimutsa
    27.01.2018 20:24

    тут необязательными являются releat, delay и what, если они не указаны

    Тут, наверное, опечатка? Должно быть repeat?


    1. aragaer Автор
      27.01.2018 20:25

      Спасибо, поправил.