Capítulo 7 de 25 12 secciones 20 min

Cada columna contra el objetivo, con una prueba de por medio

Qué prueba toca según el tipo, por qué el valor p solo no sirve, y las cuatro columnas de ruido puro que salieron significativas.

Cada tipo de cruce tiene su prueba: chi cuadrado con V de Cramér para las categóricas y Mann-Whitney con d de Cohen o AUC para las numéricas, y siempre el tamaño del efecto al lado del valor p. En este archivo manda el segmento, con V de 0,2545 y 34,92 puntos entre niveles, y se caen ciudad, categoria y unidades. La trampa: sin_descuento tiene 12,10 puntos de diferencia y al modelo con las demás columnas le aporta -0,0002, y monto pasa de p 6,30e-38 aquí a 0,1561 controlando por segmento. Un análisis bivariado ordena la investigación, no decide el modelo. Y de 40 columnas de ruido puro, cuatro salieron con p menor que 0,05.

Ahora la pregunta que de verdad importa: ¿cuál de estas columnas tiene que ver con lo que queremos predecir? 🎯

Y no a ojo. A ojo cualquier diferencia parece grande, sobre todo cuando ya tienes una teoría de por qué debería estarlo. Aquí cada cruce lleva una prueba y un tamaño del efecto al lado.

Al final del capítulo van a quedar dos columnas descartadas con nombre y apellido, y una que parece buenísima y que en el capítulo 19 al modelo no le va a aportar nada 🪤

Qué prueba toca según el tipo

La columna esEl objetivo esPruebaTamaño del efecto
CategóricaCategóricoChi cuadradoV de Cramér
NuméricaCategóricoMann-Whitneyd de Cohen, o el AUC
NuméricaNuméricoCorrelación de SpearmanEl propio coeficiente
CategóricaNuméricoKruskal-WallisEpsilon cuadrado

Nuestro objetivo, compro, es categórico, así que aquí usamos las dos primeras filas. Las otras dos están para cuando el objetivo sea un número, que es el otro tipo de problema del capítulo 1 📋

Y uso Mann-Whitney y no la t de Student a propósito: la t supone normalidad y en el capítulo 6 quedó claro que ninguna columna la cumple. Mann-Whitney compara posiciones y no le importa la forma.

El punto de partida

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):
    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

ventas = carga_limpia(URL)
ventas['precio_unitario'] = ventas['monto'] / ventas['unidades']
ventas['sin_compra_previa'] = ventas['fecha_ultima_compra'].isna().astype(int)
ventas['sin_descuento'] = ventas['descuento'].isna().astype(int)

objetivo = ventas['compro']
NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion', 'precio_unitario']
CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria',
               'sin_compra_previa', 'sin_descuento']

print('filas:', len(ventas), '| tasa de compra:', round(objetivo.mean(), 4))
filas: 3000 | tasa de compra: 0.5777

El error que se comete en el primer cruce

stats.chi2_contingency(ventas['segmento'])
TypeError: '<' not supported between instances of 'str' and 'int'

El chi cuadrado no recibe una columna: recibe una tabla de contingencia, o sea el cruce ya hecho. Es el error de la primera vez y el mensaje no ayuda nada, porque habla de comparar textos con números 🤷‍♀️

Lo que hay que pasarle es pd.crosstab(columna, objetivo).

Las categóricas: chi cuadrado y V de Cramér

El chi cuadrado dice si la diferencia se distingue del azar. La V de Cramér dice si es grande, que no es lo mismo.

V=χ2n(k1)

reparte el chi cuadrado entre las filas y los niveles para que el número no dependa del tamaño de la muestra, y así va siempre de cero a uno

def cramer(tabla):
    chi2 = stats.chi2_contingency(tabla)[0]
    n = tabla.values.sum()
    return np.sqrt(chi2 / (n * (min(tabla.shape) - 1)))

filas = []
for c in CATEGORICAS:
    tabla = pd.crosstab(ventas[c], objetivo)
    chi2, p, gl, _esperado = stats.chi2_contingency(tabla)
    tasas = ventas.groupby(c)['compro'].mean()
    filas.append({'columna': c, 'niveles': len(tabla), 'chi2': round(chi2, 2),
                  'p': p, 'cramer_v': round(cramer(tabla), 4),
                  'rango_tasas': round(tasas.max() - tasas.min(), 4)})

