Capítulo 13 de 16 8 secciones 15 min

Correlación: la que salió cero y la que se infló sola

Pearson, Spearman, y la frase que hay que decir en voz alta cada vez que aparece un coeficiente.

La correlación mide si dos variables se mueven juntas, en una escala de -1 a 1. Tres avisos: solo detecta relaciones en línea recta, un solo dato extremo puede fabricarla (aquí una fila creó un 0,5045 donde había -0,0917), y agregar por grupos la infla (0,0178 se convirtió en 0,5433 al promediar por segmento). Y nunca implica causalidad.

Ú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.

r=(xix¯)(yiy¯)(xix¯)2(yiy¯)2

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.

Tres nubes de puntos una al lado de la otra: una recta con ruido y correlación 0,83, una curva en U perfecta con correlación 0,03, y una nube redonda con un punto lejísimos que da correlación 0,50.
El del medio tiene una relación perfecta y correlación cero, porque Pearson solo ve líneas rectas. El de la derecha no tiene ninguna relación y da 0,50 por culpa de un solo punto entre doscientos.
Tres nubes de puntos una al lado de la otra: la positiva sube y tiene la correlación cerca de más 1, la negativa baja y la tiene cerca de menos 1, y la tercera no tiene forma y la tiene cerca de cero.
El mismo coeficiente puede salir de nubes muy distintas, y por eso la regla es mirar el gráfico antes de creerle al número. Una relación clarísima en forma de U da correlación cero.

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

PearsonSpearman
Qué mideRelación en línea rectaSi una sube cuando la otra sube, aunque no sea recta
UsaLos valoresLas posiciones
AtípicosLe afectan muchísimoCasi nada
CuándoDatos limpios y relación rectaAnte 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 🎩

¿Tienes alguna duda o consulta?