Saltar al contenido
← Todos los proyectos

Proyecto #7 · Customer Analytics

Propensión a contratar (cross-sell)

Propensión con validación fuera de tiempo: cómo el drift cambia el umbral óptimo de llamada.

📦 Datos: Bank Marketing (UCI) Validación fuera de tiempoRecalibración PlattUmbral costo/margenDrift 🔬 Mejor algoritmo: Logística · cliente

Resumen ejecutivo

  • Los datos cubren 2008–2010 y la conversión pasa de 3 % a 46 % entre el primer y el último decil de tiempo: hay drift fuerte. Cambio de enfoque: validación fuera de tiempo en lugar de split aleatorio.
  • La CV aleatoria es optimista: AUC 0.686 vs. 0.521 en CV temporal y 0.640 fuera de tiempo (con variables macro). Mejor conjunto OOT: Solo cliente (sin macro).
  • Prueba OOT: AUC 0.668 (IC 95 % 0.654–0.680). Sin recalibrar, la probabilidad media es 10.3% vs. 30.8% real; la recalibración con la ventana más reciente la lleva a 13.5%.
  • Utilidad en el periodo de prueba: regla costo/margen recalibrada $228,352 vs. $238,896 llamando a todos y $229,256 con el umbral viejo (oráculo $238,928). Con 31% de conversión llamar a todos ya es casi óptimo: el umbral debe recalibrarse con datos recientes y el valor del modelo está en priorizar con capacidad limitada (llamando al 30 % se captura 45% de las contrataciones).
AUC CV aleatoria (optimista)0.686
AUC fuera de tiempo0.668 [0.65–0.68]
Lift decil 1 (OOT)1.5×
Captura con 30 % de llamadas45%

Resultados

Tasa de conversión a lo largo del tiempo (deciles de llamadas)

0 %10 %20 %30 %40 %246810Decil temporal (1 = may-2008, 10 = nov-2010)Fin entrenamientoInicio prueba
% que contrata

AUC según esquema de validación (variables completas)

CV aleatoria 0.686
CV temporal 0.521
Prueba fuera de tiempo 0.64

Utilidad en el periodo de prueba por estrategia

Llamar a todos $238,896
Umbral de validación (sin recalibrar) $229,256
Regla costo/margen (recalibrado) $228,352
Oráculo (máximo posible) $238,928

Utilidad en prueba según umbral (probabilidad recalibrada)

$0$50 k$100 k$150 k$200 k0.20.40.60.8Umbral de probabilidad para llamarCosto/margen
USD

Curva ROC (set de prueba)

  • Sin recalibrar
  • Recalibrado
  • 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

  • Sin recalibrar
  • Recalibrado
  • Referencia
0 %20 %40 %01020304050% predicho (promedio por decil)
% observado

Curva de ganancia acumulada (fuera de tiempo)

  • Modelo
  • Al azar
0 %20 %40 %60 %80 %100 %020406080100% de clientes llamados
% de contrataciones capturadas

Explicabilidad: |SHAP| promedio

month 0.528
age 0.113
contact 0.095
day_of_week 0.091
job 0.089
poutcome 0.065
education 0.057
campaign 0.051
housing 0.028
marital 0.022

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

LightGBM de propensión; selección de variables y umbral con validación fuera de tiempo; recalibración Platt con la ventana más reciente.

✔ Cómo se validó

  • Comparación explícita: CV aleatoria vs. CV temporal (TimeSeriesSplit) vs. prueba fuera de tiempo.
  • Entrenamiento 0–70 %, validación 70–80 %, prueba 80–100 % en orden cronológico.
  • AUC con IC bootstrap; calibración antes y después de recalibrar.
  • Se excluye `duration` (solo existe después de la llamada: fuga).

⚙ Supuestos

  • Costo $8 por llamada y margen $120 por producto (editables en el simulador).
  • La probabilidad recalibrada es comparable con la regla costo/margen.

