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

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

    Весь код доступен в гитхабе.
    • +14
    • 3,3k
    • 3
    Поделиться публикацией
    Похожие публикации
    Ой, у вас баннер убежал!

    Ну, и что?
    Реклама
    Комментарии 3
    • +2
      Я прощу прощения, но такие публикации больше напоминают БСДМ: что-то взяли, зачем-то куда-то запихали и получили удовольствие. Нет ни постановки задачи, ни результата. Бессмысленные какие-то действия.
      • 0
        Постановка задачи это «взять и попробовать». Что именно я хочу получить — когда-то раньше написал, пожалуй следовало бы здесь снова на ту публикацию сослаться.

        В конце концов, поэтому и указан хаб «ненормальное программирование»
        • 0
          Тогда понятно. Сбываются грезы Виктора Олеговича: RCP — random code programming :)! (см. книгу iPhuck 10)

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

      Самое читаемое