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¶
- MINI PROYECTO A — Clasificador de imágenes de producto (ModaExpress)
- Contexto empresarial
- Especificación y requisitos
- Arquitectura
- Esqueleto con TODOs
- Código de referencia completo
- Criterios de aceptación
- Rúbrica de autoevaluación
- PROYECTO PROFESIONAL B — Clasificador inteligente de tickets + API (HelpDeskPro)
- Contexto empresarial
- Arquitectura
- Estructura de carpetas
- Plan por fases
- Generador del dataset (completo)
- Requisitos numerados
- Esqueletos clave
- Criterios de aceptación
- Rúbrica
- Cómo presentarlo en una entrevista
- Errores típicos
- Qué has demostrado → habilidad de empleo
- 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")
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.pyejecuta 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.mdcontiene ≥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.txtpresente 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:
- "Quiero una comparación honesta con un baseline simple. Si TF-IDF + regresión logística funciona igual, no pago GPUs."
- "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:
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.pyproducetickets.csvcon 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 unversion.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:apparranca y/docsmuestra los dos endpoints. - [ ]
POST /classifycon{"texto": "no puedo entrar desde el móvil"}devuelve categoríatecnico, prioridad, confianza y versión. - [ ] Entrada inválida (texto vacío o >2000 chars) devuelve 422 con detalle de Pydantic, no un 500.
- [ ]
GET /healthresponde 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 uplevanta la API funcional ycurl localhost:8000/healthresponde. - [ ]
pytestpasa con ≥5 tests verdes que cubren datos, predictor y API. - [ ]
index.mdsigue 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).
Navegación¶
| Anterior | Módulo | Siguiente |
|---|---|---|
| Laboratorios | 03-DEEP-LEARNING | Módulo 04: LLM |