Saltar a contenido

Proyectos — Módulo 03: Deep Learning

AI Master Academy · Módulo 03-DEEP-LEARNING · Proyectos evaluables Última actualización: julio de 2026

Los laboratorios te dieron las piezas; los proyectos te piden construir algo completo sin red de seguridad. Aquí trabajas como en una empresa: con una especificación, requisitos numerados, criterios de aceptación y una rúbrica.

Nota

haz primero el Mini Proyecto (A) para consolidar visión por

computador, y después el Proyecto Profesional (B), que es la pieza estrella de tu portfolio de este módulo. El código de referencia está en bloques <details> plegados: inténtalo primero sin mirarlo — mirar la solución antes de intentarlo es la forma más rápida de no aprender nada.


Índice

  1. MINI PROYECTO A — Clasificador de imágenes de producto (ModaExpress)
  2. Contexto empresarial
  3. Especificación y requisitos
  4. Arquitectura
  5. Esqueleto con TODOs
  6. Código de referencia completo
  7. Criterios de aceptación
  8. Rúbrica de autoevaluación
  9. PROYECTO PROFESIONAL B — Clasificador inteligente de tickets + API (HelpDeskPro)
  10. Contexto empresarial
  11. Arquitectura
  12. Estructura de carpetas
  13. Plan por fases
  14. Generador del dataset (completo)
  15. Requisitos numerados
  16. Esqueletos clave
  17. Criterios de aceptación
  18. Rúbrica
  19. Cómo presentarlo en una entrevista
  20. Errores típicos
  21. Qué has demostrado → habilidad de empleo
  22. Conexión con el módulo 04

MINI PROYECTO A — Clasificador de imágenes de producto

Tiempo estimado: 4-6 horas · Dificultad: media

Contexto empresarial: ModaExpress

ModaExpress es una tienda online de moda con 40.000 referencias. Cada semana los proveedores suben ~2.000 fotos de producto nuevas, y hoy un equipo de 3 personas las clasifica a mano en categorías (camisetas, pantalones, calzado…) para colocarlas en la web. Es lento, caro y con errores que acaban en "vestidos" dentro de la sección de "bolsos".

Te contratan para un prototipo: un clasificador automático de fotos de producto en 10 categorías. Como los datos reales están pendientes de acuerdos legales, el CTO te pide validar el enfoque con FashionMNIST (10 categorías de prendas, 70.000 imágenes 28×28 en escala de grises), que es un proxy razonable del problema.

La pregunta de negocio que debes responder con datos: ¿basta con un modelo simple, o compensa la complejidad de una CNN o el transfer learning?

Especificación y requisitos (A)

Debes entregar un repositorio con código, un informe (INFORME.md) y las figuras generadas. Requisitos numerados:

# Requisito Detalle
RA-1 Baseline MLP Un MLP simple (aplanar 28×28 → capas densas) entrenado como referencia mínima. Sin él no puedes cuantificar cuánto aporta la convolución.
RA-2 CNN propia Al menos 2 bloques conv+pool, dropout, entrenada con early stopping sobre un split de validación.
RA-3 Transfer learning ResNet18 (u otro modelo de torchvision) preentrenada, en modo feature extraction, adaptada a 10 clases.
RA-4 Comparación con tabla Tabla con accuracy en test, tiempo de entrenamiento, nº de parámetros entrenados y épocas hasta parar, para los 3 modelos.
RA-5 Análisis de errores Matriz de confusión del mejor modelo + cuadrícula con ≥12 imágenes mal clasificadas (etiqueta real vs predicha) + 3 conclusiones escritas.
RA-6 Informe INFORME.md de 1-2 páginas con: metodología, tabla de resultados, análisis de errores y recomendación de negocio justificada para ModaExpress.
RA-7 Reproducibilidad Semillas fijadas, requirements.txt, y un solo comando (python main.py) que ejecute todo.

Advertencia: el split test se toca una sola vez, al final, con los tres modelos ya elegidos. Si ajustas hiperparámetros mirando test, tu comparación queda invalidada (es el pecado metodológico que un revisor senior detecta al instante).

Arquitectura (A)

flowchart TD
    A[FashionMNIST<br/>torchvision.datasets] --> B[Preparación<br/>transforms + splits train/val/test]
    B --> C1[Modelo 1: MLP baseline<br/>flatten -> 256 -> 10]
    B --> C2[Modelo 2: CNN propia<br/>2x conv-pool + densas]
    B --> C3[Modelo 3: ResNet18<br/>congelada + cabeza nueva]
    C1 --> D[Entrenamiento común<br/>AdamW + early stopping + checkpoints]
    C2 --> D
    C3 --> D
    D --> E[Evaluación en test<br/>UNA sola vez]
    E --> F[Tabla comparativa]
    E --> G[Matriz de confusión<br/>del mejor modelo]
    E --> H[Cuadrícula de errores<br/>12+ imágenes]
    F --> I[INFORME.md<br/>recomendación de negocio]
    G --> I
    H --> I

Esqueleto con TODOs (A)

Estructura sugerida (un solo archivo está bien para este tamaño):

# main.py — ModaExpress: comparación MLP vs CNN vs transfer learning
import time
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Subset, random_split
import torchvision
from torchvision import transforms
import matplotlib.pyplot as plt

SEED = 42
torch.manual_seed(SEED); np.random.seed(SEED)
device = "cuda" if torch.cuda.is_available() else "cpu"

CLASES = ["Camiseta", "Pantalón", "Jersey", "Vestido", "Abrigo",
          "Sandalia", "Camisa", "Zapatilla", "Bolso", "Botín"]

# ---------- 1. DATOS ----------
def preparar_datos():
    # TODO: transform para MLP/CNN (ToTensor + Normalize de FashionMNIST)
    # TODO: transform para ResNet (Grayscale x3 + Resize(224) + Normalize ImageNet)
    # TODO: descargar FashionMNIST train y test
    # TODO: dividir train en train/val (p. ej. 85/15) con random_split y semilla
    # TODO: (opcional CPU) usar Subset para acelerar; documenta el tamaño usado
    # TODO: devolver los DataLoaders necesarios
    raise NotImplementedError

