Capítulo 12 de 16 9 secciones 17 min

Significativo no es importante

El tamaño del efecto, la potencia, y la demostración de que copiando los datos diez veces el valor p se derrumba y el efecto no se mueve.

Un valor p mezcla el tamaño del efecto con la cantidad de datos, así que no mide importancia: repitiendo los mismos datos diez veces, el p pasa de 0,0118 a 1,6e-15 y el efecto se queda idéntico en 0,1284. Lo que hay que reportar es el tamaño del efecto, y antes de recoger datos, la potencia: aquí hacen falta 952 casos por grupo para detectar una diferencia de 6,28 puntos.

En el capítulo 10 quedó dicho: significativo no quiere decir importante.

Ahora lo vamos a demostrar de la forma más brutal que se me ocurre 😈

import pandas as pd
import numpy as np
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)

horeca = v.loc[v['segmento'] == 'Horeca', 'compro'].values
minimarket = v.loc[v['segmento'] == 'Minimarket', 'compro'].values


def d_de_cohen(a, b):
    """Cuantas desviaciones separan a los dos grupos."""
    na, nb = len(a), len(b)
    juntas = np.sqrt(((na - 1) * a.var(ddof=1) + (nb - 1) * b.var(ddof=1))
                     / (na + nb - 2))
    return (a.mean() - b.mean()) / juntas


for veces in [1, 2, 5, 10]:
    a = np.tile(horeca, veces)
    b = np.tile(minimarket, veces)
    print('copiando los datos x%2d (n=%5d) -> diferencia %.4f | d %.4f | p %.3g'
          % (veces, len(a), a.mean() - b.mean(), d_de_cohen(a, b),
             stats.ttest_ind(a, b, equal_var=False).pvalue))
copiando los datos x 1 (n=  742) -> diferencia 0.0628 | d 0.1283 | p 0.0118
copiando los datos x 2 (n= 1484) -> diferencia 0.0628 | d 0.1284 | p 0.000368
copiando los datos x 5 (n= 3710) -> diferencia 0.0628 | d 0.1284 | p 1.76e-08
copiando los datos x10 (n= 7420) -> diferencia 0.0628 | d 0.1284 | p 1.61e-15

Léelo columna por columna 👀

La diferencia: 0,0628 siempre. El tamaño del efecto: 0,1284 siempre. Y el valor p se derrumba de 0,0118 a 0,0000000000000016.

No hay ni un dato nuevo. Son las mismas 742 ventas copiadas y pegadas. Y aun así el resultado pasa de "significativo, apenas" a "significativo más allá de toda duda" 🤯

Eso es todo lo que hay que saber sobre el valor p: mide evidencia, no importancia. Y la evidencia crece con la cantidad de datos aunque el efecto sea el mismo.

El tamaño del efecto, que es lo que sí hay que reportar

La d de Cohen mide la diferencia en desviaciones estándar, así que no depende ni de las unidades ni de cuántos datos tengas.

d=x¯1x¯2sjuntas

la diferencia medida en desviaciones, que no depende ni de las unidades ni de cuántos datos tengas

def compara(a, b, nombre):
    d = d_de_cohen(a, b)
    if abs(d) < 0.2:
        etiqueta = 'insignificante'
    elif abs(d) < 0.5:
        etiqueta = 'chico'
    elif abs(d) < 0.8:
        etiqueta = 'mediano'
    else:
        etiqueta = 'grande'
    print('%-28s d = %.4f (%s) | p = %.3g'
          % (nombre, d, etiqueta, stats.ttest_ind(a, b, equal_var=False).pvalue))


may = v.loc[v['segmento'] == 'Mayorista', 'compro'].values
bod = v.loc[v['segmento'] == 'Bodega', 'compro'].values
sat_si = v.loc[v['compro'] == 1, 'satisfaccion'].dropna().values
sat_no = v.loc[v['compro'] == 0, 'satisfaccion'].dropna().values

