Saltar a contenido

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.