Saltar al contenido

Indice / Visualizacion con Seaborn / 9.3

Caja y violín: la distribución por grupo

La caja resume cinco números y compara grupos de un vistazo. El precio que cobra por esa comodidad es esconder la forma, y a veces la forma es la noticia.

Para qué sirve

El histograma de la lección 8.5 muestra la forma de una variable. Cuando lo que hay que comparar es la misma variable en tres o siete grupos, apilar siete histogramas no funciona: no entran y no se comparan.

La caja resuelve eso reduciendo cada grupo a cinco números y dibujándolos en el mismo eje. Alcanza para responder cuál grupo está más arriba, cuál es más disperso y dónde hay observaciones que se escapan.

El ejemplo mínimo

EjemploCaja por grupo con las observaciones encima
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

notas = pd.DataFrame({
    "curso": ["Datos"] * 6 + ["Software"] * 6 + ["IA"] * 6,
    "nota": [58, 62, 65, 68, 71, 90,
             55, 60, 63, 66, 69, 72,
             70, 74, 77, 80, 83, 86]})

resumen = notas.groupby("curso")["nota"].describe()[["min", "25%", "50%", "75%", "max"]]
print("cuartiles por curso:")
print(resumen.round(2).to_string())

fig, ax = plt.subplots()
sns.boxplot(data=notas, x="curso", y="nota", hue="curso", legend=False, ax=ax)
sns.stripplot(data=notas, x="curso", y="nota", color="black", alpha=0.5,
              size=5, ax=ax)
ax.set_title("Notas por curso, con cada observacion encima")
ax.set_xlabel("Curso")
ax.set_ylabel("Nota sobre 100")
fig.tight_layout()
plt.show()
tres cajas de notas por curso con los puntos negros de cada alumno superpuestos, la caja de IA mas alta que las otras dos
cuartiles por curso:
           min    25%   50%    75%   max
curso                                   
Datos     58.0  62.75  66.5  70.25  90.0
IA        70.0  74.75  78.5  82.25  86.0
Software  55.0  60.75  64.5  68.25  72.0

Una caja se lee así, y conviene nombrar las partes en este orden:

El stripplot encima no es adorno: devuelve las observaciones que la caja acaba de resumir. Con pocos datos por grupo es casi obligatorio.

La idea: lo que la caja esconde

Una caja se construye con cinco números. Cualquier cosa que esos cinco números no distingan, la caja no la distingue. El caso que más importa es la bimodalidad: dos poblaciones mezcladas en un mismo grupo.

EjemploLo que la caja esconde y el violin muestra
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

# Semilla explicita: sin ella el grafico cambiaria en cada corrida.
rng = np.random.default_rng(8)
presencial = rng.normal(8.0, 0.8, 80)
# Virtual mezcla dos poblaciones: quienes siguieron bien y quienes se colgaron.
virtual = np.concatenate([rng.normal(8.6, 0.5, 40), rng.normal(5.8, 0.5, 40)])

encuesta = pd.DataFrame({
    "modalidad": ["Presencial"] * 80 + ["Virtual"] * 80,
    "puntaje": np.clip(np.concatenate([presencial, virtual]), 1, 10)})

resumen = encuesta.groupby("modalidad")["puntaje"].agg(
    ["count", "mean", "median", "std"])
print("resumen por modalidad:")
print(resumen.round(3).to_string())
print("\nlas medianas quedan a menos de un punto; las formas, en nada.")
print("cuantos por debajo de 7:")
print(encuesta[encuesta["puntaje"] < 7]["modalidad"]
      .value_counts().sort_index().to_string())

fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.4), sharey=True)
sns.boxplot(data=encuesta, x="modalidad", y="puntaje",
            hue="modalidad", legend=False, ax=axes[0])
axes[0].set_title("Caja: dos resumenes parecidos")
axes[0].set_xlabel("")
axes[0].set_ylabel("Puntaje")
sns.violinplot(data=encuesta, x="modalidad", y="puntaje", inner="quart",
               hue="modalidad", legend=False, ax=axes[1])
