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 es | El objetivo es | Prueba | Tamaño del efecto |
|---|---|---|---|
| Categórica | Categórico | Chi cuadrado | V de Cramér |
| Numérica | Categórico | Mann-Whitney | d de Cohen, o el AUC |
| Numérica | Numérico | Correlación de Spearman | El propio coeficiente |
| Categórica | Numérico | Kruskal-Wallis | Epsilon 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.
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
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_descuentoparece que aporta y no aporta. Si te fías, metes una columna de más, que es barato. - 🎣
montoparece 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
montocon 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) yunidades(p 0,23). - 🪤
sin_descuentotiene 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:
montoes 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.