Saltar al contenido
← Todos los proyectos

Proyecto #1 · Retención

Churn con valor económico

Quién se va, cuánto vale retenerlo y a qué porcentaje de la cartera conviene contactar.

📦 Datos: IBM Telco Customer Churn Logística vs. LightGBMCV 5×2 + t corregidoSHAP global y localCalibraciónUtilidad con IC 🧬 Alternativa: Enjambre de partículas (PSO) 🔬 Mejor algoritmo: Ensamble EBM + splines + CatBoost

Resumen ejecutivo

  • Validación cruzada 5×2: logística AUC 0.844 ± 0.010 vs. LightGBM 0.847 ± 0.010; la diferencia (-0.003) no es significativa (t corregido, p = 0.26). Ante empate se elige el modelo explicable.
  • En prueba: AUC 0.845 (IC 95 % 0.827–0.863); contactar al 20 % con mayor riesgo captura el 51% de las cancelaciones.
  • Óptimo económico: contactar al 54% de la cartera, utilidad ≈ $124,962/año (IC 90 % $112,619–$137,636).
  • Buena práctica: NO usar class_weight='balanced' cuando el score alimenta un cálculo de dinero; infla la probabilidad media a 42% vs. 27% real. La logística sin ponderar está calibrada (pendiente 0.99, intercepto -0.02).
  • Impulsores (SHAP): contrato mes a mes, baja antigüedad y fibra óptica. Palanca de retención: migrar a contratos de plazo en los primeros meses.
AUC CV logística0.844 ± 0.010
AUC prueba (IC 95 %)0.845 [0.83–0.86]
Captura top 20 %51%
Utilidad anual óptima$124,962

Resultados

Utilidad neta de la campaña según % de cartera contactada

Banda: IC 90 % bootstrap · costo $15 · retención 30% · margen 40%

  • Modelo
  • Al azar
$0$10 k$20 k$30 k$40 k020406080100% de clientes contactados (ordenados por riesgo)
USD (set de prueba)

AUC por modelo en validación cruzada 5×2 (media)

Detalle ± desviación en la tabla de validación

Regresión logística 0.844
Logística balanceada 0.844
LightGBM 0.847

Curva ROC (set de prueba)

  • Logística
  • LightGBM
  • Logística balanceada
  • Referencia
0 %20 %40 %60 %80 %100 %020406080100% falsos positivos
% verdaderos positivos

Calibración: probabilidad predicha vs. observada

Sobre la diagonal = el modelo subestima; debajo = sobreestima

  • Logística
  • LightGBM
  • Logística balanceada
  • Referencia
0 %20 %40 %60 %80 %020406080% predicho (promedio por decil)
% observado

Curva de ganancia acumulada

  • Regresión logística
  • Al azar
0 %20 %40 %60 %80 %100 %020406080100% de clientes contactados
% de churners capturados

Explicabilidad global: |SHAP| promedio por variable (LightGBM)

Contract 0.913
tenure 0.36
OnlineSecurity 0.274
InternetService 0.188
MonthlyCharges 0.178
TechSupport 0.167
PaymentMethod 0.158
TotalCharges 0.142
cargo_promedio 0.138
PaperlessBilling 0.135
OnlineBackup 0.098
MultipleLines 0.094

Explicabilidad local: ¿por qué el cliente de mayor riesgo?

Contribución SHAP en log-odds · prob. de churn 92%

Contract = Month-to-month 0.961
tenure = 1 0.835
TotalCharges = 95.1 0.402
OnlineSecurity = No 0.3
cargo_promedio = 95.1 0.259
TechSupport = No 0.202
InternetService = Fiber optic 0.189
MultipleLines = Yes 0.177

Coeficientes de la logística (log-odds): positivo sube el riesgo, negativo lo baja

Razón de momios = e^coef · numéricas estandarizadas · one-hot con regularización L2

tenure -1.319
Contract_Two year -0.728
InternetService_DSL -0.55
MonthlyCharges -0.543
PaperlessBilling_No -0.35
Dependents_Yes -0.276
n_servicios 0.149
TechSupport_No 0.169
OnlineSecurity_No 0.197
InternetService_Fiber optic 0.506
Contract_Month-to-month 0.585
TotalCharges 0.637

Simulador de negocio

Mueve los supuestos y observa cómo cambia la decisión óptima. Corre en JavaScript sobre las predicciones reales del modelo.

Cargando datos…

Metodología y validación

Regresión logística sobre variables escaladas y one-hot, comparada con LightGBM y con una logística balanceada. Selección por validación cruzada; decisión por curva de utilidad.

✔ Cómo se validó

  • Split 70/30 estratificado; la CV 5×2 usa solo el 70 % de entrenamiento.
  • Comparación de modelos con t-test corregido de Nadeau-Bengio (folds no independientes).
  • IC bootstrap (500 réplicas) para AUC y (300) para la curva de utilidad.
  • Calibración: curva por deciles + pendiente/intercepto de calibración.

