Saltar al contenido
← Todos los proyectos

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).
AUC CV (monótono)0.733 ± 0.022
AUC prueba (IC 95 %)0.727 [0.70–0.75]
Impagos alcanzados @30%41 %
Ganancia vs. regla+2 pp

Resultados

Cuentas que no regularizarían solas, alcanzadas según estrategia

  • Modelo (prob. de impago)
  • Más días de mora primero
  • Al azar
0 %20 %40 %60 %80 %100 %020406080100% de cuentas gestionadasCapacidad
% de impagos alcanzados

AUC en CV 5×2 por modelo (media)

Logística (baseline) 0.724
LightGBM 0.733
LightGBM monótono 0.733

AUC dentro de cada tramo de mora: modelo

La regla de días de mora tiene AUC ≈ 0.5 dentro de un mismo tramo

1 mes 0.671
2 meses 0.609
3+ meses 0.644

Curva ROC (set de prueba)

  • LightGBM monótono
  • Logística
  • 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

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

Efecto parcial de atraso_actual: ¿se respeta la lógica de negocio?

  • LightGBM
  • LightGBM monótono
0 %20 %40 %60 %11.522.53atraso_actual
Prob. de pago promedio %

Explicabilidad global: |SHAP| promedio

atraso_actual 0.664
meses_en_mora_6m 0.216
ratio_pago_saldo_3m 0.198
crecimiento_saldo 0.138
utilizacion 0.13
meses_sin_pago 0.119
edad 0.099
atraso_max_6m 0.098
limite 0.083
saldo 0.07

Explicabilidad local: cuenta con menor probabilidad de pago

Contribución SHAP en log-odds · prob. de pago 6%

atraso_actual = 3.0 -0.764
meses_en_mora_6m = 6.0 -0.432
edad = 55.0 -0.368
meses_sin_pago = 6.0 -0.315
utilizacion = 0.01 -0.196
ratio_pago_saldo_3m = 0.0 -0.186
limite = 500000.0 -0.128
atraso_max_6m = 3.0 -0.075

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

Regla del área 83.1 %
Greedy valor/costo 99.9 %
Genético sin semilla 78.9 %
Genético sembrado con greedy 100 %
Óptimo exacto (MILP) 100 %

Convergencia del algoritmo genético (valor esperado $)

  • Sembrado con greedy
  • Sin semilla
  • Óptimo exacto
$0$200 k$400 k$600 k$800 k020406080Generación
Valor esperado $

Comparación de métodos de asignación

MétodoValor esperado $% del óptimoCosto $Visitas# SMS# Llamada# Visita
Regla del área761,77283.13,598146915614
Greedy valor/costo915,77299.93,594212229821
Genético sin semilla723,67378.943,5999817839
Genético sembrado con greedy916,41099.973,598212249821
Óptimo exacto (MILP)916,7291003,600212259821

Supuestos de efecto por canal y tramo (% de no-pagadores que convierte)

Tramo (meses)NadaSMSLlamadaVisita
1051525
2021030
301630

✔ 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.
alt05_cobranza_canales_genetico.py165 líneas
"""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.

Línea base0.7328LightGBM monótono (14 variables)
Campeón0.7454LightGBM afinado con Optuna
Mejora en AUC (validación)+0.0126más es mejor
  • 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
0.7350.740.7450123456Iteración
AUC

Ranking de candidatos (AUC en validación)

LightGBM afinado con Optuna 0.745
Ensamble CatBoost + XGBoost + LightGBM-Optuna 0.744
EBM + variables extra 0.74
CatBoost + variables extra 0.739
XGBoost + variables extra 0.738
LightGBM monótono + 33 variables 0.734
LightGBM monótono (14 variables) 0.733

Optuna: mejores hiperparámetros encontrados

n_estimators 269
learning_rate 0.016
num_leaves 6
min_child_samples 22
colsample_bytree 0.678
reg_lambda 5.684

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

ModeloAUC prueba
Línea base: LightGBM monótono0.727
Campeón: LightGBM afinado con Optuna0.734

Bitácora de experimentos

IteraciónHipótesisCandidatoAUCDesv.DecisiónNotaΔ vs. campeónp
0Línea base: el modelo monótono del proyecto original.LightGBM monótono (14 variables)0.7330.021Línea base——
1Más información: historia cruda de 6 meses + pago relativo, pendiente y volatilidad.LightGBM monótono + 33 variables0.7340.02Nuevo campeón (mejora no significativa)0.0010.751
2CatBoost (árboles simétricos, más regularizados) aprovecha mejor la señal débil.CatBoost + variables extra0.7390.019Nuevo campeón0.0050.044
3XGBoost como segundo boosting de referencia.XGBoost + variables extra0.7380.02Se descarta-00.917
4EBM: boosting aditivo explicable con las mismas variables.EBM + variables extra0.740.02Nuevo campeón (mejora no significativa)0.0020.716
5Optuna (40 ensayos TPE) encuentra hiperparámetros mejores que los manuales.LightGBM afinado con Optuna0.7450.019Nuevo campeónLigero optimismo: Optuna usó todo el entrenamiento con otra partición.0.0050.084
6Ensamble de los tres mejores boostings.Ensamble CatBoost + XGBoost + LightGBM-Optuna0.7440.02Se descarta-0.0010.566
exp05_cobranza.py130 líneas
"""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

ModeloAUCPR-AUCKSBrier
Logística (baseline)0.724 ± 0.0210.709 ± 0.0220.377 ± 0.0380.215 ± 0.006
LightGBM0.733 ± 0.0220.720 ± 0.0180.380 ± 0.0400.210 ± 0.009
LightGBM monótono0.733 ± 0.0220.723 ± 0.0200.384 ± 0.0340.210 ± 0.009

AUC dentro de cada tramo de mora

TramoCuentasAUC modeloAUC regla (días de mora)
1 mes1,1020.6710.5
2 meses8140.6090.5
3+ meses1300.6440.462

Regularización por tramo

Tramo de moraClientesRegularizan %
1 mes3,68866.1
2 meses2,66730.9
3 meses32224.2
4+ meses14136.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.

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

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