print(pd.DataFrame(filas).sort_values('cramer_v', ascending=False)
      .to_string(index=False))
          columna  niveles   chi2            p  cramer_v  rango_tasas
         segmento        4 194.30 7.192881e-42    0.2545       0.3492
sin_compra_previa        2  72.49 1.675050e-17    0.1555       0.2004
            canal        4  65.31 4.313966e-14    0.1475       0.1937
    sin_descuento        2  28.13 1.136009e-07    0.0968       0.1210
        categoria        5   1.61 8.067344e-01    0.0232       0.0312
           ciudad        6   0.73 9.813251e-01    0.0156       0.0232

Léela de arriba abajo, que cuenta una historia entera 📖

El segmento manda: V de 0,2545 y 34,92 puntos entre el mejor y el peor. Después el canal y la bandera de cliente nuevo, las dos alrededor de 0,15.

Y abajo del todo, ciudad y categoria con V de 0,0156 y 0,0232 y valores p de 0,98 y 0,81. Esas dos no dicen nada, y no lo dicen tan claramente que ya se puede escribir en el informe.

Esto es exactamente lo que salió en el libro de estadística con las mismas ventas, por otro camino. Que dos análisis independientes coincidan es lo que convierte un hallazgo en un hecho ✅

Ojo con el rango de tasas, que es la columna que más engaña: cuantos más niveles tiene una variable, más fácil es que dos de ellos se separen por azar. Por eso la V va al lado, que sí lo tiene en cuenta.

Las numéricas: Mann-Whitney y el tamaño del efecto

d=x¯1x¯0sp

la distancia entre las dos medias medida en desviaciones típicas, que es la forma de decir si la diferencia es grande sin depender de las unidades

filas = []
for c in NUMERICAS:
    si = ventas.loc[objetivo == 1, c].dropna()
    no = ventas.loc[objetivo == 0, c].dropna()
    u, p = stats.mannwhitneyu(si, no)
    auc = u / (len(si) * len(no))
    juntas = np.sqrt(((len(si) - 1) * si.var() + (len(no) - 1) * no.var())
                     / (len(si) + len(no) - 2))
    d = (si.mean() - no.mean()) / juntas
    filas.append({'columna': c, 'media_si': round(si.mean(), 2),
                  'media_no': round(no.mean(), 2), 'p': p,
                  'cohen_d': round(d, 4), 'auc': round(auc, 4)})

tabla = pd.DataFrame(filas)
print(tabla.reindex(tabla['auc'].sub(0.5).abs().sort_values(ascending=False).index)
      .to_string(index=False))
        columna  media_si  media_no            p  cohen_d    auc
          monto    930.24    630.77 6.299395e-38   0.4061 0.6374
precio_unitario    145.15     88.61 2.580813e-24   0.2229 0.6086
   satisfaccion      3.23      2.71 3.834664e-21   0.3682 0.6026
      descuento      0.13      0.12 9.850856e-03   0.1062 0.5310
       unidades     11.68     11.49 2.342106e-01   0.0338 0.5127

Ese auc es un regalo del propio Mann-Whitney: el estadístico U dividido entre el producto de los dos tamaños es el AUC de esa columna usada sola como modelo. Sale gratis y se lee de una: 0,5 es no saber nada y 0,6374 es lo que da monto por su cuenta 🎁

La d de Cohen dice lo mismo en otra escala. Las convenciones son 0,2 pequeño, 0,5 mediano y 0,8 grande, así que aquí no hay ninguna grande: la mayor es monto con 0,4061.

Y abajo, unidades con p de 0,234 y d de 0,0338. Esa es la tercera columna que se cae, y van tres.

La trampa que este capítulo tiene que dejar montada

Fíjate en sin_descuento, en la tabla de las categóricas:

  • V de 0
  • 0968 y 12
  • 10 puntos de diferencia entre quien tiene descuento
  • Quien no
cruce = pd.crosstab(ventas['sin_descuento'], objetivo, normalize='index')
print((100 * cruce).round(2).to_string())
print()
chi2, p, gl, _e = stats.chi2_contingency(pd.crosstab(ventas['sin_descuento'], objetivo))
print(f'chi2 {chi2:.2f}   p {p:.3e}   V de Cramer '
      f'{cramer(pd.crosstab(ventas["sin_descuento"], objetivo)):.4f}')
compro             0      1
sin_descuento              
0              39.83  60.17
1              51.93  48.07

