Machine Learning, NLP

Решаем NLP-задачу – классификация текстов по темам

Время прочтения: 7 мин.

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

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

Для решения нашей задачи снова используем язык программирования python и среду разработки Jupyter notebook на платформе Google Colab.

В работе понадобятся следующие библиотеки:

  • scikit-learn – свободно распространяемая библиотека на python, содержащая реализации различных методов машинного обучения;
  • nltk – пакет библиотек для символьной и статистической обработки естественного языка;
  • matplotlib – библиотека, содержащая набор инструментов для визуализации данных, — понадобится для отображения «облака слов».

Подробности реализации

Датасет сохранен в файле формата csv и содержит чуть более 8 тысяч записей. Для работы с ним будем использовать библиотеку pandas – загружаем данные в память при помощи метода read_csv и отображаем на экране несколько первых строк:

import pandas as pd
df_habr = pd.read_csv(‘habrParse.csv’)
df_habr.head()

Набор данных представляет собой таблицу, в первой колонке которой хранится текст статьи, во второй – присвоенная категория (класс):

Построим «облако слов» для набора данных, чтобы визуально определить наиболее часто встречающиеся слова – это позволит оценить необходимость дополнительной очистки текстов от «мусора», не несущего смысловой нагрузки:

# Получение текстовой строки из списка слов
def str_corpus(corpus):
    str_corpus = ''
    for i in corpus:
        str_corpus += ' ' + i
    str_corpus = str_corpus.strip()
    return str_corpus
# Получение списка всех слов в корпусе
def get_corpus(data):
    corpus = []
    for phrase in data:
        for word in phrase.split():
            corpus.append(word)
    return corpus
# Получение облака слов
def get_wordCloud(corpus):
    wordCloud = WordCloud(background_color='white',
                              stopwords=STOPWORDS,
                              width=3000,
                              height=2500,
                              max_words=200,
                              random_state=42
                         ).generate(str_corpus(corpus))
    return wordCloud

corpus = get_corpus(df_train['text'].values)
procWordCloud = get_wordCloud(corpus)

fig = plt.figure(figsize=(20, 8))
plt.subplot(1, 2, 1)
plt.imshow(procWordCloud)
plt.axis('off')
plt.subplot(1, 2, 1)

Для необработанного набора данных «облако слов» содержит 243024 уникальных слова и выглядит так:

Попробуем очистить данные:

import nltk
nltk.download("stopwords")
from nltk.corpus import stopwords
from string import punctuation
russian_stopwords = stopwords.words("russian")

# Удаление знаков пунктуации из текста
def remove_punct(text):
    table = {33: ' ', 34: ' ', 35: ' ', 36: ' ', 37: ' ', 38: ' ', 39: ' ', 40: ' ', 41: ' ', 42: ' ', 43: ' ', 44: ' ', 45: ' ', 46: ' ', 47: ' ', 58: ' ', 59: ' ', 60: ' ', 61: ' ', 62: ' ', 63: ' ', 64: ' ', 91: ' ', 92: ' ', 93: ' ', 94: ' ', 95: ' ', 96: ' ', 123: ' ', 124: ' ', 125: ' ', 126: ' '}
    return text.translate(table)

habrParse_df['Post_clean'] = habrParse_df['Post'].map(lambda x: x.lower())
habrParse_df['Post_clean'] = habrParse_df['Post_clean'].map(lambda x: remove_punct(x))
habrParse_df['Post_clean'] = habrParse_df['Post_clean'].map(lambda x: x.split(' '))
habrParse_df['Post_clean'] = habrParse_df['Post_clean'].map(lambda x: [token for token in x if token not in russian_stopwords\
                                                                  and token != " " \
                                                                  and token.strip() not in punctuation])
habrParse_df['Post_clean'] = habrParse_df['Post_clean'].map(lambda x: ' '.join(x))

После небольшой очистки текстов от «стоп-слов» и знаков пунктуации количество уникальных слов снизилось до 142253, а «облако слов» стало более осмысленным:

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

Посмотрим статистику по классам:

Видно, что некоторые классы представлены только одним элементом, а класс «Чулан» составляет более 65% датасета. Для того чтобы работать с более или менее сбалансированным датасетом, выберем тексты только четырех классов:

df_habr_clean = df_habr.loc[df_habr['hubs'].isin(['IT-компании', 'Habr', 'Управление медиа', 'Я пиарюсь'])]

Разделим датасет на тренировочную, тестовую и валидационную части:

from sklearn.model_selection import train_test_split
X_train, X_valid, y_train, y_valid = train_test_split(df_habr_clean ['Post_clean'], df_habr_clean ['hubs'], test_size=0.1, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X_train, y_train, test_size=0.2, random_state=42)

