Saltar al contenido

Indice / Visualizacion con Seaborn / 9.2

Mapa de calor: correlación y tabla pivote

Una matriz coloreada contesta de un vistazo qué se mueve junto con qué. También es el gráfico más fácil de leer mal, y el que un solo dato sucio arruina sin avisar.

Para qué sirve

Con dos variables alcanza una dispersión. Con seis, las dispersiones son quince y nadie las mira. El mapa de calor resume todos esos pares en una grilla: cada celda es un número, y el color permite recorrerla sin leer celda por celda.

Se usa para dos cosas distintas que conviene no mezclar: una matriz de correlación, donde filas y columnas son las mismas variables, y una tabla pivote, donde filas y columnas son dos categorías y la celda es un agregado. Las dos se dibujan igual y se colorean distinto.

El ejemplo mínimo

EjemploMapa de calor de correlaciones, con los numeros escritos en cada celda
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

campania = pd.DataFrame({
    "visitas": [1200, 1500, 1800, 2100, 2400, 2700, 3000, 3300],
    "publicidad": [100, 130, 160, 190, 220, 250, 280, 310],
    "ventas": [45, 52, 61, 68, 79, 84, 95, 101],
    "devoluciones": [9, 8, 8, 6, 6, 5, 4, 3]})

corr = campania.corr()
print("matriz de correlacion:")
print(corr.round(3).to_string())

fig, ax = plt.subplots()
sns.heatmap(corr, annot=True, fmt=".2f", cmap="vlag",
            vmin=-1, vmax=1, linewidths=0.5, ax=ax)
ax.set_title("Correlacion entre variables de la campania")
fig.tight_layout()
plt.show()
matriz de cuatro por cuatro coloreada de rojo a azul con los coeficientes escritos, diagonal en uno y visitas contra ventas en azul fuerte
matriz de correlacion:
              visitas  publicidad  ventas  devoluciones
visitas         1.000       1.000   0.998        -0.986
publicidad      1.000       1.000   0.998        -0.986
ventas          0.998       0.998   1.000        -0.980
devoluciones   -0.986      -0.986  -0.980         1.000

df.corr() devuelve un DataFrame cuadrado, y sns.heatmap lo dibuja tal cual: annot=True escribe el número en cada celda y fmt=".2f" lo recorta a dos decimales. La diagonal vale 1 siempre, porque cada variable está perfectamente correlacionada consigo misma.

La idea: una divergente necesita su centro fijado a mano

La intuición que hay que corregir acá es «rojo significa importante». En una paleta divergente como vlag, rojo y azul son los dos extremos de un signo, y el blanco del medio es el cero. Eso solo se cumple si el cero está en el centro de la escala, y por defecto no lo está: Seaborn ajusta el rango a los datos.

vmin=-1, vmax=1 no es decoración

Sin esos dos argumentos, una matriz cuyo mínimo sea 0,3 pinta ese 0,3 del color más frío de la paleta, que el ojo lee como «negativo». Y dos mapas de calor hechos sobre datos distintos dejan de ser comparables entre sí, porque cada uno tiene su propia escala.

Para correlación el rango correcto es siempre el mismo y se conoce de antemano: de −1 a 1. Fijarlo cuesta dos argumentos y evita la lectura equivocada.

Sobre datos de verdad

EjemploLa misma matriz sobre el data warehouse, con el par mas asociado
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

hechos = pd.read_csv("dw_hecho_ventas.csv")
medidas = hechos[["cantidad", "ingreso", "costo"]]

corr = medidas.corr()
print("matriz de correlacion sobre las 500 filas, tal cual vienen:")
print(corr.round(3).to_string())

# El par mas asociado, sin contar la diagonal.
pares = corr.stack()
pares = pares[pares.index.get_level_values(0) < pares.index.get_level_values(1)]
a, b = pares.abs().idxmax()
print("\npar mas asociado:", a, "y", b, "->", round(float(pares[(a, b)]), 3))
print("filas usadas por columna:")
print(medidas.notna().sum().to_string())