⚠ Limitaciones

  • Banco portugués 2008–2010, periodo de crisis: las variables macro dominan el periodo.
  • El simulador del sitio usa la muestra de prueba fuera de tiempo.

🔬 Búsqueda del mejor algoritmo · 8 iteraciones

Búsqueda del mejor algoritmo de cross-sell bajo drift

Objetivo: Generalizar a un periodo futuro con conversión y tasas muy distintas.

Línea base0.5568LightGBM · cliente
Campeón0.5698Logística · cliente
Mejora en AUC (validación)+0.0130más es mejor
  • 8 hipótesis evaluadas con 3 bloques temporales. Campeón: Logística · cliente (AUC medio 0.570 vs. 0.557 de la línea base).
  • Prueba final fuera de tiempo: Logística · cliente 0.702 vs. línea base 0.667 (diferencia +0.035, IC 95 % +0.027 a +0.043).
  • Por qué gana un modelo lineal: aprende relaciones suaves en lugar de interacciones específicas de un periodo, que se rompen cuando el mercado cambia. Y con variables macro el árbol ni siquiera puede extrapolar: el euribor cae a 0.63 cuando en entrenamiento nunca bajó de 1.30, y el árbol lo trata como el valor más bajo conocido.
  • Advertencia honesta: la logística con macro llega a 0.748 en prueba, pero en validación no era la mejor y elegirla viendo la prueba sería trampa. Se reporta como hipótesis a confirmar con el siguiente periodo.
  • Pesar más lo reciente ayuda un poco a los árboles, pero recortar la ventana empeora: en crisis, el pasado reciente tampoco se parece al futuro y se pierde información útil.

💡 Bajo drift, los modelos que extrapolan (lineales) generalizan mejor que los árboles.

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

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

Ranking de candidatos (AUC en validación)

Logística · cliente 0.57
LightGBM · pesos por recencia 0.567
Ensamble logística + LightGBM · cliente 0.564
LightGBM · cliente 0.557
LightGBM · ventana reciente 0.547
LightGBM · cliente + macro 0.51
CatBoost · cliente 0.487
Logística · cliente + macro 0.456

AUC por bloque temporal del campeón

50–60 % 0.382
60–70 % 0.647
70–80 % 0.68

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

ModeloAUC prueba (80–100 %)
Línea base: LightGBM · cliente0.667
Campeón: Logística · cliente0.702
No seleccionable: Logística · cliente + macro0.748

Bitácora de experimentos

IteraciónHipótesisCandidatoAUCDesv.DecisiónNotaΔ vs. campeónp
0Línea base del proyecto: árboles con variables de cliente.LightGBM · cliente0.5570.131Línea baseBloques: 0.372 · 0.635 · 0.664——
1Las variables macro ayudan si el modelo las puede usar.LightGBM · cliente + macro0.510.128Se descartaBloques: 0.365 · 0.487 · 0.677-0.047—
2Dar más peso a lo reciente adapta el modelo al nuevo régimen.LightGBM · pesos por recencia0.5670.131Nuevo campeónBloques: 0.382 · 0.655 · 0.6640.01—
3Olvidar el pasado lejano: entrenar solo con el 40 % más reciente.LightGBM · ventana reciente0.5470.12Se descartaBloques: 0.378 · 0.632 · 0.630-0.02—
4Un modelo lineal extrapola de forma suave cuando las variables salen del rango histórico.Logística · cliente0.570.133Nuevo campeónBloques: 0.382 · 0.647 · 0.6800.003—
5Lineal + macro: la tendencia de tasas se extrapola en lugar de quedar truncada.Logística · cliente + macro0.4560.157Se descartaBloques: 0.361 · 0.329 · 0.677-0.114—
6CatBoost: categorías codificadas por target ordenado, más estable ante cambios.CatBoost · cliente0.4870.14Se descartaBloques: 0.358 · 0.422 · 0.682-0.082—
7Promediar lineal y árboles combina extrapolación e interacciones.Ensamble logística + LightGBM · cliente0.5640.142Se descartaBloques: 0.364 · 0.646 · 0.682-0.006—

