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.
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.
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
AUC por modelo en validación cruzada 5×2 (media)
Detalle ± desviación en la tabla de validación
Curva ROC (set de prueba)
- Logística
- LightGBM
- Logística balanceada
- Referencia
Calibración: probabilidad predicha vs. observada
Sobre la diagonal = el modelo subestima; debajo = sobreestima
- Logística
- LightGBM
- Logística balanceada
- Referencia
Curva de ganancia acumulada
- Regresión logística
- Al azar
Explicabilidad global: |SHAP| promedio por variable (LightGBM)
Explicabilidad local: ¿por qué el cliente de mayor riesgo?
Contribución SHAP en log-odds · prob. de churn 92%
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
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)
Frecuencia de selección en 3 corridas del enjambre
100 % = elegida con todas las semillas
AUC en prueba por enfoque
Comparación en el set de prueba
| Modelo | Variables | AUC prueba | IC 95 % | AUC CV (entrenamiento) |
|---|---|---|---|---|
| Logística con todas las variables | 22 | 0.845 | 0.828–0.863 | 0.843 |
| Logística + PSO binario | 5 | 0.84 | 0.825–0.859 | 0.837 |
| LASSO (L1, C por CV) | 22 | 0.845 | 0.828–0.863 | 0.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.
"""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.
- 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
Ranking de candidatos (AUC en validación)
EBM: importancia de cada término (incluye interacciones)
EBM: efecto aprendido de la antigüedad (log-odds)
Prueba final: campeón vs. línea base (una sola evaluación)
| Modelo | AUC prueba | Brier |
|---|---|---|
| Línea base: Logística | 0.845 | 0.136 |
| Campeón: Ensamble EBM + splines + CatBoost | 0.847 | 0.135 |
Bitácora de experimentos
| Iteración | Hipótesis | Candidato | AUC | Desv. | Decisión | Nota | Δ vs. campeón | p |
|---|---|---|---|---|---|---|---|---|
| 0 | Línea base: el campeón explicable del proyecto original. | Logística | 0.844 | 0.01 | Línea base | — | — | |
| 1 | Antigüedad y cargos tienen efectos no lineales que una recta no captura. | Logística + splines | 0.848 | 0.009 | Nuevo campeón (mejora no significativa) | 0.003 | 0.194 | |
| 2 | El boosting con interacciones automáticas debería ganar (empató en el original). | LightGBM | 0.847 | 0.01 | Se descarta | -0.001 | 0.825 | |
| 3 | Codificar categorías por target ordenado mejora a one-hot con 15 variables categóricas. | CatBoost | 0.849 | 0.009 | Nuevo campeón (mejora no significativa) | 0.001 | 0.525 | |
| 4 | Un modelo aditivo con curvas aprendidas por boosting: GAM moderno, 100 % explicable. | EBM (sin interacciones) | 0.85 | 0.009 | Nuevo campeón (mejora no significativa) | 0.001 | 0.487 | |
| 5 | Añadir las 10 interacciones pareadas más útiles (ej. contrato × antigüedad). | EBM + 10 interacciones | 0.85 | 0.009 | Se descarta | -0 | 0.896 | |
| 6 | Modelos con errores distintos se compensan al promediar. | Ensamble EBM + splines + CatBoost | 0.85 | 0.009 | Nuevo campeón (mejora no significativa) | 0 | 0.649 |
"""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)
| Modelo | AUC | PR-AUC | KS | Brier |
|---|---|---|---|---|
| Regresión logística | 0.844 ± 0.010 | 0.659 ± 0.023 | 0.543 ± 0.028 | 0.136 ± 0.004 |
| Logística balanceada | 0.844 ± 0.011 | 0.658 ± 0.023 | 0.544 ± 0.029 | 0.166 ± 0.007 |
| LightGBM | 0.847 ± 0.010 | 0.670 ± 0.023 | 0.543 ± 0.031 | 0.134 ± 0.004 |
Tabla de ganancia por decil
| Decil | Clientes | Churners | Score mínimo | Tasa churn % | Captura acum. % | Lift |
|---|---|---|---|---|---|---|
| 1 | 212 | 159 | 0.648 | 75 | 28.3 | 2.82 |
| 2 | 211 | 127 | 0.527 | 60.2 | 51 | 2.27 |
| 3 | 211 | 87 | 0.419 | 41.2 | 66.5 | 1.55 |
| 4 | 212 | 68 | 0.281 | 32.1 | 78.6 | 1.21 |
| 5 | 211 | 47 | 0.19 | 22.3 | 87 | 0.84 |
| 6 | 211 | 35 | 0.114 | 16.6 | 93.2 | 0.62 |
| 7 | 212 | 21 | 0.063 | 9.9 | 97 | 0.37 |
| 8 | 211 | 10 | 0.03 | 4.7 | 98.8 | 0.18 |
| 9 | 211 | 5 | 0.011 | 2.4 | 99.6 | 0.09 |
| 10 | 211 | 2 | 0.001 | 0.9 | 100 | 0.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.
Código completo
El script exacto que generó los resultados de esta página (python run_all.py).
"""#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()