# ---------- 2. MODELOS ----------
class MLPBaseline(nn.Module):
    def __init__(self):
        super().__init__()
        # TODO: Flatten -> Linear(784, 256) -> ReLU -> Dropout -> Linear(256, 10)
        raise NotImplementedError

class CNNPropia(nn.Module):
    def __init__(self):
        super().__init__()
        # TODO: 2 bloques [Conv2d -> ReLU -> MaxPool2d] + clasificador denso
        raise NotImplementedError

def crear_resnet():
    # TODO: cargar resnet18 con pesos IMAGENET1K_V1
    # TODO: congelar todos los parámetros
    # TODO: sustituir fc por Linear(512, 10)
    raise NotImplementedError

# ---------- 3. ENTRENAMIENTO GENÉRICO ----------
def entrenar(modelo, train_dl, val_dl, epochs_max=30, lr=1e-3, paciencia=5):
    # TODO: AdamW solo sobre parámetros con requires_grad
    # TODO: bucle de épocas con train/eval, early stopping por val loss
    # TODO: guardar checkpoint del mejor modelo y recargarlo al final
    # TODO: devolver (modelo, historia, segundos, epoca_mejor)
    raise NotImplementedError

# ---------- 4. EVALUACIÓN Y ANÁLISIS ----------
def evaluar_test(modelo, test_dl):
    # TODO: accuracy + (y_true, y_pred) completos para la matriz de confusión
    raise NotImplementedError

def analisis_errores(modelo, test_ds, y_true, y_pred):
    # TODO: matriz de confusión con nombres de clase (sklearn)
    # TODO: cuadrícula 3x4 de imágenes mal clasificadas con real vs pred
    raise NotImplementedError

# ---------- 5. ORQUESTACIÓN ----------
if __name__ == "__main__":
    # TODO: preparar datos, entrenar los 3 modelos, evaluar en test UNA vez
    # TODO: imprimir la tabla comparativa (modelo, acc, tiempo, params, épocas)
    # TODO: análisis de errores del mejor
    pass

Código de referencia completo (A)

Advertencia: ábrelo solo cuando tengas tu propia versión funcionando o lleves >1 hora atascado en el mismo punto.

Ver código de referencia completo (main.py)
# main.py — ModaExpress: MLP vs CNN vs transfer learning (solución de referencia)
import time
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Subset, random_split
import torchvision
from torchvision import transforms
from torchvision.models import resnet18, ResNet18_Weights
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
import matplotlib.pyplot as plt

SEED = 42
torch.manual_seed(SEED); np.random.seed(SEED)            # reproducibilidad total
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Dispositivo: {device}")

CLASES = ["Camiseta", "Pantalón", "Jersey", "Vestido", "Abrigo",
          "Sandalia", "Camisa", "Zapatilla", "Bolso", "Botín"]

# ---------------- 1. DATOS ----------------
# Transform para MLP y CNN: tensor 1x28x28 normalizado con stats del dataset
tf_28 = transforms.Compose([
    transforms.ToTensor(),                               # [0,255] -> [0,1]
    transforms.Normalize((0.2860,), (0.3530,)),          # media/std FashionMNIST
])
# Transform para ResNet: 3 canales, 224x224, stats de ImageNet
tf_224 = transforms.Compose([
    transforms.Grayscale(num_output_channels=3),         # gris -> 3 canales
    transforms.Resize(224),                              # tamaño de ImageNet
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
])

def preparar(transform, n_train=8000, n_test=2000, batch=64):
    """DataLoaders train/val/test con subsets para tiempos razonables en CPU."""
    full = torchvision.datasets.FashionMNIST("./data", train=True,
                                             download=True, transform=transform)
    test = torchvision.datasets.FashionMNIST("./data", train=False,
                                             download=True, transform=transform)
    full = Subset(full, range(n_train))                  # subset de trabajo
    test = Subset(test, range(n_test))
    n_val = int(0.15 * len(full))                        # 15% para validación
    train, val = random_split(full, [len(full) - n_val, n_val],
                              generator=torch.Generator().manual_seed(SEED))
    return (DataLoader(train, batch_size=batch, shuffle=True),
            DataLoader(val, batch_size=256),
            DataLoader(test, batch_size=256),
            test)                                        # el dataset test para figuras

# ---------------- 2. MODELOS ----------------
class MLPBaseline(nn.Module):
    """Baseline: ignora la estructura espacial de la imagen."""
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Flatten(),                                # 1x28x28 -> 784
            nn.Linear(784, 256), nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(256, 10),                          # 10 logits
        )
    def forward(self, x): return self.net(x)

class CNNPropia(nn.Module):
    """CNN pequeña: explota la localidad espacial con convoluciones."""
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),  # ->16x14x14
            nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # ->32x7x7
        )
        self.classifier = nn.Sequential(
            nn.Flatten(), nn.Linear(32 * 7 * 7, 128), nn.ReLU(),
            nn.Dropout(0.3), nn.Linear(128, 10),
        )
    def forward(self, x): return self.classifier(self.features(x))

def crear_resnet():
    """ResNet18 congelada con cabeza nueva de 10 clases."""
    m = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
    for p in m.parameters():
        p.requires_grad = False                          # congelar el tronco
    m.fc = nn.Linear(m.fc.in_features, 10)               # cabeza nueva entrenable
    return m

# ---------------- 3. ENTRENAMIENTO GENÉRICO ----------------
criterion = nn.CrossEntropyLoss()

def una_epoca(modelo, dl, optimizer=None):
    """Una época; si optimizer es None, solo evalúa."""
    entrenando = optimizer is not None
    modelo.train() if entrenando else modelo.eval()
    total, correct, loss_sum = 0, 0, 0.0
    with torch.set_grad_enabled(entrenando):             # grad solo si entrena
        for xb, yb in dl:
            xb, yb = xb.to(device), yb.to(device)
            logits = modelo(xb)
            loss = criterion(logits, yb)
            if entrenando:
                optimizer.zero_grad(); loss.backward(); optimizer.step()
            loss_sum += loss.item() * len(xb)
            correct += (logits.argmax(1) == yb).sum().item()
            total += len(xb)
    return loss_sum / total, correct / total

