Saltar al contenido
← Todos los proyectos

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.

📦 Datos: Hillstrom MineThatData E-Mail S/T-learner y transformed outcomeQini con CVBalance (SMD)Política 3 brazos (IPW) 🧬 Alternativa: Reglas evolucionadas (genético)

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.
Efecto promedio (IC 95 %)+6.1 pp [5.2–6.9]
Efecto top 30 %+8.8 pp
Qini CV S-learner36 ± 21
|SMD| máximo0.015

Resultados

Curva Qini en prueba: visitas incrementales acumuladas

  • S-learner
  • T-learner
  • Propensión
  • Al azar
02505007501 k020406080100% de clientes contactados (ordenados por score)
Visitas incrementales

Qini por learner (CV 5-fold, media)

Desviación por fold en la tabla de validación

S-learner 36
T-learner 31
Transformed outcome 30
Propensión 25

Uplift observado por decil de uplift predicho

Puntos porcentuales de visita: tratados − control

D1 +12 pp
D2 +7.9 pp
D3 +7 pp
D4 +6.7 pp
D5 +7.6 pp
D6 +4.8 pp
D7 +2.5 pp
D8 +3.1 pp
D9 +5.1 pp
D10 +5.1 pp

Efecto observado por segmento (pp de visita)

Diferencia de medias dentro del segmento; IC en la tabla

Historial: 5) $500 - $750 +8.2 pp
Historial: 7) $1,000 + +7.6 pp
Canal: Multichannel +6.5 pp
Historial: 4) $350 - $500 +6.4 pp
Zona: Urban +6.3 pp
Cliente nuevo: 1 +6.3 pp
Zona: Surburban +6.2 pp
Canal: Web +6.1 pp
Historial: 3) $200 - $350 +6 pp
Canal: Phone +6 pp
Cliente nuevo: 0 +5.9 pp
Historial: 6) $750 - $1,000 +5.8 pp
Historial: 1) $0 - $100 +5.8 pp
Historial: 2) $100 - $200 +5.6 pp
Zona: Rural +5.2 pp

Valor de la política: % de visita esperado (IPW)

Política del modelo asigna: Mens E-Mail 100%

Nadie 10.6 %
Todos: correo hombres 18.7 %
Todos: correo mujeres 14.7 %
Política del modelo 18.7 %

Balance de la aleatorización (|SMD| < 0.1 = balanceado)

recency 0.006
history 0.007
mens -0.007
womens 0.006
newbie 0.001
history_segment_1) $0 - $100 0.005
history_segment_2) $100 - $200 -0.015
history_segment_3) $200 - $350 0.008
history_segment_4) $350 - $500 0.002
history_segment_5) $500 - $750 -0.005
history_segment_6) $750 - $1,000 -0.001
history_segment_7) $1,000 + 0.01
zip_code_Rural 0.009
zip_code_Surburban -0.006
zip_code_Urban 0
channel_Multichannel -0.005
channel_Phone 0
channel_Web 0.003

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
0510010203040Generación
pp

Efecto del correo en el grupo elegido (prueba, pp)

Enviar a todos +7.7 pp
S-learner (misma cobertura) +8.6 pp
Regla genética +7.8 pp

Reglas encontradas (evaluadas en la mitad de prueba)

ReglaCobertura %Efecto entrenamiento (pp)Efecto prueba (pp)IC 95 % pruebaEfecto en el resto (pp)
channel ≠ Multichannel y history > 30 y recency ≤ 432.110.557.776.0–9.67.56
history > 208.4 y womens = 1 y channel ≠ Multichannel17.810.98.295.8–10.77.5
recency ≤ 4 y channel ≠ Multichannel36.19.957.375.7–9.07.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.
alt03_uplift_reglas_geneticas.py153 líneas
"""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 correoVisita (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

LearnerQini (media ± desv.)
S-learner36.3 ± 20.9
T-learner31.4 ± 11.9
Transformed outcome30.1 ± 10.5
Propensión25.1 ± 15.2

Efecto por segmento con IC 95 %

SegmentoClientesEfecto (pp)IC 2.5 %IC 97.5 %
Historial: 5) $500 - $7504,9118.226.210.25
Historial: 7) $1,000 +1,3087.633.0812.18
Canal: Multichannel7,7626.54.828.18
Historial: 4) $350 - $5006,4096.434.478.38
Zona: Urban25,6616.335.57.16
Cliente nuevo: 132,1446.285.596.97
Zona: Surburban28,7766.155.366.94
Canal: Web28,2176.075.226.93
Historial: 3) $200 - $35012,2896.014.687.33
Canal: Phone28,0215.995.236.76
Cliente nuevo: 031,8565.95.066.73
Historial: 6) $750 - $1,0001,8595.832.199.47

Calibración del uplift por decil

DecilUplift predicho (pp)Uplift observado (pp)
D19.8211.95
D277.89
D36.536.95
D45.976.69
D55.087.6
D64.614.76
D74.282.53
D843.1
D93.565.1
D102.975.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.

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

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