Capítulo 18 de 20 9 secciones 16 min

Una prueba A/B de principio a fin

El orden en que se hace: primero cuántos hacen falta, después el reparto, y el resultado al final. Y el 17,7% que cuesta mirar antes de tiempo.

Una prueba A/B es todo lo que este libro enseña, puesto en el orden en que se usa. Y el orden importa más que las cuentas: yo medí que mirar el resultado cada cien personas en vez de esperar al final sube los falsos hallazgos del 5% al 17,7%. El mismo experimento, sin nada que cambiar 🧪

Diecisiete capítulos de piezas sueltas. Este las monta todas en el orden en que se usan de verdad, sobre la pregunta que más se hace en una empresa: ¿esto que cambiamos sirvió? 🧪

Y te aviso desde ya: lo que hace que una prueba A/B valga o no valga se decide casi todo antes de empezar. Cuando ya tienes los datos, lo que queda es una cuenta de tres líneas.

import numpy as np
import pandas as pd
from scipy import stats

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'


def carga_limpia(url):
    """La misma del capítulo 2."""
    v = pd.read_csv(url).drop_duplicates()
    v['ciudad'] = (v['ciudad'].str.strip().str.lower()
                   .str.normalize('NFKD')
                   .str.encode('ascii', 'ignore').str.decode('utf-8'))
    v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
    for col in ['fecha', 'fecha_ultima_compra']:
        f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce')
        falta = f.isna() & v[col].notna()
        f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y',
                                  errors='coerce')
        v[col] = f
    return v


v = carga_limpia(URL).dropna(subset=['compro'])
BASE = v['compro'].mean()

print('clientes: %d' % len(v))
print('conversion de hoy: %.4f' % BASE)
clientes: 3000
conversion de hoy: 0.5777

Ese 0,5777 es el punto de partida, y a partir de él ya se puede contestar la pregunta que casi nadie hace 🐣

El orden de una prueba A/B: antes de empezar se calcula cuántos hacen falta y se cancela si no los hay, y se fija la fecha de parada; después se reparte al azar y se corre una prueba A/A; se espera hasta la fecha sin mirar; y al final van la prueba, el intervalo y el tamaño del efecto.
Las dos cajas de arriba son las que casi nadie hace, y son las que deciden si el experimento sirve. Lo de abajo son tres líneas de código.

Paso 1, y es el que se salta todo el mundo

Antes de tocar nada: ¿cuánta gente hace falta para poder ver el cambio que espero? Si la respuesta es más de la que tienes, el experimento no se hace, porque ya sabes que va a salir "no concluyente" pase lo que pase.

def cuantos_hacen_falta(p1, p2, potencia=0.80, alfa=0.05):
    """Cuantos por grupo para ver un cambio de p1 a p2."""
    z_a = stats.norm.ppf(1 - alfa / 2)
    z_b = stats.norm.ppf(potencia)
    p = (p1 + p2) / 2
    return int(np.ceil(2 * p * (1 - p) * (z_a + z_b) ** 2 / (p2 - p1) ** 2))


print('conversion de hoy: %.4f' % BASE)
for mejora in [0.01, 0.02, 0.03, 0.05, 0.10]:
    n = cuantos_hacen_falta(BASE, BASE + mejora)
    print('  para ver +%.0f puntos: %6d por grupo' % (mejora * 100, n))
conversion de hoy: 0.5777
  para ver +1 puntos:  38172 por grupo
  para ver +2 puntos:   9510 por grupo
  para ver +3 puntos:   4211 por grupo
  para ver +5 puntos:   1504 por grupo
  para ver +10 puntos:    367 por grupo

Ahí está la conversación entera que hay que tener con quien pide el experimento 💼

Para ver una mejora de un punto hacen falta 38.172 personas por grupo, o sea 76.344 en total. Y nosotros tenemos 3.000. Se acabó: ese experimento no se puede hacer aquí, y saberlo hoy vale más que descubrirlo en tres meses.

Con 1.500 por grupo solo alcanza para ver cambios de cinco puntos para arriba. Así que el experimento solo tiene sentido si de verdad esperas un cambio grande. Fíjate además en la forma de la tabla: bajar de 5 puntos a 1 multiplica por veinticinco la gente que necesitas, porque el efecto entra al cuadrado en el denominador. Eso ya salió en el capítulo 14, y aquí es lo que decide si hay proyecto o no.

