Привет, Хабр! На связи участница профессионального сообщества NTA Корсакова Елена.

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

Постановка задачи

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

Что это может быть за задача? В общем случае, это может быть выявление любых небольших отрывков текста, которые попали в общий набор как сор. К примеру, это могут быть заявки, ошибочно попавшие не в ту очередь. Также это могут быть необычные события в журналах или письма от мошенников. В этом посте для примера рассмотрю задачу отсеивания отрывков произведений Н.С. Лескова из набора с текстами П.П. Бажова. Почему выбрана эта пара писателей? Оба писали сказы, оба использовали разговорно-бытовой стиль, и с ходу не у всякого их текста угадывается автор.

Для решения таких задач часто используют метод одноклассовой классификации на основе опорных векторов — One-class SVM Classification. Одноклассовая классификация (ещё её называют унарной классификацией) помогает идентифицировать объекты определённого класса среди всех объектов через обучение на наборе, содержащем только объекты этого класса. Однако в моём случае этот метод дал неудовлетворительные результаты. Тогда было решено опробовать метод Кульбака—Лейблера.

Подготовка наборов данных

Наборы были подготовлены самостоятельно (их можно найти по ссылке). В них вошли части произведений из «Малахитовой шкатулки» Бажова и из сборника повестей и рассказов Лескова (например, «Левша»). Набор с текстами Бажова и вкраплениями текстов Лескова назову для простоты «большим», а набор исключительно с текстами Лескова — «малым». В большой набор вошло 2505 текста Бажова и 30 текстов Лескова, в среднем по 196 символов каждый. То есть в большом наборе 1,2% аномалий. В малый набор вошло 213 текстов Лескова по 208 символов в среднем. Загружаем наборы:

# загружаем малый набор
import os
fname = os.path.join('/kaggle/input/bazhov-leskov-dataset/Leskov.xlsx')
with open(fname) as f:
    target_corpus = pd.read_excel('/kaggle/input/bazhov-leskov-dataset/Leskov.xlsx')

# загружаем большой набор
fname = os.path.join('/kaggle/input/bazhov-leskov-dataset/Bazhov.xlsx')
with open(fname) as f:
    reference_corpus = pd.read_excel('/kaggle/input/bazhov-leskov-dataset/Bazhov.xlsx')

Примеры текстов:

Бажов:

Вот бы был наш Тимофеич дома, не то бы было. Спозаранок бы он разведал про незваных гостей и гостинцев бы им припас не столько. Напредки забыли бы дорогу к нашему городу! Дорогой человек по этому делу был. Зря его загубили!

Лесков:

«Ах, скажите, — говорю, — пожалуйста!» «Ну, Степан, — думаю, — Матвеич, отличную ж вы было со мной штуку подшутили! — и говорю, что, стало быть же, говорю, как я его теперь замечаю, он, однако, фортель!»

Далее поэтапно рассмотрю процесс подготовки текстов и поиска в них аномалий.

Подготовка текстов

Первый этап при работе с текстом — это его подготовка: очистка текстов от знаков пунктуации, числовых данных, токенов меньше трёх символов, стоп-слов, а также приведение слов к нижнему регистру и их лемматизация.

# функция для очистки текстов
def _remove_punct_num(sample):
    import re
    import string

    remove = string.punctuation
    remove = remove.replace('-', '') # don't remove hyphens
    pattern = r'[{}]'.format(remove) # create the pattern
    sample = re.sub(pattern, '', sample)
    sample = re.sub('[«»]+', '', sample)
    sample = re.sub('\\b\\d+(\\.\\d+)?%?', '', sample)
    return sample

def text_to_lemmas_KLD(sample):
    sample = _remove_punct_num(sample)
    tokens = (str(sample)).split()
    tokens = [morph.parse(tok.lower())[0].normal_form for tok in tokens if len(tok.lower())>3 and tok.lower() not in STOPLIST]
    return tokens

# чистим тексты
target_corpus['clean_text'] = target_corpus.text.apply(text_to_lemmas_KLD)
reference_corpus['clean_text'] = reference_corpus.text.apply(text_to_lemmas_KLD)

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

«Ах, скажите, — говорю, — пожалуйста!» «Ну, Степан, — думаю, — Матвеич, отличную ж вы было со мной штуку подшутили!».

['сказать', 'говорить', 'степан', 'думать', 'матвеевич', 'отличный', 'штука', 'подшутить'..]

