Привет! Меня зовут Даниил Цимерман, я R&D-инженер в NLP-отделе Тинькофф. Недавно я выступил на конференции DUMP и рассказал, как мы решали задачу определения интентов пользователей в чате в условиях быстро меняющихся запросов. Доклад можно посмотреть на Ютубе, а эта статья — его текстовая версия для читателей Хабра. Разберем, какие способы решения задачи существуют и что делать с постоянно возникающими новыми интентами. 

Суть задачи классификации интентов

У бизнеса есть множество продуктов, а у пользователей — вопросы по их применению. За ответами пользователи приходят в чат поддержки. Там алгоритм машинного обучения подхватывает запросы и пытается предложить решение. Если алгоритм верно уловил суть, он дает полезный ответ. Пользователь уходит довольным, не дойдя до сотрудника поддержки. Если этого не происходит, вопросом начинает заниматься человек. Выражаясь более техническими терминами, можно сказать, что с помощью алгоритма, описанного выше, мы решаем задачу под названием «классификация интентов».

У задачи классификации интентов есть три особенности:

  1. У большого бизнеса очень много продуктов, а у продуктов много use-кейсов. Значит, у пользователей много вопросов. 

  2. Сложно получать хорошие размеченные данные. Сбор данных — отдельная история, которой можно было бы посвятить еще одну статью.

  3. Классы, то есть интенты, могут и будут меняться. Продукты эволюционируют, запросы пользователей меняются, и из-за этого сдвигается распределение того, что спрашивают клиенты. Нужно уметь адаптироваться под новые намерения, которые появляются у пользователей. 

Основные подходы к решению задачи

Классический текстовый классификатор. Это самый понятный подход, поэтому логично начать с него. Итак, у нас есть собранные аналитиками данные. И есть какая-то модель — скорее всего, трансформер, например BERT. Мы скармливаем данные модели, и она выдает распределение вероятностей. А мы берем максимальную и считаем, что это интент, с которым пользователь пришел в чат.

Так работает классический классификатор
Так работает классический классификатор

Достоинства подхода:  

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

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

Недостаток подхода: 

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

Регулярные выражения. Этот подход тоже достаточно простой. Когда аналитик или продакт заводит новый интент, он примерно понимает, какие срабатывания там должны быть. Он накидывает регулярки, и все работает. 

Достоинства подхода: 

— Легко сделать.

— Легко добавлять новые интенты.

Недостатки подхода:

— Не захватывает семантический смысл.

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

Такие регулярки отлавливали людей, которые хотели спеть с оператором
Такие регулярки отлавливали людей, которые хотели спеть с оператором

KNN classifier. Состоит из трех компонентов. На входе классификатор получает текст. Текст подается в основную модель машинного обучения, которую мы используем, так называемый эмбеддер. Его задача — выдать нам вектор фиксированной длины. Эти векторы обладают замечательным свойством: если тексты по смыслу одинаковые, они в векторном пространстве будут близки по выбранной нами мере схожести. Например, по косинусному расстоянию или евклидовой дистанции.

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

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

Устройство KNN-классификатора
Устройство KNN-классификатора

Достоинства подхода: 

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

— Легко добавлять новые интенты. Когда появляются новые сообщения с неизвестными интентами, мы создаем такие интенты и добавляем для них тексты, которые должны стриггериться. Все это прогоняется через эмбеддер и отправляется в индекс. От введения в UI до работы на проде проходит от 3 до 5 секунд.

Недостатки: 

— Качество хуже, чем у обученного классификатора. Это решается тем, что мы можем использовать KNN-классификатор в связке с обычным классификатором.

— Не так легко завести. Но мы инженеры, поэтому сложности нас не пугают. 

KNN-классификатор: данные и валидация

Рассмотрим три компонента: данные, валидация и модель, которую мы используем. 

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

Примеры из датасета
Примеры из датасета

Валидация. Есть несколько способов валидировать классификатор. Допустим, у нас есть датасет с предыдущего слайда и мы просто разбиваем его на две выборки в соотношении 80/20. Одна часть тестовая, другая — трейн. Но тут есть недостаток. Основная задача классификатора заключается в том, чтобы хорошо работать именно на тех интентах, которых не было, когда мы обучали модель. Он должен улавливать интенты, о которых модель никогда не слышала.

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

Важно сказать о метриках. На проде есть три метрики, на которые мы хотели бы смотреть перед выкатом:

— Weighted f0.5. Основная метрика, которая показывает качество. Мы группируем по интентам и считаем f0.5, потому что лучше срабатывать реже, но правильно, чем чаще, но с ложно-положительным результатом. Считаем по каждому интенту и усредняем с весами, которые пропорциональны популярности интента на проде. Смысл в том, что если интент популярен на проде, нам нужно как можно меньше на нем ошибаться.

— Coverage. Метрика считает долю примеров, на которых мы сработали. Потому что функция принятия решений может узнать или не узнать интент. Во втором случае текст попадет на оператора, а это не круто. Мы хотим контролировать, насколько часто модель срабатывает, чтобы операторы не делали за нее работу.