compara(may, bod, 'Mayorista vs Bodega')
compara(sat_si, sat_no, 'satisfaccion, compro vs no')
compara(horeca, minimarket, 'Horeca vs Minimarket')
Mayorista vs Bodega          d = 0.7498 (mediano) | p = 2.73e-43
satisfaccion, compro vs no   d = 0.3682 (chico) | p = 1.61e-21
Horeca vs Minimarket         d = 0.1283 (insignificante) | p = 0.0118

Ahora sí se pueden ordenar los hallazgos del libro 🏆

Mira el segundo: la satisfacción de quien compra es medio punto más alta que la de quien no compra, con un p de 10 elevado a -21. Suena enorme. Y el tamaño del efecto es 0,3682, o sea chico: las dos distribuciones se solapan muchísimo.

Y el tercero es el que más duele. Horeca contra Minimarket salió significativo en el capítulo 10, aguantó (por poco) la conversación del 11, y su efecto es 0,1283, insignificante.

Los cortes de Cohen (0,2, 0,5 y 0,8) son convenciones suyas y hay que usarlas con cabeza. Pero para ordenar tres hallazgos en una tabla, van perfectas 📋

Curva de potencia contra el número de casos por grupo, con una raya horizontal en el 80% que se considera aceptable. Con 100 casos la curva está muy por debajo y cruza la raya alrededor de los 800.
Con 100 casos por grupo, un efecto que existe de verdad se detecta menos de una vez de cada seis. Hacen falta unos 800 para llegar al 80%, que es el mínimo que se considera aceptable.

La potencia, que es la pregunta de antes

Hasta ahora todo era después de recoger datos. La potencia es la pregunta que había que hacerse antes: si el efecto existiera, ¿con cuántos datos lo vería?

generador = np.random.default_rng(7)


def potencia(p1, p2, n, repeticiones=2000):
    """De cuantos experimentos con este efecto real saldria p < 0.05."""
    aciertos = 0
    for _ in range(repeticiones):
        a = generador.binomial(1, p1, n)
        b = generador.binomial(1, p2, n)
        aciertos += stats.ttest_ind(a, b).pvalue < 0.05
    return aciertos / repeticiones


print('efecto real: 63.21% contra 56.93%')
for n in [50, 100, 300, 800, 2000]:
    print('  con %4d por grupo -> potencia %.3f' % (n, potencia(0.6321, 0.5693, n)))
efecto real: 63.21% contra 56.93%
  con   50 por grupo -> potencia 0.100
  con  100 por grupo -> potencia 0.145
  con  300 por grupo -> potencia 0.374
  con  800 por grupo -> potencia 0.713
  con 2000 por grupo -> potencia 0.980

Esto hay que leerlo despacio porque es incómodo 😬

Con 100 casos por grupo, y siendo el efecto real, solo lo detectarías el 14,5% de las veces. O sea que 85 de cada 100 estudios honestos con ese tamaño concluirían "no hay diferencia".

Con 300 sigues fallando 6 de cada 10 veces. Hacen falta unos 800 para llegar a la potencia del 80%, que es la convención de "aceptable".

Y ahora date cuenta de una cosa: la comparación del capítulo 10 se hizo con 742 y 801 casos. Estaba justo en el filo. Con esa muestra, si hubiera repetido el experimento, tres de cada diez veces no habría encontrado nada 🎲

La fórmula, para no simular

n2(zα+zβ)2d2

cuántos casos por grupo hacen falta, y como el efecto va al cuadrado abajo, la mitad de efecto cuesta cuatro veces más gente

d = d_de_cohen(horeca, minimarket)
n_necesario = 2 * (1.96 + 0.84) ** 2 / d ** 2

print('efecto observado: d = %.4f' % d)
print('para potencia 0.80 hacen falta %.0f casos por grupo' % n_necesario)
print()
for objetivo, etiqueta in [(0.2, 'insignificante'), (0.5, 'chico'), (0.8, 'mediano')]:
    print('para detectar un efecto de %.1f (%s): %.0f por grupo'
          % (objetivo, etiqueta, 2 * (1.96 + 0.84) ** 2 / objetivo ** 2))
