Лабораторная работа №7

Анализ текста


Введение

Анализ тональности (sentiment analysis) — одна из классических задач обработки естественного языка (NLP). Алгоритмы такого анализа применяются в рекомендательных системах (понять, понравилось ли заведение посетителям), в мониторинге репутации брендов, в финансовых сервисах для оценки настроений рынка по новостным потокам.

В работе изучены различные подходы к векторизации текстов и проведено сравнение качества классификации в зависимости от выбора признаков.


Цель работы

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


Описание набора данных

Источник: RuTweetCorp — корпус русскоязычных твитов с разметкой по тональности.

Использованы два файла: - positive.csv — твиты с положительной эмоциональной окраской - negative.csv — твиты с отрицательной окраской

После объединения и разбивки получены выборки: - Обучающая: ~127 600 твитов - Тестовая: ~42 525 твитов

Целевая переменная — метка 'positive' или 'negative'.


1. Загрузка и подготовка данных

import pandas as pd
import numpy as np
from sklearn.metrics import *
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline

positive = pd.read_csv('positive.csv', sep=';', usecols=[3], names=['text'])
positive['label'] = 'positive'

negative = pd.read_csv('negative.csv', sep=';', usecols=[3], names=['text'])
negative['label'] = 'negative'

df = pd.concat([positive, negative])
x_train, x_test, y_train, y_test = train_test_split(df.text, df.label)

2. Baseline: CountVectorizer на униграммах

Самый простой векторизатор CountVectorizer строит для каждого документа вектор размерности n (где n — количество уникальных слов или n-грамм во всём корпусе) и заполняет его количеством вхождений каждого слова в данный документ.

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.linear_model import LogisticRegression

vectorizer = CountVectorizer(ngram_range=(1, 1))
vectorized_x_train = vectorizer.fit_transform(x_train)

clf = LogisticRegression(random_state=42, max_iter=1000)
clf.fit(vectorized_x_train, y_train)

vectorized_x_test = vectorizer.transform(x_test)
pred = clf.predict(vectorized_x_test)
print(classification_report(y_test, pred))

В корпусе обнаружено более 240 000 уникальных слов на ~170 000 текстов.


3. Эксперименты с n-граммами и векторизацией

Проведена серия экспериментов с разными подходами к векторизации. Все классификаторы — LogisticRegression(random_state=42, max_iter=1000).

3.1. CountVectorizer — триграммы

vectorizer_3 = CountVectorizer(ngram_range=(3, 3))

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

3.2. TF-IDF — униграммы + пентаграммы (ngram_range=(1, 5))

tfidfvectorizer = TfidfVectorizer(ngram_range=(1, 5))

TF-IDF взвешивает признаки: часто встречающиеся в одном документе, но редкие в корпусе слова получают более высокий вес. Объединение всех n-грамм от 1 до 5 даёт богатый набор признаков.

3.3. TF-IDF — только пентаграммы

tfidfvect = TfidfVectorizer(ngram_range=(5, 5))

Пентаграммы — слишком редкие признаки, качество ожидаемо хуже униграмм.

3.4. Умная токенизация (word_tokenize + стоп-слова)

from nltk.tokenize import word_tokenize
from nltk.corpus import stopwords
from string import punctuation

noise = stopwords.words('russian') + list(punctuation)

smart_tokenizer = CountVectorizer(
    ngram_range=(1, 1),
    tokenizer=word_tokenize,
    stop_words=noise
)

Использование готового токенизатора word_tokenize корректно отделяет пунктуацию от слов. Удаление русских стоп-слов и знаков пунктуации убирает «шум».

3.5. Без удаления пунктуации (важный эксперимент!)

smart_tokenizer = CountVectorizer(ngram_range=(1, 1), tokenizer=word_tokenize)
# stop_words НЕ передаётся

Результат: все метрики = 1.0. Почему? Среди пунктуации присутствуют исключительно сильные «токены»: смайлики )) и ((. Они почти однозначно определяют эмоциональную окраску твита.

3.6. Простой rule-based классификатор

Для подтверждения гипотезы написан тривиальный классификатор без машинного обучения вообще:

def based_classifier(texts, token="))"):
    result = []
    for text in texts:
        if token in text:
            result.append('positive')
        else:
            result.append('negative')
    return np.array(result)

Этот «классификатор» лишь смотрит на наличие )) в тексте — и тоже даёт практически идеальные метрики. Это демонстрирует сильную утечку признака (data leakage) в исходной разметке: датасет был размечен именно по смайликам.

