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


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

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


  1. sbnur
    23.10.2019 21:29

    поправьте — правильно deja vu


    1. serhit Автор
      23.10.2019 21:49

      Спасибо, поправил :)


  1. Nikolai46
    23.10.2019 22:57

    А если попробовать систему типа антиплагиат, по идее более высокая оценка по плагиату может быть как раз то что вам надо, особенно если система позволяет иметь собственный список stop-words и назначать вес специфичным терминам/фамилиям.

    Прогоняем несколько раз, с разными списками и комбинируем ответ.


    1. serhit Автор
      24.10.2019 08:59

      Я читаю их блог. Система "Антиплагиат" — это монстр, по сравнению с нашей задачей :)
      Кроме того, я боюсь, что такие методы, как там используются используются, на коротких текстах обращениях (длиной до 50 слов) могут не сработать...


      1. Nikolai46
        24.10.2019 19:35

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

        Тем более на сколько я понял, они как раз синонимы умеют отлавливать.

        Плюс например само сообщение можно обогатить, например вставляя названия отделов, подразделений, города по фамилиям вовлечённых и данным из аккаунтов. Например есть упоминание фамилии «Петров А.» — смотрим и добавляем «Отдел IT», «Департамент поддержка пользователей», «Красноярск», «Офис по ул.Цветной» и т.п.

        Или вставлять название времени суток по точным данным из сообщений, типа если 1:23am, добавляем к тексту — «глубокая ночь»

        Можно попробовать с ними поговорить, если будет работать, то им плюс — новое направление по применению существующей системы.


  1. amarao
    24.10.2019 08:47

    Я бы добавил отдельное обнаружение характерных паттернов.


    1. Трейсы ядра
    2. Трейсы питона
    3. Трейсы джавы
    4. Трейсы С++

    (всех их человек умеет обнаруживать безошибочно и может замечать за время порядка 20мс, когда простыня пролетает по экрану)


    Случай "файл/номер строки".
    Стандартные тексты для errno


    1. serhit Автор
      24.10.2019 09:09

      Это интересно… Я посмотрю, как часто у нас в текстах встречаются формальные сообщения об ошибках. Их действительно можно включить в предобработку.


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


      1. amarao
        24.10.2019 10:39

        А, у вас специальный кейс. У нас в саппорте (хостера) показать трейс — как нефиг делать.


  1. CrazyElf
    24.10.2019 13:23

    Лучше наверное всё же эмбеддинги какие-нибудь сделать, натренировать их на большом словаре подходящих текстов. И не стемминг, а лемматизацию взять. Мешок слов скорее всего не даст модели понять, что одни и те же вещи можно сказать совсем разными словами (если не использовать разметку данных), а вот эмбеддинги возможно смогут выделить одинаковый контекст при достаточном наборе примеров.


    1. serhit Автор
      24.10.2019 18:08

      Целевой эмбеддинг можно натренировать при условии достаточного количества размеченных данных: "инцидент 1 похож на инцидент 2" / "инцидент 3 не похож на инцидент 4". Мы собрали немного размеченных данных — но этого не хватает для значимого обучения.


      Я, кстати, пытался найти где-нибудь численные оценки минимально необходимого количества размеченных данных для тренировки сетей заданной архитектуры. Общий ответ — чем больше, тем лучше ( "спасибо, Кэп" ).
      Может у кого-нибудь есть статейка в закладках почитать по этой теме?


      1. CrazyElf
        24.10.2019 19:12

        Не-не-не. Вы почитайте про эмбеддинги. Там смысл в окружении слов. Примерно того же можно добиться, используя n-граммы для мешка слов, но не совсем. Эмбеддинги лучше — они занимают меньше места чем n-граммы и вообще лучше работают.
        Грубо говоря, если у вас есть набор обучающих примеров:
        — Что за фигня творится с моим десктопом при включении?
        — Что за хрень творится с моим компом при включении?
        — Что за фигня творится с моим компом при старте?
        — Что за ерунда творится с моим компом при включении?
        и т.д.
        То после тренировки эмбеддингов все эти фразы будут лежать очень близко друг от друга. Но это не всё, в векторном пространстве слов слова «фигня», «ерунда» и «хрень» будут лежать очень близко. Тоже самое со словами «комп» и «десктоп». И тоже с «включение» и «старт» (если вы использовали лемматизацию).
        Никакой мешок слов вам такое не сделает.
        И вот такие вектора можно, в принципе, учить даже не на ваших логах, а на некоем текстовом корпусе, который лежит близко к вашей области. Грубо говоря, многие вообще учат вектора на текстах из «Википедии». Вам это не факт что подойдёт, но вы вполне можете взять логи звонков некоего саппорта, похожего на ваш, если они есть в интернете — и выучить вектора на них, а потом применить их в своих моделях. Вот в чём прикол эмбеддингов.


        1. serhit Автор
          25.10.2019 17:34

          Наверное, вы имеете ввиду стандартные эмбеддинги типа word2vec или Glove. Они, действительно, обучаются на корпусе по окружению. Но только они генерируют эмбеддинги для слов, а не предложений. Получить эмбеддинг текста — это еще один шаг, который можно сделать либо просто (усреднение эмбеддингов слов), либо сложнее (вектор фиксированной длины с элементами из эмбеддингов слов).


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


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


  1. CrazyElf
    24.10.2019 13:26

    Однако чуда от системы unsupervised learning ожидать было нельзя. Коллеги жаловались на то, что иногда система предлагает совсем нерелевантные ссылки. Порой даже было сложно понять — откуда такие рекомендации берутся.

    Это связано вовсе не с unsupervised learning, а с тем, что ваша модель не интерпретируема («чёрный ящик»). Это плохо, надо всегда иметь возможность понять, почему модель приняла то или иное решение, иначе вы не сможете понять, как её улучшить.


    1. serhit Автор
      24.10.2019 18:20

      Ну, я бы не сказал, что модель не интерпретируема. Есть пары нормированных векторов, каждый компонент которых соответствует слову или n-грамме — косинусное расстояние определяет похожесть текстов.
      Кроме того, использование TfidfVectorizer, как раз позволяет даже посмотреть какие слова значимы для корпуса и для каждого инцидента — это отдельная полезная функция.


      Просто бывали случаи, когда значимых пересечений по словам становится мало (редкий случай нашли) и основной вес уходит на компоненты обозначавшие отдел из которого пришло обращение. Получалось, система сообщала что-то вроде: "не знаю о чем это, но у этих заказчиков были еще вот такие проблемы..."


      1. CrazyElf
        24.10.2019 18:59

        А, ну то есть вы на самом деле знаете, почему система такое советовала. Это другое дело. :)


        1. serhit Автор
          25.10.2019 12:35

          Ну да, мы выяснили — и приняли меры :)