def entrenar(nombre, modelo, train_dl, val_dl, epochs_max=30, lr=1e-3, paciencia=5):
    """Entrena con early stopping y checkpoint. Devuelve métricas de proceso."""
    modelo = modelo.to(device)
    params = [p for p in modelo.parameters() if p.requires_grad]
    optimizer = torch.optim.AdamW(params, lr=lr)
    n_params = sum(p.numel() for p in params)
    best, sin_mejora, mejor_epoca = float("inf"), 0, 0
    t0 = time.time()
    for epoch in range(1, epochs_max + 1):
        tr_loss, tr_acc = una_epoca(modelo, train_dl, optimizer)
        va_loss, va_acc = una_epoca(modelo, val_dl)
        if va_loss < best:                               # ¿mejora en validación?
            best, sin_mejora, mejor_epoca = va_loss, 0, epoch
            torch.save(modelo.state_dict(), f"ckpt_{nombre}.pt")
        else:
            sin_mejora += 1
        print(f"  [{nombre}] época {epoch:2d} | train {tr_loss:.4f}/{tr_acc:.3f}"
              f" | val {va_loss:.4f}/{va_acc:.3f}")
        if sin_mejora >= paciencia:
            print(f"  [{nombre}] early stopping (mejor época: {mejor_epoca})")
            break
    segundos = time.time() - t0
    modelo.load_state_dict(torch.load(f"ckpt_{nombre}.pt", weights_only=True))
    return modelo, segundos, n_params, mejor_epoca

# ---------------- 4. EVALUACIÓN Y ANÁLISIS ----------------
@torch.no_grad()
def evaluar_test(modelo, test_dl):
    """Accuracy en test + vectores completos para el análisis."""
    modelo.eval()
    ys, ps = [], []
    for xb, yb in test_dl:
        ys.append(yb); ps.append(modelo(xb.to(device)).argmax(1).cpu())
    y_true, y_pred = torch.cat(ys).numpy(), torch.cat(ps).numpy()
    return (y_true == y_pred).mean(), y_true, y_pred

def analisis_errores(test_ds, y_true, y_pred, nombre):
    """Matriz de confusión + cuadrícula de errores."""
    cm = confusion_matrix(y_true, y_pred)
    fig, ax = plt.subplots(figsize=(9, 8))
    ConfusionMatrixDisplay(cm, display_labels=CLASES).plot(
        ax=ax, cmap="Blues", xticks_rotation=45, colorbar=False)
    plt.title(f"Matriz de confusión — {nombre}")
    plt.tight_layout(); plt.savefig(f"confusion_{nombre}.png", dpi=120)

    errores = np.where(y_true != y_pred)[0]              # índices de fallos
    fig, axes = plt.subplots(3, 4, figsize=(10, 8))
    for ax, idx in zip(axes.ravel(), errores[:12]):
        img, _ = test_ds[idx]
        # si es 3x224x224 (resnet) usamos solo un canal para pintar
        img2d = img[0] if img.shape[0] in (1, 3) else img
        ax.imshow(img2d, cmap="gray"); ax.axis("off")
        ax.set_title(f"real: {CLASES[y_true[idx]]}\npred: {CLASES[y_pred[idx]]}",
                     fontsize=8)
    plt.suptitle(f"Errores — {nombre}")
    plt.tight_layout(); plt.savefig(f"errores_{nombre}.png", dpi=120)
    return len(errores)

# ---------------- 5. ORQUESTACIÓN ----------------
if __name__ == "__main__":
    resultados = []                                      # filas de la tabla final

    # Datos 28x28 para MLP y CNN (compartidos)
    tr28, va28, te28, te28_ds = preparar(tf_28)
    # Datos 224x224 para ResNet (menos ejemplos: batch más pesado)
    tr224, va224, te224, te224_ds = preparar(tf_224, n_train=3000, n_test=2000,
                                             batch=32)

    for nombre, modelo, dls in [
        ("mlp", MLPBaseline(), (tr28, va28, te28)),
        ("cnn", CNNPropia(), (tr28, va28, te28)),
        ("resnet18", crear_resnet(), (tr224, va224, te224)),
    ]:
        print(f"\n=== Entrenando {nombre} ===")
        modelo, seg, n_par, ep = entrenar(nombre, modelo, dls[0], dls[1])
        acc, y_true, y_pred = evaluar_test(modelo, dls[2])
        resultados.append((nombre, acc, seg, n_par, ep, y_true, y_pred))

    print("\n===== TABLA COMPARATIVA =====")
    print(f"{'modelo':<12}{'acc test':>10}{'tiempo(s)':>11}{'params entren.':>16}{'mejor época':>13}")
    for n, a, s, p, e, *_ in resultados:
        print(f"{n:<12}{a:>10.4f}{s:>11.0f}{p:>16,}{e:>13}")

    # Análisis de errores del MEJOR modelo por accuracy
    mejor = max(resultados, key=lambda r: r[1])
    ds_mejor = te224_ds if mejor[0] == "resnet18" else te28_ds
    n_err = analisis_errores(ds_mejor, mejor[5], mejor[6], mejor[0])
    print(f"\nMejor modelo: {mejor[0]}{n_err} errores en test. "
          f"Figuras guardadas: confusion_{mejor[0]}.png, errores_{mejor[0]}.png")
Salida esperada (orientativa, en CPU):
===== TABLA COMPARATIVA =====
modelo        acc test  tiempo(s)  params entren.  mejor época
mlp             0.8615        41         203,530            9
cnn             0.9010       118         206,922           11
resnet18        0.8825       610           5,130            3

Mejor modelo: cnn — 198 errores en test.