⚙ Supuestos

  • Costo por contacto $15, retención 30%, margen 40%, 12 meses de valor (editables en el simulador).
  • La campaña tiene el mismo efecto en todos los clientes en riesgo (ver uplift, proyecto #3).

⚠ Limitaciones

  • Datos de telecomunicaciones (proxy de un portafolio de crédito); corte transversal sin fechas, por lo que no hay validación fuera de tiempo.
  • SHAP explica el modelo, no causalidad: un impulsor no garantiza que moverlo reduzca el churn.

🧬 Enfoque alternativo · PSO binario + algoritmo genético

Selección de variables con enjambre de partículas (PSO)

¿Por qué otro enfoque? La logística y LightGBM empataron en el proyecto original. Con el mismo poder predictivo, gana el modelo más simple: el enjambre busca el subconjunto mínimo de variables que conserve el AUC.

  • El enjambre (16 partículas × 25 iteraciones) reduce el modelo de 22 a 5 variables con AUC de prueba 0.840 vs. 0.845 con todas: mismo poder con menos de la mitad de las variables.
  • LASSO, el método clásico, conserva 22 variables con AUC 0.845: el PSO llega a un modelo más compacto.
  • Estabilidad: 2 variables aparecen en las 3 corridas con semillas distintas (tenure, Contract); son las que un negocio debería vigilar.
  • PSO vs. algoritmo genético con el mismo presupuesto: fitness 0.8223 vs. 0.8226. Se evaluaron 1,546 subconjuntos distintos de 2^22 posibles.

Convergencia: mejor fitness por iteración

  • PSO (mejor)
  • Genético (mejor)
  • PSO (promedio del enjambre)
0.790.80.810.820510152025Iteración / generación
AUC CV − penalización

Frecuencia de selección en 3 corridas del enjambre

100 % = elegida con todas las semillas

tenure 100 %
Contract 100 %
MonthlyCharges 67 %
n_servicios 67 %
MultipleLines 33 %
StreamingMovies 33 %
TotalCharges 33 %
InternetService 33 %
OnlineSecurity 33 %
TechSupport 33 %
gender 0 %
Dependents 0 %
Partner 0 %
SeniorCitizen 0 %
PhoneService 0 %
StreamingTV 0 %
OnlineBackup 0 %
DeviceProtection 0 %
PaymentMethod 0 %
PaperlessBilling 0 %
cargo_promedio 0 %
autopago 0 %

AUC en prueba por enfoque

Logística con todas las variables 0.845
Logística + PSO binario 0.84
LASSO (L1, C por CV) 0.845

Comparación en el set de prueba

ModeloVariablesAUC pruebaIC 95 %AUC CV (entrenamiento)
Logística con todas las variables220.8450.828–0.8630.843
Logística + PSO binario50.840.825–0.8590.837
LASSO (L1, C por CV)220.8450.828–0.8630.843

Variables elegidas por el PSO

Variable
tenure
MultipleLines
Contract
MonthlyCharges
n_servicios

✔ Cómo se validó

  • Fitness calculado solo con CV 3-fold en entrenamiento; prueba final en datos nunca vistos.
  • 3 semillas para medir estabilidad; comparación contra LASSO y contra un algoritmo genético.

⚙ Supuestos

  • Cada variable extra debe aportar más de 0.003 de AUC para justificarse.

⚠ Limitaciones

  • Las metaheurísticas no garantizan el óptimo global; con pocas variables, LASSO es más barato.
alt01_churn_pso.py131 líneas
"""Alternativa #1 — Selección de variables con enjambre de partículas (PSO binario) y algoritmo genético.

Problema que resuelve: en el proyecto #1 la logística y LightGBM empataron. Si el poder predictivo es el
mismo, el siguiente criterio es la parsimonia: ¿cuántas variables necesitamos realmente? Un modelo con
menos variables es más barato de mantener, más fácil de explicar y menos frágil ante cambios en los datos.

Fitness = AUC en CV 3-fold (solo entrenamiento) − 0.003 × número de variables.
Comparación honesta contra LASSO (L1), la forma "clásica" de seleccionar variables.
"""
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression, LogisticRegressionCV
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

from bioinspirados import algoritmo_genetico, pso_binario
from common import SEED, bars, export_alternativa, line, table
from p01_churn import load
from validacion import bootstrap_ci, model_card

PENALIZACION = 0.003


def logistica(cols, num, cat, l1_C=None):
    n, c = [x for x in cols if x in num], [x for x in cols if x in cat]
    pre = ColumnTransformer([("num", StandardScaler(), n), ("cat", OneHotEncoder(handle_unknown="ignore"), c)])
    lr = (LogisticRegression(max_iter=3000) if l1_C is None else
          LogisticRegression(penalty="l1", solver="liblinear", C=l1_C, max_iter=3000))
    return make_pipeline(pre, lr)


def run():
    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]
    cols = np.array(df.columns)
    X_tr, X_te, y_tr, y_te = train_test_split(df, y, test_size=0.3, stratify=y, random_state=SEED)
    cv = StratifiedKFold(3, shuffle=True, random_state=SEED)
    cache: dict[bytes, float] = {}

    def auc_cv(mask) -> float:
        clave = np.asarray(mask, dtype=bool).tobytes()
        if clave not in cache:
            sel = list(cols[np.asarray(mask, dtype=bool)])
            cache[clave] = 0.5 if not sel else float(cross_val_score(
                logistica(sel, num, cat), X_tr[sel], y_tr, cv=cv, scoring="roc_auc").mean())
        return cache[clave]

    def fitness(mask):
        return auc_cv(mask) - PENALIZACION * np.asarray(mask).sum()

    # 1) PSO binario con 3 semillas (para medir la estabilidad de la selección)
    corridas = [pso_binario(fitness, len(cols), particulas=16, iteraciones=25, seed=s) for s in (SEED, 7, 99)]
    best_mask, best_fit, hist_pso = max(corridas, key=lambda r: r[1])
    frecuencia = pd.Series(np.mean([r[0] for r in corridas], axis=0), index=cols).sort_values(ascending=False)
    # 2) Algoritmo genético binario con el mismo presupuesto de evaluaciones, para comparar metaheurísticas
    _, ga_fit, hist_ga = algoritmo_genetico(fitness, len(cols), binario=True, poblacion=16, generaciones=25,
                                            p_mut=0.08)
    sel_pso = list(cols[best_mask.astype(bool)])

    # 3) Evaluación final en prueba (nunca vista): todas las variables vs. PSO vs. LASSO
    def evaluar(nombre, modelo, columnas):
        p = modelo.fit(X_tr[columnas], y_tr).predict_proba(X_te[columnas])[:, 1]
        auc, lo, hi = bootstrap_ci(y_te, p, n=300)
        return {"Modelo": nombre, "Variables": len(columnas), "AUC prueba": round(auc, 4),
                "IC 95 %": f"{lo:.3f}–{hi:.3f}", "AUC CV (entrenamiento)": round(auc_cv(np.isin(cols, columnas)), 4)}
    lasso_cv = LogisticRegressionCV(Cs=10, penalty="l1", solver="liblinear", cv=cv, scoring="roc_auc",
                                    max_iter=3000).fit(
        ColumnTransformer([("num", StandardScaler(), num), ("cat", OneHotEncoder(handle_unknown="ignore"), cat)])
        .fit_transform(X_tr), y_tr)
    # LASSO selecciona columnas one-hot; una variable original se conserva si alguna de sus columnas sobrevive
    pre = ColumnTransformer([("num", StandardScaler(), num), ("cat", OneHotEncoder(handle_unknown="ignore"), cat)],
                            verbose_feature_names_out=False).fit(X_tr)
    vivas = pre.get_feature_names_out()[np.abs(lasso_cv.coef_[0]) > 1e-6]
    sel_lasso = [c for c in cols if any(v == c or v.startswith(c + "_") for v in vivas)]
    comp = pd.DataFrame([
        evaluar("Logística con todas las variables", logistica(list(cols), num, cat), list(cols)),
        evaluar("Logística + PSO binario", logistica(sel_pso, num, cat), sel_pso),
        evaluar("LASSO (L1, C por CV)", logistica(sel_lasso, num, cat, l1_C=float(lasso_cv.C_[0])), sel_lasso),
    ])
    todas, pso = comp.iloc[0], comp.iloc[1]
    estables = frecuencia[frecuencia == 1].index.tolist()

    result = {
        "titulo": "Selección de variables con enjambre de partículas (PSO)",
        "algoritmo": "PSO binario + algoritmo genético",
        "veredicto": f"{todas['Variables']} → {pso['Variables']} variables con AUC {pso['AUC prueba']:.3f} vs. "
                     f"{todas['AUC prueba']:.3f}: mismo poder, modelo mucho más simple.",
        "por_que": "La logística y LightGBM empataron en el proyecto original. Con el mismo poder predictivo, gana "
                   "el modelo más simple: el enjambre busca el subconjunto mínimo de variables que conserve el AUC.",
        "summary": [
            f"El enjambre (16 partículas × 25 iteraciones) reduce el modelo de {todas['Variables']} a {pso['Variables']} "
            f"variables con AUC de prueba {pso['AUC prueba']:.3f} vs. {todas['AUC prueba']:.3f} con todas: "
            + ("mismo poder con menos de la mitad de las variables." if pso["AUC prueba"] >= todas["AUC prueba"] - 0.005
               else "pierde algo de poder; el ahorro de variables tiene un costo."),
            f"LASSO, el método clásico, conserva {comp.iloc[2]['Variables']} variables con AUC {comp.iloc[2]['AUC prueba']:.3f}: "
            "el PSO llega a un modelo " + ("más compacto." if pso["Variables"] < comp.iloc[2]["Variables"] else "comparable."),
            f"Estabilidad: {len(estables)} variables aparecen en las 3 corridas con semillas distintas "
            f"({', '.join(estables[:6])}); son las que un negocio debería vigilar.",
            f"PSO vs. algoritmo genético con el mismo presupuesto: fitness {best_fit:.4f} vs. {ga_fit:.4f}. "
            f"Se evaluaron {len(cache):,} subconjuntos distintos de 2^{len(cols)} posibles.",
        ],
        "charts": [
            line("conv", "Convergencia: mejor fitness por iteración", {
                "PSO (mejor)": [[a, round(b, 4)] for a, b, _ in hist_pso],
                "Genético (mejor)": [[a, round(b, 4)] for a, b, _ in hist_ga],
                "PSO (promedio del enjambre)": [[a, round(c, 4)] for a, _, c in hist_pso]},
                x_label="Iteración / generación", y_label="AUC CV − penalización", fmt="dec"),
            bars("freq", "Frecuencia de selección en 3 corridas del enjambre", frecuencia.index,
                 (frecuencia.values * 100).round(0), fmt="pct", subtitle="100 % = elegida con todas las semillas"),
            bars("auc", "AUC en prueba por enfoque", comp.Modelo, comp["AUC prueba"].values, fmt="dec"),
        ],
        "tables": [table("Comparación en el set de prueba", comp),
                   table("Variables elegidas por el PSO", pd.DataFrame({"Variable": sel_pso}))],
        "card": model_card(
            "PSO binario (sigmoide sobre la velocidad) que enciende/apaga variables de una regresión logística.",
            ["Fitness calculado solo con CV 3-fold en entrenamiento; prueba final en datos nunca vistos.",
             "3 semillas para medir estabilidad; comparación contra LASSO y contra un algoritmo genético."],
            [f"Cada variable extra debe aportar más de {PENALIZACION} de AUC para justificarse."],
            ["Las metaheurísticas no garantizan el óptimo global; con pocas variables, LASSO es más barato."]),
    }
    export_alternativa("p01-churn", result)
    return result


