Proyecto #18 · Datos y gobernanza
Monitoreo y recalibración de modelos
12 meses simulados en producción: drift, alertas y recalibrar vs. reentrenar.
Resumen ejecutivo
- El PSI del score cruza el umbral de vigilancia (0.10) en el mes 7 y llega a 0.27; la prueba KS lo confirma (p < 0.001 desde el mes 6). El PSI por variable señala la causa: MonthlyCharges, Contract, tenure.
- El AUC cae de 0.847 (meses 1–4) a 0.805 (meses 10–12), con IC bootstrap mensual: el ataque del competidor a clientes de fibra es un drift de concepto.
- Disparo en el mes 9 (brecha de calibración > 3 pp dos meses seguidos). Después del disparo: sin acción brecha 6.5 pp; recalibrar 2.8 pp; reentrenar 1.8 pp y AUC 0.810 vs. 0.805.
- Lección: recalibrar corrige el nivel pero no el orden; reentrenar corrige ambos, aunque aquí la mejora de AUC es pequeña (+0.005) porque el ataque es aleatorio dentro de fibra: sin una variable de competencia el modelo no puede ver a quién ataca. Siguiente paso: capturar esa señal.
PSI máx.0.27
AUC inicio → fin0.85 → 0.80
AUC reentrenado (post)0.810
Brecha recalibrado2.8 pp
Resultados
Estabilidad del score (PSI) por mes
< 0.10 estable · 0.10–0.25 vigilar · > 0.25 alerta
PSI
¿Qué se movió? PSI por variable al mes 12
AUC mensual con IC 95 % bootstrap
- Modelo original
- Reentrenado
AUC
Calibración: churn real vs. predicho por estrategia
- Real
- Sin acción
- Recalibrado
- Reentrenado
% de churn
Metodología y validación
Regresión logística de churn (#1) monitoreada 12 meses simulados con drift de población y de concepto.
✔ Cómo se validó
- PSI del score y por variable (CSI), prueba KS de dos muestras, AUC mensual con IC bootstrap.
- Regla de disparo explícita (brecha de calibración) y comparación de tres respuestas operativas.
⚙ Supuestos
- Las etiquetas reales llegan con un mes de rezago (se usan las del mes anterior para recalibrar/reentrenar).
- Drift simulado: más contratos mes a mes, +2 % mensual en cargos y un competidor que ataca fibra.
⚠ Limitaciones
- Escenario simulado sobre datos públicos; en producción se agregan segmentos y umbrales por negocio.
Comparación de estrategias después del disparo
| Estrategia | AUC promedio post-disparo | Brecha de calibración (pp) |
|---|---|---|
| A) Sin acción | 0.805 | 6.509 |
| B) Recalibración Platt | 0.805 | 2.788 |
| C) Reentrenamiento (últimos 3 meses) | 0.81 | 1.776 |
Tablero mensual de monitoreo
| Mes | PSI score | KS p-valor | AUC original | AUC reentrenado | Churn real % | Pred. sin acción % | Pred. recalibrado % | Pred. reentrenado % | Estado |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 0.009 | 0.352 | 0.817 | 0.817 | 24.7 | 25 | 25 | 25 | Estable |
| 2 | 0.013 | 0.566 | 0.858 | 0.858 | 27.6 | 27.5 | 27.5 | 27.5 | Estable |
| 3 | 0.011 | 0.897 | 0.867 | 0.867 | 27.2 | 26.5 | 26.5 | 26.5 | Estable |
| 4 | 0.018 | 0.681 | 0.848 | 0.848 | 25.9 | 26.5 | 26.5 | 26.5 | Estable |
| 5 | 0.016 | 0.009 | 0.843 | 0.843 | 27 | 28.8 | 28.8 | 28.8 | Estable |
| 6 | 0.084 | 0 | 0.817 | 0.817 | 32.2 | 32.8 | 32.8 | 32.8 | Estable |
| 7 | 0.105 | 0 | 0.814 | 0.814 | 32 | 32.3 | 32.3 | 32.3 | Vigilar |
| 8 | 0.087 | 0 | 0.816 | 0.816 | 35.6 | 31 | 31 | 31 | Estable |
| 9 | 0.176 | 0 | 0.819 | 0.819 | 38 | 34.4 | 34.4 | 34.4 | Vigilar |
| 10 | 0.205 | 0 | 0.796 | 0.803 | 39.7 | 34.9 | 38.5 | 38.9 | Vigilar |
| 11 | 0.178 | 0 | 0.812 | 0.821 | 42.4 | 34.7 | 38.4 | 39.4 | Vigilar |
| 12 | 0.27 | 0 | 0.806 | 0.807 | 42 | 35 | 38.9 | 40.5 | Alerta |
Pruébalo: Python en tu navegador
Una versión compacta del análisis, ejecutada con Pyodide (CPython en WebAssembly). Edita el código y vuelve a ejecutarlo; si defines la variable grafica, se dibuja abajo.
La primera ejecución descarga Python + NumPy/pandas (~15 MB, una sola vez).
Código completo
El script exacto que generó los resultados de esta página (python run_all.py).
"""#18 Monitoreo y recalibración de un modelo en producción.
Simulamos que el modelo de churn (#1) lleva 12 meses en producción. A partir del mes 5 cambia la
mezcla de clientes (más contratos mes a mes y cargos más altos → drift de población) y desde el
mes 8 un competidor ataca a clientes de fibra óptica (→ drift de concepto). Se vigila con PSI/CSI,
AUC y calibración mensual, y se recalibra (Platt) cuando la alerta se dispara.
"""
import numpy as np
import pandas as pd
from scipy.stats import ks_2samp
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from common import SEED, bars, export, line, psi, table
from p01_churn import load
from validacion import bootstrap_ci, model_card
MESES, N_MES = 12, 900
PSI_ALERTA, PSI_AVISO = 0.25, 0.10
BRECHA_RECAL = 0.03 # brecha de calibración (3 pp) que obliga a recalibrar
def psi_categorico(ref: pd.Series, act: pd.Series) -> float:
e = ref.value_counts(normalize=True)
a = act.value_counts(normalize=True).reindex(e.index, fill_value=0)
e, a = e.clip(lower=1e-6), a.clip(lower=1e-6)
return float(((a - e) * np.log(a / e)).sum())
def nuevo_modelo(num, cat):
return make_pipeline(ColumnTransformer([("num", StandardScaler(), num),
("cat", OneHotEncoder(handle_unknown="ignore"), cat)]),
LogisticRegression(max_iter=2000))
def run():
rng = np.random.default_rng(SEED)
df = load()
y = df.pop("Churn").values
cat = df.select_dtypes("object").columns.tolist()
num = [c for c in df.columns if c not in cat]
X_tr, X_pool, y_tr, y_pool = train_test_split(df, y, test_size=0.5, stratify=y, random_state=SEED)
modelo = nuevo_modelo(num, cat).fit(X_tr, y_tr)
ref_score = modelo.predict_proba(X_tr)[:, 1]
# --- Simulación de 12 meses en producción ---
mm = (X_pool["Contract"] == "Month-to-month").values
fibra = (X_pool["InternetService"] == "Fiber optic").values
meses = []
for m in range(1, MESES + 1):
w = np.where(mm, 1 + 0.35 * max(0, m - 4), 1.0) # drift de población: más contratos mes a mes
idx = rng.choice(len(X_pool), N_MES, p=w / w.sum())
Xm, ym = X_pool.iloc[idx].copy(), y_pool[idx].copy()
Xm["MonthlyCharges"] *= 1 + 0.02 * max(0, m - 4) # aumento de precios
if m >= 8: # drift de concepto: competidor ataca fibra
ataque = fibra[idx] & (ym == 0) & (rng.random(N_MES) < 0.06 * (m - 7))
ym[ataque] = 1
meses.append((Xm.reset_index(drop=True), ym))
# --- Tres estrategias de operación, con la misma regla de disparo ---
# A) no hacer nada · B) recalibrar (Platt) · C) reentrenar con los últimos 3 meses etiquetados
filas, mes_disparo, calibrador, retrain = [], None, None, None
for m, (Xm, ym) in enumerate(meses, start=1):
p = modelo.predict_proba(Xm)[:, 1]
logit = np.log(p / (1 - p)).reshape(-1, 1)
p_b = calibrador.predict_proba(logit)[:, 1] if calibrador is not None else p
p_c = retrain.predict_proba(Xm)[:, 1] if retrain is not None else p
auc, lo, hi = bootstrap_ci(ym, p, n=200)
filas.append({"mes": m, "psi_score": psi(ref_score, p), "ks_p": float(ks_2samp(ref_score, p).pvalue),
"auc": auc, "auc_lo": lo, "auc_hi": hi, "auc_c": roc_auc_score(ym, p_c),
"real": ym.mean(), "pred_a": p.mean(), "pred_b": p_b.mean(), "pred_c": p_c.mean()})
# Regla: brecha de calibración > 3 pp dos meses seguidos → se activan B y C desde el mes siguiente
brechas = [abs(f["real"] - f["pred_a"]) for f in filas[-2:]]
if mes_disparo is None and len(brechas) == 2 and min(brechas) > BRECHA_RECAL:
mes_disparo = m
calibrador = LogisticRegression().fit(logit, ym)
Xr = pd.concat([X_tr] + [meses[k][0] for k in range(m - 3, m)], ignore_index=True)
yr = np.concatenate([y_tr] + [meses[k][1] for k in range(m - 3, m)])
peso = np.concatenate([np.ones(len(y_tr)), np.full(len(yr) - len(y_tr), 3.0)]) # más peso a lo reciente
retrain = nuevo_modelo(num, cat).fit(Xr, yr, logisticregression__sample_weight=peso)
mon = pd.DataFrame(filas)
primer_alerta = int(mon.loc[mon.psi_score > PSI_AVISO, "mes"].min())
# --- Diagnóstico: ¿qué variables se movieron? PSI por variable al mes 12 ---
X12 = meses[-1][0]
drift = pd.Series({c: (psi(X_tr[c], X12[c]) if c in num else psi_categorico(X_tr[c], X12[c]))
for c in df.columns}).sort_values(ascending=False)
post = mon[mon.mes > mes_disparo]
gap = lambda col: float((post[col] - post.real).abs().mean() * 100)
estr = pd.DataFrame({
"Estrategia": ["A) Sin acción", "B) Recalibración Platt", "C) Reentrenamiento (últimos 3 meses)"],
"AUC promedio post-disparo": [post.auc.mean(), post.auc.mean(), post.auc_c.mean()],
"Brecha de calibración (pp)": [gap("pred_a"), gap("pred_b"), gap("pred_c")],
}).round(3)
tbl = mon[["mes", "psi_score", "ks_p", "auc", "auc_c", "real", "pred_a", "pred_b", "pred_c"]].copy()
for c in ["real", "pred_a", "pred_b", "pred_c"]:
tbl[c] = (tbl[c] * 100).round(1)
tbl = tbl.round(3)
tbl["estado"] = np.select([tbl.psi_score > PSI_ALERTA, tbl.psi_score > PSI_AVISO], ["Alerta", "Vigilar"], "Estable")
tbl.columns = ["Mes", "PSI score", "KS p-valor", "AUC original", "AUC reentrenado", "Churn real %",
"Pred. sin acción %", "Pred. recalibrado %", "Pred. reentrenado %", "Estado"]
mk = [[mes_disparo + 0.5, "Disparo"]]
pct = lambda col: [[int(a), round(float(b) * 100, 2)] for a, b in mon[["mes", col]].values]
result = {
"title": "Monitoreo y recalibración de modelos",
"summary": [
f"El PSI del score cruza el umbral de vigilancia (0.10) en el mes {primer_alerta} y llega a {mon.psi_score.max():.2f}; "
f"la prueba KS lo confirma (p < 0.001 desde el mes {int(mon.loc[mon.ks_p < 0.001, 'mes'].min())}). El PSI por "
f"variable señala la causa: {', '.join(drift.index[:3])}.",
f"El AUC cae de {mon.auc.iloc[:4].mean():.3f} (meses 1–4) a {mon.auc.iloc[-3:].mean():.3f} (meses 10–12), con IC "
"bootstrap mensual: el ataque del competidor a clientes de fibra es un drift de concepto.",
f"Disparo en el mes {mes_disparo} (brecha de calibración > 3 pp dos meses seguidos). Después del disparo: "
f"sin acción brecha {estr.iloc[0, 2]:.1f} pp; recalibrar {estr.iloc[1, 2]:.1f} pp; reentrenar {estr.iloc[2, 2]:.1f} pp "
f"y AUC {estr.iloc[2, 1]:.3f} vs. {estr.iloc[0, 1]:.3f}.",
"Lección: recalibrar corrige el nivel pero no el orden; reentrenar corrige ambos, aunque aquí la mejora de "
f"AUC es pequeña ({estr.iloc[2, 1] - estr.iloc[0, 1]:+.3f}) porque el ataque es aleatorio dentro de fibra: "
"sin una variable de competencia el modelo no puede ver a quién ataca. Siguiente paso: capturar esa señal.",
],
"metrics": [
{"label": "PSI máx.", "value": f"{mon.psi_score.max():.2f}"},
{"label": "AUC inicio → fin", "value": f"{mon.auc.iloc[:4].mean():.2f} → {mon.auc.iloc[-3:].mean():.2f}"},
{"label": "AUC reentrenado (post)", "value": f"{estr.iloc[2, 1]:.3f}"},
{"label": "Brecha recalibrado", "value": f"{estr.iloc[1, 2]:.1f} pp"},
],
"charts": [
line("psi", "Estabilidad del score (PSI) por mes", {"PSI score": mon[["mes", "psi_score"]].round(3).values.tolist()},
x_label="Mes en producción", y_label="PSI", fmt="dec", markers=mk,
subtitle="< 0.10 estable · 0.10–0.25 vigilar · > 0.25 alerta"),
bars("drift", "¿Qué se movió? PSI por variable al mes 12", drift.index[:10], drift.values[:10].round(3), fmt="dec"),
line("auc", "AUC mensual con IC 95 % bootstrap",
{"Modelo original": mon[["mes", "auc"]].round(3).values.tolist(),
"Reentrenado": [[int(a), round(float(b), 3)] for a, b in mon[mon.mes > mes_disparo][["mes", "auc_c"]].values]},
bands={"Modelo original": mon[["mes", "auc_lo", "auc_hi"]].round(3).values.tolist()},
x_label="Mes en producción", y_label="AUC", fmt="dec", markers=mk),
line("calib", "Calibración: churn real vs. predicho por estrategia",
{"Real": pct("real"), "Sin acción": pct("pred_a"), "Recalibrado": pct("pred_b"), "Reentrenado": pct("pred_c")},
x_label="Mes en producción", y_label="% de churn", fmt="pct", markers=mk),
],
"tables": [table("Comparación de estrategias después del disparo", estr),
table("Tablero mensual de monitoreo", tbl)],
"card": model_card(
"Regresión logística de churn (#1) monitoreada 12 meses simulados con drift de población y de concepto.",
["PSI del score y por variable (CSI), prueba KS de dos muestras, AUC mensual con IC bootstrap.",
"Regla de disparo explícita (brecha de calibración) y comparación de tres respuestas operativas."],
["Las etiquetas reales llegan con un mes de rezago (se usan las del mes anterior para recalibrar/reentrenar).",
"Drift simulado: más contratos mes a mes, +2 % mensual en cargos y un competidor que ataca fibra."],
["Escenario simulado sobre datos públicos; en producción se agregan segmentos y umbrales por negocio."]),
}
export("p18-monitoreo", result)
return result
if __name__ == "__main__":
run()