Lección 7.3.2 · 70 min

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.

Fig. — IC acumulado en vivo de cuatro políticas de reentrenamiento mensual, de enero de 2023 a diciembre de 2025, sobre los datos sintéticos de la lección (cambio de régimen el 1 de abril de 2024) (esquema)
Lecciones del nivel 7
  1. 7.1 · Ingeniería de software para trading

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

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

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

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

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

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.

fallapasaCalendarioFoto de datosEntrenarPuertasRechazo y alertaRegistroSombraChampion
Fuente 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.

IN [01]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))
OUT [01]El laboratorio: 50 activos, cinco features y un cambio de régimen
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.

IN [02]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)
OUT [02]Tres averías plantadas en la foto de datos de tres ciclos
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:

IN [03]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))
OUT [03]El ciclo: ventanas de entrenamiento y validación
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:

PuertaQué compruebaQué avería para
DatosNaN, valores repetidos de un día a otro en las 63 últimas sesionesdatos congelados o vacíos
Fugacorrelación de cada feature con el objetivo del mismo día por debajo de 0,3una feature construida con el futuro
Conductacorrelación de las predicciones del candidato y del champion con las mismas entradas ≥ 0,5modelos invertidos o vacíos
RendimientoIC diario del candidato frente al del champion en la validaciónmodelos 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=d‾σ^NW2/n,dt=ICtchallenger−ICtchampion,σ^NW2=γ^0+2∑k=1L(1−kL+1)γ^kt = \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
IN [04]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))
OUT [04]Las puertas de validación
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.

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.
IN [05]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})
OUT [05]Cuatro políticas de reentrenamiento, de 2023 a 2025
                 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:

IN [06]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")]
OUT [06]La evidencia del challenger, ciclo a ciclo
         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)

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

SumandoEn el laboratorioCómo se acortaQué cuesta
Datos del régimen nuevo en la ventanavarios mesesventana más corta o con pesos (lección 5.4.3)más varianza en los coeficientes
Validación reservada42 sesionesvalidación más cortamenos potencia: más t por debajo de 2
Evidencia exigida (t > 2)uno o dos ciclosumbral menorpromover 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.

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/501/\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:

IN [07]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)
OUT [07]El challenger en sombra: cinco sesiones con las mismas entradas
{'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.

IN [08]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"]]
OUT [08]El registro: un run por ciclo y alias champion y challenger
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 alias1Nota 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.:

IN [09]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))
OUT [09]Promoción y marcha atrás con alias
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:

puerta fallapuertas oksombra oksombra fallapromociónmarcha atrásCANDIDATORECHAZADOCHALLENGERCHAMPIONANTERIORARCHIVADO
Fuente 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

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
IN [10]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())
OUT [10]Superioridad con una ventana de 126 sesiones
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.

  1. 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
IN [11]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))
OUT [11]Una avería que las puertas no ven a la primera
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.

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

Footnotes

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

Respuesta plegada en cada pregunta

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

    • ALa de datos, porque hay valores repetidos
    • BLa de fuga, porque una feature explica el objetivo del mismo día
    • CLa de conducta: el candidato predice casi lo contrario que el champion con las mismas entradas (correlación −0,95)
    • DNinguna: el modelo se despliega y se ve en vivo
    Ver respuesta

    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?

    • ADe 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
    • BDe que MLflow tarda en registrar el modelo
    • CDe la prueba en sombra de cinco sesiones
    • DDe que el champion se reentrena cada mes
    Ver respuesta

    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?

    • APorque es más lento
    • BPorque MLflow no lo permite
    • CPorque el modelo nuevo siempre es mejor
    • DPorque 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
    Ver respuesta

    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?

    • AQue la de puertas usa más datos
    • BDos 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
    • CQue la de puertas exige superioridad
    • DEl azar del generador
    Ver respuesta

    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?

    • AQue el IC del challenger es mayor que el del champion
    • BQue el challenger tiene un Sharpe positivo
    • CQue el challenger funciona con las entradas reales del sistema: predicciones finitas, parecido al champion, dispersión y rotación de la cartera razonables
    • DQue el modelo está bien registrado en MLflow
    Ver respuesta

    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