fig, ax = plt.subplots()
sns.heatmap(corr, annot=True, fmt=".2f", cmap="vlag",
            vmin=-1, vmax=1, linewidths=0.5, ax=ax)
ax.set_title("Correlacion de las medidas de venta")
fig.tight_layout()
plt.show()
matriz de tres por tres sobre cantidad, ingreso y costo, casi toda palida porque ninguna asociacion es fuerte
matriz de correlacion sobre las 500 filas, tal cual vienen:
          cantidad  ingreso  costo
cantidad     1.000   -0.010  0.618
ingreso     -0.010    1.000 -0.019
costo        0.618   -0.019  1.000

par mas asociado: cantidad y costo -> 0.618
filas usadas por columna:
cantidad    499
ingreso     500
costo       498

El resultado es raro y vale la pena detenerse. cantidad y costo van juntas, con 0,618, que tiene todo el sentido: vender más unidades cuesta más. Pero ingreso no se asocia con nada: −0,010 con cantidad y −0,019 con costo. Un negocio donde lo que se factura no tiene relación con lo que se vende ni con lo que cuesta.

Eso no es un hallazgo. Es un síntoma.

La fila que sostiene toda la matriz

EjemploUna fila de quinientas da vuelta toda la matriz
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

hechos = pd.read_csv("dw_hecho_ventas.csv")
medidas = ["cantidad", "ingreso", "costo"]

# La fila 480 declara diez unidades y un ingreso de siete digitos: el dominio
# del ingreso llega a 100000, asi que es imposible, no atipico.
imposible = hechos["ingreso"] > 100000
print("filas fuera del dominio del ingreso:", int(imposible.sum()))
print(hechos.loc[imposible, ["venta_id", "cantidad", "ingreso", "costo"]]
      .to_string(index=False))

con = hechos[medidas].corr()
sin = hechos.loc[~imposible, medidas].corr()

print("\ncon la fila adentro:")
print(con.round(3).to_string())
print("\ncon la fila afuera:")
print(sin.round(3).to_string())
print("\ningreso contra costo:", round(float(con.loc["ingreso", "costo"]), 3),
      "->", round(float(sin.loc["ingreso", "costo"]), 3))

fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.4))
for ax, datos, titulo in ((axes[0], con, "Con la fila 480"),
                          (axes[1], sin, "Sin la fila 480")):
    sns.heatmap(datos, annot=True, fmt=".2f", cmap="vlag",
                vmin=-1, vmax=1, linewidths=0.5, cbar=False, ax=ax)
    ax.set_title(titulo)
fig.tight_layout()
plt.show()
dos matrices de tres por tres lado a lado, la de la izquierda casi sin color y la de la derecha con ingreso y costo en azul intenso
filas fuera del dominio del ingreso: 1
 venta_id  cantidad    ingreso  costo
      480      10.0 9999999.99 1272.4

con la fila adentro:
          cantidad  ingreso  costo
cantidad     1.000   -0.010  0.618
ingreso     -0.010    1.000 -0.019
costo        0.618   -0.019  1.000

con la fila afuera:
          cantidad  ingreso  costo
cantidad     1.000    0.645  0.618
ingreso      0.645    1.000  0.968
costo        0.618    0.968  1.000

ingreso contra costo: -0.019 -> 0.968

Una fila de quinientas. La venta 480 declara diez unidades, un costo de 1.272 pesos y un ingreso de 9.999.999,99, cuando el dominio declarado del ingreso llega a 100.000. No es un caso atípico: es imposible.

Sacándola, ingreso contra costo pasa de −0,019 a 0,968. La asociación más fuerte de toda la tabla estaba escondida detrás de un solo valor mal cargado, y el mapa de calor no dio ninguna señal: salió sin error, con colores, con números y perfectamente legible.