Rango del euribor: ¿por qué los árboles no extrapolan?

Periodoeuribor3m mín.euribor3m máx.
Entrenamiento (0–80 %)1.2995.045
Prueba (80–100 %)0.6341.299
exp07_cross_sell.py176 líneas
"""Búsqueda del mejor algoritmo — #7 Cross-sell bajo drift.

Punto de partida: LightGBM con variables de cliente, AUC fuera de tiempo ≈ 0.67. La conversión pasa de 3 % a
46 % en el periodo y las tasas de interés caen a mínimos: el problema es de generalización temporal, no de
capacidad del modelo. Selección con bloques temporales móviles (entrenar con todo lo anterior y validar en el
siguiente 10 %: bloques 50–60 %, 60–70 % y 70–80 %); la prueba final (80–100 %) se usa una sola vez.
"""
import numpy as np
import pandas as pd
from catboost import CatBoostClassifier
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

from common import bars, export_experimento
from exp01_churn import Promedio
from experimentos import Bitacora, dif_pareada_bootstrap
from p07_cross_sell import MACRO, gbm, load

BLOQUES = (0.5, 0.6, 0.7)


class ConPesos:
    """Envuelve un modelo para entrenar con pesos que decaen exponencialmente hacia el pasado."""

    def __init__(self, fabrica, vida_media: float):
        self.fabrica, self.vida_media = fabrica, vida_media

    def fit(self, X, y):
        edad = np.arange(len(X))[::-1] / len(X)                 # 0 = observación más reciente
        w = 0.5 ** (edad / self.vida_media)
        self.m = self.fabrica()
        if hasattr(self.m, "steps"):
            self.m.fit(X, y, **{f"{self.m.steps[-1][0]}__sample_weight": w})
        else:
            self.m.fit(X, y, sample_weight=w)
        return self

    def predict_proba(self, X):
        return self.m.predict_proba(X)


class Ventana:
    """Entrena solo con la fracción más reciente de la historia."""

    def __init__(self, fabrica, fraccion: float):
        self.fabrica, self.fraccion = fabrica, fraccion

    def fit(self, X, y):
        k = int(len(X) * (1 - self.fraccion))
        self.m = self.fabrica().fit(X.iloc[k:], y[k:])
        return self

    def predict_proba(self, X):
        return self.m.predict_proba(X)