if __name__ == "__main__":
    run()

🔬 Búsqueda del mejor algoritmo · 7 iteraciones

Búsqueda del mejor algoritmo de churn

Objetivo: Superar el empate logística ≈ LightGBM sin perder explicabilidad.

Línea base0.8443Logística
Campeón0.8503Ensamble EBM + splines + CatBoost
Mejora en AUC (validación)+0.0060más es mejor
  • 7 candidatos con los mismos 10 folds. Campeón: Ensamble EBM + splines + CatBoost con AUC 0.8503 vs. 0.8443 de la línea base.
  • En la prueba final (una sola vez): diferencia de AUC +0.0025 (IC 95 % -0.0017 a +0.0072). El IC incluye el cero: la mejora es marginal y no concluyente.
  • El EBM es la mejor opción de producción si gana o empata: tiene la precisión del boosting y cada variable es una curva que se puede mostrar a negocio y a auditoría (ver forma de la antigüedad).
  • Lección: en datos tabulares pequeños (7 mil clientes) el techo lo pone la información disponible, no el algoritmo; las ganancias entre algoritmos son de milésimas.

💡 Con poca información, el algoritmo importa poco; el EBM gana por explicabilidad, no por AUC.

Progreso de la búsqueda (AUC, validación)

  • Cada experimento
  • Mejor hasta el momento
