Поиск похожих инцидентов и заявок. Метрики и оптимизация
В предыдущей статье я рассказал про нашу систему поиска похожих заявок. После ее запуска мы стали получать первые отзывы. Какие-то рекомендации аналитикам нравились и были полезны, какие-то — нет.
Для того, чтобы двигаться дальше и находить более качественные модели, необходимо было сначала оценить работу текущей модели. Также необходимо было выбрать критерии, по которым две модели можно было бы сравнить между собой.
Под катом я расскажу про:
- сбор отзывов на рекомендации
- выработку метрик для оценки качества рекомендаций
- построение цикла оптимизации модели
- полученные инсайты и новую модель
Сбор отзывов
Идеально было бы собрать явный отзыв от аналитиков: насколько релевантна рекомендация каждого из предложенных инцидентов. Это позволит понять текущее положение и продолжить улучшение системы, основываясь на количественных показателях.
Отзывы было решено собирать в предельно простом формате:
- номер инцидента, который анализируем
- номер инцидента, который был рекомендован
- отзыв на рекомендацию: хорошо / плохо
«Голосовалку» (маленький проект, который принимал GET запросы с параметрами, и складывал информацию в файл) разместили непосредственно в блоке рекомендаций, чтобы аналитики могли оставить свой отзыв немедленно, просто кликнув на одну из ссылок: «хорошо» или «плохо».
Дополнительно, для ретроспективного просмотра рекомендации, было сделано очень простое решение:
- для большого куска исторических данных был запущена модель;
- собранные рекомендации были представлены в виде нескольких standalone HTML файлов, в которых использовалась та-же «голосовалка»;
- подготовленные файлы были розданы аналитикам для того, чтобы просмотреть результаты для 50–100 инцидентов.
Так удалось собрать данные по примерно 4000+ парам инцидент-рекомендация.
Первичный анализ отзывов
Начальные метрики были «так себе» — доля «хороших» рекомендаций, по оценке коллег, составляла всего порядка 25%.
Основные проблемы первой модели:
- инциденты, по «новым» проблемам получали от системы нерелевантные рекомендации; Оказалось, что в отсутствие совпадений по содержанию обращения, система подбирала близки инциденты по отделу обратившегося сотрудника.
- рекомендации для инцидента по одной системы попадали инциденты от других систем. Слова, использованные в обращении были похожи, но описывали проблемы других систем и были другие.
Возможными путями повышения качества рекомендаций были выбраны:
- корректировка состава и веса атрибутов обращения, которые включены в финальный вектор
- подбор настроек векторизации
TfidfVectorizer
- подбор расстояния «отсечки» рекомендаций
Выработка критериев качества и методики оценки
Для поиска улучшенной версии модели необходимо определиться с принципом оценки качества результатов модели. Это позволит количественно сравнивать две модели и выбирать лучшую.
Что можно получить из собранных отзывов
У нас есть множество m кортежей вида: «Инцидент», «Рекомендованный инцидент», «Оценка рекомендации».
- «Оценка рекомендации» (v) — задается бинарно: «Хорошо» | «Плохо» (1 / -1);
- «Инцидент» и «Рекомендованный инцидент» — просто номера инцидентов. По ним можно найти инцидент в базе.
Имея такие данные, можно посчитать:
n_inc_total
— Общее количество инцидентов, для которых есть рекомендацииn_inc_good
— Количество инцидентов, для которых есть «хорошие» рекомендацииavg_inc_good
— Среднее количество «хороших» рекомендаций для инцидентовn_rec_total
— Общее количество рекомендацийn_rec_good
— Общее количество «хороших» рекомендацийpct_inc_good
— доля инцидентов, для которых есть «хорошие» рекомендацииpct_inc_good = n_inc_good / n_inc_total
pct_rec_good
— общая доля «хорошие» рекомендацииpct_rec_good = n_rec_good / n_rec_total
Эти показатели, посчитанные на основе оценок от пользователей, можно рассматривать как «базовые показатели» исходной модели. С ней мы будем сравнивать аналогичные показатели новых версий модели.
Возьмем все уникальные «инциденты» из m, и прогоним их через новую модель.
В результате получим множество m* кортежей: «Инцидент», «Рекомендованный инцидент», «Расстояние».
Здесь «расстояние» — метрика, определенная в NearestNeighbour. В нашей модели это косинусное расстояние. Значение »0» соответствует полному совпадению векторов.
Подбор «расстояния отсечки»
Дополнив набор рекомендаций m* информацией об истинной оценке v из исходного набора оценок m, получим соответствие дистанции d и истиной оценки v для данной модели.
Имея набор (d, v) можно подобрать оптимальный уровень отсечки t, что для в d <= t рекомендация будет "хорошей", а для d > t — «плохой». Подбор t можно осуществить, проводя оптимизацию простейшего бинарного классификатора v = -1 if d>t else 1
по гиперпараметру t, и используя, например, AUC ROC в качестве метрики.
# Бинарный классификатор для оптимизации
class BinarizerClassifier(Binarizer):
def transform(self, x):
return np.array([-1 if _x > self.threshold else 1 for _x in np.array(x, dtype=float)]).reshape(-1, 1)
def predict_proba(self, x):
z = self.transform(x)
return np.array([[0 if _x > 0 else 1, 1 if _x > 0 else 0] for _x in z.ravel()])
def predict(self, x):
return self.transform(x)
#
# тут другой код:
# - подготовка пайплайна,
# - получение рекомендации для m*
# - получение пар (d,v) в z_data_for_t
#
# подбор параметра t
b = BinarizerClassifier()
z_x = z_data_for_t[['distance']]
z_y = z_data_for_t['TYPE']
cv = GridSearchCV(b,
param_grid={'threshold': np.arange(0.1, 0.7, 0.01)},
scoring='roc_auc',
cv=5, iid=False,
n_jobs=-1)
cv.fit(z_x, z_y)
score = cv.best_score_
t = cv.best_params_['threshold']
best_b = cv.best_estimator_
Полученное значение t можно использовать для фильтрации рекомендаций.
Конечно, такой подход может все еще пропускать «плохие» рекомендации и отсекать «хорошие». Поэтому, на данном этапе мы всегда показываем «Top 5» рекомендаций, но специально помечаем те, которые считаются «хорошими», с учетом найденного t.
Альтернативный вариант: если найдена хотя бы одна «хорошая» рекомендация, то показывать только «хорошие». Иначе показывать все имеющиеся (тоже — «Top N»).
Предположение для сравнения моделей
Для обучения моделей используется один и тот-же корпус инцидентов.
Предположим, что если ранее была найдена «хорошая» рекомендация, то новая модель тоже должна найти «хорошую» рекомендацию для того же инцидента. В частности новая модель может найти те же «хорошие» рекомендации, что и старая. Однако, с новой модель мы рассчитываем, что количество «плохих» рекомендаций станет меньше.
Тогда, посчитав те же показатели для рекомендаций m* новой модели, их можно сравнить с соответствующими показателями для m. На основании сравнения можно выбрать лучшую модель.
Учет «хороших» рекомендаций для множества m* можно вести одним из двух способов:
- на основании найденного t: считать, что все рекомендации из m* с d<t — «хорошими», и учитывать их для расчета метрик
- на основании соответствующих истинных оценок из множества m: из рекомендаций m* отбирать только те, для которых есть истинная оценка в m, и отбрасывать остальные.
В первом случае «абсолютные» показатели (n_inc_good
, n_rec_good
) новой модели должны быть больше, чем для базовой модели. Во втором случае — показатели должны приближаться к показателям базовой модели.
Проблема второго способа: если новая модель лучше исходной, и она находит что-то ранее неизвестное — такая рекомендация не будет учтена в расчете.
Выбор параметров сравнения моделей
При выборе новой модели хочется, чтобы по сравнению с существующей моделью улучшились показатели:
- среднее количество «хороших» рекомендаций на инцидент (
avg_inc_good
) - количество инцидентов, для которых есть «хорошие» рекомендации (
n_inc_good
).
Для сравнения с исходной моделью, будем использовать отношения этих параметров новой модели и оригинальной. Таким образом, если отношение параметра новой модели и старой больше 1 — новая модель лучше.
benchmark_agv_inc_good = avg_inc_good* / avg_inc_good
benchmark_n_inc_good = n_inc_good* / n_inc_good
Для упрощения выбора лучше использовать единый параметр. Возьмем среднее гармоническое отдельных относительных показателей и будем использовать его как единственный композитный критерий качества новой модели.
composite = 2 / ( 1/benchmark_agv_inc_good + 1/benchmark_n_inc_good)
Новая модель и ее оптимизация
Для новой модели в финальный вектор, представляющий инцидент, добавим компоненты отвечающие за «область инцидента» (одна из нескольких систем, обслуживаемых нашей командой).
Информацию о подразделении и расположении сотрудника, создавшего инцидент, тоже вынесена в отдельный векторный компонент. Все компоненты имеют свой вес в финальном векторе.
p = Pipeline(
steps=[
('grp', ColumnTransformer(
transformers=[
('text',
Pipeline(steps=[
('pp', CommentsTextTransformer(n_jobs=-1)),
("tfidf", TfidfVectorizer(stop_words=get_stop_words(),
ngram_range=(1, 3),
max_features=10000,
min_df=0))
]),
['short_description', 'comments']
),
('area',
OneHotEncoder(handle_unknown='ignore'),
['area']
),
('dept',
OneHotEncoder(handle_unknown='ignore'),
['u_impacted_department']
),
('loc',
OneHotEncoder(handle_unknown='ignore'),
['u_impacted_location']
)
],
transformer_weights={'text': 1, 'area': 0.5, 'dept': 0.1, 'loc': 0.1},
n_jobs=-1
)),
('norm', Normalizer()),
("nn", NearestNeighborsTransformer(n_neighbors=10, metric='cosine'))
],
memory=None)
Ожидается, что гиперпараметры модели влияют на целевые показатели модели. В выбранной архитектуре модели гиперпараметрами будем считать:
- параметры векторизации TF-IDF — используемые n-граммы (ngram_range), размер словаря (max_features), минимальное вождение термина (min_df)
- вклад компонентов в финальный вектор — transformer_weights.
Начальные значения гиперпараметров текстовой векторизации взяты из предыдущей модели. Начальные веса компонентов выбраны исходя и экспертной оценки.
Цикл подбора параметров
Как сравнивать подбирать уровень осечки и сравнивать модели между собой уже определили. Теперь можно перейти к оптимизации через подбор гиперпараметров.
param_grid = {
'grp__text__tfidf__ngram_range': [(1, 1), (1, 2), (1, 3), (2, 2)],
'grp__text__tfidf__max_features': [5000, 10000, 20000],
'grp__text__tfidf__min_df': [0, 0.0001, 0.0005, 0.001],
'grp__transformer_weights': [{'text': 1, 'area': 0.5, 'dept': 0.1, 'loc': 0.1},
{'text': 1, 'area': 0.75, 'dept': 0.1, 'loc': 0.1},
{'text': 1, 'area': 0.5, 'dept': 0.3, 'loc': 0.3},
{'text': 1, 'area': 0.75, 'dept': 0.3, 'loc': 0.3},
{'text': 1, 'area': 1, 'dept': 0.1, 'loc': 0.1},
{'text': 1, 'area': 1, 'dept': 0.3, 'loc': 0.3},
{'text': 1, 'area': 1, 'dept': 0.5, 'loc': 0.5}],
}
for param in ParameterGrid(param_grid=param_grid):
p.set_params(**param)
p.fit(x)
...
Результаты оптимизации
В таблице приведены результаты экспериментов, в которых были достигнуты интересные результаты — top 5 лучших и худших значений по контролируемым показателям.
Ячейки с показателями в таблице помечены как:
- темно-зеленая — лучший показатель среди всех экспериментов
- бледно-зеленый — значение показателя входит в top-5
- темно-красный — худший показатель среди всех экспериментов
- бледно-красный — значение показателя входит в worst-5
Лучший композитный показатель получился у модели с параметрами:
ngram_range = (1,2)
min_df = 0.0001
max_features = 20000
transformer_weights = {'text': 1, 'area': 1, 'dept': 0.1, 'loc': 0.1}
Модель с этими параметрами показала улучшение композитного показателя по сравнению с исходной моделью 24%
Некоторые наблюдения и выводы
По результатам оптимизации:
Использование триграмм (
ngram_range = (1,3)
), похоже, не оправдано. Они раздувают словарь и слабо повышают точность по сравнению с биграммами.Интересное поведение при построение словаря только из биграмм (
ngram_range = (2,2)
): «точность» рекомендаций возрастает, а количество найденных рекомендаций — падает. Прямо как баланс precision/recall в классификаторах. Аналогичное поведение наблюдается в подборе уровня отсечки t — для биграмм характерен более узкий «конус» отсечки и лучшее разделение «хороших» и «плохих» рекомендаций.Ненулевой параметр min_df, наряду с биграммами, повышает точность рекомендаций. Они начинают быть основанными на терминах, которые встречаются как минимум несколько раз. При росте параметра словарь начинает быстро сокращаться. Для маленьких выборок, как в нашем случае, наверное, понятнее будет оперировать количеством документов (целое значение min_df), чем долей документов (дробное значение min_df), содержащих термин.
Хорошие результаты получаются, когда признак инцидента, отвечающий за «область» включен в финальный вектор с весом равным или близким к текстовому компоненту. Низкие значения приводят к возрастанию доли «плохих» рекомендаций за счет нахождения похожих слов в документах из других областей. А вот признаки расположения заказчика так хорошо на результаты рекомендаций в нашем случае не влияют.
Появились некоторые новые идеи:
- добавить компонент «времени», чтобы среди похожих инцидентов приоритет в выдаче имели недавние инциденты.
- посмотреть, как будет влиять введение параметра max_df — хотя при tf-idf слишком общие слова для корпуса не должны иметь существенного веса, по определению.
- попробовать, наконец-то, другие способы векторизации содержания, например на базе word-to-vector, или на базе свертки tf-idf представления с помощью сетей.