Классификация текста количеством более двух меток одна из самых сложных задач в машинном обучение. Как, правило сырые данные перед обучением модели требуют серьёзной обработки.
Впервые с подобной задачей я столкнулся на своей работе – было необходимо обработать текст обратной связи посетителей портала с ограниченным количеством символов - около ста слов – сообщения могли состоять как из одного слова, так и из нескольких предложений. Количество классов – пять, от очень плохой «эмоциональной окраски» сообщения до очень хорошей.
Данные
По причине 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)
aarmaageedoon
19.07.2022 08:08+1Хотел бы задать пару вопросов.
Вы пишите, что
То есть, если мы имеем корпус с текстами с сильно различным количеством слов, мы рискуем получить завышенный показатель IDF если слово встречается только в маленьких текстах и наоборот если слово часто встречается много раз только в одном крупном тексте.
но ведь IDF не зависит от длинны документа.
И еще, вы далее даете пояснения
Первый случай. Слово t встречается пять раз в тексте длинной 500 слов: знаменатель формулы IDF получит 5 балов и снизит показатель самого IDF, а следовательно, общий вес слова.
Второй случай. Слово t встречается по одному разу в пяти текстах длинной 100 слов: знаменатель формулы IDF получит 1 балов и завысит показатель самого IDF, а следовательно, общий вес слова.
Но ведь расшифровка формулы знаменателя IDF, которую вы приводите, на русском звучит как мощность множества документов , входящих в корпус , таких, что в документе содержится токен хотя бы один раз. И тогда, в первом случае, если мы берем текст из 500 слов как корпус, то idf каждого слова в принципе будет 1. А во втором случае мы имеем корпус из 5 текстов и, если слово встречается во всех 5 текстах, то знаменатель будет равен 5.
Опять же, замечу, что знаменатель IDF не зависит от количества слов в самом документе, поэтому не понятно как количество слов влияет на показатель IDF.
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 в вики
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, а следовательно, общий вес слова.
databorodata Автор
19.07.2022 11:30+1Вот она где собака зарыта)) Это я описался! Как обычно, смотрю в книгу и вижу фигу...
Спасибо вам большое за то что поправили меня! Дико извиняюсь что завёл вас в заблуждение. Статью поправил
WELLOFF
Классная статья! Как интерпретировать "Confusion Matrix"? Можете подсказать хорошую статью на эту тему? По содержанию статьи: кто-нибудь может подробнее объяснить почему нормальное распределение дало результат хуже?
databorodata Автор
Спасибо! Матрица здесь используется в качестве метрики взаимотношения между классами при предсказание, визуализироемое с помощью тепловой карты - на ней показано кол-во предсказаний класса всеми имеющимися классами. То есть к примеру в последней картинке: 5ый класс предсказан 5ым (верно) 3266 раз, а 4ым (не верно) 344 раза.
databorodata Автор
По поводу нормального распределения мне бы тоже было интересно услышать предположения)
Я перепробовал кучу вариантов, почему выстрелил именно этот объяснить не могу((