Классификация текста количеством более двух меток одна из самых сложных задач в машинном обучение. Как, правило сырые данные перед обучением модели требуют серьёзной обработки.

Впервые с подобной задачей я столкнулся на своей работе – было необходимо обработать текст обратной связи посетителей портала с ограниченным количеством символов - около ста слов – сообщения могли состоять как из одного слова, так и из нескольких предложений. Количество классов – пять, от очень плохой «эмоциональной окраски» сообщения до очень хорошей.

Данные

По причине NDA я не могу демонстрировать данные, которые предоставил клиент, поэтому специально для этой статьи я собрал собственный сет данных отзывов об отелях – стандартная история для классификации тональности.

Мой «демонстрационный» сет данных содержит 163830 текстовых сообщений (желающие могут скачать по ссылке), как и у данных клиента, в сете присутствует сильный дисбаланс в классах, что очень ощутимо сказывается на итоговой точности предсказания.

На порядок меньше отзывов с метками 1 и 3, и напротив крупный перевес представляет 5-ый класс.

Так же тексты весомо отличаются по своей длине, при средней длине отзыва 50 токенов присутствуют крупное кол-во, превышающее значения 100 токенов – встречаются тексты и более тысячи.

Тестовые данные для чистоты эксперимента я выделил в отдельный фрейм размером 25000 строк – по 5000 сообщений на каждый класс. Моделируя реальную задачу, в тестовый набор я добавлял только сообщения с количеством токенов от 1 до 100.

Задача

В этом исследование я хочу разобрать отдельный сегмент предобработки данных, а именно показать, насколько балансировка тренировочных данных может качественно повлиять на конечный результат, а также чуть глубже погрузиться в суть работы механизма взвешивания слов TF-IDF

Генерация данных

Первым делом определимся с необходимым количеством отзывов для каждого класса. Я посчитал что 30-35 тысяч отзывов будет оптимальным решением. Необходимо сгенерить 20к текстов для первого и для третьего класса, а 15 тысячами текстов для 5-го придётся пожертвовать.

Существует множество способов генерации текста, в том числе с применением различных библиотек, я же реализую наиболее «дешевый» с точки зрения времени вычисления вариант, основанный на частоте использования биграмм тренировочного корпуса. В данной задаче для нас не имеет значение семантический аспект генерируемых предложений, так для взвешивания токенов будет использоваться метод TF-IDF. Далее в статье я подробней остановлюсь на этом методе, а здесь оставлю мой вариант скрипта, генерирующего текст для первого класса (очень плохие отзывы).

Первым делом напишем функцию (tokenize_sentences) разбиения текстов корпуса, которая возвращает список токенов, разделённых фиктивным токеном ('END_SENT_START') в конце каждого предложения. ['начнем', 'с', 'того', 'что', 'в', 'этом', 'отеле', 'не', 'берут', 'деньги', 'только', 'за', 'воздух', 'END_SENT_START', 'звонок', 'с', 'телефона', ...]

import pandas as pd
import numpy as np
import sklearn
import re

import nltk
from nltk import tokenize
from nltk.tokenize import RegexpTokenizer

def get_text_for_label(df, label): # Получим список с текстами для каждого класса
    label = 'label == ' + str(label)
    df_label = df.query(label).drop(['label'], axis=1)
    return df_label.feedback.values.tolist()

feedback_label_1 = get_text_for_label(df_fin_feedback, 1) # К примеру, список для первого класса

def tokenize_sentences(text_corp): # Функцию разбиения текстов корпуса на токены с разбивкой по предложениям
    token_corp = []
    for text in text_corp:
        text = re.sub(r'\s+', ' ', text, flags=re.M)
        for sent in re.split(r'(?<=[.!?…])\s+', text):
            sent = sent.replace('\n',' ')
            for word in sent.split():
                token = re.search(r'[а-яёА-ЯЁa-zA-Z]+', word, re.I)
                if token is None:
                    continue
                token_corp.append(token.group().lower())
            token_corp.append('END_SENT_START') # В конце каждого предложения добавляем фиктивный токен
    return token_corp