Por qué un solo punto puede tanto

La correlación de Pearson se calcula sobre desvíos respecto de la media. Un valor cuatro órdenes de magnitud más grande que el resto corre la media de la columna y domina la suma de productos. Las 499 filas normales quedan, en términos numéricos, apretadas contra el cero.

Es el mismo mecanismo que en la lección 8.6 daba vuelta el signo de la recta. Cambia el gráfico, no cambia la causa.

Lo mismo, paso a paso

Leer una matriz de correlación es leer tres cosas en cada celda, en este orden:

Cuando el mapa no es de correlación

EjemploUn mapa de calor que no es de correlacion: demanda por hora y metodo
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

accesos = pd.read_csv("servidor_acceso.csv")
momento = pd.to_datetime(accesos["fecha_hora"], format="ISO8601", errors="coerce")
print("filas:", len(accesos), "| fecha_hora ilegible:", int(momento.isna().sum()))

datos = accesos[momento.notna()].assign(hora=momento[momento.notna()].dt.hour.astype(int))
demanda = datos.pivot_table(index="metodo", columns="hora",
                            values="ip", aggfunc="count", fill_value=0)
print("\naccesos por metodo y hora:")
print(demanda.to_string())

pico = demanda.stack().idxmax()
print("\npico de demanda:", pico[0], "a las", pico[1], "->",
      int(demanda.loc[pico[0], pico[1]]), "accesos")
print("total por metodo:")
print(demanda.sum(axis=1).sort_values(ascending=False).to_string())

fig, ax = plt.subplots()
sns.heatmap(demanda, annot=True, fmt="d", cmap="YlOrRd",
            linewidths=0.5, cbar_kws={"label": "accesos"}, ax=ax)
ax.set_title("Accesos por metodo y hora del dia")
ax.set_xlabel("Hora")
ax.set_ylabel("Metodo")
fig.tight_layout()
plt.show()
matriz de cinco metodos por franjas horarias en tonos amarillo a rojo, con las celdas mas intensas en las horas centrales
filas: 794 | fecha_hora ilegible: 1

accesos por metodo y hora:
hora    8   9   10  11  12  13  14  15  16  17  18
metodo                                            
DELETE  20  12  18  13  12  15  11  16  14  17   2
GET     13  15  16  16  20  16  24  11  15  18   1
PATCH   18  18  17  16  15  14  17  17  15  13   0
POST    15  23  14  21  13  10  15  20  14  12   3
PUT     15  12  19  17  18  16   8  18  17  18   0

pico de demanda: GET a las 14 -> 24 accesos
total por metodo:
metodo
GET       165
POST      160
PATCH     160
PUT       158
DELETE    150

Acá las filas son métodos HTTP, las columnas son horas y la celda es un conteo. Nada de eso tiene signo, así que la paleta correcta es secuencial (YlOrRd va de claro a intenso) y no divergente, y no hay ningún vmin ni vmax natural que fijar: el rango son los datos.

Es la misma función y la decisión de color es la opuesta. De ahí que convenga preguntarse siempre qué significa el cero antes de elegir la paleta.

Acá hay que elegir

Divergente o secuencial
  • Divergente (vlag, coolwarm, RdBu) cuando el cero es un punto de referencia real y el signo importa: correlaciones, diferencias contra un objetivo, variaciones porcentuales. Siempre con el rango fijado a mano y simétrico.
  • Secuencial (YlOrRd, viridis, Blues) cuando la variable solo crece: conteos, importes, tiempos, cantidades. Acá el cero es simplemente el mínimo y no merece un color propio.
  • Nunca una divergente sobre conteos: pinta de un color frío la mitad baja del rango y sugiere un signo negativo que no existe.

Y una advertencia sobre viridis y compañía: son legibles en escala de grises y para daltonismo, que es una razón real para preferirlas a jet o rainbow.

Dónde se rompe

