Поиск похожих инцидентов и заявок. Метрики и оптимизация
- пятница, 8 ноября 2019 г. в 00:19:49
В предыдущей статье я рассказал про нашу систему поиска похожих заявок. После ее запуска мы стали получать первые отзывы. Какие-то рекомендации аналитикам нравились и были полезны, какие-то — нет.
Для того, чтобы двигаться дальше и находить более качественные модели, необходимо было сначала оценить работу текущей модели. Также необходимо было выбрать критерии, по которым две модели можно было бы сравнить между собой.
Под катом я расскажу про:
Идеально было бы собрать явный отзыв от аналитиков: насколько релевантна рекомендация каждого из предложенных инцидентов. Это позволит понять текущее положение и продолжить улучшение системы, основываясь на количественных показателях.
Отзывы было решено собирать в предельно простом формате:
"Голосовалку" (маленький проект, который принимал GET запросы с параметрами, и складывал информацию в файл) разместили непосредственно в блоке рекомендаций, чтобы аналитики могли оставить свой отзыв немедленно, просто кликнув на одну из ссылок: "хорошо" или "плохо".
Дополнительно, для ретроспективного просмотра рекомендации, было сделано очень простое решение:
Так удалось собрать данные по примерно 4000+ парам инцидент-рекомендация.
Начальные метрики были "так себе" — доля "хороших" рекомендаций, по оценке коллег, составляла всего порядка 25%.
Основные проблемы первой модели:
Возможными путями повышения качества рекомендаций были выбраны:
TfidfVectorizer
Для поиска улучшенной версии модели необходимо определиться с принципом оценки качества результатов модели. Это позволит количественно сравнивать две модели и выбирать лучшую.
У нас есть множество m кортежей вида: "Инцидент", "Рекомендованный инцидент", "Оценка рекомендации".
Имея такие данные, можно посчитать:
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* можно вести одним из двух способов:
В первом случае "абсолютные" показатели (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)
Ожидается, что гиперпараметры модели влияют на целевые показатели модели. В выбранной архитектуре модели гиперпараметрами будем считать:
Начальные значения гиперпараметров текстовой векторизации взяты из предыдущей модели. Начальные веса компонентов выбраны исходя и экспертной оценки.
Как сравнивать подбирать уровень осечки и сравнивать модели между собой уже определили. Теперь можно перейти к оптимизации через подбор гиперпараметров.
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 лучших и худших значений по контролируемым показателям.
Ячейки с показателями в таблице помечены как:
Лучший композитный показатель получился у модели с параметрами:
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), содержащих термин.
Хорошие результаты получаются, когда признак инцидента, отвечающий за "область" включен в финальный вектор с весом равным или близким к текстовому компоненту. Низкие значения приводят к возрастанию доли "плохих" рекомендаций за счет нахождения похожих слов в документах из других областей. А вот признаки расположения заказчика так хорошо на результаты рекомендаций в нашем случае не влияют.
Появились некоторые новые идеи: