---
title: "7.3.2 Reentrenamiento, validación y champion/challenger"
description: "Un ciclo mensual de reentrenamiento con puertas automáticas que paran tres averías plantadas, y champion/challenger con alias de MLflow."
url: "https://pythonparatrading.com/curso-python-trading/nivel-7/reentrenamiento-validacion-y-champion-challenger/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-7/reentrenamiento-validacion-y-champion-challenger.png"
---

# 7.3.2 · Reentrenamiento programado, validación automática y champion/challenger

> Reentrenar cada mes es fácil; lo difícil es no desplegar el mes en que la tubería de datos se rompe. Esta lección monta el ciclo completo sobre datos con un cambio de régimen y tres averías en fechas conocidas: entrena, pasa cuatro puertas automáticas, compara con el modelo en producción, lo prueba en sombra y lo promueve o lo descarta con alias en el registro de MLflow.

- 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: [5.4.3 · Walk-forward con reentrenamiento: ventanas y frecuencia](https://pythonparatrading.com/curso-python-trading/nivel-5/walk-forward-con-reentrenamiento/), [5.10.2 · Deriva de datos y de concepto: detectarla y qué hacer](https://pythonparatrading.com/curso-python-trading/nivel-5/deriva-de-datos-y-de-concepto/), [4.5.3 · Paper trading, shadow trading y seguimiento del slippage](https://pythonparatrading.com/curso-python-trading/nivel-4/paper-trading-shadow-trading-y-slippage/), [7.3.1 · Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin](https://pythonparatrading.com/curso-python-trading/nivel-7/registro-de-experimentos-modelos-y-datos/)

## Al terminar esta lección

- Programar un ciclo de reentrenamiento con ventanas de entrenamiento y validación que solo usan objetivos ya cerrados el día del ciclo.
- Escribir puertas de validación automática (contrato de datos, fuga, conducta y rendimiento) y saber qué avería para cada una.
- Comparar un challenger con el champion sobre las mismas sesiones con un contraste pareado y elegir entre exigir superioridad o solo pasar las puertas.
- Probar un challenger en sombra con comprobaciones operativas y promoverlo o volver atrás moviendo alias en el registro de modelos de MLflow.

## Un ciclo, no un entrenamiento

La lección 5.4.3 decidió **cuándo** reentrenar y con qué ventana, y la 5.10.2, cómo se detecta que el mundo ha
cambiado. Las dos trabajan en un laboratorio: un bucle que reentrena mil veces con datos limpios. En producción, cada
reentrenamiento es un acontecimiento: un proceso que arranca el primer día hábil del mes, pide los datos a una tubería
que puede haber fallado esa semana, entrena, y tiene que decidir sin nadie delante si el modelo que acaba de salir
sustituye al que mueve dinero. Esta lección construye ese proceso. El registro de experimentos y modelos de MLflow se
presenta en la lección 7.3.1; aquí se usa para lo que hace falta al final: versiones y alias.

```mermaid
flowchart TB
  C[Calendario] --> D[Foto de datos]
  D --> E[Entrenar]
  E --> P[Puertas]
  P -->|falla| X[Rechazo y alerta]
  P -->|pasa| R[Registro]
  R --> S[Sombra]
  S --> V[Champion]
```

En el vocabulario de MLOps, el **champion** es el modelo en producción y el **challenger** el que aspira a
sustituirlo. Para que el
relevo sea automático hacen falta tres cosas: saber con qué datos se entrenó cada candidato (la foto), una lista de
comprobaciones que un modelo roto no pasa (las puertas) y un mecanismo para cambiar de modelo y deshacer el cambio sin
desplegar código (los alias del registro).

## El laboratorio: un régimen que cambia y tres averías

Los datos son sintéticos, con la respuesta conocida. Cincuenta activos, cinco features persistentes (un AR(1) con
coeficiente 0,9, como un momentum o un valor que cambian despacio) y un rendimiento del día siguiente que depende de
ellas con unos coeficientes que cambian el **1 de abril de 2024**: la primera feature pasa de sumar a restar, la
tercera empieza a contar, y la segunda y la cuarta siguen igual.

```python
import numpy as np
import pandas as pd
from scipy.stats import rankdata
from sklearn.linear_model import Ridge

rng = np.random.default_rng(732)
fechas = pd.bdate_range("2020-06-01", "2025-12-31")
T, N, K = len(fechas), 50, 5
CAMBIO = pd.Timestamp("2024-04-01")
F = np.zeros((T, N, K))                  # features al cierre de t
F[0] = rng.standard_normal((N, K))
for t in range(1, T):                    # AR(1), persistencia 0,9
    F[t] = 0.9 * F[t - 1] + np.sqrt(0.19) * rng.standard_normal((N, K))
B_ANTES = np.array([0.4, 0.3, 0.0, 0.2, 0.0])
B_DESPUES = np.array([-0.3, 0.3, 0.4, 0.2, 0.0])
B = np.where((fechas < CAMBIO)[:, None], B_ANTES, B_DESPUES)
VOL = 0.012
# R[t]: rendimiento de t a t+1, que se conoce al cierre de t+1
R = VOL * (0.15 * np.einsum("tnk,tk->tn", F, B)
           + rng.standard_normal((T, N)))

def ic_dia(P, Y):
    """IC de Spearman de cada día (filas) entre predicción y
    rendimiento."""
    a = rankdata(P, axis=1) - (N + 1) / 2
    b = rankdata(Y, axis=1) - (N + 1) / 2
    return (a * b).sum(1) / np.sqrt((a * a).sum(1) * (b * b).sum(1))

oraculo = pd.Series(ic_dia(np.einsum("tnk,tk->tn", F, B), R),
                    index=fechas)
print(f"{T} sesiones x {N} activos x {K} features")
print("IC del oráculo antes y después del cambio:",
      round(oraculo[:CAMBIO].iloc[:-1].mean(), 3),
      round(oraculo[CAMBIO:].mean(), 3))
```
```output
1458 sesiones x 50 activos x 5 features
IC del oráculo antes y después del cambio: 0.077 0.078
```

El IC del oráculo (las predicciones con los coeficientes verdaderos de cada día) es de 0,077 antes del cambio y 0,078
después: el problema no se vuelve más difícil, se vuelve **otro**. Un modelo que no se entere seguirá apostando por la
primera feature justo cuando empieza a restar.

Encima, tres averías de la tubería de datos, cada una visible solo en la foto que recibe un ciclo concreto, como pasa
en la realidad: el proveedor arregla el dato, o alguien revierte el cambio de código, y el mes siguiente todo está
bien.

```python
AVERIAS = {
    pd.Timestamp("2023-09-01"): "f1 congelada en agosto",
    pd.Timestamp("2023-12-01"): "objetivo con el signo cambiado",
    pd.Timestamp("2025-02-03"): "f5 construida con el objetivo",
}

def foto(ciclo):
    """Features y objetivos tal como los entrega la tubería de
    datos el día del ciclo (con la avería, si ese día la hay)."""
    Fx, Rx = F.copy(), R.copy()
    a = AVERIAS.get(ciclo, "")
    if a.startswith("f1"):              # el proveedor repite el dato
        i = np.where((fechas >= "2023-08-01")
                     & (fechas < "2023-09-01"))[0]
        Fx[i, :, 0] = F[i[0] - 1, :, 0]
    elif a.startswith("objetivo"):      # precio_t / precio_t+1 - 1
        Rx = -R
    elif a.startswith("f5"):            # un join por la fecha mala
        Fx[:, :, 4] = F[:, :, 4] + R / VOL
    return Fx, Rx

for c, a in AVERIAS.items():
    print(c.date(), a)
```
```output
2023-09-01 f1 congelada en agosto
2023-12-01 objetivo con el signo cambiado
2025-02-03 f5 construida con el objetivo
```

- **Septiembre de 2023**: el proveedor repitió durante todo agosto el último valor de julio de `f1`. Es el fallo más
  común de un dato que llega por un servicio externo: no hay error, solo un número que no cambia.
- **Diciembre de 2023**: un cambio en el código que calcula el objetivo lo invierte (precio de hoy entre precio de
  mañana, en vez de al revés). Todas las columnas tienen buen aspecto.
- **Febrero de 2025**: un `merge` por la fecha equivocada mete en `f5` información del propio objetivo. Es una fuga:
  el modelo aprende de algo que en vivo no tendrá.

El ciclo se ejecuta antes de la apertura del primer día hábil de cada mes, de febrero de 2023 a diciembre de 2025. Ese
día, el último objetivo cerrado es el rendimiento de anteayer a ayer: la fila de anteayer es la última que puede
entrar en el entrenamiento. Detrás de ella se reservan 42 sesiones (dos meses) para validar, y detrás, 252 para
entrenar:

```python
VENT, VAL = 252, 42           # sesiones de entrenamiento y validación
inicio = fechas.get_loc(pd.Timestamp("2023-01-02"))
ciclos = [fechas[fechas >= d][0]
          for d in pd.date_range("2023-02-01", "2025-12-01",
                                 freq="MS")]

def ajustar(Fx, Rx, a, b):
    X, y = Fx[a:b].reshape(-1, K), Rx[a:b].ravel()
    return Ridge(alpha=1.0).fit(X, y)

def predecir(m, Fx, a, b):
    return m.predict(Fx[a:b].reshape(-1, K)).reshape(b - a, N)

def ventanas(ciclo):
    """Índices de entrenamiento y validación del ciclo. La última
    fila con objetivo cerrado es la de dos sesiones antes."""
    i = fechas.get_loc(ciclo)
    fin = i - 1                      # filas < fin: objetivo conocido
    return (fin - VAL - VENT, fin - VAL), (fin - VAL, fin)

(e0, e1), (v0, v1) = ventanas(pd.Timestamp("2024-05-01"))
print(len(ciclos), "ciclos, de", ciclos[0].date(), "a",
      ciclos[-1].date())
print("ciclo 2024-05-01 · entrena", fechas[e0].date(), "a",
      fechas[e1 - 1].date(), "· valida", fechas[v0].date(), "a",
      fechas[v1 - 1].date())
champion_0 = ajustar(F, R, inicio - 1 - VENT, inicio - 1)
print("coeficientes del champion inicial (x1000):",
      (champion_0.coef_ * 1e3).round(2))
```
```output
35 ciclos, de 2023-02-01 a 2025-12-01
ciclo 2024-05-01 · entrena 2023-03-15 a 2024-02-29 · valida 2024-03-01 a 2024-04-29
coeficientes del champion inicial (x1000): [ 0.59  0.44 -0.1   0.39  0.08]
```

El champion inicial se entrenó con las 252 sesiones anteriores a enero de 2023 y recupera bien la estructura del
primer régimen: coeficientes positivos y grandes en `f1`, `f2` y `f4`, casi cero en `f3` y `f5`. Dos detalles del
ciclo que importan en producción:

- La **foto** (`foto(ciclo)`) es lo que la tubería entregó ese día, no lo que hay hoy en la base de datos. Si el
  proveedor corrige agosto en octubre, reentrenar «el ciclo de septiembre» con los datos de hoy da otro modelo. Para
  que un ciclo sea reproducible, la foto se guarda o se identifica con una huella, como se hace más abajo.
- El modelo de cada ciclo se entrena con una receta fija (Ridge, `alpha=1`, 252 sesiones). Cambiar la receta (otras
  features, otro algoritmo) no es un reentrenamiento: es un modelo nuevo, y se valida como en las lecciones 5.4.3 y
  5.4.4 antes de entrar en el ciclo.

## Cuatro puertas

Una puerta es una comprobación automática con un umbral fijado de antemano. Si falla, el candidato no se registra como
challenger, el champion sigue y salta una alerta (lección 4.5.4). El orden va de lo barato y general a lo caro y
específico:

| Puerta | Qué comprueba | Qué avería para |
|---|---|---|
| Datos | NaN, valores repetidos de un día a otro en las 63 últimas sesiones | datos congelados o vacíos |
| Fuga | correlación de cada feature con el objetivo del mismo día por debajo de 0,3 | una feature construida con el futuro |
| Conducta | correlación de las predicciones del candidato y del champion con las mismas entradas ≥ 0,5 | modelos invertidos o vacíos |
| Rendimiento | IC diario del candidato frente al del champion en la validación | modelos peores (o no mejores) |

La de fuga se apoya en un hecho del dominio: en datos diarios ninguna feature legítima explica el rendimiento del día
siguiente con una correlación de 0,3; en el laboratorio, la mayor de los ciclos sin avería es de 0,085. La de conducta, en que dos modelos
de la misma receta con un mes de diferencia se parecen mucho: un candidato que predice lo contrario que el champion
no es una mejora, es un error. Y la de rendimiento usa la diferencia de IC **día a día** sobre las mismas sesiones, un
contraste pareado: los dos modelos ven el mismo mercado, así que la mayor parte del ruido se cancela en la diferencia.
Su error estándar se calcula con Newey-West, porque con features persistentes los IC de días seguidos están
correlacionados:

$$
t = \frac{\overline{d}}{\sqrt{\hat{\sigma}^2_{NW} / n}}, \qquad d_t = IC_t^{\text{challenger}} - IC_t^{\text{champion}}, \qquad \hat{\sigma}^2_{NW} = \hat{\gamma}_0 + 2\sum_{k=1}^{L}\left(1 - \frac{k}{L+1}\right)\hat{\gamma}_k
$$

```python
def t_hac(d, L=5):
    """t de la media de d con varianza de Newey-West (L retardos)."""
    m, e = d.mean(), d - d.mean()
    v = e @ e / len(d)
    for k in range(1, L + 1):
        v += 2 * (1 - k / (L + 1)) * (e[k:] @ e[:-k]) / len(d)
    return m / np.sqrt(v / len(d)) if v > 0 else 0.0

def puertas(ciclo, Fx, Rx, cand, champ, exigir_mejora):
    """Devuelve (puerta que falla u 'ok', detalle)."""
    (e0, e1), (v0, v1) = ventanas(ciclo)
    # 1. contrato de datos: valores repetidos en las 63 últimas
    ult = Fx[v1 - 63:v1]
    rep = (ult[1:] == ult[:-1]).mean(axis=(0, 1))
    if np.isnan(Fx[e0:v1]).any():
        return "datos", "hay NaN"
    if rep.max() > 0.05:
        return "datos", f"f{rep.argmax() + 1} repetida {rep.max():.0%}"
    # 2. fuga: ninguna feature puede explicar el objetivo del día
    X, y = Fx[e0:v1].reshape(-1, K), Rx[e0:v1].ravel()
    rho = np.array([np.corrcoef(X[:, k], y)[0, 1] for k in range(K)])
    if np.abs(rho).max() > 0.3:
        k = np.abs(rho).argmax()
        return "fuga", f"corr(f{k + 1}, objetivo) = {rho[k]:.2f}"
    # 3. conducta: parecido al champion con las mismas entradas
    pc = predecir(cand, Fx, v0, v1)
    pp = predecir(champ, Fx, v0, v1)
    r = np.corrcoef(pc.ravel(), pp.ravel())[0, 1]
    if r < 0.5:
        return "conducta", f"corr. con el champion {r:+.2f}"
    if not exigir_mejora:
        return "ok", f"corr. con el champion {r:+.2f}"
    # 4. rendimiento: IC diario frente al champion, mismas sesiones
    d = ic_dia(pc, Rx[v0:v1]) - ic_dia(pp, Rx[v0:v1])
    t = t_hac(d)
    det = f"dIC {d.mean():+.3f}, t {t:+.1f}"
    return ("ok" if d.mean() > 0.01 and t > 2 else "no mejora"), det

for c in [pd.Timestamp("2023-06-01"), *AVERIAS]:
    Fx, Rx = foto(c)
    (e0, e1), _ = ventanas(c)
    cand = ajustar(Fx, Rx, e0, e1)
    print(c.date(), *puertas(c, Fx, Rx, cand, champion_0, True))
```
```output
2023-06-01 no mejora dIC +0.003, t +0.9
2023-09-01 datos f1 repetida 35%
2023-12-01 conducta corr. con el champion -0.95
2025-02-03 fuga corr(f5, objetivo) = 0.70
```

Las tres averías caen, cada una en una puerta distinta, y un ciclo normal llega hasta la última:

- **Septiembre de 2023**: el 35 % de los valores de `f1` en las últimas 63 sesiones son iguales a los del día
  anterior. Agosto son 22 de esas 63 sesiones; con datos reales que se mueven, la proporción es cero.
- **Diciembre de 2023**: las features están bien y nada explica el objetivo del día, así que pasa las dos primeras.
  Pero sus predicciones tienen una correlación de −0,95 con las del champion: el modelo ha aprendido el mercado al
  revés.
- **Febrero de 2025**: `f5` tiene una correlación de 0,70 con el objetivo del mismo día. Ninguna feature honrada hace
  eso.
- **Junio de 2023**: un ciclo sin avería en el régimen inicial. El candidato mejora al champion en 0,003 de IC con
  t = 0,9: no hay motivo para cambiar de modelo.

<aside class="callout" data-kind="aviso">

Los umbrales se fijan antes de ver los resultados y se escriben en la configuración del ciclo, con su justificación.
Un umbral que se ajusta cada vez que una puerta molesta deja de ser una puerta. Si una puerta salta con datos buenos,
se investiga por qué y se cambia la regla con una revisión, igual que el código.

</aside>

## Champion/challenger: ¿exigir que mejore?

La pregunta de fondo es qué tiene que demostrar un candidato para sustituir al champion. Hay dos respuestas
razonables, y el laboratorio permite medir las dos, junto a dos referencias: no reentrenar nunca y reentrenar cada mes
desplegando lo que salga.

- **Fijo**: el champion de enero de 2023 para siempre.
- **Siempre**: cada ciclo reentrena con las 252 últimas sesiones y despliega, sin puertas.
- **Superioridad**: el champion/challenger clásico. El candidato pasa las cuatro puertas y, en la de rendimiento,
  tiene que superar al champion en más de 0,01 de IC con t > 2. Se despliega el modelo validado.
- **Puertas**: el candidato pasa las tres primeras puertas; si las pasa, se reajusta con las 252 últimas sesiones
  (incluidas las de validación) y se despliega. No se le pide que mejore.

```python
def politica(nombre):
    """Simula los ciclos y devuelve el IC diario en vivo, el
    registro de decisiones y los modelos desplegados."""
    champ, vigentes, reg = champion_0, [(inicio, champion_0)], []
    for c in ciclos:
        if nombre == "fijo":
            break
        Fx, Rx = foto(c)
        (e0, e1), (v0, v1) = ventanas(c)
        i = fechas.get_loc(c)
        if nombre == "siempre":            # sin puertas
            champ = ajustar(Fx, Rx, v1 - VENT, v1)
            vigentes.append((i, champ))
            reg.append((c, "desplegado", ""))
            continue
        cand = ajustar(Fx, Rx, e0, e1)
        mejora = nombre == "superioridad"
        g, det = puertas(c, Fx, Rx, cand, champ, mejora)
        reg.append((c, g, det))
        if g == "ok":                      # puertas: reajusta todo
            champ = cand if mejora else ajustar(Fx, Rx, v1 - VENT, v1)
            vigentes.append((i, champ))
    P = np.zeros((T, N))
    for j, (a, m) in enumerate(vigentes):
        b = vigentes[j + 1][0] if j + 1 < len(vigentes) else T
        P[a:b] = predecir(m, F, a, b)
    ic = pd.Series(ic_dia(P[inicio:], R[inicio:]),
                   index=fechas[inicio:])
    return ic, reg, len(vigentes) - 1

res = {n: politica(n) for n in
       ("fijo", "siempre", "superioridad", "puertas")}
filas = {}
for n, (ic, reg, k) in res.items():
    mes = ic.groupby(ic.index.to_period("M")).mean()
    filas[n] = {"IC": ic.mean(), "antes": ic[:"2024-03"].mean(),
                "después": ic["2024-04":].mean(),
                "2023-12": mes["2023-12"], "2025-02": mes["2025-02"],
                "despliegues": k}
pd.DataFrame(filas).T.round(3).astype({"despliegues": int})
```
```output
                 IC  antes  después  2023-12  2025-02  despliegues
fijo          0.032  0.094   -0.012    0.079   -0.006            0
siempre       0.067  0.087    0.053   -0.091    0.021           35
superioridad  0.064  0.094    0.042    0.079    0.095            4
puertas       0.074  0.098    0.057    0.087    0.094           32
```

La figura de la cabecera acumula el IC diario de las cuatro. Antes del cambio van juntas (de 0,087 a 0,098 de IC
medio). Después, el modelo fijo cae a −0,012: sigue apostando por `f1`. Las otras tres se recuperan, a ritmos
distintos:

- **Siempre** reacciona antes que nadie, porque cada mes despliega el modelo más fresco, pero despliega también los
  rotos: −0,091 de IC en diciembre de 2023 (el modelo invertido) y 0,021 en febrero de 2025 (el de la fuga, que en vivo
  no tiene la información con la que aprendió y apuesta por ruido).
- **Superioridad** no despliega ningún modelo roto y solo cambia de modelo cuatro veces en tres años, pero tarda en
  adaptarse: después del cambio, 0,042.
- **Puertas** tiene el mejor IC antes (0,098) y después (0,057) del cambio: la frescura de «siempre» sin sus dos meses
  malos.

La diferencia entre «siempre» y «puertas» es casi toda de esos dos meses: el mismo reentrenamiento, con y sin
comprobar el resultado. La avería de septiembre de 2023, en cambio, apenas cuesta: un mes de `f1` congelada en una
ventana de 252 sesiones solo atenúa un coeficiente. Que una avería no cueste mucho en este laboratorio no quiere decir
que no cueste en otro; la puerta está para no tener que averiguarlo.

¿Por qué tarda tanto la política clásica? La tabla de los ciclos alrededor del cambio lo cuenta:

```python
ic, reg, _ = res["superioridad"]
tabla = pd.DataFrame(reg, columns=["ciclo", "puerta", "detalle"])
tabla["ciclo"] = tabla["ciclo"].dt.date.astype(str)
tabla[tabla["ciclo"].between("2024-03-01", "2025-01-31")]
```
```output
         ciclo     puerta             detalle
13  2024-03-01  no mejora  dIC +0.009, t +1.5
14  2024-04-01  no mejora  dIC +0.001, t +0.1
15  2024-05-01  no mejora  dIC +0.001, t +0.1
16  2024-06-03  no mejora  dIC +0.001, t +0.1
17  2024-07-01  no mejora  dIC +0.005, t +0.5
18  2024-08-01  no mejora  dIC +0.014, t +0.9
19  2024-09-02  no mejora  dIC +0.018, t +1.2
20  2024-10-01  no mejora  dIC +0.031, t +1.8
21  2024-11-01         ok  dIC +0.052, t +3.3
22  2024-12-02         ok  dIC +0.013, t +3.2
23  2025-01-01         ok  dIC +0.021, t +2.5
```

En abril, mayo y junio de 2024 la ventana de entrenamiento del candidato todavía es casi entera del régimen viejo (la
validación ocupa los dos últimos meses), así que candidato y champion predicen casi lo mismo (0,001 de diferencia de
IC). A partir de agosto el
candidato empieza a ver el régimen nuevo: mejora en 0,014 (t = 0,9), en 0,018 (t = 1,2), en 0,031 (t = 1,8). Ya es
mejor, pero con 42 sesiones de validación no lo puede demostrar hasta noviembre: 0,052 con t = 3,3. Siete meses
después del cambio.

*Fig. — Estadístico t del IC diario del challenger frente al champion en la validación de cada ciclo mensual, con la regla de superioridad (datos sintéticos de la lección) (esquema).* Barras mensuales de 2023 a 2025. Hasta octubre de 2024 todas quedan por debajo del umbral 2, también después del cambio de régimen de abril de 2024: 0,1 en abril, 0,9 en agosto, 1,8 en octubre. En noviembre de 2024 la barra destacada llega a 3,3 y el challenger se promueve; también superan el umbral diciembre de 2024, enero y agosto de 2025. Tres cruces marcan los ciclos rechazados por datos, conducta y fuga.

El retraso tiene tres sumandos, y conviene saber cuál domina antes de intentar reducirlo:

| Sumando | En el laboratorio | Cómo se acorta | Qué cuesta |
|---|---|---|---|
| Datos del régimen nuevo en la ventana | varios meses | ventana más corta o con pesos (lección 5.4.3) | más varianza en los coeficientes |
| Validación reservada | 42 sesiones | validación más corta | menos potencia: más t por debajo de 2 |
| Evidencia exigida (t > 2) | uno o dos ciclos | umbral menor | promover ruido |

Por eso la superioridad tiene sentido cuando el challenger es **otra receta**: otro algoritmo, otras features, otro
objetivo. Ahí la pregunta «¿es mejor?» es la correcta y equivocarse cuesta, porque un modelo distinto puede fallar de
formas que el champion no tiene. Cuando el challenger es la misma receta con un mes más de datos, lo que se validó fue
la receta (en el walk-forward de la lección 5.4.3, con su Deflated Sharpe de la 5.4.4), y lo que hay que comprobar
cada mes es que el modelo de este mes no está roto. Es lo que hace la política de puertas.

<aside class="callout" data-kind="nota">

La política de puertas no compara el modelo reajustado con el champion en la validación. No podría hacerlo con
justicia: el champion se reajustó el mes anterior con la misma receta y ya vio casi todas esas sesiones, así que
cualquier comparación lo favorece. Para comparar dos modelos hacen falta sesiones que ninguno de los dos haya visto.

</aside>

## La sombra: el challenger con las entradas reales

Un modelo que pasa las puertas todavía no ha funcionado nunca con la tubería de producción: la foto del ciclo es una
copia, y en vivo las features llegan de otro proceso, a otra hora y a veces con otro formato. La **sombra** (lección
4.5.3, aplicada al modelo en vez de a las órdenes) ejecuta el challenger en paralelo con el champion durante unos días,
con las mismas entradas, y guarda sus predicciones sin operar con ellas.

Lo que se comprueba en la sombra no es si gana. Con 50 activos, la desviación típica del IC de un solo día es de unas
0,14 ($1/\sqrt{50}$); en cinco sesiones, la media del IC tiene un error de unas 0,06, mayor que cualquier mejora
plausible. Se comprueba que el modelo **se comporta**:

```python
c = pd.Timestamp("2024-11-01")
i = fechas.get_loc(c)
Fx, Rx = foto(c)
(e0, e1), _ = ventanas(c)
challenger = ajustar(Fx, Rx, e0, e1)
champ_vigente = champion_0           # superioridad: aún el inicial

def comprobar_sombra(champ, chal, a, b, max_rotacion=0.6):
    """Comprobaciones operativas, no estadísticas: el IC de cinco
    sesiones es ruido."""
    pc, pp = predecir(chal, F, a, b), predecir(champ, F, a, b)
    top = lambda p: set(np.argsort(p)[-10:])   # 10 mejores
    rot = np.mean([1 - len(top(pc[j]) & top(pc[j - 1])) / 10
                   for j in range(1, b - a)])
    return {"finitas": bool(np.isfinite(pc).all()),
            "corr_champion": round(float(np.corrcoef(
                pc.ravel(), pp.ravel())[0, 1]), 2),
            "dispersion_rel": round(float(pc.std() / pp.std()), 2),
            "rotacion_top10": round(float(rot), 2),
            "ok": bool(np.isfinite(pc).all() and rot < max_rotacion)}

comprobar_sombra(champ_vigente, challenger, i, i + 5)
```
```output
{'finitas': True, 'corr_champion': 0.77, 'dispersion_rel': 0.87, 'rotacion_top10': 0.28, 'ok': True}
```

El challenger de noviembre de 2024 da predicciones finitas, con una correlación de 0,77 con el champion (se parecen,
pero no son el mismo modelo: ya ha aprendido algo del régimen nuevo), una dispersión un 13 % menor y una rotación de
los diez primeros activos del 28 % por sesión. Son números para comparar con los del champion en su día y con un
máximo fijado: un modelo que cambiara cada día la mitad de la cartera generaría unos costes de transacción que la
validación, sin costes, no ve.

## El registro: un run por ciclo y alias

Lo que queda de cada ciclo, en el almacén SQLite de MLflow del entorno `ops` (lección 7.3.1): un **run** con los
parámetros, la puerta que paró al candidato o `ok`, y la huella SHA-256 de la foto de datos con la que se entrenó. Con
la huella se puede comprobar años después que un ciclo se reproduce: misma foto, mismo modelo.

```python
import hashlib
import mlflow
from mlflow import MlflowClient

def huella(Fx, Rx, a, b):
    """SHA-256 de la foto de datos con la que se entrena."""
    h = hashlib.sha256(Fx[a:b].tobytes())
    h.update(Rx[a:b].tobytes())
    return h.hexdigest()[:12]

mlflow.set_experiment("senal-n7")
for c, g, det in reg:
    Fx, Rx = foto(c)
    (e0, e1), _ = ventanas(c)
    with mlflow.start_run(run_name=f"ciclo-{c.date()}"):
        mlflow.log_params({"ventana": VENT, "validacion": VAL,
                           "alpha": 1.0, "ciclo": str(c.date())})
        mlflow.set_tags({"puerta": g, "detalle": det,
                         "datos_sha256": huella(Fx, Rx, e0, e1)})
runs = mlflow.search_runs(filter_string="tags.puerta != 'no mejora'",
                          order_by=["params.ciclo"])
print(len(mlflow.search_runs()), "runs")
runs[["params.ciclo", "tags.puerta", "tags.datos_sha256"]]
```
```output
35 runs
  params.ciclo tags.puerta tags.datos_sha256
0   2023-09-01       datos      86b09d034105
1   2023-12-01    conducta      7805683890e5
2   2024-11-01          ok      e3a71b5c0551
3   2024-12-02          ok      9e01f0587dad
4   2025-01-01          ok      eff27095c2e9
5   2025-02-03        fuga      91fbc679f221
6   2025-08-01          ok      132ff59aad40
```

Treinta y cinco runs, uno por ciclo; la consulta filtra los que no son «no mejora» y deja a la vista lo que interesa en
una auditoría: los tres rechazos con su motivo y los cuatro ciclos que pasaron. Los modelos solo se registran cuando
van a desplegarse: registrar 35 versiones que nadie usará llena el registro de ruido.

El paso a producción es un **alias**. En el registro de MLflow, un alias es un nombre que apunta a una versión
concreta de un modelo registrado (`champion` a la 1, `challenger` a la 2) y el proceso en vivo carga
`models:/senal-n7@champion` sin saber qué versión es. Las etapas fijas que usaba MLflow antes (`Staging`,
`Production`) están obsoletas desde la versión 2.9.0, y la documentación propone justo este uso de los alias[^1]:

```python
NOMBRE = "senal-n7"
ejemplo = F[i].astype("float64")[:2]
versiones = {}
for etiqueta, modelo in (("inicial", champion_0),
                         ("2024-11-01", challenger)):
    with mlflow.start_run(run_name=f"modelo-{etiqueta}"):
        info = mlflow.sklearn.log_model(modelo, name="modelo",
                                        input_example=ejemplo)
    versiones[etiqueta] = mlflow.register_model(info.model_uri,
                                                NOMBRE).version
cli = MlflowClient()
cli.set_registered_model_alias(NOMBRE, "champion", versiones["inicial"])
cli.set_registered_model_alias(NOMBRE, "challenger",
                               versiones["2024-11-01"])

def promover(cli, nombre):
    """El challenger pasa a champion; el champion, a 'anterior'."""
    antes = cli.get_model_version_by_alias(nombre, "champion")
    nuevo = cli.get_model_version_by_alias(nombre, "challenger")
    cli.set_registered_model_alias(nombre, "anterior", antes.version)
    cli.set_registered_model_alias(nombre, "champion", nuevo.version)
    cli.delete_registered_model_alias(nombre, "challenger")

def marcha_atras(cli, nombre):
    antes = cli.get_model_version_by_alias(nombre, "anterior")
    cli.set_registered_model_alias(nombre, "champion", antes.version)

def alias(cli, nombre):
    al = cli.get_registered_model(nombre).aliases
    return dict(sorted(al.items()))

print("antes:", alias(cli, NOMBRE))
promover(cli, NOMBRE)
print("promovido:", alias(cli, NOMBRE))
vivo = mlflow.sklearn.load_model(f"models:/{NOMBRE}@champion")
print("el proceso en vivo carga el challenger:",
      np.allclose(vivo.predict(F[i]), challenger.predict(F[i])))
marcha_atras(cli, NOMBRE)
print("marcha atrás:", alias(cli, NOMBRE))
```
```output
Successfully registered model 'senal-n7'.
Created version '1' of model 'senal-n7'.
Registered model 'senal-n7' already exists. Creating a new version of this model...
Created version '2' of model 'senal-n7'.
antes: {'challenger': 2, 'champion': 1}
promovido: {'anterior': 1, 'champion': 2}
el proceso en vivo carga el challenger: True
marcha atrás: {'anterior': 1, 'champion': 1}
```

Promover es mover dos alias: `champion` pasa a la versión 2 y la versión 1 queda como `anterior`. El proceso en vivo,
que cargaba por alias, recibe el challenger sin cambiar una línea de código. Volver atrás es mover `champion` a
`anterior`: un segundo, y sin reentrenar ni desplegar nada. Esa es la propiedad que justifica todo el registro. Si el
modelo nuevo se porta mal el primer día, la decisión de deshacer no puede esperar a que alguien encuentre el fichero
del modelo anterior.

El ciclo de vida de una versión, completo:

```mermaid
stateDiagram-v2
  [*] --> CANDIDATO
  CANDIDATO --> RECHAZADO: puerta falla
  CANDIDATO --> CHALLENGER: puertas ok
  CHALLENGER --> CHAMPION: sombra ok
  CHALLENGER --> RECHAZADO: sombra falla
  CHAMPION --> ANTERIOR: promoción
  ANTERIOR --> CHAMPION: marcha atrás
  ANTERIOR --> ARCHIVADO
```

<aside class="callout" data-kind="riesgo">

Un alias es un puntero compartido: quien pueda moverlo decide qué modelo opera. En producción, mover `champion` es
una acción con permisos propios (no los tiene el proceso que entrena), queda registrada con quién y cuándo, y el
proceso en vivo recarga el modelo en un momento controlado (antes de la apertura, no a mitad de una orden).

</aside>

## Qué se automatiza y qué no

El ciclo de la lección decide solo, y eso es lo que se quiere para lo repetitivo. Hay decisiones que no deben
automatizarse:

- **El primer despliegue de una receta nueva** pasa por la validación completa y por una persona. Las puertas
  comprueban que el modelo de este mes no está roto; no deciden si la receta es buena.
- **Las alertas de rechazo** llegan a alguien. Un rechazo deja al champion operando con datos más viejos; tres
  rechazos seguidos son un sistema que ha dejado de aprender, y eso también es un fallo.
- **Los umbrales de las puertas** solo cambian con revisión y quedan en el historial del repositorio.
- **Desactivar el modelo**, si su rendimiento en vivo se hunde entre dos ciclos, no puede esperar al ciclo siguiente:
  es la lección 7.3.3.

## Ejercicios

1. Repite la política de superioridad con una ventana de entrenamiento de 126 sesiones en vez de 252. ¿Cuándo se
   promueve el primer challenger después del cambio? ¿Cuántas veces se cambia de modelo en total?

**Solución.**

```python
VENT = 126
ic126, reg126, k126 = politica("superioridad")
VENT = 252
print("promociones:", [str(c.date()) for c, g, _ in reg126
                       if g == "ok"])
print(f"IC medio {ic126.mean():.3f} · después del cambio "
      f"{ic126['2024-04':].mean():.3f}")
desde_oct = [g for c, g, _ in reg126 if c >= pd.Timestamp("2024-10")]
print("puertas desde octubre de 2024:",
      pd.Series(desde_oct).value_counts().to_dict())
```
```output
promociones: ['2024-03-01']
IC medio 0.034 · después del cambio -0.009
puertas desde octubre de 2024: {'conducta': 14, 'fuga': 1}
```

Con 126 sesiones, el único challenger que se promueve es el de marzo de 2024, **antes** del cambio, y después el
IC medio es de −0,009: peor que con 252. La ventana corta aprende antes el régimen nuevo (en agosto de 2024 el
candidato ya mejora en 0,033 de IC, con t = 1,8), pero no llega a demostrarlo, y desde octubre el candidato se parece
tan poco al champion de marzo (correlación de 0,11, luego negativa) que la puerta de conducta lo rechaza en todos los
ciclos: 14 rechazos por conducta hasta diciembre de 2025, y uno por la fuga de febrero. Es un bloqueo: el champion
no se puede sustituir porque está tan desfasado que cualquier modelo actual parece roto a su lado.

Las puertas interactúan. La de conducta pregunta «¿se parece a lo que había?», y la respuesta deja de ser útil
cuando lo que había es viejo. Dos arreglos: comparar la conducta con el candidato del ciclo anterior (misma receta,
un mes de diferencia), no con el champion; y tratar varios rechazos seguidos de la misma puerta como una alerta para
una persona, no como una decisión que se repite sola cada mes.

2. Una cuarta avería: el objetivo se calcula con un día de retraso (el rendimiento de mañana a pasado mañana en vez
   del de hoy a mañana). Escribe su foto y pásala por las puertas en el ciclo de mayo de 2025. ¿La para alguna?

**Solución.**

```python
def foto_retrasada(ciclo):
    Fx, _ = foto(ciclo)
    return Fx, np.roll(R, -1, axis=0)   # rendimiento de t+1 a t+2

c = pd.Timestamp("2025-05-01")
_, (_, v1_previo) = ventanas(pd.Timestamp("2025-04-01"))
champ = ajustar(F, R, v1_previo - VENT, v1_previo)  # el de abril
Fx, Rx = foto_retrasada(c)
(e0, e1), _ = ventanas(c)
cand = ajustar(Fx, Rx, e0, e1)
for mejora in (False, True):
    print(*puertas(c, Fx, Rx, cand, champ, mejora))
print("coeficientes x1000:", (cand.coef_ * 1e3).round(2),
      (champ.coef_ * 1e3).round(2))
```
```output
ok corr. con el champion +1.00
no mejora dIC -0.003, t -1.1
coeficientes x1000: [-0.52  0.27  0.54  0.31 -0.07] [-0.66  0.36  0.6   0.32 -0.08]
```

Ninguna puerta la para. Las features están bien y ninguna explica el objetivo del mismo día; el candidato tiene una
correlación de 1,00 con el champion, y con la regla de superioridad sale «no mejora» (−0,003 de IC). Como las features
persisten de un día a otro (coeficiente 0,9), predicen casi igual de bien el rendimiento de mañana que el de pasado
mañana: el modelo aprende los mismos coeficientes, algo más pequeños, y en vivo apenas se nota. La política de
puertas lo habría desplegado.

Es una avería del **objetivo**, no del modelo, y se prueba en la tubería que lo calcula: una prueba unitaria que
recalcula el rendimiento de unos cuantos activos y fechas a partir de los precios y lo compara con la columna (lección
7.1.1), y una comprobación de que la fecha de cada objetivo es posterior a la de sus features. Las puertas del ciclo
son una red de seguridad, no la única: un error que no cambia el modelo no se ve mirando el modelo.

3. Un compañero propone acelerar la política de superioridad comparando el challenger con el champion en las sesiones
   de validación y, si gana, desplegarlo reajustado con todos los datos. ¿Qué se valida y qué se despliega en ese
   caso?

**Solución.**

Se valida un modelo y se despliega otro. El modelo reajustado no se ha comparado con nada; lo que se comparó fue el
candidato sin las 42 últimas sesiones. En la práctica suelen parecerse, y por eso la propuesta es razonable si se
dice con claridad: lo que se está validando es la **receta** («entrenar con las 252 últimas sesiones da un modelo
mejor que el champion»), no el fichero que se despliega. Para que eso sea cierto hace falta comprobar el modelo
reajustado con las tres primeras puertas (que no dependen de la validación) y pasarlo por la sombra. La alternativa
estricta, desplegar exactamente el modelo validado, es la de la lección: pierde 42 sesiones de frescura a cambio de
que el fichero en producción sea el que pasó la prueba.

[^1]: MLflow, «Model Registry Workflows» (https://mlflow.org/docs/latest/ml/model-registry/workflow/): «As of MLflow
    2.9.0, Model Stages have been deprecated and will be removed in a future major release»; el ejemplo de la
    documentación crea un alias `champion` con `set_registered_model_alias` y carga el modelo con
    `models:/<nombre>@champion`. Consultada el 23 de octubre de 2026.

## Autoevaluación

1. El ciclo del 1 de diciembre de 2023 entrena con un objetivo calculado como precio de hoy entre precio de mañana menos 1. ¿Qué puerta lo para y por qué?

   - A. La de datos, porque hay valores repetidos
   - B. La de fuga, porque una feature explica el objetivo del mismo día
   - C. La de conducta: el candidato predice casi lo contrario que el champion con las mismas entradas (correlación −0,95)
   - D. Ninguna: el modelo se despliega y se ve en vivo

   *Respuesta C: La de conducta: el candidato predice casi lo contrario que el champion con las mismas entradas (correlación −0,95).* Con el signo del objetivo cambiado, Ridge aprende los coeficientes con el signo contrario. Las features están bien y no explican el objetivo del día, así que las dos primeras puertas pasan; la tercera compara las predicciones del candidato y del champion con las mismas entradas y encuentra una correlación de −0,95.

2. Con la regla de superioridad, el régimen cambia el 1 de abril de 2024 y el primer challenger se promueve el 1 de noviembre. ¿De dónde sale la mayor parte del retraso?

   - A. De que la ventana de 252 sesiones tarda meses en contener bastantes datos del régimen nuevo y de que la validación de 42 sesiones necesita una diferencia grande para llegar a t > 2
   - B. De que MLflow tarda en registrar el modelo
   - C. De la prueba en sombra de cinco sesiones
   - D. De que el champion se reentrena cada mes

   *Respuesta A: De que la ventana de 252 sesiones tarda meses en contener bastantes datos del régimen nuevo y de que la validación de 42 sesiones necesita una diferencia grande para llegar a t > 2.* En agosto el candidato ya mejora en 0,014 de IC, pero con t = 0,9; en octubre, 0,031 con t = 1,8. La evidencia crece a la vez que la ventana se llena del régimen nuevo, y no cruza el umbral hasta noviembre (0,052, t = 3,3). La sombra añade días, no meses.

3. ¿Por qué la política «puertas» no compara en la validación el modelo reajustado con el champion?

   - A. Porque es más lento
   - B. Porque MLflow no lo permite
   - C. Porque el modelo nuevo siempre es mejor
   - D. Porque el champion, reajustado el mes anterior con la misma receta, se entrenó con casi todas esas sesiones: la comparación favorecería al que las ha visto

   *Respuesta D: Porque el champion, reajustado el mes anterior con la misma receta, se entrenó con casi todas esas sesiones: la comparación favorecería al que las ha visto.* Una comparación justa exige sesiones fuera de muestra para los dos modelos. Cuando el challenger es la misma receta con un mes más de datos, lo que se valida es la receta (en el walk-forward de la lección 5.4.3) y lo que se comprueba cada mes es que el modelo nuevo no está roto.

4. La política que reentrena y despliega cada mes sin puertas tiene un IC medio de 0,067; la de puertas, 0,074. ¿Qué explica casi toda la diferencia?

   - A. Que la de puertas usa más datos
   - B. Dos meses con modelos rotos: −0,091 en diciembre de 2023 y 0,021 en febrero de 2025, frente a 0,087 y 0,094 con puertas
   - C. Que la de puertas exige superioridad
   - D. El azar del generador

   *Respuesta B: Dos meses con modelos rotos: −0,091 en diciembre de 2023 y 0,021 en febrero de 2025, frente a 0,087 y 0,094 con puertas.* Las dos políticas reentrenan con la misma receta y la misma ventana; solo difieren en que una despliega lo que sale y la otra lo comprueba antes. La avería de agosto de 2023 apenas cuesta, pero el objetivo con el signo cambiado y la feature con fuga cuestan un mes entero cada uno.

5. ¿Qué comprueba la prueba en sombra de cinco sesiones de la lección?

   - A. Que el IC del challenger es mayor que el del champion
   - B. Que el challenger tiene un Sharpe positivo
   - C. Que el challenger funciona con las entradas reales del sistema: predicciones finitas, parecido al champion, dispersión y rotación de la cartera razonables
   - D. Que el modelo está bien registrado en MLflow

   *Respuesta C: Que el challenger funciona con las entradas reales del sistema: predicciones finitas, parecido al champion, dispersión y rotación de la cartera razonables.* Cinco sesiones de IC son ruido (la desviación típica del IC de un día con 50 activos es de unas 0,14). La sombra no es una segunda validación estadística: comprueba que el modelo nuevo, alimentado por la tubería de producción, no hace nada raro antes de que mueva dinero.

## Ideas clave

- Un ciclo de reentrenamiento es una tubería programada: foto de datos del día, entrenamiento con objetivos cerrados, puertas automáticas, registro, sombra y promoción. Cada paso deja rastro.
- Las puertas de datos, fuga y conducta paran las tres averías de la lección: una feature congelada (35 % de valores repetidos), un objetivo con el signo cambiado (correlación −0,95 con el champion) y una feature hecha con el objetivo (correlación 0,70).
- Exigir que el challenger supere al champion con t > 2 evita desplegar ruido, pero en el laboratorio tarda siete meses en reaccionar a un cambio de régimen.
- Cuando el challenger es la misma receta con más datos, basta con pasar las puertas: IC medio de 0,074 frente a 0,067 sin puertas, 0,064 con superioridad y 0,032 sin reentrenar. La superioridad se reserva para cambiar de receta.
- La sombra comprueba el modelo con la tubería real (predicciones finitas, parecido, rotación), no su rentabilidad: cinco sesiones de IC son ruido.
- En el registro, champion y challenger son alias que apuntan a versiones: promover y volver atrás es mover un alias, sin tocar el código del proceso en vivo.

## Recursos

### Research

- [Alpha Factors y Alpha Trading en Python](https://pythonparatrading.com/alpha-factors-y-alpha-trading-en-python/) · Research-0007 · 5 min

### Documentación

- [MLflow: Model Registry Workflows (alias, etiquetas y el fin de las etapas)](https://mlflow.org/docs/latest/ml/model-registry/workflow/) · mlflow.org
- [Google: Rules of Machine Learning](https://developers.google.com/machine-learning/guides/rules-of-ml) · developers.google.com

### Libros

- [Sculley, D. et al. (2015): Hidden Technical Debt in Machine Learning Systems. NeurIPS](https://papers.nips.cc/paper/5656-hidden-technical-debt-in-machine-learning-systems) · papers.nips.cc
- [Breck, E. et al. (2017): The ML Test Score: A Rubric for ML Production Readiness and Technical Debt Reduction](https://research.google/pubs/the-ml-test-score-a-rubric-for-ml-production-readiness-and-technical-debt-reduction/) · research.google
- [Newey, W. K. y West, K. D. (1987): A Simple, Positive Semi-Definite, Heteroskedasticity and Autocorrelation Consistent Covariance Matrix. Econometrica 55(3)](https://doi.org/10.2307/1913610) · doi.org

Lección anterior: [7.3.1 · Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin](https://pythonparatrading.com/curso-python-trading/nivel-7/registro-de-experimentos-modelos-y-datos/)

Lección siguiente: [7.3.3 · Monitorización del modelo en vivo y desactivación automática](https://pythonparatrading.com/curso-python-trading/nivel-7/monitorizacion-en-vivo-y-desactivacion-automatica/)