0.8440.8460.8480.850123456Iteración
AUC

Ranking de candidatos (AUC en validación)

Ensamble EBM + splines + CatBoost 0.85
EBM (sin interacciones) 0.85
EBM + 10 interacciones 0.85
CatBoost 0.849
Logística + splines 0.848
LightGBM 0.847
Logística 0.844

EBM: importancia de cada término (incluye interacciones)

Contract 0.545
tenure 0.365
TotalCharges 0.285
InternetService 0.243
OnlineSecurity 0.214
PaperlessBilling 0.197
MonthlyCharges 0.19
TechSupport 0.181
autopago 0.166
cargo_promedio 0.141
MultipleLines 0.134
OnlineBackup 0.127

EBM: efecto aprendido de la antigüedad (log-odds)

-1-0.500.510204060Meses de antigüedad
Contribución al log-odds de churn

Prueba final: campeón vs. línea base (una sola evaluación)

ModeloAUC pruebaBrier
Línea base: Logística0.8450.136
Campeón: Ensamble EBM + splines + CatBoost0.8470.135

Bitácora de experimentos

IteraciónHipótesisCandidatoAUCDesv.DecisiónNotaΔ vs. campeónp
0Línea base: el campeón explicable del proyecto original.Logística0.8440.01Línea base——
1Antigüedad y cargos tienen efectos no lineales que una recta no captura.Logística + splines0.8480.009Nuevo campeón (mejora no significativa)0.0030.194
2El boosting con interacciones automáticas debería ganar (empató en el original).LightGBM0.8470.01Se descarta-0.0010.825
3Codificar categorías por target ordenado mejora a one-hot con 15 variables categóricas.CatBoost0.8490.009Nuevo campeón (mejora no significativa)0.0010.525
4Un modelo aditivo con curvas aprendidas por boosting: GAM moderno, 100 % explicable.EBM (sin interacciones)0.850.009Nuevo campeón (mejora no significativa)0.0010.487
5Añadir las 10 interacciones pareadas más útiles (ej. contrato × antigüedad).EBM + 10 interacciones0.850.009Se descarta-00.896
6Modelos con errores distintos se compensan al promediar.Ensamble EBM + splines + CatBoost0.850.009Nuevo campeón (mejora no significativa)00.649
exp01_churn.py126 líneas
"""Búsqueda del mejor algoritmo — #1 Churn.

Punto de partida: logística y LightGBM empatados (AUC ≈ 0.845). Hipótesis a probar, en orden:
no linealidad suave (splines), boosting con categorías nativas (CatBoost), boosting explicable (EBM) y
ensambles. Todos con los mismos 10 folds (CV 5×2) sobre el 70 % de entrenamiento.
"""
import numpy as np
import pandas as pd
from catboost import CatBoostClassifier
from interpret.glassbox import ExplainableBoostingClassifier
from lightgbm import LGBMClassifier
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import brier_score_loss, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, SplineTransformer, StandardScaler

from common import SEED, bars, export_experimento, line
from experimentos import Bitacora, cv_auc, dif_pareada_bootstrap, folds_estratificados
from p01_churn import load


class Promedio:
    """Ensamble por promedio de probabilidades."""

    def __init__(self, *fabricas):
        self.fabricas = fabricas

    def fit(self, X, y):
        self.ms = [f().fit(X, y) for f in self.fabricas]
        return self

    def predict_proba(self, X):
        p = np.mean([m.predict_proba(X)[:, 1] for m in self.ms], axis=0)
        return np.column_stack([1 - p, p])


def run():
    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_te, y_tr, y_te = train_test_split(df, y, test_size=0.3, stratify=y, random_state=SEED)
    part = folds_estratificados(y_tr)
    oh = lambda: OneHotEncoder(handle_unknown="ignore")

    fab = {
        "Logística": lambda: make_pipeline(ColumnTransformer([("n", StandardScaler(), num), ("c", oh(), cat)]),
                                           LogisticRegression(max_iter=3000)),
        "Logística + splines": lambda: make_pipeline(ColumnTransformer([
            ("n", make_pipeline(StandardScaler(), SplineTransformer(n_knots=5, degree=3)), num), ("c", oh(), cat)]),
            LogisticRegression(max_iter=3000, C=0.5)),
        "LightGBM": lambda: make_pipeline(ColumnTransformer([("n", "passthrough", num), ("c", oh(), cat)]),
                                          LGBMClassifier(n_estimators=300, learning_rate=0.03, num_leaves=8,
                                                         min_child_samples=40, subsample=0.8, subsample_freq=1,
                                                         random_state=SEED, verbose=-1)),
        "CatBoost": lambda: CatBoostClassifier(iterations=600, depth=4, learning_rate=0.03, verbose=0,
                                               random_seed=SEED, cat_features=cat, allow_writing_files=False),
        "EBM (sin interacciones)": lambda: ExplainableBoostingClassifier(interactions=0, random_state=SEED),
        "EBM + 10 interacciones": lambda: ExplainableBoostingClassifier(interactions=10, random_state=SEED),
    }
    fab["Ensamble EBM + splines + CatBoost"] = lambda: Promedio(fab["EBM + 10 interacciones"],
                                                                 fab["Logística + splines"], fab["CatBoost"])
    hipotesis = {
        "Logística": "Línea base: el campeón explicable del proyecto original.",
        "Logística + splines": "Antigüedad y cargos tienen efectos no lineales que una recta no captura.",
        "LightGBM": "El boosting con interacciones automáticas debería ganar (empató en el original).",
        "CatBoost": "Codificar categorías por target ordenado mejora a one-hot con 15 variables categóricas.",
        "EBM (sin interacciones)": "Un modelo aditivo con curvas aprendidas por boosting: GAM moderno, 100 % explicable.",
        "EBM + 10 interacciones": "Añadir las 10 interacciones pareadas más útiles (ej. contrato × antigüedad).",
        "Ensamble EBM + splines + CatBoost": "Modelos con errores distintos se compensan al promediar.",
    }
    bit = Bitacora("AUC", n_train=int(len(X_tr) * 0.8), n_val=int(len(X_tr) * 0.2))
    for nombre, f in fab.items():
        bit.probar(hipotesis[nombre], nombre, cv_auc(f, X_tr, y_tr, part))

    # Prueba final única: campeón vs. línea base
    campeon = bit.campeon["Candidato"]
    m_base, m_camp = fab["Logística"]().fit(X_tr, y_tr), fab[campeon]().fit(X_tr, y_tr)
    p_base, p_camp = m_base.predict_proba(X_te)[:, 1], m_camp.predict_proba(X_te)[:, 1]
    d, lo, hi = dif_pareada_bootstrap(y_te, p_camp, p_base)
    prueba = pd.DataFrame([
        {"Modelo": "Línea base: Logística", "AUC prueba": round(roc_auc_score(y_te, p_base), 4),
         "Brier": round(brier_score_loss(y_te, p_base), 4)},
        {"Modelo": f"Campeón: {campeon}", "AUC prueba": round(roc_auc_score(y_te, p_camp), 4),
         "Brier": round(brier_score_loss(y_te, p_camp), 4)},
    ])

    # Explicabilidad del EBM (si es el campeón o el mejor explicable)
    ebm = fab["EBM + 10 interacciones"]().fit(X_tr, y_tr)
    imp = pd.Series(ebm.term_importances(), index=ebm.term_names_).sort_values(ascending=False)[:12]
    i_ten = ebm.term_names_.index("tenure")
    bordes = ebm.bins_[ebm.term_features_[i_ten][0]][0]
    forma = ebm.term_scores_[i_ten][1:-1]
    curva = [[round(float(b), 1), round(float(v), 3)] for b, v in zip(np.r_[0, bordes], forma)]

    top = bit.tabla().sort_values("AUC", ascending=False).iloc[0]
    result = bit.resultado(
        "Búsqueda del mejor algoritmo de churn",
        "Superar el empate logística ≈ LightGBM sin perder explicabilidad.",
        [
            f"{len(bit.filas)} candidatos con los mismos 10 folds. Campeón: {campeon} con AUC {top['AUC']:.4f} vs. "
            f"{bit.filas[0]['AUC']:.4f} de la línea base.",
            f"En la prueba final (una sola vez): diferencia de AUC {d:+.4f} (IC 95 % {lo:+.4f} a {hi:+.4f}). "
            + ("La mejora es real." if lo > 0 else "El IC incluye el cero: la mejora es marginal y no concluyente."),
            "El EBM es la mejor opción de producción si gana o empata: tiene la precisión del boosting y cada variable "
            "es una curva que se puede mostrar a negocio y a auditoría (ver forma de la antigüedad).",
            "Lección: en datos tabulares pequeños (7 mil clientes) el techo lo pone la información disponible, "
            "no el algoritmo; las ganancias entre algoritmos son de milésimas.",
        ],
        prueba,
        graficas_extra=[
            bars("ebm_imp", "EBM: importancia de cada término (incluye interacciones)", imp.index, imp.values.round(4), fmt="dec"),
            line("ebm_tenure", "EBM: efecto aprendido de la antigüedad (log-odds)", {"Antigüedad": curva},
                 x_label="Meses de antigüedad", y_label="Contribución al log-odds de churn", fmt="dec"),
        ],
        leccion="Con poca información, el algoritmo importa poco; el EBM gana por explicabilidad, no por AUC.",
    )
    export_experimento("p01-churn", result)
    return result


