Análisis de sentimientos
1 El problema
El análisis de sentimientos determina si un texto expresa una opinión positiva, negativa o neutral. Es una de las aplicaciones más populares de procesamiento de lenguaje natural (NLP) y un caso de estudio ideal para combinar preprocesamiento de texto, extracción de características y clasificación.
En este capítulo construiremos un clasificador de sentimientos paso a paso, desde tweets crudos hasta una predicción funcional. Usaremos regresión logística por su simplicidad e interpretabilidad.
Nota
Pipeline completo. Este capítulo integra las técnicas de los capítulos anteriores: preprocesamiento de texto y extracción de características con TF-IDF. Es un buen punto para ver cómo encajan todas las piezas.
2 Datos de ejemplo
Usaremos un conjunto de tweets etiquetados como positivos o negativos. Para que el ejemplo sea reproducible, crearemos un pequeño dataset sintético:
import numpy as np
import pandas as pd
tweets = pd.DataFrame({
'texto': [
"¡Me encanta este producto! Es perfecto, lo recomiendo totalmente",
"El servicio al cliente es horrible, nunca más compro aquí",
"Producto normal, nada especial, ni bueno ni malo",
"Compra perfecta, llegó rápido y en buen estado",
"Devolución imposible, pésimo servicio al cliente",
"La mejor compra que he hecho este año, excelente calidad",
"No funciona, es basura total, pido reembolso",
"Regular, esperaba algo mejor por ese precio",
"Fantástico! Superó mis expectativas, volveré a comprar",
"Estafado, el producto no se parece en nada a la foto",
"Muy satisfecho, relación calidad-precio inmejorable",
"Llegó roto y nadie me responde, pésima experiencia",
"Buen producto, cumple con lo prometido",
"Decepcionante, el material es de muy mala calidad",
"Excepcional, lo mejor que he probado",
"No lo recomiendo, perdimos el tiempo y el dinero",
],
'sentimiento': [1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0]
})
print(f"Tweets positivos: {(tweets.sentimiento == 1).sum()}")
print(f"Tweets negativos: {(tweets.sentimiento == 0).sum()}")
3 Preprocesamiento
Aplicamos el pipeline de preprocesamiento que vimos en el capítulo anterior:
import re
import nltk
from nltk.corpus import stopwords
from nltk.stem import SnowballStemmer
from nltk.tokenize import word_tokenize
nltk.download('punkt')
nltk.download('stopwords')
stop_es = set(stopwords.words('spanish'))
stemmer = SnowballStemmer("spanish")
def preprocesar(texto):
texto = re.sub(r'http\S+|www\.\S+', '', texto)
texto = re.sub(r'@\w+', '', texto)
texto = re.sub(r'\d+', '', texto)
texto = texto.lower()
tokens = word_tokenize(texto, language='spanish')
tokens = [
stemmer.stem(t)
for t in tokens
if t.isalpha() and t not in stop_es
]
return " ".join(tokens)
tweets['texto_limpio'] = tweets['texto'].apply(preprocesar)
# Ver ejemplo
print("Original: ", tweets.texto.iloc[0])
print("Procesado:", tweets.texto_limpio.iloc[0])
Tip
Observa el stemming en acción. “encanta” → “encant”, “perfecto” → “perfect”, “totalmente” → “total”. Las palabras flexionadas se agrupan bajo una misma raíz, reduciendo la dimensionalidad del vocabulario.
4 Extracción de características: TF-IDF
Convertimos el texto preprocesado en vectores numéricos con TF-IDF:
from sklearn.feature_extraction.text import TfidfVectorizer
vec = TfidfVectorizer(max_features=1000)
X = vec.fit_transform(tweets['texto_limpio'])
y = tweets['sentimiento'].values
print(f"Matriz TF-IDF: {X.shape}")
print(f"Vocabulario: {len(vec.vocabulary_)} términos únicos")
El resultado es una matriz dispersa donde cada fila es un tweet y cada columna es un término del vocabulario, ponderado por su importancia TF-IDF.
4.1 Visualización del vocabulario más discriminativo
# Términos con mayor IDF (más raros → más discriminativos)
idf = vec.idf_
terminos = vec.get_feature_names_out()
orden = np.argsort(idf)
print("Términos más comunes (IDF bajo):")
for i in orden[:5]:
print(f" {terminos[i]:15s} IDF={idf[i]:.3f}")
print("\nTérminos más raros (IDF alto):")
for i in orden[-5:]:
print(f" {terminos[i]:15s} IDF={idf[i]:.3f}")
5 Entrenamiento del modelo
Dividimos los datos y entrenamos una regresión logística:
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, classification_report
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y
)
modelo = LogisticRegression(max_iter=1000)
modelo.fit(X_train, y_train)
y_pred = modelo.predict(X_test)
print(f"Accuracy: {accuracy_score(y_test, y_pred):.3f}")
print()
print(classification_report(y_test, y_pred, target_names=['Negativo', 'Positivo']))
6 Interpretación del modelo
La gran ventaja de la regresión logística es la interpretabilidad: podemos ver qué palabras contribuyen más a cada clase:
# Palabras más indicativas de sentimiento positivo y negativo
coef = modelo.coef_[0]
terminos = vec.get_feature_names_out()
# Top 5 positivas
top_pos = np.argsort(coef)[-5:][::-1]
print("Más positivas:")
for i in top_pos:
print(f" {terminos[i]:15s} peso={coef[i]:+.3f}")
# Top 5 negativas
top_neg = np.argsort(coef)[:5]
print("\nMás negativas:")
for i in top_neg:
print(f" {terminos[i]:15s} peso={coef[i]:+.3f}")
Nota
¿Por qué funciona? La regresión logística aprende un peso para cada término del vocabulario. Términos como “excelente”, “fantástico” recibirán pesos positivos altos, mientras que “horrible”, “basura” recibirán pesos negativos. El sesgo (bias) determina el punto de corte entre clases.
7 Predicción con nuevos tweets
Veamos cómo funciona con tweets que nunca ha visto:
def predecir_sentimiento(textos):
textos_limpios = [preprocesar(t) for t in textos]
X_nuevos = vec.transform(textos_limpios)
predicciones = modelo.predict(X_nuevos)
probabilidades = modelo.predict_proba(X_nuevos)
for texto, pred, prob in zip(textos, predicciones, probabilidades):
sentimiento = "Positivo" if pred == 1 else "Negativo"
confianza = max(prob) * 100
print(f" [{sentimiento:8s}] ({confianza:5.1f}%) {texto}")
nuevos_tweets = [
"¡Absolutamente fantástico! No puedo dejar de recomendarlo",
"Una completa decepción, no vuelvo a comprar aquí",
"Está bien, cumple su función",
"PEOR COMPRA DE MI VIDA, basura total",
]
print("Predicciones:")
predecir_sentimiento(nuevos_tweets)
8 Validación cruzada
Con pocos datos, la validación cruzada da una estimación más fiable del rendimiento real:
from sklearn.model_selection import cross_val_score
scores = cross_val_score(LogisticRegression(max_iter=1000), X, y, cv=3, scoring='accuracy')
print(f"Accuracy por fold: {scores.round(3)}")
print(f"Media: {scores.mean():.3f} ± {scores.std():.3f}")
9 Comparación: con y sin preprocesamiento
Para medir el impacto real del preprocesamiento, comparamos el rendimiento:
# Sin preprocesamiento (solo minúsculas)
vec_raw = TfidfVectorizer()
X_raw = vec_raw.fit_transform(tweets['texto'].str.lower())
score_raw = cross_val_score(LogisticRegression(max_iter=1000), X_raw, y, cv=3).mean()
# Con preprocesamiento completo
vec_proc = TfidfVectorizer()
X_proc = vec_proc.fit_transform(tweets['texto_limpio'])
score_proc = cross_val_score(LogisticRegression(max_iter=1000), X_proc, y, cv=3).mean()
print(f"Sin preprocesamiento: {score_raw:.3f}")
print(f"Con preprocesamiento: {score_proc:.3f}")
Nota
No siempre más preprocesamiento es mejor. En datasets pequeños, el stemming puede eliminar matices útiles. En análisis de sentimientos, la negación (“no”, “nunca”) y los signos de exclamación son señales importantes que no siempre conviene eliminar.
10 Limitaciones y próximos pasos
Este clasificador tiene limitaciones importantes:
| Limitación | Por qué importa | Solución posible |
|---|---|---|
| Bag-of-Words | Pierde el orden de las palabras | N-gramas, modelos secuenciales |
| Dataset pequeño | No generaliza bien | Más datos, data augmentation |
| Solo polaridad | No captura matices (irónico, sarcástico) | Modelos contextualizados (BERT) |
| Sin negación | “No es bueno” se puede malinterpretar | Bigramas, transformadores |
Tip
Siguiente nivel. Para superar estas limitaciones, los capítulos de redes neuronales recorrentes, mecanismos de atención y transformers muestran arquitecturas que capturan contexto y dependencias secuenciales, superando las restricciones del enfoque bag-of-words.
Alternativa: Naive Bayes
¿Quieres un enfoque probabilístico más simple? El capítulo de Naive Bayes muestra cómo clasificar texto usando probabilidades condicionales y el teorema de Bayes, sin necesidad de optimización iterativa. Es un baseline rápido y sorprendentemente efectivo.
11 Resumen
| Paso | Qué hicimos | Herramienta |
|---|---|---|
| 1. Limpieza | Quitamos URLs, menciones, números | Expresiones regulares |
| 2. Normalización | Minúsculas, stemming | NLTK, SnowballStemmer |
| 3. Tokenización | Dividimos en tokens | NLTK word_tokenize |
| 4. Vectorización | Convertimos tokens a vectores TF-IDF | scikit-learn TfidfVectorizer |
| 5. Clasificación | Entrenamos regresión logística | scikit-learn LogisticRegression |
| 6. Evaluación | Validación cruzada, métricas | scikit-learn cross_val_score |
Este pipeline completo es la base de casi todos los sistemas de análisis de sentimientos. Los modelos modernos (BERT, GPT) reemplazan los pasos 2-4 con tokenización subpalabra y embeddings contextualizados, pero la estructura general se mantiene.