Phase 05 - Lesson 05

Análise de Sentimento

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

A tarefa canônica de PLN. A maior parte do que você precisa saber sobre classificação clássica de texto aparece aqui.

Tipo: Build Linguagens: Python Pré-requisitos: Fase 5 · 02 (BoW + TF-IDF), Fase 2 · 14 (Naive Bayes) Tempo: ~75 minutos

O Problema

"A comida não estava ótima." Positivo ou negativo?

Sentimento parece simples. Quem avaliou disse que gostou ou não gostou de algo. Rotule a frase. A razão de ter se tornado a tarefa canônica de PLN é que todo caso aparentemente fácil esconde um difícil. A negação inverte o significado. O sarcasmo o inverte. "Nada mal" é positivo apesar de duas palavras com carga negativa. Emojis carregam mais sinal do que o texto ao redor. Vocabulário de domínio importa (tight numa resenha de música versus tight numa resenha de moda).

Sentimento é um laboratório prático para o PLN clássico. Se você entende por que cada baseline ingênuo tem um modo de falha específico, você entende por que cada modelo mais sofisticado foi inventado. Esta lição constrói um baseline de Naive Bayes do zero, adiciona regressão logística e nomeia as armadilhas que tornam o sentimento em produção um problema de nível de conformidade.

O Conceito

Pipeline de sentimento: tokens → features → classificador → rótulo

O sentimento clássico é uma receita de dois passos.

  1. Representar. Transforme o texto em um vetor de features. BoW, TF-IDF ou n-gramas.
  2. Classificar. Ajuste um modelo linear (Naive Bayes, regressão logística, SVM) sobre exemplos rotulados.

Naive Bayes é o modelo mais burro que funciona. Suponha que toda feature seja independente dado o rótulo. Estime P(word | positive) e P(word | negative) a partir de contagens. Na inferência, multiplique as probabilidades. A suposição "ingênua" de independência é ridiculamente errada e mesmo assim os resultados são surpreendentemente fortes. A razão: com features de texto esparsas e dados moderados, o classificador se importa mais com para qual lado cada palavra pende do que com o quanto.

A regressão logística corrige a suposição de independência. Ela aprende um peso por feature, incluindo pesos negativos. not good como feature bigrama recebe um peso negativo. O Naive Bayes não consegue fazer isso para bigramas que nunca rotulou.

Construa

Passo 1: um mini-dataset de verdade

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",
]

Pequeno de propósito. Trabalho real usa dezenas de milhares de exemplos (IMDb, SST-2, Yelp polarity). A matemática é idêntica.

Passo 2: Naive Bayes multinomial do zero

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)

A suavização aditiva (alpha=1.0) é a suavização de Laplace. Sem ela, uma palavra não vista em uma classe tem probabilidade zero e o log explode. alpha=0.01 é comum na prática. alpha=1.0 é o padrão didático.

Passo 3: regressão logística do zero

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)

A regularização L2 importa aqui. Features de texto são esparsas; sem L2 o modelo memoriza os exemplos de treino. Comece em 0.01 e ajuste.

Passo 4: lidando com negação (o modo de falha)

Considere "not good" e "not bad". Um classificador BoW vê {not, good} e {not, bad} e aprende a partir de qual apareceu mais no treino. Um classificador de bigramas vê not_good e not_bad e os aprende como features distintas. Isso geralmente é suficiente.

Uma correção mais grosseira que funciona quando você não tem bigramas: escopo de negação. Prefixe os tokens que seguem uma palavra de negação com NOT_ até a próxima pontuação.

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']

Agora good e NOT_good são features diferentes. O classificador pode pesá-las de forma oposta. Três linhas de pré-processamento, salto mensurável de acurácia em benchmarks de sentimento.

Passo 5: métricas de avaliação que importam

