Saltar al contenido
← Todos los proyectos

Proyecto #18 · Datos y gobernanza

Monitoreo y recalibración de modelos

12 meses simulados en producción: drift, alertas y recalibrar vs. reentrenar.

📦 Datos: IBM Telco + drift simulado PSI / KS por variableAUC con ICRecalibrar vs. reentrenarRegla de disparo

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

00.10.224681012Mes en producciónDisparo
PSI

¿Qué se movió? PSI por variable al mes 12

MonthlyCharges 1.27
Contract 0.452
tenure 0.166
TotalCharges 0.12
n_servicios 0.111
DeviceProtection 0.08
PaymentMethod 0.072
TechSupport 0.067
OnlineSecurity 0.058
StreamingTV 0.053

AUC mensual con IC 95 % bootstrap

  • Modelo original
  • Reentrenado
0.80.850.924681012Mes en producciónDisparo
AUC

Calibración: churn real vs. predicho por estrategia

  • Real
  • Sin acción
  • Recalibrado
  • Reentrenado
0 %10 %20 %30 %40 %24681012Mes en producciónDisparo
% 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

EstrategiaAUC promedio post-disparoBrecha de calibración (pp)
A) Sin acción0.8056.509
B) Recalibración Platt0.8052.788
C) Reentrenamiento (últimos 3 meses)0.811.776

Tablero mensual de monitoreo

MesPSI scoreKS p-valorAUC originalAUC reentrenadoChurn real %Pred. sin acción %Pred. recalibrado %Pred. reentrenado %Estado
10.0090.3520.8170.81724.7252525Estable
20.0130.5660.8580.85827.627.527.527.5Estable
30.0110.8970.8670.86727.226.526.526.5Estable
40.0180.6810.8480.84825.926.526.526.5Estable
50.0160.0090.8430.8432728.828.828.8Estable
60.08400.8170.81732.232.832.832.8Estable
70.10500.8140.8143232.332.332.3Vigilar
80.08700.8160.81635.6313131Estable
90.17600.8190.8193834.434.434.4Vigilar
100.20500.7960.80339.734.938.538.9Vigilar
110.17800.8120.82142.434.738.439.4Vigilar
120.2700.8060.807423538.940.5Alerta

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.

demo.py · editable
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).

p18_monitoreo.py160 líneas
"""#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()