Nota sobre la conclusión esperada: en FashionMNIST la CNN propia suele ganar o empatar con la ResNet congelada (las imágenes 28×28 en gris se parecen poco a ImageNet). La recomendación de negocio madura NO es "usa siempre transfer learning", sino: "con las fotos reales de ModaExpress (color, alta resolución, similares a ImageNet) el transfer learning será la mejor opción; este experimento demuestra el pipeline y que el baseline MLP es insuficiente". Saber explicar por qué el proxy limita las conclusiones es parte del ejercicio.

Criterios de aceptación (A)

  • [ ] python main.py ejecuta todo de principio a fin sin intervención manual.
  • [ ] Los tres modelos entrenan con el mismo protocolo (mismo optimizador, early stopping, mismos splits salvo el transform).
  • [ ] La tabla comparativa incluye las 5 columnas exigidas y aparece en INFORME.md.
  • [ ] El MLP baseline queda claramente por debajo de la CNN (≥2 puntos de accuracy).
  • [ ] El mejor modelo supera 0.88 de accuracy en test.
  • [ ] La matriz de confusión usa nombres de clase legibles, no números.
  • [ ] La cuadrícula de errores muestra ≥12 imágenes con etiqueta real y predicha.
  • [ ] INFORME.md contiene ≥3 conclusiones del análisis de errores (p. ej. "camisa/camiseta concentran el 30% de los fallos") y una recomendación de negocio.
  • [ ] El test set se ha usado una única vez; los hiperparámetros se eligieron con validación.
  • [ ] requirements.txt presente y semillas fijadas.

Rúbrica de autoevaluación (A)

Dimensión 1 (insuficiente) 2 (aceptable) 3 (bueno) 4 (excelente)
Metodología Test usado para ajustar Splits correctos + early stopping y checkpoints + protocolo idéntico y documentado para los 3 modelos
Modelos Solo uno funciona Los 3 entrenan Los 3 con resultados razonables + experimentos extra (augmentation, fine-tuning parcial)
Análisis Solo accuracy global Matriz de confusión + inspección de errores con imágenes + hipótesis verificadas sobre los fallos
Comunicación Sin informe Tabla de resultados Informe claro con figuras + recomendación de negocio con limitaciones del proxy
Código Scripts sueltos Ejecutable de una vez Funciones reutilizables y comentadas + semillas, requirements, estructura limpia

Interpretación: <10 puntos: repásalo antes de seguir · 10-14: aprobado · 15-17: notable · 18-20: nivel portfolio.


PROYECTO PROFESIONAL B — Clasificador inteligente de tickets de soporte + API

Tiempo estimado: 12-20 horas · Dificultad: alta · Pieza de portfolio

Contexto empresarial: HelpDeskPro

HelpDeskPro es una empresa SaaS de gestión documental con 12.000 clientes en España y Latinoamérica. Su buzón de soporte recibe ~2.000 tickets al día en español, que hoy un equipo de triaje clasifica a mano en 4 colas (facturación, técnico, cuenta, comercial) y les asigna prioridad. El triaje manual cuesta ~3 minutos por ticket, introduce retrasos de horas y los errores de enrutado son la segunda causa de insatisfacción en las encuestas.

Te contratan como ML engineer para construir el servicio de clasificación automática: un modelo que asigne categoría (4 clases) y prioridad (alta/normal), expuesto como API REST lista para que el equipo de backend la integre. El CTO añade dos condiciones de veterano:

  1. "Quiero una comparación honesta con un baseline simple. Si TF-IDF + regresión logística funciona igual, no pago GPUs."
  2. "Nada de notebooks: código de producción, con tests, Docker y logging."

Nota: los datos reales están protegidos por RGPD, así que trabajarás con un dataset sintético realista generado por el script incluido más abajo. En una empresa real este paso equivale a la fase de anonimización/preparación de datos.

Arquitectura (B)

flowchart LR
    subgraph Entrenamiento offline
        A[generar_datos.py<br/>~300 tickets sintéticos] --> B[train_baseline.py<br/>TF-IDF + LogReg]
        A --> C[train_transformer.py<br/>fine-tuning DistilBERT multilingüe]
        B --> D[evaluate.py<br/>métricas por clase<br/>tabla de decisión]
        C --> D
        D --> E[(models/<br/>modelo elegido + versión)]
    end
    subgraph Servicio online
        F[Cliente / backend] -->|POST /classify|G[API FastAPI]
        G --> H[Modelo cargado<br/>en memoria]
        H --> G
        G -->|categoría + prioridad + confianza|F
        G --> I[logs/predicciones.jsonl]
        F -->|GET /health|G
    end
    E --> H
    subgraph Infraestructura
        J[Dockerfile] --> K[docker-compose.yml]
        K --> G
        L[tests/ pytest] -.valida.-> G
    end

Estructura de carpetas

helpdeskpro-classifier/
├── index.md                  # plantilla más abajo
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── data/
│   ├── generar_datos.py       # generador incluido completo abajo
│   └── tickets.csv            # generado (no lo subas si fuera real)
├── src/
│   ├── __init__.py
│   ├── config.py              # rutas, nombres de modelo, versión
│   ├── train_baseline.py      # TF-IDF + regresión logística
│   ├── train_transformer.py   # fine-tuning con Trainer
│   ├── evaluate.py            # métricas por clase + tabla comparativa
│   └── predictor.py           # clase única de inferencia (la usa la API)
├── api/
│   ├── __init__.py
│   ├── main.py                # FastAPI: /classify, /health
│   └── schemas.py             # modelos Pydantic
├── models/                    # artefactos entrenados (con versión)
├── logs/                      # predicciones.jsonl
└── tests/
    ├── test_datos.py
    ├── test_predictor.py
    └── test_api.py

Plan por fases

Fase Entregable Horas
1. Datos tickets.csv con ~300 tickets y análisis exploratorio breve 1-2
2. Baseline TF-IDF + logística (módulo 02) con métricas por clase 1-2
3. Fine-tuning DistilBERT multilingüe con Trainer, checkpoints versionados 3-5
4. Evaluación Comparación honesta + tabla de decisión baseline vs transformer 1-2
5. API FastAPI con Pydantic, /classify y /health, logging de predicciones 2-3
6. Empaquetado Dockerfile + docker-compose + tests pytest 2-3
7. Documentación README profesional + preparación de la "demo de entrevista" 1-2