token_label_1 = tokenize_sentences(feedback_label_1) # К примеру, список для первого класса

На вход следующей функции поступает получившийся список токенов. Функция возвращает словарь с группированными биграммами: ключи – отдельные токены, в значение – список слов которые следуют за ними в корпусе с собственной частотой (  { 'отель': [('отличным', 4.116e-06), ('вобщем', 2.058e-06), …)

from collections import Counter, defaultdict

def get_bigramms(token_list):
    bigramm_corp = []
    for i in range(len(token_list)-1):
        bigramm = token_list[i] + ' ' + token_list[i+1]
        bigramm_corp.append(bigramm) # Получим список биграмм
    
    unique_token_count = len(set(bigramm_corp)) # кол-во уникальных биграмм
    bigramm_proba = {} # Создаю словарик для результата: Ключ - биграмма. Значение - вероятность
     
    count_bigramm = Counter(bigramm_corp) # Создаю словарь для хранения частот биграмм
    count_token = Counter(token_list) # Создаю словарь для хранения частот токенов        
        
    # Создаю словарь с группированными биграммами: ключи – отдельные токены, 
    # в значение – список слов которые следуют за ними в корпусе 
    # с собственной частотой (  { 'отель': [('отличным', 4.116e-06), ('вобщем', 2.058e-06), …)
    grouped_bigramms = defaultdict(list)
    for bigramm in set(bigramm_corp):
        first_word, second_word = bigramm.split()
        proba = (count_bigramm[bigramm] + 1) / (count_token[first_word] + unique_token_count) # Формула Лапласа
        grouped_bigramms[first_word].append((second_word, proba))
    return grouped_bigramms

grouped_bigramm_1 = get_bigramms(token_label_1) # На примере первого класса

Дальше код для самой генерации предложений. Скрипт выдаёт 4 предложения по 15 слов каждое, примерно следующего содержания :)

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

import random


def generate_texts(token_label, grouped_bigramm, label, count_text, count_sent, count_word):

    # Создаём словарь для подсчёта биграмм "исключений"
    exceptions_bigramm = defaultdict(int)
    # Создаём список уникальных токенов для старта предложения
    unique_token = list(set(token_label))

    texts = []
    for it_text in range(count_text):  # Цикл с диапазоном кол-ва текстов
        text = ''
        unique_word = set()

        # Цикл с диапазоном кол-ва предложений в тексте
        for it_sent in range(count_sent):
            len_sent = count_word
            # Генерим случайное слово для начала предложения для обеспечения стохастического процесса генерации предложения
            start_word = random.choice(unique_token)
            # Записываем в строку с финальным предложением первое стартовое слово
            final_sent = start_word
            # Множество уникальных слов, которые уже сгенерились в предложение (чтобы геенерация не зацикливалась)
            unique_word.add(start_word)

            for step in range(count_word):
                next_word = None  # Создаём переменную для нового слова
                frequency = 0  # Переменная-счётчик для частоты каждого нового слова

                # Проходим циклом по словарю с ключом биграммы и значением её частоты
                for second_word, freq in grouped_bigramm[start_word]:
                    bigramm = start_word + ' ' + second_word
                    # Устанавливаем значение максимального повторения слова в одном тексте
                    if exceptions_bigramm[bigramm] > 3:
                        continue
                    if freq > frequency and second_word not in unique_word and second_word != 'END_SENT_START':
                        next_word = second_word
                        frequency = freq  # Если второе слово проходит условие запоминаем его
                if next_word is None:  # Если подходящего по условиям слова не найдено, перезаписываем стартовое слово и начинаем поиск заново
                    start_word = random.choice(unique_token)
                    final_sent += ', ' + start_word
                    unique_word.add(start_word)
                else:
                    # Если после цикла нашли подходящее слова (которое запомнили в цикле) - записываем его в предложение
                    exceptions_bigramm[start_word + ' ' + next_word] += 1
                    start_word = next_word
                    final_sent += ' ' + next_word
                    unique_word.add(start_word)
            final_sent += '. '
            text += final_sent
        texts.append(text)

    generation_text_df = pd.DataFrame(texts, columns=['feedback'])  # Формируем фрейм из списка
    generation_text_df['label'] = label
    return generation_text_df[['label', 'feedback']]


label_1_df = generate_texts(token_label_1, grouped_bigramm_1, label=1, count_text=15000, count_sent=4, count_word=15)

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

Баланс данных

Сгенерировав недостающее количество текстов, следует привести к балансу длину самих текстов. Для чего? Что бы ответить на этот вопрос, вспомним как работает механизм взвешивания текста TF-IDF.

Мера TF-IDF является произведением двух сомножителей.

TF - частота слова - отношение числа вхождений некоторого слова к общему числу слов документа. Таким образом, оценивается важность слова в пределах отдельного документа.

IDF - обратная частота документа - инверсия частоты, с которой некоторое слово встречается в документах коллекции. Учёт IDF уменьшает вес широкоупотребительных слов. Для каждого уникального слова в пределах конкретной коллекции документов существует только одно значение IDF.

Большой вес в TF-IDF получат слова с высокой частотой в пределах конкретного документа и с низкой частотой употреблений в других документах. То есть, если мы имеем корпус с текстами с сильно различным количеством слов, мы рискуем получить завышенный показатель IDF если слово встречается только в маленьких текстах и наоборот если слово часто встречается много раз только в одном крупном тексте.

Ещё раз по-другому

  • Первый случай. Слово t встречается пять раз в тексте длинной 500 слов: знаменатель формулы IDF получит 1 бал и завысит показатель самого IDF, а следовательно, общий вес слова.

  • Второй случай. Слово t встречается по одному разу в пяти текстах длинной 100 слов: знаменатель формулы IDF получит 5 балов и занизит показатель самого IDF, а следовательно, общий вес слова.

Подводя черту, выходит, что, зная длину текстов тестовых данных, мы можем качественно повлиять на результат взвешивания слов, корректируя длину текстов для тренировочных данных. Если мы знаем, что в тестовой выборке сообщения длиной не более 100 слов, нам выгодней использовать веса, определённые во втором случае. Проверим гипотезу на практике

Приведём токены к их леммам

from nltk.tokenize import RegexpTokenizer
from nltk import tokenize
import nltk
import pymorphy2
morph = pymorphy2.MorphAnalyzer()


def lemmatize(text):
    pattern = '[а-яёА-ЯЁ]+'
    tok = tokenize.RegexpTokenizer(pattern)
    text = tok.tokenize(text)

    def normalize(word):
        return morph.parse(word.lower())[0].normal_form

    return " ".join([normalize(it) for it in text if len(it) > 2])


df_feedback_train['feedback'] = df_feedback_train['feedback'].apply(lambda value: lemmatize(value))

Пропустим наш корпус через функцию разбиения/склеивания текстов и получим новый корпус с длинами документов, в заданном тестовым набором, диапазонах – от одного до ста токенов.

В функцию передаются аргументы: 1. сам корпус; 2. нижний порог слов в тексте; 3. верхний порог слов; 4.  нижний порог слов в текстах «под отсечение».

list_feedback_label_1 = get_text_for_label(df_feedback_train, 1) # Снова получим список документов из фрейма (пример для первого класса)
 
def balance_text(corp, low_thresh, high_thresh, low_remove_tresh):
    new_corp, temp = [], []
    for text in corp:
        if low_thresh <= len(text) <= high_thresh: # Если длина текста в пределах диапазона - оставляем текст без изменения
            new_corp.append(text)
        elif len(text) < low_thresh: # Если длина текста меньше нижнего порога - запоминаем, затем склеиваем с таким же текстом
            if len(temp) >= low_thresh:
                new_corp.append(temp)
                temp = text
            else:
                temp.extend(text)
        else:
            # Если длина текста больше верхнего порога - сплитим на меньшие тексты в рамках диапазона
            # Совсем мелкие хвостовые части не добавляем
            for j in range(0, len(text) - low_remove_tresh, high_thresh):
                new_corp.append(text[j:min(len(text), j + high_thresh)])
 
    if len(temp) > low_remove_tresh:
        new_corp.append(temp)
 
    return new_corp
 
label_1_feedback_balance = balance_text(list_feedback_label_1, 50, 100, 10) # Получаем сбалансированный текст для первого класса
# Всё тоже проделываем для всех 5-ти классов.

В результате получим следующее распределение количества токенов в документах.

Здесь следует сделать отступление. Если взглянуть на аналогичный график распределения слов для первого класса тестовой выборе (график ниже), мы увидим картину идеального нормального распределения. Следовательно, и распределение для тренировочной выборки я стремился сделать схожим – нормальным, но именно нормальное распределение в тренировочной выборке дало худший показатель точности, а самый оптимальный представлен на графике выше.

Итак, мы получили корпуса со сбалансированным количеством слов, теперь отсечём лишнее – думаю 40к доков для каждого класса будет достаточно для демонстрации, затем обучим классификатор и сравним результат с результатами на разных этапах предобработки тренировочных данных. В связке с TF-IDF неплохо работает логистическая регрессия.

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix, accuracy_score

%matplotlib inline
import matplotlib.pyplot as plt
import seaborn as sns

# Отсекаем лишнее количество документов для выравнивания кол-ва текстов в представленных классов между собой
label_1_feedback_balance = label_1_feedback_balance[:40000] # Так для каждого класса

# Склеиваем один фрейм для тренировочных данных
def list_to_df(texts, label): # Функция для получения фрейма для каждого класса
    feedback = [' '.join(it) for it in texts]
    labels = [label for i in range(len(texts))]
    return pd.DataFrame(list(zip(labels, feedback)), columns =['label', 'feedback'])

df_label_1 = list_to_df(label_1_feedback_balance, 1) # Так для каждого класса (получаем фрейм)

df_feedback_train_balance = pd.concat([df_label_1, df_label_2, df_label_3, df_label_4, df_label_5], axis=0) # Склеиваем
df_feedback_train_balance = df_feedback_train_balance.sample(frac=1).reset_index(drop=True) # Не забыть перемешать тексты


X_train_text = df_feedback_train_balance['feedback'].values
X_test_text = df_feedback_test['feedback'].values
y_train = df_feedback_train_balance['label'].values
y_test = df_feedback_test['label'].values

v = TfidfVectorizer(norm=None, max_df=0.8, max_features=500, decode_error='replace') # Взвешиваем вектора
X_train_vector = v.fit_transform(X_train_text)
X_test_vector = v.transform(X_test_text)

clf = LogisticRegression( random_state=64, solver='lbfgs', max_iter=10000, n_jobs=-1) # Обучаем классификатор
clf.fit(X_train_vector, y_train)
y_pred = clf.predict(X_test_vector)

# Вывод результатов
print(accuracy_score(y_test, y_pred))
print(classification_report(y_test,y_pred))

T5_lables = ['5','4','3','2','1']    

ax= plt.subplot()

cmm = confusion_matrix(y_test,y_pred)
sns.heatmap(cmm, annot=True, fmt='g', ax=ax);

ax.set_xlabel('Predicted labels');ax.set_ylabel('True labels'); 
ax.set_title('Confusion Matrix'); 
ax.xaxis.set_ticklabels(T5_lables); ax.yaxis.set_ticklabels(T5_lables);

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

Модель с добавлением сгенерированных данных в слабо представленных классах дала результат точности предсказания в 0,56897.

Модель же с добавлением сгенерированных данных и балансировкой текстов по длине показала точность 0,64984. Уточнённые показатели так же выглядят приятней, да и прирост в точности в 8% при многоклассовой классификации довольно ощутимый результат.

Спасибо за внимание!

Буду рад Вашим замечанием, с удовольствием отвечу на вопросы в комментариях.

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


  1. WELLOFF
    19.07.2022 07:09
    +1

    Классная статья! Как интерпретировать "Confusion Matrix"? Можете подсказать хорошую статью на эту тему? По содержанию статьи: кто-нибудь может подробнее объяснить почему нормальное распределение дало результат хуже?


    1. databorodata Автор
      19.07.2022 07:24

      Спасибо! Матрица здесь используется в качестве метрики взаимотношения между классами при предсказание, визуализироемое с помощью тепловой карты - на ней показано кол-во предсказаний класса всеми имеющимися классами. То есть к примеру в последней картинке: 5ый класс предсказан 5ым (верно) 3266 раз, а 4ым (не верно) 344 раза.


    1. databorodata Автор
      19.07.2022 07:26

      По поводу нормального распределения мне бы тоже было интересно услышать предположения)

      Я перепробовал кучу вариантов, почему выстрелил именно этот объяснить не могу((


  1. aarmaageedoon
    19.07.2022 08:08
    +1

    Хотел бы задать пару вопросов.

    Вы пишите, что

    То есть, если мы имеем корпус с текстами с сильно различным количеством слов, мы рискуем получить завышенный показатель IDF если слово встречается только в маленьких текстах и наоборот если слово часто встречается много раз только в одном крупном тексте.

    но ведь IDF не зависит от длинны документа.

    И еще, вы далее даете пояснения

    Первый случай. Слово t встречается пять раз в тексте длинной 500 слов: знаменатель формулы IDF получит 5 балов и снизит показатель самого IDF, а следовательно, общий вес слова.

    Второй случай. Слово t встречается по одному разу в пяти текстах длинной 100 слов: знаменатель формулы IDF получит 1 балов и завысит показатель самого IDF, а следовательно, общий вес слова.

    Но ведь расшифровка формулы знаменателя IDF, которую вы приводите, на русском звучит как мощность множества документов d_i, входящих в корпус D, таких, что в документе d_i содержится токен t хотя бы один раз. И тогда, в первом случае, если мы берем текст из 500 слов как корпус, то idf каждого слова в принципе будет 1. А во втором случае мы имеем корпус из 5 текстов и, если слово встречается во всех 5 текстах, то знаменатель будет равен 5.

    Опять же, замечу, что знаменатель IDF не зависит от количества слов в самом документе, поэтому не понятно как количество слов влияет на показатель IDF.


    1. databorodata Автор
      19.07.2022 10:51

      Хорошо что вы спросили. Спасибо за вопрос!

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

      Давайте на примере. Предположим слово t - "бедолага" - редкое слово с негативной эмоциональной окраской, которое редко встречается в текстах, допустим 3-го класса, всё как в примерах из статьи.

      Считаем его idf. Кол-во документов в колл. для 3-го класса - 40000 - это числитель.

      первый случай - пять раз в одном тексте (500 слов) - в знаменателе 1. 40000/1 = 40000

      второй случай - по одному разу в пяти текстах длинной 100 слов - в знаменателе 5. 40000/5 = 8000.

      Есть разница? Стоит упомянуть что

      Для каждого уникального слова в пределах конкретной коллекции документов существует только одно значение IDF.

      Можно посмотреть подробно описание формулы tfidf в вики


      1. aarmaageedoon
        19.07.2022 11:09
        +1

        Вы не учитываете числитель - то есть кол-во текстов в коллекции (корпусе).

        Числитель IDF все еще не зависит от длины документов в коллекции) В том числе, в ваших вычислениях.

        Далее, вы пишете

        первый случай - пять раз в одном тексте (500 слов) - в знаменателе 1.

        А в статье написано

        Первый случай. Слово t встречается пять раз в тексте длинной 500 слов: знаменатель формулы IDF получит 5 балов и снизит показатель самого IDF, а следовательно, общий вес слова.

        И еще вы описываете в комменте

        второй случай - по одному разу в пяти текстах длинной 100 слов - в знаменателе 5. 40000/5 = 8000

        А в статье написано

        Второй случай. Слово t встречается по одному разу в пяти текстах длинной 100 слов: знаменатель формулы IDF получит 1 балов и завысит показатель самого IDF, а следовательно, общий вес слова.


        1. databorodata Автор
          19.07.2022 11:30
          +1

          Вот она где собака зарыта)) Это я описался! Как обычно, смотрю в книгу и вижу фигу...

          Спасибо вам большое за то что поправили меня! Дико извиняюсь что завёл вас в заблуждение. Статью поправил