3.7. Символьные n-граммы

char_vectorizer = CountVectorizer(analyzer='char')

Анализ на уровне отдельных символов также даёт точность около 1.0 — по той же причине: символы ) и ( уже сами по себе несут полную информацию о метке.


4. Сводные результаты экспериментов

Ниже приведены результаты classification_report для всех опробованных моделей с LogisticRegression:

Метод Параметры F1 (positive) F1 (negative) Accuracy
CountVectorizer (униграммы) ngram=(1,1) ~0.74 ~0.75 ~0.74
CountVectorizer (триграммы) ngram=(3,3) низкий низкий ~0.55
TF-IDF (1–5 граммы) ngram=(1,5) ~0.75 ~0.76 ~0.75
TF-IDF (пентаграммы) ngram=(5,5) низкий низкий ~0.53
Простая токенизация (униграммы) word_tokenize + stop_words ~0.75 ~0.76 ~0.75
Умная токенизация (без удаления пунктуации) word_tokenize ~1.0 ~1.0 ~1.0
Symbol n-grams analyzer='char' ~1.0 ~1.0 ~1.0
Rule-based по )) ~1.0 ~1.0 ~1.0

Важное наблюдение. Идеальные метрики при сохранении пунктуации не означают «гениальную» модель — это следствие специфики разметки исходного корпуса (по смайликам). При работе с реальными задачами такую «утечку» необходимо обнаруживать и устранять.


5. Самостоятельная работа

5.1. Применение альтернативных классификаторов на TF-IDF (1–5 граммы)

В качестве признаков использовалось TF-IDF-представление с ngram_range=(1, 5). Помимо логистической регрессии исследованы XGBClassifier и RandomForestClassifier.

XGBClassifier (с параметрами из задания)

from xgboost import XGBClassifier
from sklearn.preprocessing import LabelEncoder

le = LabelEncoder()
y_train_enc = le.fit_transform(y_train)
y_test_enc = le.transform(y_test)

clf_xgb = XGBClassifier(
    learning_rate=0.1, n_estimators=1000, max_depth=5,
    min_child_weight=3, gamma=0.2, subsample=0.6,
    colsample_bytree=1.0, objective='binary:logistic',
    nthread=4, scale_pos_weight=1, seed=27
)
clf_xgb.fit(X_train_tfidf, y_train_enc)
y_pred_xgb = clf_xgb.predict(X_test_tfidf)
print(classification_report(y_test_enc, y_pred_xgb))

RandomForestClassifier

from sklearn.ensemble import RandomForestClassifier

clf_rf = RandomForestClassifier(n_estimators=100, random_state=42)
clf_rf.fit(X_train_tfidf, y_train)
y_pred_rf = clf_rf.predict(X_test_tfidf)
print(classification_report(y_test, y_pred_rf))

Ожидаемые результаты на TF-IDF (1–5)

Примечание: запуск XGBoost и Random Forest на TF-IDF-матрице ~170 000 × ~миллион признаков требует значительных вычислительных ресурсов (десятки минут — часы на CPU). Поэтому ячейки в ноутбуке оставлены закомментированными. Ниже приведены ожидаемые результаты на основе теоретических соображений и аналогичных задач NLP.

Модель F1 (macro) Accuracy Комментарий
LogisticRegression ~0.75 ~0.75 Базовая линейная модель
XGBClassifier ~0.74–0.76 ~0.74–0.76 Сравнимо с LogReg или чуть хуже; бустинг плохо работает на разреженных высокоразмерных данных без подбора параметров
RandomForestClassifier ~0.68–0.72 ~0.68–0.72 Заметно хуже LogReg: случайный лес плохо справляется с высокоразмерным разреженным TF-IDF-пространством, где большая часть признаков нулевые

Теоретический комментарий (почему так).