chi2 28.13   p 1.136e-07   V de Cramer 0.0968

Con descuento cierra el 60,17% y sin descuento el 48,07%. Doce puntos, con una p de 1,14e-07 🤝

Esa columna parece de las buenas. Y en el capítulo 19, cuando midamos qué usa el modelo, va a salir en −0,0002. Cero patatero.

No es que este capítulo se equivoque. Es que un análisis bivariado mira cada columna sola, y ahí sin_descuento dice mucho. Cuando el modelo ya tiene las otras doce columnas, esa información ya se la está dando otra, y entonces esta no añade nada.

Guárdate esa idea, porque es la limitación de todo este capítulo y el motivo por el que existe el siguiente 🔗

Y otra trampa, en la dirección contraria

monto es la mejor de las numéricas: AUC 0,6374 y p de 6,30e-38, que es un número absurdamente pequeño.

Pues en el capítulo 21, con las demás columnas dentro del mismo modelo, monto sale con p de 0,1561 y su intervalo cruza el 1.

La explicación es la misma vista al revés: aquí el monto se lleva el mérito de cosas que no son suyas. Los mayoristas compran más caro y cierran más, así que mirando el monto solo, parece que el monto explica el cierre. Descontando el segmento, casi nada.

Las dos trampas juntas son la lección del capítulo: un análisis bivariado ordena la investigación, no decide el modelo 🧭

Y fíjate en que van en direcciones contrarias, que es lo que las hace peligrosas:

  • 🪤 sin_descuento parece que aporta y no aporta. Si te fías, metes una columna de más, que es barato.
  • 🎣 monto parece que explica la compra y lo que explica es el segmento. Si te fías, sales de la reunión diciendo "subid el ticket medio y cerraréis más", que es una recomendación de negocio equivocada y cara.

El segundo error no lo arregla ningún modelo, porque el modelo ni se entera: la frase se dijo en una reunión, con una tabla de Excel delante y sin entrenar nada 💸

Cuarenta columnas de puro ruido

Antes de fiarte de una tabla de valores p, hay que saber cuántos aparecen sin que haya nada. Se mide inventando columnas que no significan nada.

from statsmodels.stats.multitest import multipletests

rng = np.random.default_rng(7)
y = objetivo.values
ruido = rng.normal(size=(len(ventas), 40))

ps = [stats.mannwhitneyu(ruido[y == 1, j], ruido[y == 0, j])[1] for j in range(40)]
sobrevive, ajustadas, _a, _b = multipletests(ps, alpha=0.05, method='fdr_bh')

print('columnas de ruido con p menor que 0,05:', int(sum(p < 0.05 for p in ps)), 'de 40')
print('y despues del ajuste                  :', int(sobrevive.sum()), 'de 40')
print('la mejor p sin ajustar:', round(min(ps), 4))
print('las que se colaron    :', sorted(round(p, 4) for p in ps if p < 0.05))
columnas de ruido con p menor que 0,05: 4 de 40
y despues del ajuste                  : 0 de 40
la mejor p sin ajustar: 0.0065
las que se colaron    : [np.float64(0.0065), np.float64(0.0296), np.float64(0.0359), np.float64(0.0443)]

Cuatro de cuarenta. Y no hay nada ahí, las inventé yo con un generador de números al azar 😱

Con cuarenta pruebas al 5% se esperan dos falsos positivos, así que cuatro está dentro de lo normal. El mejor de esos falsos sale con p de 0,0065, que en cualquier informe pasaría por hallazgo.

El ajuste de Benjamini-Hochberg los mata los cuarenta. Ese ajuste ordena los valores p y sube el listón según cuántas pruebas hiciste, y aquí acierta de pleno: no había nada y no deja pasar nada 🎯

El mismo ajuste sobre las columnas de verdad

nombres, ps = [], []
for c in CATEGORICAS:
    nombres.append(c)
    ps.append(stats.chi2_contingency(pd.crosstab(ventas[c], objetivo))[1])
for c in NUMERICAS:
    si = ventas.loc[objetivo == 1, c].dropna()
    no = ventas.loc[objetivo == 0, c].dropna()
    nombres.append(c)
    ps.append(stats.mannwhitneyu(si, no)[1])