Paso 2, repartir de verdad al azar

Ahora el reparto. Y una cosa que parece de perogrullo y no lo es:

  • Se reparte al azar
  • No por ciudad
  • Ni por vendedor
  • Ni por quien conteste primero

Si el reparto no es al azar, lo que midas después mezcla tu cambio con lo que diferencie a los grupos.

generador = np.random.default_rng(7)
lado = generador.permutation(len(v)) % 2
v = v.assign(grupo=np.where(lado == 0, 'A', 'B'))

print('a cada grupo le tocaron:')
print(v['grupo'].value_counts().to_string())
print()
print('y comprobamos que los grupos salieron parecidos:')
print(pd.crosstab(v['grupo'], v['segmento'], normalize='index').round(3).to_string())
a cada grupo le tocaron:
grupo
B    1500
A    1500

y comprobamos que los grupos salieron parecidos:
segmento  Bodega  Horeca  Mayorista  Minimarket
grupo
A          0.233   0.261      0.239       0.266
B          0.238   0.233      0.261       0.268

Esa segunda tabla es la comprobación de equilibrio, y hay que hacerla siempre 📋

Los repartos están cerca pero no son idénticos: el grupo B se llevó un 26,1% de mayoristas contra el 23,9% de A. Eso es normal y es el azar haciendo su trabajo. Lo que buscas no es que sean iguales, es que no haya una diferencia gorda que después te explique el resultado.

Y esto conecta directo con el capítulo 9: allí el problema era quién entra en la muestra, y aquí es quién cae en cada lado. Es el mismo peligro dos veces.

Paso 3, el resultado

Hasta aquí no hemos cambiado nada. Los dos grupos reciben exactamente lo mismo, porque quiero enseñarte primero lo que tiene que salir cuando no hay nada:

a = v[v['grupo'] == 'A']['compro']
b = v[v['grupo'] == 'B']['compro']
print('grupo A: %d personas, convierte %.4f' % (len(a), a.mean()))
print('grupo B: %d personas, convierte %.4f' % (len(b), b.mean()))
print('diferencia: %+.4f' % (b.mean() - a.mean()))

tabla = np.array([[a.sum(), len(a) - a.sum()], [b.sum(), len(b) - b.sum()]])
print('valor p: %.4f' % stats.chi2_contingency(tabla)[1])
grupo A: 1500 personas, convierte 0.5780
grupo B: 1500 personas, convierte 0.5773
diferencia: -0.0007
valor p: 1.0000

Siete diezmilésimas de diferencia y un valor p de 1,0000 💚

Eso es exactamente lo que tenía que salir, porque no cambiamos nada. Y esto tiene nombre: es una prueba A/A, y es lo primero que se corre antes de cualquier A/B de verdad.

Suena a perder el tiempo y es lo contrario. Si tu A/A sale significativa, no has descubierto nada: tienes rota la tubería. El reparto no es al azar, o los grupos se contaminan, o estás contando dos veces al mismo cliente. Correrla cuesta una tarde y te ahorra creerte un hallazgo que era un fallo de plomería 🔧

Paso 4, y ahora con un cambio de verdad

Vamos a fabricar un efecto real de cinco puntos y ver si la máquina lo caza:

def experimento(efecto, semilla):
    """Reparte al azar y le suma `efecto` a la conversion del grupo B."""
    g = np.random.default_rng(semilla)
    lado = g.permutation(len(v)) % 2
    base = v['compro'].values.copy()
    empujados = (lado == 1) & (base == 0) & (g.random(len(v)) < efecto / (1 - BASE))
    base = np.where(empujados, 1, base)
    return base[lado == 0], base[lado == 1]


a5, b5 = experimento(0.05, 7)
print('grupo A convierte %.4f' % a5.mean())
print('grupo B convierte %.4f' % b5.mean())
t = np.array([[a5.sum(), len(a5) - a5.sum()], [b5.sum(), len(b5) - b5.sum()]])
print('valor p: %.6f' % stats.chi2_contingency(t)[1])
grupo A convierte 0.5780
grupo B convierte 0.6300
valor p: 0.004047

Lo caza 🎯