def run():
    df = load()
    y = df.pop("y").values
    cat = df.select_dtypes("object").columns.tolist()
    for c in cat:
        df[c] = df[c].astype("category")
    full = list(df.columns)
    cli = [c for c in full if c not in MACRO]
    n = len(df)

    def logistica(cols, C=0.3):
        return lambda: make_pipeline(ColumnTransformer([
            ("n", StandardScaler(), [c for c in cols if c not in cat]),
            ("c", OneHotEncoder(handle_unknown="ignore"), [c for c in cols if c in cat])]),
            LogisticRegression(max_iter=3000, C=C))

    def catboost(cols):
        def f():
            return _CatStr(CatBoostClassifier(iterations=400, depth=4, learning_rate=0.05, verbose=0, random_seed=42,
                                              allow_writing_files=False, cat_features=[c for c in cols if c in cat]))
        return f

    def bloques(fabrica, cols):
        X = df[cols]
        out = []
        for k in BLOQUES:
            i, j = int(n * k), int(n * (k + 0.1))
            out.append(roc_auc_score(y[i:j], fabrica().fit(X.iloc[:i], y[:i]).predict_proba(X.iloc[i:j])[:, 1]))
        return np.array(out)

    candidatos = [
        ("Línea base del proyecto: árboles con variables de cliente.", "LightGBM · cliente", gbm, cli),
        ("Las variables macro ayudan si el modelo las puede usar.", "LightGBM · cliente + macro", gbm, full),
        ("Dar más peso a lo reciente adapta el modelo al nuevo régimen.", "LightGBM · pesos por recencia",
         lambda: ConPesos(gbm, 0.2), cli),
        ("Olvidar el pasado lejano: entrenar solo con el 40 % más reciente.", "LightGBM · ventana reciente",
         lambda: Ventana(gbm, 0.4), cli),
        ("Un modelo lineal extrapola de forma suave cuando las variables salen del rango histórico.",
         "Logística · cliente", logistica(cli), cli),
        ("Lineal + macro: la tendencia de tasas se extrapola en lugar de quedar truncada.",
         "Logística · cliente + macro", logistica(full), full),
        ("CatBoost: categorías codificadas por target ordenado, más estable ante cambios.", "CatBoost · cliente",
         catboost(cli), cli),
        ("Promediar lineal y árboles combina extrapolación e interacciones.", "Ensamble logística + LightGBM · cliente",
         lambda: Promedio(logistica(cli), gbm), cli),
    ]
    bit = Bitacora("AUC")
    usados = {}
    for hip, nombre, fab, cols in candidatos:
        s = bloques(fab, cols)
        bit.probar(hip, nombre, s, nota="Bloques: " + " · ".join(f"{v:.3f}" for v in s))
        usados[nombre] = (fab, cols)

    # Prueba final: reentrenar con 0–80 % y evaluar 80–100 % una sola vez
    i = int(n * 0.8)
    campeon = bit.campeon["Candidato"]
    preds = {}
    for nombre in ["LightGBM · cliente", campeon, "Logística · cliente + macro"]:
        fab, cols = usados[nombre]
        preds[nombre] = fab().fit(df[cols].iloc[:i], y[:i]).predict_proba(df[cols].iloc[i:])[:, 1]
    y_te = y[i:]
    d, lo, hi = dif_pareada_bootstrap(y_te, preds[campeon], preds["LightGBM · cliente"])
    prueba = pd.DataFrame([{"Modelo": f"{'Línea base' if k == 'LightGBM · cliente' else 'Campeón' if k == campeon else 'No seleccionable'}: {k}",
                            "AUC prueba (80–100 %)": round(roc_auc_score(y_te, p), 4)} for k, p in preds.items()])
    rango = pd.DataFrame({"Periodo": ["Entrenamiento (0–80 %)", "Prueba (80–100 %)"],
                          "euribor3m mín.": [df.euribor3m.iloc[:i].min(), df.euribor3m.iloc[i:].min()],
                          "euribor3m máx.": [df.euribor3m.iloc[:i].max(), df.euribor3m.iloc[i:].max()]})
    auc_macro = roc_auc_score(y_te, preds["Logística · cliente + macro"])
    result = bit.resultado(
        "Búsqueda del mejor algoritmo de cross-sell bajo drift",
        "Generalizar a un periodo futuro con conversión y tasas muy distintas.",
        [f"{len(bit.filas)} hipótesis evaluadas con 3 bloques temporales. Campeón: {campeon} (AUC medio "
         f"{bit.campeon['AUC']:.3f} vs. {bit.filas[0]['AUC']:.3f} de la línea base).",
         f"Prueba final fuera de tiempo: {campeon} {roc_auc_score(y_te, preds[campeon]):.3f} vs. línea base "
         f"{roc_auc_score(y_te, preds['LightGBM · cliente']):.3f} (diferencia {d:+.3f}, IC 95 % {lo:+.3f} a {hi:+.3f}).",
         "Por qué gana un modelo lineal: aprende relaciones suaves en lugar de interacciones específicas de un periodo, "
         "que se rompen cuando el mercado cambia. Y con variables macro el árbol ni siquiera puede extrapolar: el euribor "
         f"cae a {df.euribor3m.iloc[i:].min():.2f} cuando en entrenamiento nunca bajó de {df.euribor3m.iloc[:i].min():.2f}, y el "
         "árbol lo trata como el valor más bajo conocido.",
         f"Advertencia honesta: la logística con macro llega a {auc_macro:.3f} en prueba, pero en validación no era la "
         "mejor y elegirla viendo la prueba sería trampa. Se reporta como hipótesis a confirmar con el siguiente periodo.",
         "Pesar más lo reciente ayuda un poco a los árboles, pero recortar la ventana empeora: en crisis, el pasado "
         "reciente tampoco se parece al futuro y se pierde información útil."],
        prueba,
        graficas_extra=[bars("bloques", "AUC por bloque temporal del campeón",
                             [f"{int(k * 100)}–{int(k * 100) + 10} %" for k in BLOQUES], bit.campeon["_scores"].round(3), fmt="dec")],
        leccion="Bajo drift, los modelos que extrapolan (lineales) generalizan mejor que los árboles.",
    )
    result["tables"].append({"title": "Rango del euribor: ¿por qué los árboles no extrapolan?",
                             "columns": list(rango.columns), "rows": rango.round(3).values.tolist()})
    export_experimento("p07-cross-sell", result)
    return result


