Saltar a contenido

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

  1. La brecha entre el notebook y producción
  2. Serialización: congelar el modelo sin romperlo
  3. Patrones de serving: ¿cómo entrego predicciones?
  4. API REST con FastAPI
  5. Docker: empaquetar el servicio
  6. Monitorización y drift: la sección estrella
  7. Testing de sistemas de ML
  8. MLOps: panorámica honesta
  9. Ejemplo integrador: el modelo de churn a producción
  10. Caso empresarial: la degradación silenciosa
  11. Buenas prácticas
  12. Malas prácticas
  13. Errores comunes
  14. FAQ — Preguntas frecuentes
  15. Resumen del capítulo
  16. 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:

  1. Es un ciclo, no una línea. La flecha de reentrenamiento vuelve a evaluación. Un modelo en producción nunca está "terminado".
  2. 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:

  1. Fija random_state en 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.
  2. 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 InconsistentVersionWarning si detecta la discrepancia — nunca lo ignores.
  3. 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:

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

pip install "fastapi[standard]" uvicorn joblib scikit-learn pandas
  • fastapi es el framework; el extra [standard] añade utilidades comunes.
  • uvicorn es 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 el ge=0, un bug del cliente que envíe -3 entraría al modelo y produciría una predicción sin sentido sin que nadie se entere. El le=600 corta 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. Literal convierte ese fallo silencioso en un error 422 visible e inmediato.
  • cargos_mensuales: float, gt=0 — un cargo de 0 o 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
email_dominio: str = Field(min_length=4, max_length=50,
                           description="Dominio del email del cliente")
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:

  1. Toma la distribución de una feature en entrenamiento (la referencia o expected).
  2. Divídela en cubos (bins), por ejemplo 10 deciles.
  3. 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.
  4. Si los porcentajes son parecidos → distribución estable. Si divergen → drift.

La fórmula, cubo a cubo:

PSI = Σ  (actual_i − esperado_i) × ln(actual_i / esperado_i)
     bins

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:

  1. 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.
  2. 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.
  3. 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 up levanta el servicio en limpio y /health responde ok.
  • [ ] Una petición de prueba real por curl devuelve 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):

  1. Log completo de predicciones con inputs, outputs, versión y timestamp (sección 6.5).
  2. PSI semanal por feature con alertas a Slack al cruzar 0.10 y escalado a incidencia al cruzar 0.25 (sección 6.2).
  3. 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).
  4. 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 %.
  5. Política híbrida de reentrenamiento: trimestral por calendario + trigger automático por drift (sección 6.4).
  6. 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

  1. Serializa el pipeline entero, nunca solo el estimador final.
  2. Versiona todo: artefacto (nombre + fecha + métrica), metadatos JSON, requirements con ==, código en git con el commit en los metadatos.
  3. Valida la entrada en la puerta con Pydantic: tipos, rangos, Literal para categorías. Fail fast, fail loud.
  4. Carga el modelo una vez en el lifespan, no por petición.
  5. Endpoint /health siempre, y que compruebe de verdad que el modelo está cargado.
  6. Loguea cada predicción (input, output, versión, timestamp, latencia): es la materia prima de la monitorización y la auditoría.
  7. Monitoriza en tres capas: distribución de entradas (PSI), distribución de predicciones, y métricas reales cuando lleguen las etiquetas.
  8. Política de reentrenamiento híbrida: calendario base + triggers por drift, con aprobación humana del despliegue.
  9. Tests en tres familias: robustez del pipeline (nulos, categorías nuevas), contrato de la API (TestClient), humo del modelo (rangos, casos canónicos, determinismo).
  10. Separa el ciclo del código del ciclo del modelo: CI valida artefactos congelados; el reentrenamiento es otro pipeline.
  11. Contenedores mínimos y seguros: imagen slim, .dockerignore, usuario no-root, tags de versión explícitos.
  12. 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

  1. Cargar pickle/joblib de fuentes no confiables (ejecución de código arbitrario).
  2. Guardar solo el modelo sin el preprocesado y "reimplementar" las transformaciones a mano en la API (divergirán, garantizado).
  3. Desplegar sin plan de monitorización ("si falla, dará error" — el caso empresarial demuestra que no).
  4. Devolver el traceback interno al cliente en los errores 500.
  5. pip install sin versiones fijadas; latest como tag de Docker en producción.
  6. Cachear predicciones sin expiración ni versión del modelo en la clave.
  7. Reentrenar y desplegar automáticamente sin validación ni aprobación cuando el equipo aún no tiene madurez para ello.
  8. Meter datos, secretos (.env) o el entorno virtual dentro de la imagen Docker.
  9. Confundir "la API responde 200" con "el modelo funciona".
  10. 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.dump del pipeline entero, con versionado nombre_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, /health para los orquestadores, logging de cada predicción, /docs gratis.
  • 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


Anterior Módulo Siguiente
Capítulo 6: Ensembles y boosting 02-MACHINE-LEARNING Ejercicios del módulo