Consejo profesional: haz commit al terminar cada fase con un mensaje claro (feat: baseline TF-IDF con f1 macro 0.87). Un historial de commits limpio ES parte del portfolio: los entrevistadores lo miran.

Fase 1 — Datos: generador de tickets sintéticos (código completo)

Guarda esto como data/generar_datos.py. Está completo: es infraestructura del proyecto, no el ejercicio.

# generar_datos.py — Genera ~300 tickets sintéticos realistas de HelpDeskPro
import csv
import random

random.seed(42)                                       # dataset reproducible

# --- Plantillas por categoría. {x} son huecos que se rellenan al azar. ---
PLANTILLAS = {
    "facturacion": [
        "Me habéis cobrado {n} veces la cuota de {mes}, necesito que me devolváis el importe",
        "La factura {ref} tiene un importe que no corresponde con mi plan {plan}",
        "Quiero cambiar el método de pago de la suscripción a {pago}",
        "No recibo las facturas desde {mes} en el correo de la empresa",
        "Necesito las facturas de {mes} con el NIF actualizado para contabilidad",
        "El IVA de la factura {ref} está mal aplicado, somos de Canarias",
        "Se me ha cobrado el plan {plan} pero contraté el básico en {mes}",
        "¿Podéis reemitir la factura {ref}? El departamento financiero la ha rechazado",
    ],
    "tecnico": [
        "La aplicación se cierra al abrir {seccion} desde {dispositivo}",
        "Error {codigo} al subir documentos de más de {n}0 MB",
        "La sincronización con {dispositivo} falla desde la actualización de {mes}",
        "No puedo iniciar sesión desde {dispositivo}: dice credenciales inválidas y son correctas",
        "El buscador no encuentra documentos subidos esta semana en {seccion}",
        "URGENTE: llevamos toda la mañana sin poder acceder, error {codigo} en toda la empresa",
        "La exportación a PDF genera archivos corruptos desde {dispositivo}",
        "La API devuelve error {codigo} de forma intermitente desde {mes}",
    ],
    "cuenta": [
        "Quiero cambiar el correo del administrador de la cuenta de la empresa",
        "He olvidado la contraseña y el enlace de recuperación no llega a mi correo",
        "Necesito eliminar la cuenta y exportar todos nuestros datos antes",
        "Quiero añadir {n} usuarios nuevos a nuestra organización",
        "Mi cuenta aparece bloqueada desde {mes} sin motivo aparente",
        "¿Cómo activo la autenticación en dos pasos para todo el equipo?",
        "Un exempleado sigue teniendo acceso, necesito revocarlo hoy mismo",
        "No puedo transferir la propiedad de la cuenta al nuevo responsable",
    ],
    "comercial": [
        "¿Qué diferencias hay entre el plan {plan} y el enterprise?",
        "¿Hacéis descuento para {colectivo}? Somos {n}0 personas",
        "Queremos contratar el plan anual para {n}0 empleados, ¿hay oferta?",
        "¿El plan {plan} incluye soporte telefónico y SLA?",
        "Me interesa una demo del producto para mi equipo de {colectivo}",
        "¿Tenéis integración con SAP? Es condición para contratar",
        "Estamos comparando con la competencia, ¿me pasáis una propuesta?",
        "¿Puedo ampliar almacenamiento sin cambiar de plan {plan}?",
    ],
}

RELLENOS = {
    "mes": ["enero", "febrero", "marzo", "abril", "mayo", "junio"],
    "plan": ["premium", "profesional", "básico"],
    "pago": ["transferencia", "tarjeta de empresa", "domiciliación"],
    "seccion": ["informes", "el panel de control", "ajustes", "el visor de documentos"],
    "dispositivo": ["el móvil", "la tablet", "Windows", "el navegador Safari", "Mac"],
    "codigo": ["500", "403", "TIMEOUT-408", "ERR-CONN-21"],
    "colectivo": ["una ONG", "una universidad", "una startup", "un despacho de abogados"],
    "n": ["2", "3", "5"],
    "ref": ["2026-0193", "2026-0448", "2026-1102"],
}

# Palabras que disparan prioridad ALTA (regla de negocio de HelpDeskPro)
URGENTES = ["URGENTE", "toda la empresa", "hoy mismo", "sin poder acceder",
            "bloqueada", "revocarlo", "devolváis"]

# Ruido realista: coletillas, saludos, erratas leves
PREFIJOS = ["", "", "Hola, ", "Buenos días. ", "hola buenas, ", ""]
SUFIJOS = ["", "", " Gracias.", " Un saludo.", " Espero respuesta pronto.",
           " Es la segunda vez que escribo."]

def rellenar(plantilla):
    """Sustituye cada hueco {clave} por una opción aleatoria."""
    for clave, opciones in RELLENOS.items():
        while "{" + clave + "}" in plantilla:
            plantilla = plantilla.replace("{" + clave + "}",
                                          random.choice(opciones), 1)
    return plantilla

def prioridad_de(texto):
    """Prioridad alta si contiene señales de urgencia; si no, normal."""
    return "alta" if any(u.lower() in texto.lower() for u in URGENTES) else "normal"

def generar(n_por_clase=75, fichero="tickets.csv"):
    """Genera n_por_clase tickets por categoría y los guarda en CSV."""
    filas = []
    for categoria, plantillas in PLANTILLAS.items():
        for _ in range(n_por_clase):
            base = rellenar(random.choice(plantillas))          # cuerpo del ticket
            texto = random.choice(PREFIJOS) + base + random.choice(SUFIJOS)
            if random.random() < 0.1:                           # 10% en minúsculas
                texto = texto.lower()                           # (gente con prisa)
            filas.append({"texto": texto,
                          "categoria": categoria,
                          "prioridad": prioridad_de(texto)})
    random.shuffle(filas)                                       # barajar clases
    with open(fichero, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=["texto", "categoria", "prioridad"])
        w.writeheader(); w.writerows(filas)
    print(f"{len(filas)} tickets guardados en {fichero}")
    altas = sum(1 for r in filas if r["prioridad"] == "alta")
    print(f"prioridad alta: {altas} ({altas/len(filas):.0%})")

