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.
- Módulo
- 7.3 · MLOps para modelos de trading
- Duración
- 70 min
- Requisitos
- 5.4.3 · Walk-forward con reentrenamiento: ventanas y frecuencia, 5.10.2 · Deriva de datos y de concepto: detectarla y qué hacer, 4.5.3 · Paper trading, shadow trading y seguimiento del slippage, 7.3.1 · Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
Lecciones del nivel 7
7.1 · Ingeniería de software para trading
7.2 · Infraestructura y despliegue
7.3 · MLOps para modelos de trading
- 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
- 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
- 7.3.3 Monitorización del modelo en vivo y desactivación automática
- 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
7.4 · Regulación, ética y fiscalidad
- 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
- 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
- 7.4.3 PRIIPs, KID y ETFs estadounidenses
- 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
- 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
- 7.4.6 Fiscalidad del trading en España
- 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
7.5 · Carrera profesional quant
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.
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.
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))1458 sesiones x 50 activos x 5 features
IC del oráculo antes y después del cambio: 0.077 0.078El 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.
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)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
mergepor la fecha equivocada mete enf5informació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:
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))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:
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))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.70Las 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
f1en 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:
f5tiene 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.
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}) 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 32La 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:
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")] 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.5En 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.
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.
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 (); 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:
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){'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.
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"]]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 132ff59aad40Treinta 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.:
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))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:
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
- 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
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())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.
- 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
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))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.
- 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
-
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
championconset_registered_model_aliasy carga el modelo conmodels:/<nombre>@champion. Consultada el 23 de octubre de 2026. ↩
Respuesta plegada en cada pregunta
Autoevaluación
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.
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.
¿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.
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.
¿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
Documentación
Libros
- Sculley, D. et al. (2015): Hidden Technical Debt in Machine Learning Systems. NeurIPS
- Breck, E. et al. (2017): The ML Test Score: A Rubric for ML Production Readiness and Technical Debt Reduction
- Newey, W. K. y West, K. D. (1987): A Simple, Positive Semi-Definite, Heteroskedasticity and Autocorrelation Consistent Covariance Matrix. Econometrica 55(3)