Predicción vs. causalidad: lo que un modelo puede ver y lo que no
Una empresa de comercio electrónico muestra anuncios personalizados a sus usuarios y quiere saber si esos anuncios aumentan las conversiones (compras). Tiene datos de millones de usuarios: qué anuncios vio cada uno, si compró, y un conjunto de características observables.
La pregunta parece perfecta para un modelo de machine learning. Pero antes de entrenar nada, necesitamos entender qué puede y qué no puede responder un modelo predictivo — y por qué la teoría económica es imprescindible para plantear bien el problema.
El modelo estructural que queremos estimar es:
$Y_i = 1$ si el usuario $i$ compra · $D_i = 1$ si recibe el anuncio · $U_i$ = intención de compra latente y otras características no observadas
El problema: los algoritmos de targeting no muestran anuncios al azar. Muestran el anuncio a los usuarios con mayor probabilidad de comprar — precisamente los que tienen mayor $U_i$.
El algoritmo de targeting usa el historial de comportamiento del usuario para decidir a quién mostrar el anuncio. Eso implica que $D_i$ depende de variables que también predicen la compra. Dos ejemplos:
OLS sobreestima el efecto causal. La implicación para el presupuesto es grave: la empresa puede creer que cada euro en publicidad genera un retorno de $X$ cuando el retorno real es mucho menor. Parte de las conversiones observadas habrían ocurrido de todas formas — son ventas que el algoritmo captura pero no genera. Decisiones de inversión basadas en este número inflarán el presupuesto de marketing más allá de lo óptimo.
El error conceptual es una confusión entre dos preguntas que parecen la misma pero son completamente distintas.
La pregunta que responde el modelo:
El modelo aprende a identificar el perfil del comprador: historial de búsquedas, visitas previas, comportamiento en la app... Un 92% de accuracy significa que el modelo distingue muy bien quién compró de quién no, dentro del grupo que recibió el anuncio.
La pregunta que necesita el director:
Estas compras adicionales son las únicas que justifican el gasto en publicidad. Si un usuario iba a comprar de todas formas, mostrarle el anuncio es coste puro — dinero tirado.
El director confunde "el modelo predice bien quién compra después de ver el anuncio" con "el anuncio causa las compras". Son proposiciones independientes. La primera es una afirmación sobre la capacidad predictiva del modelo. La segunda es una afirmación causal sobre el mecanismo.
Para responder la segunda necesitas un contrafactual: ¿qué habría pasado con esos mismos usuarios si no hubieran visto el anuncio? Eso no está en los datos observacionales — y ningún nivel de accuracy lo puede recuperar.
En marketing se llama uplift al incremento causal en la probabilidad de compra atribuible al anuncio. Es el efecto que importa para decidir a quién mostrar el anuncio: no al que va a comprar de todas formas, sino al que cambiará su decisión gracias al anuncio.
$Y_i(d)$ = resultado potencial del usuario $i$ si recibe ($d=1$) o no recibe ($d=0$) el anuncio. El problema fundamental de la inferencia causal: nunca observamos ambos a la vez.
| Tipo | Sin anuncio | Con anuncio | Uplift |
|---|---|---|---|
| Persuadible | No compra | Compra | ? |
| Comprador seguro | Compra | Compra | ? |
| No comprador | No compra | No compra | ? |
| Reactivo negativo | Compra | No compra | ? |
| Tipo | Uplift | ¿Interesa mostrar el anuncio? |
|---|---|---|
| Persuadible | +1 | Sí — el anuncio genera la compra |
| Comprador seguro | 0 | No — compraría de todas formas; el anuncio es coste puro |
| No comprador | 0 | No — el anuncio no cambia nada |
| Reactivo negativo | −1 | No — el anuncio ahuyenta al cliente |
El único usuario al que interesa mostrarle el anuncio es el persuadible. El comprador seguro es especialmente engañoso: aparece como éxito en los datos (recibió el anuncio y compró) pero el anuncio no causó nada.
El targeting convencional maximiza $P(Y=1 \mid X)$: dirige el presupuesto hacia los compradores seguros — usuarios con alta probabilidad de compra independientemente del anuncio. Esto infla las métricas de conversión sin generar ventas adicionales reales.
El uplift modeling maximiza $\hat{\tau}_i$: dirige el presupuesto hacia los persuadibles — los únicos en los que el anuncio tiene efecto causal positivo.
Un A/B test aleatorio: asignar aleatoriamente a los usuarios entre grupo tratado (recibe el anuncio, $Z=1$) y grupo control (no lo recibe, $Z=0$). Con asignación aleatoria:
La diferencia de medias identifica el uplift medio sin sesgo. Es exactamente el mismo mecanismo que el sorteo de plazas en el curso de formación: la aleatorización rompe la correlación entre tratamiento e inobservables, haciendo a los grupos comparables.
Antes de hablar de Random Forest necesitamos entender su unidad básica: el árbol de decisión. La idea es sorprendentemente intuitiva — es lo que hacemos mentalmente cuando tomamos decisiones en secuencia.
Imagina que quieres predecir si un usuario comprará o no en tu tienda online. Podrías razonar así:
Eso es exactamente un árbol de decisión. Cada pregunta es un nodo. Cada rama es una respuesta posible. Cada respuesta final es una hoja — la predicción del modelo. La profundidad del árbol es el número de preguntas encadenadas.
El árbol del ejemplo tiene 3 nodos (las tres preguntas), 4 hojas (los cuatro resultados finales) y una profundidad de 2 — el camino más largo desde la raíz hasta una hoja atraviesa 2 nodos.
En general, un árbol de profundidad $d$ puede tener hasta $2^d$ hojas — el número de regiones distintas en las que divide el espacio de predictores.
En la práctica el overfitting suele ser más peligroso: un modelo simple falla de forma predecible y obvia. Un modelo sobreajustado tiene métricas excelentes en entrenamiento, lo que puede engañar al analista — y luego falla en producción con datos nuevos.
En el ejercicio anterior el árbol ya tenía sus preguntas escritas. Pero ¿cómo decide el algoritmo qué pregunta hacer en cada nodo? Y entre todas las preguntas posibles sobre miles de variables y millones de umbrales posibles, ¿cuál elige?
La idea es simple: el árbol elige siempre la pregunta que mejor separa las clases en ese nodo. En nuestro caso, la pregunta que mejor separa compradores de no compradores. Para medir esa separación usamos el índice de Gini:
$p_k$ = proporción de la clase $k$ en el nodo $t$. Gini = 0 cuando el nodo es puro (una sola clase). Gini = 0.5 cuando las clases están al 50-50 (máxima impureza).
El árbol evalúa todas las preguntas posibles, calcula cuánto reduciría Gini cada una, y elige la que más lo reduce. Hace esto en cada nodo, de arriba a abajo, hasta alcanzar la profundidad máxima o quedarse sin datos en las hojas.
Nodo padre (140 compran, 60 no — $p_1=0.7$, $p_0=0.3$):
Pregunta A:
Pregunta B:
Si el árbol llega a hojas con un solo usuario, ha aprendido a reconocer perfectamente cada individuo de la muestra de entrenamiento — incluyendo sus peculiaridades y su ruido particular. Cuando llega un usuario nuevo, sus características no coincidirán exactamente con ningún individuo que el árbol haya visto. El modelo habrá memorizado en lugar de aprender: es como estudiar los exámenes pasados de memoria sin entender el tema.
No es óptimo globalmente — una pregunta que parece menos útil en el nodo actual puede abrir mejores divisiones en los nodos siguientes. Esta estrategia de tomar la mejor decisión local en cada paso sin mirar al futuro se llama algoritmo greedy (voraz).
Un único árbol profundo predice bien en entrenamiento pero sobreajusta. La solución es construir muchos árboles distintos y promediar sus predicciones. Esto es el bagging (bootstrap aggregating).
Random Forest añade un ingrediente más: en cada nodo de cada árbol, en lugar de evaluar todas las variables posibles, solo evalúa un subconjunto aleatorio de $m = \sqrt{p}$ variables. Esto hace que los árboles sean distintos entre sí — si no, todos elegirían las mismas variables y serían casi idénticos.
Receta de Random Forest:
Para $b = 1, \ldots, B$:
1. Toma una muestra bootstrap de los datos de entrenamiento
2. Entrena un árbol profundo sobre esa muestra
— en cada nodo, evalúa solo $\sqrt{p}$ variables
aleatorias
3. Guarda el árbol $\hat{f}_b$
Predicción final: $\hat{f}_{RF}(x) = \dfrac{1}{B}\displaystyle\sum_{b=1}^B \hat{f}_b(x)$
El resultado es un modelo con el bajo sesgo de los árboles profundos y la baja varianza del promedio — lo mejor de los dos mundos.
búsquedas_recientes
tiene la feature importance más alta, ¿qué significa
eso exactamente? ¿Podemos concluir que las búsquedas
causan las compras?
Con $p = 100$ variables, RF evalúa $\sqrt{100} = 10$ variables por nodo. La razón de usar solo $\sqrt{p}$ es decorrelacionar los árboles: si todos evaluaran las 100 variables, todos elegirían las mismas — las más predictivas — y los $B$ árboles serían casi idénticos. Promediar árboles idénticos no reduce la varianza. Al restringir las variables disponibles en cada nodo, forzamos que distintos árboles usen distintas variables, aumentando la diversidad del ensemble.
Si los $B$ árboles fueran independientes con varianza $\sigma^2$ cada uno:
Pero los árboles no son independientes: aunque usan muestras bootstrap distintas, todos se entrenan con los mismos datos originales. Las variables más predictivas tienden a aparecer en todos los árboles. Por eso la reducción de varianza real es menor que $\sigma^2/B$ — la correlación residual entre árboles limita la ganancia. La aleatoriedad en $\sqrt{p}$ variables por nodo reduce esa correlación.
Feature importance alta de
búsquedas_recientes significa
que esa variable es muy útil para
predecir quién compra
en los datos observados. Nada más.
No podemos concluir causalidad porque la feature importance mide correlación predictiva, no efecto causal. Las búsquedas recientes son un proxy de la intención de compra latente $U_i$ — quien busca el producto ya quería comprarlo. La correlación con la compra es real, pero no es generada por las búsquedas: ambas tienen una causa común que el modelo no observa directamente.
El código simula una campaña de publicidad digital con targeting sesgado. Entrenamos dos modelos sobre los mismos datos: un Random Forest predictivo y un estimador causal basado en un A/B test. El objetivo es ver cómo el primero puede ser excelente prediciendo y completamente ciego al efecto causal.
Lee el código completo antes de responder las preguntas. Localiza en cada función los elementos del modelo teórico del Bloque I.
1 · Librerías y parámetros
import numpy as np import pandas as pd import matplotlib.pyplot as plt import statsmodels.formula.api as smf from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score, roc_auc_score # ── Parámetros del DGP y del modelo ─────────────────────────────────────────── params = { # Muestra "n" : 5000, # usuarios en la campaña "seed" : 42, # Modelo económico "beta" : 0.08, # efecto causal verdadero del anuncio (uplift medio) "gamma" : 1.5, # fuerza del targeting: cuánto usa U el algoritmo "delta_U" : 2.0, # efecto de U sobre la conversión # Features observables del usuario "n_features" : 8, # número de variables observables (X1…X8) "frac_ab" : 0.3, # fracción de usuarios en el A/B test aleatorio # Hiperparámetros de Random Forest "n_estimators": 300, # número de árboles "max_depth" : 6, # profundidad máxima de cada árbol "max_features": "sqrt", # variables consideradas en cada nodo: √p }
2 · Función que genera los datos
def simular_dgp(p): """ Simula una campaña de publicidad digital con dos grupos: - Grupo observacional: el algoritmo de targeting decide quién ve el anuncio - Grupo A/B test: asignación completamente aleatoria (submuestra) Devuelve un DataFrame con todas las variables del DGP. """ np.random.seed(p["seed"]) n = p["n"] # ── Variables observables: características del usuario ─────────────────────── # X1-X4: variables que el algoritmo usa para targeting # X5-X8: variables ruidosas sin relación causal con la compra nombres = [f"X{i}" for i in range(1, p["n_features"] + 1)] X = np.random.normal(0, 1, (n, p["n_features"])) # ── Inobservable: intención de compra latente ───────────────────────────────── # U depende parcialmente de X1 y X2 (que el algoritmo observa) # y de una componente pura no observable U = 0.6 * X[:, 0] + 0.4 * X[:, 1] + np.random.normal(0, 1, n) # ── Asignación del anuncio ──────────────────────────────────────────────────── Z_ab = np.random.binomial(1, 0.5, n) # A/B test: completamente aleatorio score = p["gamma"] * U + np.random.normal(0, 1, n) D_obs = (score > 0).astype(int) # targeting sesgado: depende de U # ── Indicador de grupo ──────────────────────────────────────────────────────── # ab_group=1: usuario en el A/B test (usa Z_ab) # ab_group=0: usuario en el grupo observacional (usa D_obs) ab_group = (np.random.uniform(0, 1, n) < p["frac_ab"]).astype(int) D = np.where(ab_group == 1, Z_ab, D_obs) # tratamiento efectivo # ── Conversión (compra) ─────────────────────────────────────────────────────── Y_star = p["beta"] * D + p["delta_U"] * U + np.random.normal(0, 1, n) Y = (Y_star > 0).astype(int) # ── Clasificación de usuarios por tipo de uplift ────────────────────────────── Y_d0 = (p["delta_U"] * U + np.random.normal(0, 1, n) > 0).astype(int) Y_d1 = (p["beta"] + p["delta_U"] * U + np.random.normal(0, 1, n) > 0).astype(int) tipo_uplift = [] for y0, y1 in zip(Y_d0, Y_d1): if y0 == 0 and y1 == 1: tipo_uplift.append("persuadible") elif y0 == 1 and y1 == 1: tipo_uplift.append("comprador_seguro") elif y0 == 0 and y1 == 0: tipo_uplift.append("no_comprador") else: tipo_uplift.append("reactivo_negativo") # ── Construir DataFrame ─────────────────────────────────────────────────────── df = pd.DataFrame(X, columns=nombres) df["U"] = U df["D"] = D df["Y"] = Y df["Z_ab"] = Z_ab df["ab_group"] = ab_group df["tipo_uplift"] = tipo_uplift return df
3 · Función que entrena los modelos
def entrenar_modelos(df, p): """ Entrena tres modelos sobre los datos: 1. OLS sobre datos observacionales (sesgado) 2. Random Forest predictivo sobre datos observacionales 3. Estimador causal (diferencia de medias) sobre el A/B test Devuelve un diccionario con resultados y el objeto RF entrenado. """ features = [f"X{i}" for i in range(1, p["n_features"] + 1)] resultados = {} # ── 1. OLS sobre datos observacionales ─────────────────────────────────────── df_obs = df[df["ab_group"] == 0].copy() ols = smf.ols("Y ~ D", data=df_obs).fit() resultados["OLS"] = { "estimacion" : round(ols.params["D"], 4), "tipo" : "causal", "datos" : "observacional", } # ── 2. Random Forest predictivo ─────────────────────────────────────────────── # Entrenamos sobre datos observacionales (targeting sesgado) # Features: variables X + indicador de anuncio recibido X_rf = df_obs[features + ["D"]].values y_rf = df_obs["Y"].values X_train, X_test, y_train, y_test = train_test_split( X_rf, y_rf, test_size=0.3, random_state=p["seed"] ) rf = RandomForestClassifier( n_estimators = p["n_estimators"], max_depth = p["max_depth"], max_features = p["max_features"], random_state = p["seed"], ) rf.fit(X_train, y_train) y_pred = rf.predict(X_test) # Efecto "estimado" del anuncio según RF: # diferencia en probabilidad predicha con D=1 vs D=0 X_d1 = df_obs[features].values X_d0 = df_obs[features].values feat_d1 = np.column_stack([X_d1, np.ones(len(df_obs))]) feat_d0 = np.column_stack([X_d0, np.zeros(len(df_obs))]) prob_d1 = rf.predict_proba(feat_d1)[:, 1] prob_d0 = rf.predict_proba(feat_d0)[:, 1] uplift_rf = (prob_d1 - prob_d0).mean() resultados["RF"] = { "estimacion" : round(uplift_rf, 4), "accuracy" : round(accuracy_score(y_test, y_pred), 4), "auc" : round(roc_auc_score(y_test, rf.predict_proba(X_test)[:, 1]), 4), "tipo" : "predictivo", "datos" : "observacional", } # ── 3. Estimador causal: diferencia de medias en el A/B test ───────────────── df_ab = df[df["ab_group"] == 1].copy() medias = df_ab.groupby("Z_ab")["Y"].mean() beta_ab = medias[1] - medias[0] resultados["AB_test"] = { "estimacion" : round(beta_ab, 4), "tipo" : "causal", "datos" : "aleatorio", } return resultados, rf, features
4 · Función que genera los gráficos
def graficar_resultados(df, resultados, rf, features, beta_verdadero): """ Genera tres gráficos: (1) Feature importance del RF: ¿qué variables usa el modelo? (2) Distribución de tipos de usuario (persuadible, comprador seguro...) (3) Comparación de estimadores vs. β verdadero """ fig, axes = plt.subplots(1, 3, figsize=(16, 5)) fig.patch.set_facecolor("#0f1117") nombres_features = features + ["D (anuncio)"] # ── Gráfico 1: Feature importance ──────────────────────────────────────────── ax1 = axes[0] ax1.set_facecolor("#0f1117") importancias = rf.feature_importances_ orden = np.argsort(importancias) colores_fi = [] for nombre in [nombres_features[i] for i in orden]: if nombre == "D (anuncio)": colores_fi.append("#ffd166") # amarillo: la variable que nos interesa elif nombre in ["X1", "X2"]: colores_fi.append("#ff7a6b") # rojo: correladas con U (endógenas) else: colores_fi.append("#4ecdc4") # verde: variables neutras ax1.barh([nombres_features[i] for i in orden], importancias[orden], color=colores_fi, edgecolor="#2a2f45", height=0.6) ax1.set_title("Feature Importance\n(rojo = correladas con U)", color="#dde2f0", fontsize=10) ax1.tick_params(colors="#7a82a0") for spine in ax1.spines.values(): spine.set_edgecolor("#2a2f45") # ── Gráfico 2: Tipos de usuario ─────────────────────────────────────────────── ax2 = axes[1] ax2.set_facecolor("#0f1117") conteo = df["tipo_uplift"].value_counts() colores_tipo = { "persuadible" : "#4ecdc4", "comprador_seguro" : "#6b8cff", "no_comprador" : "#7a82a0", "reactivo_negativo": "#ff7a6b", } bars = ax2.bar( conteo.index, conteo.values / len(df) * 100, color=[colores_tipo.get(t, "#7a82a0") for t in conteo.index], edgecolor="#2a2f45", width=0.6 ) for bar, val in zip(bars, conteo.values): pct = val / len(df) * 100 ax2.text(bar.get_x() + bar.get_width()/2, pct + 0.5, f"{pct:.1f}%", ha="center", color="#dde2f0", fontsize=9) ax2.set_title("Distribución de tipos de usuario\n(% sobre total)", color="#dde2f0", fontsize=10) ax2.tick_params(colors="#7a82a0", axis="y") ax2.tick_params(colors="#dde2f0", axis="x", labelsize=8) ax2.set_ylabel("%", color="#7a82a0") for spine in ax2.spines.values(): spine.set_edgecolor("#2a2f45") # ── Gráfico 3: Comparación de estimadores ──────────────────────────────────── ax3 = axes[2] ax3.set_facecolor("#0f1117") nombres_est = list(resultados.keys()) estimaciones = [resultados[k]["estimacion"] for k in nombres_est] colores_est = [] for k in nombres_est: sesgo = abs(resultados[k]["estimacion"] - beta_verdadero) if sesgo < 0.02: colores_est.append("#4ecdc4") elif sesgo < 0.06: colores_est.append("#ffd166") else: colores_est.append("#ff7a6b") barras = ax3.barh(nombres_est, estimaciones, color=colores_est, height=0.5, edgecolor="#2a2f45") ax3.axvline(beta_verdadero, color="white", linestyle="--", linewidth=1.5, label=f"β verdadero = {beta_verdadero}") for barra, val in zip(barras, estimaciones): ax3.text(val + 0.003, barra.get_y() + barra.get_height()/2, f"{val:.3f}", va="center", color="#dde2f0", fontsize=9) ax3.set_title("Estimadores del efecto causal\nvs. β verdadero", color="#dde2f0", fontsize=10) ax3.tick_params(colors="#7a82a0") ax3.legend(fontsize=9, labelcolor="#dde2f0", facecolor="#1e2235", edgecolor="#2a2f45") for spine in ax3.spines.values(): spine.set_edgecolor("#2a2f45") plt.tight_layout() plt.savefig("resultados_marketing.png", dpi=150, facecolor="#0f1117", bbox_inches="tight") plt.show()
5 · Ejecución del análisis completo
# ── Generar datos ──────────────────────────────────────────────────────────── df = simular_dgp(params) # ── Resumen descriptivo ─────────────────────────────────────────────────────── print("=== Distribución de tipos de usuario ===") conteo_tipos = df["tipo_uplift"].value_counts() for tipo, n in conteo_tipos.items(): print(f" {tipo:20s}: {n:5d} ({n/params['n']*100:.1f}%)") print(f"\nTasa de conversión global : {df['Y'].mean():.3f}") print(f"Fracción en A/B test : {df['ab_group'].mean():.3f}") # ── Entrenar modelos y obtener resultados ───────────────────────────────────── resultados, rf, features = entrenar_modelos(df, params) print("\n=== Resultados de los modelos ===") print(f"{'Modelo':10} {'Estimación':>12} {'Tipo':>14} {'Datos':>15}") print("-" * 55) for nombre, res in resultados.items(): print(f"{nombre:10} {res['estimacion']:>12.4f} {res['tipo']:>14} {res['datos']:>15}") print(f"\nβ verdadero : {params['beta']}") print(f"Accuracy RF (test) : {resultados['RF']['accuracy']:.3f}") print(f"AUC RF (test) : {resultados['RF']['auc']:.3f}") # ── Gráficos ───────────────────────────────────────────────────────────────── graficar_resultados(df, resultados, rf, features, params["beta"])
Responde sin ejecutar el código. Localiza en el código la línea o bloque que justifica cada respuesta.
simular_dgp(), ¿qué línea implementa
el targeting sesgado? ¿Y la asignación aleatoria del A/B test?
¿Qué parámetro de params controla la fuerza
del sesgo de selección?
U se construye como combinación
de X1 y X2 más ruido.
¿Qué consecuencia tiene esto para la feature importance
de X1 y X2 en el Random Forest?
¿Es esa importancia una medida causal o predictiva?
X1 y X2. ¿Por qué?
¿Qué debería observar un analista en ese gráfico que le
hiciera sospechar de la presencia de endogeneidad?
entrenar_modelos() estima el "uplift RF"
como la diferencia en probabilidad predicha entre D=1
y D=0. ¿Por qué esta estimación está sesgada
aunque RF tenga un AUC alto?
¿Qué diferencia hay con el estimador AB_test?
colores_fi en graficar_resultados()
se construye con un bucle for y un condicional
if/elif/else. ¿Cuántos elementos tiene esa lista?
¿Cómo accederías programáticamente al color asignado
a la variable más importante?
Targeting sesgado:
D_obs = (score > 0).astype(int) # score depende de U
Asignación aleatoria del A/B test:
Z_ab = np.random.binomial(1, 0.5, n)
El parámetro que controla la fuerza del sesgo es
params["gamma"]: multiplica a U
en el cálculo de score. A mayor
gamma, más depende la asignación del anuncio
de la intención de compra latente.
Como U = 0.6·X1 + 0.4·X2 + ruido,
las variables X1 y X2
son buenos predictores de U y por tanto
de Y. RF les asignará feature importance alta.
Pero esa importancia es predictiva,
no causal: X1 y X2 predicen
la compra porque están correlacionadas con la intención
latente, no porque causen la compra directamente.
X1 y X2
"causan" las compras y recomendar invertir en aumentarlas.
Pero son proxies de U, no causas.
X1 y X2 se colorean en rojo
porque el código las identifica explícitamente como
variables correlacionadas con U.
En datos reales no sabríamos cuáles son — pero una
señal de alerta sería observar que las variables con
mayor feature importance son precisamente las que
el algoritmo de targeting usa para seleccionar usuarios.
Si las variables que predicen quién recibe el anuncio
también predicen quién compra, hay endogeneidad.
El uplift RF está sesgado porque RF fue entrenado
con datos de targeting sesgado: aprendió que los usuarios
con alta intención de compra ($U_i$ alto) reciben el
anuncio y compran. Al comparar D=1 vs
D=0 con el modelo entrenado, mezcla el
efecto causal del anuncio con la diferencia en $U_i$
entre grupos — exactamente el sesgo de selección.
El estimador AB_test compara grupos
definidos por Z_ab, que es aleatorio
e independiente de U. Por tanto
la comparación es libre de sesgo.
La lista colores_fi tiene tantos elementos
como variables tiene el modelo: n_features + 1 = 9
(las 8 variables X más el indicador D del anuncio).
Se construye iterando sobre las variables en el orden
orden (de menor a mayor importancia).
Para acceder al color de la variable más importante
(la última en orden, ya que está ordenado
de menor a mayor):
colores_fi[-1] # último elemento de la lista
O de forma más explícita:
idx_mas_importante = orden[-1] nombre_mas_importante = nombres_features[idx_mas_importante] color_mas_importante = colores_fi[-1]
En cada experimento modificamos un parámetro del DGP o del modelo y observamos qué ocurre con las métricas predictivas (accuracy, AUC) y con las estimaciones causales (OLS, RF, A/B test).
El patrón que queremos que veas: las métricas predictivas y la calidad causal se mueven en direcciones distintas — a veces opuestas. Predice siempre antes de ejecutar.
Aumentamos gamma de 1.5 a 3.0: el algoritmo
de targeting es mucho más preciso — muestra el anuncio
casi exclusivamente a usuarios con alta intención de compra.
El efecto causal verdadero no cambia: beta = 0.08.
params_exp1 = params.copy() params_exp1["gamma"] = 3.0 df1 = simular_dgp(params_exp1) res1, rf1, feat1 = entrenar_modelos(df1, params_exp1) graficar_resultados(df1, res1, rf1, feat1, params_exp1["beta"]) print(f"OLS : {res1['OLS']['estimacion']:.4f}") print(f"RF uplift : {res1['RF']['estimacion']:.4f}") print(f"A/B test : {res1['AB_test']['estimacion']:.4f}") print(f"AUC RF : {res1['RF']['auc']:.4f}")¿Coincide el resultado con tu predicción? ¿Qué le ocurre a la feature importance de
X1
y X2 respecto al caso base?
Con gamma mayor, el score de targeting
depende más de $U_i$: los usuarios con alta intención
de compra reciben el anuncio con mucha más probabilidad.
Esto aumenta $\Cov(D_i, U_i)$ y por tanto
el sesgo de OLS crece.
Al mismo tiempo, el AUC del RF sube: los datos de entrenamiento ahora tienen una señal más clara — los usuarios que recibieron el anuncio son casi todos compradores (alta $U_i$) y los que no lo recibieron tienen baja $U_i$. RF aprende esa separación con más facilidad.
Al ejecutar observaremos:
La feature importance de X1 y X2
aumenta: como son predictores de $U$ y $U$ determina
casi completamente quién recibe el anuncio y quién compra,
el modelo las usa cada vez más. La variable D
(anuncio) puede tener feature importance baja
a pesar de tener efecto causal real.
La conclusión es incorrecta. El AUC más alto no indica que el algoritmo "haya mejorado" en ningún sentido útil para la empresa — solo indica que los datos de entrenamiento tienen una separación más clara entre grupos, precisamente porque el targeting sesgado ha creado grupos más distintos en $U_i$.
Entrenamos Random Forest con distintas profundidades máximas
(max_depth) y observamos cómo evolucionan
el AUC en test y la estimación del uplift. Mantenemos
el resto de parámetros igual al caso base.
max_depth
de 1 a 15? ¿Habrá un punto óptimo o seguirá subiendo siempre?
profundidades = [1, 2, 3, 4, 6, 8, 10, 15] resultados_depth = [] for depth in profundidades: p_tmp = params.copy() p_tmp["max_depth"] = depth df_tmp = simular_dgp(p_tmp) res_tmp, rf_tmp, _ = entrenar_modelos(df_tmp, p_tmp) resultados_depth.append({ "depth" : depth, "auc" : res_tmp["RF"]["auc"], "uplift_rf": res_tmp["RF"]["estimacion"], "uplift_ab": res_tmp["AB_test"]["estimacion"], }) df_depth = pd.DataFrame(resultados_depth) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4)) fig.patch.set_facecolor("#0f1117") for ax in [ax1, ax2]: ax.set_facecolor("#0f1117") ax.tick_params(colors="#7a82a0") for spine in ax.spines.values(): spine.set_edgecolor("#2a2f45") ax1.plot(df_depth["depth"], df_depth["auc"], color="#6b8cff", marker="o", linewidth=2) ax1.set_title("AUC en test vs. profundidad", color="#dde2f0") ax1.set_xlabel("max_depth", color="#7a82a0") ax2.plot(df_depth["depth"], df_depth["uplift_rf"], color="#ff7a6b", marker="o", linewidth=2, label="RF uplift") ax2.axhline(params["beta"], color="white", linestyle="--", linewidth=1.5, label=f"β verdadero = {params['beta']}") ax2.set_title("Uplift RF vs. profundidad", color="#dde2f0") ax2.set_xlabel("max_depth", color="#7a82a0") ax2.legend(fontsize=9, labelcolor="#dde2f0", facecolor="#1e2235", edgecolor="#2a2f45") plt.tight_layout() plt.show()¿El AUC tiene un punto óptimo o sigue subiendo? ¿Converge el uplift RF al valor verdadero a medida que aumenta la profundidad?
Según el bias-variance tradeoff, esperamos una curva en forma de U invertida para el AUC en test: sube al principio (reducción de sesgo al añadir complejidad) y se estabiliza o cae ligeramente al final (aumento de varianza por sobreajuste). Con Random Forest el efecto de sobreajuste es más suave que con un árbol individual — el promedio de muchos árboles amortigua la varianza — por lo que la caída puede ser pequeña.
Al ejecutar observaremos que el AUC sube con la
profundidad y se estabiliza en torno a
max_depth = 6–8.
El uplift RF, en cambio, no converge
al valor verdadero — fluctúa en torno
a un valor sesgado independientemente de la
profundidad del árbol.
Complejidad del modelo y calidad causal son dimensiones independientes. Un modelo más complejo:
Para decisiones de marketing basadas en uplift, un modelo más complejo sin datos experimentales puede ser peor: aprende con más precisión la correlación espuria y produce estimaciones causales más sesgadas con más confianza.
El A/B test tiene un coste: renunciamos a mostrar el anuncio
a usuarios del grupo control que habrían comprado.
El parámetro frac_ab controla qué fracción
de usuarios se asigna aleatoriamente. Queremos ver cómo
cambia la precisión del estimador causal al variar esa fracción.
AB_test cuando reducimos
frac_ab de 0.3 a 0.05? ¿Y cuando la aumentamos
a 0.8? Razona usando el concepto estadístico de tamaño muestral.
frac_ab. Muestra cómo varía la estimación
del A/B test para cada fracción. Usa la siguiente lista
de fracciones: [0.05, 0.10, 0.20, 0.30, 0.50, 0.70].
El estimador AB_test es una diferencia
de medias sobre $n_{AB} = n \cdot \text{frac\_ab}$
observaciones. Su varianza es aproximadamente:
Con frac_ab = 0.05, el A/B test usa
solo 250 observaciones de 5000 — la varianza se
multiplica por 6 respecto al caso base
(frac_ab = 0.30, 1500 obs).
Las estimaciones fluctuarán mucho entre réplicas.
Con frac_ab = 0.80, la varianza cae
pero el estimador observacional (OLS, RF) solo tiene
1000 observaciones — pierde potencia.
fracciones = [0.05, 0.10, 0.20, 0.30, 0.50, 0.70] estimaciones_ab = [] for frac in fracciones: p_tmp = params.copy() p_tmp["frac_ab"] = frac df_tmp = simular_dgp(p_tmp) res_tmp, _, __ = entrenar_modelos(df_tmp, p_tmp) estimaciones_ab.append(res_tmp["AB_test"]["estimacion"]) fig, ax = plt.subplots(figsize=(8, 4)) fig.patch.set_facecolor("#0f1117") ax.set_facecolor("#0f1117") ax.plot(fracciones, estimaciones_ab, color="#4ecdc4", marker="o", linewidth=2, label="Estimación A/B") ax.axhline(params["beta"], color="white", linestyle="--", linewidth=1.5, label=f"β verdadero = {params['beta']}") ax.set_xlabel("frac_ab", color="#7a82a0") ax.set_title("Estimación causal vs. tamaño del A/B test", color="#dde2f0") ax.tick_params(colors="#7a82a0") ax.legend(fontsize=9, labelcolor="#dde2f0", facecolor="#1e2235", edgecolor="#2a2f45") for spine in ax.spines.values(): spine.set_edgecolor("#2a2f45") plt.tight_layout() plt.show()
El criterio estándar es el análisis de potencia estadística: calcular el tamaño mínimo de muestra necesario para detectar el efecto $\beta$ con una potencia dada (típicamente 80%) a un nivel de significación $\alpha$ (típicamente 5%). La fórmula aproximada para diferencia de medias es:
Para calcularla necesitamos: