Última herramienta del libro, y la que más se usa mal: la correlación 📈
Mide si dos variables se mueven juntas, en una escala de -1 a 1. Cerca de 1, cuando una sube la otra sube. Cerca de -1, cuando una sube la otra baja. Cerca de 0, ni idea.
mide si las dos variables se alejan de su media en la misma dirección, dividido entre lo que se alejan por separado
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(v[['unidades', 'monto', 'descuento', 'satisfaccion', 'compro']]
.corr().round(4))
unidades monto descuento satisfaccion compro unidades 1.0000 0.0178 -0.0078 0.0000 0.0167 monto 0.0178 1.0000 -0.0265 -0.0165 0.1967 descuento -0.0078 -0.0265 1.0000 -0.0192 0.0520 satisfaccion 0.0000 -0.0165 -0.0192 1.0000 0.1792 compro 0.0167 0.1967 0.0520 0.1792 1.0000
Y ahí está el hallazgo que llevo prometiendo desde el capítulo 12 👀
Vender más unidades no da más dinero
r, p = stats.pearsonr(v['unidades'], v['monto'])
print('correlacion unidades-monto: %.4f' % r)
print('valor p: %.4f' % p)
print('r al cuadrado: %.6f' % (r ** 2))
correlacion unidades-monto: 0.0178 valor p: 0.3291 r al cuadrado: 0.000318
0,0178. Prácticamente cero, y con p = 0,3291, o sea que ni siquiera se distingue del ruido 🤯
Piénsalo un segundo porque es contraintuitivo de verdad: saber cuántas unidades lleva una venta no te dice absolutamente nada de cuánto dinero factura.
Ese r al cuadrado lo dice de la forma más clara que hay: 0,000318, o sea que las unidades explican el 0,03% de la variación del monto. Nada.
¿Y por qué? Mira los precios:
v['precio_unitario'] = v['monto'] / v['unidades']
print(v.groupby('segmento')['precio_unitario'].mean().round(2))
segmento Bodega 26.59 Horeca 111.46 Mayorista 279.61 Minimarket 65.69 Name: precio_unitario, dtype: float64
Ahí está 💡 El precio unitario va de 26,59 a 279,61 soles según el segmento. Diez veces de diferencia.
Como cada segmento compra alrededor de las mismas 11 unidades por venta (lo vimos en el capítulo 4: la media era 11,60 en todos), lo que decide el monto no son las unidades sino quién compra.
Y aquí viene la pregunta obligatoria: ¿será que la correlación existe dentro de cada segmento y se pierde al mezclar?
for seg, g in v.groupby('segmento'):
r, p = stats.pearsonr(g['unidades'], g['monto'])
print('%-11s r = %+.4f | p = %.4f | n = %d' % (seg, r, p, len(g)))
Bodega r = +0.0156 | p = 0.6785 | n = 707 Horeca r = -0.0163 | p = 0.6583 | n = 742 Mayorista r = +0.0297 | p = 0.4165 | n = 750 Minimarket r = -0.0114 | p = 0.7481 | n = 801
Pues no. Es cero también dentro de cada segmento 😅
O sea que este es un cero de verdad, no un artefacto de mezclar. En estas ventas, las unidades y el dinero son variables independientes.
La misma correlación con tres formas distintas
Ahora el aviso que hay que tener grabado. Un coeficiente resume una nube de puntos en un número, y por el camino se pierde muchísimo.
generador = np.random.default_rng(7)
n = 200
x_recta = generador.uniform(0, 10, n)
y_recta = 2 * x_recta + generador.normal(0, 4, n)
x_curva = generador.uniform(-5, 5, n)
y_curva = x_curva ** 2 + generador.normal(0, 3, n)
x_nube = np.append(generador.normal(0, 1, n - 1), 15)
y_nube = np.append(generador.normal(0, 1, n - 1), 15)
print('recta con ruido: r = %+.4f' % stats.pearsonr(x_recta, y_recta)[0])
print('curva en U: r = %+.4f' % stats.pearsonr(x_curva, y_curva)[0])
print('nube + 1 atipico: r = %+.4f' % stats.pearsonr(x_nube, y_nube)[0])
recta con ruido: r = +0.8336 curva en U: r = -0.0268 nube + 1 atipico: r = +0.5045
Los tres casos que hay que conocer 🎭
La curva en U da r = -0,0268, o sea cero. Y sin embargo la relación es perfecta: y es exactamente x al cuadrado. Lo que pasa es que Pearson solo ve líneas rectas, y una U no lo es.
La nube con un atípico da r = 0,5045, que en un informe se leería como "correlación moderada". Miremos qué pasa si quito esa fila:
print('con el atipico: r = %+.4f' % stats.pearsonr(x_nube, y_nube)[0])
print('sin el atipico: r = %+.4f' % stats.pearsonr(x_nube[:-1], y_nube[:-1])[0])
print('Spearman (con el atipico): r = %+.4f' % stats.spearmanr(x_nube, y_nube)[0])
con el atipico: r = +0.5045 sin el atipico: r = -0.0917 Spearman (con el atipico): r = -0.1160
De 0,5045 a -0,0917 por una fila entre doscientas 😱
Y mira lo que hace Spearman: da -0,1160 incluso con el atípico dentro. Como trabaja con posiciones y no con valores, ese punto es solo "el último de la fila", igual que pasaba con Mann-Whitney en el capítulo 11.
Pearson o Spearman
| Pearson | Spearman | |
|---|---|---|
| Qué mide | Relación en línea recta | Si una sube cuando la otra sube, aunque no sea recta |
| Usa | Los valores | Las posiciones |
| Atípicos | Le afectan muchísimo | Casi nada |
| Cuándo | Datos limpios y relación recta | Ante la duda, esta |
Mi costumbre: calcular las dos y comparar. Si coinciden, reporta Pearson que se entiende mejor. Si discrepan mucho, hay atípicos o la relación no es recta, y toca mirar 👀
La frase que hay que decir en voz alta
Correlación no implica causalidad.
Todo el mundo la sabe y casi nadie la aplica. Cuando veas dos cosas que se mueven juntas, hay cuatro explicaciones posibles y solo una es la que quieres:
- ➡️ A causa B. Lo que esperabas.
- ⬅️ B causa A. Al revés de lo que pensabas.
- 🎭 Hay un C que causa las dos. El caso más común y el más traicionero.
- 🎲 Casualidad. Si miraste muchas parejas, alguna sale.
En este archivo tenemos un ejemplo perfecto del tercero. La correlación entre monto y compra es 0,1967, y podría contarse como "las ventas grandes se cierran más". Pero ya sabemos por los capítulos 3 y 11 que hay un C detrás: el segmento, que sube las dos cosas a la vez 🎭
El error del capítulo
stats.pearsonr(v['unidades'].head(10), v['monto'].head(5))
ValueError: `x` and `y` must be broadcastable.
Dos columnas de largos distintos. Y este error es más común de lo que parece: sale en cuanto filtras una de las dos y la otra no 🙃
Lo grave es el caso de al lado, que no te frena:
print(stats.pearsonr(v['unidades'], v['satisfaccion']))
PearsonRResult(statistic=np.float64(nan), pvalue=np.float64(nan))
Los 231 nulos de satisfacción otra vez, y otra vez en silencio 🕳️ Un
nan en una matriz de correlaciones pasa desapercibido entre treinta
números.
Y pandas hace una tercera cosa distinta:
print('pandas .corr(): %.6f' % v['unidades'].corr(v['satisfaccion']))
print('scipy sin nulos: %.6f'
% stats.pearsonr(v.dropna(subset=['satisfaccion'])['unidades'],
v.dropna(subset=['satisfaccion'])['satisfaccion'])[0])
pandas .corr(): 0.000026 scipy sin nulos: 0.000026
pandas no devuelve nan: descarta las filas incompletas por su
cuenta y te da el número, sin decírtelo.
O sea que la misma pareja de columnas te da nan con scipy y un
número con pandas, y ninguno de los dos te avisa de nada. El problema se ve
entero en una matriz de correlaciones, donde cada celda puede estar
calculada con un número distinto de filas. La del principio del capítulo
mezcla celdas de 3.000 filas con celdas de 2.769 😑
Practica 💪
1. La correlación que se infla al agregar
Calcula la correlación entre unidades y monto usando las medias de cada segmento en vez de las ventas sueltas. Compara.
medias = v.groupby('segmento')[['unidades', 'monto']].mean()
print(medias.round(2))
print()
print('con las 3000 ventas: r = %+.4f'
% stats.pearsonr(v['unidades'], v['monto'])[0])
print('con las 4 medias: r = %+.4f'
% np.corrcoef(medias['unidades'], medias['monto'])[0, 1])
unidades monto segmento Bodega 11.35 178.70 Horeca 11.54 755.18 Mayorista 11.73 1856.34 Minimarket 11.76 414.92 con las 3000 ventas: r = +0.0178 con las 4 medias: r = +0.5433
De 0,0178 a 0,5433 🤯
Los mismos datos. Lo único que hice fue promediar por segmento antes de correlacionar, y la correlación se multiplicó por treinta.
Por qué pasa: al promediar, el ruido de las ventas individuales desaparece y quedan cuatro puntos que, por casualidad, están ordenaditos. Y con cuatro puntos cualquier cosa parece una tendencia.
Esto tiene nombre, se llama falacia ecológica, y es de los errores más caros que hay. Consiste en sacar conclusiones sobre individuos a partir de datos agregados 🏘️
Se ve mucho en informes por región, por sucursal o por mes: se agregan los datos, aparecen correlaciones preciosas, y ninguna vale para una persona concreta.
2. ¿Se puede correlacionar algo que no es numérico?
Correlaciona compro (que es 0/1) con el monto,
y compara con lo que dio la prueba t.
r, p = stats.pearsonr(v['compro'], v['monto'])
compradores = v.loc[v['compro'] == 1, 'monto']
resto = v.loc[v['compro'] == 0, 'monto']
print('correlacion compro-monto: r = %.4f | p = %.3g' % (r, p))
print('r al cuadrado: %.4f' % (r ** 2))
print()
print('media si compro: %.2f' % compradores.mean())
print('media si no compro: %.2f' % resto.mean())
print('prueba t: p = %.3g' % stats.ttest_ind(compradores, resto,
equal_var=False).pvalue)
correlacion compro-monto: r = 0.1967 | p = 1.47e-27 r al cuadrado: 0.0387 media si compro: 930.24 media si no compro: 630.77 prueba t: p = 1.03e-28
Sí se puede, y tiene nombre propio: correlación biserial puntual 📌
Lo interesante es que la correlación y la prueba t están contestando la misma pregunta. Las dos dicen que quien compra factura más: 930,24 de media contra 630,77.
Y el r al cuadrado añade lo que la prueba t no da: el monto explica solo el 3,87% de si una venta se cierra. O sea que la relación es real (p = 10 elevado a -27) y es chiquita, que es exactamente la lección del capítulo 12 😌
3. Correlación con el tiempo: ¿el negocio crece?
Correlaciona el monto con el número de días desde la primera venta. ¿Hay tendencia?
v['dias'] = (v['fecha'] - v['fecha'].min()).dt.days
r, p = stats.pearsonr(v['dias'], v['monto'])
print('monto vs dias: r = %+.4f | p = %.4f' % (r, p))
por_mes = v.groupby(v['fecha'].dt.to_period('M'))['monto'].sum()
meses = np.arange(len(por_mes))
r2, p2 = stats.pearsonr(meses, por_mes.values)
print('total mensual vs mes: r = %+.4f | p = %.4f' % (r2, p2))
monto vs dias: r = -0.0018 | p = 0.9208 total mensual vs mes: r = -0.0420 | p = 0.8685
Las dos formas dicen lo mismo: cero 👀
Venta a venta, r = -0,0018 con p = 0,9208. Mes a mes, r = -0,0420 con p = 0,8685. No hay ninguna tendencia en año y medio.
Y ojo con este resultado, porque en el capítulo 3 el negocio "cayó 19,66%" del primer al último mes. Las dos cosas son ciertas a la vez: hubo una caída entre esos dos meses concretos, y no hay tendencia en el periodo.
La diferencia es que comparar el primero con el último usa dos puntos y la correlación usa los dieciocho. Cuando dos números honestos discrepan, casi siempre es que uno usa más datos que el otro 📉
4. Cuántas correlaciones falsas salen por mirar de más
Genera veinte columnas al azar y mira todas las parejas. ¿Cuántas salen significativas?
from itertools import combinations
basura = pd.DataFrame({'c%d' % i: generador.normal(size=len(v)) for i in range(20)})
parejas = list(combinations(basura.columns, 2))
significativas = []
for a, b in parejas:
r, p = stats.pearsonr(basura[a], basura[b])
if p < 0.05:
significativas.append((a, b, r, p))
print('parejas revisadas: %d' % len(parejas))
print('significativas: %d' % len(significativas))
for a, b, r, p in significativas:
print(' %s vs %s: r = %+.4f | p = %.4f' % (a, b, r, p))
parejas revisadas: 190 significativas: 5 c5 vs c14: r = +0.0364 | p = 0.0461 c8 vs c12: r = -0.0401 | p = 0.0279 c11 vs c16: r = +0.0556 | p = 0.0023 c11 vs c19: r = -0.0467 | p = 0.0105 c15 vs c16: r = +0.0363 | p = 0.0467
Cinco correlaciones "significativas" entre columnas de ruido puro 🗑️
190 parejas al 5% dan unas 9,5 falsas alarmas esperadas, y salieron 5. Del orden previsto.
Ahora imagina una tabla real con 30 columnas: son 435 parejas, o sea unas 22 correlaciones falsas esperadas. Si alguien te trae "las correlaciones más fuertes que encontré en los datos", esto es lo primero que hay que preguntar: ¿cuántas miraste? 🔍
Fíjate también en los valores de r: 0,0364, -0,0401, 0,0556, -0,0467 y 0,0363. Son minúsculos. Con 3.000 filas hasta un ruido de 0,04 sale significativo, que es la lección del capítulo 12 otra vez.
5. Pearson contra Spearman en datos de verdad
Calcula las dos matrices sobre las columnas numéricas y busca dónde discrepan más.
cols = ['unidades', 'monto', 'descuento', 'compro']
pearson = v[cols].corr()
spearman = v[cols].corr('spearman')
for a, b in combinations(cols, 2):
pr, sp = pearson.loc[a, b], spearman.loc[a, b]
marca = ' <-- discrepan' if abs(pr - sp) > 0.03 else ''
print('%-10s vs %-12s pearson %+.4f | spearman %+.4f%s'
% (a, b, pr, sp, marca))
unidades vs monto pearson +0.0178 | spearman +0.0224 unidades vs descuento pearson -0.0078 | spearman -0.0051 unidades vs compro pearson +0.0167 | spearman +0.0217 monto vs descuento pearson -0.0265 | spearman -0.0201 monto vs compro pearson +0.1967 | spearman +0.2351 <-- discrepan descuento vs compro pearson +0.0520 | spearman +0.0526
La única que discrepa de verdad es monto contra compro: 0,1967 y 0,2351 👀
Spearman sale más alta, y eso tiene una lectura concreta: la relación existe pero no es una línea recta. Si lo fuera, las dos darían lo mismo.
Y tiene sentido con lo que ya sabemos. La relación no es "más soles, más probable que compre" de forma proporcional: es que los mayoristas compran mucho y cierran mucho, así que la relación va a saltos por segmento.
Cuando Spearman le gana a Pearson, casi siempre es esto: hay relación pero es curva o escalonada 🪜
6. Tu chequeo de correlación honesto
Escribe una función que calcule una correlación y avise de las tres cosas que pueden estar pasando.
def correlacion_honesta(x, y, nombre):
juntos = pd.DataFrame({'x': x, 'y': y}).dropna()
pr, pp = stats.pearsonr(juntos['x'], juntos['y'])
sp, spp = stats.spearmanr(juntos['x'], juntos['y'])
avisos = []
if len(juntos) < len(x):
avisos.append('se descartaron %d filas' % (len(x) - len(juntos)))
if abs(pr - sp) > 0.1:
avisos.append('pearson y spearman discrepan: mira atipicos o curvas')
if abs(pr) < 0.1 and pp < 0.05:
avisos.append('significativa pero minuscula')
return ('%-22s pearson %+.4f | spearman %+.4f | n=%d %s'
% (nombre, pr, sp, len(juntos),
'| ' + '; '.join(avisos) if avisos else ''))
print(correlacion_honesta(v['unidades'], v['monto'], 'unidades vs monto'))
print(correlacion_honesta(v['unidades'], v['satisfaccion'], 'unidades vs satisfaccion'))
print(correlacion_honesta(pd.Series(x_nube), pd.Series(y_nube), 'la nube con atipico'))
print(correlacion_honesta(v['monto'], v['compro'], 'monto vs compro'))
unidades vs monto pearson +0.0178 | spearman +0.0224 | n=3000 unidades vs satisfaccion pearson +0.0000 | spearman +0.0046 | n=2769 | se descartaron 231 filas la nube con atipico pearson +0.5045 | spearman -0.1160 | n=200 | pearson y spearman discrepan: mira atipicos o curvas monto vs compro pearson +0.1967 | spearman +0.2351 | n=3000
Los tres avisos funcionando 🎉
El segundo caza las 231 filas descartadas en silencio, que era el problema de la matriz del principio del capítulo.
El tercero caza el atípico que fabricaba una correlación de 0,5045 donde no había nada.
Esta función es lo que a mí me gustaría que devolviera .corr()
por defecto: el número, sí, pero con lo que hizo falta suponer para
calcularlo 😌
Comprueba que lo tienes
Unidades y monto tienen correlación 0,0178 en las 3.000 ventas, y 0,5433 promediando por segmento. ¿Cuál reportas?
- La de las 3.000 ventas, y aviso de que agregar infla la correlación
- La de 0,5433, que usa datos más limpios
- El promedio de las dos
- Ninguna, porque se contradicen
Lo que te llevas
- 📉 Unidades y monto tienen r = 0,0178, con r² de 0,000318. Vender más unidades no da más dinero, porque el precio unitario va de 26,59 a 279,61 soles según el segmento.
- 🎭 Pearson solo ve líneas rectas. Una curva en U perfecta dio r = -0,0268.
- 🪨 Un solo dato entre 200 llevó la correlación de -0,0917 a +0,5045. Spearman ni se enteró.
- 🏘️ Agregar infla: los mismos datos dieron 0,0178 por venta y 0,5433 promediando por segmento. Es la falacia ecológica.
- 🔍 190 parejas de columnas de ruido dieron 5 correlaciones significativas. Siempre hay que preguntar cuántas se miraron.
- 🗣️ Correlación no implica causalidad, y el caso más común es que haya un tercero causando las dos. Aquí el segmento sube el monto y la compra a la vez.
Qué viene ahora
Ya tienes todas las herramientas. En el capítulo 14 juntamos los errores que invalidan un análisis entero, varios de los cuales fueron saliendo por el camino, y le ponemos nombre al más famoso de todos: la paradoja de Simpson 🎩