axes[1].set_title("Violin: Virtual tiene dos grupos")
axes[1].set_xlabel("")
fig.tight_layout()
plt.show()
dos paneles sobre los mismos datos, a la izquierda dos cajas casi identicas y a la derecha dos violines, el de presencial con una sola panza y el de virtual con dos
resumen por modalidad:
            count   mean  median    std
modalidad                              
Presencial     80  7.985   7.946  0.893
Virtual        80  7.176   7.299  1.495

las medianas quedan a menos de un punto; las formas, en nada.
cuantos por debajo de 7:
modalidad
Presencial    11
Virtual       40

Las dos modalidades tienen medianas a menos de un punto de distancia. Pero en Presencial hay 11 puntajes por debajo de 7 y en Virtual hay 40, la mitad exacta del grupo. La encuesta virtual no describe un curso que salió algo peor: describe dos grupos distintos, uno que siguió bien y otro que se quedó afuera, y el promedio cae justo en el medio, donde casi no hay nadie.

El violín dibuja la densidad estimada, así que esas dos panzas se ven. La caja las funde en un solo rectángulo.

inner="quart" devuelve lo que el violín se llevó

El violín gana forma y pierde los cuartiles. Con inner="quart" los dibuja adentro como líneas punteadas, y así se tienen las dos lecturas en el mismo gráfico. Las otras opciones son "box" (una caja chica adentro), "point", "stick" y None.

Y una advertencia sobre el violín: la densidad es una estimación suavizada. Con pocos datos inventa curvas que no están en las observaciones, y suele extenderse más allá del mínimo y el máximo reales. Con menos de veinte o treinta observaciones por grupo, el violín promete más precisión de la que tiene.

Sobre datos de verdad

EjemploImporte por categoria sobre datos reales, con el corte de atipicos declarado
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

ops = pd.read_csv("transacciones_categorias.csv")
importe = pd.to_numeric(ops["importe"], errors="coerce")
print("filas:", len(ops), "| importe no numerico o ausente:", int(importe.isna().sum()))

datos = ops.assign(importe=importe).dropna(subset=["importe"])
print("filas que quedan:", len(datos))

# Regla de Tukey: atipico es lo que cae fuera de 1,5 veces el rango
# intercuartilico. Es la misma que dibuja los bigotes de la caja.
q1 = datos.groupby("categoria")["importe"].quantile(0.25)
q3 = datos.groupby("categoria")["importe"].quantile(0.75)
iqr = q3 - q1
limites = pd.DataFrame({"q1": q1, "q3": q3, "iqr": iqr,
                        "limite_bajo": q1 - 1.5 * iqr,
                        "limite_alto": q3 + 1.5 * iqr})
print("\nlimites por categoria:")
print(limites.round(2).to_string())

fuera = datos.merge(limites, left_on="categoria", right_index=True)
fuera = fuera[(fuera["importe"] < fuera["limite_bajo"]) |
              (fuera["importe"] > fuera["limite_alto"])]
print("\natipicos por categoria:")
print(fuera["categoria"].value_counts().sort_index().to_string())

fig, ax = plt.subplots()
sns.boxplot(data=datos, x="categoria", y="importe",
            hue="categoria", legend=False, ax=ax)
ax.set_title("Importe por categoria")
ax.set_xlabel("Categoria")
ax.set_ylabel("Importe")
ax.tick_params(axis="x", rotation=30)
fig.tight_layout()
plt.show()
siete cajas de importes por categoria, con varias marcas de atipicos por encima del bigote superior
filas: 150 | importe no numerico o ausente: 6
filas que quedan: 144

limites por categoria:
                      q1       q3      iqr  limite_bajo  limite_alto
categoria                                                           
Capacitación      630.87  2550.98  1920.11     -2249.30      5431.14
Consultoría       598.66  3292.42  2693.76     -3441.98      7333.06
Hardware         1820.59  3646.36  1825.77      -918.07      6385.02
Infraestructura   843.08  3540.48  2697.40     -3203.01      7586.58
Licencias         820.03  3534.21  2714.18     -3251.23      7605.48
Software         1172.59  2466.50  1293.91      -768.28      4407.36
Soporte          1172.56  2463.09  1290.53      -763.24      4398.88