sobrevive, ajustadas, _a, _b = multipletests(ps, alpha=0.05, method='fdr_bh')
print(pd.DataFrame({'columna': nombres, 'p': ps, 'p_ajustada': ajustadas,
                    'sobrevive': sobrevive}).to_string(index=False))
          columna            p   p_ajustada  sobrevive
           ciudad 9.813251e-01 9.813251e-01      False
         segmento 7.192881e-42 7.912169e-41       True
            canal 4.313966e-14 7.908938e-14       True
        categoria 8.067344e-01 8.874078e-01      False
sin_compra_previa 1.675050e-17 3.685110e-17       True
    sin_descuento 1.136009e-07 1.785158e-07       True
         unidades 2.342106e-01 2.862574e-01      False
            monto 6.299395e-38 3.464667e-37       True
        descuento 9.850856e-03 1.354493e-02       True
     satisfaccion 3.834664e-21 1.054533e-20       True
  precio_unitario 2.580813e-24 9.462982e-24       True

Las mismas tres se caen: ciudad, categoria y unidades. Y las ocho que quedan sobreviven con margen de sobra, porque sus valores p son tan pequeños que ningún ajuste razonable las toca.

Cuando el ajuste no cambia ninguna conclusión, como aquí, es buena señal: significa que tus hallazgos no dependían de dónde pusiste el listón 💪

Ejercicios

1. El informe bivariado completo, en una tabla

Junta las dos mitades del capítulo en una sola función que recorra todas las columnas y aplique la prueba que toque.

def cruza(v, columnas_cat, columnas_num, y):
    filas = []
    for c in columnas_cat:
        tabla = pd.crosstab(v[c], y)
        p = stats.chi2_contingency(tabla)[1]
        filas.append({'columna': c, 'tipo': 'categorica', 'prueba': 'chi2',
                      'p': p, 'efecto': round(cramer(tabla), 4),
                      'medida': 'V de Cramer'})
    for c in columnas_num:
        si, no = v.loc[y == 1, c].dropna(), v.loc[y == 0, c].dropna()
        u, p = stats.mannwhitneyu(si, no)
        filas.append({'columna': c, 'tipo': 'numerica', 'prueba': 'mann-whitney',
                      'p': p, 'efecto': round(u / (len(si) * len(no)), 4),
                      'medida': 'AUC'})
    salida = pd.DataFrame(filas)
    _s, salida['p_ajustada'], _a, _b = multipletests(salida['p'], method='fdr_bh')
    salida['veredicto'] = np.where(salida['p_ajustada'] < 0.05,
                                   'entra', 'no se distingue del ruido')
    return salida.sort_values('p')

print(cruza(ventas, CATEGORICAS, NUMERICAS, objetivo).to_string(index=False))
          columna       tipo       prueba            p  efecto      medida   p_ajustada                 veredicto
         segmento categorica         chi2 7.192881e-42  0.2545 V de Cramer 7.912169e-41                     entra
            monto   numerica mann-whitney 6.299395e-38  0.6374         AUC 3.464667e-37                     entra
  precio_unitario   numerica mann-whitney 2.580813e-24  0.6086         AUC 9.462982e-24                     entra
     satisfaccion   numerica mann-whitney 3.834664e-21  0.6026         AUC 1.054533e-20                     entra
sin_compra_previa categorica         chi2 1.675050e-17  0.1555 V de Cramer 3.685110e-17                     entra
            canal categorica         chi2 4.313966e-14  0.1475 V de Cramer 7.908938e-14                     entra
    sin_descuento categorica         chi2 1.136009e-07  0.0968 V de Cramer 1.785158e-07                     entra
        descuento   numerica mann-whitney 9.850856e-03  0.5310         AUC 1.354493e-02                     entra
         unidades   numerica mann-whitney 2.342106e-01  0.5127         AUC 2.862574e-01 no se distingue del ruido
        categoria categorica         chi2 8.067344e-01  0.0232 V de Cramer 8.874078e-01 no se distingue del ruido
           ciudad categorica         chi2 9.813251e-01  0.0156 V de Cramer 9.813251e-01 no se distingue del ruido

Esta tabla es la que se pega en el informe y la que se lleva a la reunión de arranque 📋

Y fíjate en el veredicto de la última columna: dice "no se distingue del ruido", no dice "no sirve". La diferencia no es de cortesía, es de honestidad: con más datos o con la columna construida de otra forma, esa conclusión puede cambiar.

2. La misma pregunta partida por segmento

Una relación global puede ser distinta dentro de cada grupo. Repite el cruce del canal dentro de cada segmento.