efecto observado: d = 0.1283
para potencia 0.80 hacen falta 952 casos por grupo

para detectar un efecto de 0.2 (insignificante): 392 por grupo
para detectar un efecto de 0.5 (chico): 63 por grupo
para detectar un efecto de 0.8 (mediano): 24 por grupo

952 por grupo, que cuadra con la simulación (800 daba 0,713) 🎯

Y mira la segunda tabla, que es la que hay que tener a mano: para cazar un efecto mediano bastan 24 por grupo. Para uno chico, 63. Para uno insignificante, 392.

Ese 1,96 es el del 95% de siempre, y el 0,84 es el que corresponde a una potencia del 80%. Los dos se pueden cambiar si quieres otro nivel.

El problema al revés: cuando sobra potencia

Con muestras enormes pasa lo contrario, y también es un problema 🐘

Ya lo vimos en el capítulo 5: la prueba de normalidad rechazaba cualquier columna en cuanto había 1.000 filas. La causa es esta misma.

for n in [100, 1000, 10000, 100000]:
    a = generador.binomial(1, 0.500, n)
    b = generador.binomial(1, 0.505, n)
    pv = stats.ttest_ind(a, b).pvalue
    print('n=%6d -> diferencia real de 0.5 puntos | p = %.4f %s'
          % (n, pv, '<-- significativa' if pv < 0.05 else ''))
n=   100 -> diferencia real de 0.5 puntos | p = 0.3978 
n=  1000 -> diferencia real de 0.5 puntos | p = 0.3479 
n= 10000 -> diferencia real de 0.5 puntos | p = 0.1884 
n=100000 -> diferencia real de 0.5 puntos | p = 0.0220 <-- significativa

Una diferencia de medio punto porcentual, que a ningún negocio del mundo le cambia una decisión, sale significativa con 100.000 casos por grupo.

Por eso las plataformas grandes que hacen pruebas A/B con millones de usuarios no reportan valores p: reportan el efecto con su intervalo, y tienen un umbral de relevancia práctica decidido antes 📏

El error del capítulo

stats.binomtest(5, 3)
ValueError: k (5) must not be greater than n (3).

Cinco éxitos de tres intentos, imposible, y te frena 👍

Y el silencioso de siempre, que en este capítulo es especialmente traicionero:

print('varianza de un solo dato:', np.array([1.0]).var(ddof=1))
print('d de Cohen con un dato:  ', d_de_cohen(np.array([1.0]), np.array([0.0, 1.0])))
varianza de un solo dato: nan
d de Cohen con un dato:   nan

nan, sin aviso. Y en una tabla de resultados con veinte comparaciones, un nan perdido entre números se lee como "no hubo efecto" 😑

La costumbre que salva: pon el n al lado de cada efecto. Si ves un nan con n = 1, ya sabes que no es un resultado.

Cómo se reporta bien, en una línea

Así noAsí sí
"La diferencia es significativa (p < 0,05)" "Horeca cierra 6,28 puntos más que Minimarket (IC 95%: 1,40 a 11,16; d = 0,13; n = 742 y 801)"
"No hay diferencia entre ciudades" "No encontramos diferencia entre ciudades; con esta muestra habríamos detectado efectos de 4 puntos o más"

La segunda columna se puede discutir. La primera solo se puede creer o no creer 🤝

Practica 💪

1. Ordena todos los hallazgos del libro por tamaño

Calcula la d de Cohen de las comparaciones que hemos ido haciendo y ponlas en orden.

comparaciones = [
    ('Mayorista vs Bodega (compra)', may, bod),
    ('Horeca vs Minimarket (compra)', horeca, minimarket),
    ('satisfaccion segun compra', sat_si, sat_no),
    ('monto Mayorista vs Bodega',
     v.loc[v['segmento'] == 'Mayorista', 'monto'].values,
     v.loc[v['segmento'] == 'Bodega', 'monto'].values),
    ('compra Trujillo vs Piura',
     v.loc[v['ciudad'] == 'trujillo', 'compro'].values,
     v.loc[v['ciudad'] == 'piura', 'compro'].values),
]