atipicos por categoria:
categoria
Hardware    1

Seis de 150 filas no traen un importe utilizable, y eso se informa antes de graficar, no después. Con las 144 que quedan, los límites de Tukey salen de los mismos cuartiles que dibuja la caja: Q1 − 1,5 · IQR y Q3 + 1,5 · IQR.

Fijate en los límites bajos: casi todos son negativos, y los importes no lo son. Eso significa que por abajo no puede haber atípicos en estas categorías: la regla de Tukey es simétrica y los datos no. Solo Hardware tiene un atípico, y por arriba.

Atípico no quiere decir erróneo

1,5 veces el rango intercuartílico es una convención, no un diagnóstico. Un punto fuera del bigote puede ser un error de carga, un caso real y raro, o una observación que pertenece a otra población.

La caja te dice dónde mirar. Qué hacer con eso se decide con el dominio, igual que en la lección 8.6: es imposible y sale, es real y se muestra con y sin, o es de otro grupo y se separa.

Cuántas observaciones sostiene cada caja

EjemploUna caja dibujada sobre tres observaciones
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

soporte = pd.DataFrame({
    "turno": ["Manana"] * 30 + ["Tarde"] * 25 + ["Noche"] * 3,
    "minutos": [12, 15, 9, 22, 18, 11, 14, 25, 17, 13,
                16, 19, 10, 21, 14, 12, 18, 23, 15, 11,
                13, 20, 16, 9, 24, 17, 12, 19, 15, 14,
                20, 26, 18, 31, 22, 19, 24, 29, 21, 17,
                23, 27, 20, 33, 25, 18, 22, 30, 19, 24,
                26, 21, 28, 23, 20,
                15, 16, 17]})

conteo = soporte.groupby("turno")["minutos"].agg(["count", "median", "std"])
print("antes de graficar, cuantas observaciones sostiene cada caja:")
print(conteo.round(2).to_string())
print("\nNoche tiene 3 observaciones: su caja describe tres numeros, no un turno.")

fig, ax = plt.subplots()
orden = ["Manana", "Tarde", "Noche"]
sns.boxplot(data=soporte, x="turno", y="minutos", order=orden,
            hue="turno", hue_order=orden, legend=False, ax=ax)
sns.stripplot(data=soporte, x="turno", y="minutos", order=orden,
              color="black", alpha=0.45, size=4, ax=ax)
for i, t in enumerate(orden):
    ax.annotate("n = %d" % int(conteo.loc[t, "count"]),
                xy=(i, soporte["minutos"].max() + 1), ha="center", fontsize=9)
ax.set_title("Tiempo de atencion por turno")
ax.set_xlabel("Turno")
ax.set_ylabel("Minutos")
fig.tight_layout()
plt.show()
tres cajas de tamanos muy distintos, la de la derecha construida con solo tres puntos y por eso enganosamente compacta
antes de graficar, cuantas observaciones sostiene cada caja:
        count  median   std
turno                      
Manana     30    15.0  4.41
Noche       3    16.0  1.00
Tarde      25    23.0  4.40

Noche tiene 3 observaciones: su caja describe tres numeros, no un turno.

Las tres cajas se dibujan igual de prolijas y una está hecha con tres observaciones. No hay nada en el gráfico que lo diga: el ancho de la caja no depende del n, así que el turno noche se lee como si fuera comparable con los otros dos.

De ahí la regla práctica: imprimir el conteo por grupo antes de graficar, y anotarlo en la figura cuando los grupos sean desparejos. Es una línea de código y evita una conclusión sobre nada.

Acá hay que elegir

Caja, violín o puntos
  • Caja cuando hay varios grupos y lo que importa es comparar nivel y dispersión. Es la opción por defecto y la que todo el mundo sabe leer.
  • Violín cuando sospechás que la forma importa: bimodalidad, asimetría fuerte, una cola larga. Necesita bastantes observaciones por grupo para no mentir.
  • Solo puntos (stripplot o swarmplot) cuando hay pocas observaciones. Con seis datos por grupo, un resumen de cinco números no resume nada: mostralos.
  • Caja más puntos, que es lo que hacen los ejemplos de esta lección, cuando querés las dos cosas y el volumen lo permite.