if __name__ == "__main__":
    run()

Validación cruzada 5×2 (media ± desviación)

ModeloAUCPR-AUCKSBrier
Regresión logística0.844 ± 0.0100.659 ± 0.0230.543 ± 0.0280.136 ± 0.004
Logística balanceada0.844 ± 0.0110.658 ± 0.0230.544 ± 0.0290.166 ± 0.007
LightGBM0.847 ± 0.0100.670 ± 0.0230.543 ± 0.0310.134 ± 0.004

Tabla de ganancia por decil

DecilClientesChurnersScore mínimoTasa churn %Captura acum. %Lift
12121590.6487528.32.82
22111270.52760.2512.27
3211870.41941.266.51.55
4212680.28132.178.61.21
5211470.1922.3870.84
6211350.11416.693.20.62
7212210.0639.9970.37
8211100.034.798.80.18
921150.0112.499.60.09
1021120.0010.91000.04

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

p01_churn.py205 líneas
"""#1 Modelo de churn con valor económico.

Pregunta de negocio: ¿a quién contactamos en la campaña de retención y cuánto dinero
genera hacerlo?  Datos: IBM Telco Customer Churn (7,043 clientes, público).
"""
import numpy as np
import pandas as pd
from lightgbm import LGBMClassifier
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, cumulative_gain_curve, download, export, gains_table, line,
                    table)
from validacion import (bootstrap_ci, calibracion_lineal, cv_clasificacion, explicacion_local,
                        graficas_validacion, model_card, resumen_cv, t_test_corregido)

URL = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"

# Supuestos de la campaña (editables en la página del portafolio)
COSTO_CONTACTO = 15.0      # USD por cliente contactado (llamada + incentivo)
TASA_RETENCION = 0.30      # % de los que se iban y se quedan gracias a la campaña
MESES_VALOR = 12           # meses de ingreso que se preservan al retener
MARGEN = 0.40              # margen sobre el ingreso mensual (no todo el ingreso es utilidad)


def load() -> pd.DataFrame:
    df = pd.read_csv(download(URL, "telco_churn.csv"))
    df["TotalCharges"] = pd.to_numeric(df["TotalCharges"], errors="coerce").fillna(0)
    df["Churn"] = (df["Churn"] == "Yes").astype(int)
    # Feature engineering con sentido de negocio
    servicios = ["PhoneService", "OnlineSecurity", "OnlineBackup", "DeviceProtection",
                 "TechSupport", "StreamingTV", "StreamingMovies"]
    df["n_servicios"] = (df[servicios] == "Yes").sum(axis=1)
    df["cargo_promedio"] = np.where(df["tenure"] > 0, df["TotalCharges"] / df["tenure"],
                                    df["MonthlyCharges"])
    df["autopago"] = df["PaymentMethod"].str.contains("automatic").astype(int)
    return df.drop(columns=["customerID"])


def profit_curve(y, score, monthly, steps=40):
    """Utilidad neta de contactar al top-k % según el score."""
    order = np.argsort(-score)
    y, monthly = y[order], monthly[order]
    valor = y * monthly * MESES_VALOR * MARGEN * TASA_RETENCION - COSTO_CONTACTO
    acum = np.cumsum(valor)
    pts = [[0.0, 0.0]]
    for k in np.linspace(len(y) / steps, len(y), steps).astype(int):
        pts.append([round(k / len(y) * 100, 1), round(float(acum[k - 1]), 0)])
    best_k = int(np.argmax(acum)) + 1
    return pts, best_k / len(y), float(acum[best_k - 1])


