Pull to refresh

«Вроде такое уже было?» Поиск похожих инцидентов и заявок

Reading time7 min
Views4.1K

Всем, кто провел определенное время, поддерживая системы, знакомо чувство déjà vu при получении новой заявки: "вроде такое было, вроде решали, но как конкретно — не помню". Можно потратить время, покопаться в предыдущих заявках и постараться найти похожие. Это поможет: инцидент будет закрыт быстрее, а может быть даже удастся обнаружить глубинную причину и закрыть проблему раз и навсегда.


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


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


Мы уже используем ML-модели для классификации инцидентов. Чтобы помочь нашей команде эффективнее обрабатывать заявки, мы создали еще одну ML-модель для подготовки списка "ранее закрытые похожие инциденты". Детали — под катом.


Что нам нужно?


Для каждого поступающего инцидента необходимо в истории найти "похожие" закрытые инциденты. Определение "похожести" должно происходить в самом начале обработки инцидента, предпочтительно еще до того, как сотрудник службы поддержки приступил к анализу.


Для сопоставления инцидентов необходимо использовать информацию, предоставленную пользователем при обращении: краткое описание, подробное описание (если есть), любые атрибуты записи пользователя.


Команда поддерживает 4 группы систем. Общее количество инцидентов, которые хочется использовать для поиска похожих — порядка 10 тысяч.


Первое решение


Никакой проверенной информации о "похожести" инцидентов на руках нет. Так что state-of-the-art варианты с обучением сиамских сетей придется пока отложить.
Первое, что приходит в голову — простая кластеризация по "мешку слов", составленных из содержания обращений.


В этом случае процесс обработки инцидентов выглядит следующим образом:


  1. Выделение необходимых текстовых фрагментов
  2. Предварительная обработка/чистка текста
  3. TF-IDF векторизация
  4. Поиск ближайшего соседа

Понятно, что при описанном подходе похожесть будет базироваться на сравнении словарей: использовании тех же слов или 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 = 10000
  • ngram=(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). Опросы пользователей мы проводили — но только качественно. Нужно было переходить к количественным оценкам, и строить на их основе четкие метрики качества.


Но об этом — в следующей части...

Only registered users can participate in poll. Log in, please.
А вы используете в системе обработки заявок механизм «подсказок» для аналитиков службы поддержки?
7.69% Да, написали сами, в системе service desk такого функционала не было1
15.38% Да, используем функционал, встроенный в систему service desk2
23.08% Пока нет, но уже внедряем3
23.08% Пока нет, но планируем внедрять3
30.77% Нет и неизвестно4
13 users voted. 7 users abstained.
Tags:
Hubs:
Total votes 3: ↑3 and ↓0+3
Comments16

Articles