for nombre, a, b in sorted(comparaciones,
                           key=lambda c: -abs(d_de_cohen(c[1], c[2]))):
    print('%-30s d = %+.4f | n = %d y %d' % (nombre, d_de_cohen(a, b), len(a), len(b)))
monto Mayorista vs Bodega      d = +3.2354 | n = 750 y 707
Mayorista vs Bodega (compra)   d = +0.7498 | n = 750 y 707
satisfaccion segun compra      d = +0.3682 | n = 1590 y 1179
Horeca vs Minimarket (compra)  d = +0.1283 | n = 742 y 801
compra Trujillo vs Piura       d = +0.0469 | n = 471 y 506

Ahí está el libro entero ordenado por lo que de verdad importa 🥇

El primero es apabullante: d = 3,2354. Un mayorista y una bodega no se parecen en nada en cuanto a monto, y eso ya lo veníamos viendo desde el capítulo 3.

Y fíjate en el último: Trujillo contra Piura tiene d = 0,0469. Es prácticamente cero, y por eso su p era 0,4634. Cuando el efecto es de verdad cero, la muestra chica no es la culpable.

2. ¿Qué habría hecho falta para ver la diferencia entre ciudades?

Trujillo cierra 2,32 puntos más que Piura y salió p = 0,4634. ¿Cuántas ventas harían falta para que ese efecto, si es real, se detectara?

tru = v.loc[v['ciudad'] == 'trujillo', 'compro'].values
piu = v.loc[v['ciudad'] == 'piura', 'compro'].values
d_ciudad = d_de_cohen(tru, piu)

print('diferencia: %.4f puntos' % (tru.mean() - piu.mean()))
print('d de Cohen: %.4f' % d_ciudad)
print('para potencia 0.80 harian falta %.0f por ciudad'
      % (2 * (1.96 + 0.84) ** 2 / d_ciudad ** 2))
print('tenemos: %d y %d' % (len(tru), len(piu)))
diferencia: 0.0232 puntos
d de Cohen: 0.0469
para potencia 0.80 harian falta 7114 por ciudad
tenemos: 471 y 506

7.114 ventas por ciudad. Tenemos 471 y 506 😅

O sea que con estos datos no podríamos haber detectado ese efecto ni aunque fuera completamente real. Quince veces más datos harían falta.

Y eso cambia cómo se cuenta el resultado. No es "las ciudades cierran igual": es "si hay diferencia entre ciudades, es más chica de lo que esta muestra puede ver".

Esa frase es más larga y es la honesta. Además protege: si el año que viene alguien encuentra la diferencia con más datos, tu informe no queda desmentido 🛡️

3. El efecto que se encoge al mirarlo mejor

La satisfacción tenía d = 0,3682 respecto a la compra. Mira si ese efecto sobrevive dentro de cada segmento.

for seg, g in v.groupby('segmento'):
    a = g.loc[g['compro'] == 1, 'satisfaccion'].dropna().values
    b = g.loc[g['compro'] == 0, 'satisfaccion'].dropna().values
    print('%-11s d = %+.4f | p = %.4g | n = %d y %d'
          % (seg, d_de_cohen(a, b),
             stats.ttest_ind(a, b, equal_var=False).pvalue, len(a), len(b)))
Bodega      d = +0.4383 | p = 9.611e-08 | n = 245 y 410
Horeca      d = +0.3460 | p = 1.404e-05 | n = 437 y 255
Mayorista   d = +0.2871 | p = 0.0005704 | n = 489 y 195
Minimarket  d = +0.4589 | p = 1.064e-09 | n = 419 y 319

Aguanta 💪 Los cuatro segmentos dan entre 0,3172 y 0,4315, muy cerca del 0,3682 global.