class _CatStr:
    """CatBoost necesita categorías como texto."""

    def __init__(self, m):
        self.m = m

    def _x(self, X):
        X = X.copy()
        for c in X.select_dtypes("category"):
            X[c] = X[c].astype(str)
        return X

    def fit(self, X, y):
        self.m.fit(self._x(X), y)
        return self

    def predict_proba(self, X):
        return self.m.predict_proba(self._x(X))


if __name__ == "__main__":
    run()

AUC por esquema de validación y conjunto de variables

VariablesAUC CV aleatoriaAUC CV temporalAUC prueba OOT
Completo (con macro)0.686 ± 0.0180.521 ± 0.0800.64
Solo cliente (sin macro)0.672 ± 0.0140.521 ± 0.0780.668

Tabla de ganancia por decil (fuera de tiempo)

DecilClientesContratanConversión %Captura acum. %Lift
182437845.914.91.49
282441750.631.31.64
382435342.845.21.39
482431738.557.71.25
582326632.368.11.05
682420224.576.10.8
782421726.384.60.85
882415118.390.60.59
982413816.7960.54
1082310112.31000.4

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

p07_cross_sell.py190 líneas
"""#7 Propensión a contratar un producto (cross-sell) con corte óptimo por costo/beneficio.

Datos: Bank Marketing (UCI, banco portugués, 41,188 llamadas de telemarketing).
Se excluye `duration` porque solo se conoce después de la llamada (fuga de información).
"""
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 StratifiedKFold, TimeSeriesSplit, cross_val_score

from common import (SEED, bars, cumulative_gain_curve, download, export, gains_table, line,
                    read_from_zip, table)
from validacion import bootstrap_ci, graficas_validacion, model_card

URL = "https://archive.ics.uci.edu/static/public/222/bank+marketing.zip"
COSTO_LLAMADA = 8.0      # USD por llamada
MARGEN_PRODUCTO = 120.0  # margen esperado por cada depósito contratado


def load() -> pd.DataFrame:
    raw = read_from_zip(download(URL, "bank_marketing.zip"), "bank-additional-full.csv")
    df = pd.read_csv(io.BytesIO(raw), sep=";")
    df["y"] = (df["y"] == "yes").astype(int)
    df["contactado_antes"] = (df["pdays"] != 999).astype(int)
    return df.drop(columns=["duration"])


MACRO = ["emp.var.rate", "cons.price.idx", "cons.conf.idx", "euribor3m", "nr.employed"]


def gbm():
    return LGBMClassifier(n_estimators=400, learning_rate=0.02, num_leaves=15, min_child_samples=100,
                          subsample=0.8, subsample_freq=1, colsample_bytree=0.8, random_state=SEED, verbose=-1)


def utilidad(y, p, u):
    sel = p >= u
    return float(y[sel].sum() * MARGEN_PRODUCTO - sel.sum() * COSTO_LLAMADA)


