Phase 05 - Lesson 05
Análisis de Sentimiento
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
La tarea canónica de PLN. La mayor parte de lo que necesitas saber sobre clasificación clásica de texto aparece aquí.
Tipo: Build Lenguajes: Python Prerrequisitos: Fase 5 · 02 (BoW + TF-IDF), Fase 2 · 14 (Naive Bayes) Tiempo: ~75 minutos
El Problema
"La comida no estuvo genial." ¿Positivo o negativo?
El sentimiento parece simple. Quien reseñó dijo que le gustó o no le gustó algo. Etiqueta la frase. La razón por la que se convirtió en la tarea canónica de PLN es que todo caso aparentemente fácil esconde uno difícil. La negación invierte el significado. El sarcasmo lo invierte. "Nada mal" es positivo a pesar de dos palabras con carga negativa. Los emojis cargan más señal que el texto circundante. El vocabulario del dominio importa (tight en una reseña de música versus tight en una reseña de moda).
El sentimiento es un laboratorio práctico para el PLN clásico. Si entiendes por qué cada baseline ingenuo tiene un modo de falla específico, entiendes por qué se inventó cada modelo más sofisticado. Esta lección construye un baseline de Naive Bayes desde cero, agrega regresión logística y nombra las trampas que convierten el sentimiento en producción en un problema de nivel de cumplimiento.
El Concepto
El sentimiento clásico es una receta de dos pasos.
- Representar. Convierte el texto en un vector de features. BoW, TF-IDF o n-gramas.
- Clasificar. Ajusta un modelo lineal (Naive Bayes, regresión logística, SVM) sobre ejemplos etiquetados.
Naive Bayes es el modelo más tonto que funciona. Supón que cada feature es independiente dada la etiqueta. Estima P(word | positive) y P(word | negative) a partir de conteos. En la inferencia, multiplica las probabilidades. La suposición "ingenua" de independencia es ridículamente errónea y aun así los resultados son sorprendentemente fuertes. La razón: con features de texto dispersas y datos moderados, al clasificador le importa más hacia qué lado se inclina cada palabra que cuánto.
La regresión logística corrige la suposición de independencia. Aprende un peso por feature, incluyendo pesos negativos. not good como feature bigrama recibe un peso negativo. Naive Bayes no puede hacer eso para bigramas que nunca ha etiquetado.
Constrúyelo
Paso 1: un mini-dataset de verdad
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",
]
Pequeño a propósito. El trabajo real usa decenas de miles de ejemplos (IMDb, SST-2, Yelp polarity). La matemática es idéntica.
Paso 2: Naive Bayes multinomial desde cero
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)
El suavizado aditivo (alpha=1.0) es el suavizado de Laplace. Sin él, una palabra no vista en una clase tiene probabilidad cero y el logaritmo explota. alpha=0.01 es común en la práctica. alpha=1.0 es el valor por defecto didáctico.
Paso 3: regresión logística desde cero
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)
La regularización L2 importa aquí. Las features de texto son dispersas; sin L2 el modelo memoriza los ejemplos de entrenamiento. Empieza en 0.01 y ajusta.
Paso 4: manejo de la negación (el modo de falla)
Considera "not good" y "not bad". Un clasificador BoW ve {not, good} y {not, bad} y aprende de cuál apareció más en el entrenamiento. Un clasificador de bigramas ve not_good y not_bad y los aprende como features distintas. Eso suele ser suficiente.
Una solución más burda que funciona cuando no tienes bigramas: alcance de la negación. Antepone NOT_ a los tokens que siguen a una palabra de negación hasta el siguiente signo de puntuación.
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']
Ahora good y NOT_good son features diferentes. El clasificador puede ponderarlas de forma opuesta. Tres líneas de preprocesamiento, un salto medible de exactitud en los benchmarks de sentimiento.
Paso 5: métricas de evaluación que importan
La exactitud por sí sola es engañosa si las clases están desbalanceadas. Los corpus reales de sentimiento suelen ser 70-80% positivos o 70-80% negativos; un clasificador de mayoría constante alcanza un 80% de exactitud y es inútil. Reporta cada una de las siguientes:
- Precisión y recall por clase. Un par por clase. Promédialos en macro para obtener un único número que respete el balance de clases.
- Macro-F1 (métrica primaria para datos desbalanceados). Media de los F1 por clase, con peso igual. Usa esto en lugar de la exactitud cuando las clases están desbalanceadas.
- Weighted-F1 (alternativa). Igual que macro, pero ponderada por la frecuencia de clase. Reportala junto a la macro-F1 cuando el desbalance en sí tenga significado de negocio.
- Matriz de confusión. Conteos crudos. Inspecciónala siempre antes de confiar en cualquier métrica escalar; revela qué par de clases confunde el modelo.
- Muestras de error por clase. Toma 5 predicciones erróneas por clase. Léelas. Nada reemplaza leer los errores reales.
Para datos severamente desbalanceados (razón > 95-5), reporta AUROC y AUPRC en lugar de exactitud. La AUPRC es más sensible a la clase minoritaria, que suele ser la que te importa (spam, fraude, sentimiento raro).
Bug común a evitar. Reportar micro-F1 en lugar de macro-F1 en datos desbalanceados da un número que parece alto porque está dominado por la clase mayoritaria. La macro-F1 te obliga a ver el rendimiento de la clase minoritaria.
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}
Úsalo
scikit-learn lo hace en seis líneas, correctamente.
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))
Tres cosas que notar. stop_words=None conserva las negaciones. ngram_range=(1, 2) agrega bigramas para que not_good se convierta en una feature. sublinear_tf=True atenúa las palabras repetidas. Estas tres flags son la diferencia entre un baseline con 75% de exactitud y uno con 85% en SST-2.
Cuándo recurrir a un transformer
- Detección de sarcasmo. Los modelos clásicos fallan aquí. Punto.
- Reseñas largas donde el sentimiento cambia a mitad del documento.
- Sentimiento basado en aspectos. "La cámara era genial pero la batería era terrible." Necesitas atribuir el sentimiento a aspectos. Solo transformers o modelos de salida estructurada.
- Lenguas no inglesas y de pocos recursos. El BERT multilingüe te da un baseline zero-shot gratis.
Si necesitas cualquiera de los anteriores, salta a la fase 7 (inmersión profunda en transformers). De lo contrario, Naive Bayes o regresión logística sobre TF-IDF más bigramas más manejo de negación es tu baseline de producción para 2026.
La trampa de la reproducibilidad (otra vez)
Reentrenar modelos de sentimiento es rutinario. Reevaluarlos no lo es. Los números de exactitud reportados en los artículos usan splits específicos, preprocesamiento específico, tokenizadores específicos. Si comparas tu nuevo modelo con un baseline sin usar el pipeline idéntico, obtendrás deltas engañosos. Regenera siempre el baseline con tu pipeline, no con el número del artículo.
Entrégalo
Guarda 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.
Ejercicios
- Fácil. Agrega
apply_negationcomo paso de preprocesamiento en el pipeline de scikit-learn y mide el delta de F1 en un pequeño dataset de sentimiento. - Medio. Implementa regresión logística con peso por clase (pasa
class_weight="balanced"a scikit-learn, o deriva el gradiente tú mismo). Mide el efecto en un desbalance sintético de 90-10. - Difícil. Construye un detector de sarcasmo entrenando un segundo clasificador sobre los residuos del modelo de sentimiento. Documenta tu configuración experimental. Advierte al lector cuando tu exactitud esté por debajo del azar (el nivel de azar en sarcasmo de 2 clases es ~50%, y la mayoría de los primeros intentos terminan ahí).
Términos Clave
| Término | Lo que la gente dice | Lo que realmente significa |
|---|---|---|
| Polaridad | Positivo o negativo | Etiqueta binaria; a veces extendida a neutral o granular (5 estrellas). |
| Sentimiento basado en aspectos | Polaridad por aspecto | Atribuir el sentimiento a entidades o atributos específicos mencionados en el texto. |
| Alcance de la negación | Invertir tokens cercanos | Anteponer NOT_ a los tokens tras "not" hasta la puntuación. |
| Suavizado de Laplace | Sumar 1 a los conteos | Evita features con probabilidad cero en Naive Bayes. |
| Regularización L2 | Encoger los pesos | Agrega lambda * sum(w^2) a la pérdida. Esencial para features de texto dispersas. |
Lecturas Adicionales
- Pang and Lee (2008). Opinion Mining and Sentiment Analysis — la encuesta fundacional. Larga, pero las primeras cuatro secciones cubren todo lo clásico.
- Wang and Manning (2012). Baselines and Bigrams: Simple, Good Sentiment and Topic Classification — el artículo que mostró que bigramas + Naive Bayes es difícil de superar en texto corto.
- scikit-learn text feature extraction docs — referencia para
CountVectorizer,TfidfVectorizery cada perilla que vas a ajustar.