El capítulo anterior dejó dos preguntas colgando, y las dos se contestan igual: mirando las columnas entre ellas 🕸️
sin_descuento tenía doce puntos y no le va a aportar nada al
modelo. monto era la mejor de las numéricas y en el capítulo
21 se queda en p de 0,1561.
Las dos cosas pasan por lo mismo: una columna no vive sola. Y este capítulo termina con un resultado que a mí me sigue pareciendo el más bonito del libro 🤯
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']
NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion',
'precio_unitario', 'monto_final_facturado', 'compro']
print('columnas que entran en la maraña:', len(NUMERICAS))
columnas que entran en la maraña: 7
El error de la primera matriz
ventas[['segmento', 'monto']].corr()
ValueError: could not convert string to float: 'Horeca'
Una correlación necesita números y segmento es texto. Para
cruzar dos categóricas entre ellas hace falta otra cosa, y la vemos más abajo
🔤
La matriz, con Spearman y no con Pearson
la correlación calculada sobre los puestos y no sobre los valores, así que mide si una sube cuando la otra sube sin pedirle que sea una recta
print(ventas[NUMERICAS].corr(method='spearman').round(3).to_string())
unidades monto descuento satisfaccion precio_unitario monto_final_facturado compro unidades 1.000 0.022 -0.005 0.005 -0.493 0.015 0.022 monto 0.022 1.000 -0.020 -0.005 0.814 0.540 0.235 descuento -0.005 -0.020 1.000 -0.019 -0.009 0.005 0.053 satisfaccion 0.005 -0.005 -0.019 1.000 -0.008 0.138 0.179 precio_unitario -0.493 0.814 -0.009 -0.008 1.000 0.439 0.186 monto_final_facturado 0.015 0.540 0.005 0.138 0.439 1.000 0.890 compro 0.022 0.235 0.053 0.179 0.186 0.890 1.000
Antes de leerla, la pregunta que casi nadie hace: ¿por qué Spearman?
spearman = ventas[NUMERICAS].corr(method='spearman')
pearson = ventas[NUMERICAS].corr(method='pearson')
pares = []
for i, a in enumerate(NUMERICAS):
for j, b in enumerate(NUMERICAS):
if i < j:
pares.append((abs(spearman.iloc[i, j] - pearson.iloc[i, j]),
a, b, spearman.iloc[i, j], pearson.iloc[i, j]))
for diferencia, a, b, s, p in sorted(pares, reverse=True)[:4]:
print(f'{a:22s} {b:22s} spearman {s:+.3f} pearson {p:+.3f} '
f'se llevan {diferencia:.3f}')
monto precio_unitario spearman +0.814 pearson +0.446 se llevan 0.369 monto_final_facturado compro spearman +0.890 pearson +0.606 se llevan 0.283 monto monto_final_facturado spearman +0.540 pearson +0.740 se llevan 0.200 unidades precio_unitario spearman -0.493 pearson -0.409 se llevan 0.083
Ahí está el motivo 👀
monto y precio_unitario dan 0,814 con Spearman y
0,446 con Pearson. Casi el doble.
Pearson mide si la relación es una recta, y esas dos
columnas suben juntas pero no en línea recta: en el capítulo
6 vimos que precio_unitario tiene asimetría
6,734, así que cuatro ventas enormes le doblan la recta a Pearson y le esconden
la relación.
Spearman trabaja con los puestos, no con los valores. La venta más cara es la número uno, valga 3.000 o 300.000. Por eso no se deja arrastrar 📊
Los pares que hay que mirar sí o sí
c = ventas[NUMERICAS].corr(method='spearman').abs()
for i, a in enumerate(NUMERICAS):
for j, b in enumerate(NUMERICAS):
if i < j and c.iloc[i, j] > 0.5:
print(f'{a:22s} {b:22s} {c.iloc[i, j]:.4f}')
monto precio_unitario 0.8142 monto monto_final_facturado 0.5397 monto_final_facturado compro 0.8897
Tres pares, y cada uno cuenta una cosa distinta:
- 🪞
montoconprecio_unitario, 0,8142. Normal: la segunda sale de dividir la primera. Son casi la misma columna y por eso en el capítulo 21 se repartían el efecto. - 💰
montoconmonto_final_facturado, 0,5397. También esperable, son dos formas de contar el mismo dinero. - 🚨
monto_final_facturadoconcompro, 0,8897. Y esa no es normal en absoluto.
Una columna que correlaciona 0,89 con lo que quieres predecir no es una buena columna: es una alarma. Es la fuga del capítulo 12, y aquí sale sin entrenar ningún modelo, mirando una matriz 🎯
La regla que uso: por encima de 0,8 contra el objetivo, primero desconfío y después celebro. Casi siempre resulta que la columna se rellena después de que pase lo que quiero predecir.
Y las categóricas entre ellas
Para dos categóricas no hay correlación, hay V de Cramér, que es la misma del capítulo 7 usada entre columnas en vez de contra el objetivo.
CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria']
def cramer(tabla):
chi2 = stats.chi2_contingency(tabla)[0]
n = tabla.values.sum()
return np.sqrt(chi2 / (n * (min(tabla.shape) - 1)))
for i, a in enumerate(CATEGORICAS):
for j, b in enumerate(CATEGORICAS):
if i < j:
print(f'{a:12s} {b:12s} V {cramer(pd.crosstab(ventas[a], ventas[b])):.4f}')
ciudad segmento V 0.0267 ciudad canal V 0.0372 ciudad categoria V 0.0480 segmento canal V 0.0319 segmento categoria V 0.0443 canal categoria V 0.0340
Las seis por debajo de 0,05, o sea prácticamente independientes 😌
Eso significa que saber el segmento no te dice nada del canal, y que el one-hot de las cuatro no va a crear columnas redundantes. Aburrido otra vez, y otra vez es buena noticia.
Y ahora sí: la relación que se evapora
Esta es la que cierra el hilo de tres capítulos 🎬
global_ = stats.spearmanr(ventas['monto'], ventas['compro'])
print(f'global rho {global_.statistic:+.4f} p {global_.pvalue:.2e} '
f'n {len(ventas)}')
for segmento in sorted(ventas['segmento'].unique()):
trozo = ventas[ventas['segmento'] == segmento]
r = stats.spearmanr(trozo['monto'], trozo['compro'])
print(f'{segmento:12s} rho {r.statistic:+.4f} p {r.pvalue:.4f} n {len(trozo)}')
global rho +0.2351 p 6.01e-39 n 3000 Bodega rho +0.0324 p 0.3903 n 707 Horeca rho -0.0128 p 0.7275 n 742 Mayorista rho +0.0061 p 0.8681 n 750 Minimarket rho +0.0262 p 0.4594 n 801
Míralo despacio, porque es de esas cosas que hay que ver dos veces 🤯
En las 3.000 ventas juntas, el monto correlaciona +0,2351 con la compra, con una p de 6,01e-39. Un número que en cualquier informe se escribe como hallazgo.
Y dentro de cada segmento no queda nada: +0,0324, −0,0128, +0,0061, +0,0262. Las cuatro con p por encima de 0,39, y con setecientas filas cada una, que son de sobra para detectar algo de ese tamaño.
La explicación está en dos tablas:
print(ventas.groupby('segmento')['monto'].agg(['mean', 'median', 'count'])
.round(2).to_string())
print()
print(ventas.groupby('segmento')['compro'].mean().round(4).to_string())
mean median count segmento Bodega 178.70 183.26 707 Horeca 755.18 743.00 742 Mayorista 1856.34 1869.26 750 Minimarket 414.92 413.74 801 segmento Bodega 0.3748 Horeca 0.6321 Mayorista 0.7240 Minimarket 0.5693
El mayorista compra por 1.856 soles de media y cierra el 72,40%. La bodega compra por 178 y cierra el 37,48%.
O sea que el monto no predice la compra: predice el segmento, y el segmento sí predice la compra. Cuando miras las 3.000 juntas, el monto se lleva un mérito que no es suyo 🎭
Esto tiene nombre, se llama confusión o variable confusora, y es la razón por la que el capítulo 21 daba p de 0,1561 para el monto: allí el segmento estaba dentro del modelo, así que ya no le dejaba llevarse nada prestado.
Y la razón por la que un análisis bivariado, él solo, puede llevarte a una conclusión completamente equivocada. "Sube el ticket medio y cerrarás más" es exactamente lo que se deduce del +0,2351, y aquí está la prueba de que no 🚫
Reducir trece columnas a unas pocas
Cuando hay muchas columnas que dicen lo mismo, se pueden resumir. El PCA busca las direcciones donde los datos más varían y las pone primero.
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
SOLO_NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion',
'precio_unitario']
M = ventas[SOLO_NUMERICAS].fillna(ventas[SOLO_NUMERICAS].median())
Z = StandardScaler().fit_transform(M)
pca = PCA().fit(Z)
acumulado = np.cumsum(pca.explained_variance_ratio_)
for i, (parte, total) in enumerate(zip(pca.explained_variance_ratio_, acumulado), 1):
print(f'componente {i}: explica {100 * parte:5.2f}% acumulado {100 * total:6.2f}%')
print()
print('componentes para llegar al 90%:', int(np.argmax(acumulado >= 0.9) + 1),
'de', len(SOLO_NUMERICAS))
componente 1: explica 31.95% acumulado 31.95% componente 2: explica 20.64% acumulado 52.58% componente 3: explica 20.28% acumulado 72.86% componente 4: explica 19.42% acumulado 92.28% componente 5: explica 7.72% acumulado 100.00% componentes para llegar al 90%: 4 de 5
Y aquí el PCA no sirve para nada, que también es un resultado 😅
Hacen falta cuatro componentes de cinco para llegar al 90%. Cambiar cinco columnas que se entienden por cuatro que no se entienden es un mal negocio.
Tiene todo el sentido: la matriz de correlaciones ya decía que estas columnas son casi independientes. El PCA solo comprime lo que está repetido, y aquí no hay casi nada repetido.
El escalado antes del PCA no es opcional, por cierto. Sin él,
monto mandaría en la primera componente solo por ir en miles
mientras satisfaccion va de 1 a 5 📏
Qué mira cada componente
carga = pd.DataFrame(pca.components_[:2].T, index=SOLO_NUMERICAS,
columns=['CP1', 'CP2'])
print(carga.round(3).to_string())
CP1 CP2 unidades -0.472 0.613 monto 0.519 0.555 descuento -0.022 -0.562 satisfaccion -0.033 0.028 precio_unitario 0.712 -0.014
La primera componente es precio_unitario (0,712) contra
unidades (−0,472): comprar caro y poco, o barato y mucho. Eso es
una idea de negocio con nombre, y es lo único interpretable que sale de aquí.
La segunda mezcla unidades, monto y descuento con signos distintos, y ya no se puede decir en voz alta. Ese es el precio del PCA: ganas compresión y pierdes la capacidad de explicar 🔇
Ejercicios
1. El buscador de columnas gemelas
Automatiza lo de arriba: una función que devuelva los pares que hay que revisar antes de modelar.
def gemelas(v, columnas, listón=0.7):
c = v[columnas].corr(method='spearman').abs()
avisos = []
for i, a in enumerate(columnas):
for j, b in enumerate(columnas):
if i < j and c.iloc[i, j] >= listón:
avisos.append(f'{a} y {b}: {c.iloc[i, j]:.4f}')
return avisos
for listón in (0.5, 0.7, 0.9):
encontradas = gemelas(ventas, NUMERICAS, listón)
print(f'con listón {listón}: {len(encontradas)} pares')
for a in encontradas:
print(' ', a)
con listón 0.5: 3 pares
monto y precio_unitario: 0.8142
monto y monto_final_facturado: 0.5397
monto_final_facturado y compro: 0.8897
con listón 0.7: 2 pares
monto y precio_unitario: 0.8142
monto_final_facturado y compro: 0.8897
con listón 0.9: 0 pares
Ese listón no tiene un valor correcto: depende de para qué. Para una regresión, dos columnas a 0,9 se pisan y hay que quitar una. Para un boosting, casi da igual 🌳
Lo que sí es universal es mirar la lista antes de entrenar, porque un par a 0,89 contra el objetivo no es colinealidad, es una fuga.
2. Qué pasa si quitas una de las gemelas
monto y precio_unitario van a
0,81. Mide qué se pierde quitando una.
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, StratifiedKFold
from sklearn.pipeline import make_pipeline
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
y = ventas['compro']
juegos = {
'las dos': ['monto', 'precio_unitario', 'satisfaccion', 'unidades'],
'solo monto': ['monto', 'satisfaccion', 'unidades'],
'solo precio': ['precio_unitario', 'satisfaccion', 'unidades'],
}
for nombre, cols in juegos.items():
X = ventas[cols].fillna(ventas[cols].median())
modelo = make_pipeline(StandardScaler(),
LogisticRegression(max_iter=1000, random_state=42))
s = cross_val_score(modelo, X, y, cv=cv, scoring='roc_auc')
print(f'{nombre:14s} AUC {s.mean():.4f} +/- {s.std():.4f}')
las dos AUC 0.6594 +/- 0.0164 solo monto AUC 0.6576 +/- 0.0172 solo precio AUC 0.6384 +/- 0.0112
Y ojo, que no son intercambiables 🪞
Con las dos sale 0,6594 y quitando precio_unitario sale 0,6576:
dieciocho diezmilésimas, o sea nada. Pero quitando monto se cae a
0,6384, veintiún milésimas menos, que ya es más que la desviación de la
validación cruzada.
Así que van a 0,81 de correlación y aun así una vale más que la otra. Eso es lo que la matriz no puede decirte: correlacionadas no es lo mismo que equivalentes.
La regla práctica: cuando dos columnas se solapan, se prueba quitando cada una y se mira el AUC. Y a igualdad de resultado, te quedas con la que vas a poder recoger mañana sin problemas.
3. La confusión, buscada a propósito
Repite lo de monto con todas las numéricas:
correlación global contra correlación dentro de los segmentos.
for c in ('monto', 'precio_unitario', 'satisfaccion', 'unidades', 'descuento'):
juntas = ventas[[c, 'compro', 'segmento']].dropna()
total = stats.spearmanr(juntas[c], juntas['compro']).statistic
dentro = [stats.spearmanr(g[c], g['compro']).statistic
for _n, g in juntas.groupby('segmento')]
print(f'{c:18s} global {total:+.4f} dentro de segmento: '
f'min {min(dentro):+.4f} max {max(dentro):+.4f} '
f'media {np.mean(dentro):+.4f}')
monto global +0.2351 dentro de segmento: min -0.0128 max +0.0324 media +0.0129 precio_unitario global +0.1858 dentro de segmento: min -0.0111 max +0.0257 media +0.0090 satisfaccion global +0.1794 dentro de segmento: min +0.1282 max +0.2229 media +0.1808 unidades global +0.0217 dentro de segmento: min -0.0214 max +0.0413 media +0.0155 descuento global +0.0526 dentro de segmento: min +0.0107 max +0.0925 media +0.0673
Y aquí está el contraste que lo explica todo 🔍
monto pasa de +0,2351 a +0,0129 de media, y
precio_unitario de +0,1858 a +0,0090. Los dos se desploman, porque
los dos dependían del segmento.
satisfaccion pasa de +0,1794 a +0,1808. No se
mueve ni una milésima: esa relación es suya y no se la debe a nadie 💪
Por eso en el capítulo 21 la satisfacción sale con p de 4,12e-22 y el monto con 0,1561, aunque aquí, mirados solos, el monto parezca mejor.
Esta comparación es de las cosas más útiles y más rápidas del análisis exploratorio, y casi nunca se hace. Cuesta seis líneas y evita escribir una recomendación que no se sostiene.
4. El PCA con las categóricas dentro
Arriba solo entraron cinco numéricas. Mete también el one-hot de las cuatro categóricas y mira si cambia algo.
M2 = pd.get_dummies(
ventas[SOLO_NUMERICAS + CATEGORICAS], columns=CATEGORICAS).astype(float)
M2 = M2.fillna(M2.median())
Z2 = StandardScaler().fit_transform(M2)
pca2 = PCA().fit(Z2)
acumulado2 = np.cumsum(pca2.explained_variance_ratio_)
print('columnas de entrada:', M2.shape[1])
print('componentes para el 90%:', int(np.argmax(acumulado2 >= 0.9) + 1))
print('las tres primeras explican:',
f'{100 * acumulado2[2]:.2f}%')
columnas de entrada: 24 componentes para el 90%: 17 las tres primeras explican: 22.10%
Con el one-hot dentro sale aún peor, y es esperable: cada variable categórica se convierte en varias columnas casi independientes entre sí 🧩
Moraleja: el PCA brilla cuando tienes cincuenta columnas numéricas que miden variantes de lo mismo (sensores, encuestas, píxeles). Con trece columnas de negocio bien elegidas, estorba.
5. La matriz, pero de nulos
Las columnas también se relacionan por dónde les faltan datos. Cruza los patrones de nulos entre ellas.
huecos = ventas[['descuento', 'satisfaccion', 'fecha_ultima_compra']].isna()
huecos.columns = ['falta_desc', 'falta_satis', 'falta_fecha']
print(huecos.mean().round(4).to_string())
print()
print(huecos.astype(int).corr(method='spearman').round(4).to_string())
print()
print('filas a las que les falta más de una cosa:',
int((huecos.sum(axis=1) > 1).sum()))
falta_desc 0.1983
falta_satis 0.0770
falta_fecha 0.1813
falta_desc falta_satis falta_fecha
falta_desc 1.0000 -0.0339 -0.0019
falta_satis -0.0339 1.0000 -0.0029
falta_fecha -0.0019 -0.0029 1.0000
filas a las que les falta más de una cosa: 169
Si los nulos correlacionaran entre ellos, no serían tres problemas sino uno: un proceso del negocio que se saltó y se llevó tres campos por delante 🕳️
Cuando salen independientes, como aquí, cada hueco se trata por su cuenta y cada bandera del capítulo 9 aporta algo distinto.
6. El mapa de calor, en texto
Sin librería de gráficos: pinta la matriz con caracteres para verla de un vistazo.
c = ventas[NUMERICAS].corr(method='spearman')
escala = ' .:-=+*#%@'
print(' ' * 24 + ''.join(f'{n[:6]:>7s}' for n in NUMERICAS))
for a in NUMERICAS:
fila = ''
for b in NUMERICAS:
nivel = int(abs(c.loc[a, b]) * (len(escala) - 1))
fila += f'{escala[nivel] * 3:>7s}'
print(f'{a:24s}{fila}')
unidad monto descue satisf precio monto_ compro unidades @@@ === monto @@@ ### === ::: descuento @@@ satisfaccion @@@ ... ... precio_unitario === ### @@@ --- ... monto_final_facturado === ... --- @@@ %%% compro ::: ... ... %%% @@@
Una matriz de trece por trece con números no se lee. Con densidad, sí: el ojo encuentra los bloques oscuros solo 👁️
Y en un cuaderno de verdad esto se hace con un mapa de calor de dos líneas. Lo pongo en texto porque así se ve exactamente qué está pintando y porque funciona en cualquier sitio, incluido un correo.
7. El informe multivariado, en avisos
Cierra el bloque de EDA con la función que resume las tres comprobaciones de este capítulo.
def revisa_maraña(v, numericas, objetivo='compro'):
avisos = []
c = v[numericas].corr(method='spearman')
for i, a in enumerate(numericas):
for j, b in enumerate(numericas):
if i < j and abs(c.iloc[i, j]) >= 0.7:
if objetivo in (a, b):
avisos.append(f'ALARMA: {a} y {b} van a {c.iloc[i, j]:.3f}. '
'Sospecha de fuga.')
else:
avisos.append(f'gemelas: {a} y {b} van a {c.iloc[i, j]:.3f}')
for col in numericas:
if col == objetivo:
continue
juntas = v[[col, objetivo, 'segmento']].dropna()
total = stats.spearmanr(juntas[col], juntas[objetivo]).statistic
dentro = np.mean([stats.spearmanr(g[col], g[objetivo]).statistic
for _n, g in juntas.groupby('segmento')])
if abs(total) > 0.1 and abs(dentro) < abs(total) / 3:
avisos.append(f'confusion: {col} correlaciona {total:+.3f} en total '
f'y {dentro:+.3f} dentro de segmento')
return avisos
for aviso in revisa_maraña(ventas, NUMERICAS):
print(' ', aviso)
gemelas: monto y precio_unitario van a 0.814 ALARMA: monto_final_facturado y compro van a 0.890. Sospecha de fuga. confusion: monto correlaciona +0.235 en total y +0.013 dentro de segmento confusion: precio_unitario correlaciona +0.186 en total y +0.009 dentro de segmento
Cuatro líneas de salida y las cuatro son conversaciones que hay que tener antes de entrenar nada 📋
Con esto se cierra el bloque de exploración. Nueve capítulos para llegar aquí puede parecer mucho, y sin embargo esta función acaba de encontrar una fuga y una variable confusora sin entrenar un solo modelo 💛
Comprueba que lo tienes
Una columna correlaciona +0,24 con el objetivo en las 3.000 filas, y dentro de cada segmento la correlación se va a cero. ¿Qué está pasando?
- Que lo que correlaciona con el objetivo es el segmento, no la columna
- Que hacen falta más datos dentro de cada segmento
- Que la relación es real pero no lineal
- Que hay que quitar el segmento del modelo para verla
Lo que te llevas
- 📊 La matriz se lee con Spearman, no con Pearson.
montoyprecio_unitariodan 0,814 con una y 0,446 con la otra, porque Pearson pide que la relación sea una recta. - 🚨
monto_final_facturadocorrelaciona 0,8897 con el objetivo. Por encima de 0,8 contra lo que quieres predecir, primero se desconfía y después se celebra. - 🪞
montoyprecio_unitarioa 0,81 son casi la misma columna, y por eso en la regresión se repartían el efecto. - 🔤 Las cuatro categóricas están por debajo de 0,05 de V entre ellas, o sea prácticamente independientes.
- 🤯 Y la grande: el monto correlaciona +0,2351 con la compra en las 3.000 filas y dentro de cada segmento se va a cero. El monto no predice la compra, predice el segmento.
- 🎭 Eso se llama variable confusora, explica la p de 0,1561 del capítulo 21 y demuestra que un análisis bivariado solo puede llevarte a una conclusión completamente equivocada.
- 📉 El PCA aquí no sirve: hacen falta cuatro componentes de cinco para el 90%. Comprime lo repetido, y estas columnas no se repiten.
- 🔇 Y lo que sí se lee de él, la primera componente, es una idea de negocio: comprar caro y poco contra barato y mucho.
Qué viene ahora
Nueve capítulos de exploración y ya sabemos qué columna sirve, cuál engaña y cuál es una fuga. Toca convertir todo eso en decisiones 🧱
El capítulo 9 saca cinco columnas nuevas de las que ya hay, y es donde de verdad se gana: cambiar de modelo te da décimas y construir una columna buena te da puntos enteros.
Lo que verás allí:
- 🕳️ El hueco convertido en dato, que es el hallazgo más grande de todo el bloque de exploración.
- 💵 El precio por unidad, que aquí salió correlacionado con el monto y que aun así aporta.
- 📊 El monto partido en tramos, para que un modelo lineal pueda ver lo que solo veía el árbol.
- 🗑️ Una que no sirve, para ver cómo se descarta con argumentos.
- 🪤 Y una trampa que sube el AUC treinta puntos y no vale nada.