Capítulo 7 — ML en producción: del notebook al servicio real¶
Módulo 02 — MACHINE LEARNING · AI Master Academy
"Un modelo que no está en producción es una opinión cara." — proverbio anónimo de todo equipo de datos que ha sufrido.
Has llegado al último capítulo teórico del módulo. Sabes entrenar pipelines completos de scikit-learn, validarlos con cross-validation y exprimir el último punto de AUC con gradient boosting. Enhorabuena: eso te coloca por delante del 90 % de los tutoriales de internet.
Y sin embargo, todavía no has creado nada que genere valor. Un modelo dentro de un notebook es como un motor de Fórmula 1 encima de una mesa: impresionante, pero no lleva a nadie a ningún sitio. Este capítulo trata de montar ese motor en un coche que circule por carreteras reales, con baches, lluvia y conductores impredecibles.
Al terminar este capítulo sabrás:
- Por qué la mayoría de los modelos mueren antes de llegar a producción y cómo evitar que el tuyo sea uno de ellos.
- Serializar correctamente un pipeline (y por qué guardar "solo el modelo" es un error clásico).
- Elegir entre serving batch, API en tiempo real o streaming.
- Construir una API REST completa con FastAPI, explicada línea a línea.
- Empaquetarla con Docker y orquestarla con docker-compose (incluyendo una caché Redis).
- Detectar data drift y concept drift con métricas como el PSI, y diseñar una política de reentrenamiento.
- Testear sistemas de ML como un profesional.
- Situarte en el mapa de MLOps sin comprarte humo.
Índice¶
- La brecha entre el notebook y producción
- Serialización: congelar el modelo sin romperlo
- Patrones de serving: ¿cómo entrego predicciones?
- API REST con FastAPI
- Docker: empaquetar el servicio
- Monitorización y drift: la sección estrella
- Testing de sistemas de ML
- MLOps: panorámica honesta
- Ejemplo integrador: el modelo de churn a producción
- Caso empresarial: la degradación silenciosa
- Buenas prácticas
- Malas prácticas
- Errores comunes
- FAQ — Preguntas frecuentes
- Resumen del capítulo
- Bibliografía y recursos
1. La brecha entre el notebook y producción¶
1.1 La estadística tristemente célebre¶
Se repite en conferencias, informes de Gartner y estudios de VentureBeat desde hace años: alrededor del 80 % de los modelos de machine learning nunca llegan a producción. El número exacto varía según el estudio (algunos dicen 85 %, otros 87 %), pero el mensaje es consistente y demoledor: la mayoría del trabajo de ciencia de datos termina en un notebook que nadie vuelve a ejecutar.
¿Por qué? Porque entrenar un modelo y operar un modelo son disciplinas distintas. El notebook es un laboratorio controlado; producción es la selva.
1.2 Qué cambia cuando sales del notebook¶
| Dimensión | En el notebook | En producción |
|---|---|---|
| Datos | CSV limpio, estático, ya explorado | Peticiones en vivo con nulos, tipos raros, categorías nunca vistas, encodings rotos |
| Latencia | Da igual: predict() tarda lo que tarde |
Un e-commerce exige respuesta en < 100 ms o pierde ventas |
| Carga | 1 usuario (tú) | Cientos o miles de peticiones por segundo, picos en Black Friday |
| Versiones | "El modelo" es la variable model en memoria |
Múltiples versiones conviviendo, rollbacks, trazabilidad de qué versión respondió qué |
| Errores | Un traceback rojo que tú lees y corriges | Un error a las 3 AM que nadie ve hasta que un cliente se queja |
| Entorno | Tu máquina, tus librerías, tu suerte | Servidores que deben reproducir exactamente tu entorno |
| Ciclo de vida | Termina cuando presentas los resultados | Empieza cuando despliegas: el mundo cambia y el modelo envejece |
| Responsabilidad | "Funciona en mi máquina" | SLAs, auditorías, regulación, dinero real en juego |
Nota
fíjate en la última fila de la tabla. La diferencia más profunda no es técnica sino de mentalidad: en el notebook el modelo es el producto final; en producción es el punto de partida de un sistema vivo que hay que vigilar, mantener y reentrenar.
1.3 El ciclo de vida completo de un modelo¶
Este es el mapa del territorio que recorreremos. Guárdalo mentalmente: cada sección del capítulo es una etapa de este diagrama.
flowchart TD
A[Datos históricos] --> B[Entrenamiento del pipeline<br/>caps. 1-6 del módulo]
B --> C[Evaluación offline<br/>CV, hold-out, métricas]
C -->|métricas OK|D[Serialización<br/>joblib + versionado]
C -->|métricas insuficientes|B
D --> E[Empaquetado<br/>FastAPI + Docker]
E --> F[Tests automáticos<br/>pipeline, API, humo]
F -->|tests pasan|G[Despliegue<br/>servidor / cloud]
F -->|tests fallan|E
G --> H[Serving<br/>predicciones reales]
H --> I[Monitorización<br/>latencia, errores, drift]
I -->|todo estable|H
I -->|drift detectado|J[Alerta al equipo]
J --> K[Reentrenamiento<br/>con datos frescos]
K --> C
style B fill:#1f6feb,color:#fff
style H fill:#238636,color:#fff
style I fill:#d29922,color:#000
style J fill:#da3633,color:#fff
Observa dos cosas cruciales:
- Es un ciclo, no una línea. La flecha de reentrenamiento vuelve a evaluación. Un modelo en producción nunca está "terminado".
- La monitorización es un nodo central, no un adorno. Sin ella, el ciclo se rompe y el modelo se degrada en silencio (lo veremos con dolor en el caso empresarial final).
Consejo profesional
cuando te propongan un proyecto de ML, estima el esfuerzo así: 20 % modelado, 80 % todo lo demás (datos, infraestructura, monitorización, mantenimiento). Si el plan del proyecto solo contempla el modelado, ese proyecto va camino de engrosar el famoso 80 % de modelos muertos.
Ejercicio rápido 1¶
Tu jefe te dice: "El modelo da 0.92 de AUC en el notebook, despliégalo el viernes y pasamos a otro proyecto". Enumera al menos 4 riesgos concretos de ese plan.
Ver solución
1. **Sin validación con datos reales de producción:** el 0.92 se midió con datos históricos limpios; las peticiones reales traerán nulos, categorías nuevas y formatos inesperados que pueden romper el pipeline o degradar la métrica. 2. **Sin monitorización:** "pasamos a otro proyecto" significa que nadie vigilará el drift. El modelo se degradará silenciosamente y nadie lo sabrá hasta que el negocio lo note (tarde y caro). 3. **Sin plan de reentrenamiento:** los datos del mundo cambian; sin política de actualización, el modelo tiene fecha de caducidad desconocida. 4. **Despliegue en viernes:** si algo falla, se descubre el fin de semana sin equipo disponible. (Regla no escrita de la industria: *read-only Friday*.) 5. (Bonus) **Sin tests ni rollback:** si la nueva versión falla, ¿cómo se vuelve a la anterior? ¿Quién responde a las 3 AM?2. Serialización: congelar el modelo sin romperlo¶
Serializar es convertir un objeto vivo de Python (tu pipeline entrenado, con todos sus pesos y parámetros) en una secuencia de bytes que puede guardarse en disco y reconstruirse después, en otra máquina, en otro momento. Es la primera frontera entre el notebook y el mundo real.
2.1 pickle vs joblib¶
Python trae de serie el módulo pickle, que serializa casi cualquier objeto. joblib es una librería (dependencia de scikit-learn) que usa el mismo protocolo por debajo pero está optimizada para objetos con grandes arrays de NumPy, que es exactamente lo que es un modelo de ML.
| Criterio | pickle |
joblib |
|---|---|---|
| Incluido en Python | Sí, stdlib | No (pero viene con sklearn) |
| Eficiencia con arrays NumPy grandes | Regular | Excelente (memmapping, compresión) |
| Compresión integrada | Manual (gzip aparte) | compress=3 en una línea |
| API | pickle.dump(obj, f) |
joblib.dump(obj, path) (más simple) |
| Recomendado para modelos sklearn | No especialmente | Es la recomendación oficial de sklearn |
| Seguridad al cargar | Ejecuta código arbitrario | Igual de inseguro (usa pickle debajo) |
import joblib
# Guardar: una sola línea. compress=3 equilibra tamaño y velocidad (0-9).
joblib.dump(pipeline, "modelo_churn.joblib", compress=3)
# Cargar: reconstruye el objeto exacto, listo para .predict()
pipeline = joblib.load("modelo_churn.joblib")
Advertencia (crítica de seguridad)
tanto pickle como joblib pueden ejecutar código Python arbitrario al deserializar. Un fichero .joblib malicioso puede contener instrucciones que se ejecutan en el momento de hacer load() — borrar archivos, robar credenciales, abrir una shell remota. Regla de oro: nunca cargues un artefacto serializado de una fuente que no controles. Ni descargado de un foro, ni recibido por email, ni de un bucket S3 con permisos de escritura públicos. Trata los .joblib como tratas los ejecutables: solo de origen confiable y verificado (por ejemplo, con un hash SHA-256 registrado). Para compartir modelos entre organizaciones existen formatos seguros como ONNX o el formato skops, que no ejecutan código al cargar.
2.2 Qué guardar exactamente: el pipeline ENTERO¶
Error clásico de principiante: guardar solo el estimador final (model = pipeline.named_steps["clf"]). Al cargarlo en producción, las peticiones llegan crudas (con categorías de texto, escalas originales, nulos) y el modelo espera datos ya transformados. Resultado: o excepciones, o —mucho peor— predicciones silenciosamente absurdas.
# ❌ MAL: guardar solo el modelo final
joblib.dump(pipeline.named_steps["clf"], "solo_modelo.joblib")
# En producción: ¿quién escala? ¿quién codifica las categorías?
# ¿con qué medias y qué vocabulario? Nadie. Desastre.
# ✅ BIEN: guardar el pipeline completo (preprocesado + modelo)
joblib.dump(pipeline, "pipeline_completo.joblib")
# En producción: pipeline.predict(datos_crudos) reproduce EXACTAMENTE
# las mismas transformaciones aprendidas en entrenamiento.
Nota
el pipeline serializado contiene el estado aprendido de cada paso: las medias y desviaciones del StandardScaler, el vocabulario del OneHotEncoder, los valores de imputación del SimpleImputer… Todo eso es tan parte del modelo como los pesos del clasificador. Por eso en los capítulos anteriores insistimos tanto en meter TODO el preprocesado dentro del Pipeline: no era manía académica, era preparación para este momento.
2.3 Versionado de artefactos¶
En producción convivirán varias versiones del modelo (la actual, la anterior por si hay rollback, la candidata en pruebas). Necesitas una convención de nombres que responda de un vistazo a: ¿qué modelo es?, ¿cuándo se entrenó?, ¿cómo de bueno es?
Convención recomendada: <nombre>_<version>_<fecha>_<métrica>.joblib
modelos/
├── churn_v1.3.0_2026-06-15_auc0.891.joblib # producción actual
├── churn_v1.2.0_2026-04-02_auc0.874.joblib # anterior (rollback)
└── churn_v1.4.0-rc1_2026-07-01_auc0.902.joblib # candidata
Y junto a cada artefacto, un fichero de metadatos en JSON:
import json
from datetime import datetime, timezone
import sklearn, numpy, pandas
# Metadatos: todo lo necesario para auditar y reproducir el artefacto.
metadata = {
"model_name": "churn", # qué problema resuelve
"version": "1.3.0", # versionado semántico
"trained_at": datetime.now(timezone.utc).isoformat(), # cuándo, en UTC
"metrics": {"auc": 0.891, "f1": 0.63}, # rendimiento en test hold-out
"training_rows": 48_213, # tamaño del dataset de entrenamiento
"features": ["antiguedad_meses", "plan", "cargos_mensuales", "num_tickets"],
"sklearn_version": sklearn.__version__, # ¡crucial para compatibilidad!
"numpy_version": numpy.__version__,
"pandas_version": pandas.__version__,
"random_state": 42, # semilla usada
"git_commit": "a3f9c21", # commit del código de entrenamiento
}
with open("modelos/churn_v1.3.0_metadata.json", "w", encoding="utf-8") as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
2.4 Reproducibilidad¶
Un artefacto sin entorno reproducible es una bomba de relojería. Tres reglas:
- Fija
random_stateen todo lo que tenga aleatoriedad (splits, modelos, SMOTE…). Sin semilla fija, reentrenar "el mismo modelo" da un modelo distinto y no puedes distinguir un bug de ruido aleatorio. - Congela las versiones de librerías. Un pipeline serializado con scikit-learn 1.5 puede fallar (o comportarse sutilmente distinto) al cargarse con la 1.7. sklearn incluso emite un
InconsistentVersionWarningsi detecta la discrepancia — nunca lo ignores. - Versiona el código de entrenamiento en git y guarda el hash del commit en los metadatos (como arriba). Así siempre puedes reconstruir el artefacto exacto.
# requirements.txt — versiones EXACTAS (==), no rangos (>=)
scikit-learn==1.7.0
pandas==2.3.0
numpy==2.3.1
joblib==1.5.1
fastapi==0.115.14
uvicorn[standard]==0.35.0
pydantic==2.11.7
redis==6.2.0
Consejo profesional
genera el fichero con pip freeze > requirements.txt en el entorno donde entrenaste, y luego límpialo dejando solo dependencias directas con versión exacta. Para proyectos serios, herramientas como pip-tools o uv gestionan esto con ficheros de lock.
Ejercicio rápido 2¶
Tu compañero te pasa por Slack un modelo_final_v2_DEFINITIVO.pkl que descargó "de un repo de GitHub que ya no recuerda". ¿Qué haces y por qué?
Ver solución
**No lo cargas.** Un fichero pickle de origen desconocido puede ejecutar código arbitrario en el `load()`. Además: (a) no sabes con qué versiones de librerías se creó, así que puede fallar o comportarse mal; (b) no tiene metadatos (métricas, features esperadas, fecha), así que no es auditable; (c) el nombre "v2_DEFINITIVO" es un anti-patrón de versionado. Lo correcto: localizar el código fuente original, reentrenar en un entorno controlado con semilla fija, y generar tu propio artefacto versionado con metadatos. Si es imprescindible inspeccionarlo, hazlo en una sandbox aislada (contenedor sin red) — nunca en tu máquina de trabajo.3. Patrones de serving: ¿cómo entrego predicciones?¶
Antes de escribir una sola línea de API, hay que responder una pregunta de negocio: ¿cuándo y cómo se necesitan las predicciones? La respuesta determina la arquitectura entera.
3.1 Los tres patrones¶
| Patrón | Cómo funciona | Latencia típica | Complejidad | Ejemplo de negocio |
|---|---|---|---|---|
| Batch (por lotes) | Un job programado (p. ej. cada noche a las 2 AM) puntúa miles/millones de filas y escribe los resultados en la base de datos | Horas (no importa) | Baja | Scoring nocturno de churn de toda la cartera de clientes; el equipo de retención lee la tabla por la mañana |
| API REST en tiempo real | Un servicio HTTP siempre encendido responde predicciones bajo demanda, una petición a la vez | 10–200 ms | Media | Scoring de fraude en el momento del pago; precio dinámico al cargar la página del producto |
| Streaming | El modelo consume eventos de un bus (Kafka, Kinesis) y emite predicciones de forma continua | Sub-segundo, continuo | Alta | Detección de anomalías en telemetría IoT; recomendaciones que se actualizan con cada clic |
3.2 Cuándo elegir cada uno¶
Elige batch cuando:
- Las predicciones no dependen de información del último segundo.
- El consumidor es un proceso interno (un dashboard, una campaña de email, un equipo humano).
- Ejemplo: predecir churn. El riesgo de fuga de un cliente no cambia entre las 14:00 y las 14:05. Puntuar toda la cartera cada noche es más simple, más barato y más robusto que mantener una API 24/7.
Elige API REST cuando:
- La predicción necesita datos que solo existen en el momento de la petición (el importe de ESTA transacción, el carrito de ESTE usuario).
- Un sistema externo (web, app móvil, otro microservicio) necesita la respuesta de forma síncrona para decidir qué hacer a continuación.
- Ejemplo: aprobar o rechazar un pago. No puedes decirle al cliente "vuelva mañana cuando corra el batch".
El streaming (que solo mencionamos aquí; se estudia en módulos avanzados de arquitectura) tiene sentido cuando hay un flujo continuo de eventos de alto volumen y la predicción debe reaccionar a cada evento con contexto acumulado. Es el patrón más potente y el más caro de operar: requiere infraestructura de mensajería (Kafka), gestión de estado y equipos con experiencia.
Consejo profesional
la pregunta correcta no es "¿qué patrón es más moderno?" sino "¿cuál es el patrón MÁS SIMPLE que satisface el requisito de negocio?". Muchísimas empresas mantienen APIs en tiempo real (con su coste de infraestructura y guardias) para casos de uso que un batch nocturno resolvería igual de bien por una décima parte del coste. Empieza por batch; sube a tiempo real solo cuando el negocio lo exija de verdad.
Nota
en este capítulo construiremos una API REST porque es el patrón que más conceptos enseña (validación, latencia, contenedores, salud del servicio) y porque el batch, una vez entiendes la API, es trivial: un script con pipeline.predict(df) y un INSERT a la base de datos, lanzado por un scheduler (cron, Airflow).
4. API REST con FastAPI¶
Llegamos al núcleo práctico. Vamos a construir un servicio HTTP completo que sirve predicciones del modelo de churn. Como es la primera vez que ves FastAPI en esta academia, explicaremos cada línea. (El módulo 10-BACKEND profundiza muchísimo más; aquí damos lo esencial, autocontenido.)
4.1 Qué es FastAPI y por qué lo usamos¶
FastAPI es un framework de Python para construir APIs HTTP. Se ha convertido en el estándar de facto para servir modelos de ML por tres razones:
- Rendimiento: es asíncrono por diseño (basado en Starlette y uvicorn) y está entre los frameworks Python más rápidos, comparable a frameworks de Node.js o Go en muchos benchmarks.
- Validación automática: se integra con Pydantic, que valida cada petición contra un esquema tipado. Si un cliente envía
"antiguedad_meses": "hola", FastAPI lo rechaza con un error claro antes de que llegue a tu modelo. En producción esto es oro puro. - Documentación automática: genera una interfaz web interactiva (
/docs, basada en Swagger/OpenAPI) donde cualquiera puede explorar y probar tu API sin escribir código.
Conceptos mínimos de HTTP que necesitas (si vienes de cero):
- Una API REST expone URLs (llamadas endpoints) a las que otros programas envían peticiones.
- GET pide información (leer); POST envía datos para que el servidor haga algo con ellos (en nuestro caso: predecir).
- Los datos viajan en formato JSON (texto estructurado con claves y valores).
- Cada respuesta lleva un código de estado:
200= OK,422= datos de entrada inválidos,500= error interno del servidor.
4.2 Instalación¶
fastapies el framework; el extra[standard]añade utilidades comunes.uvicornes el servidor ASGI: el programa que escucha en un puerto de red, recibe las peticiones HTTP y se las pasa a tu aplicación FastAPI. FastAPI define qué responder; uvicorn se encarga de escuchar y transportar.
4.3 La aplicación completa, línea por línea¶
Crea un fichero app/main.py. Primero el código completo; después lo diseccionamos bloque a bloque.
"""API de predicción de churn — AI Master Academy, Módulo 02, Capítulo 7."""
import logging # logging estándar de Python
import time # para medir latencia de cada petición
from contextlib import asynccontextmanager # para el ciclo de vida (lifespan)
from typing import Literal # para restringir valores de un campo
import joblib # para cargar el pipeline serializado
import pandas as pd # el pipeline espera un DataFrame
from fastapi import FastAPI, HTTPException, Request # piezas centrales de FastAPI
from pydantic import BaseModel, Field # esquemas de validación de datos
# ---------------------------------------------------------------------------
# 1. LOGGING: configuramos un logger con formato de timestamp + nivel + mensaje
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO, # registra INFO y superiores
format="%(asctime)s | %(levelname)s | %(message)s", # formato legible
)
logger = logging.getLogger("churn-api") # logger con nombre propio
# ---------------------------------------------------------------------------
# 2. ESTADO GLOBAL: aquí vivirá el pipeline una vez cargado.
# Usamos un dict para poder mutarlo desde el lifespan sin 'global'.
# ---------------------------------------------------------------------------
ml_artifacts = {} # {"pipeline": ..., "version": ...}
MODEL_PATH = "modelos/churn_v1.3.0_2026-06-15_auc0.891.joblib"
MODEL_VERSION = "1.3.0"
# ---------------------------------------------------------------------------
# 3. LIFESPAN: código que se ejecuta UNA VEZ al arrancar y UNA VEZ al apagar.
# Cargar el modelo aquí (y no en cada petición) es fundamental:
# joblib.load() puede tardar segundos; hacerlo por petición mataría la latencia.
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
# --- ARRANQUE: todo lo anterior al 'yield' corre antes de aceptar tráfico ---
logger.info("Cargando pipeline desde %s ...", MODEL_PATH)
ml_artifacts["pipeline"] = joblib.load(MODEL_PATH) # carga el pipeline ENTERO
ml_artifacts["version"] = MODEL_VERSION
logger.info("Pipeline v%s cargado. API lista.", MODEL_VERSION)
yield # <- aquí la API sirve tráfico
# --- APAGADO: todo lo posterior al 'yield' corre al detener el servidor ---
ml_artifacts.clear() # libera memoria ordenadamente
logger.info("Recursos liberados. API detenida.")
# ---------------------------------------------------------------------------
# 4. LA APLICACIÓN: el objeto central de FastAPI.
# ---------------------------------------------------------------------------
app = FastAPI(
title="Churn Prediction API", # aparece en la documentación /docs
description="Predice la probabilidad de fuga de un cliente.",
version=MODEL_VERSION, # versión visible en /docs
lifespan=lifespan, # conecta el ciclo de vida definido arriba
)
# ---------------------------------------------------------------------------
# 5. ESQUEMA DE ENTRADA (Pydantic): el contrato de datos de la API.
# Cada campo declara tipo y restricciones; FastAPI valida AUTOMÁTICAMENTE
# cada petición contra este esquema y rechaza con 422 lo que no cumpla.
# ---------------------------------------------------------------------------
class ClienteInput(BaseModel):
# ge=0: 'greater or equal' — la antigüedad no puede ser negativa.
# le=600: nadie lleva 50 años de cliente; si llega 700, es un dato corrupto.
antiguedad_meses: int = Field(ge=0, le=600,
description="Meses como cliente")
# Literal restringe a EXACTAMENTE estos tres valores. Si en producción
# llega "premium " (con espacio) o "PREMIUM", se rechaza en la puerta
# en vez de convertirse en una categoría desconocida dentro del encoder.
plan: Literal["basico", "estandar", "premium"] = Field(
description="Plan contratado")
# gt=0: 'greater than' — un cargo mensual de 0 o negativo es imposible.
cargos_mensuales: float = Field(gt=0, le=10_000,
description="Cuota mensual en euros")
# Tickets de soporte en el último año: entero no negativo y acotado.
num_tickets: int = Field(ge=0, le=500,
description="Tickets de soporte último año")
# Ejemplo que aparecerá precargado en la documentación /docs:
model_config = {
"json_schema_extra": {
"examples": [{
"antiguedad_meses": 8,
"plan": "basico",
"cargos_mensuales": 49.90,
"num_tickets": 5,
}]
}
}
# ---------------------------------------------------------------------------
# 6. ESQUEMA DE SALIDA: también tipamos la respuesta. Documenta el contrato
# y evita filtrar campos internos por accidente.
# ---------------------------------------------------------------------------
class PrediccionOutput(BaseModel):
churn_probability: float # probabilidad entre 0 y 1
churn_predicted: bool # decisión con umbral 0.5
model_version: str # trazabilidad: qué versión respondió
latency_ms: float # cuánto tardó la predicción
# ---------------------------------------------------------------------------
# 7. ENDPOINT DE SALUD: los orquestadores (Docker, Kubernetes, balanceadores)
# lo consultan periódicamente para saber si la instancia está viva.
# ---------------------------------------------------------------------------
@app.get("/health")
def health():
# Si el pipeline no está cargado, la instancia NO está sana:
# el balanceador debe dejar de enviarle tráfico.
if "pipeline" not in ml_artifacts:
raise HTTPException(status_code=503, detail="Modelo no cargado")
return {"status": "ok", "model_version": ml_artifacts["version"]}
# ---------------------------------------------------------------------------
# 8. ENDPOINT DE PREDICCIÓN: el corazón de la API.
# - Método POST porque el cliente ENVÍA datos.
# - response_model documenta y valida la salida.
# ---------------------------------------------------------------------------
@app.post("/predict", response_model=PrediccionOutput)
def predict(cliente: ClienteInput, request: Request):
# Cuando esta función se ejecuta, FastAPI YA validó el JSON de entrada
# contra ClienteInput. Si algo no cuadraba, el cliente recibió un 422
# con el detalle del error y esta función ni siquiera se llamó.
t0 = time.perf_counter() # cronómetro de latencia
try:
# model_dump() convierte el objeto Pydantic en dict; lo envolvemos
# en una lista para crear un DataFrame de UNA fila, que es el
# formato que espera el pipeline de sklearn.
df = pd.DataFrame([cliente.model_dump()])
# predict_proba devuelve [[p_no_churn, p_churn]]; tomamos la
# probabilidad de la clase positiva (columna 1) de la fila 0.
proba = float(ml_artifacts["pipeline"].predict_proba(df)[0, 1])
except Exception as exc:
# Si el pipeline explota (nunca debería si los tests están bien,
# pero producción es producción), registramos el error COMPLETO
# en los logs y devolvemos un 500 genérico al cliente.
# NUNCA devuelvas el traceback al cliente: filtra información interna.
logger.exception("Error durante la predicción: %s", exc)
raise HTTPException(status_code=500,
detail="Error interno del modelo") from exc
latency_ms = (time.perf_counter() - t0) * 1000 # segundos -> milisegundos
# LOG ESTRUCTURADO de la petición: imprescindible para auditoría y
# para la monitorización de drift (sección 6). Registramos entrada,
# salida, versión y latencia. La IP ayuda a investigar abusos.
logger.info(
"predict | input=%s | proba=%.4f | version=%s | %.1f ms | ip=%s",
cliente.model_dump(), proba, ml_artifacts["version"],
latency_ms, request.client.host,
)
return PrediccionOutput(
churn_probability=round(proba, 4), # 4 decimales bastan
churn_predicted=proba >= 0.5, # umbral de decisión
model_version=ml_artifacts["version"], # trazabilidad
latency_ms=round(latency_ms, 1),
)
4.4 Disección de los puntos clave¶
El lifespan (bloque 3). Es la respuesta a la pregunta "¿cuándo cargo el modelo?". Las tres opciones y por qué solo una es correcta:
| Opción | Problema |
|---|---|
| Cargar en cada petición | joblib.load() tarda de décimas a segundos → latencia inaceptable y disco machacado |
| Cargar a nivel de módulo (al importar) | Funciona, pero se ejecuta también al importar el módulo en tests, y no hay hook de limpieza al apagar |
Cargar en el lifespan |
Se ejecuta exactamente una vez al arrancar, antes de aceptar tráfico; el yield separa arranque de apagado; los tests pueden sustituirlo |
La validación Pydantic (bloque 5) es tu primera línea de defensa. Repasemos campo a campo por qué cada restricción importa en producción:
antiguedad_meses: int, ge=0, le=600— sin elge=0, un bug del cliente que envíe-3entraría al modelo y produciría una predicción sin sentido sin que nadie se entere. Elle=600corta valores absurdos (¿600 meses = 50 años de cliente? sospechoso) que suelen delatar datos corruptos aguas arriba.plan: Literal[...]— los encoders de sklearn tratan las categorías desconocidas según su configuración (handle_unknown="ignore"las convierte en todo ceros). Eso significa que"Premium"con mayúscula se puntuaría como si el cliente no tuviera plan, distorsionando la predicción en silencio.Literalconvierte ese fallo silencioso en un error422visible e inmediato.cargos_mensuales: float, gt=0— un cargo de0o negativo indica un bug del sistema que llama; mejor rechazarlo que puntuarlo.num_tickets: int, ge=0, le=500— misma lógica: acotar el universo de lo físicamente posible.
Nota
la filosofía es fail fast, fail loud (falla pronto y con ruido). En ML, los errores silenciosos son los más caros: un modelo que devuelve predicciones basura con código 200 es mucho peor que uno que devuelve 422, porque el segundo se detecta en minutos y el primero en meses.
El manejo de errores (bloque 8). Distingue dos familias:
422 Unprocessable Entity— culpa del cliente: envió datos que no cumplen el esquema. FastAPI lo genera solo, con un JSON que detalla exactamente qué campo falló y por qué. Tú no escribes nada.500 Internal Server Error— culpa nuestra: el pipeline lanzó una excepción. La capturamos, la logueamos completa (para nosotros) y devolvemos un mensaje genérico (para el cliente). Exponer el traceback al exterior es un agujero de seguridad: revela rutas, versiones y estructura interna.
4.5 Arrancar y probar el servicio¶
# Arranca el servidor de desarrollo con recarga automática al editar código.
# app.main:app significa: fichero app/main.py, variable 'app'.
uvicorn app.main:app --reload --port 8000
Probar con curl (el navegador solo hace GET; para POST usamos curl):
# Petición válida:
curl -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{"antiguedad_meses": 8, "plan": "basico", "cargos_mensuales": 49.9, "num_tickets": 5}'
Respuesta:
{
"churn_probability": 0.7431,
"churn_predicted": true,
"model_version": "1.3.0",
"latency_ms": 4.2
}
# Petición INVÁLIDA (plan inexistente) — observa la validación en acción:
curl -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{"antiguedad_meses": 8, "plan": "platino", "cargos_mensuales": 49.9, "num_tickets": 5}'
{
"detail": [{
"type": "literal_error",
"loc": ["body", "plan"],
"msg": "Input should be 'basico', 'estandar' or 'premium'",
"input": "platino"
}]
}
Código de estado: 422. El modelo ni se enteró. Eso es una API bien defendida.
Probar con la UI automática: abre http://localhost:8000/docs en el navegador. FastAPI genera una interfaz Swagger donde ves cada endpoint, su esquema con los ejemplos que definimos, y un botón "Try it out" para lanzar peticiones desde el navegador. Es, además, documentación siempre sincronizada con el código — porque se genera desde el código.
Ejercicio rápido 3¶
Añade al esquema ClienteInput un campo email_dominio (por ejemplo "gmail.com") que solo acepte cadenas de entre 4 y 50 caracteres. ¿Qué código de estado recibirá un cliente que envíe "a"?
Ver solución
Un cliente que envíe `"a"` recibirá un **422 Unprocessable Entity**, con un JSON de detalle indicando `"loc": ["body", "email_dominio"]` y un mensaje tipo *"String should have at least 4 characters"*. La función `predict` no llega a ejecutarse. (Recuerda que si añades el campo al esquema también debe añadirse al pipeline de entrenamiento, o eliminarlo antes de construir el DataFrame.)5. Docker: empaquetar el servicio¶
Tu API funciona en tu máquina. Ahora hay que ejecutarla en un servidor que no tiene tu Python, ni tus librerías, ni tu configuración. La solución histórica era una lista de instrucciones de instalación que fallaba en el paso 7. La solución moderna es Docker.
5.1 Qué es un contenedor: la analogía del contenedor de barco¶
Antes de los años 60, cargar un barco era un caos artesanal: sacos, barriles y cajas de mil formas, cada uno estibado a mano. El contenedor marítimo estandarizado cambió el mundo: da igual lo que haya dentro (plátanos, coches, muebles), por fuera todos los contenedores son idénticos, y por tanto cualquier barco, grúa, tren o camión del planeta sabe manejarlos.
Docker hace exactamente eso con el software. Un contenedor Docker empaqueta tu aplicación junto con todo lo que necesita para ejecutarse: el intérprete de Python exacto, cada librería con su versión exacta, los ficheros del modelo, la configuración. Por fuera, todos los contenedores se manejan igual: cualquier servidor con Docker (tu portátil, un servidor de la empresa, AWS, Google Cloud) los ejecuta de forma idéntica. Se acabó el "en mi máquina funciona": tu máquina viaja con la aplicación.
Dos conceptos que no debes confundir:
- Imagen: la plantilla inmutable — la receta congelada con todo dentro. Como una clase en programación, o como el molde de una galleta.
- Contenedor: una instancia en ejecución de esa imagen. Como un objeto instanciado de la clase. De una misma imagen puedes arrancar 1 o 50 contenedores idénticos (así se escala horizontalmente).
5.2 El Dockerfile, línea por línea¶
El Dockerfile es la receta para construir la imagen. Cada instrucción crea una capa que Docker cachea: si no cambias una línea ni las anteriores, esa capa se reutiliza y el build es casi instantáneo. Por eso el ORDEN importa: lo que menos cambia, arriba.
# ── Imagen base ─────────────────────────────────────────────────────────
# python:3.12-slim: Python 3.12 oficial sobre Debian "slim" (recortado).
# La variante slim pesa ~120 MB frente a ~1 GB de la completa: menos
# superficie de ataque, descargas más rápidas, despliegues más ágiles.
FROM python:3.12-slim
# ── Metadatos y entorno ─────────────────────────────────────────────────
# PYTHONUNBUFFERED=1: los print/logs salen al instante, sin buffer.
# Sin esto, los logs pueden aparecer con retraso o perderse en un crash.
ENV PYTHONUNBUFFERED=1 \
# No generar ficheros .pyc: innecesarios dentro de un contenedor.
PYTHONDONTWRITEBYTECODE=1
# ── Directorio de trabajo ───────────────────────────────────────────────
# Todas las instrucciones siguientes se ejecutan desde /code.
WORKDIR /code
# ── Dependencias PRIMERO (truco de caché) ───────────────────────────────
# Copiamos SOLO requirements.txt antes que el resto del código.
# Así, si cambias el código pero no las dependencias, Docker reutiliza
# la capa de 'pip install' (la más lenta) y el rebuild tarda segundos.
COPY requirements.txt .
# --no-cache-dir: pip no guarda su caché de descargas dentro de la imagen
# (ahorra decenas de MB en la imagen final).
RUN pip install --no-cache-dir -r requirements.txt
# ── Código y modelo ─────────────────────────────────────────────────────
# Copiamos el código de la aplicación y el artefacto del modelo.
# Lo que copie exactamente lo controla también .dockerignore (ver abajo).
COPY app/ ./app/
COPY modelos/ ./modelos/
# ── Usuario no privilegiado ─────────────────────────────────────────────
# Por defecto los contenedores corren como root: mala práctica de seguridad.
# Creamos un usuario sin privilegios y cambiamos a él.
RUN useradd --create-home apiuser
USER apiuser
# ── Puerto documentado ──────────────────────────────────────────────────
# EXPOSE no abre el puerto (eso lo hace 'docker run -p'); solo documenta
# qué puerto usa la aplicación dentro del contenedor.
EXPOSE 8000
# ── Comando de arranque ─────────────────────────────────────────────────
# Lo que se ejecuta al iniciar el contenedor: uvicorn sirviendo nuestra app.
# --host 0.0.0.0: escucha en TODAS las interfaces de red del contenedor.
# (Con el 127.0.0.1 por defecto, la API sería inalcanzable desde fuera
# del contenedor: el error nº 1 de todo principiante con Docker.)
# SIN --reload: la recarga automática es solo para desarrollo.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
5.3 .dockerignore¶
Igual que .gitignore, el fichero .dockerignore evita copiar basura dentro de la imagen. Sin él, COPY arrastraría tu entorno virtual, tus datos y tu historial de git — imágenes de gigabytes y posibles fugas de secretos.
# .dockerignore
__pycache__/ # bytecode compilado: inútil en la imagen
*.pyc
.venv/ # tu entorno virtual local: ¡puede pesar GB!
.git/ # historial completo de git: pesado e innecesario
data/ # datasets de entrenamiento: NO van en la imagen de serving
notebooks/ # los notebooks se quedan en el laboratorio
tests/ # los tests corren en CI, no en producción
.env # secretos y credenciales: JAMÁS dentro de una imagen
*.md # documentación
5.4 Build y run¶
# BUILD: construye la imagen siguiendo el Dockerfile del directorio actual (.)
# -t etiqueta la imagen con nombre:versión. Etiqueta SIEMPRE con versión
# explícita, nunca confíes en :latest para producción.
docker build -t churn-api:1.3.0 .
# RUN: arranca un contenedor a partir de la imagen.
# -d -> 'detached': corre en segundo plano
# -p 8000:8000 -> mapea el puerto 8000 de tu máquina al 8000 del contenedor
# --name -> nombre legible para gestionarlo después
docker run -d -p 8000:8000 --name churn-api churn-api:1.3.0
# Comprobar que vive:
curl http://localhost:8000/health
# {"status":"ok","model_version":"1.3.0"}
# Ver logs en directo:
docker logs -f churn-api
# Parar y eliminar:
docker stop churn-api && docker rm churn-api
5.5 docker-compose: API + Redis para cachear predicciones¶
En producción rara vez hay un solo contenedor. docker-compose define un conjunto de servicios que arrancan juntos con un solo comando y se comunican entre sí por nombre.
¿Por qué una caché de predicciones? Un modelo es una función determinista: la misma entrada produce siempre la misma salida (misma versión de modelo). Si el 30 % de tus peticiones repiten entradas ya vistas (algo habitual: el mismo cliente se consulta varias veces, o varios sistemas consultan al mismo), estás gastando CPU en recalcular lo mismo. Redis es una base de datos en memoria ultrarrápida (lecturas en < 1 ms) perfecta como caché: antes de predecir, miramos si ya tenemos la respuesta; si no, predecimos y la guardamos con un tiempo de expiración.
# docker-compose.yml
# 'services' define los contenedores que forman la aplicación.
services:
# ── Servicio 1: nuestra API ─────────────────────────────────────────
api:
build: . # construye la imagen con el Dockerfile local
image: churn-api:1.3.0 # nombre que recibirá la imagen construida
ports:
- "8000:8000" # host:contenedor — expone la API al exterior
environment:
# Variables de entorno que la app lee en arranque.
# 'redis' es el NOMBRE del otro servicio: dentro de la red interna
# que compose crea, cada servicio se resuelve por su nombre (DNS interno).
- REDIS_URL=redis://redis:6379/0
- MODEL_PATH=modelos/churn_v1.3.0_2026-06-15_auc0.891.joblib
depends_on:
redis:
condition: service_healthy # no arranca la API hasta que Redis esté sano
restart: unless-stopped # si el proceso muere, Docker lo relanza solo
healthcheck: # cómo sabe Docker que la API está viva:
test: ["CMD", "python", "-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s # comprueba cada 30 segundos
timeout: 5s # si tarda más de 5 s, cuenta como fallo
retries: 3 # 3 fallos seguidos -> contenedor 'unhealthy'
# ── Servicio 2: Redis, la caché ─────────────────────────────────────
redis:
image: redis:7-alpine # imagen oficial, variante alpine (mínima, ~30 MB)
ports:
- "6379:6379" # expuesto solo para depurar; en prod, quítalo
# Límite de memoria + política de expulsión: cuando se llene,
# Redis borra las claves menos usadas recientemente (LRU).
# Comportamiento PERFECTO para una caché: lo viejo y frío se va solo.
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"] # Redis responde PONG si está vivo
interval: 10s
timeout: 3s
retries: 3
# Levanta TODO (construye si hace falta) en segundo plano:
docker compose up -d --build
# Estado de los servicios:
docker compose ps
# Logs de todos los servicios entrelazados:
docker compose logs -f
# Apagar y limpiar:
docker compose down
Y el fragmento de la API que usa la caché (se añade a app/main.py):
import hashlib, json, os
import redis
# Conexión a Redis usando la URL del entorno (definida en compose).
# decode_responses=True: Redis devuelve str en vez de bytes.
cache = redis.Redis.from_url(
os.getenv("REDIS_URL", "redis://localhost:6379/0"),
decode_responses=True,
)
def cache_key(payload: dict, version: str) -> str:
"""Clave determinista: hash del input ordenado + versión del modelo.
Incluir la versión es CRUCIAL: al desplegar un modelo nuevo, las
claves antiguas dejan de coincidir y no se sirven predicciones
obsoletas de la versión anterior.
"""
raw = json.dumps(payload, sort_keys=True) + version # orden estable de claves
return "pred:" + hashlib.sha256(raw.encode()).hexdigest()
# Dentro del endpoint /predict, ANTES de llamar al modelo:
# key = cache_key(cliente.model_dump(), ml_artifacts["version"])
# if (hit := cache.get(key)) is not None:
# return json.loads(hit) # respuesta en < 1 ms, sin tocar el modelo
# ... y DESPUÉS de predecir:
# cache.set(key, respuesta.model_dump_json(), ex=3600) # expira en 1 hora
Advertencia
define siempre una expiración (ex=3600). Los datos del cliente cambian (paga una factura, abre un ticket) y una predicción cacheada eternamente se vuelve mentira. La expiración correcta depende del negocio: para churn, horas está bien; para fraude en pagos, probablemente no deberías cachear en absoluto, porque cada transacción es única.
Ejercicio rápido 4¶
En el docker-compose.yml, ¿qué pasaría si en REDIS_URL escribieras redis://localhost:6379/0 en lugar de redis://redis:6379/0? ¿Por qué?
Ver solución
La API no podría conectar con Redis. Dentro de un contenedor, `localhost` se refiere **al propio contenedor de la API**, no a tu máquina ni al contenedor de Redis. Cada contenedor tiene su propia pila de red aislada. En la red interna que crea docker-compose, los servicios se encuentran entre sí **por su nombre de servicio** (`redis`), que compose resuelve mediante DNS interno a la IP del contenedor correcto. Este es uno de los errores más comunes al empezar con Docker (junto con olvidar `--host 0.0.0.0` en uvicorn, que es el mismo error en espejo).6. Monitorización y drift: la sección estrella¶
Tu modelo está desplegado, contenedorizado y respondiendo en 5 ms. Enhorabuena. Ahora empieza el verdadero peligro: el mundo va a cambiar y tu modelo no. Un modelo de ML es una fotografía estadística del pasado; cuanto más se aleja el presente de esa fotografía, peor predice. Y lo hace en silencio: la API seguirá devolviendo códigos 200 con números entre 0 y 1 aunque esos números ya no signifiquen nada.
6.1 Data drift vs concept drift¶
Dos formas distintas de envejecer, con nombres que debes dominar:
-
Data drift (deriva de datos, también covariate shift): cambia la distribución de las entradas — el P(X). Las relaciones subyacentes siguen siendo válidas, pero el modelo recibe datos de zonas del espacio donde entrenó poco. Ejemplo: entrenaste el modelo de churn cuando el 70 % de los clientes tenía plan básico; tras una campaña de marketing, el 60 % es premium. La relación plan→churn no ha cambiado, pero el modelo ahora opera mayoritariamente en una región donde vio pocos ejemplos.
-
Concept drift (deriva de concepto): cambia la relación entre entradas y objetivo — el P(y|X). Las mismas entradas ahora significan otra cosa. Es más grave y más difícil de detectar. Ejemplo canónico: la pandemia de 2020 destrozó todos los modelos de demanda del mundo. Un supermercado con las mismas features de siempre (día de la semana, temporada, promociones) vio la demanda de papel higiénico multiplicarse por 10. Las entradas eran normales; el concepto "demanda" había cambiado de significado. Otro ejemplo: en fraude, los defraudadores estudian el modelo y cambian de táctica — el mismo patrón de transacción que ayer era legítimo hoy es fraude.
flowchart LR
subgraph MUNDO["El mundo cambia"]
direction TB
DD["DATA DRIFT<br/>cambia P(X)<br/>las entradas se mueven"]
CD["CONCEPT DRIFT<br/>cambia P(y|X)<br/>el significado se mueve"]
end
DD -->|"detectable SIN etiquetas<br/>comparando distribuciones<br/>de entrada (PSI, KS)"|DET1["Detección temprana "]
CD -->|"requiere etiquetas reales<br/>(que llegan con retraso)<br/>o proxies indirectos"|DET2["Detección tardía "]
DET1 --> ACC["Alerta → investigar<br/>→ ¿reentrenar?"]
DET2 --> ACC
style DD fill:#1f6feb,color:#fff
style CD fill:#da3633,color:#fff
style ACC fill:#d29922,color:#000
Nota
ambos tipos suelen llegar juntos y el data drift es a menudo el canario en la mina del concept drift: si las entradas se mueven, sospecha que la relación también puede estar moviéndose.
6.2 Detectar data drift: el PSI (Population Stability Index)¶
El PSI es la métrica industrial clásica (nació en el credit scoring bancario) para cuantificar cuánto se ha movido una distribución. La intuición:
- Toma la distribución de una feature en entrenamiento (la referencia o expected).
- Divídela en cubos (bins), por ejemplo 10 deciles.
- Mide qué porcentaje de los datos de referencia cae en cada cubo, y qué porcentaje de los datos actuales de producción cae en cada cubo.
- Si los porcentajes son parecidos → distribución estable. Si divergen → drift.
La fórmula, cubo a cubo:
Cada término es siempre ≥ 0 (si actual > esperado, ambos factores son positivos; si actual < esperado, ambos negativos). Cuanto más difieren las proporciones de un cubo, más aporta ese cubo al total. El logaritmo hace la métrica simétrica: duplicar la proporción de un cubo pesa igual que reducirla a la mitad.
Umbrales convencionales de la industria:
| PSI | Interpretación | Acción |
|---|---|---|
| < 0.10 | Distribución estable | Nada: seguir monitorizando |
| 0.10 – 0.25 | Cambio moderado | Investigar la causa; vigilar de cerca |
| > 0.25 | Cambio significativo | Alerta: evaluar reentrenamiento urgente |
Implementación completa en NumPy, comentada:
import numpy as np
def psi(esperado: np.ndarray, actual: np.ndarray, n_bins: int = 10) -> float:
"""Population Stability Index entre dos muestras de una feature numérica.
Parámetros
----------
esperado : muestra de referencia (p. ej. la feature en el set de entrenamiento)
actual : muestra reciente de producción (p. ej. la última semana de peticiones)
n_bins : número de cubos (10 deciles es el estándar de la industria)
"""
# 1. Definimos los bordes de los cubos con los CUANTILES de la referencia.
# Usar cuantiles (y no cortes uniformes) garantiza que cada cubo
# contenga ~10% de la referencia, evitando cubos vacíos.
bordes = np.quantile(esperado, np.linspace(0, 1, n_bins + 1))
bordes[0], bordes[-1] = -np.inf, np.inf # abrimos los extremos: cualquier
# valor futuro cae en algún cubo
# 2. Proporción de cada muestra que cae en cada cubo.
prop_esp = np.histogram(esperado, bins=bordes)[0] / len(esperado)
prop_act = np.histogram(actual, bins=bordes)[0] / len(actual)
# 3. Suavizado: si un cubo tiene proporción 0, el logaritmo explota.
# Sustituimos ceros por un epsilon pequeño (técnica estándar).
eps = 1e-4
prop_esp = np.clip(prop_esp, eps, None)
prop_act = np.clip(prop_act, eps, None)
# 4. La fórmula del PSI, vectorizada:
return float(np.sum((prop_act - prop_esp) * np.log(prop_act / prop_esp)))
# ─── Demostración ───────────────────────────────────────────────────────
rng = np.random.default_rng(42)
entrenamiento = rng.normal(50, 10, 10_000) # cargos mensuales en entrenamiento
sin_drift = rng.normal(50, 10, 2_000) # producción: misma distribución
drift_leve = rng.normal(53, 10, 2_000) # la media subió un poco (inflación)
drift_grave = rng.normal(65, 18, 2_000) # cambió media Y dispersión
print(f"PSI sin drift: {psi(entrenamiento, sin_drift):.4f}") # ~0.003 ✅
print(f"PSI drift leve: {psi(entrenamiento, drift_leve):.4f}") # ~0.09 ⚠
print(f"PSI drift grave: {psi(entrenamiento, drift_grave):.4f}") # ~0.7 ❌
Para features categóricas el PSI es aún más simple: los "cubos" son directamente las categorías, y comparas la proporción de cada categoría en referencia vs producción con la misma fórmula.
Consejo profesional
calcula el PSI de cada feature por separado, cada día o cada semana, y guarda la serie temporal. Un PSI que sube gradualmente cuenta una historia distinta (cambio demográfico lento) que uno que salta de golpe (un sistema upstream cambió el formato de un campo — el "drift" más común de todos es, en realidad, un bug de datos).
6.3 Las tres capas de monitorización de un modelo¶
De más inmediata a más lenta:
- Distribución de las entradas (disponible al instante). PSI por feature, tasa de nulos, tasa de categorías rechazadas por validación, volumen de peticiones. Detecta data drift y bugs de datos el mismo día que ocurren.
- Distribución de las predicciones (disponible al instante). Si tu modelo pasó de predecir una probabilidad media de churn del 12 % a predecir el 31 %, algo pasa — o el mundo cambió o los datos se rompieron. Monitoriza media, percentiles y el PSI de la propia distribución de scores. Es un detector barato y sorprendentemente eficaz.
- Métricas reales con etiquetas retrasadas (disponible con días/semanas/meses de retraso). Para saber si el cliente realmente se fue, hay que esperar. Cuando las etiquetas llegan, calcula AUC/F1 sobre las predicciones que hiciste en su momento y compáralas con la métrica de validación. Esta es la única medida directa del concept drift — por eso las dos capas anteriores son vitales: te avisan mientras esperas.
6.4 Política de reentrenamiento: ¿calendario o por drift?¶
| Estrategia | Pros | Contras |
|---|---|---|
| Por calendario (p. ej. cada mes) | Simple de implementar y de explicar; predecible (costes, agenda); no requiere sistema de detección | Puede reentrenar sin necesidad (coste inútil) o demasiado tarde (el drift no espera al día 1); da falsa sensación de seguridad |
| Por drift (cuando PSI o métricas cruzan umbral) | Reacciona a la realidad, no al calendario; ahorra cómputo cuando todo está estable; detecta crisis rápido | Requiere infraestructura de monitorización madura; umbrales mal calibrados → alertas falsas o silencios peligrosos; menos predecible operativamente |
| Híbrida (calendario base + trigger por drift) | Lo mejor de ambas: red de seguridad periódica + reacción rápida a crisis | Es la más completa pero también la que más piezas requiere |
Consejo profesional
la política híbrida es el estándar sensato: reentrena cada N meses pase lo que pase (mantiene el músculo del pipeline de reentrenamiento en forma — un pipeline de reentrenamiento que no se ejecuta nunca está roto y no lo sabes) y añade triggers automáticos por drift para las sorpresas. Y siempre con un humano aprobando el despliegue del modelo nuevo hasta que tengas mucha madurez (ver sección 8).
6.5 Logging de predicciones para auditoría¶
Cada predicción servida debe quedar registrada. Es la materia prima de la monitorización, la auditoría y el reentrenamiento. Qué guardar, como mínimo:
| Campo | Por qué |
|---|---|
request_id |
Identificador único para correlacionar logs, respuestas y quejas de clientes |
timestamp (UTC) |
Cuándo — para series temporales de drift y depuración |
input completo |
Sin esto no puedes calcular PSI ni reproducir la predicción |
output (probabilidad y decisión) |
Para monitorizar la distribución de scores y evaluar cuando lleguen etiquetas |
model_version |
¿Qué modelo respondió? Imprescindible cuando conviven versiones |
latency_ms |
Salud operacional del servicio |
(después) etiqueta_real |
Se rellena cuando el resultado se conoce: cierra el círculo del aprendizaje |
Advertencia
si los inputs contienen datos personales (y en churn los contienen), este log está sujeto a RGPD/normativa de protección de datos: define retención limitada, control de acceso, y pseudonimización donde sea posible. Habla con el equipo legal antes de que el log tenga seis meses de datos, no después.
Ejercicio rápido 5¶
El PSI de todas tus features lleva semanas < 0.05 (estable), pero la probabilidad media de churn predicha subió del 12 % al 14 % y el AUC calculado con las etiquetas que van llegando cayó de 0.89 a 0.81. ¿Qué tipo de drift es y por qué?
Ver solución
Es **concept drift**. Las entradas no se han movido (PSI estable → no hay data drift), pero la relación entre entradas y resultado sí: con los mismos datos de entrada, el modelo acierta menos (AUC 0.89 → 0.81). Algo en el mundo cambió el *significado* de las features — por ejemplo, un competidor lanzó una oferta agresiva y ahora clientes con perfil "seguro" también se fugan. La subida de la probabilidad media predicha es un efecto secundario menor; la señal definitiva es la caída de la métrica real. Acción: investigar la causa de negocio y reentrenar con datos recientes que capturen la nueva relación (y quizá añadir features nuevas que la expliquen, como "presión competitiva").7. Testing de sistemas de ML¶
Los sistemas de ML necesitan los tests de siempre más una familia propia: el código puede ser correcto y el sistema estar roto (modelo desactualizado, datos corruptos, pipeline que traga basura sin quejarse).
7.1 Tests del pipeline de preprocesado¶
El preprocesado debe ser un tanque: nulos, categorías nuevas y valores extremos no deben tumbarlo.
# tests/test_pipeline.py
import joblib
import pandas as pd
import pytest
@pytest.fixture(scope="module")
def pipeline():
"""Carga el artefacto UNA vez para todos los tests del módulo."""
return joblib.load("modelos/churn_v1.3.0_2026-06-15_auc0.891.joblib")
def fila_base() -> dict:
"""Una fila válida y típica que sirve de plantilla para los tests."""
return {"antiguedad_meses": 24, "plan": "estandar",
"cargos_mensuales": 55.0, "num_tickets": 2}
def test_prediccion_basica_no_falla(pipeline):
"""El caso feliz: una fila normal produce una probabilidad válida."""
df = pd.DataFrame([fila_base()])
proba = pipeline.predict_proba(df)[0, 1]
assert 0.0 <= proba <= 1.0 # probabilidad en rango
def test_pipeline_tolera_nulos(pipeline):
"""Un nulo en una feature numérica NO debe lanzar excepción:
el SimpleImputer del pipeline debe absorberlo."""
fila = fila_base()
fila["num_tickets"] = None # nulo deliberado
df = pd.DataFrame([fila])
proba = pipeline.predict_proba(df)[0, 1] # si explota, el test falla
assert 0.0 <= proba <= 1.0
def test_pipeline_tolera_categoria_nueva(pipeline):
"""Una categoría nunca vista no debe tumbar el pipeline
(el OneHotEncoder debe tener handle_unknown='ignore').
OJO: la API rechaza esto con 422 gracias a Literal, pero el pipeline
debe ser robusto POR SÍ MISMO — defensa en profundidad: también se
usa en scoring batch, donde no hay Pydantic delante."""
fila = fila_base()
fila["plan"] = "plan_fantasma_2099"
df = pd.DataFrame([fila])
proba = pipeline.predict_proba(df)[0, 1]
assert 0.0 <= proba <= 1.0
7.2 Tests de la API con TestClient¶
FastAPI incluye TestClient: un cliente HTTP falso que llama a tu aplicación en memoria, sin arrancar servidor ni abrir puertos. Los tests corren en milisegundos.
# tests/test_api.py
from fastapi.testclient import TestClient
from app.main import app
# El 'with' dispara el lifespan: el modelo se carga igual que en producción.
client = TestClient(app)
def test_health_ok():
"""El endpoint de salud responde 200 y reporta la versión."""
with TestClient(app) as c:
r = c.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
assert "model_version" in r.json()
def test_predict_caso_valido():
"""Una petición válida devuelve 200 con el contrato de salida completo."""
with TestClient(app) as c:
r = c.post("/predict", json={
"antiguedad_meses": 8, "plan": "basico",
"cargos_mensuales": 49.9, "num_tickets": 5,
})
assert r.status_code == 200
body = r.json()
assert 0.0 <= body["churn_probability"] <= 1.0 # probabilidad en rango
assert isinstance(body["churn_predicted"], bool)
assert body["model_version"] == "1.3.0" # trazabilidad correcta
def test_predict_rechaza_plan_invalido():
"""Un plan fuera del Literal debe devolver 422, no 200 ni 500."""
with TestClient(app) as c:
r = c.post("/predict", json={
"antiguedad_meses": 8, "plan": "platino", # ¡no existe!
"cargos_mensuales": 49.9, "num_tickets": 5,
})
assert r.status_code == 422
# El detalle del error señala exactamente el campo culpable:
assert r.json()["detail"][0]["loc"] == ["body", "plan"]
def test_predict_rechaza_negativos():
"""Antigüedad negativa: violación de ge=0 -> 422."""
with TestClient(app) as c:
r = c.post("/predict", json={
"antiguedad_meses": -3, "plan": "basico",
"cargos_mensuales": 49.9, "num_tickets": 5,
})
assert r.status_code == 422
def test_predict_rechaza_campos_faltantes():
"""Si falta un campo obligatorio, 422 (no un 500 confuso)."""
with TestClient(app) as c:
r = c.post("/predict", json={"plan": "basico"}) # faltan 3 campos
assert r.status_code == 422
7.3 Tests de humo del modelo¶
Un smoke test del modelo verifica que el artefacto se comporta de forma plausible, no que sea óptimo. Son la última barrera antes de desplegar un artefacto corrupto o absurdo.
# tests/test_model_smoke.py
import joblib
import pandas as pd
pipeline = joblib.load("modelos/churn_v1.3.0_2026-06-15_auc0.891.joblib")
def test_predicciones_en_rango():
"""Todas las probabilidades deben estar en [0, 1] para un lote variado."""
lote = pd.DataFrame([
{"antiguedad_meses": 1, "plan": "basico", "cargos_mensuales": 9.9, "num_tickets": 0},
{"antiguedad_meses": 120, "plan": "premium", "cargos_mensuales": 199.0, "num_tickets": 30},
{"antiguedad_meses": 36, "plan": "estandar", "cargos_mensuales": 59.9, "num_tickets": 3},
])
probas = pipeline.predict_proba(lote)[:, 1]
assert (probas >= 0).all() and (probas <= 1).all()
def test_caso_canonico_alto_riesgo():
"""Conocimiento de dominio como test: un cliente nuevo, plan básico y
lleno de tickets DEBE tener más riesgo que un veterano premium sin
tickets. Si esto deja de cumplirse tras reentrenar, el modelo nuevo
hace algo raro y merece revisión humana antes de desplegar."""
riesgo_alto = pd.DataFrame([{"antiguedad_meses": 2, "plan": "basico",
"cargos_mensuales": 49.9, "num_tickets": 12}])
riesgo_bajo = pd.DataFrame([{"antiguedad_meses": 96, "plan": "premium",
"cargos_mensuales": 120.0, "num_tickets": 0}])
p_alto = pipeline.predict_proba(riesgo_alto)[0, 1]
p_bajo = pipeline.predict_proba(riesgo_bajo)[0, 1]
assert p_alto > p_bajo, f"Incoherencia: {p_alto:.3f} <= {p_bajo:.3f}"
def test_prediccion_es_determinista():
"""La misma entrada dos veces -> exactamente la misma salida.
Si falla, hay aleatoriedad sin semilla en el pipeline de inferencia."""
fila = pd.DataFrame([{"antiguedad_meses": 24, "plan": "estandar",
"cargos_mensuales": 55.0, "num_tickets": 2}])
p1 = pipeline.predict_proba(fila)[0, 1]
p2 = pipeline.predict_proba(fila)[0, 1]
assert p1 == p2
7.4 CI que reentrena vs CI que solo valida artefacto¶
Advertencia
este es un punto de diseño que separa los equipos maduros de los accidentados. Hay dos filosofías para la integración continua (CI):
| CI que valida el artefacto (recomendado para empezar) | CI que reentrena en cada cambio | |
|---|---|---|
| Qué hace | Los tests cargan un artefacto ya entrenado y versionado, y validan API + humo | Cada push reentrena el modelo desde los datos y testea el resultado |
| Velocidad | Segundos/minutos | Minutos/horas (según dataset) |
| Determinismo | Total: el artefacto es fijo | Frágil: datos cambiantes → tests que fallan sin que el código haya cambiado |
| Acceso a datos | No necesita datos de entrenamiento en CI | CI necesita credenciales a los datos (superficie de riesgo) |
| Cuándo tiene sentido | Casi siempre: el ciclo de código y el de modelo son independientes | Solo con pipelines de reentrenamiento muy maduros, datos versionados (DVC) y presupuesto de cómputo |
La regla práctica: separa el ciclo de vida del código del ciclo de vida del modelo. El CI de código valida que la API y los tests funcionan contra un artefacto congelado. El reentrenamiento es OTRO pipeline, con su propio disparador (calendario/drift), su propia validación y su propia aprobación de despliegue.
8. MLOps: panorámica honesta¶
MLOps es la disciplina que aplica prácticas de ingeniería de software (automatización, versionado, testing, monitorización) al ciclo de vida completo de los modelos de ML. Todo lo que has hecho en este capítulo es MLOps; las herramientas solo lo hacen más cómodo a escala.
8.1 Niveles de madurez¶
Inspirados en los niveles que popularizó Google:
flowchart LR
N0["NIVEL 0<br/>Manual<br/>─────────<br/>notebook → joblib<br/>→ copiar al servidor<br/>a mano"]
N1["NIVEL 1<br/>CI/CD de código<br/>─────────<br/>tests automáticos,<br/>build de Docker,<br/>despliegue automatizado;<br/>reentrenamiento manual"]
N2["NIVEL 2<br/>Pipeline de ML automatizado<br/>─────────<br/>reentrenamiento disparado<br/>por calendario o drift,<br/>validación y registro<br/>automáticos del modelo"]
N0 -->|"añade tests,<br/>contenedores, CI"|N1
N1 -->|"añade monitorización,<br/>triggers, model registry"|N2
style N0 fill:#da3633,color:#fff
style N1 fill:#d29922,color:#000
style N2 fill:#238636,color:#fff
Honestidad brutal: la mayoría de las empresas del mundo está en el nivel 0 o entre el 0 y el 1, y para muchas es razonable. El nivel 2 solo compensa cuando tienes varios modelos en producción y reentrenamientos frecuentes. No dejes que un vendedor de plataformas te convenza de que necesitas el nivel 2 para tu primer modelo.
8.2 Herramientas por categoría¶
| Categoría | Para qué sirve | Herramientas representativas |
|---|---|---|
| Experiment tracking | Registrar cada entrenamiento: parámetros, métricas, artefactos | MLflow, Weights & Biases |
| Model registry | Catálogo central de modelos con versiones y estados (staging/producción) | MLflow Model Registry |
| Versionado de datos | Git para datasets: reproducir el dataset exacto de cada entrenamiento | DVC (mención; se ve en módulos avanzados) |
| Feature store | Repositorio central de features compartidas entre entrenamiento y serving, garantizando consistencia | Feast, Tecton (mención; territorio de grandes equipos) |
| Orquestación | Programar y encadenar pipelines (ingesta → entrenamiento → despliegue) | Airflow, Prefect, Dagster |
| Monitorización de ML | Drift, calidad de datos, rendimiento | Evidently, WhyLabs, o tu propio PSI + Grafana |
8.3 MLflow: demo mínima¶
MLflow es open source, se instala con pip install mlflow y resuelve el problema de "¿qué parámetros tenía el modelo bueno de hace dos semanas?":
import mlflow
import mlflow.sklearn
from sklearn.model_selection import cross_val_score
# Dónde guardar los experimentos (aquí, carpeta local; en equipo, un servidor).
mlflow.set_tracking_uri("file:./mlruns")
mlflow.set_experiment("churn") # agrupa runs relacionados
# Cada entrenamiento se registra como un 'run':
with mlflow.start_run(run_name="hgb_lr0.05"):
# 1. Registra los hiperparámetros que estás probando:
mlflow.log_param("learning_rate", 0.05)
mlflow.log_param("max_iter", 300)
# 2. Entrena y evalúa como siempre:
auc = cross_val_score(pipeline, X, y, cv=5, scoring="roc_auc").mean()
# 3. Registra la métrica resultante:
mlflow.log_metric("auc_cv", auc)
# 4. Guarda el pipeline entero como artefacto del run:
mlflow.sklearn.log_model(pipeline, name="modelo")
# Interfaz web para comparar todos los runs (tabla ordenable por métrica):
# mlflow ui -> http://localhost:5000
Con esto, cada experimento queda registrado con sus parámetros, métricas y artefacto, comparable en una tabla web. El Model Registry de MLflow añade encima el ciclo de estados (candidato → staging → producción) con historial de quién promovió qué y cuándo.
8.4 Qué necesita una startup vs una Fortune 500¶
| Dimensión | Startup (1-3 modelos, equipo de 2-5) | Fortune 500 (decenas de modelos, equipos múltiples) |
|---|---|---|
| Tracking | MLflow local o carpeta de metadatos JSON bien llevada | MLflow/W&B en servidor central con control de acceso |
| Registry | Convención de nombres + bucket S3 versionado | Model registry formal con aprobaciones y auditoría |
| Serving | 1 API FastAPI en Docker en un VPS o servicio gestionado | Kubernetes, autoescalado, canary deployments, multi-región |
| Monitorización | PSI en un cron + alertas a Slack | Plataforma de observabilidad dedicada, dashboards, on-call |
| Reentrenamiento | Manual con checklist, mensual | Pipelines automatizados con validación y aprobación |
| Datos | requirements.txt + CSVs versionados con fecha | DVC/lakehouse, feature store, linaje de datos |
| Coste mensual infra ML | Decenas-cientos de € | Cientos de miles de € |
Consejo profesional
el error de la startup es copiar la arquitectura de la Fortune 500 ("necesitamos Kubernetes y un feature store") y morir de complejidad. El error de la Fortune 500 es operar como una startup ("el modelo lo despliega Paco desde su portátil") y morir de riesgo. La madurez correcta es la que tu escala exige, ni más ni menos.
9. Ejemplo integrador: el modelo de churn a producción¶
Vamos a juntarlo todo: el modelo de churn que entrenaste en el capítulo 3 (y mejoraste con boosting en el 6) se convierte en un servicio de producción completo.
9.1 Estructura de proyecto profesional¶
churn-service/
├── src/ # código de ENTRENAMIENTO (el laboratorio)
│ └── train.py # script reproducible de entrenamiento
├── app/ # código de SERVING (la fábrica)
│ ├── __init__.py
│ └── main.py # la API FastAPI de la sección 4 (+ caché Redis)
├── modelos/ # artefactos versionados
│ ├── churn_v1.3.0_2026-06-15_auc0.891.joblib
│ └── churn_v1.3.0_metadata.json
├── tests/ # las tres familias de tests de la sección 7
│ ├── test_pipeline.py
│ ├── test_api.py
│ └── test_model_smoke.py
├── data/ # datos locales (fuera de git y de Docker)
│ └── churn.csv
├── Dockerfile # receta de la imagen (sección 5.2)
├── docker-compose.yml # API + Redis (sección 5.5)
├── .dockerignore
├── .gitignore
├── requirements.txt # dependencias con versiones exactas
└── index.md # cómo entrenar, testear, desplegar
La separación src/ (entrenamiento) vs app/ (serving) refleja la lección de la sección 7.4: son dos ciclos de vida distintos que comparten un solo punto de contacto: el artefacto en modelos/.
9.2 El script de entrenamiento reproducible¶
Del notebook exploratorio destilamos un script determinista. El notebook no se tira — se queda como documento de exploración — pero la fuente de verdad del modelo de producción es el script:
# src/train.py
"""Entrenamiento reproducible del modelo de churn.
Uso: python src/train.py --data data/churn.csv --version 1.3.0
"""
import argparse
import json
from datetime import datetime, timezone
import joblib
import pandas as pd
import sklearn
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.impute import SimpleImputer
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.metrics import roc_auc_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder
SEED = 42 # UNA semilla, usada en TODAS partes
NUMERICAS = ["antiguedad_meses", "cargos_mensuales", "num_tickets"]
CATEGORICAS = ["plan"]
def construir_pipeline() -> Pipeline:
"""Pipeline completo: imputación + encoding + boosting (caps. 3-6)."""
pre = ColumnTransformer([
# Imputamos nulos numéricos con la mediana (robusta a outliers):
("num", SimpleImputer(strategy="median"), NUMERICAS),
# One-hot con handle_unknown='ignore': las categorías nuevas en
# producción se codifican como todo-ceros en vez de romper.
("cat", OneHotEncoder(handle_unknown="ignore"), CATEGORICAS),
])
clf = HistGradientBoostingClassifier(
learning_rate=0.05, max_iter=300, random_state=SEED,
)
return Pipeline([("pre", pre), ("clf", clf)])
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--data", required=True)
parser.add_argument("--version", required=True)
args = parser.parse_args()
# 1. Datos:
df = pd.read_csv(args.data)
X, y = df[NUMERICAS + CATEGORICAS], df["churn"]
# 2. Hold-out final (estratificado) para la métrica honesta del artefacto:
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=SEED)
# 3. CV sobre el train para estimar estabilidad:
pipe = construir_pipeline()
cv_auc = cross_val_score(pipe, X_tr, y_tr, cv=5, scoring="roc_auc")
print(f"AUC CV: {cv_auc.mean():.4f} ± {cv_auc.std():.4f}")
# 4. Entrenamiento final con TODO el train y evaluación en el hold-out:
pipe.fit(X_tr, y_tr)
test_auc = roc_auc_score(y_te, pipe.predict_proba(X_te)[:, 1])
print(f"AUC test: {test_auc:.4f}")
# 5. Serialización con la convención nombre_versión_fecha_métrica:
hoy = datetime.now(timezone.utc).date().isoformat()
ruta = f"modelos/churn_v{args.version}_{hoy}_auc{test_auc:.3f}.joblib"
joblib.dump(pipe, ruta, compress=3)
# 6. Metadatos junto al artefacto:
meta = {
"version": args.version, "trained_at": hoy,
"metrics": {"auc_test": round(test_auc, 4),
"auc_cv_mean": round(float(cv_auc.mean()), 4)},
"features": NUMERICAS + CATEGORICAS,
"sklearn_version": sklearn.__version__,
"random_state": SEED, "training_rows": len(X_tr),
}
with open(f"modelos/churn_v{args.version}_metadata.json", "w",
encoding="utf-8") as f:
json.dump(meta, f, indent=2, ensure_ascii=False)
print(f"Artefacto guardado en {ruta}")
if __name__ == "__main__":
main()
9.3 Flujo git básico del proyecto¶
git init && git add . && git commit -m "estructura inicial del servicio de churn"
# Cada cambio, en su rama:
git checkout -b feature/endpoint-batch
# ... trabajas, testeas ...
pytest tests/ -v # TODO verde antes de commitear
git add . && git commit -m "añade endpoint /predict-batch con validación"
git push -u origin feature/endpoint-batch
# -> pull request -> revisión de un compañero -> CI en verde -> merge a main
# .gitignore mínimo del proyecto:
__pycache__/
.venv/
data/ # los datos NO van a git (tamaño + privacidad)
modelos/*.joblib # los artefactos van a un storage versionado (S3/registry), no a git
.env
mlruns/
Nota
los .joblib no van a git porque git maneja mal los binarios grandes y porque el modelo tiene su propio ciclo de versionado (sección 2.3). En git van el código y los metadatos; el artefacto vive en un almacenamiento de artefactos (un bucket, un registry de MLflow) referenciado por los metadatos.
9.4 Checklist de despliegue¶
Antes de que una versión nueva toque producción, todo esto en verde:
- [ ]
pytest tests/pasa al 100 % (pipeline, API, humo). - [ ] Métrica del candidato ≥ métrica del modelo actual en el mismo hold-out.
- [ ] Metadatos completos junto al artefacto (versión, métricas, versiones de librerías, semilla, commit).
- [ ]
docker compose uplevanta el servicio en limpio y/healthrespondeok. - [ ] Una petición de prueba real por
curldevuelve una predicción sensata. - [ ] La versión anterior sigue disponible para rollback en un comando.
- [ ] Dashboards/alertas de monitorización apuntando a la versión nueva.
- [ ] El log de predicciones registra la nueva
model_version. - [ ] Anunciado al equipo (canal de despliegues) con enlace a métricas.
- [ ] No es viernes por la tarde. (En serio.)
10. Caso empresarial: la degradación silenciosa¶
Caso empresarial
el modelo que murió sin hacer ruido.
Una cadena de retail europea con 340 tiendas usaba desde 2023 un modelo de previsión de demanda para decidir cuánto stock enviar a cada tienda cada semana. El modelo — un gradient boosting muy similar al que tú sabes entrenar — funcionaba de maravilla: error medio del 9 %, ahorros millonarios en inventario. El equipo lo desplegó, celebró, y pasó al siguiente proyecto. Sin monitorización de drift: "si algo falla, la API dará error y nos enteraremos".
La API nunca dio error. Durante 8 meses, respondió cada petición con un impecable código 200.
Lo que pasó por debajo: a lo largo de 2025 la empresa cambió su estrategia comercial — más promociones flash online, apertura de la venta por app con recogida en tienda, y dos competidores agresivos en el 40 % de sus zonas. La relación entre las features del modelo (histórico de ventas, temporada, promociones planificadas) y la demanda real cambió: concept drift de libro. El error medio del modelo pasó del 9 % al 23 % de forma gradual, un punto por mes, demasiado lento para que nadie lo notara semana a semana.
Los síntomas los sufrieron otros: las tiendas acumulaban stock de productos que ya no rotaban (capital inmovilizado) y agotaban los que la app había puesto de moda (ventas perdidas). Cada gerente de tienda lo achacaba a su caso particular. Nadie sospechó del modelo — el modelo funcionaba, ¿no? Devolvía números todos los lunes.
La detección llegó por accidente: una analista de inventario, preparando un informe anual, cruzó las previsiones históricas con las ventas reales y vio la curva de error subiendo mes a mes. La cifra que presentó a dirección: entre sobre-stock, roturas y costes logísticos de corrección, la degradación había costado unos 4,2 millones de euros en 8 meses. El reentrenamiento que lo arregló costó dos semanas de trabajo de una persona.
Lo que montaron después (y que tú ya sabes construir con este capítulo):
- Log completo de predicciones con inputs, outputs, versión y timestamp (sección 6.5).
- PSI semanal por feature con alertas a Slack al cruzar 0.10 y escalado a incidencia al cruzar 0.25 (sección 6.2).
- Monitor de la distribución de predicciones: la media y los percentiles de la demanda prevista, comparados contra la referencia del entrenamiento (sección 6.3).
- Métrica real con retraso: cada semana, al conocerse las ventas reales, se calcula el error de las previsiones de la semana anterior y se pinta en un dashboard con umbral de alerta en el 12 %.
- Política híbrida de reentrenamiento: trimestral por calendario + trigger automático por drift (sección 6.4).
- Una regla cultural nueva: ningún modelo se despliega sin su plan de monitorización. El plan de monitorización se revisa en la misma reunión que aprueba el despliegue.
La moraleja, que resume todo el capítulo: el peligro de un sistema de ML no es que falle con estruendo, sino que siga funcionando en apariencia mientras se equivoca cada vez más. Los códigos 200 no miden la verdad; solo la monitorización lo hace.
11. Buenas prácticas¶
- Serializa el pipeline entero, nunca solo el estimador final.
- Versiona todo: artefacto (nombre + fecha + métrica), metadatos JSON, requirements con
==, código en git con el commit en los metadatos. - Valida la entrada en la puerta con Pydantic: tipos, rangos,
Literalpara categorías. Fail fast, fail loud. - Carga el modelo una vez en el
lifespan, no por petición. - Endpoint
/healthsiempre, y que compruebe de verdad que el modelo está cargado. - Loguea cada predicción (input, output, versión, timestamp, latencia): es la materia prima de la monitorización y la auditoría.
- Monitoriza en tres capas: distribución de entradas (PSI), distribución de predicciones, y métricas reales cuando lleguen las etiquetas.
- Política de reentrenamiento híbrida: calendario base + triggers por drift, con aprobación humana del despliegue.
- Tests en tres familias: robustez del pipeline (nulos, categorías nuevas), contrato de la API (TestClient), humo del modelo (rangos, casos canónicos, determinismo).
- Separa el ciclo del código del ciclo del modelo: CI valida artefactos congelados; el reentrenamiento es otro pipeline.
- Contenedores mínimos y seguros: imagen slim,
.dockerignore, usuario no-root, tags de versión explícitos. - Empieza simple: batch antes que API si el negocio lo permite; MLflow local antes que plataforma enterprise; el nivel de madurez MLOps que tu escala exige.
12. Malas prácticas¶
- Cargar
pickle/joblibde fuentes no confiables (ejecución de código arbitrario). - Guardar solo el modelo sin el preprocesado y "reimplementar" las transformaciones a mano en la API (divergirán, garantizado).
- Desplegar sin plan de monitorización ("si falla, dará error" — el caso empresarial demuestra que no).
- Devolver el traceback interno al cliente en los errores 500.
pip installsin versiones fijadas;latestcomo tag de Docker en producción.- Cachear predicciones sin expiración ni versión del modelo en la clave.
- Reentrenar y desplegar automáticamente sin validación ni aprobación cuando el equipo aún no tiene madurez para ello.
- Meter datos, secretos (
.env) o el entorno virtual dentro de la imagen Docker. - Confundir "la API responde 200" con "el modelo funciona".
- Desplegar en viernes.
13. Errores comunes¶
| Error | Síntoma | Solución |
|---|---|---|
| Guardar solo el clasificador, no el pipeline | Excepciones por columnas/tipos, o predicciones absurdas con datos crudos | joblib.dump(pipeline, ...) con el preprocesado dentro |
| Cargar el modelo en cada petición | Latencias de segundos, CPU al 100 % | Cargar una vez en el lifespan |
uvicorn sin --host 0.0.0.0 en Docker |
La API funciona dentro del contenedor pero es inalcanzable desde fuera | Añadir --host 0.0.0.0 al CMD |
Usar localhost para hablar con otro contenedor |
ConnectionRefusedError al conectar con Redis/BD |
Usar el nombre del servicio de compose (redis) |
| Versiones de sklearn distintas entre entrenamiento y serving | InconsistentVersionWarning, comportamientos sutiles o errores al cargar |
Fijar versiones exactas en requirements y construir la imagen con ellas |
Encoder sin handle_unknown="ignore" |
El batch nocturno revienta con la primera categoría nueva | Configurarlo, y además testearlo (sección 7.1) |
| PSI calculado con bins uniformes en features sesgadas | Cubos vacíos, PSI inestable o infinito | Bins por cuantiles de la referencia + clip con epsilon |
| Caché sin la versión del modelo en la clave | Tras desplegar v1.4, se sirven predicciones de v1.3 durante horas | Incluir model_version en la clave de caché |
| Tests que reentrenan el modelo en CI | CI lento y que falla aleatoriamente sin cambios de código | CI valida un artefacto congelado; reentrenar es otro pipeline |
| Ignorar las etiquetas retrasadas | Nunca se mide el rendimiento real post-despliegue | Pipeline que casa predicciones con resultados cuando llegan y recalcula métricas |
14. FAQ — Preguntas frecuentes¶
1. ¿Puedo usar pickle en vez de joblib?
Técnicamente sí: joblib usa el protocolo de pickle por debajo. Pero joblib comprime y maneja mejor los arrays grandes de NumPy y es la recomendación oficial de scikit-learn. Lo que NO cambia entre ambos es la seguridad: ninguno debe cargar ficheros de origen no confiable.
2. ¿Por qué FastAPI y no Flask?
Flask es venerable y sigue siendo válido, pero FastAPI da tres cosas gratis que en Flask requieren extensiones y disciplina: validación automática con Pydantic, documentación interactiva /docs y soporte asíncrono nativo con mejor rendimiento bajo carga. Para servir modelos, esa validación automática por sí sola justifica la elección.
3. ¿Necesito Docker si mi empresa tiene un solo servidor? Casi seguro que sí. Docker no es (solo) para escalar: es para reproducibilidad. Garantiza que el entorno de producción es idéntico al de desarrollo y que el próximo despliegue no dependerá de qué versión de Python tenga instalada ese servidor. El coste de aprenderlo se amortiza en el primer "en mi máquina funcionaba".
4. ¿Cada cuánto debo reentrenar mi modelo? No hay número universal: depende de la velocidad a la que cambia tu dominio. Fraude: días/semanas. Churn de telecomunicaciones: meses. Riesgo crediticio: trimestres (y con regulación de por medio). La respuesta profesional no es una frecuencia sino un sistema: monitoriza el drift (PSI, distribución de predicciones, métricas retrasadas) y reentrena cuando los datos lo pidan, con un reentrenamiento periódico de seguridad.
5. ¿Qué hago si el modelo reentrenado es peor que el actual? No lo despliegues — para eso está la validación del checklist (métrica del candidato ≥ métrica en producción sobre el mismo hold-out). Investiga por qué: ¿datos recientes de peor calidad?, ¿menos volumen?, ¿un bug en el pipeline de datos? Un candidato peor es información valiosa sobre tus datos, no un trámite fallido.
6. ¿El PSI funciona con cualquier tipo de feature? Con numéricas (binning por cuantiles) y categóricas (una "bin" por categoría) funciona bien. Para features de altísima cardinalidad (IDs, texto libre) no tiene sentido directo: monitoriza en su lugar estadísticos derivados (longitud, tasa de valores nuevos) o embeddings agregados. Y recuerda: PSI detecta que algo cambió, no por qué — el diagnóstico sigue siendo trabajo tuyo.
7. ¿Debo cachear todas las predicciones en Redis? No. La caché compensa cuando (a) hay entradas repetidas con frecuencia y (b) la predicción es válida durante un tiempo razonable. Churn consultado por varios sistemas: sí. Scoring de fraude por transacción (cada entrada es única e irrepetible): no — solo añadirías latencia y complejidad.
8. ¿Qué diferencia hay entre /health y simplemente probar /predict?
/health es barato, no requiere payload válido y está pensado para que máquinas (Docker, Kubernetes, balanceadores) lo llamen cada pocos segundos. Probar /predict con un caso real es más profundo pero más caro; algunos equipos añaden un /health/deep que hace una predicción canónica. Ambos patrones son válidos; el /health ligero es el mínimo imprescindible.
9. ¿MLflow sustituye a mi convención de nombres de artefactos? La formaliza. Con MLflow, el registry gestiona versiones, estados y metadatos por ti, con interfaz y API. La convención manual de la sección 2.3 es el mismo concepto en modo artesanal — perfecta para empezar y para entender qué hace el registry por debajo.
10. ¿Todo esto aplica igual a los LLMs y a la IA generativa?
Los principios sí (versionado, validación de entrada, monitorización, evaluación continua); la mecánica cambia (no serializas un .joblib de 5 MB sino que gestionas pesos gigantes o llamadas a APIs externas, y la "métrica retrasada" se convierte en evaluación de calidad de texto). Lo verás en los módulos de LLMs; la mentalidad que has construido aquí es exactamente la que necesitarás.
15. Resumen del capítulo¶
- La mayoría de los modelos nunca llega a producción porque operar un modelo (latencia, carga, datos sucios, versiones, drift) es una disciplina distinta de entrenarlo. El ciclo de vida es un círculo, no una línea.
- Serialización:
joblib.dumpdel pipeline entero, con versionadonombre_versión_fecha_métrica, metadatos JSON, semillas fijas y requirements congelados. Nunca cargues artefactos de fuentes no confiables. - Serving: batch (simple y suficiente sorprendentemente a menudo), API REST (cuando la predicción se necesita en el momento), streaming (eventos de alto volumen). Elige el patrón más simple que cumpla el requisito.
- FastAPI: validación Pydantic campo a campo como primera defensa (422 para culpa del cliente, 500 opaco para culpa nuestra), modelo cargado una vez en el
lifespan,/healthpara los orquestadores, logging de cada predicción,/docsgratis. - Docker: la imagen es el molde, el contenedor la instancia. Dockerfile con capas ordenadas (requirements antes que código),
.dockerignore, usuario no-root,--host 0.0.0.0. Compose orquesta API + Redis (caché con expiración y versión en la clave). - Drift: data drift (cambia P(X), detectable al instante con PSI) vs concept drift (cambia P(y|X), requiere etiquetas retrasadas). Monitoriza en tres capas y reentrena con política híbrida.
- Testing: robustez del pipeline, contrato de la API con TestClient, humo del modelo. CI valida artefactos congelados; reentrenar es otro pipeline.
- MLOps: niveles de madurez 0→2; usa las herramientas (MLflow, DVC) cuando tu escala las pida, no antes.
- Y la lección del caso empresarial: un modelo puede devolver 200 durante meses mientras se equivoca cada vez más. La monitorización no es opcional; es la diferencia entre un sistema de ML y una bomba de relojería con API.
Con esto cierras la teoría del módulo de Machine Learning. Te esperan los ejercicios integradores del módulo — y más adelante, los módulos 10-BACKEND y 14-DEVOPS profundizarán en FastAPI y Docker con el rigor que aquí solo hemos podido esbozar.
16. Bibliografía y recursos¶
- FastAPI — Documentación oficial (incluye tutorial completo y sección de ML): https://fastapi.tiangolo.com/
- Pydantic — Documentación: https://docs.pydantic.dev/
- Docker — Documentación oficial (Get Started y guía de Python): https://docs.docker.com/
- Docker Compose — Referencia: https://docs.docker.com/compose/
- MLflow — Documentación: https://mlflow.org/docs/latest/
- DVC — Data Version Control: https://dvc.org/doc
- Redis — Documentación: https://redis.io/docs/
- scikit-learn — Model persistence (joblib, seguridad, alternativas como skops/ONNX): https://scikit-learn.org/stable/model_persistence.html
- Gama, J. et al. (2014). A survey on concept drift adaptation. ACM Computing Surveys. https://dl.acm.org/doi/10.1145/2523813
- Lu, J. et al. (2019). Learning under Concept Drift: A Review. IEEE TKDE. https://arxiv.org/abs/2004.05785
- Sculley, D. et al. (2015). Hidden Technical Debt in Machine Learning Systems. NeurIPS. https://papers.nips.cc/paper/2015/hash/86df7dcfd896fcaf2674f757a2463eba-Abstract.html
- Breck, E. et al. (2017). The ML Test Score: A Rubric for ML Production Readiness. IEEE Big Data. https://research.google/pubs/pub46555/
- Google Cloud. MLOps: Continuous delivery and automation pipelines in machine learning: https://cloud.google.com/architecture/mlops-continuous-delivery-and-automation-pipelines-in-machine-learning
- Evidently AI — guías prácticas de monitorización y drift: https://www.evidentlyai.com/blog
- Huyen, C. (2022). Designing Machine Learning Systems. O'Reilly. https://www.oreilly.com/library/view/designing-machine-learning/9781098107956/
| Anterior | Módulo | Siguiente |
|---|---|---|
| Capítulo 6: Ensembles y boosting | 02-MACHINE-LEARNING | Ejercicios del módulo |