for segmento in sorted(ventas['segmento'].unique()):
    trozo = ventas[ventas['segmento'] == segmento]
    tabla = pd.crosstab(trozo['canal'], trozo['compro'])
    chi2, p, gl, _e = stats.chi2_contingency(tabla)
    tasas = trozo.groupby('canal')['compro'].mean()
    print(f'{segmento:12s} n {len(trozo):4d}  V {cramer(tabla):.4f}  p {p:.4f}  '
          f'mejor canal {tasas.idxmax():10s} {tasas.max():.3f}  '
          f'peor {tasas.idxmin():10s} {tasas.min():.3f}')
Bodega       n  707  V 0.1328  p 0.0060  mejor canal WhatsApp   0.455  peor Marketplace 0.276
Horeca       n  742  V 0.1875  p 0.0000  mejor canal Tienda     0.759  peor Marketplace 0.505
Mayorista    n  750  V 0.1424  p 0.0016  mejor canal WhatsApp   0.819  peor Marketplace 0.637
Minimarket   n  801  V 0.1946  p 0.0000  mejor canal WhatsApp   0.692  peor Marketplace 0.416

Y la respuesta es que no hay interacción: Marketplace es el peor canal en los cuatro segmentos y WhatsApp el mejor en tres. El orden es el mismo en todas partes, solo cambia el nivel 🔀

Si el mejor canal fuera distinto en cada segmento, eso sí sería una interacción y habría que dársela escrita al modelo, como en el capítulo 9. Aquí no hace falta.

Y ojo con el tamaño de cada trozo: al partir en cuatro, cada prueba se queda con la cuarta parte de los datos y pierde capacidad de detectar. Una p que sube al partir no significa que el efecto desaparezca dentro del grupo.

3. Cuánto habría hecho falta para detectar lo que no salió

ciudad salió con p de 0,98. ¿Es que no hay efecto, o es que no había datos suficientes? Se contesta al revés: qué efecto habrías detectado con este tamaño.

from statsmodels.stats.power import GofChisquarePower

n = len(ventas)
prueba = GofChisquarePower()
for efecto in (0.05, 0.08, 0.10, 0.15):
    poder = prueba.power(effect_size=efecto, nobs=n, n_bins=6, alpha=0.05)
    print(f'V de {efecto:.2f} -> lo detectaria el {100 * poder:5.1f}% de las veces')
print()
print('la V que salio en ciudad:', round(cramer(pd.crosstab(ventas['ciudad'], objetivo)), 4))
V de 0.05 -> lo detectaria el  53.3% de las veces
V de 0.08 -> lo detectaria el  94.3% de las veces
V de 0.10 -> lo detectaria el  99.6% de las veces
V de 0.15 -> lo detectaria el 100.0% de las veces

la V que salio en ciudad: 0.0156

Con 3.000 filas y seis niveles, un efecto de V 0,08 se detectaría casi siempre. Y la ciudad salió en 0,0156 🔍

Así que la conclusión se puede escribir con más fuerza de la habitual: no es solo que no encontramos efecto, es que si hubiera uno del tamaño que importaría, lo habríamos visto.

Esta cuenta es la que casi nadie hace y la que convierte un "no salió significativo" en una conclusión de verdad.

4. Qué pasa si usas la prueba equivocada

La t de Student supone normalidad y ninguna columna la cumple. Compara lo que dicen las dos.

for c in NUMERICAS:
    si, no = ventas.loc[objetivo == 1, c].dropna(), ventas.loc[objetivo == 0, c].dropna()
    _t, p_t = stats.ttest_ind(si, no, equal_var=False)
    _u, p_u = stats.mannwhitneyu(si, no)
    print(f'{c:18s} t de Student p {p_t:.3e}   Mann-Whitney p {p_u:.3e}   '
          f'{"coinciden" if (p_t < 0.05) == (p_u < 0.05) else "NO COINCIDEN"}')
unidades           t de Student p 3.601e-01   Mann-Whitney p 2.342e-01   coinciden
monto              t de Student p 1.031e-28   Mann-Whitney p 6.299e-38   coinciden
descuento          t de Student p 1.062e-02   Mann-Whitney p 9.851e-03   coinciden
satisfaccion       t de Student p 1.611e-21   Mann-Whitney p 3.835e-21   coinciden
precio_unitario    t de Student p 1.020e-10   Mann-Whitney p 2.581e-24   coinciden

