El módulo 2 dejó a CineClick con un proyecto reproducible y una pregunta abierta: comparar una ejecución contra otra con dvc metrics diff funciona, pero explorar decenas de combinaciones de hiperparámetros y recordar qué se probó hace tres semanas pide otra herramienta. En esta lección instrumentamos el pipeline de churn con MLflow Tracking: cada entrenamiento quedará registrado con sus parámetros, métricas, código y datos, consultable en una interfaz web con tabla, filtros y comparaciones. Con esa memoria de equipo montada, Laura lanzará una pequeña búsqueda de hiperparámetros que encontrará un candidato claramente mejor para el negocio — y podremos resolver, por fin, el misterio de "la v2" que arrastra desde el módulo 1.
Contenido
- El problema: comparar decenas de ejecuciones (y por qué
dvc metrics diffse queda corto) - Conceptos de MLflow: tracking server, experimento, run, params, metrics, tags y artifacts
- Instrumentar
train.pyyevaluate.pyde CineClick - La interfaz de MLflow: tabla de runs, filtros y comparación
- Una búsqueda de hiperparámetros: 12 runs y un nuevo candidato
- Dónde vive el tracking:
mlrunslocal vs. servidor compartido - DVC y MLflow juntos, y el misterio de "la v2" resuelto
El problema: comparar decenas de ejecuciones
En el módulo 2, cuando Laura probó n_estimators: 300, el flujo fue: editar params.yaml, ejecutar dvc repro, y comparar con dvc metrics diff. Perfecto para responder "¿este cambio mejora respecto a lo que hay en Git?". Pero el diff de DVC compara el estado actual contra una revisión: es una foto de dos puntos, no una memoria histórica.
Las preguntas que Laura no puede responder con ese flujo:
- ¿Qué combinaciones hemos probado ya? Si prueba 12 configuraciones, tendría que hacer 12 commits (o apuntarlas a mano en una hoja de cálculo, que es lo que hacía con el notebook).
- ¿Cuál fue el mejor recall de todas las pruebas del mes pasado, y con qué parámetros?
git logguarda commits, no una tabla ordenable de métricas. - ¿Ese experimento lo lanzó Laura o Marc, con qué versión de los datos? No hay dónde mirarlo.
El tracking de experimentos resuelve exactamente esto: un registro centralizado donde cada ejecución de entrenamiento se anota automáticamente con todo su contexto. No sustituye a DVC (enseguida veremos cómo conviven): añade la dimensión que a DVC le falta, la exploración masiva y su historia.
| Necesidad | dvc metrics diff |
Tracking (MLflow) |
|---|---|---|
| Comparar estado actual vs. un commit | Excelente | Posible pero indirecto |
| Tabla con 50 ejecuciones ordenada por recall | No | Sí, nativo |
| Filtrar "todas las runs con recall > 0.5" | No | Sí (sintaxis de búsqueda) |
| Saber quién lanzó qué y cuándo | Vía commits (si se comitea todo) | Automático en cada run |
| Reproducir exactamente una ejecución | Excelente (es su razón de ser) | Solo si registras commit y datos |
Fíjate en la última fila: el tracking observa, no garantiza reproducibilidad por sí mismo. Por eso las dos herramientas se complementan en lugar de competir.
Conceptos de MLflow
MLflow es una plataforma open source de gestión del ciclo de vida de ML. Tiene varios componentes (Tracking, Model Registry, Models, Projects); en esta lección usamos Tracking, y en la siguiente el registry. Su vocabulario:
- Tracking server: el servicio que recibe y almacena los registros. Puede ser simplemente una carpeta local (
mlruns/) o un servidor compartido con base de datos — lo vemos en el apartado 6. - Experimento (experiment): un contenedor con nombre que agrupa ejecuciones relacionadas. Para nosotros:
churn-cineclick. Un proyecto suele tener uno o pocos experimentos. - Run: una ejecución concreta de entrenamiento. Es la unidad central: cada
dvc reproque entrene un modelo, o cada iteración de un bucle de búsqueda, será una run con su identificador único (run_id). - Params: los hiperparámetros y configuración de esa run (
n_estimators=100,test_size=0.2). Son pares clave-valor inmutables: se escriben una vez. - Metrics: valores numéricos de resultado (
recall=0.43). Pueden registrarse varias veces por run (por ejemplo, una métrica por época en deep learning), aunque en nuestro caso será un valor final. - Tags: metadatos libres para organizar y buscar (
autor=laura,datos_dvc=af12bc...). MLflow añade algunos automáticamente, como el commit de Git. - Artifacts: ficheros asociados a la run — el modelo serializado, gráficas, el
params.yamlusado. Lo que no cabe en un par clave-valor, va aquí.
graph TD
E["Experimento: churn-cineclick"] --> R1["Run a3f9... (baseline)"]
E --> R2["Run 7c21... (300 arboles)"]
E --> R3["Run f0d4... (balanced)"]
R3 --> P["Params: n_estimators, max_depth, class_weight"]
R3 --> M["Metrics: accuracy, precision, recall, f1"]
R3 --> T["Tags: git commit, datos DVC, autor"]
R3 --> A["Artifacts: modelo, firma, ejemplo de entrada"]Instrumentar train.py y evaluate.py de CineClick
Primero, MLflow entra en las dependencias del proyecto como cualquier otra librería (módulo 2: nada se instala "a mano"):
# Añadimos mlflow a pyproject.toml (sección dependencies) y recompilamos el lock pip-compile pyproject.toml -o requirements.lock pip-sync requirements.lock
Nuestro pipeline tiene una particularidad: entrenar y evaluar son stages separados de dvc.yaml, pero conceptualmente son una misma run (un modelo y sus métricas). La solución estándar: train.py abre la run, guarda su run_id en un fichero, y evaluate.py reabre esa misma run para añadir las métricas.
src/cineclick_churn/train.py instrumentado (fragmento relevante):
import json
from pathlib import Path
import mlflow
import pandas as pd
import yaml
from mlflow.models import infer_signature
from sklearn.ensemble import RandomForestClassifier
def entrenar(ruta_features: str, ruta_modelo: str, ruta_params: str = "params.yaml"):
# 1. Cargamos los parámetros desde params.yaml, la única fuente de verdad
params = yaml.safe_load(Path(ruta_params).read_text())["entrenar"]
df = pd.read_csv(ruta_features)
X, y = df.drop(columns=["abandono"]), df["abandono"]
# 2. Seleccionamos (o creamos) el experimento del proyecto
mlflow.set_experiment("churn-cineclick")
# 3. Abrimos una run: todo lo que se registre dentro del "with" queda asociado a ella
with mlflow.start_run() as run:
# 4. Registramos TODOS los params de la sección 'entrenar' de golpe
mlflow.log_params(params)
modelo = RandomForestClassifier(
n_estimators=params["n_estimators"],
random_state=params["random_state"],
)
modelo.fit(X, y)
# 5. La firma (signature) describe el esquema de entrada/salida del modelo;
# el input_example guarda 5 filas reales como ejemplo documentado.
firma = infer_signature(X, modelo.predict(X))
mlflow.sklearn.log_model(
modelo,
artifact_path="modelo",
signature=firma,
input_example=X.head(5),
)
# 6. Guardamos el run_id para que el stage 'evaluar' reabra esta misma run
Path("models/mlflow_run_id.txt").write_text(run.info.run_id)
# El .pkl sigue generándose: es el output que DVC rastrea (de momento)
import joblib
joblib.dump(modelo, ruta_modelo)Puntos que conviene entender bien:
mlflow.set_experiment("churn-cineclick")crea el experimento si no existe y lo activa. Sin esta línea, las runs caerían en el experimento por defecto (Default), un cajón de sastre que conviene evitar.mlflow.log_params(params)recibe el diccionario entero: no copiamos valores a mano, así lo registrado coincide siempre con lo ejecutado. Este detalle mata una clase entera de errores ("el Excel decía 200 árboles pero eran 100").- La firma y el
input_exampleparecen burocracia, pero son oro: documentan qué columnas, con qué tipos, espera el modelo. Cuando en el módulo 4 haya que servirlo, esa será la especificación del contrato de entrada, y MLflow validará contra ella.
Y evaluate.py, que reabre la run y añade las métricas (las mismas que sigue escribiendo en metrics.json para DVC):
import json
from pathlib import Path
import mlflow
def evaluar(ruta_modelo: str, ruta_test: str, ruta_metricas: str = "metrics.json"):
metricas = _calcular_metricas(ruta_modelo, ruta_test) # accuracy, precision, recall, f1
# metrics.json sigue existiendo: es el fichero que dvc.yaml declara como métrica
Path(ruta_metricas).write_text(json.dumps(metricas, indent=2))
# Reabrimos la run que dejó abierta (y cerrada) train.py, indicando su id
run_id = Path("models/mlflow_run_id.txt").read_text().strip()
with mlflow.start_run(run_id=run_id):
mlflow.log_metrics(metricas)Ahora un dvc repro normal deja, además de los outputs de siempre, una run completa en MLflow. Nada del flujo del módulo 2 se ha roto: solo hemos añadido observación.
La interfaz de MLflow: tabla, filtros y comparación
Para explorar lo registrado, arrancamos la interfaz web local:
# Desde la raíz del repo (donde está la carpeta mlruns/ que MLflow ha creado) mlflow ui --port 5000 # Abre http://127.0.0.1:5000 en el navegador
Lo esencial de la UI:
- Tabla de runs: cada fila una run; columnas configurables con params y metrics. Se puede ordenar por cualquier métrica — "dame la tabla ordenada por recall descendente" es un clic.
- Búsqueda con filtros: la caja de búsqueda acepta una sintaxis tipo SQL. Ejemplos útiles:
metrics.recall > 0.5 metrics.recall > 0.5 and params.class_weight = 'balanced' tags.mlflow.source.git.commit = 'a1b2c3d'
- Comparación de runs: seleccionas varias filas, pulsas Compare, y obtienes params lado a lado (con las diferencias resaltadas) y gráficos de dispersión métrica-vs-parámetro. Es la versión visual y multi-run del
dvc metrics diff. - Vista de detalle: dentro de una run están sus artifacts navegables — el modelo, su firma, el
input_example.
Una búsqueda de hiperparámetros: 12 runs y un nuevo candidato
Recordemos el problema de negocio: el modelo base tiene recall 0.43 — de cada 100 clientes que se van, solo detecta 43. Para una campaña de retención eso deja escapar a más de la mitad de los que se podrían salvar. Laura sospecha que el desbalanceo (~15% de abandono) es el culpable: el bosque aprende que "casi nadie se va" y se queda tan ancho.
La exploración: un bucle sobre tres hiperparámetros. Esto es exploración, no el pipeline bendecido, así que va en un script aparte (scripts/buscar_hiperparametros.py) y no toca dvc.yaml:
import itertools
import mlflow
import pandas as pd
from mlflow.models import infer_signature
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score
from sklearn.model_selection import train_test_split
df = pd.read_csv("data/processed/features.csv")
X, y = df.drop(columns=["abandono"]), df["abandono"]
# Mismo split que el pipeline: test_size=0.2, random_state=42, estratificado
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
mlflow.set_experiment("churn-cineclick")
# 2 x 3 x 2 = 12 combinaciones
malla = itertools.product(
[100, 300], # n_estimators
[10, 20, None], # max_depth
[None, "balanced"], # class_weight
)
for n_est, profundidad, peso in malla:
with mlflow.start_run(run_name=f"rf-{n_est}-{profundidad}-{peso}"):
mlflow.log_params({
"n_estimators": n_est,
"max_depth": profundidad,
"class_weight": peso,
})
mlflow.set_tag("tipo", "busqueda-hiperparametros")
modelo = RandomForestClassifier(
n_estimators=n_est, max_depth=profundidad,
class_weight=peso, random_state=42,
)
modelo.fit(X_tr, y_tr)
pred = modelo.predict(X_te)
mlflow.log_metrics({
"accuracy": accuracy_score(y_te, pred),
"precision": precision_score(y_te, pred),
"recall": recall_score(y_te, pred),
"f1": f1_score(y_te, pred),
})
firma = infer_signature(X_tr, modelo.predict(X_tr))
mlflow.sklearn.log_model(modelo, "modelo", signature=firma,
input_example=X_tr.head(5))Doce runs en unos minutos. En la UI, Laura ordena por recall y el patrón salta a la vista — resumido:
| run | n_estimators | max_depth | class_weight | accuracy | precision | recall | F1 |
|---|---|---|---|---|---|---|---|
| rf-300-20-balanced | 300 | 20 | balanced | 0.842 | 0.55 | 0.68 | 0.61 |
| rf-100-20-balanced | 100 | 20 | balanced | 0.839 | 0.54 | 0.67 | 0.60 |
| rf-300-None-balanced | 300 | None | balanced | 0.848 | 0.57 | 0.62 | 0.59 |
| rf-300-None-None | 300 | None | None | 0.861 | 0.62 | 0.44 | 0.51 |
| rf-100-None-None (≈ base) | 100 | None | None | 0.860 | 0.61 | 0.43 | 0.50 |
La lectura, que es lo importante:
class_weight="balanced"es el factor decisivo: le dice al bosque que cada cliente de la clase minoritaria (abandono=1) pesa más en el entrenamiento, compensando el desbalanceo del 15%. El recall salta de 0.43 a 0.68.- El precio: la precision baja de 0.61 a 0.55 (más falsas alarmas) y la accuracy cae de 0.86 a 0.842. ¿Es buen trato? Para CineClick, sí: una llamada de retención a un cliente que no iba a irse cuesta poco; un churner no detectado es una suscripción perdida. Cazar 68 de cada 100 en vez de 43 es una mejora de negocio real. F1 sube de 0.50 a 0.61, confirmando que el intercambio es globalmente favorable.
- Comparad con el módulo 2: subir a 300 árboles sin tocar el peso de clases daba una mejora marginal (recall 0.44). La búsqueda sistemática encontró en una tarde lo que los retoques puntuales no encontraban.
El nuevo candidato: RandomForest con n_estimators=300, max_depth=20, class_weight="balanced" — accuracy 0.842, precision 0.55, recall 0.68, F1 0.61. Para consagrarlo, el flujo del módulo 2 sigue vigente: se añaden max_depth y class_weight a la sección entrenar: de params.yaml (y como argumentos en train.py), se ejecuta dvc repro, y se comitea. Exploración con MLflow, consagración con DVC + Git. Qué hacer con este modelo "ganador" — versionarlo y marcarlo formalmente — es exactamente el tema de la próxima lección.
Dónde vive el tracking: mlruns local vs. servidor compartido
Hasta ahora todo se ha guardado en una carpeta mlruns/ junto al repo (conviene añadirla a .gitignore: son datos de registro, no código). Eso funciona para una persona, pero el objetivo era la memoria del equipo: si Marc lanza runs en su máquina, Laura no las ve.
La arquitectura de MLflow separa dos almacenes:
- Backend store: donde van params, metrics y tags — datos estructurados y consultables. En serio: una base de datos (PostgreSQL, MySQL, o SQLite para empezar).
- Artifact store: donde van los ficheros grandes (modelos, gráficas). En serio: almacenamiento de objetos (S3, Azure Blob, GCS...) — el mismo tipo de sitio donde vive el remote
almacende DVC.
graph LR
L["Laura<br/>train.py"] -->|"log_params, log_metrics"| S["Tracking server<br/>(mlflow server)"]
M["Marc<br/>buscar_hiperparametros.py"] -->|"log_model"| S
S --> B[("Backend store<br/>PostgreSQL: params, metrics, tags")]
S --> A[("Artifact store<br/>S3/Blob: modelos, ficheros")]
U["Navegador del equipo<br/>UI de MLflow"] --> SLevantar un servidor compartido (aquí en versión mínima local, con SQLite):
mlflow server \ --backend-store-uri sqlite:///mlflow.db \ --artifacts-destination ./mlartifacts \ --host 0.0.0.0 --port 5000
Y los scripts apuntan a él con una variable de entorno — sin cambiar ni una línea de código:
En producción real, ese servidor es un servicio interno más (un contenedor con su base de datos gestionada y su bucket), pero la mecánica que has aprendido es idéntica: solo cambia la URI.
DVC y MLflow juntos, y el misterio de "la v2" resuelto
La división de trabajo que usará CineClick el resto del curso:
| Responsabilidad | Herramienta | Por qué |
|---|---|---|
| Versionar datasets y sus cambios | DVC | Es su función nativa (módulo 2) |
| Definir y ejecutar el pipeline reproducible | DVC (dvc.yaml + dvc repro) |
Invalidación selectiva, atado a Git |
| Registrar y comparar experimentos en masa | MLflow Tracking | Tabla, filtros, historia del equipo |
| Guardar el modelo con firma y contexto | MLflow | Artifacts + signature + registry (siguiente lección) |
DVC gobierna (decide qué se ejecuta y garantiza que sea reproducible); MLflow observa y recuerda (anota qué pasó y con qué resultado). Para que la observación tenga valor de trazabilidad completa, cada run debe quedar atada a código y datos:
- Código: MLflow registra automáticamente el commit de Git en el tag
mlflow.source.git.commit(si lanzas el script desde un repo con el trabajo comiteado — otra razón para comitear antes de entrenar). - Datos: lo atamos nosotros con un tag, leyendo el hash que DVC ya calcula:
import yaml
lock = yaml.safe_load(open("dvc.lock"))
md5_datos = lock["stages"]["preparar_datos"]["deps"][0]["md5"]
mlflow.set_tag("datos_dvc_md5", md5_datos)Y con esto, cerremos la cuenta pendiente. En el módulo 1, la anécdota: existía un modelo_churn_v2.pkl que alguien entrenó, nadie sabía con qué parámetros ni con qué datos, y la eterna duda de si "la v2 era peor". La respuesta honesta es que era incontestable: la información necesaria para responderla no se guardó en ningún sitio. No era un misterio, era una ausencia de registro. Con el sistema de esta lección, la pregunta ni siquiera puede formularse: cualquier "v2" futura será una run con sus params, sus métricas, su commit de código y su hash de datos — y compararla con la v1 costará dos clics en la pestaña Compare. La moraleja del tracking es esa: no hace mejores modelos, hace imposible no saber de dónde salió cada uno.
Errores Comunes y Consejos
- Registrar métricas a mano en vez de desde variables: si escribes
mlflow.log_metric("recall", 0.68)con el número copiado, el tracking miente en cuanto cambie algo. Registra siempre el resultado calculado. - Olvidar
set_experimenty llenarDefault: acabarás con 200 runs de tres proyectos mezcladas. Fija el experimento al principio del script, siempre. - Runs huérfanas de contexto: una run sin commit de Git asociado (porque lanzaste con cambios sin comitear) o sin referencia a los datos vuelve al problema de "la v2". Convención de equipo: comitear antes de lanzar experimentos "serios".
- Meter la búsqueda de hiperparámetros en
dvc.yaml: el pipeline de DVC es el camino bendecido y determinista; la exploración es divergente por naturaleza. Sepáralas: scripts de exploración registran en MLflow; lo que gana, se consagra enparams.yamlydvc repro. - Comitear
mlruns/: son datos de registro (potencialmente gigas de modelos). A.gitignore. La memoria compartida se consigue con un tracking server, no con Git. - Confundir params y metrics: los params son entrada (los eliges tú), las metrics son salida (las produce la evaluación). Si registras
test_sizecomo métrica, los filtros y gráficos de la UI dejarán de tener sentido.
Ejercicios
-
Filtros de la UI. Escribe las expresiones de búsqueda de MLflow para: (a) runs con F1 superior a 0.55; (b) runs de la búsqueda de hiperparámetros (tag
tipo) que usenclass_weightbalanced; (c) runs con recall mayor que 0.6 y precision mayor que 0.5. -
Ampliar la malla. Modifica
scripts/buscar_hiperparametros.pypara añadir el hiperparámetromin_samples_leafcon valores[1, 5]a la malla. ¿Cuántas runs generará ahora el bucle? Añade además un taglotecon valor"malla-2"para poder distinguir esta tanda de la primera en la UI. -
Trazabilidad completa. Marc encuentra en la UI una run interesante de hace dos semanas y quiere reproducirla exactamente en su máquina. Enumera los pasos, indicando de qué campo de la run sale cada dato que necesita (suponiendo que la run tiene el tag
mlflow.source.git.commity el tagdatos_dvc_md5).
Soluciones
- Las tres expresiones:
metrics.f1 > 0.55 tags.tipo = 'busqueda-hiperparametros' and params.class_weight = 'balanced' metrics.recall > 0.6 and metrics.precision > 0.5
Nota: los valores de params y tags se comparan como cadenas (comillas simples); las métricas, como números.
- La malla pasa a ser 2 × 3 × 2 × 2 = 24 runs. Cambios en el script:
malla = itertools.product(
[100, 300], [10, 20, None], [None, "balanced"],
[1, 5], # min_samples_leaf
)
for n_est, profundidad, peso, hoja in malla:
with mlflow.start_run(run_name=f"rf-{n_est}-{profundidad}-{peso}-{hoja}"):
mlflow.log_params({..., "min_samples_leaf": hoja})
mlflow.set_tag("tipo", "busqueda-hiperparametros")
mlflow.set_tag("lote", "malla-2")
modelo = RandomForestClassifier(..., min_samples_leaf=hoja, random_state=42)Consejo: las mallas crecen multiplicativamente; antes de añadir un cuarto hiperparámetro, pregúntate si los resultados de la malla anterior sugieren que merece la pena.
- Pasos de Marc: (1) de la run, copia el commit del tag
mlflow.source.git.commity ejecutagit checkout <commit>en su clon — eso recupera código,params.yaml,dvc.yamly los metaficheros.dvc; (2) ejecutadvc checkout(condvc pullsi le faltan datos en caché) — como los metaficheros del commit apuntan a los datos de entonces, eldatos_dvc_md5de la run le sirve de verificación de que tiene la versión correcta; (3) recrea el entorno con el lock de ese commit (pip-sync requirements.lock); (4) ejecutadvc repro. Si la run era de exploración (script fuera del pipeline), ejecuta el script con los params que muestra la run. Todo el contexto necesario estaba en la run: eso es exactamente lo que "la v2" nunca tuvo.
Conclusión
CineClick tiene ahora memoria: cada entrenamiento queda registrado en el experimento churn-cineclick con sus parámetros, métricas, firma del modelo, commit de código y hash de datos, consultable y comparable en la UI de MLflow. La división de trabajo está clara — DVC gobierna datos y pipeline, MLflow observa y recuerda — y la primera búsqueda sistemática de hiperparámetros ha dado fruto: con class_weight="balanced", n_estimators=300 y max_depth=20, el recall sube de 0.43 a 0.68 (precision 0.55, F1 0.61), un intercambio claramente favorable para el negocio de retención. Pero ese modelo ganador es, ahora mismo, solo "la run f0d4... que salió bien" y un modelo_churn.pkl que alguien tendrá que copiar a donde toque. ¿Cómo se convierte una run ganadora en el modelo oficial, con versión, estado y un nombre por el que producción pueda pedirlo? Ese es el trabajo del model registry, la pieza que gestiona el modelo "bendecido" — y el tema de la siguiente lección.
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
