21. Caso Práctico: A/B Testing
Diseña y analiza un A/B test completo desde cero
Diseña, ejecuta y analiza un experimento A/B completo desde cero. Esta lección integra todo lo aprendido sobre estadística aplicada en un proyecto del mundo real: optimizar la conversión de una landing page.
El proyecto: Optimización de landing page
Trabajas como Data Analyst en una startup SaaS. El equipo de marketing quiere aumentar la conversión de su landing page principal. Han creado dos variantes y necesitas diseñar y analizar un A/B test riguroso.
Objetivo de negocio: Aumentar la tasa de conversión de visitantes a registro (sign-up) del 8% actual a al menos 10%.
Variantes a probar:
- Versión A (Control): Landing page actual con CTA "Empezar ahora"
- Versión B (Tratamiento): Nueva landing page con CTA "Prueba gratis 30 días"
Fase 1: Diseño del experimento
1.1 Definir hipótesis y parámetros
import numpy as np
import pandas as pd
from scipy import stats
import matplotlib.pyplot as plt
import seaborn as sns
# FORMULACIÓN DE HIPÓTESIS
print("=== DISEÑO DEL EXPERIMENTO A/B ===\n")
print("Hipótesis de negocio:")
print(" Agregar 'Prueba gratis 30 días' aumentará la conversión\n")
print("Hipótesis estadística:")
print(" H₀: conversion_rate_A = conversion_rate_B")
print(" H₁: conversion_rate_A ≠ conversion_rate_B\n")
# Parámetros del test
baseline_conversion = 0.08 # Conversión actual: 8%
mde = 0.02 # Minimum Detectable Effect: 2pp
alpha = 0.05 # Nivel de significancia
power = 0.80 # Poder estadístico
print("Parámetros:")
print(f" Conversión baseline: {baseline_conversion*100:.1f}%")
print(f" MDE: {mde*100:.1f} puntos porcentuales")
print(f" Alpha: {alpha}, Power: {power}")
1.2 Calcular tamaño de muestra
from statsmodels.stats.proportion import proportion_effectsize
from statsmodels.stats.power import zt_ind_solve_power
# Calcular tamaño de muestra
effect_size = proportion_effectsize(baseline_conversion, baseline_conversion + mde)
sample_per_group = int(np.ceil(zt_ind_solve_power(
effect_size=effect_size,
alpha=alpha,
power=power,
alternative='two-sided'
)))
print(f"\n=== TAMAÑO DE MUESTRA ===")
print(f"Muestra por grupo: {sample_per_group:,}")
print(f"Total: {sample_per_group * 2:,}")
# Estimar duración
daily_visitors = 500
days_needed = int(np.ceil((sample_per_group * 2) / daily_visitors))
print(f"Duración estimada: {days_needed} días ({days_needed/7:.1f} semanas)")
Fase 2: Simulación y análisis de datos
2.1 Generar datos del experimento
# Simular experimento con datos realistas
np.random.seed(42)
n_per_group = 4000
cr_a = 0.08 # Control: 8%
cr_b = 0.105 # Tratamiento: 10.5%
data_ab = pd.DataFrame({
'user_id': range(1, n_per_group * 2 + 1),
'variant': ['A'] * n_per_group + ['B'] * n_per_group,
'converted': (
np.random.binomial(1, cr_a, n_per_group).tolist() +
np.random.binomial(1, cr_b, n_per_group).tolist()
)
})
# Agregar metadata
data_ab['device'] = np.random.choice(['Desktop', 'Mobile', 'Tablet'],
size=len(data_ab), p=[0.45, 0.45, 0.10])
print("\n=== DATOS DEL EXPERIMENTO ===")
print(data_ab.head())
print(f"\nTotal: {len(data_ab):,} visitantes")
2.2 Análisis descriptivo
# Estadísticas por variante
stats_variant = data_ab.groupby('variant').agg({
'user_id': 'count',
'converted': ['sum', 'mean']
})
stats_variant.columns = ['Visitantes', 'Conversiones', 'CR']
stats_variant['CR_Pct'] = stats_variant['CR'] * 100
print("\n=== RESULTADOS POR VARIANTE ===")
print(stats_variant)
# Diferencia observada
cr_a_obs = stats_variant.loc['A', 'CR']
cr_b_obs = stats_variant.loc['B', 'CR']
diff = cr_b_obs - cr_a_obs
lift = (diff / cr_a_obs) * 100
print(f"\nDiferencia: {diff*100:+.2f}pp")
print(f"Lift: {lift:+.1f}%")
Fase 3: Testing estadístico
3.1 Z-test de proporciones
from statsmodels.stats.proportion import proportions_ztest
# Preparar datos
conversions = np.array([
data_ab[data_ab['variant'] == 'A']['converted'].sum(),
data_ab[data_ab['variant'] == 'B']['converted'].sum()
])
visitors = np.array([
len(data_ab[data_ab['variant'] == 'A']),
len(data_ab[data_ab['variant'] == 'B'])
])
# Test
z_stat, p_value = proportions_ztest(conversions, visitors)
print("\n=== TEST ESTADÍSTICO ===")
print(f"Z-statistic: {z_stat:.4f}")
print(f"P-valor: {p_value:.6f}")
if p_value < alpha:
print(f"\n✅ SIGNIFICATIVO (p < {alpha})")
print("Rechazamos H₀: Las conversiones son diferentes")
else:
print(f"\n❌ NO SIGNIFICATIVO (p ≥ {alpha})")
print("No hay evidencia suficiente de diferencia")
3.2 Intervalos de confianza
from statsmodels.stats.proportion import proportion_confint
# IC al 95%
ci_a_low, ci_a_high = proportion_confint(conversions[0], visitors[0], method='wilson')
ci_b_low, ci_b_high = proportion_confint(conversions[1], visitors[1], method='wilson')
print(f"\n=== INTERVALOS DE CONFIANZA 95% ===")
print(f"Variante A: {ci_a_low*100:.2f}% - {ci_a_high*100:.2f}%")
print(f"Variante B: {ci_b_low*100:.2f}% - {ci_b_high*100:.2f}%")
Fase 4: Visualización
# Gráfico de conversión por variante
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Gráfico 1: Barras de conversion rate
ax1 = axes[0]
variants = ['Variante A', 'Variante B']
rates = [stats_variant.loc['A', 'CR_Pct'], stats_variant.loc['B', 'CR_Pct']]
colors = ['#3498db', '#2ecc71']
bars = ax1.bar(variants, rates, color=colors, edgecolor='black', linewidth=2)
ax1.set_ylabel('Conversion Rate (%)', fontsize=12, fontweight='bold')
ax1.set_title('Conversion Rate por Variante', fontsize=14, fontweight='bold')
ax1.set_ylim(0, max(rates) * 1.3)
for bar, rate in zip(bars, rates):
ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height(),
f'{rate:.2f}%', ha='center', va='bottom', fontsize=14, fontweight='bold')
# Gráfico 2: Funnel
ax2 = axes[1]
x = [0, 1]
width = 0.35
visitors_counts = [stats_variant.loc['A', 'Visitantes'], stats_variant.loc['B', 'Visitantes']]
conversion_counts = [stats_variant.loc['A', 'Conversiones'], stats_variant.loc['B', 'Conversiones']]
ax2.bar([i - width/2 for i in x], visitors_counts, width, label='Visitantes', color='#3498db')
ax2.bar([i + width/2 for i in x], conversion_counts, width, label='Conversiones', color='#2ecc71')
ax2.set_xticks(x)
ax2.set_xticklabels(variants)
ax2.set_ylabel('Cantidad')
ax2.set_title('Funnel de Conversión')
ax2.legend()
plt.tight_layout()
plt.savefig('ab_test_results.png', dpi=300)
plt.show()
Fase 5: Impacto en negocio
# Proyección de impacto
monthly_visitors = 15000
ltv = 1200
additional_conversions = monthly_visitors * diff
monthly_revenue_increase = additional_conversions * ltv
print("\n=== IMPACTO EN NEGOCIO ===")
print(f"Visitantes mensuales: {monthly_visitors:,}")
print(f"Conversiones adicionales/mes: {additional_conversions:.0f}")
print(f"Incremento ingresos mensual: ${monthly_revenue_increase:,.0f}")
print(f"Incremento ingresos anual: ${monthly_revenue_increase * 12:,.0f}")
Fase 6: Reporte ejecutivo
def generar_reporte(data, alpha=0.05):
"""Genera reporte ejecutivo completo"""
print("=" * 60)
print(" REPORTE EJECUTIVO: A/B TEST LANDING PAGE")
print("=" * 60)
# Resultados
stats = data.groupby('variant')['converted'].agg(['sum', 'count', 'mean'])
cr_a = stats.loc['A', 'mean']
cr_b = stats.loc['B', 'mean']
lift = ((cr_b - cr_a) / cr_a) * 100
print(f"\n📊 RESULTADOS")
print(f"Variante A: {cr_a*100:.2f}% conversión")
print(f"Variante B: {cr_b*100:.2f}% conversión")
print(f"Lift: {lift:+.1f}%")
# Test estadístico
conv = np.array([stats.loc['A', 'sum'], stats.loc['B', 'sum']])
vis = np.array([stats.loc['A', 'count'], stats.loc['B', 'count']])
_, p = proportions_ztest(conv, vis)
print(f"\n🔬 SIGNIFICANCIA")
print(f"P-valor: {p:.6f}")
if p < alpha:
print(f"✅ SIGNIFICATIVO (p < {alpha})")
decision = "IMPLEMENTAR VARIANTE B"
else:
print(f"❌ NO SIGNIFICATIVO")
decision = "CONTINUAR TESTING"
# Impacto
monthly_impact = 15000 * (cr_b - cr_a) * 1200
print(f"\n💰 IMPACTO PROYECTADO")
print(f"Ingresos adicionales/año: ${monthly_impact * 12:,.0f}")
print(f"\n🎯 RECOMENDACIÓN: {decision}")
print("=" * 60)
# Generar reporte
generar_reporte(data_ab)
Ejercicio práctico
Ahora es tu turno. Diseña y analiza un A/B test para estas variaciones de email subject line:
# TU TURNO: Analiza este A/B test de email marketing
np.random.seed(123)
email_data = pd.DataFrame({
'email_id': range(1, 10001),
'subject_variant': np.random.choice(['A', 'B'], 10000),
'opened': np.random.binomial(1, 0.22, 10000) # 22% open rate promedio
})
# Ajustar: B tiene 2pp más de open rate
mask_b = email_data['subject_variant'] == 'B'
email_data.loc[mask_b, 'opened'] = np.random.binomial(1, 0.24, mask_b.sum())
# TAREAS:
# 1. Calcula estadísticas descriptivas por variante
# 2. Realiza Z-test de proporciones
# 3. Calcula intervalos de confianza
# 4. Visualiza resultados
# 5. Determina si hay ganador claro
# 6. Calcula impacto: 100k emails/mes, cada open vale $0.50
# SOLUCIÓN (no mirar hasta intentarlo):
# stats_email = email_data.groupby('subject_variant')['opened'].agg(['sum', 'count', 'mean'])
# conversions = np.array([stats_email.loc['A', 'sum'], stats_email.loc['B', 'sum']])
# visitors = np.array([stats_email.loc['A', 'count'], stats_email.loc['B', 'count']])
# z, p = proportions_ztest(conversions, visitors)
# impact = 100000 * (stats_email.loc['B', 'mean'] - stats_email.loc['A', 'mean']) * 0.50
Resumen de la lección
✅ Diseño: Define hipótesis, calcula muestra, diseña asignación aleatoria ✅ Análisis: Z-test, p-valores, intervalos de confianza ✅ Visualización: Gráficos claros para stakeholders ✅ Validación: Verifica balance de grupos (SRM), efectos temporales ✅ Impacto: Traduce estadística a dólares y conversiones ✅ Comunicación: Reporte ejecutivo con recomendación clara
Checklist A/B testing profesional
- Define H₀ y H₁ antes de ver datos
- Calcula tamaño de muestra necesario
- Asegura asignación aleatoria 50/50
- No detengas test antes de alcanzar muestra
- Verifica supuestos estadísticos
- Reporta IC además de p-valores
- Considera significancia práctica
- Analiza segmentos clave
- Comunica en lenguaje de negocio
Errores comunes a evitar
- ❌ "Peeking" (detener test al ver significancia)
- ❌ Cambiar hipótesis después de ver datos
- ❌ Ignorar SRM (desbalance de grupos)
- ❌ Declarar ganador sin significancia
- ❌ No considerar impacto práctico
🎯 Próxima lección: 22. Introducción a Web Scraping con BeautifulSoup
Has completado el módulo de Estadística. Ahora aprenderás Web Scraping: extraer datos de sitios web y APIs. ¡Nos vemos! 🚀
¿Completaste esta lección?
Marca esta lección como completada. Tu progreso se guardará en tu navegador.