— Negative accuracy. У нас есть негативы — неправильные срабатывания на проде. Метрика считается так: единица минус доля раз, когда мы определили интент, не относящийся к тексту. Смысл в том, что негативные примеры — это примеры, на которых уже была ошибка на проде. Скорее всего, они сложные, поэтому мы не хотим на них ошибаться. 

Чтобы получить эти метрики, нам нужно поднять сервис, который занимается классификатором. А мы хотим быстро обучать модель и понимать, хорошая она или плохая. Поэтому у нас были две другие прокси-метрики, на которые мы смотрели при обучении: ROC AUC и Classification f0,5. Это не те метрики, на которые мы хотим смотреть на проде, но они хорошо коррелируют с тем, что мы получаем, когда поднимаем сервис и получаем честные метрики.

Данные и валидация — это только начало. Теперь поговорим о выборе модели.

Выбор модели

MUSE. Если вам нужен хороший текстовый эмбеддер, работающий на русском, скорее всего, вы начнете с MUSE. Эту мультиязычную модель в 2018 году выпустил Google. MUSE обучалась на трех задачах: question answering, NLI и translation. Мы использовали ее в качестве эмбеддера, а в качестве функции принятия решений использовали среднюю похожесть всех примеров интента. То есть усредняли косинусную похожесть по каждому интенту. 

MUSE — отличный бейзлайн и хороший эмбеддер, если мы не хотим ничего обучать. Проблема в том, что он никогда не видел банковские данные. Он обучался на открытых датасетах, которые ничего не знают о вопросах клиентов Тинькофф в чате. 

Кроме того, непонятно, как его файнтьюнить.

Бейзлайн, использующий MUSE
Бейзлайн, использующий MUSE

Softmax classifier as KNN. Второй бейзлайн, который мы попробовали. Его придумали авторы этой статьи. Они показали, что, когда мы обучаем Softmax-классификатор, можно брать получившееся распределение вероятностей и использовать его в K-means-кластеризации. А если мы можем использовать это в K-means, то и в ближайших соседях все сработает. Таким образом, мы можем использовать классификатор, обученный на банковских данных, в качестве эмбеддера. Кроме того, у этого есть понятное логическое объяснение: если два текста похожи по смыслу, вполне вероятно, что классификатор выдаст для них похожее распределение вероятностей. 

Softmax classifier в качестве эмбеддера
Softmax classifier в качестве эмбеддера

В результате мы получили прирост по прокси-метрикам, на которые смотрим. Это классно, но для нашей задачи мы все еще ничего не обучили. Мы просто взяли какие-то эмбеддеры, чтобы узнать, какого качества сможем достичь. 

Бенчмаркинг бейзлайнов, сравнение с MUSE
Бенчмаркинг бейзлайнов, сравнение с MUSE

Когда мы говорим о задачах эмбеддера, мы, вероятнее всего, хотим обучать какие-то metric-learning-модели. Нам нужно получить эмбеддер, который выдавал бы похожие векторные представления для двух текстов с одинаковым интентом. Чтобы обучить метрику такой модели, нужен датасет, состоящий из пар. У этих пар должна стоять метка — относятся они к одному классу или к разным. Если к одному, мы минимизируем расстояние между ними. Если к разным — максимизируем его. 

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

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

Это не то, что мы хотим для обучения
Это не то, что мы хотим для обучения

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

Для этого мы можем использовать вспомогательный индекс из KNN или MinHash, чтобы негатив для каждого текста был максимально похожим на него текстом из другого интента. Так мы получим примеры, которые относятся к другим интентам, но в то же время похожи на примеры, для которых нужно получить негатив. 

Чтобы собрать датасет, мы проходимся по каждому из них. Мы знаем его интент и для каждого такого примера строим индекс. В нашем случае строим MinHash-индекс, который ориентируется на коэффициент Жаккара. То есть смотрит, насколько сильно пересекаются N-граммы нашего текста и N-граммы других текстов из других интентов.

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

Кусочек датасета. Библиотека для MinHash — по ссылке на Гитхабе
Кусочек датасета. Библиотека для MinHash — по ссылке на Гитхабе

Модели, которые мы используем

Базовая модель — наш предобученный BERT. Он предобучался как классический BERT на задаче MLM: нам нужно было предсказать, какой токен стоял на месте замаскированного. Так мы получили модель с хорошим представлением о том, что такое банковский текст. 

За счет того, что мы предобучали модель на банковских текстах, у нас получаются хорошие начальные веса, чтобы потом тюниться под задачу metric-learning. 

BERT, предобученный на MLM
BERT, предобученный на MLM

На выходе он выдает эмбеддинг для каждого отдельного токена. При этом нам нужно получать один эмбеддинг для всего текста, чтобы потом использовать его в KNN-индексе. Каким образом мы можем адаптировать наш BERT к задаче metric-learning, чтобы он выдавал нам один эмбеддинг? 

Лучший эвристически подобранный способ — использовать простое усреднение эмбеддингов всех токенов. BERT выдает эмбеддинг для каждого отдельного токена, а мы это усредняем. Получается, что среднее всех эмбеддингов каждого токена — это эмбеддинг одного предложения. То есть в нем заложен смысл о всей последовательности токенов, которая пришла на вход. 