if __name__ == "__main__":
    generar()

Salida esperada:

300 tickets guardados en tickets.csv
prioridad alta: 62 (21%)

Advertencia (honestidad metodológica): este dataset viene de plantillas, así que un modelo puede aprender las plantillas en lugar del lenguaje. Para mitigar la contaminación entre splits, haz el split por plantilla cuando sea posible o, como mínimo, discute esta limitación en el README. Detectar y declarar este problema es un requisito (RB-2) — en el mundo real se llama data leakage por duplicados casi-idénticos y aparece constantemente.

Requisitos numerados (B)

# Requisito Detalle
RB-1 Datos Generar tickets.csv, explorarlo (distribución de clases y prioridades, longitudes) y hacer split train/test estratificado con semilla.
RB-2 Análisis de fugas Detectar y documentar la contaminación por plantillas; aplicar al menos una mitigación (split por plantilla o deduplicación aproximada).
RB-3 Baseline TF-IDF + regresión logística (módulo 02) para categoría. Métricas: accuracy, F1 macro y F1 por clase. Tiempo de entrenamiento e inferencia medidos.
RB-4 Fine-tuning DistilBERT multilingüe (u otro modelo compacto del Hub) con Trainer. Mismo split que el baseline. Guardar el modelo con número de versión (models/v1/).
RB-5 Prioridad Un segundo clasificador de prioridad (alta/normal). Se admite regla + modelo o dos cabezas; justifica la elección.
RB-6 Tabla de decisión Comparación honesta baseline vs transformer: métricas, tiempo de entrenamiento, latencia de inferencia por ticket, coste de infraestructura estimado y una recomendación argumentada (¿merece la pena el transformer?).
RB-7 API FastAPI con POST /classify (entrada/salida Pydantic validadas), GET /health (estado + versión del modelo cargado). Documentación automática en /docs funcionando.
RB-8 Logging Cada predicción se registra en logs/predicciones.jsonl (timestamp, texto truncado, categoría, prioridad, confianza, versión del modelo, latencia ms).
RB-9 Docker Dockerfile + docker-compose.yml: docker compose up levanta la API funcional.
RB-10 Tests ≥5 tests pytest: generación de datos, predictor (formato y clases válidas), API (/health, /classify correcto, /classify con entrada inválida → 422).
RB-11 README Según la plantilla incluida: problema, solución, resultados, cómo ejecutar, decisiones y limitaciones.
RB-12 Reproducibilidad Semillas, requirements.txt con versiones, y scripts ejecutables por separado (python -m src.train_baseline, etc.).

Esqueletos clave (B)

api/schemas.py (esqueleto)

# schemas.py — Contratos de entrada/salida de la API (Pydantic v2)
from pydantic import BaseModel, Field

class TicketIn(BaseModel):
    """Cuerpo de POST /classify."""
    texto: str = Field(min_length=5, max_length=2000,
                       description="Texto del ticket en español")
    # TODO: opcional: id_cliente, canal (email/web/chat) con valores permitidos

class TicketOut(BaseModel):
    """Respuesta de POST /classify."""
    categoria: str            # una de: facturacion, tecnico, cuenta, comercial
    prioridad: str            # alta | normal
    confianza: float          # probabilidad de la categoría elegida, [0, 1]
    version_modelo: str       # p. ej. "v1"
    # TODO: latencia_ms para observabilidad

class HealthOut(BaseModel):
    status: str               # "ok"
    version_modelo: str
    # TODO: uptime, nº de predicciones servidas

api/main.py (esqueleto)

# main.py — API de clasificación de tickets de HelpDeskPro
import json, time
from pathlib import Path
from fastapi import FastAPI
from api.schemas import TicketIn, TicketOut, HealthOut
from src.predictor import Predictor

app = FastAPI(title="HelpDeskPro Classifier", version="1.0.0")
predictor = Predictor("models/v1")            # carga el modelo UNA vez al arrancar
LOG = Path("logs/predicciones.jsonl")

@app.get("/health", response_model=HealthOut)
def health():
    # TODO: devolver status y versión del modelo cargado
    ...

@app.post("/classify", response_model=TicketOut)
def classify(ticket: TicketIn):
    t0 = time.perf_counter()
    # TODO: predecir categoría + confianza con el predictor
    # TODO: predecir/derivar prioridad
    # TODO: escribir línea JSON en LOG (texto truncado a 80 chars, sin datos sensibles)
    # TODO: devolver TicketOut
    ...

src/predictor.py (esqueleto)

# predictor.py — Única puerta de entrada a la inferencia (la usan API y tests)
class Predictor:
    def __init__(self, ruta_modelo: str):
        # TODO: cargar tokenizer y modelo desde ruta_modelo (una sola vez)
        # TODO: model.eval(); leer la versión de un fichero version.txt
        ...

    def predecir(self, texto: str) -> dict:
        # TODO: tokenizar, forward sin gradientes, softmax
        # TODO: devolver {"categoria": ..., "confianza": ..., "prioridad": ...}
        ...

Dockerfile y docker-compose.yml (esqueleto)

# Dockerfile — imagen de la API (CPU)
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
# TODO: instalar dependencias con pip (usa --no-cache-dir para imagen ligera)
# TODO: copiar src/, api/ y models/v1/
# TODO: EXPOSE 8000 y CMD con uvicorn api.main:app --host 0.0.0.0
# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - ./logs:/app/logs        # los logs sobreviven al contenedor
    # TODO: healthcheck que consulte GET /health

tests/test_api.py (esqueleto)

# test_api.py — Tests de la API con TestClient (no requiere servidor corriendo)
from fastapi.testclient import TestClient
from api.main import app

