Proyecto #5 · Riesgo y cobranza
Cobranza: probabilidad de pago tras mora
Priorizar la gestión de cartera vencida según la probabilidad de regularización.
Resumen ejecutivo
- Universo: 6,818 cuentas con atraso; 50% regulariza al mes siguiente.
- CV 5×2 (AUC): Logística (baseline) 0.724 ± 0.021, LightGBM 0.733 ± 0.022, LightGBM monótono 0.733 ± 0.022. Las restricciones monótonas cambian el AUC en +0.000 (p = 0.91) y garantizan que más atraso nunca suba la probabilidad de pago: modelo auditable.
- En prueba: AUC 0.727 (IC 95 % 0.704–0.750), KS 0.341; calibración pendiente 0.82 (< 1: probabilidades algo extremas; se corrige con recalibración Platt si se usan como probabilidad), intercepto +0.00.
- Con capacidad para 30% de las cuentas, el modelo alcanza 41 % de los clientes que no regularizarían vs. 39 % de la regla de días de mora (ganancia IC 95 % +0.9 a +5.1 pp). El valor real está dentro de cada tramo, donde la regla no distingue (ver AUC por tramo).
Resultados
Cuentas que no regularizarían solas, alcanzadas según estrategia
- Modelo (prob. de impago)
- Más días de mora primero
- Al azar
AUC en CV 5×2 por modelo (media)
AUC dentro de cada tramo de mora: modelo
La regla de días de mora tiene AUC ≈ 0.5 dentro de un mismo tramo
Curva ROC (set de prueba)
- LightGBM monótono
- Logística
- Referencia
Calibración: probabilidad predicha vs. observada
Sobre la diagonal = el modelo subestima; debajo = sobreestima
- LightGBM monótono
- Logística
- Referencia
Efecto parcial de atraso_actual: ¿se respeta la lógica de negocio?
- LightGBM
- LightGBM monótono
Explicabilidad global: |SHAP| promedio
Explicabilidad local: cuenta con menor probabilidad de pago
Contribución SHAP en log-odds · prob. de pago 6%
Metodología y validación
LightGBM con restricciones monótonas sobre variables de comportamiento de pago, comparado con logística y LightGBM libre.
✔ Cómo se validó
- Split 70/30 estratificado; CV 5×2 en entrenamiento con t-test corregido.
- AUC con IC bootstrap, calibración y AUC estratificado por tramo de mora.
- IC bootstrap de la ganancia operativa frente a la regla tradicional.
- Efectos parciales para verificar que el modelo respeta la lógica de negocio.
⚙ Supuestos
- "Regularizar" = no caer en default el mes siguiente (definición del dataset).
- El call center gestiona 30% de las cuentas en mora al mes.
⚠ Limitaciones
- Tarjetas de crédito en Taiwán (2005); sin fechas de corte, por lo que no hay validación fuera de tiempo.
- El modelo predice pago, no el efecto de la llamada: para eso se necesita un experimento (ver #3).
🧬 Enfoque alternativo · Algoritmo genético con reparación + MILP (HiGHS)
Asignación de canal de cobranza: genético vs. óptimo exacto
¿Por qué otro enfoque? El modelo predictivo ganaba poco sobre la regla. El valor está en decidir: qué canal usar con cada cuenta bajo presupuesto y capacidad de visitas. Es un problema de optimización, no de predicción.
- Con un presupuesto de $3,600 y 60 visitas para 2,000 cuentas, la regla del área logra 83 % del valor óptimo; el greedy 99.9 % y el genético sembrado 100.0 %.
- Pasar de la regla a la asignación optimizada sube el valor esperado de $761,772 a $916,729 (+20%) con el mismo costo: aquí está el valor que el modelo predictivo solo no capturaba.
- Lección honesta: el genético sin semilla solo llega a 79 % — con 2,000 genes el espacio es enorme. Como el problema es lineal, el solver exacto es la herramienta correcta; el genético vale cuando hay reglas no lineales (fatiga de contacto, metas por gestor) que un MILP no expresa.
- Los efectos por canal son supuestos explícitos y editables; en producción se estiman con un experimento (#3).
% del valor óptimo por método
Convergencia del algoritmo genético (valor esperado $)
- Sembrado con greedy
- Sin semilla
- Óptimo exacto
Comparación de métodos de asignación
| Método | Valor esperado $ | % del óptimo | Costo $ | Visitas | # SMS | # Llamada | # Visita |
|---|---|---|---|---|---|---|---|
| Regla del área | 761,772 | 83.1 | 3,598 | 14 | 69 | 156 | 14 |
| Greedy valor/costo | 915,772 | 99.9 | 3,594 | 21 | 222 | 98 | 21 |
| Genético sin semilla | 723,673 | 78.94 | 3,599 | 9 | 817 | 83 | 9 |
| Genético sembrado con greedy | 916,410 | 99.97 | 3,598 | 21 | 224 | 98 | 21 |
| Óptimo exacto (MILP) | 916,729 | 100 | 3,600 | 21 | 225 | 98 | 21 |
Supuestos de efecto por canal y tramo (% de no-pagadores que convierte)
| Tramo (meses) | Nada | SMS | Llamada | Visita |
|---|---|---|---|---|
| 1 | 0 | 5 | 15 | 25 |
| 2 | 0 | 2 | 10 | 30 |
| 3 | 0 | 1 | 6 | 30 |
✔ Cómo se validó
- Mismo problema y mismos datos para los cinco métodos; el MILP da la cota óptima exacta.
- Las probabilidades vienen del modelo monótono del proyecto #5 (muestra de prueba).
⚙ Supuestos
- Costos por canal {'Nada': np.float64(0.0), 'SMS': np.float64(2.0), 'Llamada': np.float64(15.0), 'Visita': np.float64(80.0)}; recuperación 25% del saldo; presupuesto 12% del costo de llamar a todos.
⚠ Limitaciones
- Los efectos por canal son supuestos; sin un experimento el valor absoluto es indicativo.
"""Alternativa #5 — Asignación óptima de canal de cobranza con algoritmo genético vs. programación entera.
Problema que resuelve: en el proyecto #5 el modelo predictivo ganó poco sobre la regla de días de mora.
El cambio de enfoque es pasar de PREDECIR a DECIDIR: con un presupuesto y una capacidad de visitas fijos,
¿qué canal (nada, SMS, llamada o visita) conviene para cada cuenta?
Es un problema de mochila de opción múltiple con dos restricciones. Se comparan cuatro métodos:
regla del área, greedy por valor/costo, algoritmo genético (con reparación) y el óptimo exacto (MILP, HiGHS).
"""
import numpy as np
import pandas as pd
from scipy.optimize import Bounds, LinearConstraint, milp
from bioinspirados import algoritmo_genetico
from common import SEED, SITE_DATA, bars, export_alternativa, line, table
from validacion import model_card
CANALES = ["Nada", "SMS", "Llamada", "Visita"]
COSTO = np.array([0.0, 2.0, 15.0, 80.0])
# Supuesto (editable): % de los clientes que NO pagarían solos y que el canal logra convertir, por tramo de mora
EFECTO = {1: [0, 0.05, 0.15, 0.25], 2: [0, 0.02, 0.10, 0.30], 3: [0, 0.01, 0.06, 0.30]}
RECUPERACION = 0.25 # fracción del saldo que se recupera cuando el cliente regulariza
PRESUPUESTO_FRAC = 0.12 # presupuesto = 12 % de lo que costaría llamar a todas las cuentas
VISITAS_MAX = 60 # capacidad de gestores de campo
def valores(df: pd.DataFrame) -> np.ndarray:
"""Valor esperado incremental de cada canal para cada cuenta (n × 4)."""
tramo = df.atraso.clip(upper=3).astype(int).values
ef = np.array([EFECTO[t] for t in tramo])
return ef * (1 - df.p_pago.values)[:, None] * df.saldo.values[:, None] * RECUPERACION
def run():
df = pd.read_csv(SITE_DATA / "samples" / "p05-cobranza.csv").sample(2000, random_state=SEED).reset_index(drop=True)
V = valores(df)
n = len(df)
presupuesto = PRESUPUESTO_FRAC * COSTO[2] * n
def evaluar(asig):
a = np.asarray(asig, dtype=int)
return V[np.arange(n), a].sum(), COSTO[a].sum(), (a == 3).sum()
def reparar(a):
"""Si la asignación viola presupuesto o visitas, degrada un nivel las opciones con peor valor/costo
(en bloque, para que la reparación sea rápida dentro del genético)."""
a = a.copy()
exceso_v = (a == 3).sum() - VISITAS_MAX
if exceso_v > 0:
i = np.where(a == 3)[0]
a[i[np.argsort(V[i, 3])[:exceso_v]]] = 2
while (exceso := COSTO[a].sum() - presupuesto) > 0:
i = np.where(a > 0)[0]
ahorro = COSTO[a[i]] - COSTO[a[i] - 1]
ratio = (V[i, a[i]] - V[i, a[i] - 1]) / ahorro
orden = np.argsort(ratio)
k = int(np.searchsorted(np.cumsum(ahorro[orden]), exceso)) + 1
a[i[orden[:k]]] -= 1
return a
# 1) Regla del área: visita a 3+ meses, llamada a 2 meses, SMS a 1 mes; se atiende por saldo de mayor a menor
# mientras alcance el presupuesto (si el canal deseado ya no cabe, se usa el siguiente más barato)
regla = np.zeros(n, dtype=int)
gasto, visitas = 0.0, 0
for i in np.argsort(-df.saldo.values):
canal = {1: 1, 2: 2}.get(int(min(df.atraso[i], 3)), 3)
while canal > 0 and (gasto + COSTO[canal] > presupuesto or (canal == 3 and visitas >= VISITAS_MAX)):
canal -= 1
regla[i] = canal
gasto += COSTO[canal]
visitas += canal == 3
# 2) Greedy: va subiendo de canal a la cuenta con mejor valor incremental por peso gastado
greedy = np.zeros(n, dtype=int)
while True:
sig = np.minimum(greedy + 1, 3)
inc_v = V[np.arange(n), sig] - V[np.arange(n), greedy]
inc_c = COSTO[sig] - COSTO[greedy]
ratio = np.where((greedy < 3) & (inc_v > 0), inc_v / np.maximum(inc_c, 1e-9), -1)
if (sig == 3).any():
ratio[(sig == 3) & ((greedy == 3).sum() >= VISITAS_MAX)] = -1
j = int(np.argmax(ratio))
if ratio[j] <= 0 or COSTO[greedy].sum() + inc_c[j] > presupuesto:
break
greedy[j] = sig[j]
# 3) Algoritmo genético: un gen por cuenta (canal), reparación de factibilidad, sembrado con el greedy
decod = lambda g: reparar(np.minimum((g * 4).astype(int), 3))
fit = lambda g: evaluar(decod(g))[0]
g0 = (greedy + 0.5) / 4
mejor_g, _, hist = algoritmo_genetico(fit, n, poblacion=30, generaciones=80, p_mut=0.01, inicial=g0)
genetico = decod(mejor_g)
# Genético SIN semilla: ¿cuánto logra partiendo de cero?
g_libre, _, hist_libre = algoritmo_genetico(fit, n, poblacion=30, generaciones=80, p_mut=0.01, seed=7)
libre = decod(g_libre)
# 4) Óptimo exacto: programación lineal entera mixta (variables x[i, canal] binarias)
c = -V.ravel()
una = np.zeros((n, 4 * n))
for i in range(n):
una[i, 4 * i:4 * i + 4] = 1
restricciones = [LinearConstraint(una, 1, 1),
LinearConstraint(np.tile(COSTO, n)[None, :], 0, presupuesto),
LinearConstraint(np.tile([0, 0, 0, 1], n)[None, :], 0, VISITAS_MAX)]
sol = milp(c, constraints=restricciones, integrality=np.ones(4 * n), bounds=Bounds(0, 1),
options={"time_limit": 60})
optimo = sol.x.reshape(n, 4).argmax(1)
metodos = {"Regla del área": regla, "Greedy valor/costo": greedy, "Genético sin semilla": libre,
"Genético sembrado con greedy": genetico, "Óptimo exacto (MILP)": optimo}
filas = []
v_opt = evaluar(optimo)[0]
for nombre, a in metodos.items():
v, cst, vis = evaluar(a)
mix = pd.Series(a).map(dict(enumerate(CANALES))).value_counts()
filas.append({"Método": nombre, "Valor esperado $": round(v), "% del óptimo": round(v / v_opt * 100, 2),
"Costo $": round(cst), "Visitas": int(vis),
**{f"# {c}": int(mix.get(c, 0)) for c in CANALES[1:]}})
res = pd.DataFrame(filas)
r = res.set_index("Método")
result = {
"titulo": "Asignación de canal de cobranza: genético vs. óptimo exacto",
"algoritmo": "Algoritmo genético con reparación + MILP (HiGHS)",
"veredicto": f"{v_opt / r.loc['Regla del área', 'Valor esperado $'] - 1:+.0%} de valor esperado vs. la regla con el "
"mismo costo; el solver exacto es la herramienta correcta.",
"por_que": "El modelo predictivo ganaba poco sobre la regla. El valor está en decidir: qué canal usar con cada "
"cuenta bajo presupuesto y capacidad de visitas. Es un problema de optimización, no de predicción.",
"summary": [
f"Con un presupuesto de ${presupuesto:,.0f} y {VISITAS_MAX} visitas para {n:,} cuentas, la regla del área "
f"logra {r.loc['Regla del área', '% del óptimo']:.0f} % del valor óptimo; el greedy "
f"{r.loc['Greedy valor/costo', '% del óptimo']:.1f} % y el genético sembrado {r.loc['Genético sembrado con greedy', '% del óptimo']:.1f} %.",
f"Pasar de la regla a la asignación optimizada sube el valor esperado de ${r.loc['Regla del área', 'Valor esperado $']:,.0f} "
f"a ${v_opt:,.0f} ({v_opt / r.loc['Regla del área', 'Valor esperado $'] - 1:+.0%}) con el mismo costo: "
"aquí está el valor que el modelo predictivo solo no capturaba.",
f"Lección honesta: el genético sin semilla solo llega a {r.loc['Genético sin semilla', '% del óptimo']:.0f} % — con "
"2,000 genes el espacio es enorme. Como el problema es lineal, el solver exacto es la herramienta correcta; el "
"genético vale cuando hay reglas no lineales (fatiga de contacto, metas por gestor) que un MILP no expresa.",
"Los efectos por canal son supuestos explícitos y editables; en producción se estiman con un experimento (#3).",
],
"charts": [
bars("pct", "% del valor óptimo por método", res["Método"], res["% del óptimo"].values, fmt="pct"),
line("conv", "Convergencia del algoritmo genético (valor esperado $)", {
"Sembrado con greedy": [[a, round(b)] for a, b, _ in hist],
"Sin semilla": [[a, round(b)] for a, b, _ in hist_libre],
"Óptimo exacto": [[0, round(v_opt)], [len(hist) - 1, round(v_opt)]]},
x_label="Generación", y_label="Valor esperado $", fmt="usd"),
],
"tables": [table("Comparación de métodos de asignación", res),
table("Supuestos de efecto por canal y tramo (% de no-pagadores que convierte)",
pd.DataFrame(EFECTO, index=CANALES).T.mul(100).reset_index().rename(columns={"index": "Tramo (meses)"}))],
"card": model_card(
"Mochila de opción múltiple: valor = efecto del canal × prob. de no pagar × saldo × recuperación.",
["Mismo problema y mismos datos para los cinco métodos; el MILP da la cota óptima exacta.",
"Las probabilidades vienen del modelo monótono del proyecto #5 (muestra de prueba)."],
[f"Costos por canal {dict(zip(CANALES, COSTO))}; recuperación {RECUPERACION:.0%} del saldo; "
f"presupuesto {PRESUPUESTO_FRAC:.0%} del costo de llamar a todos."],
["Los efectos por canal son supuestos; sin un experimento el valor absoluto es indicativo."]),
}
export_alternativa("p05-cobranza", result)
return result
if __name__ == "__main__":
run()
🔬 Búsqueda del mejor algoritmo · 7 iteraciones
Búsqueda del mejor algoritmo de cobranza
Objetivo: Subir el AUC de 0.733 con más información, mejores algoritmos y ajuste fino.
- 7 iteraciones. Campeón: LightGBM afinado con Optuna con AUC CV 0.7454 vs. 0.7328 de la línea base.
- Prueba final única: diferencia +0.0063 (IC 95 % -0.0008 a +0.0135). El IC incluye el cero.
- Optuna exploró 40 configuraciones con búsqueda bayesiana; mejores: hojas 6, tasa 0.016, 269 árboles.
- Lección: agregar la historia cruda casi no ayuda (las variables de comportamiento ya la resumían); lo que mueve la aguja es cambiar de algoritmo y ensamblar modelos que fallan distinto.
💡 Las variables nuevas aportan poco; el ensamble de boostings da la mejora.
Progreso de la búsqueda (AUC, validación)
- Cada experimento
- Mejor hasta el momento
Ranking de candidatos (AUC en validación)
Optuna: mejores hiperparámetros encontrados
Prueba final: campeón vs. línea base (una sola evaluación)
| Modelo | AUC prueba |
|---|---|
| Línea base: LightGBM monótono | 0.727 |
| Campeón: LightGBM afinado con Optuna | 0.734 |
Bitácora de experimentos
| Iteración | Hipótesis | Candidato | AUC | Desv. | Decisión | Nota | Δ vs. campeón | p |
|---|---|---|---|---|---|---|---|---|
| 0 | Línea base: el modelo monótono del proyecto original. | LightGBM monótono (14 variables) | 0.733 | 0.021 | Línea base | — | — | |
| 1 | Más información: historia cruda de 6 meses + pago relativo, pendiente y volatilidad. | LightGBM monótono + 33 variables | 0.734 | 0.02 | Nuevo campeón (mejora no significativa) | 0.001 | 0.751 | |
| 2 | CatBoost (árboles simétricos, más regularizados) aprovecha mejor la señal débil. | CatBoost + variables extra | 0.739 | 0.019 | Nuevo campeón | 0.005 | 0.044 | |
| 3 | XGBoost como segundo boosting de referencia. | XGBoost + variables extra | 0.738 | 0.02 | Se descarta | -0 | 0.917 | |
| 4 | EBM: boosting aditivo explicable con las mismas variables. | EBM + variables extra | 0.74 | 0.02 | Nuevo campeón (mejora no significativa) | 0.002 | 0.716 | |
| 5 | Optuna (40 ensayos TPE) encuentra hiperparámetros mejores que los manuales. | LightGBM afinado con Optuna | 0.745 | 0.019 | Nuevo campeón | Ligero optimismo: Optuna usó todo el entrenamiento con otra partición. | 0.005 | 0.084 |
| 6 | Ensamble de los tres mejores boostings. | Ensamble CatBoost + XGBoost + LightGBM-Optuna | 0.744 | 0.02 | Se descarta | -0.001 | 0.566 |
"""Búsqueda del mejor algoritmo — #5 Cobranza.
Punto de partida: LightGBM monótono, AUC ≈ 0.733. Hipótesis: más información (historia cruda de 6 meses y
variables de comportamiento nuevas), otros boostings (CatBoost, XGBoost), ajuste de hiperparámetros con
Optuna (búsqueda bayesiana TPE) y ensambles. Mismos 10 folds para todos.
"""
import numpy as np
import optuna
import pandas as pd
from catboost import CatBoostClassifier
from interpret.glassbox import ExplainableBoostingClassifier
from lightgbm import LGBMClassifier
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import StratifiedKFold, train_test_split
from xgboost import XGBClassifier
from common import SEED, bars, export_experimento
from exp01_churn import Promedio
from experimentos import Bitacora, cv_auc, dif_pareada_bootstrap, folds_estratificados
from p05_cobranza import BILL, MONOTONAS, PAY, PAYAMT, features, load
optuna.logging.set_verbosity(optuna.logging.WARNING)
def variables_extra(df: pd.DataFrame, base: pd.DataFrame) -> pd.DataFrame:
"""Historia cruda + comportamiento: pago relativo por mes, pendiente del saldo, volatilidad de pagos."""
ex = pd.concat([base, df[PAY + BILL + PAYAMT + ["LIMIT_BAL", "AGE"]].set_axis(base.index)], axis=1)
for i in range(1, 6):
ex[f"pago_rel_{i}"] = df[f"PAY_AMT{i}"].values / np.maximum(df[f"BILL_AMT{i + 1}"].values, 1)
ex["pendiente_saldo"] = np.polyfit(np.arange(6), df[BILL].values.T, 1)[0]
ex["vol_pagos"] = df[PAYAMT].std(axis=1).values / np.maximum(df[PAYAMT].mean(axis=1).values, 1)
ex["pago_minimo_ok"] = (df["PAY_AMT1"].values >= 0.05 * df["BILL_AMT2"].values).astype(int)
return ex.replace([np.inf, -np.inf], 0)
def run():
raw = load()
F = features(raw)
mora = (F.atraso_actual >= 1).values
base = F[mora].reset_index(drop=True)
ex = variables_extra(raw[mora].reset_index(drop=True), base)
y = 1 - raw["default"].values[mora]
idx_tr, idx_te = train_test_split(np.arange(len(y)), test_size=0.3, stratify=y, random_state=SEED)
y_tr, y_te = y[idx_tr], y[idx_te]
B_tr, B_te, E_tr, E_te = base.iloc[idx_tr], base.iloc[idx_te], ex.iloc[idx_tr], ex.iloc[idx_te]
part = folds_estratificados(y_tr)
lgbm = lambda cols, **kw: (lambda: LGBMClassifier(
n_estimators=300, learning_rate=0.03, num_leaves=15, min_child_samples=50, subsample=0.8, subsample_freq=1,
random_state=SEED, verbose=-1, monotone_constraints=[MONOTONAS.get(c, 0) for c in cols], **kw))
cat = lambda: CatBoostClassifier(iterations=700, depth=5, learning_rate=0.03, verbose=0, random_seed=SEED,
allow_writing_files=False)
xgb = lambda: XGBClassifier(n_estimators=400, max_depth=4, learning_rate=0.03, subsample=0.8,
colsample_bytree=0.8, random_state=SEED)
bit = Bitacora("AUC", n_train=int(len(idx_tr) * 0.8), n_val=int(len(idx_tr) * 0.2))
bit.probar("Línea base: el modelo monótono del proyecto original.", "LightGBM monótono (14 variables)",
cv_auc(lgbm(list(base.columns)), B_tr, y_tr, part))
bit.probar("Más información: historia cruda de 6 meses + pago relativo, pendiente y volatilidad.",
"LightGBM monótono + 33 variables", cv_auc(lgbm(list(ex.columns)), E_tr, y_tr, part))
bit.probar("CatBoost (árboles simétricos, más regularizados) aprovecha mejor la señal débil.",
"CatBoost + variables extra", cv_auc(cat, E_tr, y_tr, part))
bit.probar("XGBoost como segundo boosting de referencia.", "XGBoost + variables extra", cv_auc(xgb, E_tr, y_tr, part))
bit.probar("EBM: boosting aditivo explicable con las mismas variables.", "EBM + variables extra",
cv_auc(lambda: ExplainableBoostingClassifier(interactions=5, random_state=SEED), E_tr, y_tr, part))
# Optuna: búsqueda bayesiana de hiperparámetros con CV interna de 3 folds (semilla distinta a la externa)
interno = StratifiedKFold(3, shuffle=True, random_state=SEED + 1)
def objetivo(trial):
params = dict(n_estimators=trial.suggest_int("n_estimators", 150, 900),
learning_rate=trial.suggest_float("learning_rate", 0.01, 0.1, log=True),
num_leaves=trial.suggest_int("num_leaves", 4, 48),
min_child_samples=trial.suggest_int("min_child_samples", 20, 200),
colsample_bytree=trial.suggest_float("colsample_bytree", 0.4, 1.0),
reg_lambda=trial.suggest_float("reg_lambda", 1e-3, 10, log=True))
s = []
for a, b in interno.split(E_tr, y_tr):
m = LGBMClassifier(**params, subsample=0.8, subsample_freq=1, random_state=SEED, verbose=-1)
m.fit(E_tr.iloc[a], y_tr[a])
s.append(roc_auc_score(y_tr[b], m.predict_proba(E_tr.iloc[b])[:, 1]))
return np.mean(s)
estudio = optuna.create_study(direction="maximize", sampler=optuna.samplers.TPESampler(seed=SEED))
estudio.optimize(objetivo, n_trials=40)
mejores = estudio.best_params
lgbm_opt = lambda: LGBMClassifier(**mejores, subsample=0.8, subsample_freq=1, random_state=SEED, verbose=-1)
bit.probar("Optuna (40 ensayos TPE) encuentra hiperparámetros mejores que los manuales.",
"LightGBM afinado con Optuna", cv_auc(lgbm_opt, E_tr, y_tr, part),
nota="Ligero optimismo: Optuna usó todo el entrenamiento con otra partición.")
bit.probar("Ensamble de los tres mejores boostings.", "Ensamble CatBoost + XGBoost + LightGBM-Optuna",
cv_auc(lambda: Promedio(cat, xgb, lgbm_opt), E_tr, y_tr, part))
fabricas = {"LightGBM monótono (14 variables)": (lgbm(list(base.columns)), False),
"LightGBM monótono + 33 variables": (lgbm(list(ex.columns)), True),
"CatBoost + variables extra": (cat, True), "XGBoost + variables extra": (xgb, True),
"EBM + variables extra": (lambda: ExplainableBoostingClassifier(interactions=5, random_state=SEED), True),
"LightGBM afinado con Optuna": (lgbm_opt, True),
"Ensamble CatBoost + XGBoost + LightGBM-Optuna": (lambda: Promedio(cat, xgb, lgbm_opt), True)}
campeon = bit.campeon["Candidato"]
f_c, usa_ex = fabricas[campeon]
p_base = lgbm(list(base.columns))().fit(B_tr, y_tr).predict_proba(B_te)[:, 1]
p_camp = f_c().fit(E_tr if usa_ex else B_tr, y_tr).predict_proba(E_te if usa_ex else B_te)[:, 1]
d, lo, hi = dif_pareada_bootstrap(y_te, p_camp, p_base)
prueba = pd.DataFrame([{"Modelo": "Línea base: LightGBM monótono", "AUC prueba": round(roc_auc_score(y_te, p_base), 4)},
{"Modelo": f"Campeón: {campeon}", "AUC prueba": round(roc_auc_score(y_te, p_camp), 4)}])
hp = pd.Series(mejores).round(4)
result = bit.resultado(
"Búsqueda del mejor algoritmo de cobranza",
"Subir el AUC de 0.733 con más información, mejores algoritmos y ajuste fino.",
[f"{len(bit.filas)} iteraciones. Campeón: {campeon} con AUC CV {bit.campeon['AUC']:.4f} vs. "
f"{bit.filas[0]['AUC']:.4f} de la línea base.",
f"Prueba final única: diferencia {d:+.4f} (IC 95 % {lo:+.4f} a {hi:+.4f}). "
+ ("Mejora real y estadísticamente distinguible." if lo > 0 else "El IC incluye el cero."),
f"Optuna exploró 40 configuraciones con búsqueda bayesiana; mejores: hojas {mejores['num_leaves']}, "
f"tasa {mejores['learning_rate']:.3f}, {mejores['n_estimators']} árboles.",
"Lección: la historia cruda casi no ayuda (las variables de comportamiento ya la resumían). Lo que más "
f"mueve la aguja es la regularización: Optuna prefiere árboles muy simples ({mejores['num_leaves']} hojas). "
f"La ganancia en CV ({bit.campeon['AUC'] - bit.filas[0]['AUC']:+.4f}) se reduce en prueba ({d:+.4f}): "
"parte de la mejora de CV era optimismo por elegir entre muchos candidatos."],
prueba,
graficas_extra=[bars("optuna", "Optuna: mejores hiperparámetros encontrados", hp.index, hp.values, fmt="dec")],
leccion="Regularizar (árboles simples) aporta más que agregar variables; elegir entre muchos candidatos infla la CV.",
)
export_experimento("p05-cobranza", result)
return result
if __name__ == "__main__":
run()
Validación cruzada 5×2
| Modelo | AUC | PR-AUC | KS | Brier |
|---|---|---|---|---|
| Logística (baseline) | 0.724 ± 0.021 | 0.709 ± 0.022 | 0.377 ± 0.038 | 0.215 ± 0.006 |
| LightGBM | 0.733 ± 0.022 | 0.720 ± 0.018 | 0.380 ± 0.040 | 0.210 ± 0.009 |
| LightGBM monótono | 0.733 ± 0.022 | 0.723 ± 0.020 | 0.384 ± 0.034 | 0.210 ± 0.009 |
AUC dentro de cada tramo de mora
| Tramo | Cuentas | AUC modelo | AUC regla (días de mora) |
|---|---|---|---|
| 1 mes | 1,102 | 0.671 | 0.5 |
| 2 meses | 814 | 0.609 | 0.5 |
| 3+ meses | 130 | 0.644 | 0.462 |
Regularización por tramo
| Tramo de mora | Clientes | Regularizan % |
|---|---|---|
| 1 mes | 3,688 | 66.1 |
| 2 meses | 2,667 | 30.9 |
| 3 meses | 322 | 24.2 |
| 4+ meses | 141 | 36.9 |
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).
"""#5 Modelo de cobranza: probabilidad de pago tras mora.
Datos: Default of Credit Card Clients (UCI, Taiwán, 30,000 tarjetahabientes, 6 meses de historial).
Objetivo: dentro de la cartera vencida, priorizar la gestión según la probabilidad de que el cliente
regularice el próximo mes y el saldo en juego.
"""
import io
import numpy as np
import pandas as pd
from lightgbm import LGBMClassifier
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 StandardScaler
from common import SEED, bars, download, export, ks_stat, line, read_from_zip, table
from validacion import (bootstrap_ci, calibracion_lineal, cv_clasificacion, explicacion_local,
graficas_validacion, model_card, resumen_cv, t_test_corregido)
URL = "https://archive.ics.uci.edu/static/public/350/default+of+credit+card+clients.zip"
PAY = ["PAY_0", "PAY_2", "PAY_3", "PAY_4", "PAY_5", "PAY_6"] # estatus de pago, mes t … t-5
BILL = [f"BILL_AMT{i}" for i in range(1, 7)]
PAYAMT = [f"PAY_AMT{i}" for i in range(1, 7)]
CAPACIDAD = 0.30 # el call center alcanza a gestionar 30 % de la cartera vencida al mes
def load() -> pd.DataFrame:
raw = read_from_zip(download(URL, "default_credit_card.zip"), ".xls")
df = pd.read_excel(io.BytesIO(raw), header=1)
return df.rename(columns={"default payment next month": "default"})
def features(df: pd.DataFrame) -> pd.DataFrame:
f = pd.DataFrame(index=df.index)
pay = df[PAY].clip(lower=0).values # meses de atraso por periodo
f["atraso_actual"] = pay[:, 0]
f["atraso_max_6m"] = pay.max(1)
f["meses_en_mora_6m"] = (pay > 0).sum(1)
# Racha: meses consecutivos en mora hasta hoy
f["racha_mora"] = np.argmin(np.c_[pay > 0, np.zeros(len(df), bool)], axis=1)
f["tendencia_atraso"] = pay[:, 0] - pay[:, 2] # ¿empeora o mejora en 3 meses?
bill, pagos = df[BILL].values, df[PAYAMT].values
f["saldo"] = bill[:, 0]
f["utilizacion"] = bill[:, 0] / df["LIMIT_BAL"].values
f["ratio_pago_saldo_1m"] = pagos[:, 0] / np.maximum(bill[:, 1], 1)
f["ratio_pago_saldo_3m"] = pagos[:, :3].sum(1) / np.maximum(bill[:, 1:4].sum(1), 1)
f["meses_sin_pago"] = (pagos == 0).sum(1)
f["crecimiento_saldo"] = (bill[:, 0] - bill[:, 3]) / np.maximum(np.abs(bill[:, 3]), 1)
f["limite"] = df["LIMIT_BAL"]
f["edad"] = df["AGE"]
f["escolaridad"] = df["EDUCATION"]
return f.replace([np.inf, -np.inf], 0)
# Variables cuya relación con la probabilidad de pago tiene sentido de negocio conocido:
# más atraso / más meses sin pagar → menos probable que regularice (restricción monótona −1).
MONOTONAS = {"atraso_actual": -1, "atraso_max_6m": -1, "meses_en_mora_6m": -1, "racha_mora": -1,
"meses_sin_pago": -1, "ratio_pago_saldo_1m": 1, "ratio_pago_saldo_3m": 1}
def modelos(columnas):
base = dict(n_estimators=300, learning_rate=0.03, num_leaves=15, min_child_samples=50,
subsample=0.8, subsample_freq=1, random_state=SEED, verbose=-1)
mono = [MONOTONAS.get(c, 0) for c in columnas]
return {
"Logística (baseline)": make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)),
"LightGBM": LGBMClassifier(**base),
"LightGBM monótono": LGBMClassifier(**base, monotone_constraints=mono),
}
def run():
df = load()
X_all, y_default = features(df), df["default"].values
# Universo de cobranza: clientes con algún atraso este mes
mora = X_all["atraso_actual"] >= 1
X, y = X_all[mora], 1 - y_default[mora] # y = 1 → paga / regulariza
saldo = X["saldo"].clip(lower=0).values
X_tr, X_te, y_tr, y_te, s_tr, s_te = train_test_split(X, y, saldo, test_size=0.3, stratify=y,
random_state=SEED)
mods = modelos(list(X.columns))
# 1) CV 5×2 con los mismos folds para los tres modelos
cv = cv_clasificacion(mods, X_tr, y_tr, folds=5, repeticiones=2)
auc_cv = cv.groupby("modelo", sort=False).AUC
d_mono, p_mono = t_test_corregido(cv[cv.modelo == "LightGBM monótono"].AUC.values,
cv[cv.modelo == "LightGBM"].AUC.values,
int(len(X_tr) * 0.8), int(len(X_tr) * 0.2))
# 2) Modelo final: LightGBM monótono (mismo desempeño, comportamiento explicable y auditable)
fit = {k: m.fit(X_tr, y_tr) for k, m in mods.items()}
probs = {k: m.predict_proba(X_te)[:, 1] for k, m in fit.items()}
gbm = fit["LightGBM monótono"]
p = probs["LightGBM monótono"]
auc, auc_lo, auc_hi = bootstrap_ci(y_te, p)
ks = ks_stat(y_te, p)
pend, interc = calibracion_lineal(y_te, p)
# 3) ¿El modelo solo reproduce el tramo de mora? AUC DENTRO de cada tramo
tramo_te = pd.cut(X_te["atraso_actual"], [0, 1, 2, 9], labels=["1 mes", "2 meses", "3+ meses"])
auc_tramo = []
for t_, m in pd.Series(tramo_te).groupby(tramo_te, observed=True).groups.items():
idx = X_te.index.get_indexer(m)
if len(np.unique(y_te[idx])) == 2:
auc_tramo.append([str(t_), len(idx), round(roc_auc_score(y_te[idx], p[idx]), 3),
round(roc_auc_score(y_te[idx], -X_te["atraso_actual"].values[idx]), 3)])
auc_tramo = pd.DataFrame(auc_tramo, columns=["Tramo", "Cuentas", "AUC modelo", "AUC regla (días de mora)"])
# 4) Priorización vs. regla tradicional y azar
def capturado(orden):
no_paga = (1 - y_te)[orden]
acum = np.cumsum(no_paga) / no_paga.sum()
idx = np.linspace(0, len(acum) - 1, 40).astype(int)
return [[0, 0]] + [[round((i + 1) / len(acum) * 100, 1), round(acum[i] * 100, 2)] for i in idx]
rng = np.random.default_rng(SEED)
estrategias = {
"Modelo (prob. de impago)": np.argsort(p),
"Más días de mora primero": np.lexsort((-s_te, -X_te["atraso_actual"].values)),
"Al azar": rng.permutation(len(p)),
}
curvas = {k: capturado(v) for k, v in estrategias.items()}
cap = {n: next(v for x, v in reversed(c) if x <= CAPACIDAD * 100) for n, c in curvas.items()}
# IC bootstrap de la ganancia del modelo sobre la regla a la capacidad dada
k = int(len(p) * CAPACIDAD)
gan = []
for _ in range(300):
i = rng.integers(0, len(p), len(p))
yy, pp, at, ss = y_te[i], p[i], X_te["atraso_actual"].values[i], s_te[i]
imp = (1 - yy).sum()
m_ = (1 - yy)[np.argsort(pp)[:k]].sum() / imp
r_ = (1 - yy)[np.lexsort((-ss, -at))[:k]].sum() / imp
gan.append((m_ - r_) * 100)
g_lo, g_hi = np.percentile(gan, [2.5, 97.5])
# 5) Explicabilidad: SHAP global y local (cuenta con menor probabilidad de pago)
contrib = gbm.predict(X_te, pred_contrib=True)
shap = pd.Series(np.abs(contrib[:, :-1]).mean(0), index=X.columns).sort_values(ascending=False)
i0 = int(np.argmin(p))
etiquetas, valores = explicacion_local(contrib[i0, :-1], list(X.columns), X_te.iloc[i0].round(2))
# Efecto parcial (dependencia) de la variable con más peso, verificando la monotonía
var = shap.index[0]
grid = np.unique(np.quantile(X_te[var], np.linspace(0.02, 0.98, 15)))
pdp = {}
for nombre in ["LightGBM", "LightGBM monótono"]:
pts = []
for v in grid:
Xg = X_te.copy()
Xg[var] = v
pts.append([round(float(v), 3), round(float(fit[nombre].predict_proba(Xg)[:, 1].mean() * 100), 2)])
pdp[nombre] = pts
tramo = pd.cut(X["atraso_actual"], [0, 1, 2, 3, 9], labels=["1 mes", "2 meses", "3 meses", "4+ meses"])
por_tramo = pd.DataFrame({"tramo": tramo, "paga": y}).groupby("tramo", observed=True).agg(
clientes=("paga", "size"), tasa_pago=("paga", "mean")).reset_index()
por_tramo["tasa_pago"] = (por_tramo["tasa_pago"] * 100).round(1)
por_tramo.columns = ["Tramo de mora", "Clientes", "Regularizan %"]
modelo, regla = "Modelo (prob. de impago)", "Más días de mora primero"
result = {
"title": "Cobranza: probabilidad de pago tras mora",
"summary": [
f"Universo: {mora.sum():,} cuentas con atraso; {y.mean():.0%} regulariza al mes siguiente.",
"CV 5×2 (AUC): " + ", ".join(f"{k} {v.mean():.3f} ± {v.std():.3f}" for k, v in auc_cv) +
f". Las restricciones monótonas cambian el AUC en {d_mono:+.3f} (p = {p_mono:.2f}) y garantizan que "
"más atraso nunca suba la probabilidad de pago: modelo auditable.",
f"En prueba: AUC {auc:.3f} (IC 95 % {auc_lo:.3f}–{auc_hi:.3f}), KS {ks:.3f}; calibración pendiente "
f"{pend:.2f} (< 1: probabilidades algo extremas; se corrige con recalibración Platt si se usan como "
f"probabilidad), intercepto {interc:+.2f}.",
f"Con capacidad para {CAPACIDAD:.0%} de las cuentas, el modelo alcanza {cap[modelo]:.0f} % de los clientes que no "
f"regularizarían vs. {cap[regla]:.0f} % de la regla de días de mora (ganancia IC 95 % {g_lo:+.1f} a {g_hi:+.1f} pp). "
"El valor real está dentro de cada tramo, donde la regla no distingue (ver AUC por tramo).",
],
"metrics": [
{"label": "AUC CV (monótono)", "value": f"{auc_cv.mean()['LightGBM monótono']:.3f} ± {auc_cv.std()['LightGBM monótono']:.3f}"},
{"label": "AUC prueba (IC 95 %)", "value": f"{auc:.3f} [{auc_lo:.2f}–{auc_hi:.2f}]"},
{"label": f"Impagos alcanzados @{CAPACIDAD:.0%}", "value": f"{cap[modelo]:.0f} %"},
{"label": "Ganancia vs. regla", "value": f"{cap[modelo] - cap[regla]:+.0f} pp"},
],
"charts": [
line("prior", "Cuentas que no regularizarían solas, alcanzadas según estrategia", curvas,
x_label="% de cuentas gestionadas", y_label="% de impagos alcanzados", fmt="pct",
markers=[[CAPACIDAD * 100, "Capacidad"]]),
bars("cv", "AUC en CV 5×2 por modelo (media)", auc_cv.mean().index, auc_cv.mean().round(4).values, fmt="dec"),
bars("auct", "AUC dentro de cada tramo de mora: modelo", auc_tramo["Tramo"], auc_tramo["AUC modelo"].values,
fmt="dec", subtitle="La regla de días de mora tiene AUC ≈ 0.5 dentro de un mismo tramo"),
*graficas_validacion(y_te, {"LightGBM monótono": p, "Logística": probs["Logística (baseline)"]}),
line("pdp", f"Efecto parcial de {var}: ¿se respeta la lógica de negocio?", pdp,
x_label=var, y_label="Prob. de pago promedio %", fmt="pct"),
bars("shap", "Explicabilidad global: |SHAP| promedio", shap.index[:10], shap.values[:10].round(3), fmt="dec"),
bars("local", "Explicabilidad local: cuenta con menor probabilidad de pago", etiquetas, valores, fmt="dec",
subtitle=f"Contribución SHAP en log-odds · prob. de pago {p[i0]:.0%}"),
],
"tables": [table("Validación cruzada 5×2", resumen_cv(cv)),
table("AUC dentro de cada tramo de mora", auc_tramo),
table("Regularización por tramo", por_tramo)],
"card": model_card(
"LightGBM con restricciones monótonas sobre variables de comportamiento de pago, comparado con "
"logística y LightGBM libre.",
["Split 70/30 estratificado; CV 5×2 en entrenamiento con t-test corregido.",
"AUC con IC bootstrap, calibración y AUC estratificado por tramo de mora.",
"IC bootstrap de la ganancia operativa frente a la regla tradicional.",
"Efectos parciales para verificar que el modelo respeta la lógica de negocio."],
["\"Regularizar\" = no caer en default el mes siguiente (definición del dataset).",
f"El call center gestiona {CAPACIDAD:.0%} de las cuentas en mora al mes."],
["Tarjetas de crédito en Taiwán (2005); sin fechas de corte, por lo que no hay validación fuera de tiempo.",
"El modelo predice pago, no el efecto de la llamada: para eso se necesita un experimento (ver #3)."]),
}
sample = pd.DataFrame({"p_pago": p.round(4), "paga": y_te, "saldo": s_te,
"atraso": X_te["atraso_actual"].values})
export("p05-cobranza", result, sample.sample(min(3000, len(sample)), random_state=1))
return result
if __name__ == "__main__":
run()