Вместо лемматизации можно использовать стемминг. Эта операция возвращает основу слова, тогда как операция лемматизации возвращает словарную форму слова. Пример: «скажите» → «скаж». Пример лемматизации: «скажите» → «сказать». Лемматизация лучше, но из-за технических ограничений я использовала стемминг с помощью Snowball Stemmer NLTK. Стемминг хуже тем, что основы разных слов могут быть одинаковы и при подсчёте частот это даст дополнительную ошибку.

Отбор слов: метод Кульбака—Лейблера

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

# проводим подсчет частот лемм в корпусах
dictionary_to_investigate = sum((Counter(x) for x in target_corpus.clean_text), Counter())
dictionary_to_reference = sum((Counter(x) for x in reference_corpus.clean_text), Counter())

# функция подсчета относительных частот лемм в корпусах
def count_freq(counter):
    total = 0
    for key in counter:
        total += counter[key]
    result = {}
    for key in counter:
        result[key] = counter[key] / total
    return result

# считаем относительные частоты лемм в корпусах
investigate = count_freq(dictionary_to_investigate)
reference = count_freq(dictionary_to_reference)

Сравнение двух корпусов проводится с использованием расхождения Кульбака—Лейблера. Это мера того, насколько одно распределение вероятностей отличается от второго, эталонного, распределения вероятностей. В моём случае это вероятность встретить слово в корпусе. Рассчитывается оно так:

 D_{KL}(P\parallel Q)=\int_{X}^{}p\textrm{log}\frac{p}{q}d\mu

где µ — любая мера на X, для которой существуют абсолютно непрерывные относительно µ функции p = dP/dµ и q = dQ/dµ (а это как раз относительные частоты лемм в корпусах). Основание логарифма в этой формуле существенной роли не играет. Его выбор позволяет зафиксировать конкретный вид функции из семейства эквивалентных функций и равносилен выбору единицы измерения расхождения Кульбака—Лейблера, поэтому возможно применение логарифма с любым основанием, большим единицы. Я использовала двоичный логарифм. Код для этого этапа следующий:

# функция для расчета расхождения Кульбака-Лейблера
def partial_KLD(px, qx):
        if not all([px, qx]):
            return 0
        return px * log2(px/qx)   

def top_KLD_outliers(p, q, threshold, include_new=True):
    keys = set(p.keys()).union(q.keys())
    result = {}
    for key in keys:
        if key in p and key not in q:
            if include_new:
                result[key] = float('inf')
        elif key in p and key in q:
            pkld = partial_KLD(float(p[key]), float(q[key]))
            if pkld > threshold:
                result[key] = pkld
    return result

DKL(P‖Q) — безразмерная величина. Чему она может быть равна? Если два распределения в разрезе слов полностью совпадают, то получим DKL(P‖Q)=0. Если слово редкое, то получим отрицательное значение. Оба эти случая мне неинтересны: либо потому, что это низкочастотное слово, либо потому, что в обоих корпусах слово одинаково распределено. Поэтому их отсекаю, принимая отсечку по DKL(P‖Q) на уровне 0,0001:

# рассчитаем расхождение Кульбака—Лейблера для лемм малого набора
outliers = top_KLD_outliers(investigate, reference, 0.0001, False)

В итоге получаем список специфичных слов с соответствующими значениями DKL(P‖Q). Таким образом получилось 781 слово (а точнее 781 лемма), самые специфичные из которых — «говорить» и «государь».

Нужно помнить, что расхождение Кульбака—Лейблера является несимметричной мерой, то есть может быть DKL(P‖Q) ≠ DKL(Q‖P). Поэтому всегда важно правильно определять эталонный и референсный корпуса.

Поиск

Следующий этап — поиск текстов Лескова в наборе с текстами Бажова. Для каждого слова из текстов большого набора, проверяю есть ли оно среди специфичных слов, и если есть, то беру его DKL(P‖Q). Далее, суммирую все DKL(P‖Q), а затем, для учёта размера текста, умножаю эту сумму на количество специфичных слов и делю на общее количество слов текста после чистки.

Specificity_{text}=\frac{\sum DKL(P\parallel Q)\times N_{specific words}}{N_{clean words}}

После сортировки по убыванию по ΣDKL(P‖Q) и выбора отсечки получаю набор текстов, похожих на тексты Лескова.

# функция для расчета специфичности текстов 
def find_specificity(cell):
    total_specificity = 0
    total_specific_words = 0
    for key in cell:
        total_clean_words = len(cell)
        if key in outliers:
            total_specificity += float(outliers[key])
            total_specific_words += 1
    return total_specificity*total_specific_words/total_clean_words

