La decisión está tomada: además del batch semanal para marketing, CineClick necesita un servicio online que, en el momento en que un cliente pulsa "cancelar suscripción", devuelva su probabilidad de abandono en menos de 300 ms para que producto decida si mostrar una oferta. En esta lección construimos ese servicio con FastAPI: definiremos contratos de entrada y salida con Pydantic, cargaremos al campeón desde el registry al arrancar, expondremos los endpoints /predecir, /salud y /version, y — cumpliendo el mandato de la lección 03-03 — importaremos construir_features del paquete instalable en lugar de reescribirla. Al final tendrás el servicio corriendo en tu máquina con uvicorn, probado desde la documentación interactiva y desde curl, y dejando en los logs la semilla de la monitorización que llegará en el módulo 6.
Contenido
- Por qué FastAPI para servir modelos
- Estructura del servicio en el repo
- Esquemas Pydantic: el contrato de entrada y salida
- Cargar el modelo al arrancar: lifespan
- El endpoint POST /predecir
- Healthcheck y versión: GET /salud y GET /version
- Probar el servicio: uvicorn, /docs y curl
- Logging estructurado de cada predicción
Por qué FastAPI para servir modelos
Hay muchas formas de exponer un modelo por HTTP (Flask, Django, un servidor a mano). FastAPI se ha convertido en el estándar de facto para servir ML en Python por cuatro razones concretas:
- Tipado con Pydantic: defines la entrada y la salida como clases Python con tipos, y FastAPI valida automáticamente cada petición contra ese esquema. En un servicio de ML esto es oro: la causa número uno de predicciones absurdas en producción es una entrada malformada que el modelo traga sin quejarse (un
planinexistente, una antigüedad negativa). Con Pydantic, la petición inválida muere en la puerta con un error 422 explicativo, antes de tocar el modelo. - Documentación OpenAPI automática: el servicio publica en
/docsuna interfaz interactiva generada a partir de los esquemas. El equipo de producto de CineClick puede ver y probar el contrato sin leer ni una línea de Python. - Soporte async nativo: FastAPI corre sobre un servidor ASGI (uvicorn) y puede atender muchas conexiones concurrentes. Matiz importante para ML:
predictde scikit-learn es código síncrono ligado a CPU, así que lo declararemos en un endpointdefnormal (noasync def) y FastAPI lo ejecutará en un threadpool sin bloquear el bucle de eventos. - Rendimiento y ergonomía: es de lo más rápido del ecosistema Python y el código resultante es corto y legible — verás que el servicio completo cabe en dos ficheros.
Añadimos las dependencias al proyecto (y las congelamos en el lock, como manda el módulo 2):
# añadir a pyproject.toml en [project.dependencies]: # fastapi==0.115.5 # uvicorn[standard]==0.32.1 pip-compile pyproject.toml -o requirements.lock # regenerar el lock pip install -e . # reinstalar el paquete
Estructura del servicio en el repo
El servicio vive dentro del paquete cineclick_churn, no en un repo aparte. Esta decisión es consecuencia directa de 03-03: el servicio debe importar construir_features del mismo paquete que usa el entrenamiento, y tenerlos juntos garantiza que ambos se instalan y versionan a la vez.
cineclick-churn/ ├── src/cineclick_churn/ │ ├── data.py │ ├── features.py # fuente única de features (03-03) │ ├── train.py │ ├── evaluate.py │ └── api/ # NUEVO: el servicio de predicción │ ├── __init__.py │ ├── main.py # la app FastAPI: lifespan + endpoints │ └── schemas.py # contratos Pydantic de entrada/salida ├── tests/ ├── configs/ └── ...
Dos ficheros: schemas.py define qué entra y sale; main.py define qué se hace con ello. Separarlos mantiene el contrato legible por sí solo.
Esquemas Pydantic: el contrato de entrada y salida
El esquema de entrada replica las columnas crudas de clientes_churn.csv (menos id_cliente, que viaja aparte para trazabilidad, y abandono, que es lo que predecimos). Fíjate en que las validaciones codifican reglas de dominio, no solo tipos:
# src/cineclick_churn/api/schemas.py
from typing import Literal
from pydantic import BaseModel, Field
class ClienteEntrada(BaseModel):
"""Datos crudos de un cliente, tal como los tiene el frontend."""
id_cliente: str = Field(..., min_length=1, description="Identificador del cliente")
antiguedad_meses: int = Field(..., ge=0, le=600,
description="Meses desde el alta (>= 0)")
horas_semana: float = Field(..., ge=0, le=168,
description="Horas de visionado esta semana")
tickets_soporte: int = Field(..., ge=0,
description="Tickets de soporte abiertos")
plan: Literal["basico", "estandar", "premium"]
metodo_pago: Literal["tarjeta", "domiciliacion", "paypal"]
descuento_activo: bool
class PrediccionSalida(BaseModel):
"""Respuesta del servicio: probabilidad, decisión y trazabilidad."""
id_cliente: str
probabilidad_abandono: float = Field(..., ge=0, le=1)
prediccion: bool # True = se predice abandono
umbral: float # umbral aplicado en esta respuesta
version_modelo: str # p. ej. "2" — la versión del registryDesglose de las decisiones:
ge=0enantiguedad_mesesytickets_soportecorta en seco entradas negativas — que, recuerda, romperían silenciosamente la featureratio_tickets(elclip(lower=1)protege contra divisiones raras, pero una antigüedad de -3 es un dato corrupto que no debe llegar tan lejos).le=168enhoras_semana: una semana tiene 168 horas; más que eso es un bug del emisor, no un cliente muy fan.Literal[...]enplanymetodo_pagorestringe a los valores con los que el modelo se entrenó. Si mañana negocio lanza el plan"familiar", la API devolverá 422 en lugar de dejar que el modelo alucine sobre una categoría que nunca vio — y ese 422 es la alarma temprana de que hay que reentrenar y actualizar el contrato.PrediccionSalidadevuelve no solo la probabilidad sino el umbral aplicado y la versión del modelo: cada respuesta es autoexplicativa y auditable. Cuando en el módulo 6 investiguemos una deriva, saber qué versión respondió cada petición será imprescindible.
Cargar el modelo al arrancar: lifespan
Regla de oro del serving: el modelo se carga una vez, al arrancar el proceso — nunca dentro del endpoint. Cargar desde el registry cuesta segundos; hacerlo por petición destruiría cualquier presupuesto de latencia. FastAPI ofrece el patrón lifespan para ejecutar código en el arranque y la parada:
# src/cineclick_churn/api/main.py (parte 1: arranque)
import logging
import os
import time
from contextlib import asynccontextmanager
import mlflow
import pandas as pd
from fastapi import FastAPI, HTTPException
from cineclick_churn.features import construir_features # LA fuente única
from cineclick_churn.api.schemas import ClienteEntrada, PrediccionSalida
logger = logging.getLogger("cineclick_churn.api")
MODEL_URI = "models:/churn-cineclick@champion" # el contrato fijado en 03-02
UMBRAL_DEFECTO = float(os.getenv("UMBRAL_ABANDONO", "0.5"))
estado = {} # aquí vivirán el modelo y sus metadatos durante toda la vida del proceso
@asynccontextmanager
async def lifespan(app: FastAPI):
# --- ARRANQUE ---
# MLFLOW_TRACKING_URI debe estar en el entorno; si falta, mejor saberlo YA.
tracking_uri = os.environ["MLFLOW_TRACKING_URI"] # KeyError = fallo claro
mlflow.set_tracking_uri(tracking_uri)
logger.info("Cargando modelo desde %s ...", MODEL_URI)
try:
estado["modelo"] = mlflow.sklearn.load_model(MODEL_URI)
cliente = mlflow.MlflowClient()
mv = cliente.get_model_version_by_alias("churn-cineclick", "champion")
estado["version_modelo"] = mv.version # "2" a día de hoy
except Exception:
logger.exception("No se pudo cargar el modelo del registry. Abortando.")
raise # fallar rápido: mejor un proceso muerto que un zombi sin modelo
logger.info("Modelo churn-cineclick v%s cargado.", estado["version_modelo"])
yield # ---- aquí el servicio atiende peticiones ----
# --- PARADA ---
estado.clear()
app = FastAPI(title="CineClick Churn API", version="0.1.0", lifespan=lifespan)La nota de resiliencia merece pararse: si el registry no responde al arrancar, hay dos filosofías. La equivocada es capturar la excepción, arrancar "de todos modos" y devolver errores en cada /predecir — un servicio que parece sano pero no puede hacer su trabajo (un zombi). La correcta, y la que implementamos, es fallar rápido y claro: el raise mata el proceso con un mensaje inequívoco en el log, y el orquestador (Docker en 04-03, Kubernetes en 04-04) lo detectará y reintentará o alertará. Un proceso que muere ruidosamente se diagnostica en minutos; un zombi silencioso, en días.
El endpoint POST /predecir
# src/cineclick_churn/api/main.py (parte 2: predicción)
@app.post("/predecir", response_model=PrediccionSalida)
def predecir(cliente: ClienteEntrada, umbral: float | None = None) -> PrediccionSalida:
"""Devuelve la probabilidad de abandono de UN cliente."""
t0 = time.perf_counter()
umbral_aplicado = umbral if umbral is not None else UMBRAL_DEFECTO
# 1. Del JSON validado a un DataFrame de una fila con las columnas crudas
df = pd.DataFrame([cliente.model_dump()])
# 2. Features: la MISMA función que usó el entrenamiento. Ni una línea copiada.
X = construir_features(df.drop(columns=["id_cliente"]))
# 3. Probabilidad de la clase positiva (abandono)
prob = float(estado["modelo"].predict_proba(X)[0, 1])
# 4. Decisión binaria según el umbral
salida = PrediccionSalida(
id_cliente=cliente.id_cliente,
probabilidad_abandono=round(prob, 4),
prediccion=prob >= umbral_aplicado,
umbral=umbral_aplicado,
version_modelo=estado["version_modelo"],
)
latencia_ms = (time.perf_counter() - t0) * 1000
logger.info("prediccion", extra={"datos_evento": {
"id_cliente": cliente.id_cliente,
"probabilidad": salida.probabilidad_abandono,
"prediccion": salida.prediccion,
"version_modelo": salida.version_modelo,
"latencia_ms": round(latencia_ms, 1),
}})
return salidaPaso a paso:
cliente.model_dump()convierte el objeto Pydantic (ya validado) en un diccionario, ypd.DataFrame([...])lo envuelve en un DataFrame de una fila — el formato queconstruir_featuresespera, idéntico al de entrenamiento.construir_featuresimportada del paquete. Este es el momento en que la decisión de 03-03 paga:ratio_tickets = tickets_soporte / antiguedad_meses.clip(lower=1)se calcula con la implementación canónica. Si alguien la mejora mañana enfeatures.py, entrenamiento y serving cambian a la vez — el training/serving skew es estructuralmente imposible.predict_proba(X)[0, 1]: fila 0 (solo hay una), columna 1 (probabilidad de la clase positiva). Devolvemos la probabilidad, no solo el booleano, porque el consumidor puede querer graduar la oferta.- El umbral es configurable (variable de entorno
UMBRAL_ABANDONO, sobreescribible por petición con el query paramumbral). El 0.5 por defecto es solo el punto de partida matemático: el umbral real es una decisión de negocio. Con el campeón actual (precision 0.55, recall 0.68), bajar el umbral captura más futuros abandonos a costa de ofrecer descuentos a clientes que no se iban a ir; subirlo hace lo contrario. Producto y finanzas deben elegir ese punto con una cuenta de costes — la API solo tiene que permitir cambiarlo sin redesplegar.
Healthcheck y versión: GET /salud y GET /version
# src/cineclick_churn/api/main.py (parte 3: operación)
@app.get("/salud")
def salud() -> dict:
"""Healthcheck: ¿está el proceso vivo Y con modelo cargado?"""
if "modelo" not in estado:
raise HTTPException(status_code=503, detail="Modelo no cargado")
return {"estado": "ok", "modelo": "churn-cineclick",
"version_modelo": estado["version_modelo"]}
@app.get("/version")
def version() -> dict:
"""Qué corre exactamente aquí: versión del servicio y del modelo."""
return {"servicio": app.version, # 0.1.0
"modelo_uri": MODEL_URI,
"version_modelo": estado.get("version_modelo", "desconocida")}/saludno responde "estoy vivo" sin más: comprueba que el modelo está en memoria. Este matiz importa porque es el endpoint que Docker (04-03) y las probes de Kubernetes (04-04) llamarán para decidir si el contenedor recibe tráfico o se reinicia. Un healthcheck que miente es peor que no tener healthcheck./versionresponde a la pregunta operativa por excelencia: "¿qué modelo está sirviendo esto AHORA?". Cuando en 03-02 movimos el alias@champion, dijimos que el rollback era "mover un alias";/versiones cómo verificas, tras reiniciar el servicio, que el cambio surtió efecto.
Probar el servicio: uvicorn, /docs y curl
Con el servidor de MLflow corriendo (el de sqlite del módulo 3), arrancamos:
# terminal 1: el servidor MLflow del módulo 3 (si no está ya corriendo) mlflow server --backend-store-uri sqlite:///mlflow.db --host 127.0.0.1 --port 5000 # terminal 2: el servicio de predicción export MLFLOW_TRACKING_URI=http://127.0.0.1:5000 uvicorn cineclick_churn.api.main:app --host 0.0.0.0 --port 8000
En el log verás Cargando modelo desde models:/churn-cineclick@champion ... y después Modelo churn-cineclick v2 cargado. — el lifespan en acción. Abre ahora http://localhost:8000/docs: FastAPI ha generado la documentación interactiva con los tres endpoints, los esquemas con sus restricciones (antiguedad_meses >= 0, los valores permitidos de plan) y un botón Try it out para lanzar peticiones desde el navegador. Este /docs es el contrato que le pasas al equipo de producto.
Probemos con curl y un cliente ficticio de perfil arriesgado (poca antigüedad, poco uso, tickets):
curl -s -X POST http://localhost:8000/predecir \
-H "Content-Type: application/json" \
-d '{
"id_cliente": "CLI-04217",
"antiguedad_meses": 3,
"horas_semana": 1.5,
"tickets_soporte": 4,
"plan": "basico",
"metodo_pago": "tarjeta",
"descuento_activo": false
}'Respuesta:
{
"id_cliente": "CLI-04217",
"probabilidad_abandono": 0.8412,
"prediccion": true,
"umbral": 0.5,
"version_modelo": "2"
}Y la validación en acción — un plan que no existe:
curl -s -X POST http://localhost:8000/predecir \
-H "Content-Type: application/json" \
-d '{"id_cliente": "CLI-1", "antiguedad_meses": 3, "horas_semana": 1.5,
"tickets_soporte": 4, "plan": "familiar", "metodo_pago": "tarjeta",
"descuento_activo": false}'
# → HTTP 422: "Input should be 'basico', 'estandar' or 'premium'"El modelo ni se enteró. Eso es Pydantic haciendo de portero.
Una nota sobre seguridad, sin desviarnos: este servicio correrá en la red interna de CineClick, pero aun así el mínimo exigible es una API key simple (una cabecera X-API-Key verificada con una dependencia de FastAPI contra un secreto en variable de entorno). La autenticación empresarial seria — OAuth2, mTLS, gateway — es terreno de la plataforma de la empresa, no de esta lección; lo importante es que el endpoint no quede abierto a cualquiera ni siquiera "temporalmente".
Logging estructurado de cada predicción
Ya lo has visto en el endpoint: cada predicción emite una línea de log con campos estructurados, no una frase libre. Configuramos la salida como JSON para que sea procesable por máquinas:
# src/cineclick_churn/api/logging_config.py — formateador JSON mínimo
import json, logging, hashlib
class FormateadorJSON(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
base = {"ts": self.formatTime(record), "nivel": record.levelname,
"logger": record.name, "mensaje": record.getMessage()}
base.update(getattr(record, "datos_evento", {}))
return json.dumps(base, ensure_ascii=False)
def hash_entrada(payload: dict) -> str:
"""Hash estable del input: permite detectar duplicados y auditar sin guardar PII."""
return hashlib.sha256(
json.dumps(payload, sort_keys=True).encode()
).hexdigest()[:16]Cada línea de log de predicción contiene: hash del input (con hash_entrada sobre el payload — trazable sin volcar datos del cliente en texto plano), probabilidad, predicción, versión del modelo y latencia en ms. Parece poco, pero es exactamente la semilla de la monitorización del módulo 6: con estos logs acumulados podremos pintar la distribución de probabilidades a lo largo del tiempo (deriva, 06-02), la latencia p95 (06-01) y el volumen de peticiones. La regla: si no lo registras desde el día uno, no existirá cuando lo necesites.
{"ts": "2026-07-07 11:32:04", "nivel": "INFO", "logger": "cineclick_churn.api",
"mensaje": "prediccion", "id_cliente": "CLI-04217", "probabilidad": 0.8412,
"prediccion": true, "version_modelo": "2", "latencia_ms": 14.3}Errores Comunes y Consejos
- Error: cargar el modelo dentro del endpoint. Cada petición pagaría segundos de carga desde el registry. El modelo se carga una vez en el
lifespany vive en memoria. - Error: reimplementar las features en la API. "Es solo una división" — hasta que alguien cambia el
clipenfeatures.pyy el serving se queda con la versión vieja: training/serving skew silencioso, el bug más caro de detectar. En CineClick está prohibido por decisión de 03-03: siemprefrom cineclick_churn.features import construir_features. - Error: arrancar sin modelo y devolver errores por petición. El servicio zombi. Falla rápido en el arranque (
raiseen el lifespan) y deja que el orquestador haga su trabajo. - Error: usar
async defen el endpoint de predicción.predict_probaes CPU síncrono: dentro deasync defbloquearía el bucle de eventos y hundiría la concurrencia. Condef, FastAPI lo lleva al threadpool automáticamente. - Error: aceptar cualquier string en los campos categóricos. Sin
Literal, unplan="Básico"(con mayúscula y tilde) llegaría al modelo como categoría desconocida. El esquema debe reflejar el vocabulario exacto del entrenamiento. - Consejo: devuelve siempre la versión del modelo en la respuesta. Cuesta un campo y convierte cada respuesta en evidencia auditable.
- Consejo: trata el umbral como configuración de negocio, no como constante del código. Cambiar de 0.5 a 0.4 no debería requerir un despliegue.
Ejercicios
Ejercicio 1
Producto pide un endpoint POST /predecir-lote que reciba una lista de clientes (máximo 100) y devuelva una lista de predicciones, para puntuar de una vez a todos los asistentes a un webinar de retención. Impleméntalo reutilizando los esquemas existentes. Pista: predict_proba acepta un DataFrame de N filas.
Ejercicio 2
Escribe un test con TestClient de FastAPI (en tests/test_api.py) que verifique que una petición con antiguedad_meses: -5 recibe un 422 sin llegar a invocar el modelo. Pista: puedes monkeypatchear estado para no depender del registry en los tests.
Ejercicio 3
Ahora mismo, si MLflow está caído cuando arranca el servicio, el proceso muere (correcto). Pero ¿qué pasa si MLflow cae después, con el servicio ya arrancado? Razona: (a) ¿sigue funcionando /predecir?, (b) ¿qué devuelve /salud?, (c) ¿es este comportamiento aceptable?
Soluciones
Solución 1
# en schemas.py
class LoteEntrada(BaseModel):
clientes: list[ClienteEntrada] = Field(..., min_length=1, max_length=100)
# en main.py
@app.post("/predecir-lote", response_model=list[PrediccionSalida])
def predecir_lote(lote: LoteEntrada) -> list[PrediccionSalida]:
df = pd.DataFrame([c.model_dump() for c in lote.clientes])
X = construir_features(df.drop(columns=["id_cliente"]))
probs = estado["modelo"].predict_proba(X)[:, 1] # UN solo predict para N filas
return [
PrediccionSalida(
id_cliente=c.id_cliente,
probabilidad_abandono=round(float(p), 4),
prediccion=float(p) >= UMBRAL_DEFECTO,
umbral=UMBRAL_DEFECTO,
version_modelo=estado["version_modelo"],
)
for c, p in zip(lote.clientes, probs)
]Lo esencial: una sola llamada a construir_features y a predict_proba para las N filas, no un bucle de N predicciones — la diferencia de rendimiento la cuantificaremos en 04-05. El max_length=100 protege al servicio de que alguien le envíe la base entera por HTTP (para eso está el batch de 04-01).
Solución 2
# tests/test_api.py
from fastapi.testclient import TestClient
from cineclick_churn.api import main
def test_antiguedad_negativa_devuelve_422(monkeypatch):
# Modelo falso en el estado: si el endpoint lo llamara, explotaría el test
class ModeloProhibido:
def predict_proba(self, X):
raise AssertionError("El modelo no debe invocarse con entrada inválida")
monkeypatch.setitem(main.estado, "modelo", ModeloProhibido())
monkeypatch.setitem(main.estado, "version_modelo", "test")
cliente_http = TestClient(main.app) # sin ejecutar lifespan real
respuesta = cliente_http.post("/predecir", json={
"id_cliente": "CLI-X", "antiguedad_meses": -5, "horas_semana": 2,
"tickets_soporte": 0, "plan": "basico", "metodo_pago": "tarjeta",
"descuento_activo": False,
})
assert respuesta.status_code == 422Si Pydantic dejara pasar la entrada, ModeloProhibido.predict_proba lanzaría el AssertionError y el test fallaría con un mensaje claro. Este test entrará en el CI de la lección 05-01.
Solución 3
(a) Sí. El modelo ya está en memoria; /predecir no toca MLflow en el camino de la petición. Que el registry esté caído no afecta al tráfico en curso — es una virtud del patrón "cargar al arrancar". (b) /salud devuelve 200 ok, porque solo comprueba que el modelo está en memoria, no la conectividad con MLflow — y eso es correcto: el healthcheck debe medir la capacidad de hacer el trabajo (predecir), no la salud de dependencias que ya no necesita. (c) Aceptable con un matiz: los nuevos arranques (un reinicio, una réplica nueva al escalar) sí fallarán mientras MLflow esté caído. Es un riesgo conocido que apuntamos para 04-03: allí discutiremos la alternativa de hornear el modelo en la imagen justamente para eliminar esta dependencia en el arranque, y por qué CineClick aun así elige el registry.
Conclusión
El modelo de Laura ya atiende peticiones. El servicio tiene contrato explícito (Pydantic valida dominio, no solo tipos), carga al campeón una única vez desde models:/churn-cineclick@champion con política de fallar rápido, predice importando construir_features de la fuente única — cero lógica duplicada —, expone /salud y /version para que las máquinas y los humanos sepan qué corre, y registra cada predicción en logs JSON que el módulo 6 convertirá en monitorización. Todo esto funciona... en tu máquina, con tu Python 3.11.9, tu requirements.lock instalado y tu variable de entorno apuntando a tu MLflow. El servidor de producción no tiene nada de eso, y "instálalo a mano y cruza los dedos" es exactamente la clase de proceso que este curso vino a eliminar. La siguiente lección congela el servicio completo — sistema operativo, Python, dependencias y código — en una imagen Docker que corre idéntica en cualquier parte: el "funciona en mi máquina" está a punto de dejar de ser una excusa.
Curso de MLOps
Módulo 1: Fundamentos de MLOps
- Qué es MLOps y por qué los modelos mueren en el notebook
- El ciclo de vida de un modelo de ML en producción
- Niveles de madurez MLOps y roles del equipo
- El proyecto del curso: del notebook a producción
Módulo 2: Del notebook al código reproducible
- Estructura de un proyecto de ML: del notebook al paquete
- Entornos reproducibles y gestión de dependencias
- Versionado de datos con DVC
- Pipelines de entrenamiento reproducibles
Módulo 3: Experimentos y registro de modelos
- Tracking de experimentos con MLflow
- Model registry: versionar y promocionar modelos
- Feature stores: cuándo y para qué
Módulo 4: Servir modelos en producción
- Patrones de despliegue: batch, online y streaming
- Un servicio de predicción con FastAPI
- Empaquetado con Docker
- Escalado y despliegue: Kubernetes y serverless
- Optimización de la inferencia: latencia y coste
Módulo 5: Automatización: CI/CD y orquestación
- CI para ML: tests de código, datos y modelos
- CD: automatizar el despliegue del modelo
- Orquestación de pipelines de ML
- Estrategias de release: shadow, canary y A/B
