Lección 7.3.1 · 70 min

Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin

Un número de un informe de research vale lo que vale poder reproducirlo. Esta lección pone el panel semanal del nivel 5 bajo DVC y el código bajo git, registra siete ejecuciones de un ridge en MLflow (incluida una que falla y otra con fuga), registra el modelo elegido con su versión de datos y, tras una corrección del proveedor, recupera los datos exactos de aquella ejecución y obtiene el mismo IC.

Fig. — IC medio semanal fuera de muestra (2020-2025) de las siete ejecuciones registradas del ridge transversal: las dos con revision_analistas superan seis veces el techo del oráculo (0,029); la elegida da 0,024 y una falla
Lecciones del nivel 7
  1. 7.1 · Ingeniería de software para trading

    1. 7.1.1 Estructura de proyecto, configuración y calidad: del notebook al repositorio
    2. 7.1.2 Rendimiento: medir, perfilar y compilar solo lo que hace falta
    3. 7.1.3 Patrones: eventos, colas de mensajes y arquitectura hexagonal
  2. 7.2 · Infraestructura y despliegue

    1. 7.2.1 Docker y docker-compose: imágenes reproducibles con uv
    2. 7.2.2 VPS, nube y latencia; CI/CD con GitHub Actions
    3. 7.2.3 Orquestación y monitorización: grafos de tareas, logs estructurados, métricas, alertas y latidos
    4. 7.2.4 Seguridad y continuidad: 2FA, IP, copias y procedimientos ante fallos
  3. 7.3 · MLOps para modelos de trading

    1. 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
    2. 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
    3. 7.3.3 Monitorización del modelo en vivo y desactivación automática
    4. 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
  4. 7.4 · Regulación, ética y fiscalidad

    1. 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
    2. 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
    3. 7.4.3 PRIIPs, KID y ETFs estadounidenses
    4. 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
    5. 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
    6. 7.4.6 Fiscalidad del trading en España
    7. 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
  5. 7.5 · Carrera profesional quant

    1. 7.5.1 Roles del quant: seis oficios alrededor de un mismo sistema
    2. 7.5.2 Dónde trabajar: qué hace cada empresa y qué dicen los registros
    3. 7.5.3 Portfolio público y entrevistas: enseñar criterio, no rentabilidades
    4. 7.5.4 Certificaciones, formación y prop firms: qué certifica cada cosa y qué mide un examen

Qué hay que poder reproducir

La lección 2.2.4 montó un registro de experimentos en un fichero JSONL, con la huella de los datos y del código en cada línea y una cadena de hashes para detectar ediciones. La 5.4.4 registró veinte variantes de un modelo para poder calcular el Deflated Sharpe al final, y su último ejercicio enumeró los campos que necesitaría un registro real. Esta lección los guarda con las herramientas que se usan en producción, y comprueba al final que con ellos se reproduce un resultado.

Reproducir un número exige cinco cosas: los datos exactos, el código exacto, los parámetros, el entorno (versiones de las librerías) y las semillas. El entorno lo fija uv.lock (lección 0.2.2) y la imagen de Docker (lección 7.2.1); las semillas son parámetros. Quedan tres piezas y tres herramientas: git para el código, DVC para los datos, que no caben en git, y MLflow para lo que une las dos en cada ejecución.

Código: gitEjecuciónDatos: DVCParámetrosMétricasModeloRegistro
Fuente Mermaid
flowchart TB
  G[Código: git] --> E[Ejecución]
  D[Datos: DVC] --> E
  P[Parámetros] --> E
  E --> M[Métricas]
  E --> O[Modelo]
  O --> R[Registro]

El experimento es el ridge transversal del nivel 5: predecir el rendimiento de la semana siguiente de 400 acciones sintéticas con sus features semanales, entrenando hasta 2019 y midiendo el IC (la correlación de rangos semanal entre predicción y resultado) de 2020 a 2025. Los datos del nivel 5 tienen dos trampas conocidas: revision_analistas es una fuga, y sentimiento está vacía para las acciones sin cobertura. Ninguna ejecución de esta lección es un buen modelo; lo que importa es lo que el registro dice de cada una.

El conjunto de datos y el código

Primero, el conjunto de datos del experimento: ocho features pasadas a rangos transversales y la etiqueta en exceso sobre la media de cada semana, en un parquet dentro de la carpeta de un proyecto nuevo.

IN [01]python
import hashlib
from pathlib import Path

import numpy as np
import pandas as pd

COLS = ["ret_1s", "mom_12_1", "vol_63d", "volumen_rel", "valor_bp",
        "sue", "sentimiento", "revision_analistas"]
f = pd.read_parquet("panel5_features.parquet",
                    columns=["fecha", "ticker"] + COLS)
e = pd.read_parquet("panel5_etiquetas.parquet",
                    columns=["fecha", "ticker", "ret_sig_semana"])
df = f.merge(e, on=["fecha", "ticker"]).dropna(
    subset=["ret_sig_semana"])
for c in COLS:                     # rango transversal en [-0,5, 0,5]
    df[c] = df.groupby("fecha")[c].rank(pct=True) - 0.5
df["y"] = (df["ret_sig_semana"]
           - df.groupby("fecha")["ret_sig_semana"].transform("mean"))
