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.
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).
Resultados
Tasa de conversión a lo largo del tiempo (deciles de llamadas)
AUC según esquema de validación (variables completas)
Utilidad en el periodo de prueba por estrategia
Utilidad en prueba según umbral (probabilidad recalibrada)
Curva ROC (set de prueba)
- Sin recalibrar
- Recalibrado
- Referencia
Calibración: probabilidad predicha vs. observada
Sobre la diagonal = el modelo subestima; debajo = sobreestima
- Sin recalibrar
- Recalibrado
- Referencia
Curva de ganancia acumulada (fuera de tiempo)
- Modelo
- Al azar
Explicabilidad: |SHAP| promedio
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.
- 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
Ranking de candidatos (AUC en validación)
AUC por bloque temporal del campeón
Prueba final: campeón vs. línea base (una sola evaluación)
| Modelo | AUC prueba (80–100 %) |
|---|---|
| Línea base: LightGBM · cliente | 0.667 |
| Campeón: Logística · cliente | 0.702 |
| No seleccionable: Logística · cliente + macro | 0.748 |
Bitácora de experimentos
| Iteración | Hipótesis | Candidato | AUC | Desv. | Decisión | Nota | Δ vs. campeón | p |
|---|---|---|---|---|---|---|---|---|
| 0 | Línea base del proyecto: árboles con variables de cliente. | LightGBM · cliente | 0.557 | 0.131 | Línea base | Bloques: 0.372 · 0.635 · 0.664 | — | — |
| 1 | Las variables macro ayudan si el modelo las puede usar. | LightGBM · cliente + macro | 0.51 | 0.128 | Se descarta | Bloques: 0.365 · 0.487 · 0.677 | -0.047 | — |
| 2 | Dar más peso a lo reciente adapta el modelo al nuevo régimen. | LightGBM · pesos por recencia | 0.567 | 0.131 | Nuevo campeón | Bloques: 0.382 · 0.655 · 0.664 | 0.01 | — |
| 3 | Olvidar el pasado lejano: entrenar solo con el 40 % más reciente. | LightGBM · ventana reciente | 0.547 | 0.12 | Se descarta | Bloques: 0.378 · 0.632 · 0.630 | -0.02 | — |
| 4 | Un modelo lineal extrapola de forma suave cuando las variables salen del rango histórico. | Logística · cliente | 0.57 | 0.133 | Nuevo campeón | Bloques: 0.382 · 0.647 · 0.680 | 0.003 | — |
| 5 | Lineal + macro: la tendencia de tasas se extrapola en lugar de quedar truncada. | Logística · cliente + macro | 0.456 | 0.157 | Se descarta | Bloques: 0.361 · 0.329 · 0.677 | -0.114 | — |
| 6 | CatBoost: categorías codificadas por target ordenado, más estable ante cambios. | CatBoost · cliente | 0.487 | 0.14 | Se descarta | Bloques: 0.358 · 0.422 · 0.682 | -0.082 | — |
| 7 | Promediar lineal y árboles combina extrapolación e interacciones. | Ensamble logística + LightGBM · cliente | 0.564 | 0.142 | Se descarta | Bloques: 0.364 · 0.646 · 0.682 | -0.006 | — |
Rango del euribor: ¿por qué los árboles no extrapolan?
| Periodo | euribor3m mín. | euribor3m máx. |
|---|---|---|
| Entrenamiento (0–80 %) | 1.299 | 5.045 |
| Prueba (80–100 %) | 0.634 | 1.299 |
"""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
| Variables | AUC CV aleatoria | AUC CV temporal | AUC prueba OOT |
|---|---|---|---|
| Completo (con macro) | 0.686 ± 0.018 | 0.521 ± 0.080 | 0.64 |
| Solo cliente (sin macro) | 0.672 ± 0.014 | 0.521 ± 0.078 | 0.668 |
Tabla de ganancia por decil (fuera de tiempo)
| Decil | Clientes | Contratan | Conversión % | Captura acum. % | Lift |
|---|---|---|---|---|---|
| 1 | 824 | 378 | 45.9 | 14.9 | 1.49 |
| 2 | 824 | 417 | 50.6 | 31.3 | 1.64 |
| 3 | 824 | 353 | 42.8 | 45.2 | 1.39 |
| 4 | 824 | 317 | 38.5 | 57.7 | 1.25 |
| 5 | 823 | 266 | 32.3 | 68.1 | 1.05 |
| 6 | 824 | 202 | 24.5 | 76.1 | 0.8 |
| 7 | 824 | 217 | 26.3 | 84.6 | 0.85 |
| 8 | 824 | 151 | 18.3 | 90.6 | 0.59 |
| 9 | 824 | 138 | 16.7 | 96 | 0.54 |
| 10 | 823 | 101 | 12.3 | 100 | 0.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.
Código completo
El script exacto que generó los resultados de esta página (python run_all.py).
"""#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()