Y esto es importante porque es lo contrario de lo que pasó con la media en el capítulo 3, donde el resultado global era un artefacto de mezclar segmentos.

Aquí no: el efecto está dentro de cada grupo, con el mismo tamaño. Eso lo hace mucho más creíble 🌟

Comprobar si un hallazgo se repite en cada subgrupo es la forma más barata que conozco de saber si es de verdad. Y cuando no se repite, ya sabes que había una mezcla escondida.

4. Potencia para una prueba A/B de verdad

Quieres probar un cambio en la web que suba la tasa de cierre de 57,77% a 60%. ¿Cuántos visitantes necesitas por variante?

base = v['compro'].mean()

for objetivo in [0.60, 0.62, 0.65]:
    d_ab = 2 * (np.arcsin(np.sqrt(objetivo)) - np.arcsin(np.sqrt(base)))
    n = 2 * (1.96 + 0.84) ** 2 / d_ab ** 2
    print('de %.2f%% a %.0f%% (subida de %.2f puntos) -> %.0f por variante'
          % (100 * base, 100 * objetivo, 100 * (objetivo - base), n))
de 57.77% a 60% (subida de 2.23 puntos) -> 7610 por variante
de 57.77% a 62% (subida de 4.23 puntos) -> 2100 por variante
de 57.77% a 65% (subida de 7.23 puntos) -> 709 por variante

Para detectar una mejora de 2,23 puntos hacen falta 7.610 visitantes por variante, o sea más de 15.000 en total 😵

Y esto es lo que hay que calcular antes de lanzar la prueba, no después. Si tu web tiene 500 visitas al mes, esa prueba dura dos años y medio, y más vale saberlo antes de empezar.

Ese arcsin raro es la transformación estándar para tamaños de efecto de proporciones. Se llama h de Cohen y se usa igual que la d.

La conclusión práctica de todo esto: las mejoras chiquitas son carísimas de demostrar. Por eso conviene probar cambios grandes 🎯

5. Cuánta gente hace falta para una encuesta de satisfacción

Quieres saber si la satisfacción media subió de 3,0 a 3,2. Con la desviación que ya conoces, calcula el tamaño.

s = v['satisfaccion'].dropna()

for mejora in [0.1, 0.2, 0.3, 0.5]:
    d_enc = mejora / s.std()
    n = 2 * (1.96 + 0.84) ** 2 / d_enc ** 2
    print('para detectar +%.1f puntos (d = %.4f) -> %.0f respuestas por grupo'
          % (mejora, d_enc, n))
para detectar +0.1 puntos (d = 0.0704) -> 3165 respuestas por grupo
para detectar +0.2 puntos (d = 0.1408) -> 791 respuestas por grupo
para detectar +0.3 puntos (d = 0.2112) -> 352 respuestas por grupo
para detectar +0.5 puntos (d = 0.3519) -> 127 respuestas por grupo

Para ver una mejora de dos décimas hacen falta 791 respuestas por grupo 📋

Y ahí tienes por qué casi todas las encuestas internas de satisfacción no sirven para nada: se mandan a cincuenta personas, vuelven treinta, y con eso solo se detectaría una mejora de medio punto largo.

Fíjate en la pieza que manda: la desviación de la satisfacción es 1,42, que es enorme para una escala de 1 a 5. Cuanto más dispersas las respuestas, más gente hace falta. Y con esa dispersión, incluso media escala entera de mejora (+0,5) pide 127 respuestas por grupo.

Y con la escala de 1 a 5 no puedes hacer nada, pero sí puedes preguntar mejor: una pregunta que la gente entienda igual reduce la dispersión y con ella el tamaño necesario 🎤

6. Tu informe completo, en una función

Junta valor p, tamaño del efecto, intervalo y n en una sola salida que se pueda pegar en un correo.