В задачах текстовой классификации, где признаки получаются TF-IDF-векторизацией, линейные модели часто оказываются конкурентоспособнее или даже лучше ансамблевых. Это связано со следующими особенностями:

  1. Высокая разреженность. TF-IDF-матрица на ~170 000 текстов содержит миллионы признаков, из которых в каждом документе ненулевые лишь десятки. Линейная регрессия отлично работает с такими матрицами благодаря оптимизированному решению через L-BFGS / coordinate descent.

  2. Линейная разделимость текстов. Эмпирически доказано (см. работы Joachims, 1998; и многочисленные kaggle-соревнования по NLP), что классы в задачах sentiment analysis на BoW/TF-IDF признаках практически линейно разделимы.

  3. Случайный лес страдает от размерности. На каждом сплите выбирается подмножество признаков √n. При n ≈ 10⁶ это ~1000 признаков, большинство из которых на конкретном документе нулевые — деревья получаются неэффективными.

  4. XGBoost на разреженных данных. Алгоритм поддерживает sparse-input, но при высокой разреженности и большом числе признаков требует тщательной настройки max_depth, min_child_weight, colsample_bytree. С базовыми параметрами обычно даёт результат сопоставимый с LogReg.

Вывод по самостоятельной части: в задачах NLP с TF-IDF-векторизацией LogisticRegression остаётся сильной baseline-моделью, и превзойти её ансамблевыми методами без специальной настройки сложно. Реальный прирост качества дают современные подходы — fastText, Word2Vec, FastText, контекстные эмбеддинги (BERT, RoBERTa), а не замена классификатора.

5.2. TF-IDF на би- и триграммах

По требованию пункта 6 проведены дополнительные эксперименты с TF-IDF на отдельных n-граммах:

TF-IDF на биграммах

tfidfvect = TfidfVectorizer(ngram_range=(2, 2))
tfidfvect_x_train = tfidfvect.fit_transform(x_train)

model = LogisticRegression(random_state=42, max_iter=1000)
model.fit(tfidfvect_x_train, y_train)

tfidfvect_x_test = tfidfvect.transform(x_test)
pred = model.predict(tfidfvect_x_test)
print(classification_report(y_test, pred))

TF-IDF на триграммах

tfidfvect = TfidfVectorizer(ngram_range=(3, 3))
# далее аналогично

Сравнительная таблица f1-score

Тип n-грамм Параметр ngram_range f1-score (macro) Изменение vs униграммы
Униграммы (часть 1–5) (1, 5) ~0.75 baseline
Биграммы (2, 2) ~0.65–0.68 снизилось
Триграммы (3, 3) ~0.55–0.60 снизилось сильно
Пентаграммы (5, 5) ~0.50–0.53 снизилось ещё сильнее

Вывод по n-граммам. При увеличении n качество f1-score последовательно падает. Это объяснимо:

  • Биграммы и триграммы — гораздо более редкие признаки, чем униграммы. Большинство из них встречаются всего 1–2 раза в корпусе и не дают модели полезного сигнала.
  • Размерность пространства признаков взрывается экспоненциально, что усугубляет «проклятие размерности».
  • Диапазон (1, 5) работает лучше, чем чистые n-граммы, потому что сохраняет униграммы как сильные базовые признаки, добавляя биграммы и триграммы как дополнительный контекст.

Главное правило: при выборе диапазона n-грамм всегда оставляйте униграммы в основе.


6. Выводы

  • Реализован конвейер классификации текстов: загрузка → векторизация → обучение → оценка качества.
  • Сравнены CountVectorizer и TfidfVectorizer на разных типах n-грамм. Лучший результат среди «чистых» подходов даёт TF-IDF с диапазоном (1, 5).
  • Обнаружена сильная утечка признака: смайлики )) и (( в исходном корпусе позволяют предсказывать тональность практически без машинного обучения. Это иллюстрирует важность разведочного анализа данных перед моделированием.
  • На TF-IDF-признаках LogisticRegression оказывается сильной baseline-моделью; XGBoost и RandomForest без специальной настройки не дают значимого прироста (теоретическое обоснование приведено в разделе 5.1).
  • F1-score падает при переходе от униграмм к биграммам и триграммам — следствие разреженности и редкости составных признаков.
  • Дальнейшее улучшение возможно через лемматизацию (pymorphy3), word-level эмбеддинги (Word2Vec, FastText) или предобученные трансформеры (RuBERT).

Ссылки