# строим рейтинг текстов большого набора по специфичности
reference_corpus['specificity'] = reference_corpus['clean_text'].apply(find_specificity)
reference_corpus = reference_corpus.sort_values(by='specificity', ascending=False)

Как правильно выбрать отсечку?

Для того, чтобы это понять, построим график специфичности для топ-50 исследуемых текстов. Те тексты, которые имеют специфичность выше 0,74 — это гарантированно тексты Лескова. Их получилось 26 штук. И действительно, контрольная колонка с авторами для этих текстов содержит только значение «Лесков». Те тексты, которые имеют специфичность ниже 0,21 — тексты Бажова. Проверка контрольной колонки подтверждает это.

График топ-50 текстов большого набора в разрезе специфичности.
График топ-50 текстов большого набора в разрезе специфичности.

А вот между точками со специфичностью 0,74 и 0,21 находится пограничная зона, в которой специфичность текстов резко снижается. И тут хорошо бы иметь под рукой эксперта, который сможет вручную обработать тексты этой области. Однако можно поступить проще. В том случае, если важно исключить аномалии по максимуму, можно отсечь тексты по порогу 0,21. Отбросим 32 текста, из которых 30 текстов Лескова и 2 текста Бажова. Неплохой результат!

Матрица несоответствий для метода Кульбака—Лейблера (отсечка 0,21): Actual label — фактическая метка, Predicted label — предсказанная метка; 0 — количество текстов Лескова, определённых как тексты Бажова, 30 — количество правильно определённых текстов Лескова, 2 — количество текстов Бажова, определённых как тексты Лескова, 2503 — количество правильно определённых текстов Бажова.
Матрица несоответствий для метода Кульбака—Лейблера (отсечка 0,21): Actual label — фактическая метка, Predicted label — предсказанная метка; 0 — количество текстов Лескова, определённых как тексты Бажова, 30 — количество правильно определённых текстов Лескова, 2 — количество текстов Бажова, определённых как тексты Лескова, 2503 — количество правильно определённых текстов Бажова.

Если важно оставить все целевые тексты, — а это тексты Бажова, — то можно отсечь по порогу 0,74. Так будут сохранены все тексты Бажова, но среди 2505 фрагментоы останется 4 текста Лескова. Для этого нужно найти первую точку перегиба на кривой специфичности. Ищу её как вторую производную от слегка сглаженных данных по специфичности.

raw = reference_corpus.specificity.to_list()
smooth = gaussian_filter1d(raw, 8)
smooth_d2 = np.gradient(np.gradient(smooth))
inflection_points = np.where(np.diff(np.sign(smooth_d2)))[0]

А вот график:

# plot threshold results
plt.plot(raw, label='Noisy Data')
plt.plot(smooth, label='Smoothed Data')
plt.plot(np.max(smooth)*(smooth_d2)/(np.max(smooth_d2)-np.min(smooth_d2)), label='Second Derivative (scaled)')
for i, infl in enumerate(inflection_points, 1):
    plt.axvline(x=infl, color='k', label=f'Inflection Point {i}')
plt.legend(bbox_to_anchor=(1.55, 1.0))
Поиск осечки аномалий по точке перегиба кривой специфичности текстов: синяя линия — изначальный (зашумленный) график специфичности; оранжевая линия — сглаженный график специфичности; зеленая линия — график второй производной функции специфичности (масштабированный). При х = 26 вторая производная меняет свой знак. Так мы находим точку перегиба функции специфичности (26; 0,74).
Поиск осечки аномалий по точке перегиба кривой специфичности текстов: синяя линия — изначальный (зашумленный) график специфичности; оранжевая линия — сглаженный график специфичности; зеленая линия — график второй производной функции специфичности (масштабированный). При х = 26 вторая производная меняет свой знак. Так мы находим точку перегиба функции специфичности (26; 0,74).

И затем сохраняю результат:

reference_corpus[:inflection_points[0]].to_excel('KLD_result.xlsx', index=False)

Сравнение результатов с One-class SVM classification

Наборы готовила так же, как для метода Кульбака—Лейблера. Для векторизации использовался TfidfVectorizer. Метод One-class SVM взят в библиотеке Scikit learn.

Оказалось, что с One-class SVM classification не всё так хорошо, как с методом Кульбака—Лейблера, как видно из матрицы несоответствий: было правильно распознано 18 аномальных текстов, тогда как 12 аномальных текстов было причислено к классу нормальных. Но что хуже всего — 99 текстов Бажова было отнесено к текстам Лескова, а это почти 4% всех нормальных текстов!

