Reporte de Práctica — Semana 5: Análisis de Sentimientos y Clasificación de Texto¶
Asignatura: Procesamiento Natural del Lenguaje (NRC-8774)
Programa: Especialización en Inteligencia Artificial — UNIMINUTO
Docente: Nathalia Orozco Morales
| Dato | Valor |
|---|---|
| Grupo | 7 |
| Integrantes | Alexander Oviedo Fadul, María Fernanda Ruiz Paipilla, Neheman Samir Jaller Cerchiaro, William David Obando López |
| Fecha | Julio 2026 |
Objetivo¶
Aplicar las tecnologías del procesamiento de lenguaje natural de acuerdo con métodos y técnicas de la inteligencia artificial, mediante la construcción de un pipeline de análisis de sentimientos sobre el corpus movie_reviews de NLTK y la clasificación automática de tickets de soporte del caso Nexo Digital, comparando modelos supervisados (Naive Bayes, Regresión Logística con TF-IDF y n-gramas) con herramientas preentrenadas (TextBlob), evaluando métricas por clase y experimentando con umbrales de confianza para definir políticas de automatización vs. revisión humana.
# Ejecutar solo la primera vez o en Google Colab
!pip install -q nltk textblob scikit-learn pandas matplotlib seaborn
(dependencias ya instaladas)
1.1 Importaciones y descarga del corpus¶
Utilizamos movie_reviews de NLTK, un corpus clásico con 2.000 reseñas de cine etiquetadas como positivas o negativas. Como mencionaba la profesora Nathalia en la clase de esta semana, este dataset nos permite practicar el pipeline completo de clasificación antes de enfrentar datos más complejos como los tickets empresariales.
import warnings
warnings.filterwarnings("ignore")
import nltk
nltk.download('movie_reviews', quiet=True)
nltk.download('punkt', quiet=True)
nltk.download('punkt_tab', quiet=True)
nltk.download('stopwords', quiet=True)
nltk.download('wordnet', quiet=True)
nltk.download('omw-1.4', quiet=True)
nltk.download('averaged_perceptron_tagger', quiet=True)
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import re
from nltk.corpus import movie_reviews, stopwords
from nltk.tokenize import RegexpTokenizer
from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold
from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
accuracy_score, classification_report, confusion_matrix,
ConfusionMatrixDisplay, f1_score
)
from textblob import TextBlob
print("✅ Librerías e imports cargados correctamente.")
✅ Librerías e imports cargados correctamente.
1.2 Carga y exploración del corpus¶
Lo primero que hacemos en el análisis exploratorio es verificar la distribución de clases. Un corpus desbalanceado puede engañarnos con un accuracy alto pero un F1 pobre en la clase minoritaria. Aquí, afortunadamente, movie_reviews está perfectamente balanceado: 1.000 reseñas por clase.
# Construir el DataFrame
documentos = []
for categoria in movie_reviews.categories():
for fileid in movie_reviews.fileids(categoria):
texto = movie_reviews.raw(fileid)
documentos.append({"texto": texto, "sentimiento": categoria})
df_movies = pd.DataFrame(documentos)
print(f"Total de reseñas: {len(df_movies)}")
print(f"\nDistribución por clase:")
print(df_movies["sentimiento"].value_counts())
print(f"\nEjemplo de reseña positiva (primeros 300 caracteres):")
print(df_movies[df_movies["sentimiento"] == "pos"].iloc[0]["texto"][:300])
Total de reseñas: 2000 Distribución por clase: sentimiento neg 1000 pos 1000 Name: count, dtype: int64 Ejemplo de reseña positiva (primeros 300 caracteres): films adapted from comic books have had plenty of success , whether they're about superheroes ( batman , superman , spawn ) , or geared toward kids ( casper ) or the arthouse crowd ( ghost world ) , but there's never really been a comic book like from hell before . for starters , it was created by
# Estadísticas de longitud
df_movies["n_palabras"] = df_movies["texto"].str.split().str.len()
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# Distribución de clases
df_movies["sentimiento"].value_counts().plot(kind="bar", ax=axes[0], color=["#e74c3c", "#2ecc71"])
axes[0].set_title("Distribución de clases")
axes[0].set_ylabel("Cantidad")
axes[0].tick_params(axis='x', rotation=0)
# Distribución de longitud
for sent in ["neg", "pos"]:
subset = df_movies[df_movies["sentimiento"] == sent]
axes[1].hist(subset["n_palabras"], bins=30, alpha=0.6, label=sent)
axes[1].set_title("Distribución de longitud (palabras)")
axes[1].set_xlabel("Número de palabras")
axes[1].legend()
plt.tight_layout()
plt.show()
print(f"\nEstadísticas de longitud por clase:")
print(df_movies.groupby("sentimiento")["n_palabras"].agg(["mean", "min", "max", "std"]).round(1))
Estadísticas de longitud por clase:
mean min max std
sentimiento
neg 705.6 17 2181 296.7
pos 787.1 130 2678 352.8
1.3 Preprocesamiento de texto¶
El preprocesamiento es fundamental: como explicaba la profesora, si la entrada no captura la semántica, el techo del modelo queda limitado. Aplicamos:
- Minúsculas — normalización básica
- Eliminación de caracteres especiales — conservamos solo letras
- Eliminación de stopwords — palabras funcionales que no aportan al sentimiento
- Tokenización — separación en palabras individuales
No lematizamos porque queremos conservar variantes morfológicas (ej.: "loved", "loving") que el TF-IDF con n-gramas puede aprovechar.
stop_words = set(stopwords.words('english'))
tokenizer = RegexpTokenizer(r'\w+')
def preprocesar(texto):
"""Pipeline de limpieza: minúsculas → tokenización → stopwords."""
texto = texto.lower()
texto = re.sub(r'[^a-záéíóúñ\s]', ' ', texto) # Solo letras
tokens = tokenizer.tokenize(texto)
tokens = [t for t in tokens if t not in stop_words and len(t) > 2]
return ' '.join(tokens)
df_movies["texto_limpio"] = df_movies["texto"].apply(preprocesar)
print("Ejemplo de preprocesamiento:")
print(f" Original: {df_movies.iloc[0]['texto'][:150]}...")
print(f" Limpio: {df_movies.iloc[0]['texto_limpio'][:150]}...")
Ejemplo de preprocesamiento: Original: plot : two teen couples go to a church party , drink and then drive . they get into an accident . one of the guys dies , but his girlfriend continue... Limpio: plot two teen couples church party drink drive get accident one guys dies girlfriend continues see life nightmares deal watch movie sorta find critiqu...
1.4 Modelo Naive Bayes con TF-IDF¶
Seguimos el laboratorio proporcionado (AnalisisSentimientosNaiveBayes.ipynb): la variable independiente es el texto vectorizado con TF-IDF, y la variable dependiente es la etiqueta de sentimiento (pos/neg). Usamos MultinomialNB, que es el clasificador bayesiano estándar para datos de texto.
¿Por qué TF-IDF y no solo conteo? Porque TF-IDF penaliza las palabras que aparecen en muchos documentos (como "movie" o "film" en un corpus de reseñas), dándole más peso a las que realmente discriminan entre positivo y negativo.
# División de datos: 75% train, 25% test, estratificado, semilla 42
X = df_movies["texto_limpio"]
y = df_movies["sentimiento"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42
)
print(f"Train: {len(X_train)} | Test: {len(X_test)}")
print(f"Distribución train: {dict(y_train.value_counts())}")
print(f"Distribución test: {dict(y_test.value_counts())}")
Train: 1500 | Test: 500
Distribución train: {'neg': np.int64(750), 'pos': np.int64(750)}
Distribución test: {'pos': np.int64(250), 'neg': np.int64(250)}
# Vectorización TF-IDF
tfidf = TfidfVectorizer(max_features=10000, sublinear_tf=True)
X_train_tfidf = tfidf.fit_transform(X_train)
X_test_tfidf = tfidf.transform(X_test)
print(f"Dimensiones de la matriz TF-IDF: {X_train_tfidf.shape}")
print(f"Vocabulario: {len(tfidf.vocabulary_)} términos únicos")
Dimensiones de la matriz TF-IDF: (1500, 10000) Vocabulario: 10000 términos únicos
# Entrenamiento de Naive Bayes
nb_model = MultinomialNB(alpha=1.0)
nb_model.fit(X_train_tfidf, y_train)
# Predicción y evaluación
y_pred_nb = nb_model.predict(X_test_tfidf)
print("=" * 60)
print("REPORTE DE CLASIFICACIÓN — NAIVE BAYES")
print("=" * 60)
print(classification_report(y_test, y_pred_nb, digits=3))
print(f"Accuracy: {accuracy_score(y_test, y_pred_nb):.3f}")
print(f"Macro-F1: {f1_score(y_test, y_pred_nb, average='macro'):.3f}")
============================================================
REPORTE DE CLASIFICACIÓN — NAIVE BAYES
============================================================
precision recall f1-score support
neg 0.816 0.852 0.834 250
pos 0.845 0.808 0.826 250
accuracy 0.830 500
macro avg 0.831 0.830 0.830 500
weighted avg 0.831 0.830 0.830 500
Accuracy: 0.830
Macro-F1: 0.830
# Matriz de confusión
fig, ax = plt.subplots(figsize=(5, 4))
ConfusionMatrixDisplay.from_estimator(nb_model, X_test_tfidf, y_test, ax=ax,
display_labels=["Negativo", "Positivo"],
cmap="Blues")
ax.set_title("Matriz de Confusión — Naive Bayes con TF-IDF")
plt.tight_layout()
plt.show()
1.5 Validación cruzada (5-fold)¶
Como recomendó la profesora, usamos validación cruzada estratificada con 5 folds para tener una estimación más robusta del desempeño. Con un solo split podemos tener suerte (o mala suerte) con la partición.
# Validación cruzada sobre todo el dataset
from sklearn.pipeline import Pipeline
pipe_nb = Pipeline([
("tfidf", TfidfVectorizer(max_features=10000, sublinear_tf=True)),
("clf", MultinomialNB(alpha=1.0))
])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores_nb = cross_val_score(pipe_nb, X, y, cv=cv, scoring="f1_macro")
print(f"F1-macro por fold: {[round(s, 3) for s in scores_nb]}")
print(f"F1-macro promedio: {scores_nb.mean():.3f} ± {scores_nb.std():.3f}")
F1-macro por fold: [np.float64(0.83), np.float64(0.82), np.float64(0.827), np.float64(0.83), np.float64(0.827)] F1-macro promedio: 0.827 ± 0.004
1.6 Comparación con TextBlob (modelo preentrenado)¶
TextBlob usa un analizador de sentimiento basado en un lexicón preentrenado (no supervisado). Cada texto recibe una polaridad (-1.0 a 1.0) y una subjetividad (0.0 a 1.0).
La ventaja: no requiere datos de entrenamiento. La desventaja: no se adapta al dominio específico. Como señalaba la profesora en clase, la confianza de algunas librerías preentrenadas puede ser menor que la de modelos entrenados localmente.
def analizar_textblob(texto):
"""Retorna polaridad, subjetividad y clasificación de TextBlob."""
blob = TextBlob(texto)
polaridad = blob.sentiment.polarity
subjetividad = blob.sentiment.subjectivity
if polaridad > 0.05:
sentimiento = "pos"
elif polaridad < -0.05:
sentimiento = "neg"
else:
sentimiento = "neutro"
return polaridad, subjetividad, sentimiento
# Aplicar TextBlob sobre el texto original (sin preprocesar, porque TextBlob
# tiene su propio pipeline interno)
resultados_tb = df_movies["texto"].apply(lambda t: analizar_textblob(t))
df_movies["polaridad"] = resultados_tb.apply(lambda x: x[0])
df_movies["subjetividad"] = resultados_tb.apply(lambda x: x[1])
df_movies["pred_textblob"] = resultados_tb.apply(lambda x: x[2])
print(f"Distribución de predicciones TextBlob:")
print(df_movies["pred_textblob"].value_counts())
print(f"\nPolaridad media por clase real:")
print(df_movies.groupby("sentimiento")["polaridad"].mean().round(3))
Distribución de predicciones TextBlob: pred_textblob pos 1434 neutro 478 neg 88 Name: count, dtype: int64 Polaridad media por clase real: sentimiento neg 0.050 pos 0.136 Name: polaridad, dtype: float64
# Evaluación de TextBlob (excluyendo neutros para comparar justo con NB)
mask_no_neutro = df_movies["pred_textblob"] != "neutro"
y_real_tb = df_movies.loc[mask_no_neutro, "sentimiento"]
y_pred_tb = df_movies.loc[mask_no_neutro, "pred_textblob"]
print("=" * 60)
print("REPORTE DE CLASIFICACIÓN — TEXTBLOB (sin neutros)")
print("=" * 60)
print(classification_report(y_real_tb, y_pred_tb, digits=3))
print(f"Accuracy: {accuracy_score(y_real_tb, y_pred_tb):.3f}")
print(f"Macro-F1: {f1_score(y_real_tb, y_pred_tb, average='macro'):.3f}")
print(f"\nNota: {(~mask_no_neutro).sum()} reseñas clasificadas como 'neutro' fueron excluidas.")
============================================================
REPORTE DE CLASIFICACIÓN — TEXTBLOB (sin neutros)
============================================================
precision recall f1-score support
neg 0.920 0.132 0.231 614
pos 0.628 0.992 0.769 908
accuracy 0.645 1522
macro avg 0.774 0.562 0.500 1522
weighted avg 0.746 0.645 0.552 1522
Accuracy: 0.645
Macro-F1: 0.500
Nota: 478 reseñas clasificadas como 'neutro' fueron excluidas.
# Visualización comparativa de polaridad
fig, axes = plt.subplots(1, 2, figsize=(13, 4))
# Distribución de polaridad por clase real
for sent, color in [("neg", "#e74c3c"), ("pos", "#2ecc71")]:
subset = df_movies[df_movies["sentimiento"] == sent]
axes[0].hist(subset["polaridad"], bins=40, alpha=0.6, label=sent, color=color)
axes[0].set_title("Distribución de Polaridad por Sentimiento Real")
axes[0].set_xlabel("Polaridad TextBlob")
axes[0].axvline(x=0.05, color="gray", linestyle="--", alpha=0.5)
axes[0].axvline(x=-0.05, color="gray", linestyle="--", alpha=0.5)
axes[0].legend()
# Distribución de subjetividad
for sent, color in [("neg", "#e74c3c"), ("pos", "#2ecc71")]:
subset = df_movies[df_movies["sentimiento"] == sent]
axes[1].hist(subset["subjetividad"], bins=40, alpha=0.6, label=sent, color=color)
axes[1].set_title("Distribución de Subjetividad por Sentimiento Real")
axes[1].set_xlabel("Subjetividad TextBlob")
axes[1].legend()
plt.tight_layout()
plt.show()
1.7 Comparación Naive Bayes vs. TextBlob¶
| Aspecto | Naive Bayes (TF-IDF) | TextBlob |
|---|---|---|
| Tipo | Supervisado (entrenado en el corpus) | Basado en lexicón preentrenado |
| Requiere datos etiquetados | Sí | No |
| Adaptación al dominio | Alta (aprende del corpus específico) | Baja (lexicón genérico) |
| Velocidad de configuración | Media (requiere preprocesamiento + entrenamiento) | Rápida (plug & play) |
| Manejo de negaciones | Parcial (depende de n-gramas) | Limitado |
La conclusión que saco de esta comparación —y que coincide con lo que hemos discutido en semanas anteriores— es que los modelos supervisados superan consistentemente a los basados en lexicón cuando se dispone de datos etiquetados de calidad. Sin embargo, TextBlob sigue siendo útil como baseline rápido o cuando no hay datos de entrenamiento disponibles.
# Tabla resumen comparativa
comparacion_modelos = pd.DataFrame({
"Modelo": ["Naive Bayes (TF-IDF)", "TextBlob (lexicón)"],
"Accuracy": [
accuracy_score(y_test, y_pred_nb),
accuracy_score(y_real_tb, y_pred_tb)
],
"Macro-F1": [
f1_score(y_test, y_pred_nb, average="macro"),
f1_score(y_real_tb, y_pred_tb, average="macro")
],
"Tipo": ["Supervisado", "Preentrenado"],
"Requiere entrenamiento": ["Sí", "No"]
}).round(3)
print("=" * 60)
print("TABLA COMPARATIVA DE MODELOS — ANÁLISIS DE SENTIMIENTOS")
print("=" * 60)
print(comparacion_modelos.to_string(index=False))
============================================================
TABLA COMPARATIVA DE MODELOS — ANÁLISIS DE SENTIMIENTOS
============================================================
Modelo Accuracy Macro-F1 Tipo Requiere entrenamiento
Naive Bayes (TF-IDF) 0.830 0.83 Supervisado Sí
TextBlob (lexicón) 0.645 0.50 Preentrenado No
Parte 2 — Clasificación de Tickets (Caso Nexo Digital)¶
2.0 Contexto del caso de estudio¶
En la clase de esta semana, la profesora Nathalia presentó el caso de Nexo Digital, una empresa de software con 1.340 clientes activos que recibe en promedio 180 solicitudes diarias (con picos superiores a 300). La coordinadora de servicio dedica ~3h40min diarios a leer y clasificar manualmente cada ticket en 5 colas: facturación, soporte técnico, ventas, cancelación y reclamo.
Los hallazgos de una auditoría trimestral sobre 600 solicitudes fueron preocupantes:
- 22% de los tickets se asignaron a la cola equivocada
- 31% de los reclamos terminaron en colas de facturación o soporte técnico
Nuestro objetivo: construir un clasificador automático que reduzca estos errores y libere tiempo de la coordinadora para tareas de mayor valor.
2.1 Carga y exploración del dataset de tickets¶
# Cargar el dataset de tickets
# En Google Colab, subir el archivo o montar Drive
import os
# Intentar cargar desde ruta local (para ejecución en el repositorio)
rutas_posibles = [
"tickets_soporte.csv",
"/content/tickets_soporte.csv",
os.path.join(os.path.dirname("__file__"), "tickets_soporte.csv"),
]
df_tickets = None
for ruta in rutas_posibles:
if os.path.exists(ruta):
df_tickets = pd.read_csv(ruta)
print(f"✅ Dataset cargado desde: {ruta}")
break
if df_tickets is None:
# Si no se encuentra, crear desde el corpus embebido
print("⚠️ Archivo no encontrado, usando corpus embebido...")
CORPUS = {
"facturacion": [
"Me aparece un cobro por reconexión que no debería estar",
"Solicito el detalle de los consumos facturados este mes",
"¿Por qué me cobran IVA si el servicio está exento?",
"Hice el pago por PSE y no se ha registrado",
"Necesito la factura electrónica con mi razón social actualizada",
"Me cobraron el mes que ya había pagado por adelantado",
"Solicito devolución del dinero cobrado en exceso",
"El valor del recargo no está justificado en el recibo",
"Requiero factura a nombre de la empresa y no personal",
"¿Puedo diferir el saldo pendiente a varias cuotas?",
"No entiendo los cargos adicionales que aparecen en mi cuenta",
],
"soporte_tecnico": [
"La aplicación se cierra sola cada vez que inicio sesión",
"No puedo acceder a la plataforma, me dice contraseña incorrecta",
"El sitio web carga muy lento desde ayer",
"Me aparece un error 500 al guardar el formulario",
"No me llega el código de verificación al celular",
"El botón de descargar reporte no funciona",
"La app no carga las imágenes en Android",
"Se me quedó la pantalla en blanco después de actualizar",
"No puedo subir archivos, dice que el formato no es válido",
"El sistema me expulsa de la sesión cada cinco minutos",
"Los datos del tablero no se están actualizando",
"La integración con el correo dejó de sincronizar",
"Al intentar restablecer la contraseña no llega el enlace",
"El reporte se descarga vacío, sin ninguna fila",
"Me sale un mensaje de error al cargar el archivo de Excel",
"La plataforma no me deja guardar los cambios del perfil",
"El buscador no devuelve resultados aunque el dato existe",
"Después de la actualización perdí el acceso a mis carpetas",
"La sesión se cae cuando cambio de pestaña",
"No se ven los gráficos en el navegador Chrome",
"El formulario se envía pero no queda registrado nada",
"La sincronización con el celular está fallando",
"Me pide un permiso que mi usuario ya tiene asignado",
"El correo de notificaciones llega sin el archivo adjunto",
"El sistema me marca datos duplicados que no existen",
"No carga el módulo de configuración, queda girando",
"La impresión del comprobante sale cortada",
"El inicio de sesión con Google no está funcionando",
"La aplicación consume demasiada memoria y se congela",
"El escáner de documentos no reconoce la cédula",
],
"ventas": [
"Quiero información sobre los planes empresariales",
"¿Tienen descuento por pago anual?",
"Necesito una cotización para 50 usuarios",
"Me interesa contratar el servicio, ¿cómo empiezo?",
"¿Cuál es la diferencia entre el plan básico y el premium?",
"Quisiera agendar una demostración del producto",
"¿Manejan precios especiales para instituciones educativas?",
"Deseo ampliar mi plan actual con más licencias",
"¿El servicio incluye capacitación para el equipo?",
"Solicito información comercial y formas de pago",
"¿Puedo probar el producto antes de comprarlo?",
"Quiero renovar el contrato por un año más",
"Quiero saber qué incluye el plan corporativo",
"Necesito una propuesta económica para una entidad pública",
"Solicito asesoría comercial para elegir el plan adecuado",
"¿Tienen algún plan gratuito para empezar?",
"Me gustaría conocer casos de éxito antes de decidir",
"Necesito agregar diez usuarios más a mi cuenta actual",
"¿El precio incluye soporte prioritario?",
"Quiero comparar su producto con el de la competencia",
"¿Cuánto cuesta la implementación inicial?",
"Deseo hablar con un asesor comercial esta semana",
"¿Ofrecen contrato por menos de doce meses?",
"Requiero ficha técnica y precios para presentar a mi jefe",
"Nos interesa el servicio para tres sedes de la empresa",
"¿Hay descuento si migramos desde otro proveedor?",
"Quiero solicitar una prueba piloto de dos semanas",
"Necesito saber si el producto se integra con nuestro ERP",
"¿Cuál plan recomiendan para un equipo de veinte personas?",
"Estoy evaluando proveedores y quisiera una cotización formal",
],
"cancelacion": [
"Deseo cancelar mi suscripción de inmediato",
"Quiero dar de baja el servicio a fin de mes",
"¿Cuál es el procedimiento para terminar el contrato?",
"No quiero seguir con el plan, por favor cancélenlo",
"Solicito la cancelación de la renovación automática",
"Ya no necesito el servicio, ¿cómo lo suspendo?",
"Quiero eliminar mi cuenta y todos mis datos",
"Pedí la cancelación hace un mes y me siguen cobrando",
"Necesito pausar el servicio por tres meses",
"¿Hay penalidad si termino el contrato antes de tiempo?",
"Confirmen por favor la baja definitiva de mi cuenta",
"Deseo retirar una de las licencias contratadas",
"Quiero cancelar antes de que se renueve el próximo periodo",
"Solicito la terminación del contrato por escrito",
"Ya no usaré el servicio, procedan con la baja",
"¿Qué debo hacer para retirarme del servicio?",
"Deseo suspender temporalmente mi suscripción",
"Cancelen la renovación, no deseo continuar",
"Quiero desactivar mi cuenta de forma permanente",
"Solicito no continuar con el plan al vencimiento",
"Me cambio de proveedor, requiero cerrar el contrato",
"Pido la baja del servicio desde el primero del mes",
"¿Debo pagar algo si cancelo en este momento?",
"Ya envié la solicitud de cancelación y no la han aplicado",
"Quiero anular el contrato firmado la semana pasada",
"Solicito el retiro definitivo de mi suscripción",
"No renovaré el servicio para el próximo año",
"Deseo eliminar el plan adicional que tengo activo",
"¿Cómo cancelo sin perder la información almacenada?",
"Necesito terminar el servicio lo antes posible",
],
"reclamo": [
"Llevo tres días esperando respuesta y nadie me contesta",
"El asesor fue grosero durante la llamada",
"Es la tercera vez que reporto lo mismo sin solución",
"Estoy muy inconforme con el servicio recibido",
"Me prometieron una solución en 24 horas y no cumplieron",
"Voy a poner una queja formal ante la superintendencia",
"Nadie se hace responsable de mi caso, es inaceptable",
"Pésima atención, quiero hablar con un supervisor",
"Me cerraron el ticket sin resolver el problema",
"Exijo una compensación por los días sin servicio",
"El servicio no cumple con lo que me ofrecieron",
"Manifiesto mi inconformidad por los retrasos constantes",
"Nadie ha dado solución después de cinco reportes",
"El servicio estuvo caído dos días y nadie avisó",
"Estoy cansado de que me pasen de un área a otra",
"Presento queja por el incumplimiento del tiempo de respuesta",
"La atención recibida fue deficiente y poco profesional",
"Me colgaron la llamada sin resolver mi solicitud",
"Es inadmisible que lleve dos semanas sin respuesta",
"Quiero elevar mi caso a una instancia superior",
"Me siento engañado por la información que me dieron",
"Reclamo por el mal servicio prestado durante el mes",
"Nunca me notificaron el cambio en las condiciones",
"Solicito una explicación formal por las fallas reiteradas",
"El compromiso de solución se incumplió otra vez",
"Estoy insatisfecho y considero cambiar de proveedor",
"Radico queja por la demora injustificada en mi trámite",
"El trato del personal fue irrespetuoso",
"Ya perdí la confianza en el servicio que ofrecen",
"Exijo respuesta escrita sobre lo sucedido",
],
}
rows = []
for cat, textos in CORPUS.items():
for t in textos:
rows.append({"texto": t, "categoria": cat})
df_tickets = pd.DataFrame(rows)
print(f" Corpus embebido: {len(df_tickets)} tickets")
print(f"\nColumnas: {list(df_tickets.columns)}")
print(f"Total tickets: {len(df_tickets)}")
print(f"\nDistribución por categoría:")
print(df_tickets["categoria"].value_counts())
✅ Dataset cargado desde: tickets_soporte.csv Columnas: ['texto', 'categoria'] Total tickets: 150 Distribución por categoría: categoria facturacion 30 soporte_tecnico 30 ventas 30 cancelacion 30 reclamo 30 Name: count, dtype: int64
# Exploración rápida
df_tickets["n_palabras"] = df_tickets["texto"].str.split().str.len()
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
df_tickets["categoria"].value_counts().plot(kind="bar", ax=axes[0],
color=["#3498db", "#e74c3c", "#2ecc71", "#f39c12", "#9b59b6"])
axes[0].set_title("Distribución de tickets por categoría")
axes[0].set_ylabel("Cantidad")
axes[0].tick_params(axis='x', rotation=45)
df_tickets.boxplot(column="n_palabras", by="categoria", ax=axes[1])
axes[1].set_title("Longitud de texto por categoría")
axes[1].set_xlabel("Categoría")
plt.suptitle("")
plt.tight_layout()
plt.show()
2.2 Modelo A — TF-IDF de palabras (baseline)¶
Empezamos con el enfoque más básico: TF-IDF a nivel de palabras completas con regresión logística. Como explicaba la profesora, este es nuestro baseline — el punto de referencia contra el cual medimos mejoras.
from sklearn.pipeline import Pipeline
X_tk = df_tickets["texto"]
y_tk = df_tickets["categoria"]
X_train_tk, X_test_tk, y_train_tk, y_test_tk = train_test_split(
X_tk, y_tk, test_size=0.25, stratify=y_tk, random_state=42
)
cv_tk = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
# Modelo A: TF-IDF de palabras
pipe_palabras = Pipeline([
("tfidf", TfidfVectorizer(sublinear_tf=True)),
("clf", LogisticRegression(max_iter=2000, C=10)),
])
f1_palabras = cross_val_score(pipe_palabras, X_tk, y_tk, cv=cv_tk, scoring="f1_macro")
pipe_palabras.fit(X_train_tk, y_train_tk)
print(f"Modelo A — TF-IDF palabras:")
print(f" F1-macro (CV): {f1_palabras.mean():.3f} ± {f1_palabras.std():.3f}")
print(f"\nReporte sobre test set:")
y_pred_A = pipe_palabras.predict(X_test_tk)
print(classification_report(y_test_tk, y_pred_A, digits=3))
Modelo A — TF-IDF palabras:
F1-macro (CV): 0.691 ± 0.044
Reporte sobre test set:
precision recall f1-score support
cancelacion 0.500 0.250 0.333 8
facturacion 0.667 0.857 0.750 7
reclamo 0.600 0.750 0.667 8
soporte_tecnico 0.778 0.875 0.824 8
ventas 0.667 0.571 0.615 7
accuracy 0.658 38
macro avg 0.642 0.661 0.638 38
weighted avg 0.641 0.658 0.635 38
2.3 Modelo B — TF-IDF de n-gramas de caracteres (3-5)¶
Aquí aplicamos lo que la profesora explicó sobre los n-gramas: con analyzer="char_wb" y ngram_range=(3, 5), el modelo extrae secuencias de 3 a 5 caracteres dentro de los límites de cada palabra. Así, palabras como "cancelar", "cancelación", "cancélenlo" comparten los n-gramas "can", "ance", "ncel", etc.
En la demo de clase, esta técnica mejoró el F1 de 0.69 a 0.82 — una mejora sustancial.
# Modelo B: TF-IDF con n-gramas de caracteres
pipe_char = Pipeline([
("tfidf", TfidfVectorizer(analyzer="char_wb", ngram_range=(3, 5), sublinear_tf=True)),
("clf", LogisticRegression(max_iter=3000, C=30)),
])
f1_char = cross_val_score(pipe_char, X_tk, y_tk, cv=cv_tk, scoring="f1_macro")
pipe_char.fit(X_train_tk, y_train_tk)
print(f"Modelo B — TF-IDF n-gramas (3-5):")
print(f" F1-macro (CV): {f1_char.mean():.3f} ± {f1_char.std():.3f}")
print(f"\nReporte sobre test set:")
y_pred_B = pipe_char.predict(X_test_tk)
print(classification_report(y_test_tk, y_pred_B, digits=3))
Modelo B — TF-IDF n-gramas (3-5):
F1-macro (CV): 0.822 ± 0.067
Reporte sobre test set:
precision recall f1-score support
cancelacion 0.667 0.500 0.571 8
facturacion 0.833 0.714 0.769 7
reclamo 0.700 0.875 0.778 8
soporte_tecnico 0.778 0.875 0.824 8
ventas 0.571 0.571 0.571 7
accuracy 0.711 38
macro avg 0.710 0.707 0.703 38
weighted avg 0.710 0.711 0.704 38
# Comparación visual de modelos
comparacion_tickets = pd.DataFrame({
"Modelo": ["A: TF-IDF palabras", "B: TF-IDF char 3-5"],
"F1-macro (CV)": [f1_palabras.mean().round(3), f1_char.mean().round(3)],
"Desv. Std.": [f1_palabras.std().round(3), f1_char.std().round(3)],
})
print("=" * 60)
print("COMPARACIÓN DE MODELOS — CLASIFICACIÓN DE TICKETS")
print("=" * 60)
print(comparacion_tickets.to_string(index=False))
# Gráfico comparativo
fig, ax = plt.subplots(figsize=(7, 4))
bars = ax.bar(comparacion_tickets["Modelo"], comparacion_tickets["F1-macro (CV)"],
color=["#3498db", "#2ecc71"], edgecolor="white")
ax.set_ylabel("F1-macro")
ax.set_title("Comparación: TF-IDF palabras vs. n-gramas de caracteres")
ax.set_ylim(0, 1)
for bar, val in zip(bars, comparacion_tickets["F1-macro (CV)"]):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.02,
f"{val:.3f}", ha="center", fontweight="bold")
plt.tight_layout()
plt.show()
============================================================
COMPARACIÓN DE MODELOS — CLASIFICACIÓN DE TICKETS
============================================================
Modelo F1-macro (CV) Desv. Std.
A: TF-IDF palabras 0.691 0.044
B: TF-IDF char 3-5 0.822 0.067
2.4 Evaluación detallada del modelo elegido (Modelo B)¶
Seleccionamos el Modelo B (n-gramas) y evaluamos por clase. La profesora enfatizó que no basta con mirar el promedio: hay que revisar el desempeño por categoría, especialmente en colas críticas como reclamo y cancelación que el caso Nexo Digital identificó como problemáticas.
# Matriz de confusión del mejor modelo
pipe = pipe_char # modelo elegido
fig, ax = plt.subplots(figsize=(6, 5))
ConfusionMatrixDisplay.from_estimator(
pipe, X_test_tk, y_test_tk, ax=ax, xticks_rotation=45,
colorbar=False, cmap="YlOrRd"
)
ax.set_title("Matriz de Confusión — Modelo B (n-gramas 3-5)")
plt.tight_layout()
plt.show()
2.5 Trazabilidad: texto, real, predicho, confianza¶
Como indicó la profesora, la trazabilidad es fundamental para auditoría y mejora continua. Para cada ticket, registramos el texto original, la etiqueta real, la predicción del modelo y el nivel de confianza (probabilidad máxima).
# Tabla de trazabilidad completa
y_proba = pipe.predict_proba(X_test_tk)
y_conf = y_proba.max(axis=1).round(3)
y_pred_final = pipe.predict(X_test_tk)
trazabilidad = pd.DataFrame({
"texto": X_test_tk.values,
"real": y_test_tk.values,
"predicho": y_pred_final,
"confianza": y_conf,
"correcto": y_test_tk.values == y_pred_final,
})
print("=" * 60)
print("TRAZABILIDAD DE PREDICCIONES (muestra)")
print("=" * 60)
print(trazabilidad.to_string(index=False, max_colwidth=60))
============================================================
TRAZABILIDAD DE PREDICCIONES (muestra)
============================================================
texto real predicho confianza correcto
El formulario se envía pero no queda registrado nada soporte_tecnico soporte_tecnico 0.663 True
Manifiesto mi inconformidad por los retrasos constantes reclamo reclamo 0.441 True
Requiero factura a nombre de la empresa y no personal facturacion facturacion 0.558 True
Es la tercera vez que reporto lo mismo sin solución reclamo reclamo 0.714 True
Pedí la cancelación hace un mes y me siguen cobrando cancelacion cancelacion 0.438 True
Pésima atención, quiero hablar con un supervisor reclamo ventas 0.357 False
Me cambio de proveedor, requiero cerrar el contrato cancelacion reclamo 0.354 False
Solicito el retiro definitivo de mi suscripción cancelacion cancelacion 0.865 True
Al intentar restablecer la contraseña no llega el enlace soporte_tecnico soporte_tecnico 0.478 True
Pagué ayer y el sistema sigue mostrando saldo pendiente facturacion soporte_tecnico 0.636 False
Necesito saber si el producto se integra con nuestro ERP ventas ventas 0.798 True
¿Puedo diferir el saldo pendiente a varias cuotas? facturacion facturacion 0.320 True
Quiero cambiar el medio de pago a débito automático facturacion facturacion 0.382 True
Me sale un mensaje de error al cargar el archivo de Excel soporte_tecnico soporte_tecnico 0.887 True
Estoy evaluando proveedores y quisiera una cotización formal ventas reclamo 0.508 False
El inicio de sesión con Google no está funcionando soporte_tecnico soporte_tecnico 0.848 True
Quiero renovar el contrato por un año más ventas cancelacion 0.774 False
Quiero eliminar mi cuenta y todos mis datos cancelacion cancelacion 0.366 True
¿Dónde puedo descargar mi historial de pagos? facturacion soporte_tecnico 0.594 False
¿Manejan precios especiales para instituciones educativas? ventas ventas 0.761 True
¿El servicio incluye capacitación para el equipo? ventas ventas 0.747 True
¿Debo pagar algo si cancelo en este momento? cancelacion facturacion 0.374 False
La atención recibida fue deficiente y poco profesional reclamo reclamo 0.443 True
Estoy cansado de que me pasen de un área a otra reclamo reclamo 0.357 True
El escáner de documentos no reconoce la cédula soporte_tecnico ventas 0.297 False
Estoy muy inconforme con el servicio recibido reclamo reclamo 0.484 True
¿Cómo cancelo sin perder la información almacenada? cancelacion reclamo 0.377 False
Quiero desactivar mi cuenta de forma permanente cancelacion ventas 0.545 False
Solicito devolución del dinero cobrado en exceso facturacion facturacion 0.486 True
La plataforma no me deja guardar los cambios del perfil soporte_tecnico soporte_tecnico 0.749 True
Nadie se hace responsable de mi caso, es inaceptable reclamo reclamo 0.572 True
¿Hay descuento si migramos desde otro proveedor? ventas ventas 0.344 True
El correo de notificaciones llega sin el archivo adjunto soporte_tecnico soporte_tecnico 0.522 True
Después de la actualización perdí el acceso a mis carpetas soporte_tecnico soporte_tecnico 0.610 True
Me descontaron del banco un valor distinto al acordado facturacion facturacion 0.379 True
Confirmen por favor la baja definitiva de mi cuenta cancelacion cancelacion 0.350 True
El servicio estuvo caído dos días y nadie avisó reclamo reclamo 0.730 True
Deseo ampliar mi plan actual con más licencias ventas cancelacion 0.488 False
# Análisis de errores
errores_tk = trazabilidad[~trazabilidad["correcto"]].copy()
if len(errores_tk) > 0:
print(f"\n{'='*60}")
print(f"ANÁLISIS DE ERRORES ({len(errores_tk)} tickets mal clasificados)")
print(f"{'='*60}")
for _, row in errores_tk.iterrows():
print(f"\n Texto: {row['texto'][:80]}...")
print(f" Real: {row['real']}")
print(f" Predicho: {row['predicho']}")
print(f" Confianza: {row['confianza']}")
else:
print("\n✅ No hubo errores en el conjunto de prueba.")
============================================================ ANÁLISIS DE ERRORES (11 tickets mal clasificados) ============================================================ Texto: Pésima atención, quiero hablar con un supervisor... Real: reclamo Predicho: ventas Confianza: 0.357 Texto: Me cambio de proveedor, requiero cerrar el contrato... Real: cancelacion Predicho: reclamo Confianza: 0.354 Texto: Pagué ayer y el sistema sigue mostrando saldo pendiente... Real: facturacion Predicho: soporte_tecnico Confianza: 0.636 Texto: Estoy evaluando proveedores y quisiera una cotización formal... Real: ventas Predicho: reclamo Confianza: 0.508 Texto: Quiero renovar el contrato por un año más... Real: ventas Predicho: cancelacion Confianza: 0.774 Texto: ¿Dónde puedo descargar mi historial de pagos?... Real: facturacion Predicho: soporte_tecnico Confianza: 0.594 Texto: ¿Debo pagar algo si cancelo en este momento?... Real: cancelacion Predicho: facturacion Confianza: 0.374 Texto: El escáner de documentos no reconoce la cédula... Real: soporte_tecnico Predicho: ventas Confianza: 0.297 Texto: ¿Cómo cancelo sin perder la información almacenada?... Real: cancelacion Predicho: reclamo Confianza: 0.377 Texto: Quiero desactivar mi cuenta de forma permanente... Real: cancelacion Predicho: ventas Confianza: 0.545 Texto: Deseo ampliar mi plan actual con más licencias... Real: ventas Predicho: cancelacion Confianza: 0.488
2.6 Análisis de umbrales de confianza¶
Uno de los conceptos más importantes de la clase fue el umbral de confianza: por debajo de cierto nivel, el modelo no debe clasificar automáticamente sino derivar a revisión humana. La profesora mostró que con un umbral del 45%, la cobertura era ~70% y la exactitud ~91%.
Probemos varios umbrales para ver el trade-off entre cobertura automática y precisión.
from sklearn.model_selection import cross_val_predict
# Obtener probabilidades out-of-sample (más realistas)
y_proba_cv = cross_val_predict(pipe, X_tk, y_tk, cv=cv_tk, method="predict_proba")
y_pred_cv = pipe.classes_[y_proba_cv.argmax(axis=1)]
confianza_cv = y_proba_cv.max(axis=1)
# Evaluar diferentes umbrales
umbrales = [0.30, 0.35, 0.40, 0.45, 0.50, 0.55, 0.60, 0.70]
resultados_umbral = []
for umbral in umbrales:
mask = confianza_cv >= umbral
n_auto = mask.sum()
cobertura = n_auto / len(y_tk)
if n_auto > 0:
exactitud = (y_pred_cv[mask] == y_tk.values[mask]).mean()
errores_auto = 1 - exactitud
else:
exactitud = 0
errores_auto = 0
derivados = (~mask).sum()
resultados_umbral.append({
"Umbral": umbral,
"Clasificados auto": n_auto,
"Cobertura (%)": round(cobertura * 100, 1),
"Exactitud (%)": round(exactitud * 100, 1),
"Errores auto (%)": round(errores_auto * 100, 1),
"Derivados a humano": derivados,
})
df_umbrales = pd.DataFrame(resultados_umbral)
print("=" * 75)
print("ANÁLISIS DE UMBRALES DE CONFIANZA")
print("=" * 75)
print(df_umbrales.to_string(index=False))
=========================================================================== ANÁLISIS DE UMBRALES DE CONFIANZA =========================================================================== Umbral Clasificados auto Cobertura (%) Exactitud (%) Errores auto (%) Derivados a humano 0.30 147 98.0 83.0 17.0 3 0.35 139 92.7 84.2 15.8 11 0.40 124 82.7 86.3 13.7 26 0.45 105 70.0 91.4 8.6 45 0.50 97 64.7 94.8 5.2 53 0.55 81 54.0 95.1 4.9 69 0.60 70 46.7 94.3 5.7 80 0.70 44 29.3 95.5 4.5 106
# Gráfico de trade-off
fig, ax1 = plt.subplots(figsize=(9, 5))
ax1.plot(df_umbrales["Umbral"], df_umbrales["Cobertura (%)"], "b-o", label="Cobertura (%)")
ax1.plot(df_umbrales["Umbral"], df_umbrales["Exactitud (%)"], "g-s", label="Exactitud (%)")
ax1.set_xlabel("Umbral de confianza")
ax1.set_ylabel("Porcentaje")
ax1.set_title("Trade-off: Cobertura vs. Exactitud según Umbral de Confianza")
ax1.legend(loc="center left")
ax1.grid(True, alpha=0.3)
ax1.axvline(x=0.45, color="red", linestyle="--", alpha=0.5, label="Umbral sugerido (0.45)")
ax1.legend()
plt.tight_layout()
plt.show()
print("\n💡 Recomendación: Un umbral de 0.45 ofrece un buen balance entre")
print(" cobertura automática y precisión. Los tickets con confianza < 0.45")
print(" se derivan a la coordinadora para revisión manual.")
💡 Recomendación: Un umbral de 0.45 ofrece un buen balance entre cobertura automática y precisión. Los tickets con confianza < 0.45 se derivan a la coordinadora para revisión manual.
Conclusiones¶
Parte 1 — Análisis de Sentimientos¶
Naive Bayes con TF-IDF demostró ser un clasificador robusto para el corpus
movie_reviews, superando al análisis basado en lexicón (TextBlob). El modelo supervisado se adapta al vocabulario específico del dominio, mientras que TextBlob trabaja con un lexicón genérico que no captura matices de las reseñas cinematográficas.TextBlob tiene su valor como herramienta de exploración rápida cuando no se dispone de datos etiquetados, pero su precisión es notablemente inferior en dominios especializados. Además, clasifica un porcentaje significativo de reseñas como "neutras", reduciendo su cobertura efectiva.
La validación cruzada confirmó que los resultados no dependen de una partición afortunada, dando confianza en la generalización del modelo.
Parte 2 — Clasificación de Tickets¶
Los n-gramas de caracteres (3-5) superan consistentemente al TF-IDF de palabras completas. Este hallazgo es coherente con lo que explicó la profesora: al capturar raíces ("can", "cancel", "cancela"), el modelo maneja mejor las variantes morfológicas del español.
El análisis de umbrales demostró que es posible automatizar una fracción significativa de los tickets con alta confianza y derivar los ambiguos a revisión humana. Un umbral de 0.45 ofrece un balance razonable para el caso Nexo Digital.
La trazabilidad (texto + etiqueta real + predicción + confianza) es indispensable para auditoría, re-entrenamiento y mejora continua del modelo.
Reflexión del Grupo 7¶
A partir del análisis técnico desarrollado en esta práctica sobre el caso Nexo Digital, el Grupo 7 concluye que el valor fundamental del Procesamiento del Lenguaje Natural en entornos operativos no reside únicamente en alcanzar la métrica de F1 más alta, sino en la capacidad de diseñar pipelines adaptativos. La combinación de representación subpalabra mediante n-gramas de caracteres (3-5) —que mitiga la dispersión léxica y las variaciones tipográficas— junto con una política de abstención basada en umbrales de confianza (Human-in-the-Loop), permite transformar un problema de asignación manual de 180 a 300 tickets diarios en un flujo híbrido eficiente. De este modo, la automatización asume con alta precisión los casos de alta certeza y preserva la capacidad del equipo humano para atender las solicitudes complejas y de baja confianza.
Referencias:
- Srinivasa-Desikan, B. (2018). Natural Language Processing and Computational Linguistics. Packt Publishing.
- Ganegedara, T. (2018). Natural Language Processing with TensorFlow. Packt Publishing.
- Rajput, A. (2020). Hands-On Natural Language Processing with Python. Packt Publishing.
- Bird, S., Klein, E., & Loper, E. (2009). Natural Language Processing with Python. O'Reilly Media.
- Material de clase — Semana 5, PNL (NRC-8774), Prof. Nathalia Orozco Morales.