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.
- Módulo
- 7.3 · MLOps para modelos de trading
- Duración
- 70 min
Lecciones del nivel 7
7.1 · Ingeniería de software para trading
7.2 · Infraestructura y despliegue
7.3 · MLOps para modelos de trading
- 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
- 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
- 7.3.3 Monitorización del modelo en vivo y desactivación automática
- 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
7.4 · Regulación, ética y fiscalidad
- 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
- 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
- 7.4.3 PRIIPs, KID y ETFs estadounidenses
- 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
- 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
- 7.4.6 Fiscalidad del trading en España
- 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
7.5 · Carrera profesional quant
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.
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.
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")(291600, 11) 2012-01-13 2025-12-26
4.5 MB · md5 36eccd106ec9200919d2c447fffccac2
0.32 vacíos en sentimientoSon 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:
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 entrenarVersionar 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..
Fuente Mermaid
flowchart TB W[panel.parquet] --> C[Caché DVC] C --> R[Remoto] W --> P[panel.parquet.dvc] P --> G[Repositorio git]
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])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:
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)])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.
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}) 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ónde | Ejemplo |
|---|---|---|
| Versión de los datos | etiqueta | datos_md5, el commit con el .dvc |
| Versión del código | etiqueta | git_commit; sin cambios pendientes de commit al ejecutar |
| Validación | parámetros | corte, ventana, purga y embargo (lecciones 5.4.2 y 5.4.3) |
| Costes | parámetros | modelo de costes y supuestos de ejecución |
| Semillas | parámetros | semilla del modelo y del muestreo |
| Resultado resumido | métricas | IC medio y t, Sharpe neto, rotación |
| Resultado en el tiempo | métricas por paso | IC por año, drawdown por año |
| Serie completa | artefacto | rendimientos o IC semanales, para el Deflated Sharpe y la PBO |
| Decisión | etiqueta | «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:
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)))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)]
TrueMLflow 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.
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"))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 modifieddvc 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.
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))md5 coincide con la etiqueta: True
registrado 0.024276 · reproducido 0.024276 · con v2 0.024311
2023: 0.0013 frente a 0.0015El 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:
import subprocess
subprocess.run(["git", "checkout", "<commit>", "--",
"datos/panel_semanal.parquet.dvc"], check=True)
subprocess.run(["dvc", "checkout"], check=True) # o dvc pullServidor, 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:
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.:
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()})| MLflow | Weights & Biases | DVC | |
|---|---|---|---|
| Qué guarda | ejecuciones, métricas, artefactos, modelos registrados | ejecuciones, métricas, artefactos, paneles | versiones de datos y modelos (y pipelines) |
| Dónde | carpeta, base de datos o servidor propio | servicio en la nube | caché local y remoto (carpeta, S3, SSH…) |
| Licencia | código abierto (Apache 2.0) | servicio comercial | código abierto (Apache 2.0) |
| Encaja con | registro y despliegue de modelos sin depender de terceros | equipos que quieren paneles sin mantener servidor | datos 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
- Compara la historia anual del IC (
ic_anual) de las ejecucionesprecio a=1yprecio+fund a=1conget_metric_history. ¿En qué años aporta algo añadirvalor_bpysue?
Solución
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)) 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.0030Añ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.
- Escribe una puerta (
puede_registrarse(run)) que impida registrar una ejecución si le falta la etiquetagit_commitodatos_md5, si su estado no esFINISHED, si usa alguna feature de una lista negra o si su IC medio supera el techo. Pásala por las siete ejecuciones.
Solución
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")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.
- 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
-
DVC, «Get Started: Data Versioning» (https://dvc.org/doc/start/data-management/data-versioning):
dvc addcrea el.dvccon el md5, la caché va en.dvc/cache/files/md5,dvc push/dvc pullcon el remoto, y para volver a una versión,git checkoutdel.dvcydvc checkout. Consultada el 23 de octubre de 2026. ↩ -
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 comomodels:/MyModel@champion. «Tracking» (https://mlflow.org/docs/latest/ml/tracking/) describe los almacenes ymlflow 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. ↩ -
DVC, «dvc.api.open()» (https://dvc.org/doc/api-reference/open): abre un fichero versionado por DVC en un proyecto de DVC;
reves la revisión de git (commit, rama o etiqueta). Consultada el 23 de octubre de 2026. ↩ -
Weights & Biases, «Experiments overview» (https://docs.wandb.ai/guides/track/):
wandb.initcomo gestor de contexto,run.config,run.logyrun.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
Tras
dvc add datos/panel_semanal.parquetygit 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.dvccon el md5 y el tamaño, y un.gitignoreque excluye el parquet; los datos van a la caché de DVC y al remoto
Ver respuesta
Respuesta D. El fichero
panel_semanal.parquet.dvccon el md5 y el tamaño, y un.gitignoreque excluye el parquet; los datos van a la caché de DVC y al remotoDVC deja en git un puntero pequeño y legible. El contenido vive en
.dvc/cache(direccionado por su md5) y, trasdvc push, en el remoto. Por eso un commit identifica una versión exacta de los datos sin meter megas en el historial.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 muestraEl 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.
La ejecución con
sentimientofalla porque la feature tiene valores vacíos. ¿Por qué conviene que quede registrada comoFAILED?- 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.
El proveedor corrige los datos y
dvc addgenera 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_commitde la ejecución:dvc.api.open(…, rev=commit)lee esa versión (ogit checkoutdel.dvcydvc checkout), y su md5 coincide con la etiquetadatos_md5 - DCon
mlflow.load_model
Ver respuesta
Respuesta C. Con el commit guardado en la etiqueta
git_commitde la ejecución:dvc.api.open(…, rev=commit)lee esa versión (ogit checkoutdel.dvcydvc checkout), y su md5 coincide con la etiquetadatos_md5La caché y el remoto de DVC guardan cada versión por su md5, y el
.dvcde cada commit dice cuál corresponde. La etiquetadatos_md5de la ejecución permite comprobar que lo recuperado es lo mismo, byte a byte.¿Para qué sirve el alias
candidatodel modelo registrado?- APara cifrar el modelo
- BPara que el código que lo usa pida
models:/ridge-semanal@candidatoy 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@candidatoy no un número de versión: cambiar de versión es mover el alias, sin tocar ese códigoUn 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
.dvccon 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, ydvc.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_analistasda 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@candidatoy 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.