# визуализируем матрицу несоответствий
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

class_names=[0,1] # name  of classes
fig, ax = plt.subplots()
tick_marks = np.arange(len(class_names))
plt.xticks(tick_marks, class_names)
plt.yticks(tick_marks, class_names)
# create heatmap
sns.heatmap(pd.DataFrame(results), annot=True, cmap="YlGnBu",fmt='g', linewidths=1, linecolor='#dddddd', annot_kws={"size": 16})
ax.xaxis.set_label_position("top")
plt.tight_layout()
plt.title('Confusion matrix | One-class SVM classification', y=1.1)
plt.ylabel('Actual label')
plt.xlabel('Predicted label')
for item in ([ax.title, ax.xaxis.label, ax.yaxis.label] +
             ax.get_xticklabels() + ax.get_yticklabels()):
    item.set_fontsize(16)
Матрица несоответствий для метода One-class SVM Classification: Actual label — фактическая метка, Predicted label — предсказанная метка; 12 — количество текстов Лескова, определённых как тексты Бажова, 18 — количество правильно определённых текстов Лескова, 99 — количество текстов Бажова, определённых как тексты Лескова, 2406 — количество правильно определённых текстов Бажова.
Матрица несоответствий для метода One-class SVM Classification: Actual label — фактическая метка, Predicted label — предсказанная метка; 12 — количество текстов Лескова, определённых как тексты Бажова, 18 — количество правильно определённых текстов Лескова, 99 — количество текстов Бажова, определённых как тексты Лескова, 2406 — количество правильно определённых текстов Бажова.

Заключение

Итак, в посте описана возможность использования метода Кульбака—Лейблера для поиска текстовых аномалий при наличии размеченного набора с образцами исключительно аномального класса. Разобран пример с несбалансированными по размеру наборами (2535 строк vs. 213 строк).

Метод Кульбака—Лейблера показал хорошие результаты, в отличие от метода одноклассовой классификации на основе SVM: с нулевыми потерями по текстам Бажова в противовес 4% для SVM и с четырьмя оставленными в наборе текстами Лескова против 12 для SVM. Точно так же можно искать необычные события в логах, письма от мошенников, аномальные заявки, ошибочно попавшие не в свою очередь.

Полный код, список стоп-слов и наборы для обучения можно найти по ссылке.

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


  1. Ranckont
    28.12.2023 04:55

    А если имена сделать одинаковыми, то насколько текст будет одинаковым?


    1. NewTechAudit Автор
      28.12.2023 04:55

      Добрый день!

      Убрала имена собственные из датасетов. Запустила скрипт.

      One‑class SVM Classification отработал лучше.

      Было: 12 — количество текстов Лескова, определённых как тексты Бажова, 18 — количество правильно определённых текстов Лескова.

      Стало: 8 — количество текстов Лескова, определённых как тексты Бажова, 22 — количество правильно определённых текстов Лескова.

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


  1. nikolay_karelin
    28.12.2023 04:55

    А на би-/мульти-граммах или эмбеддингах не пробовали классификатор строить?


    1. NewTechAudit Автор
      28.12.2023 04:55

      Доброго времени суток! Спасибо за вопрос.

      Расхождение Кульбака‑Лейблера не используется для работы с векторным представлением слов.

      А что касается сочетаний из нескольких слов, то метод Кульбака‑Лейблера можно использовать в качестве первого шага. С его помощью можно найти ключевые слова (в англоязычной литературе используют термины keywords, headwords, node words).

      Скорее всего, нужны не просто би‑/мульти‑граммы, а устойчивые словосочетания. Иногда их приравнивают к термину коллокации. Так вот, коллокации можно определить с использованием ассоциативных мер, коих очень много: Mutual Information, Log‑likelihood, T‑score, logDice и т. д. Тут есть свои нюансы. Например, нужно определиться с коллокационным окном — сколько слов до ключевого слова и после ключевого слова рассматривать в качестве потенциальных коллокатов.

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


  1. Jeshua
    28.12.2023 04:55

    Не могу найти. Гипотеза о том, что за Ильфа и Петрова писал Булгаков, уже была кем-то проверена? Например, ваша модель может дать ответ на нее? И если это не Булгаков, может ли модель отличить Ильфа от Петрова?


    1. NewTechAudit Автор
      28.12.2023 04:55

      Добрый день!

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