![](https://habrastorage.org/getpro/habr/upload_files/300/6b8/486/3006b8486e709955863b0965ba13c904.jpg)
Привет, Хабр!
Меня зовут Дарморезов Вадим, я Data Scientist и участник профессионального сообщества NTA. Актуальность работы с большими объемами текстовой информации ещё долгое время (а может быть и всегда) будет неоспорима. При этом спектр задач весьма вариативен — от задач по поиску именованных сущностей, до классификации и кластеризации текстов обрабатываемых документов.
Представим ситуацию. Перед вами важная задача — классифицировать огромный поток входящих обращений сотрудников/клиентов для дальнейшего анализа профильными сотрудниками на предмет отклонений и для построения интересующих статистик. Первое решение, приходящее в голову — в ручном режиме просматривать обращения и проводить их классификацию. Спустя пару часов, приходит осознание того, что решение было не самым правильным и так задачу не выполнить в срок. Как же тогда поступить? Именно об этом будет следующий пост.
Задача классификации текстовых данных на языке Python довольно обширная тема, в ней могут встречаться как automl‑подходы, модели тематического моделирования так и нейросетевые методы. В рамках данного поста будет рассмотрен относительно эталонный pipeline для решения данной задачи с помощью классических моделей машинного обучения, предназначенных для классификации.
![](https://habrastorage.org/getpro/habr/upload_files/94e/b97/4b1/94eb974b1d5c9365580183a9b26eabe3.png)
1. Формирование обучающей выборки.
В процессе формирования обучающей выборки производится выбор некоторой части данных (пропорции разбиения различны, но чаще всего используются разбиения 30/70 или 20/80 размеченные/неразмеченные данные), которые необходимо классифицировать вручную (данные, которые так самоотверженно размечали несколько часов тут придутся очень кстати). Эти данные необходимы для предварительной обработки и приведения текстов в числовое представление, понятное для модели и необходимое для дальнейшего поиска скрытых закономерностей, благодаря которым текст может быть отнесен к той или иной тематике.
2. Обработка данных.
Обработка текстов в рамках задач машинного обучения — довольная важная задача. Модели работают только с числовым представлением текстов, для чего необходимо строить их вектора (пункт векторизация текстов). Для того, чтобы вектор был наиболее информативен для модели и наилучшим образом мог указывать содержание анализируемого текста, необходимо проводить унификацию данных, удалять не несущие смысла слова и знаки. Основные задачи, решаемые при обработке данных:
Приведение к нижнему регистру. Для компьютера одно и то же слово, написанное в разном регистре, будет восприниматься как абсолютно разные слова.
Очистка от «мусора». В данном случае очистка подразумевает удаление спецсимволов, опечаток, слов с неправильной кодировкой, знаков пунктуации.
Удаление стоп‑слов. К стоп‑словам в рамках обработки и анализа текстовой информации обычно относят служебные части речи, такие как частицы, союзы, предлоги и междометия, которые часто можно встретить в тексте, и которые в свою очередь не несут какой‑либо смысловой нагрузки.
Стемминг/ лемматизация слов. Стемминг — это процесс нахождения основы слова, а лемматизация — процесс нахождения нормальной формы слова.
3. Векторизация документов.
Преобразование представления текста документов из текстового представления в цифровое, для дальнейшего использования при обучении модели. Наиболее популярными методами векторизации являются: Прямое кодированеие (one‑hot encoding), bag of words, tf‑idf а так же построение эмбедингов слов/текстов (word2vec, doc2vec)
4. Выбор модели классификатора и её обучение.
На данном этапе производится выбор наиболее подходящей для задачи модели, а также поиск оптимальных гиперпараметров модели.
5. Оценка качества модели с помощью метрик, например — precision, accuracy, recall, F‑мера.
6. Применение модели к неразмеченным данным
Когда основной фронт работ выстроен, можно приступать к реализации.
В качестве датасета для работы в данном случае выступает выборка новостных статей с указанием тематики статьи (спорт, мир, наука и техника, культура, экономика, интернет и сми), сохраненная в формате csv. Общее количество статей — 432 158.
Для начала импортируем необходимые для работы библиотеки:
import numpy as np
import pandas as pd
import nltk
nltk.download('stopwords') # выполнить команду после самого первого импорта библиотеки
nltk.download('punkt')
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize
from pymystem3 import Mystem
from tqdm import tqdm
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.experimental import enable_halving_search_cv
from sklearn.model_selection import (
train_test_split,
HalvingRandomSearchCV
)
from sklearn.metrics import (
ConfusionMatrixDisplay, confusion_matrix,
f1_score, roc_auc_score,
classification_report, make_scorer
)
from sklearn.ensemble import RandomForestClassifier
from xgboost import XGBClassifier
from collections import Counter
from sklearn.datasets import make_classification
from imblearn.under_sampling import NearMiss
import matplotlib.pyplot as plt
topics_dict = {#словарь для кодирования категориальных признаков
'Спорт': 1, 'Мир': 2,
'Наука и техника': 3, 'Культура': 4,
'Экономика': 5, 'Интернет и СМИ': 6
}
Дополним список стоп‑слов из библиотеки nltk своими собственными. На данный момент список стоп‑слов ограничен 151 словом и последний раз обновлялся более 10 лет назад.
additional_stopwords = ['которых','которые','твой','которой','которого','сих','ком','свой','твоя','этими','слишком','нами','всему', 'будь','саму','чаще','ваше','сами','наш','затем', 'самих','наши','ту','каждое','мочь','весь','этим', 'наша','своих','оба','который','зато','те','этих','вся', 'ваш','такая','теми','ею','которая','нередко','каждая', 'также','чему','собой','самими','нем','вами','ими', 'откуда','такие','тому','та','очень','сама','нему',
'алло','оно','этому','кому','тобой','таки','твоё', 'каждые','твои','нею','самим','ваши','ваша','кем','мои','однако','сразу','свое','ними','всё','неё','тех','хотя','всем','тобою','тебе','одной','другие','само','эта', 'самой','моё','своей','такое','всею','будут','своего', 'кого','свои','мог','нам','особенно','её','самому',
'наше','кроме','вообще','вон','мною','никто','это']
stop_words = stopwords.words('russian') + additional_stopwords
m = Mystem()
Ниже приведена часть кода, в которой произведена компиляция наиболее часто используемых для очистки регулярных выражений (при использовании в циклах с большим количеством шагов дает прирост в скорости) и набор функций, использующихся для очистки входного текста, удаления стоп‑слов, а также его лемматизации:
del_n = re.compile('\n') # перенос каретки
del_tags = re.compile('<[^>]*>') # html-теги
del_brackets = re.compile('\([^)]*\)') # содержимое круглых скобок
clean_text = re.compile('[^а-яa-z\s]') # все небуквенные символы кроме пробелов
del_spaces = re.compile('\s{2,}')
def prepare_text(text):
text = del_n.sub(' ', str(text).lower())
text = del_tags.sub('', text)
text = del_brackets.sub('', text)
res_text = clean_text.sub('', text)
return del_spaces.sub(' ',res_text)
def del_stopwords(text):
clean_tokens = tuple(
map( lambda x: x if x not in stop_words else '', word_tokenize(text) )
)
res_text = ' '.join(clean_tokens)
return res_text
def lemmatize(text):
lemmatized_text = ''.join(m.lemmatize(text))
return lemmatized_text.split('|')
Далее производим объединение всех функций. В следующем участке кода считанные из csv‑файла тексты разделяются на части по 10 тысяч строк в каждой. Для чего это делается? Благодаря материалу на Хабре «Лемматизируй это быстрее» я выяснил, что один и тот же объем текстов разительно отличается в скорости обработки в зависимости от количества отдельных вызовов функции лемматизации, в связи с чем было решено объединять тексты и использовать определенный разделитель, а после обработки возвращать их в исходное состояние. После многочисленных экспериментов с chunksize (размер отдельно выбранной части общего датасета), разбиение по 10 тысяч дало наиболее оптимальный по скорости результат (на меньшем количестве выигрыш по времени был незначительным, на большем — очистка данных на других этапах становилась намного дольше, нивелируя увеличение скорости лемматизации).
df = pd.read_csv('file_directory', chunksize = 10000)
filtered_chunk_list=[]
for chunk in tqdm(df):
chunk['text'] = chunk['text'].apply(lambda x: prepare_text(str(x)) )
all_texts = '|'.join( chunk['text'].tolist())
clean_texts = del_stopwords(all_texts)
chunk['text'] = lemmatize(clean_texts)
chunk['title'] = chunk['title'].apply(lambda x: prepare_text(str(x)))
all_titles = '|'.join( chunk['title'].tolist())
clean_titles = del_stopwords(all_titles)
chunk['title'] = lemmatize(clean_titles)
chunk['topic'] = chunk['topic'].map(topics_dict)
filtered_chunk_list.append(chunk)
model_df = pd.concat(filtered_chunk_list)
model_df.to_csv('text_prepare.csv', index=False)
Представленная ниже функция помогает определить наилучшие гиперпараметры для выбранной модели:
def search_best_estimator(pipeline, param_grid, x, y):
hrs = HalvingRandomSearchCV(
estimator=pipeline,
param_distributions=param_grid,
scoring='f1_weighted',
cv=3,
n_candidates="exhaust",
factor=5,
n_jobs=-1,
)
_ = hrs.fit(x, y)
return hrs.best_estimator_
Для определения качества работы модели помимо использования различных метрик бывает полезно визуальное представление результатов в виде графиков, гистограмм и т. д. Одним из методов визуального представления является матрица ошибок (confusion matrix). На данной матрице легко определить количество правильно/неправильно спрогнозированных моделью значений.
def plot_confusion_matrix(y_test, y_preds, model):
fig, ax = plt.subplots(figsize=(16,10))
cm = confusion_matrix(y_test, y_preds)
cmp = ConfusionMatrixDisplay(cm, display_labels = model.classes_ )
cmp.plot(ax=ax)
plt.show()
Обучение модели
Когда все необходимые функции реализованы, приступаем к обучению модели.
Строим пайплайн, в который включен метод векторизации наших текстов, а также выбрана модель, с помощью которой будет строиться классификация.
x, y = df['text'].tolist(), df['topic'].tolist()
pipeline = Pipeline(
steps = [("tfidf", TfidfVectorizer() ),("base",RandomForestClassifier() )]
)
param_grid = {
"tfidf__min_df": [i for i in range(25,35,5)],
"base__n_estimators": [i for i in range(150,250,50)],
"base__max_depth": [i for i in range(25,35,5)],
"base__min_samples_split":[i for i in range(6,10,2)],
"base__min_samples_leaf": [2],
}
estimator = search_best_estimator(pipeline, param_grid, x, y)
Делим выборку на тренировочную и тестовую.
X_train, X_test, y_train, y_test = train_test_split(
x, y, random_state=42, test_size=0.3, stratify=y
)
Обучаем модель, делаем предсказание на тестовых данных и строим на результатах матрицу путаницы.
![](https://habrastorage.org/getpro/habr/upload_files/0ee/320/2e4/0ee3202e4639423404b42b4a5c970beb.png)
Как видно из матрицы ошибок (confusion matrix), наибольшее количество ложных срабатываний приходится на второй класс. Для задачи классификации по нескольким классам была выбрана взвешенная метрика F1-score, так как при оценке качества в данном типе задач мы не вычисляем общую оценку F-1, а вместо этого производится вычисление оценки F1 для каждого класса в соотношении один/остальные. При таком подходе мы оцениваем успех каждого класса отдельно, словно для каждого класса существуют отдельные классификаторы. Точность работы классификатора на взвешенной метрике F1-score составляет 69%. С чем может быть связано такое поведение при обучении модели? Выведем количество текстов, в каждом из классифицируемых классов в нашей обучающей выборке:
![](https://habrastorage.org/getpro/habr/upload_files/db2/17d/5dd/db217d5dd60a0a6c851d0f84e819c37e.png)
После этого все встает на свои места, класс 2 — мажоритарный и поэтому при обучении модели она наиболее сильно ориентируется на данные полученные из текстов данного класса. Как же обучить модель мультиклассовой классификации при дисбалансе классов?
Необходимо прибегнуть к одному из методов передискретизации (ресэмплинга). Суть данного подхода заключается либо в добавлении недостающих данных в недостаточно большой набор (oversampling), либо в удалении элементов из слишком большого набора (undersampling).
![](https://habrastorage.org/getpro/habr/upload_files/c17/f79/d3f/c17f79d3f0646d687988f3fad51de732.png)
С целью улучшения качества модели применим один из методов undersampling, а именно NearMiss.
vectorizer = TfidfVectorizer(min_df=30)
vect_x = vectorizer.fit_transform(x)
nm = NearMiss()
X_res, Y_res = nm.fit_resample(vect_x, y)
pipeline2 = Pipeline( steps = [("base", RandomForestClassifier() )] )
param_grid2 = {
"base__n_estimators": [i for i in range(200,300,50)],
"base__max_depth": [i for i in range(25,35,5)],
"base__min_samples_split": [i for i in range(8,12,2)],
}
estimator2 = search_best_estimator(pipeline2, param_grid2, X_res, Y_res)
X_train, X_test, y_train, y_test = train_test_split(
vect_x, y, random_state=42, test_size=0.3, stratify=y
)
estimator2.fit(X_res, Y_res)
y_preds2 = estimator2.predict(X_test)
plot_confusion_matrix(y_test, y_preds2, estimator2)
![](https://habrastorage.org/getpro/habr/upload_files/332/c92/925/332c92925922ee112b747071da6425f9.png)
Как видно из confusion matrix, проведение undersampling привело к улучшению классификации. Метрика F1-score выросла до 79%.
xgbc = XGBClassifier()
xgbc.fit(X_res,[j-1 for j in Y_res])
X_train, X_test, y_train, y_test = train_test_split(
vect_x, y, random_state=42, test_size=0.3, stratify=y
)
pred_y = xgbc.predict(X_test)
f1 = f1_score( y_test, [j+1 for j in pred_y], average='weighted')
print( f' Model F1-score: {f1}' )
plot_confusion_matrix(y_test2, pred_y, xgbc)
![](https://habrastorage.org/getpro/habr/upload_files/4ae/23a/0e3/4ae23a0e3ed484ff17cc9cf80812e1f6.png)
Взвешенный параметр метрики F1-score для модели XGBoostClassifier достиг 92%. По confusion matrix можно заметить, что существенно снизилась доля ошибок, связанных с отнесением текстов к мажоритарному классу и не относящихся к нему.
Вывод
На основании проведенных исследований удалось достигнуть качества классификации в 92% по взвешенной метрике F1-score. На данный момент существует довольно большое количество моделей, специализирующихся на решении задач в области Natural Language Processing, в том числе основанных на глубоком обучении, поэтому рассмотренные модели и качество их работы не являются «панацеей» и могут быть мало применимы для другого датасета. Но, нет предела совершенству! Одним из вариантов улучшения классификации является продолжение экспериментов с подбором оптимальной связки «метод передискректизации — метод векторизации — модель классификации».
Так же стоит отметить тот факт, что дисбаланс в классах оказывает значительное влияние на точность работы модели, в связи с чем я считаю, что целесообразно производить обучение лишь на сбалансированных данных.
Ссылка на Github‑репозиторий.