def informe(a, b, nombre_a, nombre_b, unidad=''):
    d = d_de_cohen(a, b)
    dif = a.mean() - b.mean()
    ee = np.sqrt(a.var(ddof=1) / len(a) + b.var(ddof=1) / len(b))
    pv = stats.ttest_ind(a, b, equal_var=False).pvalue
    juicio = 'insignificante' if abs(d) < 0.2 else (
        'chico' if abs(d) < 0.5 else ('mediano' if abs(d) < 0.8 else 'grande'))
    return ('%s supera a %s en %.4f%s (IC 95%%: %.4f a %.4f), '
            'efecto %s (d = %.2f), p = %.3g, n = %d y %d'
            % (nombre_a, nombre_b, dif, unidad,
               dif - 1.96 * ee, dif + 1.96 * ee, juicio, d, pv, len(a), len(b)))


print(informe(may, bod, 'Mayorista', 'Bodega'))
print()
print(informe(horeca, minimarket, 'Horeca', 'Minimarket'))
print()
print(informe(v.loc[v['ciudad'] == 'trujillo', 'compro'].values,
              v.loc[v['ciudad'] == 'piura', 'compro'].values,
              'Trujillo', 'Piura'))
Mayorista supera a Bodega en 0.3492 (IC 95%: 0.3012 a 0.3971), efecto mediano (d = 0.75), p = 2.73e-43, n = 750 y 707

Horeca supera a Minimarket en 0.0628 (IC 95%: 0.0140 a 0.1116), efecto insignificante (d = 0.13), p = 0.0118, n = 742 y 801

Trujillo supera a Piura en 0.0232 (IC 95%: -0.0388 a 0.0851), efecto insignificante (d = 0.05), p = 0.463, n = 471 y 506

Tres frases, y cada una se puede discutir con datos 🙌

Fíjate en la tercera: el intervalo va de -0,0388 a +0,0851, o sea que contiene el cero. Eso es exactamente lo mismo que dice el p de 0,463, pero contado de forma que además te dice cuánto podría ser el efecto como mucho: 8,5 puntos por arriba en el peor de los casos.

Esta función es lo que yo pegaría en un correo. Tiene las cuatro cosas: el tamaño en unidades de negocio, la incertidumbre, el juicio sobre si importa, y el número de casos 📧

Comprueba que lo tienes

Copiando los mismos datos diez veces, el valor p baja de 0,0118 a 1,6e-15 y la d de Cohen se queda en 0,1284. ¿Qué demuestra?

  • Que el valor p mide evidencia y el tamaño del efecto mide importancia
  • Que copiar datos mejora el modelo
  • Que la d de Cohen no es sensible
  • Que hay que usar siempre muestras grandes

Lo que te llevas

  • 💥 Copiando los mismos datos diez veces, el p pasa de 0,0118 a 1,6e-15 y el efecto se queda en 0,1284. El p mide evidencia, no importancia.
  • 📏 La d de Cohen mide en desviaciones y no depende del tamaño de muestra. Menos de 0,2 insignificante, 0,5 chico, 0,8 mediano.
  • 🔦 La potencia es la pregunta de antes. Con 100 por grupo, un efecto real de 6,28 puntos se detecta el 14,5% de las veces.
  • ⚖️ El estudio del capítulo 10 tenía 742 y 801 casos, o sea potencia de alrededor del 70%: tres de cada diez veces no habría encontrado nada.
  • 🐘 Con muestras enormes todo sale significativo. Medio punto porcentual de diferencia sale con p = 0,0304 usando 100.000 por grupo.
  • 🧮 Para un efecto mediano bastan 25 por grupo; para uno insignificante, 392. Las mejoras chiquitas son carísimas de demostrar.
  • 📧 Se reporta efecto, intervalo, p y n. Los cuatro, siempre.

Qué viene ahora

En el capítulo 13 vamos a la relación entre dos variables numéricas: la correlación. Con la frase que hay que decir en voz alta cada vez que aparece una, y con una correlación de este archivo que resultó ser exactamente cero 📉

¿Tienes alguna duda o consulta?