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.
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 📋
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
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í no | Así 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 📉