Cinco puntos de mejora y un valor p de 0,004. Y no es suerte: la tabla del paso 1 decía que con 1.504 por grupo se ven los cambios de cinco puntos, y tenemos 1.500. El experimento estaba diseñado para poder ver esto, y por eso lo ve.

Y ahora el pecado, que es el que de verdad cuesta dinero

Tienes el experimento corriendo. Van tres días. Y miras. Y al día siguiente miras otra vez. Y el jueves sale p = 0,03 y lo paras y lo anuncias 🎉

Vamos a medir cuánto cuesta eso. Trescientos experimentos donde no hay ningún efecto, comparando mirar solo al final contra mirar cada cien personas y parar al primer resultado bonito:

def mira_pronto(efecto, semilla, cada=100):
    """Corre el experimento y va mirando el valor p cada `cada` personas."""
    a, b = experimento(efecto, semilla)
    for n in range(cada, len(a) + 1, cada):
        ta = np.array([[a[:n].sum(), n - a[:n].sum()],
                       [b[:n].sum(), n - b[:n].sum()]])
        if ta.min() == 0:
            continue
        if stats.chi2_contingency(ta)[1] < 0.05:
            return True
    return False


sin_efecto = sum(mira_pronto(0.0, s) for s in range(300))
print('de 300 experimentos donde NO hay ningun efecto real:')
print('  mirando solo al final, lo esperado es el 5.0%')
print('  mirando cada 100 personas y parando al primer p<0.05: %.1f%%'
      % (sin_efecto / 300 * 100))
de 300 experimentos donde NO hay ningun efecto real:
  mirando solo al final, lo esperado es el 5.0%
  mirando cada 100 personas y parando al primer p<0.05: 17.7%

Del 5% al 17,7% 😱

Uno de cada seis experimentos sin ningún efecto te va a dar un resultado significativo en algún momento del camino, si te dedicas a mirar. Y lo vas a parar justo ahí, porque es cuando la noticia es buena.

No hace falta mala fe. Basta con querer saber cómo va. El valor p del capítulo 12 promete un 5% de falsas alarmas si miras una vez, y cada vistazo extra es una oportunidad más de que el azar te sonría.

El tamaño de la muestra y la fecha de parada se escriben antes de empezar. Todo lo que se decida después de ver los datos ya no es un experimento.

Los dos errores, y el peor es el que no revienta

Empiezo por el que sí revienta, y es bonito porque la que se queja es la aritmética:

cuantos_hacen_falta(BASE, BASE)
OverflowError: cannot convert float infinity to integer

Le preguntaste cuánta gente hace falta para detectar una mejora de cero, y la respuesta es infinita. Tiene todo el sentido: una diferencia que no existe no se detecta nunca, por mucha gente que metas. La división entre cero del denominador es esa frase escrita en números 🔑

Y ahora el que no revienta, que es el que te va a morder de verdad. chi2_contingency quiere cuántos, no qué proporción. Si le pasas las tasas de conversión en vez de los conteos:

stats.chi2_contingency(np.array([[0.5780, 0.4220],
                                 [0.6300, 0.3700]]))
# devuelve p = 1.0000, sin una sola queja

Un valor p perfectamente formado, perfectamente inútil y perfectamente silencioso. Con proporciones la prueba cree que tienes un cliente por grupo en vez de mil quinientos, así que no encuentra nada nunca.

La comprobación cabe en una línea y hay que hacerla siempre: tabla.sum() tiene que darte el número de personas del experimento. Si te da 2, la tabla está en proporciones 🚩

La receta entera, para tenerla a mano

CuándoQué se haceQué pasa si te lo saltas
AntesEscribir la pregunta y qué mejora valdría la penaAl final se elige la métrica que salió bien
AntesCalcular cuántos hacen falta, y cancelar si no los tienesTres meses para un "no concluyente" que ya se sabía
AntesFijar la fecha de paradaEl 17,7% de arriba
Al empezarRepartir al azar y correr una A/ASe confunde el cambio con lo que ya diferenciaba a los grupos
Al empezarComprobar que los grupos quedaron parecidosUn desequilibrio gordo explica el resultado y nadie lo mira
Al finalUna prueba, un intervalo y el tamaño del efectoSe reporta un valor p solo, que no dice si importa