def run():
    df = load()
    y = df.pop("y").values
    for c in df.select_dtypes("object").columns:
        df[c] = df[c].astype("category")
    conjuntos = {"Completo (con macro)": list(df.columns),
                 "Solo cliente (sin macro)": [c for c in df.columns if c not in MACRO]}

    # Los datos están ordenados en el tiempo (may-2008 → nov-2010). Un split aleatorio mezcla periodos y es
    # optimista. Cambio de enfoque: validación fuera de tiempo (OOT) —
    # entrenamiento 0–70 %, validación 70–80 % (umbral y recalibración), prueba 80–100 %.
    n = len(df)
    i_tr, i_va = int(n * 0.7), int(n * 0.8)
    tasa = [float(y[k * n // 10:(k + 1) * n // 10].mean() * 100) for k in range(10)]

    comp = []
    for nombre, cols in conjuntos.items():
        X = df[cols]
        # (a) CV aleatoria estratificada — lo que haría un análisis ingenuo
        aleat = cross_val_score(gbm(), X.iloc[:i_va], y[:i_va], cv=StratifiedKFold(5, shuffle=True, random_state=SEED),
                                scoring="roc_auc")
        # (b) CV temporal (expanding window) — respeta el orden
        temp = cross_val_score(gbm(), X.iloc[:i_va], y[:i_va], cv=TimeSeriesSplit(5), scoring="roc_auc")
        # (c) Prueba fuera de tiempo
        m = gbm().fit(X.iloc[:i_tr], y[:i_tr])
        oot = roc_auc_score(y[i_va:], m.predict_proba(X.iloc[i_va:])[:, 1])
        comp.append({"Variables": nombre, "CV aleatoria": aleat.mean(), "CV temporal": temp.mean(),
                     "Prueba OOT": oot, "std_a": aleat.std(), "std_t": temp.std()})
    comp = pd.DataFrame(comp)
    mejor = comp.loc[comp["Prueba OOT"].idxmax(), "Variables"]

    # Modelo final con el conjunto que mejor generaliza fuera de tiempo
    X = df[conjuntos[mejor]]
    modelo = gbm().fit(X.iloc[:i_tr], y[:i_tr])
    p_va, p_te = modelo.predict_proba(X.iloc[i_tr:i_va])[:, 1], modelo.predict_proba(X.iloc[i_va:])[:, 1]
    y_va, y_te = y[i_tr:i_va], y[i_va:]
    auc, auc_lo, auc_hi = bootstrap_ci(y_te, p_te)
    # Recalibración Platt con la ventana de validación (la más reciente antes de la prueba)
    logit = lambda q: np.log(np.clip(q, 1e-6, 1 - 1e-6) / (1 - np.clip(q, 1e-6, 1 - 1e-6))).reshape(-1, 1)
    platt = LogisticRegression(C=1e6).fit(logit(p_va), y_va)
    p_te_cal = platt.predict_proba(logit(p_te))[:, 1]

    # Umbral: (1) regla teórica costo/margen sobre prob. calibrada; (2) óptimo en validación; (3) oráculo en prueba
    u_teorico = COSTO_LLAMADA / MARGEN_PRODUCTO
    umbrales = np.round(np.arange(0.01, 0.81, 0.01), 2)
    u_va = float(umbrales[np.argmax([utilidad(y_va, p_va, u) for u in umbrales])])
    util_te = [utilidad(y_te, p_te_cal, u) for u in umbrales]
    u_oraculo = float(umbrales[int(np.argmax(util_te))])
    estrategias = {
        "Llamar a todos": utilidad(y_te, np.ones_like(p_te), 0.5),
        "Umbral de validación (sin recalibrar)": utilidad(y_te, p_te, u_va),
        "Regla costo/margen (recalibrado)": utilidad(y_te, p_te_cal, u_teorico),
        "Oráculo (máximo posible)": max(util_te),
    }

    # Explicabilidad: SHAP global + efecto parcial de la variable principal
    contrib = modelo.predict(X.iloc[i_va:], pred_contrib=True)[:, :-1]
    shap = pd.Series(np.abs(contrib).mean(0), index=X.columns).sort_values(ascending=False)
    top = shap.index[0]
    pdp = []
    if pd.api.types.is_numeric_dtype(X[top]):
        for v in np.unique(np.quantile(X[top], np.linspace(0.02, 0.98, 15))):
            Xg = X.iloc[i_va:].copy()
            Xg[top] = v
            pdp.append([round(float(v), 3), round(float(modelo.predict_proba(Xg)[:, 1].mean() * 100), 2)])

    g = gains_table(y_te, p_te)
    g_tbl = g[["decil", "n", "pos", "tasa", "captura_acum", "lift"]].copy()
    g_tbl["tasa"] = (g_tbl["tasa"] * 100).round(1)
    g_tbl["captura_acum"] = (g_tbl["captura_acum"] * 100).round(1)
    g_tbl["lift"] = g_tbl["lift"].round(2)
    g_tbl.columns = ["Decil", "Clientes", "Contratan", "Conversión %", "Captura acum. %", "Lift"]
    comp_tbl = pd.DataFrame({"Variables": comp["Variables"],
                             "AUC CV aleatoria": [f"{m:.3f} ± {s:.3f}" for m, s in comp[["CV aleatoria", "std_a"]].values],
                             "AUC CV temporal": [f"{m:.3f} ± {s:.3f}" for m, s in comp[["CV temporal", "std_t"]].values],
                             "AUC prueba OOT": comp["Prueba OOT"].round(3)})
    c_full = comp.set_index("Variables").loc["Completo (con macro)"]
    # Con capacidad limitada (30 % de llamadas) el valor del modelo es priorizar
    orden = np.argsort(-p_te)
    cap30 = y_te[orden[: int(len(y_te) * 0.3)]].sum() / y_te.sum()
    result = {
        "title": "Propensión a contratar (cross-sell)",
        "summary": [
            f"Los datos cubren 2008–2010 y la conversión pasa de {tasa[0]:.0f} % a {tasa[-1]:.0f} % entre el primer y el último "
            "decil de tiempo: hay drift fuerte. Cambio de enfoque: validación fuera de tiempo en lugar de split aleatorio.",
            f"La CV aleatoria es optimista: AUC {c_full['CV aleatoria']:.3f} vs. {c_full['CV temporal']:.3f} en CV temporal "
            f"y {c_full['Prueba OOT']:.3f} fuera de tiempo (con variables macro). Mejor conjunto OOT: {mejor}.",
            f"Prueba OOT: AUC {auc:.3f} (IC 95 % {auc_lo:.3f}–{auc_hi:.3f}). Sin recalibrar, la probabilidad media es "
            f"{p_te.mean():.1%} vs. {y_te.mean():.1%} real; la recalibración con la ventana más reciente la lleva a {p_te_cal.mean():.1%}.",
            f"Utilidad en el periodo de prueba: regla costo/margen recalibrada ${estrategias['Regla costo/margen (recalibrado)']:,.0f} "
            f"vs. ${estrategias['Llamar a todos']:,.0f} llamando a todos y ${estrategias['Umbral de validación (sin recalibrar)']:,.0f} "
            f"con el umbral viejo (oráculo ${estrategias['Oráculo (máximo posible)']:,.0f}). Con {y_te.mean():.0%} de conversión "
            "llamar a todos ya es casi óptimo: el umbral debe recalibrarse con datos recientes y el valor del modelo "
            f"está en priorizar con capacidad limitada (llamando al 30 % se captura {cap30:.0%} de las contrataciones).",
        ],
        "metrics": [
            {"label": "AUC CV aleatoria (optimista)", "value": f"{c_full['CV aleatoria']:.3f}"},
            {"label": "AUC fuera de tiempo", "value": f"{auc:.3f} [{auc_lo:.2f}–{auc_hi:.2f}]"},
            {"label": "Lift decil 1 (OOT)", "value": f"{g.lift.iloc[0]:.1f}×"},
            {"label": "Captura con 30 % de llamadas", "value": f"{cap30:.0%}"},
        ],
        "charts": [
            line("drift", "Tasa de conversión a lo largo del tiempo (deciles de llamadas)",
                 {"Conversión": [[k + 1, round(v, 2)] for k, v in enumerate(tasa)]},
                 x_label="Decil temporal (1 = may-2008, 10 = nov-2010)", y_label="% que contrata", fmt="pct",
                 markers=[[7.5, "Fin entrenamiento"], [8.5, "Inicio prueba"]]),
            bars("auc", "AUC según esquema de validación (variables completas)",
                 ["CV aleatoria", "CV temporal", "Prueba fuera de tiempo"],
                 [round(c_full["CV aleatoria"], 3), round(c_full["CV temporal"], 3), round(c_full["Prueba OOT"], 3)], fmt="dec"),
            bars("estr", "Utilidad en el periodo de prueba por estrategia", list(estrategias),
                 [round(v) for v in estrategias.values()], fmt="usd"),
            line("util", "Utilidad en prueba según umbral (probabilidad recalibrada)",
                 {"Utilidad": [[float(u), v] for u, v in zip(umbrales, util_te)]},
                 x_label="Umbral de probabilidad para llamar", y_label="USD", fmt="usd",
                 markers=[[round(u_teorico, 3), "Costo/margen"]]),
            *graficas_validacion(y_te, {"Sin recalibrar": p_te, "Recalibrado": p_te_cal}),
            line("gain", "Curva de ganancia acumulada (fuera de tiempo)",
                 {"Modelo": cumulative_gain_curve(y_te, p_te), "Al azar": [[0, 0], [100, 100]]},
                 x_label="% de clientes llamados", y_label="% de contrataciones capturadas", fmt="pct"),
            bars("shap", "Explicabilidad: |SHAP| promedio", shap.index[:10], shap.values[:10].round(3), fmt="dec"),
            *([line("pdp", f"Efecto parcial de {top}", {"Prob. de contratar": pdp}, x_label=top,
                    y_label="Prob. promedio %", fmt="pct")] if pdp else []),
        ],
        "tables": [table("AUC por esquema de validación y conjunto de variables", comp_tbl),
                   table("Tabla de ganancia por decil (fuera de tiempo)", g_tbl)],
        "card": model_card(
            "LightGBM de propensión; selección de variables y umbral con validación fuera de tiempo; "
            "recalibración Platt con la ventana más reciente.",
            ["Comparación explícita: CV aleatoria vs. CV temporal (TimeSeriesSplit) vs. prueba fuera de tiempo.",
             "Entrenamiento 0–70 %, validación 70–80 %, prueba 80–100 % en orden cronológico.",
             "AUC con IC bootstrap; calibración antes y después de recalibrar.",
             "Se excluye `duration` (solo existe después de la llamada: fuga)."],
            [f"Costo ${COSTO_LLAMADA:.0f} por llamada y margen ${MARGEN_PRODUCTO:.0f} por producto (editables en el simulador).",
             "La probabilidad recalibrada es comparable con la regla costo/margen."],
            ["Banco portugués 2008–2010, periodo de crisis: las variables macro dominan el periodo.",
             "El simulador del sitio usa la muestra de prueba fuera de tiempo."]),
        "assumptions": {"costo_llamada": COSTO_LLAMADA, "margen_producto": MARGEN_PRODUCTO},
    }
    sample = pd.DataFrame({"p": p_te_cal.round(4), "y": y_te})
    export("p07-cross-sell", result, sample.sample(min(4000, len(sample)), random_state=1))
    return result


if __name__ == "__main__":
    run()