client = TestClient(app)

def test_health_ok():
    r = client.get("/health")
    assert r.status_code == 200
    assert r.json()["status"] == "ok"

def test_classify_ticket_valido():
    r = client.post("/classify", json={"texto": "me habéis cobrado dos veces este mes"})
    assert r.status_code == 200
    body = r.json()
    assert body["categoria"] in {"facturacion", "tecnico", "cuenta", "comercial"}
    assert 0.0 <= body["confianza"] <= 1.0

def test_classify_texto_vacio_devuelve_422():
    r = client.post("/classify", json={"texto": ""})
    assert r.status_code == 422              # Pydantic rechaza min_length=5

# TODO: test de prioridad alta con un texto urgente
# TODO: test de que el log crece tras una predicción
# TODO: test del generador de datos (300 filas, 4 clases balanceadas)

Plantilla de README profesional

# HelpDeskPro — Clasificador inteligente de tickets

Clasificación automática de tickets de soporte en español (4 categorías +
prioridad) servida por API REST. Fine-tuning de DistilBERT multilingüe,
comparado honestamente con un baseline TF-IDF.

## Resultados

| Modelo | F1 macro | Latencia/ticket (CPU) | Entrenamiento |
| ... completar con TUS números ... |

**Decisión:** [baseline / transformer] porque [argumento con números].

## Ejecutar
```bash
docker compose up          # API en http://localhost:8000/docs
pytest                     # tests
python -m src.train_transformer   # reentrenar

Arquitectura

[diagrama Mermaid o imagen]

Decisiones técnicas

  • Por qué DistilBERT multilingüe y no un modelo mayor: ...
  • Cómo se maneja la prioridad: ...
  • Contaminación por plantillas y mitigación: ...

Limitaciones y siguiente iteración

  • Dataset sintético: los números NO extrapolan a producción sin datos reales.
  • Sin reintentos ni rate-limiting en la API: ...
  • Monitorización de drift: propuesta en la sección siguiente. ```

Criterios de aceptación (B)

Datos y modelos

  • [ ] python data/generar_datos.py produce tickets.csv con 300 filas, 4 clases balanceadas y ~20% de prioridad alta.
  • [ ] El análisis de fugas por plantillas está documentado y mitigado (RB-2).
  • [ ] El baseline TF-IDF + logística está entrenado con el MISMO split que el transformer.
  • [ ] El fine-tuning alcanza F1 macro ≥ 0.90 en test (con este dataset sintético es alcanzable; si el split es por plantilla, ≥ 0.80).
  • [ ] Las métricas se reportan por clase, no solo agregadas.
  • [ ] El modelo se guarda versionado en models/v1/ con un version.txt.

Tabla de decisión

  • [ ] Incluye como mínimo: F1 macro, F1 de la peor clase, tiempo de entrenamiento, latencia por ticket en CPU, tamaño del artefacto en MB y coste de infra estimado.
  • [ ] Hay una recomendación explícita escrita ("elegimos X porque..."), no solo números.

API y servicio

  • [ ] uvicorn api.main:app arranca y /docs muestra los dos endpoints.
  • [ ] POST /classify con {"texto": "no puedo entrar desde el móvil"} devuelve categoría tecnico, prioridad, confianza y versión.
  • [ ] Entrada inválida (texto vacío o >2000 chars) devuelve 422 con detalle de Pydantic, no un 500.
  • [ ] GET /health responde en <100 ms e incluye la versión del modelo.
  • [ ] Cada predicción añade una línea JSON válida a logs/predicciones.jsonl.
  • [ ] El modelo se carga UNA vez al arrancar, no en cada petición.

Infraestructura y calidad

  • [ ] docker compose up levanta la API funcional y curl localhost:8000/health responde.
  • [ ] pytest pasa con ≥5 tests verdes que cubren datos, predictor y API.
  • [ ] index.md sigue la plantilla y contiene números reales tuyos.
  • [ ] Ninguna ruta absoluta ni credencial en el código; semillas fijadas.

Rúbrica de autoevaluación (B)

Dimensión 1 (insuficiente) 2 (aceptable) 3 (bueno) 4 (excelente)
Datos CSV generado y ya + exploración básica + fuga por plantillas detectada + mitigación implementada y medida
Modelado Solo transformer Baseline + transformer + métricas por clase y splits idénticos + análisis de errores cualitativo de ambos
Decisión "El transformer es mejor" sin datos Tabla con métricas Tabla completa con costes + recomendación matizada por escenario (volumen, latencia, presupuesto)
API Endpoint que funciona a veces /classify + /health correctos + validación Pydantic robusta y logging + carga única del modelo, latencia medida, manejo de errores
Ingeniería Sin tests ni Docker Docker O tests Docker Y ≥5 tests + healthcheck, compose con volúmenes, CI-ready
Comunicación Sin README útil README con instrucciones + resultados y decisiones + limitaciones honestas y plan de siguiente iteración

Interpretación: <12: incompleto para portfolio · 12-17: sólido · 18-21: notable · 22-24: nivel "contratable directamente".

Cómo presentarlo en una entrevista

Este proyecto está diseñado para sostener 20 minutos de conversación técnica. Prepara respuestas de 60-90 segundos a estas preguntas (son las que caen de verdad):

"¿Por qué fine-tuning y no llamar a un LLM por API (GPT/Claude/Gemini)?"

Respuesta fuerte: "Lo evalué como trade-off, no como dogma. Con 2.000 tickets/día, un LLM por API cuesta dinero por ticket y añade latencia y dependencia externa; mi modelo fine-tuneado corre en CPU propia con latencia de ~50 ms y coste marginal cero, y con 4 categorías estables alcanza F1 >0.9. Un LLM gana cuando las categorías cambian a menudo, hay pocos datos o se necesita extraer campos abiertos. De hecho propondría el LLM como fallback para tickets de baja confianza." — Esta última frase suele impresionar: arquitectura híbrida.

"¿Cómo manejas una categoría nueva, p. ej. 'partners'?"