Las cinco coinciden en el veredicto, y eso no es casualidad ni suerte: con 3.000 filas el teorema central del límite hace que la t aguante aunque los datos no sean normales 💪

La lección no es "da igual cuál uses". Es que con estos tamaños da igual. Con 30 filas por grupo, la misma comparación puede dar dos respuestas distintas, y ahí es donde elegir bien cuesta dinero.

5. Los nulos también son una columna

Ya lo hicimos con el descuento. Hazlo con todas: convierte cada patrón de nulos en una bandera y crúzalo.

for c in ('descuento', 'satisfaccion', 'fecha_ultima_compra'):
    bandera = ventas[c].isna().astype(int)
    tabla = pd.crosstab(bandera, objetivo)
    chi2, p, gl, _e = stats.chi2_contingency(tabla)
    tasas = ventas.groupby(bandera)['compro'].mean()
    print(f'falta_{c:22s} nulos {int(bandera.sum()):4d}  V {cramer(tabla):.4f}  '
          f'p {p:.3e}  con dato {tasas[0]:.3f}  sin dato {tasas[1]:.3f}')
falta_descuento              nulos  595  V 0.0968  p 1.136e-07  con dato 0.602  sin dato 0.481
falta_satisfaccion           nulos  231  V 0.0229  p 2.091e-01  con dato 0.574  sin dato 0.619
falta_fecha_ultima_compra    nulos  544  V 0.1555  p 1.675e-17  con dato 0.614  sin dato 0.414

Dos de las tres dicen mucho y una no dice nada 🕳️

Que falte la fecha de última compra vale 20 puntos (0,614 contra 0,414) y que falte el descuento vale 12. Pero que falte la satisfacción sale con V de 0,0229 y p de 0,209: ese hueco no significa nada.

Y esa diferencia es justo la que hay que buscar. Cuando el hueco dice algo, rellenarlo con la mediana y seguir borra información, y por eso las banderas del capítulo 9 van siempre. Cuando no dice nada, como aquí con la satisfacción, la bandera es una columna de más que no aporta.

Sin esta comprobación se hacen las tres banderas por si acaso. Con ella se sabe cuál de las tres se puede quitar 📏

6. Cuántos pares mirar antes de que el ajuste te coma

Si en vez de once columnas tuvieras doscientas, ¿cuánto tendría que valer una p para sobrevivir al ajuste?

for cuantas in (11, 40, 200, 1000):
    ps_falsas = np.full(cuantas, 0.9)
    ps_falsas[0] = 0.001
    sobrevive, ajustadas, _a, _b = multipletests(ps_falsas, alpha=0.05,
                                                 method='fdr_bh')
    bonferroni = 0.05 / cuantas
    print(f'{cuantas:5d} pruebas -> una p de 0,001 {"SI" if sobrevive[0] else "no"} '
          f'sobrevive a Benjamini-Hochberg | listón de Bonferroni {bonferroni:.6f}')
   11 pruebas -> una p de 0,001 SI sobrevive a Benjamini-Hochberg | listón de Bonferroni 0.004545
   40 pruebas -> una p de 0,001 SI sobrevive a Benjamini-Hochberg | listón de Bonferroni 0.001250
  200 pruebas -> una p de 0,001 no sobrevive a Benjamini-Hochberg | listón de Bonferroni 0.000250
 1000 pruebas -> una p de 0,001 no sobrevive a Benjamini-Hochberg | listón de Bonferroni 0.000050

Bonferroni divide el 0,05 entre el número de pruebas y es durísimo: con mil comparaciones te pide una p menor que 0,00005 🥶

Benjamini-Hochberg es más razonable porque controla otra cosa: no la probabilidad de tener un falso positivo, sino la proporción de falsos entre los que declaras. Para explorar es el que se usa; para decidir algo irreversible, Bonferroni.

7. Lo mismo, si el objetivo fuera un número

Las otras dos filas de la tabla del principio. Cruza las columnas contra monto en vez de contra compro.

print('categoricas contra el monto (Kruskal-Wallis):')
for c in ('segmento', 'canal', 'ciudad', 'categoria'):
    grupos = [g['monto'].values for _n, g in ventas.groupby(c)]
    h, p = stats.kruskal(*grupos)
    epsilon = (h - len(grupos) + 1) / (len(ventas) - len(grupos))
    print(f'  {c:12s} H {h:8.2f}  p {p:.3e}  epsilon2 {epsilon:.4f}')

