Получение параметров команды из человеческой фразы

habr.png

Хотя мне и удалось разобраться с классификацией интента, осталась более сложная задача — выцепить из фразы дополнительные параметры. Я знаю, что это делается с помощью тегов. Один раз я уже успешно применил 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 к своим экспериментальным скриптам. Нельзя сказать, что это было очень сложно.

код
make_sample
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.

Весь код доступен в гитхабе.

© Habrahabr.ru