---
title: "7.3.1 Registro de experimentos, modelos y datos"
description: "MLflow con SQLite para ejecuciones, métricas y modelos registrados; DVC y git para versionar datos, y una ejecución reproducida desde sus etiquetas."
url: "https://pythonparatrading.com/curso-python-trading/nivel-7/registro-de-experimentos-modelos-y-datos/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-7/registro-de-experimentos-modelos-y-datos.png"
---

# 7.3.1 · 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.

- Curso: [Nivel 7: Producción, MLOps, regulación, carrera y capstone](https://pythonparatrading.com/curso-python-trading/nivel-7/)
- Módulo: 7.3 · MLOps para modelos de trading
- Duración: 70 min
- Requisitos: [0.2.5 · Git y GitHub: commits, ramas, pull requests y .gitignore](https://pythonparatrading.com/curso-python-trading/nivel-0/git-y-github-para-trading/), [2.2.4 · Diario de investigación y registro de experimentos contra el data snooping](https://pythonparatrading.com/curso-python-trading/nivel-2/diario-de-investigacion-y-registro-de-experimentos/), [5.4.4 · Backtest de señales de ML con costes y Deflated Sharpe](https://pythonparatrading.com/curso-python-trading/nivel-5/backtest-de-senales-de-ml-y-deflated-sharpe/)

## Al terminar esta lección

- Enumerar lo que hace falta para reproducir un resultado (datos, código, parámetros, entorno, semillas) y dónde guarda cada cosa git, DVC y MLflow.
- Versionar un conjunto de datos con DVC en un repositorio git, enviarlo a un remoto y leer una versión anterior desde un commit.
- Registrar ejecuciones en MLflow con parámetros, etiquetas de versión, métricas por paso y estado, también las que fallan, y consultarlas con filtros.
- Registrar un modelo con firma, ejemplo de entrada, etiquetas y alias, y cargarlo por su nombre para obtener las mismas predicciones.
- Reproducir una ejecución pasada a partir de sus etiquetas y explicar por qué el número de ejecuciones registradas alimenta la corrección por pruebas múltiples.

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

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

```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")
```
```output
(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:

```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 baja[^1].

```mermaid
flowchart TB
  W[panel.parquet] --> C[Caché DVC]
  C --> R[Remoto]
  W --> P[panel.parquet.dvc]
  P --> G[Repositorio git]
```

```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])
```
```output
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:

```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)])
```
```output
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.

```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})
```
```output
               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 MLflow[^2]. 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:

```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)))
```
```output
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`.

```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"))
```
```output
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 DVC[^3]; sus parámetros dicen cómo entrenar; y su métrica dice qué debería
salir.

```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))
```
```output
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:

```python skip title="Volver a la versión de datos de un commit"
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:

```python skip title="Servidor de MLflow y remoto de DVC en la nube"
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ón[^4]:

```python skip title="El mismo registro en Weights & Biases"
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

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

```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))
```
```output
      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.

2. 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.**

```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")
```
```output
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.

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

[^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.

## Autoevaluación

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

   - A. El parquet entero, comprimido por git
   - B. Nada: DVC sustituye a git
   - C. Una copia del parquet en `.git/lfs`
   - 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

   *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?

   - A. Que es el mejor modelo y hay que registrarlo
   - 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
   - C. Que el alpha estaba mal elegido
   - D. Nada: MLflow no guarda las features

   *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`?

   - 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
   - B. Porque MLflow no deja borrarla
   - C. Porque ocupa poco
   - D. No conviene: hay que borrar los fallos para que el informe quede limpio

   *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?

   - A. No se puede: DVC solo guarda la última versión
   - B. Volviendo a descargar los datos del proveedor
   - 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`
   - D. Con `mlflow.load_model`

   *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?

   - A. Para cifrar el modelo
   - 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
   - C. Para borrar las versiones antiguas
   - D. Para que MLflow reentrene el modelo

   *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

### Documentación

- [MLflow: Tracking (ejecuciones, almacenes y servidor)](https://mlflow.org/docs/latest/ml/tracking/) · mlflow.org
- [MLflow: Model Registry (versiones, etiquetas y alias)](https://mlflow.org/docs/latest/ml/model-registry/) · mlflow.org
- [DVC: Get Started, Data Versioning](https://dvc.org/doc/start/data-management/data-versioning) · dvc.org
- [DVC: Python API, dvc.api.open](https://dvc.org/doc/api-reference/open) · dvc.org
- [Weights & Biases: Experiments overview](https://docs.wandb.ai/guides/track/) · docs.wandb.ai

### Datos

- [Datos del nivel 5: features del panel semanal (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_features.parquet) · Descarga · panel5_features.parquet

Lección anterior: [7.2.4 · Seguridad y continuidad: 2FA, IP, copias y procedimientos ante fallos](https://pythonparatrading.com/curso-python-trading/nivel-7/seguridad-y-continuidad-de-negocio/)

Lección siguiente: [7.3.2 · Reentrenamiento programado, validación automática y champion/challenger](https://pythonparatrading.com/curso-python-trading/nivel-7/reentrenamiento-validacion-y-champion-challenger/)