Cuatro de las seis filas son de antes de empezar, y eso es lo que quiero que te lleves de este capítulo 🗓️

Si quieres leer más, la guía de experimentos de exp-platform, que es el equipo que montó la experimentación en Microsoft, tiene los casos reales de todo lo que sale mal, y son mucho más raros de lo que uno se imagina 📄

La trampa

Una tienda prueba un botón nuevo. A la semana el equipo reporta que el grupo B convierte más y que el valor p es 0,012, y lo lanzan a todos.

# lunes
print(prueba(a[:400], b[:400]))     # p = 0.31, seguimos
# miercoles
print(prueba(a[:900], b[:900]))     # p = 0.18, seguimos
# viernes
print(prueba(a[:1500], b[:1500]))   # p = 0.012  y lo paramos aqui
Qué está mal

Miraron tres veces y pararon en la que salió bien. Eso ya no es una prueba con un 5% de falsas alarmas: en el capítulo está medido que mirando cada cien personas y parando al primer p bonito, el 17,7% de los experimentos sin ningún efecto acaban dando significativo. Y fíjate en lo que hace de esto una trampa y no un error: cada una de las tres líneas es correcta por separado. La prueba está bien hecha, los datos son buenos y el 0,012 es de verdad. Lo que está mal es que la decisión de parar dependió del resultado. Se arregla de dos formas, y las dos hay que tomarlas antes: fijar la fecha de parada y no moverla, o usar una prueba diseñada para mirar varias veces, que existen y se llaman secuenciales.

Ejercicios

Seis. El 2 es el que te va a servir mañana en una reunión 💛

1. Corre tu propia A/A

Reparte al azar veinte veces sin cambiar nada y cuenta cuántas salen significativas.

salen = 0
for s in range(20):
    a0, b0 = experimento(0.0, s)
    t0 = np.array([[a0.sum(), len(a0) - a0.sum()], [b0.sum(), len(b0) - b0.sum()]])
    salen += stats.chi2_contingency(t0)[1] < 0.05
print('significativas de 20:', salen)

Tiene que salir cerca de una. Si te salen cinco, el problema está en cómo repartes y no en el mundo.

2. La calculadora de antes de empezar

Con la conversión de tu negocio, haz la tabla de cuánta gente necesitas para cada mejora.

for base in [0.02, 0.10, 0.30, 0.58]:
    fila = [cuantos_hacen_falta(base, base + d) for d in (0.01, 0.02, 0.05)]
    print('conversion %.2f -> +1pt: %7d   +2pt: %7d   +5pt: %6d' % (base, *fila))

Guárdate esa tabla. Es lo que hay que enseñar cuando alguien propone un experimento, y suele terminar la conversación en dos minutos.

3. Cuánto cuesta mirar más seguido

Repite la medición del 17,7% mirando cada 50, cada 100 y cada 300 personas.

Cuanto más seguido miras, peor. Y la pregunta buena del ejercicio: si mirar cada cien te da 17,7% y mirar una sola vez te da 5%, ¿qué pasa con un panel que enseña el valor p en tiempo real a todo el mundo? 📊

4. Ponle intervalo a la diferencia

Con el bootstrap del capítulo 11, dale un intervalo a la diferencia entre A y B.

Un valor p te dice si crees que hay algo. El intervalo te dice cuánto, y es lo que hace falta para decidir si el cambio paga. Reportar los dos es lo que separa un informe de un titular.

5. El efecto que no alcanzas a ver

Fabrica un efecto real de dos puntos y mira si la prueba lo detecta.

No lo va a detectar casi nunca, y no porque el efecto no exista: la tabla del paso 1 dice que para dos puntos hacen falta 9.510 por grupo y tenemos 1.500. Ojo con lo que eso significa de verdad, que es la lección escondida: un "no significativo" en un experimento pequeño no es evidencia de que no funcione.

6. Mira si el efecto es igual en todos los segmentos

Parte el resultado por segmento y mira si el cambio ayuda a todos por igual.

Cuidado con este, que es el más peligroso de los seis. Partir por segmentos después de ver el resultado es exactamente el p-hacking del capítulo 17: con cuatro segmentos tienes cuatro oportunidades de encontrar algo. Se hace, y se reporta diciendo que se hizo después y que hay que confirmarlo con otro experimento 🚩