Con muchos miles de observaciones el stripplot se vuelve una mancha: ahí conviene alpha bajo, una muestra, o quedarse con el violín.

Dónde se rompe

(sin error) todas las cajas quedan aplastadas contra el piso

Causa: un valor fuera de dominio estira el eje. Es lo mismo que pasaba en la lección 9.1 con la nota de 99.

Arreglo: filtrar por el rango declarado antes de graficar e informarlo. No usar set_ylim: el valor seguiría contaminando los cuartiles.

(sin error) una caja es una línea sin altura

Causa: Q1 y Q3 coinciden. Pasa cuando el grupo tiene muy pocas observaciones o casi todas repiten el mismo valor.

Arreglo: mirá el conteo por grupo. Si son tres o cuatro datos, mostrá los puntos en vez de la caja.

(sin error) el violín se extiende por debajo de cero en una variable positiva

Causa: la densidad estimada suaviza más allá de los datos observados. No es un dato: es la curva.

Arreglo: cut=0 corta la densidad en el mínimo y el máximo reales. Y aclará en el texto que la panza es una estimación.

(sin error) los grupos salen en un orden raro

Causa: Seaborn los ordena por orden de aparición o alfabéticamente, y «Mañana, Noche, Tarde» casi nunca es lo que querés.

Arreglo: order=["Manana", "Tarde", "Noche"], y el mismo valor en hue_order si estás usando hue.

(sin error) los puntos del stripplot tapan la caja

Causa: demasiados puntos, opacos y grandes.

Arreglo: alpha=0.4 y size=4. Con miles de observaciones, sacá el stripplot: ya no aporta.

Recetario

Distribución por grupo
ExpresiónQué haceCuándo
sns.boxplot(data=df, x="g", y="v")Una caja por grupoComparar nivel y dispersión
hue="g", legend=FalseColorea por grupo sin leyenda redundanteCuando hue repite a x
order=[...]Fija el orden de las categoríasCasi siempre
sns.stripplot(..., alpha=0.5, size=4)Las observaciones encimaHasta unos cientos de puntos
sns.swarmplot(...)Puntos sin superponerPocas observaciones, más legible
sns.violinplot(..., inner="quart")Densidad con los cuartiles adentroCuando la forma importa
cut=0No extiende la densidad más allá de los datosVariables acotadas
df.groupby("g")["v"].describe()Los cinco números y el conteoAntes de graficar, siempre
df.groupby("g")["v"].quantile(0.25)Q1 por grupoCalcular límites de Tukey
q3 - q1Rango intercuartílicoLa altura de la caja
q1 - 1.5 * iqr, q3 + 1.5 * iqrLos cortes de atípicoContar y listar los que se escapan
ax.tick_params(axis="x", rotation=30)Gira las etiquetasCategorías con nombres largos

Ponete a prueba

1. ¿Qué representa la línea central de una caja?Nivel 1 · Recordar
2. ¿Qué mide la altura de la caja?Nivel 1 · Recordar
3. Dos grupos tienen medianas casi iguales, pero uno de ellos mezcla dos poblaciones distintas. ¿Qué gráfico lo revela?Nivel 2 · Predecir la salida
4. ¿Hasta dónde llegan los bigotes de un boxplot de Seaborn?Nivel 2 · Predecir la salida
5. Calculás los límites de Tukey por categoría de importes y casi todos los límites bajos dan negativos. ¿Qué significa?Nivel 3 · Elegir entre dos casi correctas
6. Un turno tiene 3 observaciones y los otros dos tienen 30 y 25. ¿Qué problema trae graficarlos juntos sin más?Nivel 3 · Elegir entre dos casi correctas
7. Un violín sobre importes positivos se extiende por debajo de cero. ¿Qué está pasando y cómo se arregla?Nivel 4 · Encontrar el defecto

Prácticas de esta lección