Итак, у нас есть усредняющий BERT и данные. Мы поставили модель учиться и получили вот такой график. Конечно, это не то, что мы хотели бы видеть при обучении модели.

Видно, что модель не учится
Видно, что модель не учится

У нас была куча гипотез, почему так происходит. Один инженер говорил, что у нас градиенты взрываются, другой — что мы сделали суперсложные негативы и нужно использовать curriculum learning. 

Но все оказалось куда проще. Если посмотреть первые 64 примера из обучающего датасета, можно увидеть, что один из текстов константный.

Но если мы хотим использовать стохастический градиентный спуск, батчи, которые мы подаем в модель, должны быть репрезентативными. А когда один текст в батче константный, мы уводим градиент не в том направлении и он перестает быть стохастическим. После перемешивания датасета мы заново запустили обучение. Все получилось: градиенты текут, моделька обучается, все супер.

Результаты после переобучения
Результаты после переобучения

Итак, мы обучили модель. Прокси-метрики показывают, что если мы дообучаем эмбеддер в metric-learning контексте, получаем буст в 16 процентных пунктов по метрике F0.5 и в почти 19 пунктов ROC AUC относительно бейзлайна в качестве MUSE. 

Сравнение трех моделей с бейзлайном
Сравнение трех моделей с бейзлайном

Мы подумали, что все отлично и пора катить это на прод. Подняли сервисы, посчитали предпродовые метрики и увидели, что основная метрика упала на 11 процентных пунктов. 

Предпродовые метрики, промежуточный результат
Предпродовые метрики, промежуточный результат

Однако если мы посмотрим на coverage, то увидим, что пайплайн начал покрывать намного больше примеров. Но делать это он стал хуже, потому что у нас падает weighted F0,5. В нашем пайплане есть функция принятия решений, в которую зашиты трешхолды. И если агрегированный скор меньше трешхолда, то есть мы не совсем уверены, что это наш интент, мы ничего не выдаем. Не потюнив трешхолды под конкретную модель, мы будем получать противоречивые результаты.

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

Затем мы на этой выделенной выборке решаем задачу оптимизации с ограничениями. Нам нужно максимизировать гармоническое среднее между weighted F0,5 и негативной точностью при условии, что coverage больше какого-то x, где x — это бизнес-метрика, которую поставили продакты:

max(harmonic_mean(f0.5, neg_accuracy)) s.t. coverage > x.

После подбора порогов мы получаем вот такие финальные метрики на продовых значениях. Притом что coverage у нас не изменился, то есть модель не стала срабатывать реже. 

Итоговый результат
Итоговый результат

Способы улучшить наше решение

Мы нашли решение проблемы, но попробовали далеко не все, что можно было придумать. Поэтому давайте порассуждаем, как можно улучшить то, что мы сделали. 

Больше обучаемых параметров, больше данных. Самое очевидное и неинтересное: увеличить обучающую выборку и размер/архитектуру модели. 

Triplet loss. Вероятно, многие из вас думали об этом способе, пока читали статью. Мы не использовали его, потому что собирать датасет для него сложнее. Но если мы соберем датасет, в котором для каждого примера есть хороший позитив и хороший негатив, вероятно, получится буст качества.  

DSSM-like pretraining on dialog data. В качестве базовой модели мы взяли BERT, предобученный на задаче MLM. Он выдает хорошие эмбеддинги для каждого токена, но модель никогда не обучалась под усреднение. Поэтому мы будем использовать предобучение, например в контексте диалоговых данных. 

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

Фреймворк SimCSE pretraining. Авторы придумали способ предобучать эмбеддеры на данных без разметки. Например, есть датасет с клиентскими сообщениями. В качестве позитивных пар мы можем использовать один и тот же текст, применяя разные дропауты. А в качестве негативных — случайные пары. Ребята, написавшие статью, доказали, что в таком контексте мы получаем более качественный эмбеддинг для всего предложения. Выглядит как отличный способ, и мы собираемся попробовать его в ближайшем будущем. 

Выводы

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

Вот какие выводы можно сделать из всего этого:

  1. В бизнес-кейсах не всегда интересно решение с лучшим качеством. В нашем случае важнее получить большее покрытие и чаще закрывать пользовательские потребности. 

  2. «Когда слышите звук копыт, думайте о лошадях, а не о зебрах». В случае с проблемами при обучении модели достаточно часто самое простое объяснение причин оказывается правильным. 

  3. Важно думать о масштабировании решения. Его можно масштабировать по разным осям. Мы думали о масштабируемости по оси количества запросов, то есть хотели масштабироваться за счет большего количества классификаторов. Но теперь задумались о масштабировании по оси интентов. 

Если у вас есть вопросы по нашему решению — пишите комментарии, а я постараюсь ответить.

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


  1. gofat
    01.11.2022 15:30

    Изображение "Устройство KNN-классификатора" стоит поправить. На скриншот попало всплывающее сообщение, которое закрывает часть информации на этом изображении


    1. solemn_leader Автор
      01.11.2022 18:26

      точно, спасибо за замечание)