El servicio de churn está desplegado con 3 réplicas y autoescalado. La pregunta siguiente la hará finanzas antes que ingeniería: ¿cuánto cuesta esto y podría costar menos? Y producto añadirá la suya: ¿de verdad respondemos siempre dentro de los 300 ms? La optimización de inferencia responde a ambas, pero tiene una regla de oro que esta lección repetirá hasta el aburrimiento: medir antes de optimizar. Optimizar sin medir es adivinar, y en inferencia las intuiciones fallan estrepitosamente (spoiler: el predict casi nunca es donde se va el tiempo, y la GPU casi nunca es la respuesta para un modelo tabular). En esta lección mediremos el servicio de CineClick con percentiles — no con medias —, desglosaremos dónde se va cada milisegundo, y aplicaremos las optimizaciones por orden de rentabilidad: vectorización, caché, modelo más ligero, ONNX y (des)cartar hardware. Cerraremos el módulo haciendo balance de lo construido... y de todo lo que aún hacemos a mano.
Contenido
- Medir antes de optimizar: percentiles, throughput y coste
- Medir el servicio de CineClick: un script de carga
- Dónde se va el tiempo en una predicción
- Optimización 1: predicción vectorizada (batch de peticiones)
- Optimización 2: cachear features y respuestas
- Optimización 3: un modelo más ligero, con la pérdida cuantificada
- Optimización 4: ONNX, un formato pensado para inferencia
- Optimización 5 (o no): CPU frente a GPU
- La tabla de palancas y el cierre del módulo
Medir antes de optimizar: percentiles, throughput y coste
Tres métricas definen el rendimiento de un servicio de inferencia:
Latencia por percentiles. La media engaña, y conviene entender por qué con un ejemplo: si 99 peticiones tardan 20 ms y una tarda 2.000 ms, la media es ~40 ms — "todo bien" — mientras un cliente de cada cien sufre 2 segundos mirando la pantalla de cancelación. Por eso se mide con percentiles:
- p50 (mediana): la experiencia típica. La mitad de las peticiones responden más rápido.
- p95: la experiencia de los peores 1 de cada 20. Aquí viven los efectos de picos, GC, contención.
- p99: la cola larga. En servicios de cara al usuario, el p99 es el que rompe los presupuestos — y el primero que empeora cuando algo va mal.
El objetivo de CineClick se expresa así: p95 < 300 ms (no "media de 300 ms": eso permitiría colas horribles).
Throughput: peticiones por segundo que el servicio sostiene sin degradar la latencia. Latencia y throughput se compran mutuamente: al acercarte a la saturación, el p99 se dispara mucho antes de que el servicio "caiga".
Coste por 1.000 predicciones: la métrica que une ingeniería con finanzas. Se calcula desde el coste de infraestructura y el volumen real:
Con números de CineClick: 3 réplicas con request de 0.25 CPU / 512 MiB en un Kubernetes gestionado cuestan del orden de 90 €/mes; con ~600.000 predicciones/mes del flujo de cancelación, sale a 0,15 € por 1.000 predicciones. Este número convierte cualquier optimización en una frase que finanzas entiende: "la caché reduce el coste por mil a la mitad".
Medir el servicio de CineClick: un script de carga
No necesitas una herramienta pesada para una primera medición honesta. Un script con httpx y asyncio que lance peticiones concurrentes y calcule percentiles:
# scripts/medir_carga.py — medición de latencia con percentiles
import asyncio
import statistics
import time
import httpx
URL = "http://localhost:8000/predecir"
CLIENTE_PRUEBA = {
"id_cliente": "CLI-04217", "antiguedad_meses": 3, "horas_semana": 1.5,
"tickets_soporte": 4, "plan": "basico", "metodo_pago": "tarjeta",
"descuento_activo": False,
}
N_PETICIONES = 1000
CONCURRENCIA = 20 # peticiones simultáneas: simula tráfico real, no una cola de a uno
async def una_peticion(cliente: httpx.AsyncClient, sem: asyncio.Semaphore) -> float:
async with sem: # limita la concurrencia al valor fijado
t0 = time.perf_counter()
r = await cliente.post(URL, json=CLIENTE_PRUEBA)
r.raise_for_status()
return (time.perf_counter() - t0) * 1000 # ms
async def main() -> None:
sem = asyncio.Semaphore(CONCURRENCIA)
async with httpx.AsyncClient(timeout=10) as cliente:
t0 = time.perf_counter()
latencias = await asyncio.gather(
*[una_peticion(cliente, sem) for _ in range(N_PETICIONES)]
)
duracion = time.perf_counter() - t0
latencias.sort()
def pct(p: float) -> float:
return latencias[int(len(latencias) * p) - 1]
print(f"peticiones: {N_PETICIONES} concurrencia: {CONCURRENCIA}")
print(f"throughput: {N_PETICIONES / duracion:.0f} req/s")
print(f"p50: {pct(0.50):.1f} ms p95: {pct(0.95):.1f} ms p99: {pct(0.99):.1f} ms")
print(f"media (para comparar): {statistics.mean(latencias):.1f} ms")
if __name__ == "__main__":
asyncio.run(main())Resultado contra el contenedor local de 04-03 (portátil de desarrollo; los números absolutos variarán en tu máquina — lo que importa son las proporciones):
peticiones: 1000 concurrencia: 20 throughput: 210 req/s p50: 38.2 ms p95: 92.5 ms p99: 148.7 ms media (para comparar): 47.1 ms
Lectura: p95 de ~93 ms — dentro del presupuesto de 300 ms con margen. Fíjate en que la media (47 ms) es un 23% mayor que la p50: la cola ya asoma. Para cargas más serias existen herramientas dedicadas — locust (Python, escenarios programables) y k6 (JavaScript, muy usado en plataformas) — que añaden rampas de carga, usuarios virtuales e informes; no las cubrimos aquí, pero el concepto es el mismo: concurrencia controlada + percentiles. Y una distinción de alcance: esto es una medición puntual de rendimiento, una foto; la vigilancia continua de estas mismas métricas en producción es monitorización, y llega en la lección 06-01.
Dónde se va el tiempo en una predicción
Antes de optimizar nada, desglosemos una petición. Instrumentamos cada tramo del endpoint con time.perf_counter() (temporalmente, o mejor: dejando los tiempos por tramo en el log estructurado de 04-02) y promediamos sobre mil peticiones:
| Tramo | Qué hace | Tiempo típico | % del total |
|---|---|---|---|
| Deserialización + validación | JSON → objeto Pydantic validado | ~1 ms | 3% |
| Construcción del DataFrame | dict → pd.DataFrame de 1 fila |
~8 ms | 21% |
construir_features |
ratio_tickets, encodings... sobre 1 fila | ~9 ms | 24% |
predict_proba |
los 300 árboles del RandomForest | ~18 ms | 47% |
| Serialización de respuesta | objeto → JSON | ~1 ms | 3% |
| Red y framework | HTTP, routing, threadpool | ~1 ms | 3% |
Dos sorpresas que casi todo el mundo se lleva la primera vez:
- Más de la mitad del tiempo NO es el modelo. Crear un DataFrame de una fila y pasar features por pandas cuesta casi tanto como los 300 árboles: pandas está diseñado para operar sobre miles de filas, y su sobrecoste fijo por operación se come a las filas solitarias.
- El
predict_probade 1 fila tarda ~18 ms... y el de 1.000 filas tarda ~50 ms. No mil veces más: tres veces más. Ese abismo es la primera optimización.
Moral: si hubiéramos "optimizado el modelo" sin medir, habríamos atacado el 47% ignorando el 45% que se va en preparar una fila. Los perfiles mandan.
Optimización 1: predicción vectorizada (batch de peticiones)
La demostración con números, ejecutable tal cual:
# scripts/demo_vectorizacion.py
import time
import mlflow
import pandas as pd
from cineclick_churn.features import construir_features
modelo = mlflow.sklearn.load_model("models:/churn-cineclick@champion")
df = pd.read_csv("data/clientes_churn.csv").drop(columns=["id_cliente", "abandono"])
X = construir_features(df).head(1000)
# A) 1000 predicciones de 1 fila (lo que hace la API con peticiones sueltas)
t0 = time.perf_counter()
for i in range(1000):
modelo.predict_proba(X.iloc[[i]])
print(f"1000 x predict(1 fila): {time.perf_counter() - t0:.2f} s")
# B) 1 predicción de 1000 filas
t0 = time.perf_counter()
modelo.predict_proba(X)
print(f"1 x predict(1000 filas): {time.perf_counter() - t0:.2f} s")Un factor ~370x. La razón: cada llamada a predict_proba paga un coste fijo (validación de entrada, conversión a arrays, despacho a los árboles) que domina cuando la carga útil es una fila; sobre mil filas, ese coste se amortiza y los árboles procesan en vectorizado. Cómo explotarlo en CineClick:
- El batch semanal ya lo hace bien: el
score_batch.pyde 04-01 puntúa toda la base en un solopredict_proba. Puntuar 100.000 clientes cuesta segundos — jamás escribas ese job como un bucle de llamadas a la API (100.000 × ~40 ms ≈ más de una hora, más el coste de tener la API aguantándolo). - El endpoint
/predecir-lote(ejercicio de 04-02) da a los consumidores internos con N clientes la vía vectorizada: una llamada, unpredict_proba. - Para el flujo de cancelación (peticiones de 1 fila que llegan sueltas) existe una técnica avanzada, el micro-batching adaptativo — acumular las peticiones de una ventana de pocos ms y predecirlas juntas —, que frameworks de serving especializados traen de serie. Con nuestro p95 de 93 ms no la necesitamos; conocerla basta.
Optimización 2: cachear features y respuestas
Segunda palanca: no calcular dos veces lo mismo. Es el patrón híbrido de 04-01 (precomputar + servir de caché) aplicado en pequeño, dentro del servicio:
- Cachear la respuesta completa por hash del input (¡ya calculamos ese hash para el log en 04-02!): si el mismo cliente con los mismos datos pregunta dos veces en poco tiempo — reintentos del frontend, doble clic, el usuario que va y vuelve de la pantalla de cancelación — la segunda respuesta sale de memoria en <1 ms.
- TTL corto y deliberado: la caché caduca (por ejemplo, a los 300 segundos) porque la frescura era la razón de ser del patrón online. Un TTL de 5 minutos es un compromiso explícito: "aceptamos servir una predicción de hace ≤5 min a cambio de recortar coste"; un TTL de horas nos devolvería, por la puerta de atrás, al problema del batch envejecido.
# esquema de caché en memoria con TTL (por réplica; para compartir entre réplicas: Redis)
import time
CACHE: dict[str, tuple[float, "PrediccionSalida"]] = {}
TTL_SEGUNDOS = 300
def obtener_cacheada(clave: str):
entrada = CACHE.get(clave)
if entrada and time.time() - entrada[0] < TTL_SEGUNDOS:
return entrada[1] # acierto: respuesta en microsegundos
return None
def guardar(clave: str, salida) -> None:
CACHE[clave] = (time.time(), salida)La clave es el hash_entrada(payload) de 04-02. Con una tasa de aciertos del 20% (medible: registra aciertos/fallos en el log), el 20% de las peticiones pasa de ~40 ms a <1 ms y — más relevante para finanzas — deja de consumir CPU: menos réplicas del HPA en pico. Si el tramo caro fueran las features (agregaciones sobre histórico, llamadas a otros servicios), se cachearían las features por cliente en lugar de la respuesta; en CineClick construir_features es barata y la respuesta completa es la unidad natural.
Optimización 3: un modelo más ligero, con la pérdida cuantificada
Los 300 árboles con max_depth=20 del campeón, ¿son todos necesarios en serving? Laura reentrena variantes reducidas (mismos datos, mismo pipeline dvc repro, todo trackeado en el experimento churn-cineclick de MLflow, como manda el módulo 3) y medimos las tres dimensiones que importan:
| Variante | Tamaño en disco | p95 latencia (1 fila) | Recall | Precision | F1 |
|---|---|---|---|---|---|
| Campeón v2 (300 árboles, prof. 20) | 145 MB | 92 ms | 0.68 | 0.55 | 0.61 |
| 150 árboles, prof. 20 | 74 MB | 71 ms | 0.67 | 0.55 | 0.60 |
| 100 árboles, prof. 12 | 21 MB | 58 ms | 0.65 | 0.54 | 0.59 |
| 50 árboles, prof. 8 | 4 MB | 49 ms | 0.60 | 0.52 | 0.56 |
(Métricas sobre el mismo conjunto de evaluación del módulo 3; latencias del script de carga, misma máquina.)
La lectura correcta de esta tabla no es técnica sino de negocio: pasar de 300 a 150 árboles ahorra un 20% de latencia y la mitad de memoria a cambio de un punto de recall (0.68 → 0.67) — un punto de recall son clientes que abandonan sin que nadie les hiciera una oferta. ¿Compensa? Con p95 = 92 ms frente a un presupuesto de 300, no hay problema de latencia que justifique pagar recall: CineClick se queda con el campeón intacto. La decisión distinta sería legítima con otro contexto (presupuesto de 50 ms, o memoria cara): lo importante es que ahora es una decisión cuantificada en ambos platos de la balanza, no un "recorté árboles porque iba lento". Si algún día se adopta una variante ligera, entra por el camino de siempre: nueva versión en el registry, revisión humana, alias.
Optimización 4: ONNX, un formato pensado para inferencia
ONNX (Open Neural Network Exchange) es un formato estándar de intercambio de modelos: exportas el modelo entrenado a un grafo de operaciones, y un runtime optimizado (onnxruntime, en C++) lo ejecuta. Para serving aporta dos cosas:
- Inferencia más rápida, especialmente en el caso que nos duele — pocas filas por llamada — porque elimina el sobrecoste de Python/sklearn por predicción.
- Serving sin scikit-learn: el contenedor de inferencia solo necesita
onnxruntime(decenas de MB frente al stack sklearn+scipy+pandas), lo que adelgaza la imagen y elimina la clase de bugs "versión de sklearn distinta al deserializar".
# scripts/exportar_onnx.py — exportar el campeón a ONNX
from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType
import mlflow
modelo = mlflow.sklearn.load_model("models:/churn-cineclick@champion")
# ONNX exige declarar la entrada: N filas x n_features de tipo float
n_features = modelo.n_features_in_
tipo_entrada = [("entrada", FloatTensorType([None, n_features]))]
onx = convert_sklearn(modelo, initial_types=tipo_entrada)
with open("models/churn.onnx", "wb") as f:
f.write(onx.SerializeToString())
# inferencia con el runtime (así lo usaría el servicio)
import onnxruntime as rt
import numpy as np
sesion = rt.InferenceSession("models/churn.onnx")
X_ejemplo = np.random.rand(1, n_features).astype(np.float32) # ilustrativo
probs = sesion.run(None, {"entrada": X_ejemplo})[1] # probabilidadesEn pruebas con el modelo de CineClick, onnxruntime reduce el predict de 1 fila de ~18 ms a ~2-4 ms. Los peajes, para valorarlo honestamente: es una pieza más que mantener (exportación en el pipeline, verificar la equivalencia numérica de las predicciones sklearn vs. ONNX antes de confiar, algún operador no soportado según el modelo), y las features siguen siendo pandas — ONNX acelera el tramo predict, no el pipeline entero. CineClick lo deja anotado como siguiente palanca si el presupuesto de latencia se estrecha; hoy, con p95 en 92 ms, no paga su complejidad.
Optimización 5 (o no): CPU frente a GPU
Hay que decirlo sin rodeos: para un RandomForest sobre datos tabulares, la GPU no compensa. Los árboles de decisión son ramificaciones y comparaciones — trabajo irregular en el que la CPU es excelente — no las multiplicaciones masivas de matrices para las que la GPU existe. Añadir GPU al servicio de churn multiplicaría el coste de cada réplica (una instancia con GPU cuesta varias veces una de CPU) para ganar poco o nada, y complicaría imagen, drivers y scheduling en Kubernetes.
¿Cuándo SÍ es conversación de GPU? Deep learning en inferencia: transformers, visión, modelos de embeddings — y aun entonces solo con batching que llene la GPU (peticiones de 1 en 1 la desperdician; de ahí los servidores de inferencia especializados con micro-batching). La heurística para tu día a día:
| Modelo | Hardware de inferencia razonable |
|---|---|
| Modelos tabulares (árboles, lineales, boosting) | CPU, siempre. Optimiza con las palancas 1–4 |
| Redes pequeñas / embeddings a bajo volumen | CPU suele bastar; mide antes |
| Transformers/visión con volumen o latencia exigente | GPU con batching (o endpoints gestionados) |
Y la última palanca de coste ya la tienes de 04-04: el autoscaling ajusta las réplicas al tráfico (pagar por demanda real), y para el job batch semanal, cómputo efímero o serverless con escala a cero — un job que corre 10 minutos a la semana no debe tener un servidor esperándole los otros 10.070 minutos.
La tabla de palancas y el cierre del módulo
El resumen ejecutivo de la lección:
| Palanca | Ganancia típica | Coste de implementación | ¿CineClick la aplica? |
|---|---|---|---|
| 1. Predicción vectorizada / batch | 10–400x en throughput masivo | Casi nulo (ya escribes bien el batch y /predecir-lote) |
Sí, desde el diseño |
| 2. Caché de respuestas/features (TTL) | Elimina el 100% del cómputo en cada acierto | Bajo (hash ya existente + dict/Redis); vigilar frescura | Sí, TTL 300 s |
| 3. Modelo más ligero | 20–50% latencia, 50–95% memoria | Medio: reentrenar y pagar métrica (decisión de negocio) | No — el recall vale más que los ms |
| 4. ONNX / runtime optimizado | 3–10x en el tramo predict |
Medio: exportación, verificación de equivalencia, pieza extra | Anotada para el futuro |
| 5. GPU | Solo deep learning con batching | Alto (coste instancia, drivers, complejidad) | No — tabular = CPU |
| Autoscaling / escala a cero (04-04) | Pagar por demanda real | Ya implementado (HPA); serverless para el batch | Sí |
El orden no es casual: es rentabilidad decreciente. Las dos primeras palancas cuestan casi nada y las regala una buena arquitectura; las últimas exigen inversión y solo se justifican con mediciones en la mano. Y esa es la disciplina completa: medir (percentiles) → localizar (desglose por tramos) → aplicar la palanca más barata que resuelva → volver a medir.
Hagamos balance del módulo 4. CineClick eligió sus patrones con criterio (batch para marketing, online para la cancelación), construyó el servicio FastAPI con contrato Pydantic, campeón del registry y features de la fuente única, lo congeló en la imagen cineclick/churn-api:0.1.0, lo desplegó en Kubernetes con 3–10 réplicas, probes y dos niveles de rollback, y ahora sabe que responde con p95 de 92 ms a 0,15 € las mil predicciones — medido, no supuesto. Pero repasa cómo ha llegado todo eso a producción: Laura ejecutó los tests a mano, tú construiste la imagen a mano (docker build), la subiste a mano, aplicaste los manifiestos a mano (kubectl apply), y el job batch semanal... aún lo lanza alguien a mano un lunes a las 6:00. Cada "a mano" es un olvido esperando a ocurrir.
Errores Comunes y Consejos
- Error: optimizar sin perfil. La intuición dice "el modelo es lo lento"; la medición dice que la mitad del tiempo era pandas preparando una fila. Sin desglose por tramos, optimizarás el tramo equivocado.
- Error: reportar (y prometer) latencias medias. La media esconde la cola; los usuarios viven en el p95 y el p99. Presupuestos y SLO siempre en percentiles.
- Error: puntuar en masa llamando a la API en bucle. 100.000 clientes por
/predecires una hora de peticiones y carga inútil; el mismo trabajo vectorizado son segundos. Cada patrón (04-01) por su vía. - Error: cachear sin TTL (o con TTL alegre). Una caché eterna convierte tu servicio online en un batch encubierto y desactualizado — justo lo que el caso de la cancelación no tolera. El TTL es una decisión de frescura, escríbela y justifícala.
- Error: recortar el modelo sin cuantificar la pérdida. "Bajé a 50 árboles y vuela" — y el recall cayó de 0.68 a 0.60 sin que nadie pusiera ese coste sobre la mesa. La tabla tamaño-latencia-métrica es obligatoria antes de decidir.
- Error: pedir GPU para un modelo tabular. Es la optimización más cara y menos útil de la lista. CPU + vectorización + caché llegan lejísimos.
- Consejo: deja la medición ejecutable en el repo (
scripts/medir_carga.py): medir el rendimiento debe costar un comando, porque en 05-01 querremos que sea un paso más que se ejecuta solo. - Consejo: acompaña cada propuesta de optimización con su coste por 1.000 predicciones antes/después. Es el idioma común entre ingeniería y negocio.
Ejercicios
Ejercicio 1
Un servicio reporta estas 10 latencias (ms) bajo carga: 22, 24, 25, 25, 26, 28, 30, 31, 45, 950. Calcula media, p50 y p90 (con el método de la lección: valor en la posición int(n × p) - 1 de la lista ordenada). ¿Qué historia cuenta cada métrica y cuál usarías para verificar el presupuesto de 300 ms?
Ejercicio 2
Marketing propone reutilizar /predecir para puntuar los 80.000 clientes de la campaña "porque la API ya está desplegada y escalada". Con los números de la lección (p50 ≈ 38 ms por petición, throughput ≈ 210 req/s con 20 de concurrencia; predict_proba vectorizado de 1.000 filas ≈ 0,05 s), estima cuánto tardaría cada enfoque y escribe la recomendación.
Ejercicio 3
El presupuesto de latencia baja de 300 ms a 60 ms (p95) porque la pantalla de cancelación se rediseña. Con el desglose por tramos y las tablas de la lección, propón un plan por orden de rentabilidad para pasar de p95 = 92 ms a p95 < 60 ms, indicando qué NO harías todavía.
Soluciones
Solución 1
Ordenadas ya lo están. Media = (22+24+25+25+26+28+30+31+45+950)/10 = 120,6 ms. p50 = posición int(10×0.5)-1 = 4 → 26 ms. p90 = posición int(10×0.9)-1 = 8 → 45 ms (y esa petición de 950 ms vive en el p100/máximo; con solo 10 muestras el p99 no es estimable con seriedad — otra lección: los percentiles altos necesitan muchas muestras). Historias: la media (120 ms) sugiere un servicio 4-5 veces más lento de lo que experimenta el usuario típico (p50 = 26 ms) — está secuestrada por un único outlier; el p90 dice que 9 de cada 10 peticiones van en ≤45 ms. Para el presupuesto de 300 ms se verifica el percentil comprometido (p95/p99 con muestras suficientes): aquí la única violación potencial es esa cola de 950 ms, que es exactamente lo que habría que investigar y lo que la media diluye y la p50 ignora.
Solución 2
- Vía API, secuencial: 80.000 × 38 ms ≈ 3.040 s ≈ 51 minutos de peticiones, ocupando el servicio (y disparando el HPA: pagarías réplicas extra para autoinfligirte carga).
- Vía API con la concurrencia medida: 80.000 / 210 req/s ≈ 380 s ≈ 6,3 minutos a throughput máximo — es decir, saturando el servicio que atiende a los usuarios reales de la pantalla de cancelación durante 6 minutos. Inaceptable en horario de servicio.
- Vía batch vectorizado (
score_batch.py): 80 lotes de 1.000 filas × 0,05 s ≈ 4 segundos depredict_proba(más lectura/escritura de datos: total del orden de un minuto), sin tocar la API ni su infraestructura.
Recomendación: la campaña se puntúa con el job batch de 04-01 — que ya existe, es tres órdenes de magnitud más eficiente y no compite con el tráfico online. "La API ya está desplegada" no es un argumento: la API está dimensionada (3-10 réplicas, presupuesto de latencia) para otro patrón de consumo.
Solución 3
Con p95 = 92 ms y objetivo < 60 ms hay que recortar ~35 ms. Por orden de rentabilidad según el desglose (DataFrame ~8 ms + features ~9 ms + predict ~18 ms dominan):
- Caché de respuestas con TTL (ya diseñada): cada acierto responde en <1 ms; con un 20% de aciertos el p95 apenas baja (los percentiles altos son los fallos de caché), así que ayuda al coste pero no resuelve sola el p95 — hay que atacar el camino sin caché.
- Recortar el tramo pandas (~17 ms): para una fila, construir las features con operaciones sobre dict/numpy en lugar de DataFrame — pero ojo: eso rozaría la reimplementación de features prohibida por 03-03; la vía correcta es optimizar
construir_featuresen el propiofeatures.py(que acepte también la ruta rápida), manteniendo la fuente única y con tests de equivalencia. Ganancia estimada: 8-12 ms. - ONNX para el
predict(palanca 4): de ~18 ms a ~2-4 ms el tramo del modelo, con verificación de equivalencia numérica antes de promocionar. Ganancia: ~14 ms. Con los pasos 2+3, el p95 estimado queda holgadamente bajo 60 ms. - Qué NO haría todavía: sacrificar árboles/recall (palanca 3) — no se paga métrica de negocio mientras queden palancas puramente técnicas — y, por supuesto, GPU (palanca 5), que no aplica a un modelo tabular. Y tras cada cambio: volver a pasar
medir_carga.py, porque las estimaciones son hipótesis hasta que el percentil las confirma.
Conclusión
El módulo 4 termina donde empezó el curso a prometer: el modelo de Laura ya no vive en un notebook — atiende peticiones reales. El recorrido completo está en pie y medido: patrones elegidos por consumidor (batch semanal para marketing, online para la cancelación), servicio FastAPI que valida con Pydantic, carga models:/churn-cineclick@champion e importa las features de la fuente única, imagen cineclick/churn-api:0.1.0 reproducible y sin secretos, Kubernetes con 3-10 réplicas, probes contra /salud y dos rollbacks bien separados (imagen y modelo), y un rendimiento verificado de p95 = 92 ms a 0,15 € por mil predicciones, con las palancas de optimización priorizadas y las descartadas — GPU incluida — descartadas por escrito. Pero fíjate en el pegamento de todo esto: manos. Tests que se pasan si alguien se acuerda, docker build y push desde el portátil de turno, kubectl apply tecleado con cuidado, un job batch que "alguien lanza los lunes". Cada paso manual es lento, inauditable y — tarde o temprano — se saltará justo el día que no debía. El módulo 5 automatiza el pegamento: CI que ejecuta tests de código, de datos y de modelos en cada cambio, CD que construye, publica y despliega la imagen sin manos, orquestación que lanza el batch semanal con reintentos y alertas, y estrategias de release (shadow, canary, A/B) para que la versión nueva se gane la confianza con tráfico real antes de quedarse con todo. Las piezas ya existen; ahora toca que se muevan solas.
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
