Всем, кто провел определенное время, поддерживая системы, знакомо чувство déjà vu при получении новой заявки: "вроде такое было, вроде решали, но как конкретно — не помню". Можно потратить время, покопаться в предыдущих заявках и постараться найти похожие. Это поможет: инцидент будет закрыт быстрее, а может быть даже удастся обнаружить глубинную причину и закрыть проблему раз и навсегда.
У "молодых" сотрудников, только присоединившихся к команде, такой истории в голове еще нет. Они, скорее всего, не знают, что аналогичный инцидент, например, произошел полгода-год назад. И решил тот инцидент коллега из соседней комнаты.
Скорее всего, "молодые" сотрудники не станут искать в базе инцидентов что-то похожее, а будут решать проблемы "с нуля". Потратят больше времени, приобретут опыт и в следующий раз справятся быстрее. А может быть — сразу забудут под потоком новых заявок. И в следующий раз все повторится снова.
Мы уже используем ML-модели для классификации инцидентов. Чтобы помочь нашей команде эффективнее обрабатывать заявки, мы создали еще одну ML-модель для подготовки списка "ранее закрытые похожие инциденты". Детали — под катом.
Что нам нужно?
Для каждого поступающего инцидента необходимо в истории найти "похожие" закрытые инциденты. Определение "похожести" должно происходить в самом начале обработки инцидента, предпочтительно еще до того, как сотрудник службы поддержки приступил к анализу.
Для сопоставления инцидентов необходимо использовать информацию, предоставленную пользователем при обращении: краткое описание, подробное описание (если есть), любые атрибуты записи пользователя.
Команда поддерживает 4 группы систем. Общее количество инцидентов, которые хочется использовать для поиска похожих — порядка 10 тысяч.
Первое решение
Никакой проверенной информации о "похожести" инцидентов на руках нет. Так что state-of-the-art варианты с обучением сиамских сетей придется пока отложить.
Первое, что приходит в голову — простая кластеризация по "мешку слов", составленных из содержания обращений.
В этом случае процесс обработки инцидентов выглядит следующим образом:
- Выделение необходимых текстовых фрагментов
- Предварительная обработка/чистка текста
- TF-IDF векторизация
- Поиск ближайшего соседа
Понятно, что при описанном подходе похожесть будет базироваться на сравнении словарей: использовании тех же слов или n-грам в двух разных инцидентах будет расцениваться как "похожесть".
Конечно, это довольно упрощенный подход. Но помня, что мы оцениваем тексты пользовательских обращений, если проблема описана схожими словами — скорее всего инциденты сходные. Дополнительно к тексту можно подмешать название отдела пользователя, ожидая, что у пользователей одинаковых отделов в разных организациях проблемы будут похожие.
Выделение необходимых текстовых фрагментов
Данные об инцидентах мы получаем из системы service-now.com наиболее простым способом — программным запуском пользовательских отчетов и получением их результатов в виде CSV файлов.
Данные о сообщениях, которыми обменивались служба поддержки и пользователи в рамках инцидента, возвращается в этом случае в виде одного большого текстового поля, со всей историей переписки.
Информацию о первом обращении из такого поля пришлось "выпиливать" регулярными выражениями.
- Все сообщения разделяются характерной строкой <когда> — <кто>.
- Сообщения часто заканчиваются формальными подписями, особенно в случае если обращение было сделано по электронной почте. Эта информация заметно "фонила" в списке значимых слов, поэтому подписи тоже пришлось удалять.
Получилось что-то вроде этого:
def get_first_message(messages):
res = ""
if len(messages) > 0:
# take the first message
spl = re.split("\d{2}-\d{2}-\d{4} \d{2}:\d{2}:\d{2} - ((\w+((\s|-)\w+)?,(\s\w+)+)|\w{9}|guest)\s\(\w+\s\w+\)\n",
messages.lower())
res = spl[-1]
# cut off "mail footer" with finalization statements
res = re.split("(best|kind)(\s)+regard(s)+", res)[0]
# cut off "mail footer" with embedded pictures
res = re.split("\[cid:", res)[0]
# cut off "mail footer" with phone prefix
res = re.split("\+(\d(\s|-)?){7}", res)[0]
return res
Предварительная обработка текстов инцидента
Для повышения качества классификации текст обращения предварительно обрабатывается.
С помощью набора регулярных выражений в описаниях инцидентов находились характерные фрагменты: даты, названия серверов, коды продуктов, IP-адреса, веб-адреса, неправильные формы названий итд. Такие фрагменты заменялись на соответствующие токены-понятия.
В конце применялся стэмминг для приведения слов к общей форме. Это позволило избавиться от учета множественных форм и окончаний глаголов. В качестве стеммера был использован широко известный snowballstemmer
.
Все процессы обработки сведены в один класс-трансформер, который можно использовать в разных процессах.
Кстати, выяснилось (опытным путем, разумеется), что метод stemmer.stemWord()
не является thread safe. Поэтому если попытаться реализовать в рамках pipeline параллельную обработку текста, например, с использованием joblib
Prallel / delayed, — то обращение к общему экземпляру стеммера надо защищать блокировками.
__replacements = [
('(\d{1,3}\.){3}\d{1,3}', 'IPV4'),
('(?<=\W)((\d{2}[-\/ \.]?){2}(19|20)\d{2})|(19|20)\d{2}([-\/ \.]?\d{2}){2}(?=\W)', 'YYYYMMDD'),
('(?<=\W)(19|20)\d{2}(?=\W)', 'YYYY'),
('(?<=\W)(0|1)?\d\s?(am|pm)(?=\W)', 'HOUR'),
('http[s]?:\/\/(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', 'SOMEURL')
# Тут можно добавлять шаблоны замены
]
__stemmer_lock = threading.Lock()
__stemmer = snowballstemmer.stemmer('english')
def stem_string(text: str):
def stem_words(word_list):
with __stemmer_lock:
res = __stemmer.stemWords(word_list)
return res
return " ".join(stem_words(text.split()))
def clean_text(text: str):
res = text
for p in __replacements:
res = re.sub(p[0], '#'+p[1]+'#', res)
return res
def process_record(record):
txt = ""
for t in record:
t = "" if t == np.nan else t
txt += " " + get_first_message(str(t))
return stem_string(clean_text(txt.lower()))
class CommentsTextTransformer(BaseEstimator, TransformerMixin):
_n_jobs = 1
def __init__(self, n_jobs=1):
self._n_jobs = n_jobs
def fit(self, X, y=None):
return self
def transform(self, X, y=None):
features = Parallel(n_jobs=self._n_jobs)(
delayed(process_record)(rec) for i, rec in enumerate(X.values)
)
return np.array(features, dtype=object).reshape(len(X),)
Векторизация
Векторизация проводится стандартным TfidfVectorizer
со следующими настройками:
max_features
= 10000ngram
=(1,3) — в попытке уловить устойчивые сочетания и смысловые связкиmax_df
/min_df
— оставлены по умолчаниюstop_words
— стандартный список английских слов, плюс собственный дополнительный набор слов. Например, некоторые пользователи упоминали имена аналитиков, и довольно часто имена собственные становились значимыми признаками.
TfidfVectorizer
по умолчанию сам делает L2 нормализацию, так что вектора инцидентов уже готовы для измерения косинусного расстояния между ними.
Поиск похожих инцидентов
Основная задача процесса — вернуть список ближайших N соседей. Для этого вполне подходит класс sklearn.neighbors.NearestNeighbors
. Одна проблема — он не реализует метод transform
, без которого его в составе pipeline
использовать не получится.
Поэтому его пришлось сделать на его основе Transformer
, который уже потом поместить на последний шаг pipeline
:
class NearestNeighborsTransformer(NearestNeighbors, TransformerMixin):
def __init__(self,
n_neighbors=5,
radius=1.0,
algorithm='auto',
leaf_size=30,
metric='minkowski',
p=2,
metric_params=None,
n_jobs=None,
**kwargs):
super(NearestNeighbors, self).__init__(n_neighbors=n_neighbors,
radius=radius,
algorithm=algorithm,
leaf_size=leaf_size,
metric=metric,
p=p,
metric_params=metric_params,
n_jobs=n_jobs)
def transform(self, X, y=None):
res = self.kneighbors(X, self.n_neighbors, return_distance=True)
return res
Процесс обработки
Собрав все вместе, получаем компактный процесс:
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))
]),
['short_description', 'comments', 'u_impacted_department']
)
]
)),
("nn", NearestNeighborsTransformer(n_neighbors=10, metric='cosine'))
],
memory=None)
После обучения pipeline
можно сохранить в файл с помощью pickle
и использовать для обработки входящих инцидентов.
Вместе с моделью будем сохранять и необходимые поля инцидентов — для того, чтобы позже использовать их в выдаче при работе модели.
# inc_data - pandas.Dataframe, содержащий список новых инцидентов
# ref_data - pandas.Dataframe, на котором тренировалась модель.
# Необходимо для показа минимальной информации. Загружается с моделью
#
inc_data["recommendations_json"] = ""
# Ищем ближайших соседей.
# column_list - список колонок, которые нужно передать в модель для каждой записи инцидента
nn_dist, nn_refs = p.transform(inc_data[column_list])
for idx, refs in enumerate(nn_refs):
nn_data = ref_data.iloc[refs][['number', 'short_description']].copy()
nn_data['distance'] = nn_dist[idx]
inc_data.iloc[idx]["recommendations_json"] = nn_data.to_json(orient='records')
# Выгружаем обработанные результаты в файл, например. Или отдаем клиенту еще как-нибудь.
inc_data[['number', 'short_description', 'recommendations_json']].to_json(out_file_name, orient='records')
Первые результаты применения
Реакция коллег на внедрение системы "подсказок" было в целом очень позитивное. Повторяющиеся инциденты стали разрешаться быстрее, мы начали работать над устранением проблем.
Однако чуда от системы unsupervised learning ожидать было нельзя. Коллеги жаловались на то, что иногда система предлагает совсем нерелевантные ссылки. Порой даже было сложно понять — откуда такие рекомендации берутся.
Понятно было, что поле для улучшение модели — огромное. Часть недочетов можно решить, включая или исключая некоторые атрибуты инцидента. Часть — подбором адекватного уровня отсечки по расстоянию между текущим инцидентом и "рекомендацией". Можно рассмотреть другие способы векторизации.
Но основной проблемой было отсутствие метрик качества рекомендаций. А раз так — нельзя было понять "что такое хорошо, а что такое плохо, и сколько это", и построить на этом сравнение моделей.
Доступа к http логам у нас не было, поскольку сервисная система работает удаленно (SaaS). Опросы пользователей мы проводили — но только качественно. Нужно было переходить к количественным оценкам, и строить на их основе четкие метрики качества.
Но об этом — в следующей части...