Pull to refresh

Простой intent classifier

Reading time 7 min
Views 6.1K
В прошлый раз я научился использовать нейросети, чтобы понимать, что именно пользователь хочет добиться от бота. То, что у меня тогда получилось, обладало рядом недостатков. Во-первых, я ограничился только одним видом фраз. Во-вторых, я использовал тяжеловесный nmt, чтобы выцепить intent из фразы. При этом такую задачу обычно решают обычными классификаторами.

Более удобная генерация учебных данных


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

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

код
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, tags = [cls], [], []
    for tag, value, just_word in tag_var_re.findall(result):
        if just_word:
            en.append(just_word)
            tags.append('O')
        else:
            _, tag = tag.split('-', maxsplit=1)
            words = value.split()
            en.append(words.pop(0))
            tags.append('B-'+tag)
            for word in words:
                en.append(word)
                tags.append('I-'+tag)
            cmd.append(tag+':')
            cmd.append('"'+value+'"')
    return cmd, en, tags
использование
    rs = RiveScript(utf8=True)
    rs.load_directory(os.path.join(this_dir, 'human_train_1'))
    rs.sort_replies()

    for c in ('yes', 'no', 'ping'):
        for _ in range(COUNT):
            add_sample(make_sample(rs, c))

    to_remind = ['wash hands', 'read books', 'make tea', 'pay bills',
                 'eat food', 'buy stuff', 'take a walk', 'do maki-uchi',
                 'say hello', 'say yes', 'say no', 'play games']

    for _ in range(COUNT):
        r = random.choice(to_remind)
        add_sample(make_sample(rs, 'remind', r))
RiveScript
+ hello
- hello
- hey
- hi

+ ping
- {@hello}{random}|, sweetie{/random}
- {@hello} there
- {random}are |{/random}you {random}here|there{/random}?
- ping
- yo

+ yes
- yes
- yep
- yeah

+ no
- no
- not yet
- nope
+ remind *
@ maybe-please remind-without-please data-remind-action(<star>)

+ remind-without-please *
- remind me to <star>
- remind me data-remind-when({@when}) to <star>
- remind me to <star> data-remind-when({@when})

+ when
- today
- later
- tomorrow

+ maybe-please *
- <@> {weight=3}
- please, <@>
- <@>, please

В результате таких фокусов получается примерно такое:

Исходная строка для генерации: remind do maki-uchi
Полученное из RiveScript: please, remind me data-remind-when(tomorrow) to data-remind-action(do maki-uchi)
Строка «на английском»: please, remind me tomorrow to do maki-uchi
Строка «на ботовском»: remind when: "tomorrow" what: "do maki-uchi"
Соответствующие теги: O O O B-when O B-action I-action

Хотя теги не нужны для классификации, они понадобятся позже для теггера.

Сам классификатор


Моей основной проблемой в прошлый раз было полное незнание терминологии. Сейчас я уже знаю некоторые ключевые слова, поэтому просто вбил в поисковик «classify sentence tensorflow» и получил кучу более-менее пригодных для использования материалов. Впрочем, даже это не потребовалось, потому что у меня уже была сохранена закладка, которая меня почти полностью устраивала. Особенно мне понравилось то, что не нужен отдельный словарь, ведь предложенная там модель может построить word embeddings непосредственно из тестового набора.

word embeddings
Честно говоря, я довольно долго не понимал, что такое word embeddings. На самом деле это просто некий словарь, в котором каждому слову соответствует вектор float-ов, причем для «близких» слов эти вектора будут близкими. Что бы это ни значило.

Приведенная в примере сеть требует только одного — чтобы вместо слов ей на вход подавали список целых чисел. Конечно, я мог бы составить список всех доступных слов и заменить каждое слово на его номер в этом списке. Но это было бы не очень интересно. Тем более, что в примере предлагалось задействовать функцию one_hot, которая входит в состав keras.preprocessing.text.

код
сам классификатор
def _embed(sentence):
    return one_hot(sentence, HASH_SIZE)


def _make_classifier(input_length, vocab_size, class_count):
    result = Sequential()
    result.add(Embedding(vocab_size, 8, input_length=input_length))
    result.add(Flatten())
    result.add(Dense(class_count, activation='sigmoid'))
    result.compile(optimizer='adam', loss='binary_crossentropy', metrics=['acc'])
    return result


def _train(model, prep_func, train, validation=None, epochs=10, verbose=2):
    X, y = prep_func(*train)
    validation_data = None if validation is None else prep_func(*validation)
    model.fit(X, y, epochs=epochs, verbose=verbose, shuffle=False,
              validation_data=validation_data)


class Translator:

    def __init__(self, class_count=None, cls=None, lb=None):
        if class_count is None and lb is None and cls is None:
            raise Exception("Class count is not known")
        self.max_length = 32
        self.lb = lb or LabelBinarizer()
        if class_count is None and lb is not None:
            class_count = len(lb.classes_)
        self.classifier = cls or _make_classifier(self.max_length, HASH_SIZE, class_count)

    def _prepare_classifier_data(self, lines, labels):
        X = pad_sequences([_embed(line) for line in lines],
                            padding='post', maxlen=self.max_length)
        y = self.lb.transform(labels)
        return X, y

    def train_classifier(self, lines, labels, validation=None):
        _train(self.classifier, self._prepare_classifier_data,
               (lines, labels), validation)

    def classifier_eval(self, lines, labels):
        X = pad_sequences([_embed(line) for line in lines],
                            padding='post', maxlen=self.max_length)
        y = self.lb.transform(labels)
        loss, accuracy = self.classifier.evaluate(X, y)
        print(loss, accuracy*100)

    def classify(self, line):
        res = self._classifier_predict(line)
        if max(res[0]) > 0.1:
            return self.lb.inverse_transform(res)[0]
        else:
            return 'unknown'

    def classify2(self, line):
        res = self._classifier_predict(line)
        print('\n'.join(map(str, zip(self.lb.classes_, res[0]))))
        m = max(res[0])
        c = self.lb.inverse_transform(res)[0]
        if m > 0.05:
            return c
        elif m > 0.02:
            return 'probably ' + c
        else:
            return 'unknown ' + c + '? ' + str(m)
обучение
def load_sentences(file_name):
    with open(file_name) as fen:
        return [l.strip() for l in fen.readlines()]

def load_labels(file_name):
    with open(file_name) as fpa:
        return [line.strip().split(maxsplit=1)[0] for line in fpa]
    sentences = load_sentences(os.path.join(data_dir, "train.en"))
    labels = load_labels(os.path.join(data_dir, "train.pa"))
    tags = load_sentences(os.path.join(data_dir, "train.tg"))
    label_count = len(set(labels))

    translator = Translator(label_count)
    translator.lb.fit(labels)
    translator.train_classifier(sentences, labels)
использование
    classifier = model_from_json(os.path.join(data_dir, "trained.cls"))
    with open(os.path.join(data_dir, "trained.lb"), 'rb') as labels_file:
        lb = pickle.load(labels_file)

    translator = Translator(lb=lb, cls=classifier, tagger=tagger)

    line = ' '.join(sys.argv)
    print(translator.classify2(line))

Я сочинил первые 4 класса фраз (yes, no, ping и remind), реализовал сохранение и загрузку и решил попробовать. К моему удивлению, классификатор некорректно переводил даже фразы из учебного набора. Тогда я добавил в скрипт обучения оценку на тестовом наборе. Эта оценка показала точность 98-99%. Тогда я скопировал скрипт перевода, но вместо анализа фразы-аргумента передал еще раз прогнал кросс-валидацию. И получил результат в 25%. Как раз такой, как если бы нейросеть наугад выбирала один из четырех классов.

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

Как оказалось, не зря.
one_hot
One-hot encodes a text into a list of word indexes in a vocabulary of size n.
This is a wrapper to the hashing_trick function using hash as the hashing function.
Здесь, казалось бы, ничто не намекает.
hashing_trick
Converts a text to a sequence of indices in a fixed-size hashing space
Вроде тоже ничего. Но если все-таки посмотреть ниже, на список аргументов...
hash_function: defaults to python hash function, can be 'md5' or any function that takes in input a string and returns a int. Note that 'hash' is not a stable hashing function, so it is not consistent across different runs, while 'md5' is a stable hashing function.
Я поменял one_hot на hashing_trick с md5, но результат не изменился, я получал все те же 25% правильных ответов. Использование one_hot несомненно было ошибкой, но не единственной.

Следующим подозреваемым была функция сохранения и загрузки обученной нейросети. Как оказалось, model.to_json и model_from_json работают только с моделью сети, но не сохраняют и не загружают веса. А для сохранения весов требовалось еще доустановить пакет h5py. После исправления этой досадной ошибки я, наконец, получил результаты, похожие на правду:
$ ./translate4.py 'please, remind me to make some tea'
probably remind


После этого я сочинил еще несколько классов фраз, доведя общее их число до 10. С разными вариантами всего получилось 13 — два варианта для remind (одно действие или два) и три варианта для find (поиск по одной ключевой фразе, или по двум с AND и с OR).

Результат


У меня получился простой классификатор, который быстро (несколько секунд) обучается и выдает неплохие результаты. Значительно лучше, чем использовать для этого nmt. Следующим шагом должен быть теггер. Я мог бы опять использовать готовый sequence tagging, но мне очень не хочется держать у себя многогигабайтный GloVe. Поэтому я продолжаю этот эксперимент, пытаясь сделать теггер, который бы меня устраивал. Пока безуспешно.

В какой-то момент возни с теггером я почти сдался. Но потом мне на глаза попалась статья про Алису. Как раз накануне я решил отвлечься от анализа текста в сторону того, как должен работать «мозг». То, что я смог придумать, оказалось первым шагом в сторону того, как это сделано в Алисе. Плюс опять же идет речь о семантическом анализе фраз. И у них это получилось. А значит можно надеяться, что я тоже смогу. Вот только пойму, как задействовать двунаправленные LSTM вместо обычных, а также что такое state-of-the-art, так сразу и.

Весь код моих экспериментов доступен в гитхабе.
Tags:
Hubs:
+11
Comments 2
Comments Comments 2

Articles