ValueError: could not convert string to float

Causa: le pasaste a heatmap un DataFrame con columnas de texto. El heatmap dibuja números, no tablas cualesquiera.

Arreglo: df.select_dtypes("number").corr(), o armá la tabla pivote con pivot_table y un aggfunc numérico.

(sin error) toda la matriz sale del mismo color

Causa: hay una celda con un valor enorme que se lleva todo el rango de la escala, y el resto queda comprimido en un extremo.

Arreglo: en correlación, fijá vmin=-1, vmax=1. En una tabla pivote, buscá el valor máximo con .stack().idxmax() y revisá si es real antes de graficar.

(sin error) la correlación da casi cero y el negocio dice otra cosa

Causa: un valor fuera de dominio, o una relación que no es lineal.

Arreglo: primero filtrá por el dominio declarado de cada columna y volvé a calcular. Si sigue baja, mirá la dispersión: puede ser una curva.

(sin error) hay columnas que desaparecen de la matriz

Causa: corr() ignora las no numéricas en silencio. Una columna que quedó como object por un token de texto no aparece.

Arreglo: comprobá df.dtypes antes. Lo que debería ser número y es object, se convierte con pd.to_numeric.

(sin error) los números de las celdas se superponen

Causa: demasiadas variables para el ancho de la figura.

Arreglo: con más de diez o doce variables, sacá annot y dejá solo el color, o seleccioná antes las columnas que te interesan. Una matriz de 30 por 30 anotada no la lee nadie.

Recetario

Mapas de calor de correlación y de tabla pivote
ExpresiónQué haceCuándo
df.corr()Matriz de Pearson, ignora las no numéricasExplorar asociaciones lineales
df.select_dtypes("number")Se queda con las columnas numéricasAntes de corr, para saber qué entró
sns.heatmap(m, annot=True, fmt=".2f")Dibuja y escribe el valorHasta diez o doce variables
vmin=-1, vmax=1Fija la escala completaSiempre, en correlación
cmap="vlag"Paleta divergente, blanco en el centroCuando el signo importa
cmap="YlOrRd"Paleta secuencialConteos, importes, tiempos
linewidths=0.5Separa las celdasGrillas chicas, mejora la lectura
cbar_kws={"label": "accesos"}Rotula la barra de colorSiempre que la unidad no sea obvia
df.pivot_table(index=, columns=, values=, aggfunc=)Tabla de dos categoríasHeatmap que no es de correlación
fill_value=0Rellena las celdas sin datosCuando el vacío significa cero
m.stack().idxmax()Fila y columna del máximoLocalizar el pico
fmt="d"Anota enteros sin decimalesConteos

Ponete a prueba

1. En una matriz de correlación, ¿por qué la diagonal vale siempre 1?Nivel 1 · Recordar
2. Una correlación de −0,9 entre dos variables, ¿es débil o fuerte?Nivel 2 · Predecir la salida
3. ¿Para qué sirven vmin=-1 y vmax=1 en un heatmap de correlación?Nivel 2 · Predecir la salida
4. Vas a dibujar un heatmap de accesos por hora y método, donde cada celda es un conteo. ¿Qué paleta corresponde?Nivel 3 · Elegir entre dos casi correctas
5. Calculás corr() sobre un DataFrame de ocho columnas y en la matriz aparecen solo cinco. ¿Qué pasó?Nivel 3 · Elegir entre dos casi correctas
6. Un heatmap de correlación corre sin error, se ve prolijo, y dice que el ingreso no se asocia con nada. Al sacar una fila de quinientas, la asociación con el costo pasa de −0,019 a 0,968. ¿Qué enseña eso sobre el gráfico?Nivel 4 · Encontrar el defecto
7. Dos variables tienen una relación en forma de U perfecta. ¿Qué va a mostrar el mapa de calor?Nivel 4 · Encontrar el defecto

Prácticas de esta lección