Ya sabes qué es un valor p. Ahora toca la pregunta práctica que atasca a todo el mundo: ¿cuál de todas las pruebas uso? 🧰
La buena noticia es que la respuesta es casi mecánica. Depende del tipo de las dos variables que estés comparando, que es justo lo del capítulo 2.
| Lo que comparas | Prueba | Si hay cola o pocos datos |
|---|---|---|
| Categórica contra categórica | Ji cuadrado | Prueba exacta de Fisher |
| Numérica entre 2 grupos | Prueba t | Mann-Whitney |
| Numérica entre 3 o más grupos | ANOVA | Kruskal-Wallis |
| Numérica contra numérica | Correlación | Spearman (capítulo 13) |
Vamos con las tres primeras sobre las ventas de siempre.
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)
print('filas:', len(v))
filas: 3000
Ji cuadrado: dos categóricas
Es la prueba de la independencia del capítulo 7, con un número encima. Empezamos por la tabla:
tabla = pd.crosstab(v['ciudad'], v['compro']) print(tabla)
compro 0 1 ciudad arequipa 232 314 chiclayo 218 292 cusco 211 285 lima 196 275 piura 218 288 trujillo 192 279
chi2, p, gl, esperados = stats.chi2_contingency(tabla)
print('ji cuadrado: %.4f' % chi2)
print('grados de libertad: %d' % gl)
print('valor p: %.4f' % p)
ji cuadrado: 0.7292 grados de libertad: 5 valor p: 0.9813
p = 0,9813. El 98% de los mundos sin efecto darían una tabla al menos tan desigual como esta 🤷
Este es el número que llevo prometiendo desde el capítulo 1, y ahí está: la ciudad no tiene absolutamente nada que decir sobre si una venta se cierra.
Lo bonito de ji cuadrado es lo que hay dentro: compara lo observado con lo que saldría si fueran independientes, que es justo el producto de probabilidades del capítulo 7.
sumas, celda por celda, cuánto se aleja lo observado de lo que saldría si las dos columnas fueran independientes
print(pd.DataFrame(esperados.round(1), index=tabla.index,
columns=tabla.columns))
compro 0 1 ciudad arequipa 230.6 315.4 chiclayo 215.4 294.6 cusco 209.5 286.5 lima 198.9 272.1 piura 213.7 292.3 trujillo 198.9 272.1
Compara con la tabla real: 232 contra 230,6, 218 contra 215,4... Todas pegadas. Por eso el ji cuadrado sale casi cero 🎯
Ahora las cuatro categóricas del archivo, de una pasada:
for col in ['ciudad', 'categoria', 'canal', 'segmento']:
chi2, p, gl, esp = stats.chi2_contingency(pd.crosstab(v[col], v['compro']))
print('%-11s chi2 = %8.4f | p = %.4g' % (col, chi2, p))
ciudad chi2 = 0.7292 | p = 0.9813 categoria chi2 = 1.6114 | p = 0.8067 canal chi2 = 65.3061 | p = 4.314e-14 segmento chi2 = 194.2990 | p = 7.193e-42
Ahí está el archivo entero resumido en cuatro líneas 🏆 Segmento y canal mandan; ciudad y categoría no dicen nada.
Es exactamente el mismo orden que salió en el capítulo 7 con el cálculo casero. Lo que añade la prueba es el "cuánto hace falta para no ser casualidad".
Y ahora lo que casi nadie hace: mirar los residuos
Un ji cuadrado te dice "aquí pasa algo" pero no dónde. Para eso están los residuos, que miden cuánto se desvía cada celda:
tabla_seg = pd.crosstab(v['segmento'], v['compro']) chi2, p, gl, esp = stats.chi2_contingency(tabla_seg) residuos = (tabla_seg - esp) / np.sqrt(esp) print(residuos.round(3))
compro 0 1 segmento Bodega 8.299 -7.096 Horeca -2.281 1.950 Mayorista -6.167 5.273 Minimarket 0.365 -0.312
Ahora sí se ve la película 🎬
Un residuo se lee como una puntuación z: por encima de 2 en valor absoluto ya es notable, por encima de 3 es fuerte.
- 🔴 Bodega: +8,299 en la columna de "no compró". Es la celda que más manda de toda la tabla: hay muchísimas más bodegas que no compran de las que cabría esperar.
- 🟢 Mayorista: +5,273 en "sí compró". La otra punta.
- ⚪ Minimarket: 0,365 y -0,312. Se comporta exactamente como la media. No aporta nada al resultado.
O sea que ese ji cuadrado de 194,30 no viene de los cuatro segmentos: viene sobre todo de Bodega. Si el negocio quiere mover una aguja, ahí está la aguja 📍
ANOVA: una numérica entre varios grupos
La prueba t compara dos grupos. Con cuatro, la que toca es ANOVA:
grupos = [g['monto'].values for _, g in v.groupby('canal')]
print('monto por canal:', stats.f_oneway(*grupos))
grupos_seg = [g['monto'].values for _, g in v.groupby('segmento')]
print('monto por segmento:', stats.f_oneway(*grupos_seg))
monto por canal: F_onewayResult(statistic=np.float64(0.1865156164511293), pvalue=np.float64(0.905618171704761)) monto por segmento: F_onewayResult(statistic=np.float64(2636.7595738084665), pvalue=np.float64(0.0))
Los dos extremos posibles, en la misma pantalla 😄
Por canal, p = 0,9056: los cuatro canales facturan lo mismo por venta. Por segmento, p = 0,0 literal, o sea tan chico que ni se representa.
Y aquí hay una pregunta que quizá te estés haciendo: si tengo cuatro grupos, ¿por qué no hago las seis pruebas t de todos contra todos? Porque cada prueba tiene su 5% de falsa alarma, y seis pruebas suben eso al 26%. ANOVA hace una sola pregunta ("¿hay algún grupo distinto?") con un solo 5% 🛡️
Las no paramétricas, para cuando hay cola
ANOVA y la t comparan medias y suponen forma acampanada. Y ya sabemos que el monto tiene una cola de campeonato. La versión que no supone nada compara posiciones en vez de valores:
print('ANOVA por canal: p = %.4f' % stats.f_oneway(*grupos).pvalue)
print('Kruskal-Wallis por canal: p = %.4f' % stats.kruskal(*grupos).pvalue)
ANOVA por canal: p = 0.9056 Kruskal-Wallis por canal: p = 0.8179
0,9056 y 0,8179. Las dos dicen lo mismo, así que la cola no estaba molestando 👍
Mi criterio, y te lo doy como criterio y no como ley: corre las dos y mira si coinciden. Cuando coinciden, reporta la paramétrica que todo el mundo entiende. Cuando discrepan, la cola sí importa y hay que mirar por qué.
El error del capítulo
Quiero ji cuadrado sobre un cruce que no existe:
stats.chi2_contingency([[10, 5], [0, 0]])
ValueError: The internally computed table of expected frequencies has a zero element at (np.int64(1), np.int64(0)).
Ese error es de los que enseñan 🎓
Ji cuadrado divide entre los valores esperados. Si una fila entera está vacía, su esperado es cero y la división se va al infinito. No es un capricho del software: la prueba no está definida ahí.
Y hay un pariente que no da error y que hay que vigilar. La regla es que ningún esperado debería bajar de 5:
chi2, p, gl, esp = stats.chi2_contingency(pd.crosstab(v['ciudad'], v['compro']))
print('el esperado mas chico de nuestra tabla: %.1f' % esp.min())
pequena = pd.crosstab(v['ciudad'], v['compro']).head(2) // 100
print()
print(pequena)
chi2b, pb, glb, espb = stats.chi2_contingency(pequena)
print('esperado mas chico: %.2f | p = %.4f' % (espb.min(), pb))
el esperado mas chico de nuestra tabla: 198.9 compro 0 1 ciudad arequipa 2 3 chiclayo 2 2 esperado mas chico: 1.78 | p = 1.0000
Con esperados de 1,78 la prueba corre igual y te devuelve un p tan formal como cualquier otro 😑 Solo que no vale nada, porque la aproximación en la que se basa necesita celdas con al menos cinco casos esperados.
Para tablas chicas está la prueba exacta de Fisher, que calcula la probabilidad contando todas las tablas posibles en vez de aproximar.
Practica 💪
1. ¿Qué canal manda dentro del ji cuadrado?
Ya sabemos que canal sale con p = 4,3e-14. Saca los residuos y di qué canal es el responsable.
tabla_canal = pd.crosstab(v['canal'], v['compro']) chi2, p, gl, esp = stats.chi2_contingency(tabla_canal) print(((tabla_canal - esp) / np.sqrt(esp)).round(3))
compro 0 1 canal Marketplace 4.945 -4.228 Tienda -1.748 1.495 Web -0.136 0.116 WhatsApp -3.194 2.731
Marketplace, con +4,945 en "no compró" 🔴
Y mira Web: -0,136 y +0,116. Casi cero. Web se comporta prácticamente como el promedio, o sea que no aporta nada al resultado de la prueba.
Eso cambia la conclusión práctica. No es que "el canal importe": es que Marketplace convierte mal y WhatsApp convierte bien, y los otros dos están en la media. Con esa frase se puede hacer algo; con "el canal es significativo", no 💼
2. La prueba de Fisher para tablas chicas
Coge las ventas de un solo día y compara segmento contra compra con las dos pruebas.
un_dia = v[v['fecha'] == '2025-03-14']
tabla_dia = pd.crosstab(un_dia['segmento'] == 'Mayorista', un_dia['compro'])
print(tabla_dia)
print()
chi2, p, gl, esp = stats.chi2_contingency(tabla_dia)
print('esperado mas chico: %.2f' % esp.min())
print('ji cuadrado: p = %.4f' % p)
print('Fisher: p = %.4f' % stats.fisher_exact(tabla_dia).pvalue)
compro 0 1 segmento False 3 1 True 0 1 esperado mas chico: 0.40 ji cuadrado: p = 0.8195 Fisher: p = 0.4000
Cinco ventas ese día, y el esperado más chico es 0,40 😬
Y mira los dos valores p: ji cuadrado dice 0,8195 y Fisher dice 0,4000. Sobre la misma tabla de cinco filas, uno da el doble que el otro.
Ninguno encuentra nada, que con cinco filas es la única respuesta honesta posible, pero fíjate en que ji cuadrado no protestó: corrió con esperados de 0,40 y devolvió un número con cuatro decimales. Fisher es el que hay que usar aquí, porque cuenta todas las tablas posibles en vez de aproximar.
La lección no es sobre pruebas, es sobre datos: con cinco filas no se hace inferencia de nada. Y sin embargo se hace todo el tiempo, cada vez que alguien filtra por región, mes y categoría a la vez 🧊
3. Todas contra todas, con y sin corrección
Haz las seis comparaciones de segmentos dos a dos y cuenta cuántas salen significativas. Luego aplica la corrección de Bonferroni.
from itertools import combinations
segmentos = sorted(v['segmento'].unique())
pares = list(combinations(segmentos, 2))
alfa_corregido = 0.05 / len(pares)
print('%d comparaciones, alfa corregido = %.4f' % (len(pares), alfa_corregido))
print()
for a, b in pares:
ga = v.loc[v['segmento'] == a, 'compro']
gb = v.loc[v['segmento'] == b, 'compro']
pv = stats.ttest_ind(ga, gb, equal_var=False).pvalue
print('%-11s vs %-11s p = %.6f %s %s'
% (a, b, pv,
'sig' if pv < 0.05 else ' ',
'sig corregido' if pv < alfa_corregido else ''))
6 comparaciones, alfa corregido = 0.0083 Bodega vs Horeca p = 0.000000 sig sig corregido Bodega vs Mayorista p = 0.000000 sig sig corregido Bodega vs Minimarket p = 0.000000 sig sig corregido Horeca vs Mayorista p = 0.000142 sig sig corregido Horeca vs Minimarket p = 0.011806 sig Mayorista vs Minimarket p = 0.000000 sig sig corregido
Cinco de las seis aguantan la corrección. La que se cae es justo la que usamos de ejemplo en el capítulo 10: Horeca contra Minimarket, con p = 0,011806, que queda por encima del 0,0083 corregido 🫠
Con alfa de 0,05 era significativa. Con el alfa corregido de 0,0083, no.
Y no hay nada nuevo en los datos: lo único que cambió es que ahora estoy haciendo seis preguntas en vez de una, así que exijo más evidencia para cada una. Bonferroni divide alfa entre el número de comparaciones, y es la corrección más simple que existe.
Es también la más severa, y ahí está su crítica: protege tanto de falsas alarmas que te hace perder efectos reales. Volvemos a esto en el capítulo 14 ⚖️
4. Comparar la misma cosa antes y después
Las pruebas de arriba comparan grupos distintos. Prueba la versión emparejada: parte el año y compara los mismos clientes en los dos tramos.
mitad = v['fecha'].median()
antes = v[v['fecha'] <= mitad].groupby('cliente_id')['monto'].mean()
despues = v[v['fecha'] > mitad].groupby('cliente_id')['monto'].mean()
comunes = antes.index.intersection(despues.index)
a = antes[comunes]
d = despues[comunes]
print('clientes en los dos tramos: %d' % len(comunes))
print('promedio antes: %.2f' % a.mean())
print('promedio despues: %.2f' % d.mean())
print()
print('emparejada: p = %.4f' % stats.ttest_rel(a, d).pvalue)
print('sin emparejar: p = %.4f' % stats.ttest_ind(a, d).pvalue)
clientes en los dos tramos: 507 promedio antes: 822.34 promedio despues: 798.75 emparejada: p = 0.4772 sin emparejar: p = 0.4722
507 clientes aparecen en los dos tramos. Promediaban 822,34 antes y 798,75 después, y las dos pruebas dicen que no pasó nada (p = 0,4772 y p = 0,4722) 🙂
Lo que quiero que veas es la diferencia entre las dos pruebas. La emparejada mira el cambio de cada cliente consigo mismo; la otra trata los dos tramos como si fueran gente distinta.
Cuando de verdad hay un efecto, la emparejada lo detecta muchísimo mejor, porque se quita de encima toda la variación entre clientes (que aquí es enorme: un mayorista contra una bodega). Aquí dan casi lo mismo porque no hay nada que detectar.
Regla: si puedes emparejar, empareja. Antes y después del mismo cliente, de la misma tienda, del mismo mes del año anterior 🔗
5. Cuando la paramétrica y la no paramétrica discrepan
Fabrica el caso: coge los montos de Bodega, mételes un atípico enorme y mira qué prueba se deja engañar.
bod = v.loc[v['segmento'] == 'Bodega', 'monto'].values
mini = v.loc[v['segmento'] == 'Minimarket', 'monto'].values
print('sin tocar nada:')
print(' t: p = %.3e' % stats.ttest_ind(bod, mini, equal_var=False).pvalue)
print(' Mann-Whitney: p = %.3e' % stats.mannwhitneyu(bod, mini).pvalue)
trucado = np.concatenate([bod, [500000.0]])
print()
print('metiendo una venta de 500000 en Bodega:')
print(' media de Bodega pasa de %.2f a %.2f' % (bod.mean(), trucado.mean()))
print(' t: p = %.4f' % stats.ttest_ind(trucado, mini, equal_var=False).pvalue)
print(' Mann-Whitney: p = %.3e' % stats.mannwhitneyu(trucado, mini).pvalue)
sin tocar nada: t: p = 1.042e-222 Mann-Whitney: p = 6.808e-189 metiendo una venta de 500000 en Bodega: media de Bodega pasa de 178.70 a 884.66 t: p = 0.5060 Mann-Whitney: p = 6.662e-188
Una sola fila 🤯
La prueba t pasa de p = 10 elevado a -222 (o sea "segurísimo que son distintos") a p = 0,5060, que significa "no distingo nada". Una venta entre 708.
Y mira lo que le hizo a la media: Bodega pasa de 178,70 a 884,66, o sea que por culpa de una fila el segmento que menos vende parece el que más.
Mann-Whitney ni se inmuta: pasa de 10 elevado a -189 a 10 elevado a -188. Como solo mira posiciones, esa venta enorme es simplemente "la última de la fila", y da igual que valga 500.000 o 1.000.
Este es el argumento entero a favor de las no paramétricas, en un ejemplo. Cuando sospeches de tus datos, o cuando los extremos sean de verdad, usa la que no se deja arrastrar 🛡️
6. Tu elector de pruebas
Escribe una función que, dadas dos columnas, elija la prueba y la corra.
def compara(df, grupo, medida):
niveles = df[grupo].nunique()
datos = [g[medida].dropna().values for _, g in df.groupby(grupo)]
categorica = df[medida].nunique() <= 2
if categorica:
chi2, pv, gl, esp = stats.chi2_contingency(pd.crosstab(df[grupo], df[medida]))
return 'ji cuadrado', pv, esp.min() < 5
if niveles == 2:
return 'prueba t', stats.ttest_ind(*datos, equal_var=False).pvalue, False
return 'ANOVA', stats.f_oneway(*datos).pvalue, False
for grupo, medida in [('segmento', 'compro'), ('ciudad', 'compro'),
('canal', 'monto'), ('segmento', 'monto')]:
prueba, pv, aviso = compara(v, grupo, medida)
print('%-10s vs %-7s -> %-12s p = %-10.4g %s'
% (grupo, medida, prueba, pv, 'AVISO: celdas chicas' if aviso else ''))
segmento vs compro -> ji cuadrado p = 7.193e-42 ciudad vs compro -> ji cuadrado p = 0.9813 canal vs monto -> ANOVA p = 0.9056 segmento vs monto -> ANOVA p = 0
Cuatro cruces, cuatro pruebas bien elegidas, una línea cada uno 🙌
Esta función la puedes pegar en cualquier cuaderno. Y fíjate en lo que hace
por dentro: la elección se decide mirando nunique(), que es
exactamente el detector de tipos del capítulo 2.
Un aviso para que no la uses a ciegas: esto elige la prueba, no sustituye a pensar. Te devuelve un p y nada más. Le falta lo que viene en el capítulo 12, que es lo que de verdad hay que reportar 😌
Comprueba que lo tienes
El ji cuadrado del segmento sale 194,30 con p = 7,2e-42. ¿Qué haces después?
- Miro los residuos para saber qué celda manda
- Reporto que el segmento es significativo y sigo
- Hago las seis pruebas de a dos para ver cuáles difieren
- Reviso si la muestra es suficiente
Lo que te llevas
- 🧰 La prueba la elige el tipo de las variables. Dos categóricas, ji cuadrado. Numérica entre dos grupos, t. Entre tres o más, ANOVA.
- 📊 Ciudad da p = 0,9813 y categoría 0,8067: nada. Canal 4,3e-14 y segmento 7,2e-42: todo.
- 🔍 Los residuos dicen dónde está el efecto. Bodega tiene +8,299 en "no compró" y Minimarket 0,365, o sea que Minimarket no aporta nada al resultado.
- 🛡️ ANOVA en vez de seis pruebas t porque seis pruebas al 5% suben la falsa alarma al 26%.
- 👻 Ji cuadrado con esperados de 1,78 corre igual y devuelve un p que no vale nada. Con cinco filas, ji cuadrado dio 0,8195 y Fisher 0,4000 sobre la misma tabla.
- 🪨 Una sola venta inventada llevó la prueba t de p = 10⁻²²² a p = 0,5060, y la media de Bodega de 178,70 a 884,66. Mann-Whitney ni se movió.
- 🔗 Si puedes emparejar, empareja: la prueba emparejada se quita de encima toda la variación entre individuos.
Qué viene ahora
Todo este capítulo devuelve valores p, y ya avisé en el 10 de que un p no mide importancia. En el capítulo 12 vemos lo que hay que reportar al lado: el tamaño del efecto y la potencia 📐