Lección 21 de 29Módulo 4: Estadística Aplicada

21. Caso Práctico: A/B Testing

Diseña y analiza un A/B test completo desde cero

30 minutos

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.