panel = df[["fecha", "ticker"] + COLS + ["y"]].astype(
    {c: "float32" for c in COLS + ["y"]})

RUTA = Path("proyecto/datos/panel_semanal.parquet")
RUTA.parent.mkdir(parents=True)
panel.to_parquet(RUTA, index=False)

def md5(ruta):
    return hashlib.md5(Path(ruta).read_bytes()).hexdigest()

print(panel.shape, panel["fecha"].min().date(),
      panel["fecha"].max().date())
print(f"{RUTA.stat().st_size / 1e6:.1f} MB · md5 {md5(RUTA)}")
print(panel["sentimiento"].isna().mean().round(3),
      "vacíos en sentimiento")
OUT [01]El panel semanal del experimento
(291600, 11) 2012-01-13 2025-12-26
4.5 MB · md5 36eccd106ec9200919d2c447fffccac2
0.32 vacíos en sentimiento

Son 291.600 filas (400 acciones en 729 semanas con etiqueta), de enero de 2012 a diciembre de 2025, en 4,5 MB. El 32 % de los valores de sentimiento están vacíos: el rango de una acción sin dato también es vacío. El md5 es la huella del contenido, y cambiar un solo valor lo cambia entero. La lección 2.2.4 lo calculaba a mano; a partir de aquí lo calcula y lo guarda DVC.

El código de entrenamiento va en un fichero del proyecto, no en la celda: lo que se versiona con git es el fichero, y el commit identifica exactamente el código que produjo cada número. El IC semanal es la correlación de Pearson entre los rangos de la predicción y de la etiqueta dentro de cada semana, calculada con agregados por grupo:

IN [02]python
import sys

CODIGO = '''\
"""Ridge transversal y su IC semanal fuera de muestra."""
from sklearn.linear_model import Ridge

CORTE = "2020-01-01"


def ic_semanal(datos, pred):
    r = datos[["fecha", "y"]].assign(p=pred)
    r[["p", "y"]] = r.groupby("fecha")[["p", "y"]].rank()
    g = r.groupby("fecha")
    cov = (r["p"] * r["y"]).groupby(r["fecha"]).mean() - (
        g["p"].mean() * g["y"].mean())
    return cov / (g["p"].std(ddof=0) * g["y"].std(ddof=0))


def entrenar(panel, features, alpha):
    tr = panel[panel["fecha"] < CORTE]
    te = panel[panel["fecha"] >= CORTE]
    modelo = Ridge(alpha=alpha).fit(tr[features], tr["y"])
    return modelo, ic_semanal(te, modelo.predict(te[features]))
'''
Path("proyecto/entrenar.py").write_text(CODIGO)
Path("proyecto/.gitignore").write_text("__pycache__/\n")
sys.path.insert(0, "proyecto")
import entrenar

Versionar los datos con DVC

