Proyecto #3 · Retención
Uplift modeling: ¿a quién contactar?
Inferencia causal con un experimento: a quién le cambia la decisión la campaña y qué correo enviarle.
Resumen ejecutivo
- Experimento válido: el mayor desbalance entre grupos es |SMD| = 0.015 (< 0.1). Efecto promedio del correo sobre la visita: +6.1 pp (IC 95 % 5.2–6.9).
- El correo para hombres rinde más: +7.7 pp de visita y +$0.77 de gasto por cliente vs. +4.5 pp y +$0.42 del de mujeres.
- CV 5-fold del Qini: S-learner 36, T-learner 31, Transformed outcome 30, Propensión 25. S-learner es el mejor pero con mucha varianza entre folds (±21): el uplift individual es una señal débil; en prueba, el top 30 % tiene efecto +8.8 pp (IC 7.1–10.5); Spearman predicho vs. observado por decil = 0.76.
- Cambio de enfoque, de "¿contacto o no?" a "¿qué correo a quién?": la política personalizada logra 18.7 % de visita (IPW) vs. 18.7 % enviando a todos el de hombres y 10.6 % sin correo. Conclusión: personalizar no supera a la mejor regla simple; recomendación = correo de hombres a todos.
Resultados
Curva Qini en prueba: visitas incrementales acumuladas
- S-learner
- T-learner
- Propensión
- Al azar
Qini por learner (CV 5-fold, media)
Desviación por fold en la tabla de validación
Uplift observado por decil de uplift predicho
Puntos porcentuales de visita: tratados − control
Efecto observado por segmento (pp de visita)
Diferencia de medias dentro del segmento; IC en la tabla
Valor de la política: % de visita esperado (IPW)
Política del modelo asigna: Mens E-Mail 100%
Balance de la aleatorización (|SMD| < 0.1 = balanceado)
Metodología y validación
Meta-learners de uplift (S, T y transformed outcome) con LightGBM poco profundo; política de tres brazos con S-learner multi-tratamiento.
✔ Cómo se validó
- Chequeo de aleatorización con diferencias de medias estandarizadas.
- Qini con validación cruzada estratificada (tratamiento × respuesta) de 5 folds.
- Calibración del uplift por decil y correlación de Spearman.
- Valor de políticas fuera de muestra con ponderación por probabilidad inversa (IPW).
⚙ Supuestos
- Aleatorización correcta (verificada) y sin interferencia entre clientes (SUTVA).
- La visita es un buen proxy de valor; el gasto es muy escaso (≈1 % convierte).
⚠ Limitaciones
- El uplift individual es ruidoso: las decisiones se toman por segmentos o deciles, no por cliente.
- Experimento de 2008 de un minorista; el patrón se debe revalidar con un piloto propio.
🧬 Enfoque alternativo · Algoritmo genético (torneo + cruce uniforme + elitismo)
Reglas de segmentación evolucionadas con un algoritmo genético
¿Por qué otro enfoque? El uplift individual del proyecto original era ruidoso y difícil de explicar. Un genético puede buscar directamente en el espacio de reglas legibles y optimizar el límite inferior del efecto.
- Mejor regla evolucionada: "channel ≠ Multichannel y history > 30 y recency ≤ 4" — cubre 32 % de los clientes con efecto de +7.8 pp en prueba (IC 6.0–9.6) vs. +7.7 pp enviando a todos y +7.6 pp en el resto.
- Contra el S-learner con la misma cobertura (+8.6 pp, IC 6.8–10.5): el modelo de caja negra rinde más; la regla cambia poder por explicabilidad.
- Sobreajuste: el efecto cae de 10.5 pp en entrenamiento a 7.8 pp en prueba en promedio; aun optimizando el límite inferior del IC, el genético encuentra parte de ruido. Por eso la validación en una mitad que el algoritmo nunca vio es obligatoria.
- Conclusión: un método independiente confirma lo del proyecto #3: el efecto del correo es casi homogéneo y ninguna regla supera de forma clara a enviar a todos.
Evolución del fitness (límite inferior del efecto, pp)
- Mejor individuo
- Promedio de la población
Efecto del correo en el grupo elegido (prueba, pp)
Reglas encontradas (evaluadas en la mitad de prueba)
| Regla | Cobertura % | Efecto entrenamiento (pp) | Efecto prueba (pp) | IC 95 % prueba | Efecto en el resto (pp) |
|---|---|---|---|---|---|
| channel ≠ Multichannel y history > 30 y recency ≤ 4 | 32.1 | 10.55 | 7.77 | 6.0–9.6 | 7.56 |
| history > 208.4 y womens = 1 y channel ≠ Multichannel | 17.8 | 10.9 | 8.29 | 5.8–10.7 | 7.5 |
| recency ≤ 4 y channel ≠ Multichannel | 36.1 | 9.95 | 7.37 | 5.7–9.0 | 7.79 |
✔ Cómo se validó
- Evolución en la mitad de entrenamiento; efecto, IC y comparación en la mitad de prueba.
- Fitness robusto: límite inferior del IC 95 %, cobertura mínima de 15 %.
- Comparación con el S-learner a la misma cobertura.
⚙ Supuestos
- El efecto en el grupo es causal porque el experimento está aleatorizado (validado en #3).
⚠ Limitaciones
- Con efectos heterogéneos débiles, cualquier segmentación gana poco sobre enviar a todos.
"""Alternativa #3 — Reglas de segmentación evolucionadas con un algoritmo genético.
Problema que resuelve: en el proyecto #3 el uplift individual resultó ruidoso y la política personalizada
no superó a la regla simple. En lugar de un score por cliente, el algoritmo genético busca una REGLA legible
("si recencia ≤ X y canal = Web…") cuyo grupo tenga el mayor efecto del correo.
Cromosoma: 3 condiciones (activa, variable, umbral/categoría, dirección) codificadas en genes reales [0, 1].
Fitness: límite inferior del IC 95 % del efecto en el grupo (no el efecto puntual), exigiendo al menos 15 %
de cobertura. Usar el límite inferior castiga a los grupos chicos y ruidosos: es selección robusta.
"""
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from bioinspirados import algoritmo_genetico
from common import SEED, bars, export_alternativa, line, table
from p03_uplift import PARAMS, dif_ic, load
from validacion import model_card
NUMERICAS = ["recency", "history"]
BINARIAS = ["mens", "womens", "newbie"]
CATEGORICAS = {"zip_code": ["Rural", "Surburban", "Urban"], "channel": ["Multichannel", "Phone", "Web"]}
VARIABLES = NUMERICAS + BINARIAS + list(CATEGORICAS)
SLOTS, GENES_SLOT = 3, 4
COBERTURA_MIN = 0.15
def decodificar(ind: np.ndarray, X: pd.DataFrame, cuantiles: dict):
"""Convierte el cromosoma en (máscara de clientes, texto de la regla)."""
mascara = np.ones(len(X), dtype=bool)
partes = []
for k in range(SLOTS):
activa, var, val, direc = ind[k * GENES_SLOT:(k + 1) * GENES_SLOT]
if activa < 0.5:
continue
v = VARIABLES[min(int(var * len(VARIABLES)), len(VARIABLES) - 1)]
if v in NUMERICAS:
u = cuantiles[v][min(int(val * 9), 8)]
if direc < 0.5:
mascara &= X[v].values <= u
partes.append(f"{v} ≤ {u:g}")
else:
mascara &= X[v].values > u
partes.append(f"{v} > {u:g}")
elif v in BINARIAS:
b = int(direc >= 0.5)
mascara &= X[v].values == b
partes.append(f"{v} = {b}")
else:
cats = CATEGORICAS[v]
c = cats[min(int(val * len(cats)), len(cats) - 1)]
mascara &= (X[v].values == c) if direc < 0.5 else (X[v].values != c)
partes.append(f"{v} {'=' if direc < 0.5 else '≠'} {c}")
return mascara, " y ".join(partes) or "todos los clientes"
def run():
df = load()
df = df[df.segment.isin(["Mens E-Mail", "No E-Mail"])].reset_index(drop=True) # el mejor brazo del #3
t = (df.segment == "Mens E-Mail").astype(int).values
y = df.visit.values
X = df[VARIABLES]
X_tr, X_te, y_tr, y_te, t_tr, t_te = train_test_split(X, y, t, test_size=0.5, stratify=t * 2 + y,
random_state=SEED)
cuantiles = {v: np.quantile(X_tr[v], np.linspace(0.1, 0.9, 9)).round(1) for v in NUMERICAS}
efecto_global = dif_ic(y_tr, t_tr)[0]
def fitness(ind):
m, _ = decodificar(ind, X_tr, cuantiles)
if m.mean() < COBERTURA_MIN or t_tr[m].min() == t_tr[m].max():
return -1.0
_, lo, _ = dif_ic(y_tr[m], t_tr[m])
return float(lo)
# Varias corridas con distinta semilla → reglas alternativas (nichos)
corridas = [algoritmo_genetico(fitness, SLOTS * GENES_SLOT, poblacion=60, generaciones=40, p_mut=0.15, seed=s)
for s in (SEED, 11, 23, 37)]
filas, vistas = [], set()
for ind, fit, _ in sorted(corridas, key=lambda r: -r[1]):
m_tr, texto = decodificar(ind, X_tr, cuantiles)
if texto in vistas:
continue
vistas.add(texto)
m_te, _ = decodificar(ind, X_te, cuantiles)
e_tr = dif_ic(y_tr[m_tr], t_tr[m_tr])
e_te, lo, hi = dif_ic(y_te[m_te], t_te[m_te])
e_resto = dif_ic(y_te[~m_te], t_te[~m_te])[0]
filas.append({"Regla": texto, "Cobertura %": round(m_te.mean() * 100, 1),
"Efecto entrenamiento (pp)": round(e_tr[0] * 100, 2),
"Efecto prueba (pp)": round(e_te * 100, 2), "IC 95 % prueba": f"{lo * 100:.1f}–{hi * 100:.1f}",
"Efecto en el resto (pp)": round(e_resto * 100, 2)})
reglas = pd.DataFrame(filas)
mejor = reglas.iloc[0]
hist = max(corridas, key=lambda r: r[1])[2]
ef_global_te, g_lo, g_hi = dif_ic(y_te, t_te)
# Comparación justa: S-learner (del #3) eligiendo la misma cobertura
from lightgbm import LGBMClassifier
Xs_tr = pd.get_dummies(X_tr, dtype=float)
Xs_te = pd.get_dummies(X_te, dtype=float).reindex(columns=Xs_tr.columns, fill_value=0)
ms = LGBMClassifier(**PARAMS).fit(Xs_tr.assign(t=t_tr), y_tr)
u = ms.predict_proba(Xs_te.assign(t=1))[:, 1] - ms.predict_proba(Xs_te.assign(t=0))[:, 1]
top = u >= np.quantile(u, 1 - mejor["Cobertura %"] / 100)
e_s, s_lo, s_hi = dif_ic(y_te[top], t_te[top])
comp = {"Enviar a todos": ef_global_te * 100, "S-learner (misma cobertura)": e_s * 100,
"Regla genética": mejor["Efecto prueba (pp)"]}
gana = mejor["Efecto prueba (pp)"] >= e_s * 100 - 0.3
result = {
"titulo": "Reglas de segmentación evolucionadas con un algoritmo genético",
"algoritmo": "Algoritmo genético (torneo + cruce uniforme + elitismo)",
"veredicto": f"Regla legible con +{mejor['Efecto prueba (pp)']:.1f} pp vs. +{ef_global_te * 100:.1f} pp a todos; "
"confirma que el efecto es casi homogéneo.",
"por_que": "El uplift individual del proyecto original era ruidoso y difícil de explicar. Un genético puede "
"buscar directamente en el espacio de reglas legibles y optimizar el límite inferior del efecto.",
"summary": [
f"Mejor regla evolucionada: \"{mejor['Regla']}\" — cubre {mejor['Cobertura %']:.0f} % de los clientes con "
f"efecto de +{mejor['Efecto prueba (pp)']:.1f} pp en prueba (IC {mejor['IC 95 % prueba']}) vs. "
f"+{ef_global_te * 100:.1f} pp enviando a todos y +{mejor['Efecto en el resto (pp)']:.1f} pp en el resto.",
f"Contra el S-learner con la misma cobertura (+{e_s * 100:.1f} pp, IC {s_lo * 100:.1f}–{s_hi * 100:.1f}): "
+ ("la regla rinde igual o mejor y además se puede escribir en una línea para el equipo de campañas."
if gana else "el modelo de caja negra rinde más; la regla cambia poder por explicabilidad."),
f"Sobreajuste: el efecto cae de {reglas['Efecto entrenamiento (pp)'].mean():.1f} pp en entrenamiento a "
f"{reglas['Efecto prueba (pp)'].mean():.1f} pp en prueba en promedio; aun optimizando el límite inferior del IC, "
"el genético encuentra parte de ruido. Por eso la validación en una mitad que el algoritmo nunca vio es obligatoria.",
"Conclusión: un método independiente confirma lo del proyecto #3: el efecto del correo es casi homogéneo y "
+ ("ninguna regla supera de forma clara a enviar a todos." if mejor["Efecto prueba (pp)"] - ef_global_te * 100 < 1
else "sí hay un segmento que vale la pena priorizar."),
],
"charts": [
line("conv", "Evolución del fitness (límite inferior del efecto, pp)", {
"Mejor individuo": [[a, round(b * 100, 3)] for a, b, _ in hist],
"Promedio de la población": [[a, round(max(c, -0.02) * 100, 3)] for a, _, c in hist]},
x_label="Generación", y_label="pp", fmt="dec"),
bars("comp", "Efecto del correo en el grupo elegido (prueba, pp)", list(comp),
[round(v, 2) for v in comp.values()], fmt="pp"),
],
"tables": [table("Reglas encontradas (evaluadas en la mitad de prueba)", reglas)],
"card": model_card(
"Algoritmo genético con cromosoma de 3 condiciones; brazo 'correo de hombres' vs. sin correo; visita como objetivo.",
["Evolución en la mitad de entrenamiento; efecto, IC y comparación en la mitad de prueba.",
"Fitness robusto: límite inferior del IC 95 %, cobertura mínima de 15 %.",
"Comparación con el S-learner a la misma cobertura."],
["El efecto en el grupo es causal porque el experimento está aleatorizado (validado en #3)."],
["Con efectos heterogéneos débiles, cualquier segmentación gana poco sobre enviar a todos."]),
}
export_alternativa("p03-uplift", result)
return result
if __name__ == "__main__":
run()
Efectos por brazo del experimento
| Brazo vs. sin correo | Visita (IC 95 %) | Conversión (IC 95 %) | Gasto por cliente |
|---|---|---|---|
| Mens E-Mail | +7.66 pp [7.00, 8.32] | +0.68 pp [0.50, 0.86] | $+0.77 |
| Womens E-Mail | +4.52 pp [3.89, 5.16] | +0.31 pp [0.15, 0.47] | $+0.42 |
Validación cruzada del Qini por learner
| Learner | Qini (media ± desv.) |
|---|---|
| S-learner | 36.3 ± 20.9 |
| T-learner | 31.4 ± 11.9 |
| Transformed outcome | 30.1 ± 10.5 |
| Propensión | 25.1 ± 15.2 |
Efecto por segmento con IC 95 %
| Segmento | Clientes | Efecto (pp) | IC 2.5 % | IC 97.5 % |
|---|---|---|---|---|
| Historial: 5) $500 - $750 | 4,911 | 8.22 | 6.2 | 10.25 |
| Historial: 7) $1,000 + | 1,308 | 7.63 | 3.08 | 12.18 |
| Canal: Multichannel | 7,762 | 6.5 | 4.82 | 8.18 |
| Historial: 4) $350 - $500 | 6,409 | 6.43 | 4.47 | 8.38 |
| Zona: Urban | 25,661 | 6.33 | 5.5 | 7.16 |
| Cliente nuevo: 1 | 32,144 | 6.28 | 5.59 | 6.97 |
| Zona: Surburban | 28,776 | 6.15 | 5.36 | 6.94 |
| Canal: Web | 28,217 | 6.07 | 5.22 | 6.93 |
| Historial: 3) $200 - $350 | 12,289 | 6.01 | 4.68 | 7.33 |
| Canal: Phone | 28,021 | 5.99 | 5.23 | 6.76 |
| Cliente nuevo: 0 | 31,856 | 5.9 | 5.06 | 6.73 |
| Historial: 6) $750 - $1,000 | 1,859 | 5.83 | 2.19 | 9.47 |
Calibración del uplift por decil
| Decil | Uplift predicho (pp) | Uplift observado (pp) |
|---|---|---|
| D1 | 9.82 | 11.95 |
| D2 | 7 | 7.89 |
| D3 | 6.53 | 6.95 |
| D4 | 5.97 | 6.69 |
| D5 | 5.08 | 7.6 |
| D6 | 4.61 | 4.76 |
| D7 | 4.28 | 2.53 |
| D8 | 4 | 3.1 |
| D9 | 3.56 | 5.1 |
| D10 | 2.97 | 5.12 |
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).
"""#3 Uplift modeling: ¿a quién vale la pena contactar?
Datos: Kevin Hillstrom MineThatData E-Mail Analytics (64,000 clientes; tercio sin correo = control).
Se estima el efecto incremental del correo sobre la visita al sitio con un S-learner / T-learner y se evalúa
con la curva Qini, comparándolo con el enfoque tradicional de "propensión".
"""
import numpy as np
import pandas as pd
from lightgbm import LGBMClassifier, LGBMRegressor
from sklearn.model_selection import StratifiedKFold, train_test_split
from common import SEED, bars, download, export, line, table
from validacion import model_card
URL = "https://hillstorm1.s3.us-east-2.amazonaws.com/hillstorm_no_indices.csv.gz"
def load() -> pd.DataFrame:
df = pd.read_csv(download(URL, "hillstrom.csv.gz"))
df["tratamiento"] = (df["segment"] != "No E-Mail").astype(int)
for c in ["history_segment", "zip_code", "channel"]:
df[c] = df[c].astype("category")
return df
def qini_curve(y, t, uplift, points=40):
"""Qini: incremento acumulado de conversiones atribuibles al tratamiento al contactar top-k."""
o = np.argsort(-uplift)
y, t = y[o], t[o]
nt, nc = np.cumsum(t), np.cumsum(1 - t)
yt, yc = np.cumsum(y * t), np.cumsum(y * (1 - t))
q = yt - yc * np.divide(nt, nc, out=np.zeros_like(nt, dtype=float), where=nc > 0)
idx = np.linspace(0, len(y) - 1, points).astype(int)
pts = [[0.0, 0.0]] + [[round((i + 1) / len(y) * 100, 1), round(float(q[i]), 1)] for i in idx]
return pts, float(q[-1])
def qini_coef(curve, total):
"""Área entre la curva y la diagonal aleatoria, normalizada por el número de puntos."""
xs = np.array([c[0] for c in curve]) / 100
ys = np.array([c[1] for c in curve])
return float(np.trapezoid(ys - xs * total, xs))
FEATS = ["recency", "history_segment", "history", "mens", "womens", "zip_code", "newbie", "channel"]
# Árboles poco profundos y hojas grandes: el efecto incremental es una señal débil y ruidosa
PARAMS = dict(n_estimators=200, learning_rate=0.03, num_leaves=4, min_child_samples=500,
random_state=SEED, verbose=-1)
def learners(X_tr, y_tr, t_tr, X_te) -> dict[str, np.ndarray]:
"""Scores de uplift de varios meta-learners (y la propensión como referencia)."""
m_t = LGBMClassifier(**PARAMS).fit(X_tr[t_tr == 1], y_tr[t_tr == 1])
m_c = LGBMClassifier(**PARAMS).fit(X_tr[t_tr == 0], y_tr[t_tr == 0])
m_s = LGBMClassifier(**PARAMS).fit(X_tr.assign(t=t_tr), y_tr)
# Transformed outcome: Z = Y·(T/e − (1−T)/(1−e)) tiene esperanza igual al uplift
e = t_tr.mean()
z = y_tr * (t_tr / e - (1 - t_tr) / (1 - e))
m_to = LGBMRegressor(**PARAMS).fit(X_tr, z)
return {
"S-learner": m_s.predict_proba(X_te.assign(t=1))[:, 1] - m_s.predict_proba(X_te.assign(t=0))[:, 1],
"T-learner": m_t.predict_proba(X_te)[:, 1] - m_c.predict_proba(X_te)[:, 1],
"Transformed outcome": m_to.predict(X_te),
"Propensión": m_t.predict_proba(X_te)[:, 1],
}
def smd(df: pd.DataFrame, t: np.ndarray) -> pd.Series:
"""Diferencia de medias estandarizada tratados vs. control (balance de la aleatorización)."""
X = pd.get_dummies(df, dtype=float)
a, b = X[t == 1], X[t == 0]
return ((a.mean() - b.mean()) / np.sqrt((a.var() + b.var()) / 2)).fillna(0)
def dif_ic(y, t):
"""Diferencia de proporciones tratados − control con IC 95 % (aproximación normal)."""
p1, p0 = y[t == 1].mean(), y[t == 0].mean()
se = np.sqrt(p1 * (1 - p1) / (t == 1).sum() + p0 * (1 - p0) / (t == 0).sum())
return p1 - p0, p1 - p0 - 1.96 * se, p1 - p0 + 1.96 * se
def run():
df = load()
y, t = df["visit"].values, df["tratamiento"].values
# 1) Validación del experimento: ¿la aleatorización balanceó las covariables?
balance = smd(df[FEATS], t)
max_smd = float(balance.abs().max())
# 2) Efectos promedio por brazo con IC 95 % (visita, conversión y gasto)
brazos = {}
for brazo in ["Mens E-Mail", "Womens E-Mail"]:
m = df["segment"].isin([brazo, "No E-Mail"]).values
tt = (df["segment"].values[m] == brazo).astype(int)
brazos[brazo] = {k: dif_ic(df[k].values[m], tt) for k in ["visit", "conversion"]}
g = df["spend"].values[m]
brazos[brazo]["spend"] = (g[tt == 1].mean() - g[tt == 0].mean(),)
# 3) Validación cruzada 5-fold de los learners (Qini en cada fold de validación)
X = df[FEATS]
cv = []
for k, (tr, va) in enumerate(StratifiedKFold(5, shuffle=True, random_state=SEED).split(X, t * 2 + y)):
sc = learners(X.iloc[tr], y[tr], t[tr], X.iloc[va])
for nombre, u in sc.items():
curva, total = qini_curve(y[va], t[va], u)
cv.append({"learner": nombre, "fold": k + 1, "qini": qini_coef(curva, total)})
cv = pd.DataFrame(cv)
cv_res = cv.groupby("learner", sort=False).qini.agg(["mean", "std"])
# 4) Ajuste final con split 60/40 y curvas en prueba
X_tr, X_te, y_tr, y_te, t_tr, t_te = train_test_split(X, y, t, test_size=0.4, stratify=t * 2 + y,
random_state=SEED)
sc = learners(X_tr, y_tr, t_tr, X_te)
uplift = sc["S-learner"]
rng = np.random.default_rng(SEED)
curvas = {k: qini_curve(y_te, t_te, v)[0] for k, v in sc.items() if k != "Transformed outcome"}
curvas["Al azar"], _ = qini_curve(y_te, t_te, rng.random(len(y_te)))
ate, ate_lo, ate_hi = dif_ic(y_te, t_te)
d = pd.DataFrame({"y": y_te, "t": t_te, "u": uplift})
d["decil"] = pd.qcut(-d["u"].rank(method="first"), 10, labels=[f"D{i}" for i in range(1, 11)])
dec = d.groupby("decil", observed=True).apply(
lambda g: pd.Series({"pred": g.u.mean() * 100, "obs": (g.y[g.t == 1].mean() - g.y[g.t == 0].mean()) * 100}),
include_groups=False).reset_index()
top30 = d.nlargest(int(len(d) * 0.3), "u")
up_top30, lo30, hi30 = dif_ic(top30.y.values, top30.t.values)
# Correlación de rangos entre uplift predicho y observado por decil (¿el orden se sostiene?)
rho = float(dec[["pred", "obs"]].corr(method="spearman").iloc[0, 1])
# 5) Explicabilidad honesta: efecto observado (con IC) por segmento, sin modelo de por medio
seg_rows = []
for col, nombre in [("history_segment", "Historial"), ("channel", "Canal"), ("zip_code", "Zona"),
("newbie", "Cliente nuevo")]:
for v, g in df.groupby(col, observed=True):
e, lo, hi = dif_ic(g.visit.values, g.tratamiento.values)
seg_rows.append([f"{nombre}: {v}", len(g), round(e * 100, 2), round(lo * 100, 2), round(hi * 100, 2)])
seg = pd.DataFrame(seg_rows, columns=["Segmento", "Clientes", "Efecto (pp)", "IC 2.5 %", "IC 97.5 %"])
seg = seg.sort_values("Efecto (pp)", ascending=False)
# 6) Cambio de enfoque: política de 3 brazos (¿correo de hombres, de mujeres o ninguno?)
# S-learner multi-tratamiento y valor de la política estimado con IPW en el set de prueba.
arm_tr = df.loc[X_tr.index, "segment"].values
arm_te = df.loc[X_te.index, "segment"].values
ms = LGBMClassifier(**PARAMS).fit(X_tr.assign(arm=pd.Categorical(arm_tr)), y_tr)
cats = pd.Categorical(arm_tr).categories
pred_arm = np.column_stack([ms.predict_proba(X_te.assign(arm=pd.Categorical([a] * len(X_te), categories=cats)))[:, 1]
for a in cats])
politica = cats[np.argmax(pred_arm, axis=1)]
prob = pd.Series(arm_tr).value_counts(normalize=True)
def valor(asignacion): # IPW: promedio de y en quienes recibieron lo que la política indica
m = arm_te == np.asarray(asignacion)
return float((y_te * m / prob.reindex(arm_te).values).mean() * 100)
politicas = {"Nadie": valor(["No E-Mail"] * len(y_te)), "Todos: correo hombres": valor(["Mens E-Mail"] * len(y_te)),
"Todos: correo mujeres": valor(["Womens E-Mail"] * len(y_te)), "Política del modelo": valor(politica)}
mix = pd.Series(politica).value_counts(normalize=True)
cv_tbl = cv_res.reset_index()
cv_tbl["Qini (media ± desv.)"] = cv_tbl.apply(lambda r: f"{r['mean']:.1f} ± {r['std']:.1f}", axis=1)
cv_tbl = cv_tbl[["learner", "Qini (media ± desv.)"]].rename(columns={"learner": "Learner"})
brazo_tbl = pd.DataFrame([[b, f"{v['visit'][0] * 100:+.2f} pp [{v['visit'][1] * 100:.2f}, {v['visit'][2] * 100:.2f}]",
f"{v['conversion'][0] * 100:+.2f} pp [{v['conversion'][1] * 100:.2f}, {v['conversion'][2] * 100:.2f}]",
f"${v['spend'][0]:+.2f}"] for b, v in brazos.items()],
columns=["Brazo vs. sin correo", "Visita (IC 95 %)", "Conversión (IC 95 %)", "Gasto por cliente"])
dec_tbl = dec.round(2)
dec_tbl.columns = ["Decil", "Uplift predicho (pp)", "Uplift observado (pp)"]
mejor = cv_res["mean"].idxmax()
result = {
"title": "Uplift modeling: ¿a quién contactar?",
"summary": [
f"Experimento válido: el mayor desbalance entre grupos es |SMD| = {max_smd:.3f} (< 0.1). Efecto promedio "
f"del correo sobre la visita: +{ate * 100:.1f} pp (IC 95 % {ate_lo * 100:.1f}–{ate_hi * 100:.1f}).",
f"El correo para hombres rinde más: +{brazos['Mens E-Mail']['visit'][0] * 100:.1f} pp de visita y "
f"+${brazos['Mens E-Mail']['spend'][0]:.2f} de gasto por cliente vs. +{brazos['Womens E-Mail']['visit'][0] * 100:.1f} pp "
f"y +${brazos['Womens E-Mail']['spend'][0]:.2f} del de mujeres.",
f"CV 5-fold del Qini: {', '.join(f'{k} {v:.0f}' for k, v in cv_res['mean'].items())}. {mejor} es el mejor "
f"pero con mucha varianza entre folds (±{cv_res.loc[mejor, 'std']:.0f}): el uplift individual es una señal débil; "
f"en prueba, el top 30 % tiene efecto +{up_top30 * 100:.1f} pp (IC {lo30 * 100:.1f}–{hi30 * 100:.1f}); "
f"Spearman predicho vs. observado por decil = {rho:.2f}.",
f"Cambio de enfoque, de \"¿contacto o no?\" a \"¿qué correo a quién?\": la política personalizada logra "
f"{politicas['Política del modelo']:.1f} % de visita (IPW) vs. {politicas['Todos: correo hombres']:.1f} % enviando "
f"a todos el de hombres y {politicas['Nadie']:.1f} % sin correo. "
+ ("Conclusión: personalizar no supera a la mejor regla simple; recomendación = correo de hombres a todos."
if politicas['Política del modelo'] - politicas['Todos: correo hombres'] < 0.3
else "La personalización agrega valor sobre la mejor regla simple."),
],
"metrics": [
{"label": "Efecto promedio (IC 95 %)", "value": f"+{ate * 100:.1f} pp [{ate_lo * 100:.1f}–{ate_hi * 100:.1f}]"},
{"label": "Efecto top 30 %", "value": f"+{up_top30 * 100:.1f} pp"},
{"label": f"Qini CV {mejor}", "value": f"{cv_res.loc[mejor, 'mean']:.0f} ± {cv_res.loc[mejor, 'std']:.0f}"},
{"label": "|SMD| máximo", "value": f"{max_smd:.3f}"},
],
"charts": [
line("qini", "Curva Qini en prueba: visitas incrementales acumuladas", curvas,
x_label="% de clientes contactados (ordenados por score)", y_label="Visitas incrementales"),
bars("cvq", "Qini por learner (CV 5-fold, media)", cv_res.index, cv_res["mean"].round(1).values,
fmt="num", subtitle="Desviación por fold en la tabla de validación"),
bars("dec", "Uplift observado por decil de uplift predicho", dec["decil"], dec["obs"].round(2),
fmt="pp", subtitle="Puntos porcentuales de visita: tratados − control"),
bars("segm", "Efecto observado por segmento (pp de visita)", seg["Segmento"], seg["Efecto (pp)"].values,
fmt="pp", subtitle="Diferencia de medias dentro del segmento; IC en la tabla"),
bars("pol", "Valor de la política: % de visita esperado (IPW)", list(politicas), [round(v, 2) for v in politicas.values()],
fmt="pct", subtitle="Política del modelo asigna: " + ", ".join(f"{k} {v:.0%}" for k, v in mix.items())),
bars("smd", "Balance de la aleatorización (|SMD| < 0.1 = balanceado)", balance.index,
balance.round(4).values, fmt="dec"),
],
"tables": [table("Efectos por brazo del experimento", brazo_tbl),
table("Validación cruzada del Qini por learner", cv_tbl),
table("Efecto por segmento con IC 95 %", seg),
table("Calibración del uplift por decil", dec_tbl)],
"card": model_card(
"Meta-learners de uplift (S, T y transformed outcome) con LightGBM poco profundo; política de "
"tres brazos con S-learner multi-tratamiento.",
["Chequeo de aleatorización con diferencias de medias estandarizadas.",
"Qini con validación cruzada estratificada (tratamiento × respuesta) de 5 folds.",
"Calibración del uplift por decil y correlación de Spearman.",
"Valor de políticas fuera de muestra con ponderación por probabilidad inversa (IPW)."],
["Aleatorización correcta (verificada) y sin interferencia entre clientes (SUTVA).",
"La visita es un buen proxy de valor; el gasto es muy escaso (≈1 % convierte)."],
["El uplift individual es ruidoso: las decisiones se toman por segmentos o deciles, no por cliente.",
"Experimento de 2008 de un minorista; el patrón se debe revalidar con un piloto propio."]),
}
sample = pd.DataFrame({"uplift": uplift.round(4), "visita": y_te, "tratamiento": t_te})
export("p03-uplift", result, sample.sample(5000, random_state=1))
return result
if __name__ == "__main__":
run()