"El modelo actual no puede predecirla: softmax sobre 4 clases cerradas. El proceso es: recolectar y etiquetar ejemplos de la nueva clase (el logging de predicciones con baja confianza ayuda a encontrarlos), reentrenar con 5 salidas, evaluar que las clases antiguas no degradan, y desplegar como v2 con el versionado que ya tengo en la API. Mientras tanto, un umbral de confianza deriva a triaje humano los tickets que no encajan."

"¿Qué es el drift y cómo lo detectarías aquí?"

"Dos tipos: cambia el lenguaje de entrada (nuevos productos, nuevas quejas) o cambia la relación texto→categoría. Como logueo cada predicción con confianza, monitorizaría (1) la distribución de confianzas — si la media baja, el modelo está viendo cosas raras—, (2) la distribución de categorías predichas frente a la histórica, y (3) una muestra semanal etiquetada a mano como ground truth para medir F1 real. Alarma → reentrenar con datos recientes."

"¿Por qué el baseline importa si el transformer gana?"

"Porque cuantifica el valor añadido. Si el baseline da 0.88 y el transformer 0.91, esos 3 puntos tienen que pagar el coste extra de GPU, complejidad y mantenimiento; a veces no lo pagan. Sin baseline no hay decisión de ingeniería, solo moda."

"¿Qué harías distinto con datos reales?"

"Split temporal en lugar de aleatorio (los tickets del futuro no deben filtrarse al train), análisis de duplicados, tratamiento de PII antes de loguear, calibración de las confianzas y una clase 'otros' para el mundo real, que nunca son 4 categorías limpias."

Consejo profesional: lleva la demo en vivo: docker compose up, abre /docs, clasifica un ticket delante del entrevistador y enseña la línea que apareció en el log. Una demo de 90 segundos vale más que 10 diapositivas.

Errores típicos del proyecto

Error Consecuencia Cómo evitarlo
Cargar el modelo dentro del endpoint /classify Latencia de 5-30 s por petición Cárgalo en el arranque de la app (variable global o lifespan de FastAPI)
Comparar baseline y transformer con splits distintos Comparación inválida; el CTO no se fía Genera el split UNA vez, guárdalo (índices o CSVs) y compártelo
F1 = 1.00 y celebrarlo Contaminación por plantillas no detectada Split por plantilla; sospecha SIEMPRE de métricas perfectas
Loguear el texto completo del ticket Riesgo RGPD con datos reales Trunca a 80 chars y documenta la política en el README
docker build copia models/ de 500 MB en cada cambio de código Builds eternos Ordena las capas: requirements → modelo → código; usa .dockerignore
Tests que llaman al Hub de Hugging Face CI lenta y frágil (falla sin red) Testea el predictor con el modelo local ya descargado; marca tests de red con @pytest.mark.slow
Devolver 500 ante entrada inválida El backend cliente no sabe qué corregir Deja que Pydantic valide: 422 con detalle es el contrato correcto
Olvidar model.eval() en el predictor Predicciones no deterministas (dropout activo) model.eval() en __init__ del Predictor + test de determinismo

Qué has demostrado → habilidad de empleo

Si has completado ambos proyectos, esto es lo que puedes defender en un CV o entrevista, traducido al idioma de las ofertas de trabajo:

Qué has hecho Habilidad de empleo (como aparece en ofertas)
Backprop y gradient checking a mano (LAB 1) "Comprensión profunda de fundamentos de deep learning" — te distingue de quien solo pega código
Training loop con early stopping, checkpoints y curvas "Experiencia entrenando y depurando redes neuronales con PyTorch"
Comparación MLP vs CNN vs transfer learning con protocolo limpio "Diseño y evaluación rigurosa de experimentos de ML"
Matriz de confusión + inspección de errores + informe "Análisis de errores y comunicación de resultados a negocio"
Mini-GPT desde cero (atención causal, generación) "Conocimiento de arquitecturas Transformer" — la pregunta técnica estrella de 2026
Fine-tuning con Hugging Face sobre datos propios "Fine-tuning de modelos de lenguaje preentrenados (Transformers, HF)"
Baseline honesto + tabla de decisión con costes "Criterio de ingeniería: elegir la solución adecuada, no la más moderna"
API FastAPI + Pydantic + logging + versionado de modelo "Puesta en producción de modelos de ML (MLOps básico)"
Docker + docker-compose + tests pytest "Buenas prácticas de ingeniería de software aplicadas a ML"
Respuestas preparadas sobre drift, categorías nuevas, LLM vs fine-tuning "Visión de ciclo de vida completo de un sistema de ML en producción"

Caso empresarial (el meta-caso): el proyecto B entero es, casi literalmente, una prueba técnica real de las que las empresas mandan para puestos de ML engineer junior/mid en 2026. Haberla hecho con calma, con tests y con criterio, es tu ventaja.


Conexión con el módulo 04-LLM

Cierra los ojos y mira lo que acabas de construir en el LAB 4: embeddings + atención causal + predicción del siguiente token + muestreo con temperatura. Eso ES un LLM. GPT-4, Claude o Llama son tu mini-GPT con más capas, más datos, más trucos de entrenamiento y una fase de alineamiento encima — pero ya no hay magia dentro de la caja para ti.

En el módulo 04 cambias de rol: de construir el motor a explotarlo profesionalmente — prompting sistemático, APIs de LLMs, RAG (donde reutilizarás los embeddings del LAB 5 tal cual), agentes y evaluación. La diferencia entre un usuario de LLMs y un ingeniero de LLMs es que el segundo sabe qué hay dentro. Tú ya lo sabes.

Nota: antes de saltar al módulo 04, asegúrate de que el proyecto B está en tu GitHub con el README pulido. Los proyectos de los módulos 04-06 se apoyarán en él (le añadirás un agente de triaje con LLM como capa superior: la arquitectura híbrida de la que hablaste en la sección de entrevista).


Anterior Módulo Siguiente
Laboratorios 03-DEEP-LEARNING Módulo 04: LLM