Фаза 05 · урок 05

Анализ тональности

Цель урока: Тональность кажется простой задачей. Рецензент сказал, что ему что-то понравилось или не понравилось. Присвойте предложению метку. Причина, по которой она стала канонической задачей NLP, в том, что за каждым на вид простым случаем…

Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.

Курс
AI Engineering from Scratch
Фаза
NLP: от основ к продвинутым темам
Чтение
11 мин.
Проверено
Содержание урока
  1. Проблема
  2. Концепция
  3. Соберите это
  4. Шаг 1: настоящий мини-набор данных
  5. Шаг 2: мультиномиальный наивный Байес с нуля
  6. Шаг 3: логистическая регрессия с нуля
  7. Шаг 4: обработка отрицания (режим отказа)
  8. Шаг 5: значимые метрики оценки
  9. Используйте это
  10. Когда стоит использовать трансформер
  11. Ловушка воспроизводимости (снова)
  12. Внедрите это
  13. Упражнения
  14. Ключевые термины
  15. Дополнительное чтение

Каноническая задача NLP. Здесь проявляется большая часть того, что нужно знать о классической классификации текста.

Тип: Сборка Языки: Python Предварительные требования: Фаза 5 · 02 (BoW + TF-IDF), фаза 2 · 14 (Наивный Байес) Время: ~75 минут

Схема к уроку «Анализ тональности»

Проблема

«Еда была не очень». Положительная или отрицательная оценка?

Тональность кажется простой задачей. Рецензент сказал, что ему что-то понравилось или не понравилось. Присвойте предложению метку. Причина, по которой она стала канонической задачей NLP, в том, что за каждым на вид простым случаем скрывается сложный. Отрицание меняет смысл на противоположный. Сарказм инвертирует его. «Совсем не плохо» — это положительная оценка, несмотря на два слова с отрицательной окраской. Эмодзи несут больше сигнала, чем окружающий текст. Важен словарь предметной области (tight в музыкальной рецензии и tight в модной рецензии).

Анализ тональности — рабочая лаборатория для классического NLP. Если вы понимаете, почему у каждого наивного базового подхода есть конкретный режим отказа, вы понимаете, зачем была изобретена каждая более богатая модель. В этом уроке с нуля строится базовая модель наивного Байеса, затем добавляется логистическая регрессия и называются ловушки, из-за которых производственный анализ тональности становится задачей уровня требований к соблюдению норм.

sentiment-logits

Концепция

Классический анализ тональности — это рецепт из двух шагов.

  1. Представьте. Превратите текст в вектор признаков: BoW, TF-IDF или n-граммы.
  2. Классифицируйте. Обучите линейную модель (наивный Байес, логистическую регрессию, 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.

Упражнения

  1. Легко. Добавьте apply_negation как шаг предобработки в конвейер scikit-learn и измерьте изменение F1 на небольшом наборе данных тональности.
  2. Средне. Реализуйте логистическую регрессию со взвешиванием классов (передайте class_weight="balanced" в scikit-learn или выведите градиент самостоятельно). Измерьте эффект на синтетическом дисбалансе классов 90:10.
  3. Сложно. Постройте детектор сарказма, обучив второй классификатор на остатках модели тональности. Задокументируйте экспериментальную установку. Предупредите читателя, если ваша точность ниже случайного уровня (случайный уровень для двух классов сарказма — ~50%, и там оказывается большинство первых попыток).

Ключевые термины

Термин Как обычно говорят Что это на самом деле означает
Полярность Положительное или отрицательное Бинарная метка; иногда расширяется до нейтральной или детализированной (5 звёзд).
Анализ тональности по аспектам Полярность по каждому аспекту Относит тональность к конкретным сущностям или атрибутам, упомянутым в тексте.
Область действия отрицания Инвертирование соседних токенов Добавляет к токенам после «not» префикс NOT_ до знака препинания.
Сглаживание Лапласа Добавление 1 к подсчётам Предотвращает признаки с нулевой вероятностью в наивном Байесе.
Регуляризация L2 Сжатие весов Добавляет lambda * sum(w^2) к функции потерь. Необходима для разреженных текстовых признаков.

Дополнительное чтение


Источник: Sentiment Analysis 05.04 — GloVe, fastText и подслова · Фаза 5 — Основы и продвинутые темы NLP · Полный каталог · 05.06 — Распознавание именованных сущностей