Получение параметров команды из человеческой фразы
Хотя мне и удалось разобраться с классификацией интента, осталась более сложная задача — выцепить из фразы дополнительные параметры. Я знаю, что это делается с помощью тегов. Один раз я уже успешно применил sequence_tagging, но я не очень рад тому, что нужно держать словарь векторных представлений слов размером больше 6 гигабайт.
Попытка нулевая
Я нашел пример реализации теггера на Keras и, в лучших традициях своих экспериментов, начал бездумно копировать оттуда куски кода. В примере нейросеть обрабатывает входную строку как последовательность символов, не разделяя ее на слова. Но дальше по тексту есть пример с использованием Embedding слоя. А раз я научился использовать hashing_trick, то я почувствовал острое желание воспользоваться этим навыком.
То, что у меня получилось, обучалось значительно медленнее, чем классификатор. Я включил в Keras отладочный вывод, и, задумчиво глядя на медленно появляющиеся строчки, обратил внимание на значение Loss. Оно не особенно убывало, при этом мне оно показалось достаточно большим. А accuracy при этом была маленькой. Сидеть и ждать результата мне было лень, поэтому я вспомнил одну из рекомендаций Andrew Ng — попробовать свою нейросеть на меньшем по размеру набору учебных данных. По виду зависимости Loss от количества примеров можно оценить, стоит ли ожидать хороших результатов.
Поэтому я остановил обучение, сгенерил новый набор учебных данных — в 10 раз меньше предыдущего — и снова запустил обучение. И почти сразу получил тот же самый Loss и тот же самый Accuracy. Получается, что от увеличения числа учебных примеров лучше не станет.
Я все-таки дождался окончания обучения (около часа, при том, что классификатор обучался за несколько секунд) и решил ее опробовать. И понял, что копировать надо было больше, потому что в случае seq2seq для обучения и для реальной работы нужны разные модели. Еще немного поковырялся с кодом и решил остановиться и подумать, что делать дальше.
Передо мной был выбор — снова взять готовый пример, но уже без самодеятельности, либо же взять готовый seq2seq, или же вернуться к инструменту, который у меня уже работал — sequence tagger на NERModel. Правда чтобы без GloVe.
Я решил попробовать все три в обратном порядке.
NER model из sequence tagging
Желание править существующий код улетучилось сразу же, как только я заглянул внутрь. Поэтому я пошел с другой стороны — надергать из sequence tagging разных классов и методов, взять gensim.models.Word2Vec и это все туда скормить. После часа попыток я смог сделать учебные наборы данных, но вот именно словарь мне подменить не удалось. Я посмотрел на ошибку, прилетевшую откуда-то из глубин numpy, и отказался от этой затеи.
Сделал коммит, чтобы на всякий случай не потерялось.
Seq2Seq
В документации на Seq2Seq описано только как ее приготовить, но не как ей пользоваться. Пришлось найти пример и попытаться опять же подстроить под свое. Еще пара часов экспериментов и результат — точность в процессе обучения стабильно равна 0.83. Независимо от размера учебных данных. Значит я опять что-то где-то перепутал.
Здесь мне в примере не очень понравилось, что, во-первых, вручную идет разбиение учебных данных на куски, а во-вторых, вручную же делается embedding. Я в итоге прикрутил в одну Keras-модель сначала Embedding, потом Seq2Seq, а данные подготовил одним большим куском.
model = Sequential()
model.add(Embedding(256, TOKEN_REPRESENTATION_SIZE,
input_length=INPUT_SEQUENCE_LENGTH))
model.add(SimpleSeq2Seq(input_dim=TOKEN_REPRESENTATION_SIZE,
input_length=INPUT_SEQUENCE_LENGTH,
hidden_dim=HIDDEN_LAYER_DIMENSION,
output_dim=output_dim,
output_length=ANSWER_MAX_TOKEN_LENGTH,
depth=1))
model.compile(loss='categorical_crossentropy', optimizer='rmsprop', metrics=['accuracy'])
Но красота не спасла — поведение сети не изменилось.
Еще один коммит, перехожу к третьему варианту.
Seq2seq вручную
Сначала я честно все скопировал и попробовал запустить как есть. На вход подается просто последовательность символов исходной фразы, на выходе должна быть последовательность символов, которую можно посплитать по пробелам и получить список тегов. Точность вроде бы была неплохой. Потому что нейросеть быстро научилась, что если она начала по буквам выдавать какой-то тег, то дальше уже без ошибок напишет его до конца. А вот сами теги ну нисколько не соответствовали желаемому результату.
Вносим небольшое изменение — результат должен быть не последовательностью символов, а последовательностью тегов, набранных из конечного списка. Точность сразу упала — потому что теперь уже стало честно понятно, что сеть не справляется.
Тем не менее, я довел обучение сети до конца и посмотрел, что же именно она выдает. Потому что стабильный результат в 20% это наверно что-то значит. Как оказалось, сеть нашла способ особо не напрягаться:
please, remind me tomorrow to buy stuff
O
То есть делает вид, что во фразе всего одно слово, которое не содержит никаких данных (в смысле таких, которые еще не съел классификатор). Смотрим на учебные данные… действительно, порядка 20% фраз именно такие — yes, no, часть ping (то есть всякие hello) и часть acknowledge (всякие thanks).
Начинаем ставить сети палки в колеса. Урезаю количество yes/no в 4 раза, ping/acknowledge в 2 раза и добавляю еще всякого «мусора» в одно слово, но содержащее данные. На этом этапе я решил, что не надо мне в тегах иметь явную привязку к классу, поэтому например B-makiuchi-count
превратилось в просто B-count
. А новый «мусор» это были просто числа с классом B-count
, «время» в виде »4:30» с ожидаемым тегом B-time
, указания на дату типа «now», «today» и «tomorrow» с тегом B-when
.
Все равно не получается. Сеть уже не выдает однозначного ответа «O и все», но при этом accuracy так и остается на уровне 18%, а ответы совершенно неадекватные.
not yet
expected ['O', 'O']
actual ['O', 'O', 'B-what']
what is the weather outside?
expected ['O', 'O', 'O', 'O', 'O']
actual ['O', 'O', 'B-what']
Пока тупик.
Интерлюдия — осмысление
Отсутствие результата — тоже результат. У меня появилось пусть и поверхностное, но понимание того, что именно происходит, когда я конструирую модели в Keras. Научился их сохранять, загружать и даже доучивать по мере необходимости. Но при этом я не добился того, чего хотел — перевода «человеческой» речи в «язык бота». Зацепок у меня больше не оставалось.
И тогда я начал писать статью. Предыдущую статью. В первоначальном ее варианте на этом месте все заканчивалось — у меня есть классификатор, но нет теггера. После некоторых раздумий я отказался от этой затеи и оставил только про более-менее успешный классификатор и упомянул проблемы с теггером.
Расчет оправдался — я получил ссылку на Rasa NLU. На первый взгляд это выглядело как что-то очень подходящее.
Rasa NLU
Несколько дней я не возвращался к своим экспериментам. Потом сел и за час с небольшим прикрутил Rasa NLU к своим экспериментальным скриптам. Нельзя сказать, что это было очень сложно.
tag_var_re = re.compile(r'data-([a-z-]+)\((.*?)\)|(\S+)')
def make_sample(rs, cls, *args, **kwargs):
tokens = [cls] + list(args)
for k, v in kwargs.items():
tokens.append(k)
tokens.append(v)
result = rs.reply('', ' '.join(map(str, tokens))).strip()
if result == '[ERR: No Reply Matched]':
raise Exception("failed to generate string for {}".format(tokens))
cmd, en, rasa_entities = cls, [], []
for tag, value, just_word in tag_var_re.findall(result):
if just_word:
en.append(just_word)
else:
_, tag = tag.split('-', maxsplit=1)
words = value.split()
start = len(' '.join(en))
if en:
start += 1
en.extend(words)
end = len(' '.join(en))
rasa_entities.append({"start": start, "end": end,
"value": value, "entity": tag})
assert ' '.join(en)[start:end] == value
return cmd, en, rasa_entities
rasa_examples = []
for e, p, r in zip(en, pa, rasa):
sample = {"text": ' '.join(e), "intent": p}
if r:
sample["entities"] = r
rasa_examples.append(sample)
with open(os.path.join(data_dir, "rasa_train.js"), "w") as rf:
json.dump({"rasa_nlu_data": {"common_examples": rasa_examples,
"regex_features": [],
"entity_synonims": []}},
rf)
Самое сложное в создании модели — правильный конфиг training_data = load_data(os.path.join(data_dir, "rasa_train.js"))
config = RasaNLUConfig()
config.pipeline = registry.registered_pipeline_templates["spacy_sklearn"]
config.max_training_processes = 4
trainer = Trainer(config)
trainer.train(training_data)
model_dir = trainer.persist(os.path.join(data_dir, "rasa"))
А самое сложное в использовании — найти ее config = RasaNLUConfig()
config.pipeline = registry.registered_pipeline_templates["spacy_sklearn"]
config.max_training_processes = 4
model_dir = glob.glob(data_dir+"/rasa/default/model_*")[0]
interpreter = Interpreter.load(model_dir, config)
parsed = interpreter.parse(line)
result = [parsed['intent_ranking'][0]['name']]
for entity in parsed['entities']:
result.append(entity['entity']+':')
result.append('"'+entity['value']+'"')
print(' '.join(result))
please, find me some pictures of japanese warriors
find what: "japanese warriors"
remind me to have a breakfast now, sweetie
remind action: "have a breakfast" when: "now" what: "sweetie"
… хотя еще есть над чем работать.
Из недостатоков — процесс обучения происходит совсем молча. Наверняка это где-то включается. Впрочем, все обучение заняло около трех минут. Еще для работы spacy все-таки требуется модель для исходного языка. Но она весит значительно меньше, чем GloVe — для английского языка это меньше 300 мегабайт. Правда для русского языка модели еще пока нет —, а конечная цель моих экспериментов должна работать именно с русским. Надо будет посмотреть на другие pipeline, доступные в Rasa.
Весь код доступен в гитхабе.