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

  1. El problema: comparar decenas de ejecuciones (y por qué dvc metrics diff se queda corto)
  2. Conceptos de MLflow: tracking server, experimento, run, params, metrics, tags y artifacts
  3. Instrumentar train.py y evaluate.py de CineClick
  4. La interfaz de MLflow: tabla de runs, filtros y comparación
  5. Una búsqueda de hiperparámetros: 12 runs y un nuevo candidato
  6. Dónde vive el tracking: mlruns local vs. servidor compartido
  7. 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 log guarda 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 repro que 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.yaml usado. 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_example parecen 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 almacen de 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"] --> S

Levantar 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:

export MLFLOW_TRACKING_URI=http://servidor-mlflow.cineclick.internal:5000

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_experiment y llenar Default: 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 en params.yaml y dvc 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_size como métrica, los filtros y gráficos de la UI dejarán de tener sentido.

Ejercicios

  1. 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 usen class_weight balanced; (c) runs con recall mayor que 0.6 y precision mayor que 0.5.

  2. Ampliar la malla. Modifica scripts/buscar_hiperparametros.py para añadir el hiperparámetro min_samples_leaf con valores [1, 5] a la malla. ¿Cuántas runs generará ahora el bucle? Añade además un tag lote con valor "malla-2" para poder distinguir esta tanda de la primera en la UI.

  3. 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.commit y el tag datos_dvc_md5).

Soluciones

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

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

  1. Pasos de Marc: (1) de la run, copia el commit del tag mlflow.source.git.commit y ejecuta git checkout <commit> en su clon — eso recupera código, params.yaml, dvc.yaml y los metaficheros .dvc; (2) ejecuta dvc checkout (con dvc pull si le faltan datos en caché) — como los metaficheros del commit apuntan a los datos de entonces, el datos_dvc_md5 de 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) ejecuta dvc 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.

© Copyright 2026. Todos los derechos reservados