print('\nnumericas contra el monto (Spearman):')
for c in ('unidades', 'satisfaccion', 'descuento'):
    juntas = ventas[[c, 'monto']].dropna()
    rho, p = stats.spearmanr(juntas[c], juntas['monto'])
    print(f'  {c:12s} rho {rho:+.4f}  p {p:.3e}')
categoricas contra el monto (Kruskal-Wallis):
  segmento     H  2401.47  p 0.000e+00  epsilon2 0.8006
  canal        H     0.93  p 8.179e-01  epsilon2 -0.0007
  ciudad       H     0.55  p 9.901e-01  epsilon2 -0.0015
  categoria    H     5.69  p 2.238e-01  epsilon2 0.0006

numericas contra el monto (Spearman):
  unidades     rho +0.0224  p 2.190e-01
  satisfaccion rho -0.0052  p 7.864e-01
  descuento    rho -0.0201  p 3.234e-01

Mira el epsilon2 del segmento contra el monto: 0,8006 . El segmento explica el 80% de la variación del monto, y los otros tres se quedan pegados a cero 🤯

Guárdate ese número, porque es el que explica las dos trampas de este capítulo. Si el segmento y el monto están así de pegados, cualquier cosa que diga el monto sobre la compra puede ser en realidad del segmento. El capítulo 8 lo demuestra.

Y Spearman en vez de Pearson, por la razón de siempre: Pearson mide si la relación es una recta y Spearman si es creciente, sin pedirle forma 📈

Con las colas que tiene monto, Pearson se deja arrastrar por cuatro ventas grandes. Spearman las trata como lo que son: las cuatro más grandes, sin importar cuánto.

Comprueba que lo tienes

Cruzas 40 columnas contra el objetivo y cuatro salen con p por debajo de 0,05. ¿Qué concluyes?

  • Nada todavía: con 40 pruebas al 5% se esperan dos por puro azar
  • Que esas cuatro son las que importan
  • Que las otras 36 no sirven
  • Que hay que bajar el umbral a 0,01

Lo que te llevas

  • 🧮 Cada tipo de cruce tiene su prueba: chi cuadrado con V de Cramér para las categóricas, Mann-Whitney con d de Cohen o AUC para las numéricas.
  • 📐 El valor p dice si se distingue del azar y el tamaño del efecto dice si importa. Los dos, siempre, y en esta tabla no hay ni un efecto grande: el mayor es monto con d de 0,4061.
  • 🎁 El AUC de una columna sola sale gratis del propio Mann-Whitney: es U dividido entre el producto de los dos tamaños.
  • 🥇 Manda el segmento, con V de 0,2545 y 34,92 puntos entre el mejor y el peor nivel.
  • 🚪 Se caen tres con nombre y apellido: ciudad (p 0,98), categoria (p 0,81) y unidades (p 0,23).
  • 🪤 sin_descuento tiene 12,10 puntos de diferencia y p de 1,14e-07, y al modelo con las otras doce columnas le va a aportar −0,0002. Bivariado no es lo mismo que dentro de un modelo.
  • 🔄 Y al revés: monto es la mejor numérica aquí, con p de 6,30e-38, y controlando por segmento y canal se queda en 0,1561. Se estaba llevando el mérito de otra columna.
  • 😱 De 40 columnas de ruido puro, cuatro salieron con p menor que 0,05 y la mejor con 0,0065. El ajuste de Benjamini-Hochberg mata las cuarenta.
  • 🧭 Un análisis bivariado ordena la investigación. No decide el modelo.

Qué viene ahora

Ya sabemos qué columna tiene que ver con el objetivo. Falta la otra mitad: qué tienen que ver las columnas entre ellas 🕸️

Y esa es justo la pregunta que explica las dos trampas de este capítulo. Si el monto se lleva el mérito del segmento es porque están relacionados, y eso aquí no se puede ver.

El capítulo 8 mira la maraña:

  • 🔗 La matriz de correlaciones, y por qué se lee con Spearman.
  • 🪞 Columnas que dicen lo mismo con otro nombre, y qué hacer con ellas.
  • 📉 Reducir trece columnas a unas pocas y ver cuánta información sobrevive.
  • 🎭 Y una relación que se da la vuelta al partir por grupos, que es lo que hace que un informe entero diga lo contrario de lo que pasa.
¿Tienes alguna duda o consulta?