Фаза 05 · урок 05
Анализ тональности
Цель урока: Тональность кажется простой задачей. Рецензент сказал, что ему что-то понравилось или не понравилось. Присвойте предложению метку. Причина, по которой она стала канонической задачей NLP, в том, что за каждым на вид простым случаем…
Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.
Содержание урока
- Проблема
- Концепция
- Соберите это
- Шаг 1: настоящий мини-набор данных
- Шаг 2: мультиномиальный наивный Байес с нуля
- Шаг 3: логистическая регрессия с нуля
- Шаг 4: обработка отрицания (режим отказа)
- Шаг 5: значимые метрики оценки
- Используйте это
- Когда стоит использовать трансформер
- Ловушка воспроизводимости (снова)
- Внедрите это
- Упражнения
- Ключевые термины
- Дополнительное чтение
Каноническая задача NLP. Здесь проявляется большая часть того, что нужно знать о классической классификации текста.
Тип: Сборка Языки: Python Предварительные требования: Фаза 5 · 02 (BoW + TF-IDF), фаза 2 · 14 (Наивный Байес) Время: ~75 минут
Проблема
«Еда была не очень». Положительная или отрицательная оценка?
Тональность кажется простой задачей. Рецензент сказал, что ему что-то понравилось или не понравилось. Присвойте предложению метку. Причина, по которой она стала канонической задачей NLP, в том, что за каждым на вид простым случаем скрывается сложный. Отрицание меняет смысл на противоположный. Сарказм инвертирует его. «Совсем не плохо» — это положительная оценка, несмотря на два слова с отрицательной окраской. Эмодзи несут больше сигнала, чем окружающий текст. Важен словарь предметной области (tight в музыкальной рецензии и tight в модной рецензии).
Анализ тональности — рабочая лаборатория для классического NLP. Если вы понимаете, почему у каждого наивного базового подхода есть конкретный режим отказа, вы понимаете, зачем была изобретена каждая более богатая модель. В этом уроке с нуля строится базовая модель наивного Байеса, затем добавляется логистическая регрессия и называются ловушки, из-за которых производственный анализ тональности становится задачей уровня требований к соблюдению норм.
sentiment-logits
Концепция
Классический анализ тональности — это рецепт из двух шагов.
- Представьте. Превратите текст в вектор признаков: BoW, TF-IDF или n-граммы.
- Классифицируйте. Обучите линейную модель (наивный Байес, логистическую регрессию, SVM) на размеченных примерах.
Наивный Байес — самая примитивная модель, которая работает. Предположите, что каждый признак независим при заданной метке. Оцените P(word | positive) и P(word | negative) по подсчётам. При выводе перемножайте вероятности. «Наивное» предположение о независимости смехотворно неверно, и всё же результаты поразительно сильны. Причина в том, что при разреженных текстовых признаках и умеренном объёме данных классификатору важнее, к какой стороне склоняется каждое слово, чем насколько сильно.
Логистическая регрессия исправляет предположение о независимости. Она обучает вес для каждого признака, в том числе отрицательные веса. Биграмма not good как признак получает отрицательный вес. Наивный Байес не может сделать этого для биграмм, которым он никогда не назначал метку.
Соберите это
Шаг 1: настоящий мини-набор данных
POSITIVE = [
"absolutely loved this movie",
"beautiful cinematography and a great story",
"one of the best films of the year",
"brilliant acting from the lead",
"heartwarming and funny",
]
NEGATIVE = [
"boring and far too long",
"not worth your time",
"the plot made no sense",
"terrible acting, awful script",
"i want my two hours back",
]
Маленький намеренно. В реальной работе используются десятки тысяч примеров (IMDb, SST-2, Yelp polarity). Математика идентична.
Шаг 2: мультиномиальный наивный Байес с нуля
import math
from collections import Counter
def train_nb(docs_by_class, vocab, alpha=1.0):
class_priors = {}
class_word_probs = {}
total_docs = sum(len(d) for d in docs_by_class.values())
for cls, docs in docs_by_class.items():
class_priors[cls] = len(docs) / total_docs
counts = Counter()
for doc in docs:
for token in doc:
counts[token] += 1
total = sum(counts.values()) + alpha * len(vocab)
class_word_probs[cls] = {
w: (counts[w] + alpha) / total for w in vocab
}
return class_priors, class_word_probs
def predict_nb(doc, class_priors, class_word_probs):
scores = {}
for cls in class_priors:
s = math.log(class_priors[cls])
for token in doc:
if token in class_word_probs[cls]:
s += math.log(class_word_probs[cls][token])
scores[cls] = s
return max(scores, key=scores.get)
Аддитивное сглаживание (alpha=1.0) — это сглаживание Лапласа. Без него слово, не встречавшееся в классе, имеет нулевую вероятность, и вычисление логарифма даёт сбой. На практике часто используют alpha=0.01. alpha=1.0 — значение по умолчанию для обучения.
Шаг 3: логистическая регрессия с нуля
import numpy as np
def sigmoid(x):
return 1.0 / (1.0 + np.exp(-np.clip(x, -20, 20)))
def train_lr(X, y, epochs=500, lr=0.05, l2=0.01):
n_features = X.shape[1]
w = np.zeros(n_features)
b = 0.0
for _ in range(epochs):
logits = X @ w + b
preds = sigmoid(logits)
err = preds - y
grad_w = X.T @ err / len(y) + l2 * w
grad_b = err.mean()
w -= lr * grad_w
b -= lr * grad_b
return w, b
def predict_lr(X, w, b):
return (sigmoid(X @ w + b) >= 0.5).astype(int)
Регуляризация L2 здесь важна. Текстовые признаки разрежены; без L2 модель запоминает обучающие примеры. Начните с 0.01 и подберите значение.
Шаг 4: обработка отрицания (режим отказа)
Рассмотрим «not good» и «not bad». Классификатор BoW видит {not, good} и {not, bad} и обучается на том, что чаще встречалось в обучении. Классификатор биграмм видит not_good и not_bad и изучает их как отдельные признаки. Обычно этого достаточно.
Более грубое исправление, работающее, когда у вас нет биграмм: область действия отрицания (negation scoping). Добавляйте к токенам после слова отрицания префикс NOT_ до ближайшего знака препинания.
NEGATION_WORDS = {"not", "no", "never", "nor", "none", "nothing", "neither"}
NEGATION_TERMINATORS = {".", "!", "?", ",", ";"}
def apply_negation(tokens):
out = []
negate = False
for token in tokens:
if token in NEGATION_TERMINATORS:
negate = False
out.append(token)
continue
if token in NEGATION_WORDS:
negate = True
out.append(token)
continue
out.append(f"NOT_{token}" if negate else token)
return out
>>> apply_negation(["not", "good", "at", "all", ".", "but", "funny"])
['not', 'NOT_good', 'NOT_at', 'NOT_all', '.', 'but', 'funny']
Теперь good и NOT_good — разные признаки. Классификатор может назначить им противоположные веса. Три строки предобработки дают измеримый скачок точности на бенчмарках тональности.
Шаг 5: значимые метрики оценки
Одной точности недостаточно, если классы несбалансированы. Реальные корпуса тональности обычно на 70–80% состоят из положительных или на 70–80% из отрицательных примеров; классификатор, постоянно предсказывающий большинство, получает 80% точности и бесполезен. Сообщайте о каждой из следующих метрик:
- Точность и полнота по классам. По одной паре для каждого класса. Вычислите их макроусреднение, чтобы получить одно число, учитывающее баланс классов.
- Macro-F1 (основная метрика для несбалансированных данных). Среднее F1-оценок по классам с равными весами. Используйте её вместо точности при несбалансированных классах.
- Weighted-F1 (альтернатива). То же, что macro, но со взвешиванием по частоте класса. Сообщайте её вместе с macro-F1, когда сам дисбаланс имеет бизнес-смысл.
- Матрица ошибок. Сырые подсчёты. Всегда изучайте её, прежде чем доверять какой-либо скалярной метрике; она показывает, какую пару классов модель путает.
- Примеры ошибок по классам. Возьмите по 5 неверных предсказаний на класс. Прочитайте их. Ничто не заменит чтение фактических ошибок.
Для сильно несбалансированных данных (соотношение > 95:5) сообщайте AUROC и AUPRC вместо точности. AUPRC более чувствительна к миноритарному классу, который обычно вас и интересует (спам, мошенничество, редкая тональность).
Распространённая ошибка, которой следует избегать. Сообщение о micro-F1 вместо macro-F1 на несбалансированных данных даёт число, которое выглядит высоким, потому что в нём доминирует мажоритарный класс. Macro-F1 заставляет увидеть качество на миноритарном классе.
def evaluate(y_true, y_pred):
tp = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 1)
fp = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 1)
fn = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 0)
tn = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 0)
precision = tp / (tp + fp) if tp + fp else 0
recall = tp / (tp + fn) if tp + fn else 0
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0
return {"tp": tp, "fp": fp, "tn": tn, "fn": fn, "precision": precision, "recall": recall, "f1": f1}
Используйте это
scikit-learn делает это правильно за шесть строк.
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
pipe = Pipeline([
("tfidf", TfidfVectorizer(ngram_range=(1, 2), min_df=2, sublinear_tf=True, stop_words=None)),
("clf", LogisticRegression(C=1.0, max_iter=1000)),
])
pipe.fit(X_train, y_train)
print(pipe.score(X_test, y_test))
Обратите внимание на три вещи. stop_words=None сохраняет отрицания. ngram_range=(1, 2) добавляет биграммы, поэтому not_good становится признаком. sublinear_tf=True подавляет повторяющиеся слова. Эти три флага определяют разницу между базовой моделью с точностью 75% и моделью с точностью 85% на SST-2.
Когда стоит использовать трансформер
- Обнаружение сарказма. Здесь классические модели терпят неудачу. Точка.
- Длинные отзывы, где тональность меняется в середине документа.
- Анализ тональности по аспектам. «Камера была отличной, но батарея ужасной». Нужно отнести тональность к аспектам. Подойдут только трансформеры или модели структурированного вывода.
- Неанглийские языки с малым объёмом ресурсов. Многоязычный BERT даёт базовую модель zero-shot бесплатно.
Если вам нужно что-либо из перечисленного, переходите к фазе 7 (подробное изучение трансформеров). В остальных случаях наивный Байес или логистическая регрессия на TF-IDF с биграммами и обработкой отрицаний — ваша производственная базовая модель 2026 года.
Ловушка воспроизводимости (снова)
Переобучение моделей тональности — обычное дело. Их повторная оценка — нет. Числа точности в статьях получены на конкретных разбиениях, конкретной предобработке, конкретных токенизаторах. Если сравнить новую модель с базовой, не используя идентичный конвейер, вы получите вводящие в заблуждение дельты. Всегда заново получайте базовый результат на своём конвейере, а не берите число из статьи.
Внедрите это
Сохраните как outputs/prompt-sentiment-baseline.md:
---
name: sentiment-baseline
description: Design a sentiment analysis baseline for a new dataset.
phase: 5
lesson: 05
---
Given a dataset description (domain, language, size, label granularity, latency budget), you output:
1. Feature extraction recipe. Specify tokenizer, n-gram range, stopword policy (usually keep), negation handling (scoped prefix or bigrams).
2. Classifier. Naive Bayes for baseline, logistic regression for production, transformer only if the domain needs sarcasm / aspects / cross-lingual.
3. Evaluation plan. Report precision, recall, F1, confusion matrix, and per-class error samples (not just scalars).
4. One failure mode to monitor post-deployment. Domain drift and sarcasm are the top two.
Refuse to recommend dropping stopwords for sentiment tasks. Refuse to report accuracy as the sole metric when classes are imbalanced (e.g., 90% positive). Flag subword-rich languages as needing FastText or transformer embeddings over word-level TF-IDF.
Упражнения
- Легко. Добавьте
apply_negationкак шаг предобработки в конвейер scikit-learn и измерьте изменение F1 на небольшом наборе данных тональности. - Средне. Реализуйте логистическую регрессию со взвешиванием классов (передайте
class_weight="balanced"в scikit-learn или выведите градиент самостоятельно). Измерьте эффект на синтетическом дисбалансе классов 90:10. - Сложно. Постройте детектор сарказма, обучив второй классификатор на остатках модели тональности. Задокументируйте экспериментальную установку. Предупредите читателя, если ваша точность ниже случайного уровня (случайный уровень для двух классов сарказма — ~50%, и там оказывается большинство первых попыток).
Ключевые термины
| Термин | Как обычно говорят | Что это на самом деле означает |
|---|---|---|
| Полярность | Положительное или отрицательное | Бинарная метка; иногда расширяется до нейтральной или детализированной (5 звёзд). |
| Анализ тональности по аспектам | Полярность по каждому аспекту | Относит тональность к конкретным сущностям или атрибутам, упомянутым в тексте. |
| Область действия отрицания | Инвертирование соседних токенов | Добавляет к токенам после «not» префикс NOT_ до знака препинания. |
| Сглаживание Лапласа | Добавление 1 к подсчётам | Предотвращает признаки с нулевой вероятностью в наивном Байесе. |
| Регуляризация L2 | Сжатие весов | Добавляет lambda * sum(w^2) к функции потерь. Необходима для разреженных текстовых признаков. |
Дополнительное чтение
- Pang and Lee (2008). Opinion Mining and Sentiment Analysis — основополагающий обзор. Он длинный, но первые четыре раздела охватывают всё классическое.
- Wang and Manning (2012). Baselines and Bigrams: Simple, Good Sentiment and Topic Classification — статья, показавшая, что биграммы + наивный Байес трудно превзойти на коротком тексте.
- Документация scikit-learn по извлечению текстовых признаков — справочник по
CountVectorizer,TfidfVectorizerи каждой настройке, которую вам предстоит подбирать.
Источник: Sentiment Analysis 05.04 — GloVe, fastText и подслова · Фаза 5 — Основы и продвинутые темы NLP · Полный каталог · 05.06 — Распознавание именованных сущностей