7. Lo que cuestan la potencia y el alfa

Deja fijos los tres puntos de mejora y mueve la potencia y el alfa. Arma la tabla del precio de cada combinación.

print('%-9s %10s %10s %10s' % ('potencia', 'alfa 0.10', 'alfa 0.05', 'alfa 0.01'))
for potencia in [0.50, 0.80, 0.90, 0.95]:
    fila = [cuantos_hacen_falta(BASE, BASE + 0.03, potencia, a)
            for a in (0.10, 0.05, 0.01)]
    print('%-9.2f %10d %10d %10d' % (potencia, fila[0], fila[1], fila[2]))
potencia   alfa 0.10  alfa 0.05  alfa 0.01
0.50            1452       2061       3560
0.80            3317       4211       6266
0.90            4595       5637       7983
0.95            5806       6972       9557

La misma mejora cuesta 1452 personas o cuesta 9557 😵

Es un factor de seis y medio, y lo único que cambió son dos números que casi nadie discute porque vienen puestos. La fila de potencia 0,50 es la más honesta de todas: con esa muestra, si la mejora existe, la ves la mitad de las veces. Es tirar una moneda.

Cuando alguien te diga que no hay presupuesto para tantos usuarios, enséñale esta tabla y que escoja la fila. No es lo mismo recortar la muestra que recortar las ganas de enterarte 💰

8. La potencia, comprobada a mano

Corre el experimento doscientas veces con tres efectos distintos y cuenta cuántas veces sale significativo.

def sale_significativo(efecto, semilla):
    a, b = experimento(efecto, semilla)
    t = np.array([[a.sum(), len(a) - a.sum()],
                  [b.sum(), len(b) - b.sum()]])
    return stats.chi2_contingency(t)[1] < 0.05


for efecto in [0.00, 0.02, 0.05]:
    salen = sum(sale_significativo(efecto, s) for s in range(200))
    print('con un efecto de %.2f sale significativo %3d de 200 veces (%.1f%%)'
          % (efecto, salen, salen / 2))
con un efecto de 0.00 sale significativo   5 de 200 veces (2.5%)
con un efecto de 0.02 sale significativo  38 de 200 veces (19.0%)
con un efecto de 0.05 sale significativo 163 de 200 veces (81.5%)

Este ejercicio comprueba la fórmula del capítulo con la fuerza bruta 🔬

La cuenta pedía 1504 por grupo para ver cinco puntos con potencia 0,80, y aquí cada grupo tiene unos 1500. Sale 81,5 por ciento de las veces. La fórmula no era un adorno.

Las otras dos filas valen igual. Sin efecto ninguno salta el 2,5 por ciento de las veces, que es el falso positivo del que habla 17. Y con dos puntos de mejora real, que es una mejora que cualquiera firmaría, este experimento la encuentra 19 veces de cada 100 y la deja pasar las otras 81. Ahí es donde mueren los tests que "no dieron nada" 🪦

Comprueba que lo tienes

Tu experimento lleva cuatro días de los diez planeados y ya sale p = 0,04. ¿Qué haces?

  • Lo dejas correr hasta el día diez, como estaba escrito
  • Lo paras y lo lanzas, que ya salió
  • Lo paras y corres otro para confirmar
  • Sigues y a partir de ahora miras todos los días

Lo que te llevas

  • 🗓️ Cuatro de los seis pasos son antes de empezar. Lo de después son tres líneas.
  • 🔢 Para ver +1 punto sobre una conversión de 0,5777 hacen falta 38.172 por grupo. Con 3.000 clientes ese experimento no se puede hacer, y saberlo hoy vale más que descubrirlo en tres meses.
  • 🔁 La prueba A/A va primero: si sale significativa sin cambiar nada, lo roto es la tubería.
  • 👀 Mirar cada cien personas y parar en el primer resultado bonito sube los falsos hallazgos del 5% al 17,7%.
  • 📉 Y un "no significativo" con muestra chica no dice que el cambio no sirva.

El capítulo 19 hace este mismo recorrido sobre una pregunta de negocio entera y termina en un informe de una página. Y el vocabulario suelto está en el glosario de IA 📖

Que tengas lindo día! 🌸

Practica este capítulo 📓

Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab y no hay que instalar nada.

¿Tienes alguna duda o consulta?