Получили следующее соотношение выборок: X_train – 1136 элементов, X_test – 283 элемента, X_valid – 158 элементов

Для дальнейшей работы понадобится импортировать несколько модулей из библиотеки scikit-learn:

from sklearn.pipeline import Pipeline
# pipeline позволяет объединить в один блок трансформер и модель, что упрощает написание кода и улучшает его читаемость
from sklearn.feature_extraction.text import TfidfVectorizer
# TfidfVectorizer преобразует тексты в числовые вектора, отражающие важность использования каждого слова из некоторого набора слов (количество слов набора определяет размерность вектора) в каждом тексте
from sklearn.linear_model import SGDClassifier
from sklearn.neighbors import KNeighborsClassifier
# линейный классификатор и классификатор методом ближайших соседей
from sklearn import metrics
# набор метрик для оценки качества модели
from sklearn.model_selection import GridSearchCV
# модуль поиска по сетке параметров

Сначала создадим 2 классификатора (чтобы можно было в дальнейшем сравнить качество получившихся моделей) и обучим их на тестовых данных:

sgd_ppl_clf = Pipeline([
    ('tfidf', TfidfVectorizer()),
    ('sgd_clf', SGDClassifier(random_state=42))])
knb_ppl_clf = Pipeline([
    ('tfidf', TfidfVectorizer()),
    ('knb_clf', KNeighborsClassifier(n_neighbors=10))])
sgd_ppl_clf.fit(X_train, y_train)
knb_ppl_clf.fit(X_train, y_train)

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

predicted_sgd = sgd_ppl_clf.predict(X_test)
print(metrics.classification_report(predicted_sgd, y_test))
predicted_sgd = knb_ppl_clf.predict(X_test)
print(metrics.classification_report(predicted_sgd, y_test))

В связи с тем, что датасет не сбалансирован, метрику «accuracy» (доля верных ответов) использовать нельзя, так как это приведет к завышенной оценке качества работы классификатора. В данном случае самое правильное – считать сразу несколько метрик, устойчивых к распределению классов (в данном случае, это — точность, полнота и f-мера) и смотреть на них все. Однако часто бывает удобно получить не большой набор цифр, а одно число, по которому можно понять, насколько хорошо модель работает. В нашей задаче лучше всего подходит «macro-avg» (сначала подсчитывается каждая метрика по каждому классу, а потом усредняется). Macro-avg более устойчива к скошенным распределениям классов.

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

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

Попробуем улучшить нашу модель, используя различные параметры. Следует иметь в виду, что для доступа к параметрам объекта pipeline необходимо указывать их в виде «название объекта»__«название параметра»:

parameters = { 
              'sgd_clf__loss':['hinge', 'log', 'modified_huber', 'squared_hinge', 'perceptron'],
              'sgd_clf__class_weight':[None, 'balanced'],
              'sgd_clf__penalty':[None, 'l2', 'l1', 'elasticnet'],
              'tfidf__strip_accents':['ascii', 'unicode', None],
               'tfidf__ngram_range':[(1,2), (2,3), (3,4)]
              }
model = GridSearchCV(sgd_ppl_clf, parameters, cv=4, n_jobs=-1).fit(X_train, y_train)
print('Best score and parameter combination:')
print(model.best_score_, model.best_params_)
Best score and parameter combination:
0.852112676056338 {'sgd_clf__class_weight': 'balanced', 'sgd_clf__loss': 'hinge', 'sgd_clf__penalty': 'elasticnet', 'tfidf__ngram_range': (1, 2), 'tfidf__strip_accents': None}

Обучим модель, используя предложенные параметры (часть из них получила значение по умолчанию, поэтому их указывать не обязательно), и оценим ее качество на тестовых данных:

sgd_ppl_clf = Pipeline([
    ('tfidf', TfidfVectorizer(ngram_range=(1, 2))),
    ('sgd_clf', SGDClassifier(penalty='elasticnet', class_weight='balanced', random_state=42))])
sgd_ppl_clf.fit(X_train, y_train)
predicted_sgd = sgd_ppl_clf.predict(X_test)
print(metrics.classification_report(predicted_sgd, y_test))

Значение целевой метрики выросло.

predicted_sgd_val = sgd_ppl_clf.predict(X_valid)
print(metrics.classification_report(predicted_sgd_val, y_valid))

На валидационной выборке качество также выросло (0,8 против 0,76 с использованием стандартных параметров классификатора), следовательно, мы успешно справились с поставленной задачей.

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

Надеюсь, статья была полезной и поможет вам начать самостоятельно решать nlp-задачи.

Советуем почитать