#!/usr/bin/env python3
"""
Genera el notebook Oviedo_Alexander_Reporte_S5_PNL.ipynb
Actividad Semana 5 - PNL (NRC-8774)
Grupo 7: Alexander Oviedo Fadul, María Fernanda Ruiz Paipilla,
         Neheman Samir Jaller Cerchiaro, William David Obando López
Docente: Nathalia Orozco Morales
"""

import json, os

def md(source):
    """Crea una celda markdown."""
    return {"cell_type": "markdown", "metadata": {}, "source": source.split("\n")}

def code(source, outputs=None):
    """Crea una celda de código."""
    return {
        "cell_type": "code",
        "metadata": {},
        "source": source.split("\n"),
        "outputs": outputs or [],
        "execution_count": None,
    }

cells = []

# ============================================================
# PORTADA
# ============================================================
cells.append(md("""# 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."""))

# ============================================================
# INSTALACIÓN
# ============================================================
cells.append(md("""## Parte 1 — Análisis de Sentimientos con Movie Reviews

### 1.0 Instalación de dependencias

Antes de empezar, necesitamos asegurar que las librerías estén disponibles. En Google Colab la mayoría ya vienen preinstaladas, pero por si acaso ejecutamos la celda de instalación."""))

cells.append(code("""# Ejecutar solo la primera vez o en Google Colab
!pip install -q nltk textblob scikit-learn pandas matplotlib seaborn"""))

# ============================================================
# IMPORTS Y DESCARGAS
# ============================================================
cells.append(md("""### 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."""))

cells.append(code("""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.")"""))

# ============================================================
# CARGA Y EXPLORACIÓN
# ============================================================
cells.append(md("""### 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."""))

cells.append(code("""# 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])"""))

cells.append(code("""# 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))"""))

# ============================================================
# PREPROCESAMIENTO
# ============================================================
cells.append(md("""### 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:

1. **Minúsculas** — normalización básica
2. **Eliminación de caracteres especiales** — conservamos solo letras
3. **Eliminación de stopwords** — palabras funcionales que no aportan al sentimiento
4. **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."""))

cells.append(code("""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]}...")"""))

# ============================================================
# NAIVE BAYES
# ============================================================
cells.append(md("""### 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."""))

cells.append(code("""# 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())}")"""))

cells.append(code("""# 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")"""))

cells.append(code("""# 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}")"""))

cells.append(code("""# 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()"""))

# ============================================================
# VALIDACIÓN CRUZADA NB
# ============================================================
cells.append(md("""### 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."""))

cells.append(code("""# 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}")"""))

# ============================================================
# TEXTBLOB
# ============================================================
cells.append(md("""### 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."""))

cells.append(code("""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))"""))

cells.append(code("""# 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.")"""))

cells.append(code("""# 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()"""))

# ============================================================
# COMPARACIÓN
# ============================================================
cells.append(md("""### 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."""))

cells.append(code("""# 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))"""))

# ============================================================
# PARTE 2 — TICKETS
# ============================================================
cells.append(md("""---

## 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."""))

cells.append(md("""### 2.1 Carga y exploración del dataset de tickets"""))

cells.append(code("""# 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())"""))

cells.append(code("""# 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()"""))

# ============================================================
# MODELO A PALABRAS
# ============================================================
cells.append(md("""### 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."""))

cells.append(code("""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 B N-GRAMAS
# ============================================================
cells.append(md("""### 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."""))

cells.append(code("""# 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))"""))

cells.append(code("""# 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()"""))

# ============================================================
# EVALUACIÓN DETALLADA + MATRIZ DE CONFUSIÓN
# ============================================================
cells.append(md("""### 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."""))

cells.append(code("""# 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()"""))

# ============================================================
# TRAZABILIDAD
# ============================================================
cells.append(md("""### 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)."""))

cells.append(code("""# 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))"""))

cells.append(code("""# 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.")"""))

# ============================================================
# UMBRALES
# ============================================================
cells.append(md("""### 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."""))

cells.append(code("""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))"""))

cells.append(code("""# 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.")"""))

# ============================================================
# CONCLUSIONES
# ============================================================
cells.append(md("""---

## Conclusiones

### Parte 1 — Análisis de Sentimientos

1. **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.

2. **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.

3. 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

4. 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.

5. 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.

6. 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."""))

# ============================================================
# CONSTRUIR NOTEBOOK
# ============================================================
notebook = {
    "nbformat": 4,
    "nbformat_minor": 5,
    "metadata": {
        "kernelspec": {
            "display_name": "Python 3",
            "language": "python",
            "name": "python3",
        },
        "language_info": {
            "name": "python",
            "version": "3.10.0",
        },
    },
    "cells": cells,
}

# Corregir: cada línea del source debe terminar en \n excepto la última
for cell in notebook["cells"]:
    lines = cell["source"]
    for i in range(len(lines)):
        if not lines[i].endswith("\n") and i < len(lines) - 1:
            lines[i] += "\n"

output_path = "/Volumes/NVMe1TB/GitHub/UNIMINUTO/2do CUATRIMESTRE/NRC-8774-Procesamiento Natural Lenguaje/ACTIVIDADES/MA Semana 5/Oviedo_Alexander_Reporte_S5_PNL.ipynb"

with open(output_path, "w", encoding="utf-8") as f:
    json.dump(notebook, f, ensure_ascii=False, indent=1)

print(f"✅ Notebook generado: {output_path}")
print(f"   Total de celdas: {len(cells)}")