def modelos(num, cat):
    """Mismo preprocesamiento para todos (pipeline: sin fuga de información entre folds)."""
    pre = ColumnTransformer([("num", StandardScaler(), num),
                             ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), cat)],
                            verbose_feature_names_out=False)
    gbm = LGBMClassifier(n_estimators=300, learning_rate=0.03, num_leaves=8, min_child_samples=40,
                         subsample=0.8, subsample_freq=1, colsample_bytree=0.8, reg_lambda=1.0,
                         random_state=SEED, verbose=-1)
    return {
        "Regresión logística": make_pipeline(pre, LogisticRegression(max_iter=2000)),
        "Logística balanceada": make_pipeline(pre, LogisticRegression(max_iter=2000, class_weight="balanced")),
        "LightGBM": make_pipeline(pre, gbm),
    }


def run():
    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_te, y_tr, y_te = train_test_split(df, y, test_size=0.3, stratify=y, random_state=SEED)
    mods = modelos(num, cat)

    # 1) Validación cruzada 5×2 en entrenamiento: misma partición para los tres modelos
    cv = cv_clasificacion(mods, X_tr, y_tr, folds=5, repeticiones=2)
    a, b = cv[cv.modelo == "Regresión logística"].AUC, cv[cv.modelo == "LightGBM"].AUC
    dif, p_val = t_test_corregido(a.values, b.values, n_train=int(len(X_tr) * 0.8), n_test=int(len(X_tr) * 0.2))

    # 2) Ajuste final y evaluación en el set de prueba (nunca visto durante la CV)
    fit = {k: m.fit(X_tr, y_tr) for k, m in mods.items()}
    p = {k: m.predict_proba(X_te)[:, 1] for k, m in fit.items()}
    auc_log, lo_log, hi_log = bootstrap_ci(y_te, p["Regresión logística"])
    auc_gbm, lo_gbm, hi_gbm = bootstrap_ci(y_te, p["LightGBM"])
    best_name = "Regresión logística"   # elegido por CV (empate estadístico → el más explicable)
    p_best = p[best_name]
    pend, interc = calibracion_lineal(y_te, p_best)
    pend_bal, _ = calibracion_lineal(y_te, p["Logística balanceada"])
    media_bal = p["Logística balanceada"].mean()

    # 3) Explicabilidad: SHAP de LightGBM (global agregado por variable original + local)
    pre, gbm = fit["LightGBM"][0], fit["LightGBM"][-1]
    Xte_t = pd.DataFrame(pre.transform(X_te), columns=pre.get_feature_names_out())
    contrib = gbm.predict(Xte_t, pred_contrib=True)[:, :-1]
    origen = [next(c for c in num + cat if f == c or f.startswith(c + "_")) for f in Xte_t.columns]
    shap_var = pd.DataFrame(np.abs(contrib), columns=origen).T.groupby(level=0).sum().T.mean()
    shap_var = shap_var.sort_values(ascending=False)
    contrib_var = pd.DataFrame(contrib, columns=origen).T.groupby(level=0).sum().T
    i_top = int(np.argmax(p["LightGBM"]))
    etiquetas, valores = explicacion_local(contrib_var.iloc[i_top].values, list(contrib_var.columns),
                                           X_te.iloc[i_top])
    # Coeficientes de la logística como razones de momios (variables numéricas estandarizadas)
    lr = fit["Regresión logística"][-1]
    coefs = pd.Series(lr.coef_[0], index=fit["Regresión logística"][0].get_feature_names_out())
    coefs = coefs.sort_values()
    coefs = pd.concat([coefs.head(6), coefs.tail(6)])

    # 4) Valor económico con incertidumbre (bootstrap de la curva de utilidad)
    monthly = X_te["MonthlyCharges"].values
    curve_model, best_frac, best_profit = profit_curve(y_te, p_best, monthly)
    rng = np.random.default_rng(SEED)
    curve_rand, _, _ = profit_curve(y_te, rng.random(len(y_te)), monthly)
    boot = []
    for _ in range(300):
        idx = rng.integers(0, len(y_te), len(y_te))
        boot.append([v for _, v in profit_curve(y_te[idx], p_best[idx], monthly[idx])[0]])
    lo_b, hi_b = np.percentile(boot, [5, 95], axis=0)
    banda = [[x, float(l), float(h)] for (x, _), l, h in zip(curve_model, lo_b, hi_b)]
    optimos = np.array([max(b) for b in boot])

    order = np.argsort(-p_best)
    captura_top20 = y_te[order[: int(len(y_te) * 0.2)]].sum() / y_te.sum()
    g = gains_table(y_te, p_best)
    g_tbl = g.assign(tasa=lambda d: (d.tasa * 100).round(1), captura_acum=lambda d: (d.captura_acum * 100).round(1),
                     lift=lambda d: d.lift.round(2), score_min=lambda d: d.score_min.round(3))
    g_tbl.columns = ["Decil", "Clientes", "Churners", "Score mínimo", "Tasa churn %", "Captura acum. %", "Lift"]
    cv_tbl = resumen_cv(cv)
    cv_auc = cv.groupby("modelo", sort=False).AUC

    escala = 1 / 0.3  # el set de prueba es 30 % de la cartera
    result = {
        "title": "Churn con valor económico",
        "summary": [
            f"Validación cruzada 5×2: logística AUC {a.mean():.3f} ± {a.std():.3f} vs. LightGBM {b.mean():.3f} ± "
            f"{b.std():.3f}; la diferencia ({dif:+.3f}) no es significativa (t corregido, p = {p_val:.2f}). "
            "Ante empate se elige el modelo explicable.",
            f"En prueba: AUC {auc_log:.3f} (IC 95 % {lo_log:.3f}–{hi_log:.3f}); contactar al 20 % con mayor riesgo "
            f"captura el {captura_top20:.0%} de las cancelaciones.",
            f"Óptimo económico: contactar al {best_frac:.0%} de la cartera, utilidad ≈ ${best_profit * escala:,.0f}/año "
            f"(IC 90 % ${np.percentile(optimos, 5) * escala:,.0f}–${np.percentile(optimos, 95) * escala:,.0f}).",
            f"Buena práctica: NO usar class_weight='balanced' cuando el score alimenta un cálculo de dinero; infla "
            f"la probabilidad media a {media_bal:.0%} vs. {y_te.mean():.0%} real. La logística sin ponderar está "
            f"calibrada (pendiente {pend:.2f}, intercepto {interc:+.2f}).",
            "Impulsores (SHAP): contrato mes a mes, baja antigüedad y fibra óptica. Palanca de retención: "
            "migrar a contratos de plazo en los primeros meses.",
        ],
        "metrics": [
            {"label": "AUC CV logística", "value": f"{a.mean():.3f} ± {a.std():.3f}"},
            {"label": "AUC prueba (IC 95 %)", "value": f"{auc_log:.3f} [{lo_log:.2f}–{hi_log:.2f}]"},
            {"label": "Captura top 20 %", "value": f"{captura_top20:.0%}"},
            {"label": "Utilidad anual óptima", "value": f"${best_profit * escala:,.0f}"},
        ],
        "charts": [
            line("profit", "Utilidad neta de la campaña según % de cartera contactada",
                 {"Modelo": curve_model, "Al azar": curve_rand},
                 x_label="% de clientes contactados (ordenados por riesgo)", y_label="USD (set de prueba)",
                 fmt="usd", bands={"Modelo": banda},
                 subtitle=f"Banda: IC 90 % bootstrap · costo ${COSTO_CONTACTO:.0f} · retención {TASA_RETENCION:.0%} · margen {MARGEN:.0%}"),
            bars("cv", "AUC por modelo en validación cruzada 5×2 (media)", cv_auc.mean().index,
                 cv_auc.mean().round(4).values, fmt="dec",
                 subtitle="Detalle ± desviación en la tabla de validación"),
            *graficas_validacion(y_te, {"Logística": p_best, "LightGBM": p["LightGBM"],
                                        "Logística balanceada": p["Logística balanceada"]}),
            line("gain", "Curva de ganancia acumulada",
                 {best_name: cumulative_gain_curve(y_te, p_best), "Al azar": [[0, 0], [100, 100]]},
                 x_label="% de clientes contactados", y_label="% de churners capturados", fmt="pct"),
            bars("shap", "Explicabilidad global: |SHAP| promedio por variable (LightGBM)", shap_var.index[:12],
                 shap_var.values[:12].round(3), fmt="dec"),
            bars("local", "Explicabilidad local: ¿por qué el cliente de mayor riesgo?", etiquetas, valores,
                 fmt="dec", subtitle=f"Contribución SHAP en log-odds · prob. de churn {p['LightGBM'][i_top]:.0%}"),
            bars("coef", "Coeficientes de la logística (log-odds): positivo sube el riesgo, negativo lo baja", coefs.index,
                 coefs.round(3).values, fmt="dec",
                 subtitle="Razón de momios = e^coef · numéricas estandarizadas · one-hot con regularización L2"),
        ],
        "tables": [table("Validación cruzada 5×2 (media ± desviación)", cv_tbl),
                   table("Tabla de ganancia por decil", g_tbl)],
        "card": model_card(
            "Regresión logística sobre variables escaladas y one-hot, comparada con LightGBM y con una logística "
            "balanceada. Selección por validación cruzada; decisión por curva de utilidad.",
            ["Split 70/30 estratificado; la CV 5×2 usa solo el 70 % de entrenamiento.",
             "Comparación de modelos con t-test corregido de Nadeau-Bengio (folds no independientes).",
             "IC bootstrap (500 réplicas) para AUC y (300) para la curva de utilidad.",
             "Calibración: curva por deciles + pendiente/intercepto de calibración."],
            [f"Costo por contacto ${COSTO_CONTACTO:.0f}, retención {TASA_RETENCION:.0%}, margen {MARGEN:.0%}, "
             f"{MESES_VALOR} meses de valor (editables en el simulador).",
             "La campaña tiene el mismo efecto en todos los clientes en riesgo (ver uplift, proyecto #3)."],
            ["Datos de telecomunicaciones (proxy de un portafolio de crédito); corte transversal sin fechas, "
             "por lo que no hay validación fuera de tiempo.",
             "SHAP explica el modelo, no causalidad: un impulsor no garantiza que moverlo reduzca el churn."]),
        "assumptions": {"costo_contacto": COSTO_CONTACTO, "tasa_retencion": TASA_RETENCION,
                        "meses_valor": MESES_VALOR, "margen": MARGEN},
    }
    sample = pd.DataFrame({"score": p_best.round(4), "churn": y_te, "monthly": monthly})
    export("p01-churn", result, sample)
    return result


if __name__ == "__main__":
    run()