Un parquet de varios megas no debe ir en git: cada versión se quedaría para siempre en el historial de todos los clones. DVC resuelve esto con un puntero: dvc add calcula el md5 del fichero, lo copia a una caché local (.dvc/cache, direccionada por ese md5), escribe un fichero .dvc pequeño con el md5 y el tamaño, y añade el original a .gitignore. Git versiona el .dvc; dvc push sube el contenido a un remoto (aquí, una carpeta local; en producción, S3, Azure, GCS, SSH…) y dvc pull lo baja1Nota 1 · DVC, «Get Started: Data Versioning» (https://dvc.org/doc/start/data-management/data-versioning): dvc add crea el .dvc con el md5, la caché va en .dvc/cache/files/md5, dvc push/dvc pull con el remoto, y para volver a una versión, git checkout del .dvc y dvc checkout. Consultada el 23 de octubre de 2026..

panel.parquetCaché DVCRemotopanel.parquet.dvcRepositorio git
Fuente Mermaid
flowchart TB
  W[panel.parquet] --> C[Caché DVC]
  C --> R[Remoto]
  W --> P[panel.parquet.dvc]
  P --> G[Repositorio git]
IN [03]python
import subprocess

def sh(*cmd):
    r = subprocess.run(cmd, cwd="proyecto", capture_output=True,
                       text=True, check=True)
    return (r.stdout + r.stderr).strip()

sh("git", "init", "-q")
sh("dvc", "init", "-q")
sh("dvc", "add", "-q", "datos/panel_semanal.parquet")
print(sh("dvc", "remote", "add", "-d", "local", "../remoto-dvc"))
sh("git", "add", "-A")
sh("git", "commit", "-q", "-m", "Panel semanal v1 y entrenar.py")
sh("dvc", "push", "-q")
COMMIT_V1 = sh("git", "rev-parse", "--short", "HEAD")

print("commit", COMMIT_V1)
print(Path("proyecto/datos/panel_semanal.parquet.dvc").read_text())
print(sh("git", "ls-files"))
remoto = [p for p in Path("remoto-dvc").rglob("*") if p.is_file()]
print(len(remoto), "fichero en el remoto:",
      remoto[0].relative_to("remoto-dvc").parts[:2])
OUT [03]Datos y código bajo control: git y DVC
Setting 'local' as a default remote.
commit e77070e
outs:
- md5: 36eccd106ec9200919d2c447fffccac2
  size: 4529279
  hash: md5
  path: panel_semanal.parquet

.dvc/.gitignore
.dvc/config
.dvcignore
.gitignore
datos/.gitignore
datos/panel_semanal.parquet.dvc
entrenar.py
1 fichero en el remoto: ('files', 'md5')

Solo dvc remote add -d imprime algo. El .dvc es lo que entra en git: el md5, el tamaño en bytes (4.529.279), el algoritmo y la ruta. git ls-files confirma que el parquet no está en el repositorio: están el .dvc, el código, la configuración de DVC y un datos/.gitignore que dvc add creó para excluir el parquet. El remoto tiene un fichero, guardado bajo files/md5/ con su md5 como nombre. El commit e77070e identifica a la vez el código y la versión de los datos; sale igual en cada ejecución de la lección porque el contenido, el autor y la fecha del commit son fijos.

Un experimento en MLflow

MLflow organiza el trabajo en experimentos que contienen ejecuciones (runs). Cada ejecución guarda parámetros (lo que se eligió), métricas (lo que se midió, opcionalmente por paso), etiquetas (contexto libre), artefactos (ficheros, modelos) y su estado. Sin servidor configurado, el cliente de MLflow 3.17 del entorno crea una base de datos SQLite (mlflow.db) en la carpeta de trabajo y guarda los artefactos en mlruns/; la documentación general aún describe el almacén de ficheros mlruns como el de por defecto, pero esa versión ya no lo usa sin una variable explícita. La rejilla: tres conjuntos de features, dos valores de alpha y una variante más con sentimiento. Cada ejecución lleva como etiquetas el commit del código y el md5 de los datos:

IN [04]python
import os
import mlflow

mlflow.set_experiment("ridge-semanal")
DATOS_MD5 = md5(RUTA)
BASE = ["ret_1s", "mom_12_1", "vol_63d", "volumen_rel"]
REJILLA = [("precio", BASE, a) for a in (1.0, 1e5)]
REJILLA += [("precio+fund", BASE + ["valor_bp", "sue"], a)
            for a in (1.0, 1e5)]
REJILLA += [("precio+fund+rev", BASE + ["valor_bp", "sue",
             "revision_analistas"], a) for a in (1.0, 1e5)]
REJILLA += [("precio+fund+sent", BASE + ["valor_bp", "sue",
             "sentimiento"], 1.0)]

for conjunto, feats, alpha in REJILLA:
    try:
        with mlflow.start_run(run_name=f"{conjunto} a={alpha:g}"):
            mlflow.set_tags({"git_commit": COMMIT_V1,
                             "datos_md5": DATOS_MD5,
                             "datos": "datos/panel_semanal.parquet"})
            mlflow.log_params({"conjunto": conjunto, "alpha": alpha,
                               "features": ",".join(feats),
                               "corte": entrenar.CORTE})
            modelo, ic = entrenar.entrenar(panel, feats, alpha)
            for anio, v in ic.groupby(ic.index.year).mean().items():
                mlflow.log_metric("ic_anual", v, step=anio)
            mlflow.log_metrics({
                "ic_medio": ic.mean(),
                "ic_t": ic.mean() / ic.std() * np.sqrt(len(ic))})
    except ValueError as err:
        print(f"{conjunto} a={alpha:g}:", str(err).splitlines()[0])

print([p for p in ("mlflow.db", "mlruns") if os.path.exists(p)])
OUT [04]Siete ejecuciones registradas
precio+fund+sent a=1: Input X contains NaN.
['mlflow.db']

Seis ejecuciones terminan y una falla: el ridge de scikit-learn no acepta valores vacíos, y sentimiento los tiene. La excepción sale del bloque with, y MLflow cierra la ejecución con estado FAILED antes de que la capture el except: el intento queda registrado con sus parámetros y sus etiquetas. En la carpeta de trabajo aparece mlflow.db y todavía no mlruns, porque ninguna ejecución ha guardado artefactos.

Buscar, comparar y contar

Las ejecuciones se consultan con mlflow.search_runs, que devuelve un DataFrame con una columna por parámetro, métrica y etiqueta, y acepta filtros con una sintaxis parecida a SQL. El DataFrame trae también identificadores y horas, que cambian en cada ejecución: se seleccionan las columnas que interesan.

IN [05]python
runs = mlflow.search_runs(experiment_names=["ridge-semanal"],
                          order_by=["metrics.ic_medio DESC"])
vista = runs[["tags.mlflow.runName", "status", "metrics.ic_medio",
              "metrics.ic_t"]].set_axis(
    ["ejecución", "estado", "ic_medio", "t"], axis=1)
print(vista.round({"ic_medio": 4, "t": 1}).to_string(index=False))
print("pruebas registradas:", len(runs), "· fallidas:",
      int((runs["status"] == "FAILED").sum()))

TECHO = 0.029                      # IC del oráculo (README del nivel 5)
sosp = mlflow.search_runs(experiment_names=["ridge-semanal"],
                          filter_string=f"metrics.ic_medio > {TECHO}")
extra = {f for fs in sosp["params.features"]
         for f in fs.split(",")} - set(BASE + ["valor_bp", "sue"])
print("por encima del techo:", len(sosp), "· features de más:", extra)

cli = mlflow.MlflowClient()
r0 = runs[runs["tags.mlflow.runName"] == "precio+fund a=1"].iloc[0]
hist = cli.get_metric_history(r0["run_id"], "ic_anual")
print({m.step: round(m.value, 3) for m in hist})
OUT [05]Las ejecuciones, ordenadas por IC
               ejecución   estado  ic_medio    t
     precio+fund+rev a=1 FINISHED    0.1763 58.7
precio+fund+rev a=100000 FINISHED    0.1763 58.6
         precio+fund a=1 FINISHED    0.0243  4.5
    precio+fund a=100000 FINISHED    0.0243  4.5
         precio a=100000 FINISHED    0.0213  3.7
              precio a=1 FINISHED    0.0212  3.6
    precio+fund+sent a=1   FAILED       NaN  NaN
pruebas registradas: 7 · fallidas: 1
por encima del techo: 2 · features de más: {'revision_analistas'}
{2020: 0.018, 2021: 0.018, 2022: 0.017, 2023: 0.001, 2024: 0.051, 2025: 0.04}

Las dos primeras ejecuciones dan un IC de 0,176 con un t de 58: seis veces el IC del oráculo de los datos (0,029), el máximo alcanzable fuera de muestra. El filtro metrics.ic_medio > 0.029 las encuentra, y la diferencia entre sus features y las de las demás señala a revision_analistas, la feature con fuga del nivel 5. Sin el parámetro features en el registro, ese número se habría leído como un descubrimiento. Las honestas dan 0,021 con las features de precio y 0,024 añadiendo valor_bp y sue, con t de 3,6 a 4,5; alpha apenas cambia nada con más de 160.000 filas de entrenamiento. Se registraron siete pruebas, una fallida: ese 7 es el número de intentos que pide el Deflated Sharpe (lecciones 2.2.4 y 5.4.4), y saldría más bajo si los fallos no quedaran registrados. La figura de la cabecera pone las siete juntas. La historia por año de la ejecución elegida, por último, muestra que el IC no es estable: casi cero en 2023 y 0,05 en 2024.

Qué registrar en un experimento de trading

La rejilla de la lección registra lo mínimo. Un experimento de trading que vaya a acabar en un informe o en producción necesita más campos, y cada uno tiene un sitio natural:

QuéDóndeEjemplo
Versión de los datosetiquetadatos_md5, el commit con el .dvc
Versión del códigoetiquetagit_commit; sin cambios pendientes de commit al ejecutar
Validaciónparámetroscorte, ventana, purga y embargo (lecciones 5.4.2 y 5.4.3)
Costesparámetrosmodelo de costes y supuestos de ejecución
Semillasparámetrossemilla del modelo y del muestreo
Resultado resumidométricasIC medio y t, Sharpe neto, rotación
Resultado en el tiempométricas por pasoIC por año, drawdown por año
Serie completaartefactorendimientos o IC semanales, para el Deflated Sharpe y la PBO
Decisiónetiqueta«descartada: fuga en revision_analistas»

Lo que no va: credenciales (los parámetros y etiquetas se leen en claro, lección 4.4.5), datos personales y conjuntos de datos enteros como artefactos (para eso está DVC). Y una regla de proceso: el registro se escribe desde el mismo código que entrena, no a mano después, porque lo que se apunta a mano es lo que salió bien.

Del experimento al registro de modelos

Una ejecución guarda lo que se probó; el registro de modelos guarda lo que se va a usar. Un modelo registrado tiene un nombre único y versiones numeradas, cada una enlazada con la ejecución que la produjo; las versiones admiten etiquetas y alias, referencias con nombre que se pueden mover de una versión a otra, como models:/MiModelo@champion en la documentación de MLflow2Nota 2 · MLflow, «Model Registry» (https://mlflow.org/docs/latest/ml/model-registry/): nombre único, versiones que empiezan en 1, etiquetas, URI models:/<nombre>/<versión> y alias como models:/MyModel@champion. «Tracking» (https://mlflow.org/docs/latest/ml/tracking/) describe los almacenes y mlflow server. Consultadas el 23 de octubre de 2026. El almacén SQLite por defecto y el aviso del almacén de ficheros son los de mlflow-skinny 3.17.2, comprobados en el entorno del curso.. El modelo elegido se guarda dentro de su ejecución con una firma (las columnas y tipos de entrada, inferidos del ejemplo) y se registra:

IN [06]python
feats = r0["params.features"].split(",")
modelo, ic = entrenar.entrenar(panel, feats, float(r0["params.alpha"]))
X_ej = panel.loc[panel["fecha"] == panel["fecha"].max(), feats].head(3)

with mlflow.start_run(run_id=r0["run_id"]):
    info = mlflow.sklearn.log_model(
        modelo, name="modelo", input_example=X_ej,
        registered_model_name="ridge-semanal")
version = info.registered_model_version
cli.set_model_version_tag("ridge-semanal", version, "datos_md5",
                          DATOS_MD5)
cli.set_registered_model_alias("ridge-semanal", "candidato", version)

mv = cli.get_model_version_by_alias("ridge-semanal", "candidato")
print("versión", mv.version, "· de la ejecución elegida:",
      mv.run_id == r0["run_id"], "· etiquetas:", mv.tags)
pf = mlflow.pyfunc.load_model("models:/ridge-semanal@candidato")
print(pf.metadata.get_input_schema())
print(np.allclose(pf.predict(X_ej), modelo.predict(X_ej)))
OUT [06]Registrar el modelo elegido y cargarlo por su alias
Successfully registered model 'ridge-semanal'.
Created version '1' of model 'ridge-semanal'.
versión 1 · de la ejecución elegida: True · etiquetas: {'datos_md5': '36eccd106ec9200919d2c447fffccac2'}
['ret_1s': float (required), 'mom_12_1': float (required), 'vol_63d': float (required), 'volumen_rel': float (required), 'valor_bp': float (required), 'sue': float (required)]
True

MLflow avisa de que creó el modelo registrado y su versión 1. El alias candidato apunta a esa versión, que enlaza con la ejecución elegida y lleva la etiqueta con el md5 de los datos. La firma, inferida del ejemplo de entrada, son las seis columnas con su tipo: un predict al que le falte alguna lanza una excepción en vez de devolver números sin sentido. Y el modelo cargado por su alias con la interfaz genérica (pyfunc), como lo haría un servicio que no sabe nada de scikit-learn, da las mismas predicciones que el original. El código que lo usa pide models:/ridge-semanal@candidato; cuando haya una versión mejor, se mueve el alias y ese código no cambia.

Una corrección del proveedor

Los datos cambian. El proveedor avisa de que los SUE de 2023 se publicaron con una semana de adelanto en el fichero: cada valor se conocía en realidad una semana más tarde. La versión corregida (v2) desplaza sue una semana dentro de 2023, y el flujo es el mismo que con el código: modificar, dvc add, commit y dvc push.

IN [07]python
v2 = panel.copy()
en_2023 = v2["fecha"].dt.year == 2023
v2.loc[en_2023, "sue"] = (v2[en_2023].groupby("ticker")["sue"]
                          .shift(1).fillna(0))
v2.to_parquet(RUTA, index=False)
print(sh("dvc", "status"))
sh("dvc", "add", "-q", "datos/panel_semanal.parquet")
sh("git", "commit", "-q", "-am", "Panel semanal v2: SUE de 2023")
sh("dvc", "push", "-q")
print(sh("git", "log", "--oneline"))
print("md5 v1", DATOS_MD5[:12], "· md5 v2", md5(RUTA)[:12])
print(sh("dvc", "diff", "HEAD~1"))
OUT [07]Datos v2: corregir, versionar y enviar
datos/panel_semanal.parquet.dvc:
	changed outs:
		modified:           datos/panel_semanal.parquet
3562fe4 Panel semanal v2: SUE de 2023
e77070e Panel semanal v1 y entrenar.py
md5 v1 36eccd106ec9 · md5 v2 968bf4c6f9f1
Modified:
    datos/panel_semanal.parquet

files summary: 1 modified

dvc status detecta que el fichero ya no coincide con su .dvc. Tras dvc add y el commit, el historial tiene dos versiones, cada una con su md5, y dvc diff HEAD~1 resume el cambio: un fichero modificado. Los dos contenidos siguen en la caché y en el remoto; el .dvc de cada commit dice cuál es cuál.

Reproducir la ejecución elegida

Ahora la prueba de que el registro sirve. Partiendo solo de la ejecución registrada, sin nada de lo que hay en la memoria del notebook: sus etiquetas dicen qué commit y qué md5; dvc.api.open lee el fichero tal como estaba en ese commit, desde el almacenamiento de DVC3Nota 3 · DVC, «dvc.api.open()» (https://dvc.org/doc/api-reference/open): abre un fichero versionado por DVC en un proyecto de DVC; rev es la revisión de git (commit, rama o etiqueta). Consultada el 23 de octubre de 2026.; sus parámetros dicen cómo entrenar; y su métrica dice qué debería salir.

IN [08]python
import io
import dvc.api

run = mlflow.get_run(r0["run_id"])
tags, params = run.data.tags, run.data.params
with dvc.api.open("datos/panel_semanal.parquet", repo="proyecto",
                  rev=tags["git_commit"], mode="rb") as fh:
    crudo = fh.read()
print("md5 coincide con la etiqueta:",
      hashlib.md5(crudo).hexdigest() == tags["datos_md5"])

datos_v1 = pd.read_parquet(io.BytesIO(crudo))
f_run = params["features"].split(",")
_, ic_v1 = entrenar.entrenar(datos_v1, f_run, float(params["alpha"]))
_, ic_v2 = entrenar.entrenar(v2, f_run, float(params["alpha"]))
print(f"registrado {run.data.metrics['ic_medio']:.6f} · "
      f"reproducido {ic_v1.mean():.6f} · con v2 {ic_v2.mean():.6f}")
print("2023:", round(ic_v1[ic_v1.index.year == 2023].mean(), 4),
      "frente a", round(ic_v2[ic_v2.index.year == 2023].mean(), 4))
OUT [08]De la ejecución registrada al mismo IC
md5 coincide con la etiqueta: True
registrado 0.024276 · reproducido 0.024276 · con v2 0.024311
2023: 0.0013 frente a 0.0015

El md5 de lo leído coincide con la etiqueta, y el IC reproducido coincide con el registrado hasta la sexta cifra decimal: 0,024276. Con los datos corregidos, el mismo código y los mismos parámetros dan 0,024311, y el IC de 2023 pasa de 0,0013 a 0,0015. La diferencia es pequeña, pero basta para que un número escrito con v1 no se pueda verificar con v2: sin la versión de los datos, quien intente comprobar ese 0,0243 meses después obtendrá otro número y no sabrá por qué.

En un proyecto real, el código también se recupera del commit (git worktree add ../rep <commit> o git checkout), y el entorno de su uv.lock. Aquí el código no cambió entre v1 y v2, así que basta con el módulo cargado. Para trabajar con la versión vieja en la carpeta del proyecto, el camino de la documentación de DVC es recuperar el .dvc de ese commit y sincronizar:

IN [09]Volver a la versión de datos de un commitpython
import subprocess

subprocess.run(["git", "checkout", "<commit>", "--",
                "datos/panel_semanal.parquet.dvc"], check=True)
subprocess.run(["dvc", "checkout"], check=True)   # o dvc pull

Servidor, equipo y alternativas

Todo lo anterior funciona en una carpeta, que es lo que necesita una persona sola. Con un equipo, MLflow se sirve como un servidor de seguimiento con una base de datos compartida (PostgreSQL, MySQL) y un almacén de artefactos (S3, Azure, GCS), y la interfaz web compara ejecuciones y dibuja métricas. El entorno del curso usa mlflow-skinny, el cliente sin servidor ni interfaz, así que estos comandos van sin ejecutar:

IN [10]Servidor de MLflow y remoto de DVC en la nubepython
import subprocess

# servidor de seguimiento con la base de datos SQLite de la lección
subprocess.run(["mlflow", "server", "--backend-store-uri",
                "sqlite:///mlflow.db", "--port", "5000"])
# en el código de entrenamiento:
# mlflow.set_tracking_uri("http://127.0.0.1:5000")

# remoto de DVC en un bucket (credenciales fuera del repositorio)
subprocess.run(["dvc", "remote", "add", "-d", "nube",
                "s3://mi-bucket/dvc"], cwd="proyecto")

Un servidor de MLflow da acceso a todos los experimentos y modelos: se publica en 127.0.0.1 y se entra por un túnel SSH, como Grafana en la lección 7.2.1. Las credenciales del bucket, en el gestor de secretos o en variables de entorno del proceso (lección 4.4.5), nunca en .dvc/config, que va a git (para eso existe .dvc/config.local, que no se versiona).

Weights & Biases es la alternativa comercial más conocida: un servicio en la nube (con instalación autoalojada) con el mismo modelo de ejecuciones, configuración, métricas por paso y artefactos, y paneles para comparar. Su documentación se publica hoy dentro de la de CoreWeave. El flujo básico, según esa documentación4Nota 4 · Weights & Biases, «Experiments overview» (https://docs.wandb.ai/guides/track/): wandb.init como gestor de contexto, run.config, run.log y run.log_artifact; la página se sirve como «CoreWeave Docs». Opciones de despliegue, incluida Self-Managed: https://docs.wandb.ai/platform/hosting/. Consultadas el 23 de octubre de 2026.:

IN [11]El mismo registro en Weights & Biasespython
import wandb

config = {"conjunto": "precio+fund", "alpha": 1.0}
with wandb.init(project="ridge-semanal", config=config) as run:
    for anio, v in ic.groupby(ic.index.year).mean().items():
        run.log({"ic_anual": v, "anio": anio})
    run.log({"ic_medio": ic.mean()})
MLflowWeights & BiasesDVC
Qué guardaejecuciones, métricas, artefactos, modelos registradosejecuciones, métricas, artefactos, panelesversiones de datos y modelos (y pipelines)
Dóndecarpeta, base de datos o servidor propioservicio en la nubecaché local y remoto (carpeta, S3, SSH…)
Licenciacódigo abierto (Apache 2.0)servicio comercialcódigo abierto (Apache 2.0)
Encaja conregistro y despliegue de modelos sin depender de tercerosequipos que quieren paneles sin mantener servidordatos grandes que deben ir con el código

Elegir es secundario. Lo que no es opcional es lo que hace la celda de la sección anterior: que desde cualquier número de un informe se pueda llegar a sus datos, su código y sus parámetros, y volver a obtenerlo. La lección 7.3.2 parte de este registro para reentrenar, validar y promover modelos con alias; la 7.3.3 vigila en vivo los que están en uso.

Ejercicios

  1. Compara la historia anual del IC (ic_anual) de las ejecuciones precio a=1 y precio+fund a=1 con get_metric_history. ¿En qué años aporta algo añadir valor_bp y sue?
Solución
IN [12]python
def historia(nombre):
    rid = runs.loc[runs["tags.mlflow.runName"] == nombre,
                   "run_id"].iloc[0]
    return pd.Series({m.step: m.value for m in
                      cli.get_metric_history(rid, "ic_anual")})

comp = pd.DataFrame({"precio": historia("precio a=1"),
                     "precio+fund": historia("precio+fund a=1")})
comp["diferencia"] = comp["precio+fund"] - comp["precio"]
print(comp.round(4))
OUT [12]IC por año de dos ejecuciones
      precio  precio+fund  diferencia
2020  0.0109       0.0175      0.0066
2021  0.0204       0.0182     -0.0022
2022  0.0121       0.0171      0.0051
2023 -0.0023       0.0013      0.0036
2024  0.0492       0.0515      0.0022
2025  0.0372       0.0402      0.0030

Añadir valor_bp y sue mejora el IC en cinco de los seis años, con diferencias de 0,002 a 0,007, y lo empeora ligeramente en 2021. Ninguna diferencia anual es grande frente al ruido de un IC semanal, así que la conclusión honesta es la de la media: una mejora pequeña y bastante consistente, que cuenta como una prueba más.

  1. Escribe una puerta (puede_registrarse(run)) que impida registrar una ejecución si le falta la etiqueta git_commit o datos_md5, si su estado no es FINISHED, si usa alguna feature de una lista negra o si su IC medio supera el techo. Pásala por las siete ejecuciones.
Solución
IN [13]python
NEGRA = {"revision_analistas"}

def puede_registrarse(r):
    motivos = []
    for t in ("git_commit", "datos_md5"):
        if not isinstance(r.get(f"tags.{t}"), str):
            motivos.append(f"sin {t}")
    if r["status"] != "FINISHED":
        motivos.append("no terminó")
    usadas = set(str(r["params.features"]).split(","))
    if usadas & NEGRA:
        motivos.append(f"usa {sorted(usadas & NEGRA)}")
    if r["metrics.ic_medio"] > TECHO:
        motivos.append("IC por encima del techo")
    return motivos

for _, r in runs.iterrows():
    print(f"{r['tags.mlflow.runName']:25}",
          puede_registrarse(r) or "ok")
OUT [13]Una puerta antes del registro
precio+fund+rev a=1       ["usa ['revision_analistas']", 'IC por encima del techo']
precio+fund+rev a=100000  ["usa ['revision_analistas']", 'IC por encima del techo']
precio+fund a=1           ok
precio+fund a=100000      ok
precio a=100000           ok
precio a=1                ok
precio+fund+sent a=1      ['no terminó']

Las dos ejecuciones con fuga se rechazan por dos motivos (la feature de la lista negra y el IC imposible), la fallida porque no terminó, y las cuatro honestas pasan. En un proyecto real, la puerta va en el mismo código que registra, justo antes de register_model, para que nadie se la salte por olvido.

  1. Un compañero propone guardar los datos en git con Git LFS y los experimentos en una hoja de cálculo. ¿Qué perdería el equipo frente a la combinación de la lección?
Solución

Git LFS también guarda punteros en git y el contenido aparte, así que para versionar ficheros grandes es una alternativa razonable; lo que no da es la lectura de una versión desde Python por commit (dvc.api.open), los pipelines ni la elección de remotos que no sean el servidor de LFS. La hoja de cálculo es el problema serio: nadie apunta en ella los fallos, ni el md5 de los datos, ni la métrica por año, ni el commit, y se edita a mano sin rastro. El registro de la lección se escribe solo, desde el mismo código que entrena, incluidas las ejecuciones que fallan; eso es lo que permite contar las pruebas para el Deflated Sharpe (lección 2.2.4) y reproducir un resultado meses después. Y el registro de modelos enlaza cada versión desplegada con la ejecución que la produjo, que una hoja no hace.

Footnotes

  1. DVC, «Get Started: Data Versioning» (https://dvc.org/doc/start/data-management/data-versioning): dvc add crea el .dvc con el md5, la caché va en .dvc/cache/files/md5, dvc push/dvc pull con el remoto, y para volver a una versión, git checkout del .dvc y dvc checkout. Consultada el 23 de octubre de 2026. ↩

  2. MLflow, «Model Registry» (https://mlflow.org/docs/latest/ml/model-registry/): nombre único, versiones que empiezan en 1, etiquetas, URI models:/<nombre>/<versión> y alias como models:/MyModel@champion. «Tracking» (https://mlflow.org/docs/latest/ml/tracking/) describe los almacenes y mlflow server. Consultadas el 23 de octubre de 2026. El almacén SQLite por defecto y el aviso del almacén de ficheros son los de mlflow-skinny 3.17.2, comprobados en el entorno del curso. ↩

  3. DVC, «dvc.api.open()» (https://dvc.org/doc/api-reference/open): abre un fichero versionado por DVC en un proyecto de DVC; rev es la revisión de git (commit, rama o etiqueta). Consultada el 23 de octubre de 2026. ↩

  4. Weights & Biases, «Experiments overview» (https://docs.wandb.ai/guides/track/): wandb.init como gestor de contexto, run.config, run.log y run.log_artifact; la página se sirve como «CoreWeave Docs». Opciones de despliegue, incluida Self-Managed: https://docs.wandb.ai/platform/hosting/. Consultadas el 23 de octubre de 2026. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. Tras dvc add datos/panel_semanal.parquet y git commit, ¿qué contiene el repositorio git?

    • AEl parquet entero, comprimido por git
    • BNada: DVC sustituye a git
    • CUna copia del parquet en .git/lfs
    • DEl fichero panel_semanal.parquet.dvc con el md5 y el tamaño, y un .gitignore que excluye el parquet; los datos van a la caché de DVC y al remoto
    Ver respuesta

    Respuesta D. El fichero panel_semanal.parquet.dvc con el md5 y el tamaño, y un .gitignore que excluye el parquet; los datos van a la caché de DVC y al remoto

    DVC deja en git un puntero pequeño y legible. El contenido vive en .dvc/cache (direccionado por su md5) y, tras dvc push, en el remoto. Por eso un commit identifica una versión exacta de los datos sin meter megas en el historial.

  2. Una de las ejecuciones registradas da un IC medio de 0,176 con un t de 58. ¿Qué dice el registro?

    • AQue es el mejor modelo y hay que registrarlo
    • BQue sus parámetros incluyen revision_analistas, la feature con fuga del nivel 5, y que su IC supera el techo del oráculo (0,029): es imposible fuera de muestra
    • CQue el alpha estaba mal elegido
    • DNada: MLflow no guarda las features
    Ver respuesta

    Respuesta B. Que sus parámetros incluyen revision_analistas, la feature con fuga del nivel 5, y que su IC supera el techo del oráculo (0,029): es imposible fuera de muestra

    El registro guarda qué features usó cada ejecución. Un resultado mejor que el techo teórico es una fuga, no un descubrimiento, y la lista de features dice cuál. Sin el registro, ese número podría acabar en un informe sin saber de dónde salía.

  3. La ejecución con sentimiento falla porque la feature tiene valores vacíos. ¿Por qué conviene que quede registrada como FAILED?

    • APorque cuenta como prueba: el número de intentos es el que pide el Deflated Sharpe, y los fallos también son decisiones tomadas mirando los datos
    • BPorque MLflow no deja borrarla
    • CPorque ocupa poco
    • DNo conviene: hay que borrar los fallos para que el informe quede limpio
    Ver respuesta

    Respuesta A. Porque cuenta como prueba: el número de intentos es el que pide el Deflated Sharpe, y los fallos también son decisiones tomadas mirando los datos

    Cada variante probada, funcione o no, es una mirada a los datos (lección 2.2.4). Si los fallos desaparecen, el número de pruebas se infravalora y la corrección por pruebas múltiples se queda corta.

  4. El proveedor corrige los datos y dvc add genera un md5 nuevo. ¿Cómo se recuperan los datos con los que se entrenó la ejecución elegida?

    • ANo se puede: DVC solo guarda la última versión
    • BVolviendo a descargar los datos del proveedor
    • CCon el commit guardado en la etiqueta git_commit de la ejecución: dvc.api.open(…, rev=commit) lee esa versión (o git checkout del .dvc y dvc checkout), y su md5 coincide con la etiqueta datos_md5
    • DCon mlflow.load_model
    Ver respuesta

    Respuesta C. Con el commit guardado en la etiqueta git_commit de la ejecución: dvc.api.open(…, rev=commit) lee esa versión (o git checkout del .dvc y dvc checkout), y su md5 coincide con la etiqueta datos_md5

    La caché y el remoto de DVC guardan cada versión por su md5, y el .dvc de cada commit dice cuál corresponde. La etiqueta datos_md5 de la ejecución permite comprobar que lo recuperado es lo mismo, byte a byte.

  5. ¿Para qué sirve el alias candidato del modelo registrado?

    • APara cifrar el modelo
    • BPara que el código que lo usa pida models:/ridge-semanal@candidato y no un número de versión: cambiar de versión es mover el alias, sin tocar ese código
    • CPara borrar las versiones antiguas
    • DPara que MLflow reentrene el modelo
    Ver respuesta

    Respuesta B. Para que el código que lo usa pida models:/ridge-semanal@candidato y no un número de versión: cambiar de versión es mover el alias, sin tocar ese código

    Un alias es una referencia con nombre a una versión concreta, que se puede reasignar. La lección 7.3.2 lo usa para el esquema champion/challenger.

Ideas clave

  • Reproducir un número exige cinco cosas: los datos exactos, el código exacto, los parámetros, el entorno y las semillas. Git guarda el código, DVC los datos (por md5) y MLflow une las dos cosas en cada ejecución.
  • DVC deja en git un .dvc con el md5 y el tamaño; el contenido va a la caché y a un remoto. Un commit identifica una versión exacta de los datos, y dvc.api.open(…, rev=…) la lee.
  • Cada ejecución de MLflow guarda parámetros, etiquetas (commit, md5 de los datos), métricas por paso y estado; las que fallan también cuentan como pruebas para el Deflated Sharpe.
  • El registro delata la fuga: la ejecución con revision_analistas da un IC de 0,176, seis veces el techo del oráculo (0,029); la honesta, 0,024.
  • El registro de modelos da versiones, etiquetas y alias: el código pide models:/ridge-semanal@candidato y obtiene las mismas predicciones que el modelo de scikit-learn.
  • Tras la corrección del proveedor, los datos de la ejecución elegida se recuperan con su commit, su md5 coincide con la etiqueta y el IC sale idéntico; con los datos nuevos, no.

Recursos

Datos