A acurácia sozinha é enganosa se as classes estão desbalanceadas. Corpora reais de sentimento costumam ser 70-80% positivos ou 70-80% negativos; um classificador de maioria constante atinge 80% de acurácia e é inútil. Reporte cada uma das seguintes:

  • Precisão e recall por classe. Um par por classe. Faça a média macro deles para obter um único número que respeite o balanceamento das classes.
  • Macro-F1 (métrica primária para dados desbalanceados). Média dos F1 por classe, com peso igual. Use isto em vez da acurácia quando as classes estão desbalanceadas.
  • Weighted-F1 (alternativa). Igual à macro, mas ponderada pela frequência da classe. Reporte junto com a macro-F1 quando o próprio desbalanceamento tiver significado de negócio.
  • Matriz de confusão. Contagens brutas. Sempre inspecione antes de confiar em qualquer métrica escalar; ela revela qual par de classes o modelo confunde.
  • Amostras de erro por classe. Pegue 5 previsões erradas por classe. Leia-as. Nada substitui ler os erros reais.

Para dados severamente desbalanceados (razão > 95-5), reporte AUROC e AUPRC em vez de acurácia. A AUPRC é mais sensível à classe minoritária, que costuma ser a que importa (spam, fraude, sentimento raro).

Bug comum a evitar. Reportar micro-F1 em vez de macro-F1 em dados desbalanceados dá um número que parece alto porque é dominado pela classe majoritária. A macro-F1 te força a ver o desempenho da classe minoritária.

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}

Use

O scikit-learn faz isso em seis linhas, corretamente.

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))

Três coisas a notar. stop_words=None mantém as negações. ngram_range=(1, 2) adiciona bigramas para que not_good se torne uma feature. sublinear_tf=True amortece palavras repetidas. Essas três flags são a diferença entre um baseline com 75% de acurácia e um com 85% no SST-2.

Quando recorrer a um transformer

  • Detecção de sarcasmo. Modelos clássicos falham aqui. Ponto.
  • Resenhas longas em que o sentimento muda no meio do documento.
  • Sentimento baseado em aspectos. "A câmera era ótima, mas a bateria era terrível." Você precisa atribuir o sentimento a aspectos. Apenas transformers ou modelos de saída estruturada.
  • Línguas não inglesas e de poucos recursos. O BERT multilíngue te dá um baseline zero-shot de graça.

Se você precisa de qualquer um dos itens acima, pule para a fase 7 (mergulho profundo em transformers). Caso contrário, Naive Bayes ou regressão logística sobre TF-IDF mais bigramas mais tratamento de negação é o seu baseline de produção para 2026.

A armadilha da reprodutibilidade (de novo)

Retreinar modelos de sentimento é rotineiro. Reavaliá-los não é. Os números de acurácia reportados em artigos usam splits específicos, pré-processamento específico, tokenizadores específicos. Se você comparar seu novo modelo a um baseline sem usar o pipeline idêntico, vai obter deltas enganosos. Sempre regenere o baseline no seu pipeline, não o número do artigo.

Entregue

Salve como 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.

Exercícios

  1. Fácil. Adicione apply_negation como etapa de pré-processamento no pipeline do scikit-learn e meça o delta de F1 em um pequeno dataset de sentimento.
  2. Médio. Implemente regressão logística com peso por classe (passe class_weight="balanced" ao scikit-learn, ou derive o gradiente você mesmo). Meça o efeito em um desbalanceamento sintético de 90-10.
  3. Difícil. Construa um detector de sarcasmo treinando um segundo classificador sobre os resíduos do modelo de sentimento. Documente seu setup experimental. Avise o leitor quando sua acurácia estiver abaixo do acaso (o nível de acaso em sarcasmo de 2 classes é ~50%, e a maioria das primeiras tentativas para nisso).

Termos-Chave

Termo O que as pessoas dizem O que realmente significa
Polaridade Positivo ou negativo Rótulo binário; às vezes estendido para neutro ou granular (5 estrelas).
Sentimento baseado em aspectos Polaridade por aspecto Atribuir o sentimento a entidades ou atributos específicos mencionados no texto.
Escopo de negação Inverter tokens próximos Prefixar tokens após "not" com NOT_ até a pontuação.
Suavização de Laplace Somar 1 às contagens Evita features de probabilidade zero no Naive Bayes.
Regularização L2 Encolher os pesos Adiciona lambda * sum(w^2) à perda. Essencial para features de texto esparsas.

Leitura Adicional

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).