Saltar al contenido

Indice / Visualizacion con Seaborn / 9.5

Paleta, anotación y contraste

Personalizar un gráfico no es decorarlo. Cada decisión de color, de anotación y de estilo cambia qué se entiende primero, y algunas se pueden verificar con una cuenta en vez de con una opinión.

Para qué sirve

Hasta acá los gráficos salieron con los colores que Seaborn eligió. Para explorar está bien: nadie más los va a ver. Cuando el gráfico sale del script y entra en un informe, en una presentación o en un tablero, aparecen tres problemas que los colores por defecto no resuelven.

El primero es la consistencia: que «Ventas» sea del mismo color en los seis gráficos del informe. El segundo es el foco: que quien mira encuentre en dos segundos lo que el gráfico vino a mostrar. El tercero es la legibilidad: que se lea en un proyector, impreso en blanco y negro, y por alguien que no distingue el rojo del verde.

El ejemplo mínimo

EjemploUna paleta fija por categoria, para que el color diga lo mismo en todos los graficos
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

PALETA = {"Datos": "#00A6A6", "Software": "#FFB000", "IA": "#7B61FF"}
print("paleta declarada:")
for nombre, color in PALETA.items():
    print("  %-9s %s" % (nombre, color))

enero = pd.DataFrame({"curso": ["Datos", "Software", "IA"],
                      "inscriptos": [120, 95, 140]})
marzo = pd.DataFrame({"curso": ["IA", "Datos"],
                      "inscriptos": [180, 110]})
print("\nenero:", dict(zip(enero["curso"], enero["inscriptos"])))
print("marzo:", dict(zip(marzo["curso"], marzo["inscriptos"])),
      "(Software no abrio)")

sns.set_theme(style="whitegrid")
fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.4), sharey=True)

# Sin paleta fija, marzo reasigna colores por orden de aparicion.
sns.barplot(data=marzo, x="curso", y="inscriptos",
            hue="curso", legend=False, ax=axes[0])
axes[0].set_title("Marzo sin paleta fija")
axes[0].set_xlabel("")
axes[0].set_ylabel("Inscriptos")

sns.barplot(data=marzo, x="curso", y="inscriptos", hue="curso",
            palette=PALETA, legend=False, ax=axes[1])
axes[1].set_title("Marzo con la paleta de enero")
axes[1].set_xlabel("")

fig.tight_layout()
plt.show()
dos paneles con las mismas tres categorias, a la izquierda con colores asignados por orden y a la derecha con la paleta fija donde cada categoria conserva su color
paleta declarada:
  Datos     #00A6A6
  Software  #FFB000
  IA        #7B61FF

enero: {'Datos': 120, 'Software': 95, 'IA': 140}
marzo: {'IA': 180, 'Datos': 110} (Software no abrio)

Esto es lo que pasa cuando la paleta no es fija. En enero hay tres cursos y Seaborn les asigna colores por orden. En marzo Software no abrió, y quedan dos: el color que era de Software pasa a IA, porque la asignación es por posición, no por valor.

Los dos gráficos son correctos por separado y juntos mienten: quien vea el informe va a creer que el curso azul creció, cuando lo que pasó es que otro curso heredó el azul.

La paleta como diccionario es la solución, y es una línea

palette={"Datos": "#00A6A6", "Software": "#FFB000", "IA": "#7B61FF"} ata cada color a un valor de la columna, no a una posición. Sobrevive a que falte una categoría, a que cambie el orden y a que se agregue una fila.

Se declara una vez arriba del script, en mayúsculas como cualquier constante, y se pasa a todos los gráficos del informe.

La idea: anotar es elegir qué se lee primero

EjemploAnotar un solo hallazgo, el que la figura viene a contar
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

estudio = pd.DataFrame({
    "horas": [2, 3, 4, 5, 5, 6, 7, 8, 9, 10, 11, 12],
    "nota": [52, 55, 60, 62, 68, 70, 74, 78, 83, 85, 89, 92],
    "curso": ["Datos", "Software", "IA"] * 4})

mejor = estudio.loc[estudio["nota"].idxmax()]
print("fila con la nota mas alta:")
print(mejor.to_string())
print("\ncorrelacion horas y nota:",
      round(estudio["horas"].corr(estudio["nota"]), 4))

sns.set_theme(style="whitegrid")
fig, ax = plt.subplots()
sns.scatterplot(data=estudio, x="horas", y="nota", hue="curso",
                palette={"Datos": "#00A6A6", "Software": "#FFB000",
                         "IA": "#7B61FF"},
                s=70, ax=ax)
ax.annotate("Mayor nota: %d h, %d puntos" % (mejor["horas"], mejor["nota"]),
            xy=(mejor["horas"], mejor["nota"]),
            xytext=(mejor["horas"] - 5.5, mejor["nota"] - 14),
            arrowprops={"arrowstyle": "->", "color": "#444444"},
            fontsize=9)
ax.set(title="Horas de estudio y nota final",
       xlabel="Horas por semana", ylabel="Nota sobre 100")
fig.tight_layout()
plt.show()
nube de doce puntos con una flecha que senala el punto de nota mas alta y su etiqueta
fila con la nota mas alta:
horas    12
nota     92
curso    IA

correlacion horas y nota: 0.9924

La anotación no agrega datos: el punto ya estaba ahí. Lo que agrega es jerarquía. Sin la flecha, los doce puntos compiten en igualdad de condiciones y el lector arma su propio recorrido. Con la flecha, el gráfico dice por dónde empezar.

Por eso conviene anotar un solo hallazgo, el que la figura vino a contar. Cinco anotaciones son cero anotaciones: si todo está destacado, nada lo está.

Y notá que el punto anotado no se elige a mano: sale de df.loc[df["nota"].idxmax()]. Si los datos cambian, la flecha se mueve sola. Escribir las coordenadas a mano es la forma más rápida de tener un gráfico que miente después de la primera actualización.

ax.set(...) junta varias propiedades en una línea

ax.set(title="...", xlabel="...", ylabel="...") hace lo mismo que tres llamadas separadas. Es cómodo y es el estilo que usa la cátedra.

Sobre los títulos, la regla que conviene seguir: un título dice qué, quién y cuándo. «Ventas» no es un título. «Ventas mensuales por región, 2026» sí. Y si el gráfico tiene una conclusión, el título puede ser la conclusión: «Las ventas del Centro duplican a las del Oeste» es más útil que «Ventas por región».

Sobre datos de verdad: el contraste se calcula

EjemploEl contraste de una paleta se puede calcular, no hace falta opinar
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

FLOJA = ["#f2e8b8", "#f5eec6", "#efe6b0", "#f7f0cd"]
BUENA = ["#0b6e4f", "#8a6000", "#7b61ff", "#d7263d"]


def luminancia(hexa):
    """Luminancia relativa segun WCAG 2.1, sobre el hex sin el numeral."""
    canales = []
    for i in (0, 2, 4):
        c = int(hexa[1 + i:3 + i], 16) / 255
        canales.append(c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4)
    r, g, b = canales
    return 0.2126 * r + 0.7152 * g + 0.0722 * b


def contraste(hexa, fondo="#ffffff"):
    a, b = luminancia(hexa), luminancia(fondo)
    claro, oscuro = max(a, b), min(a, b)
    return (claro + 0.05) / (oscuro + 0.05)


print("contraste de cada color contra fondo blanco (WCAG, 3:1 es el minimo)")
for nombre, paleta in (("floja", FLOJA), ("buena", BUENA)):
    razones = [contraste(c) for c in paleta]
    print("\npaleta %s:" % nombre)
    for c, r in zip(paleta, razones):
        print("  %s  %5.2f:1  %s" % (c, r, "ok" if r >= 3 else "insuficiente"))
    print("  minimo de la paleta: %.2f:1" % min(razones))

ventas = pd.DataFrame({"region": ["Norte", "Sur", "Centro", "Oeste"],
                       "ventas": [420, 310, 505, 265]})
print("\nventas por region:")
print(ventas.to_string(index=False))

fig, axes = plt.subplots(2, 1, figsize=(7.2, 4.6), sharex=True)
for ax, paleta, titulo in ((axes[0], FLOJA, "Contraste insuficiente"),
                           (axes[1], BUENA, "Contraste suficiente")):
    sns.barplot(data=ventas, x="region", y="ventas", hue="region",
                palette=paleta, legend=False, ax=ax)
    ax.set_title(titulo)
    ax.set_xlabel("")
    ax.set_ylabel("Ventas")
fig.tight_layout()
plt.show()
dos paneles de barras con las mismas cuatro categorias, arriba una paleta de bajo contraste contra el fondo y abajo una que si contrasta
contraste de cada color contra fondo blanco (WCAG, 3:1 es el minimo)

paleta floja:
  #f2e8b8   1.23:1  insuficiente
  #f5eec6   1.17:1  insuficiente
  #efe6b0   1.26:1  insuficiente
  #f7f0cd   1.15:1  insuficiente
  minimo de la paleta: 1.15:1

paleta buena:
  #0b6e4f   6.25:1  ok
  #8a6000   5.59:1  ok
  #7b61ff   4.20:1  ok
  #d7263d   4.96:1  ok
  minimo de la paleta: 4.20:1

ventas por region:
region  ventas
 Norte     420
   Sur     310
Centro     505
 Oeste     265

«Esta paleta se ve bien» es una opinión. «El color más flojo de esta paleta tiene 1,15 a 1 de contraste contra el fondo blanco, y el mínimo utilizable es 3 a 1» es un hecho, y sale de una función de veinte líneas.

La cuenta es la de WCAG 2.1: se pasa cada canal de sRGB a luminancia relativa y se compara con el fondo. Sirve para lo mismo que sirve en una página web, porque el problema es el mismo: distinguir formas de un fondo.

Vale la pena mirar lo que pasó al armar la paleta buena. El primer amarillo elegido (#f2b705) daba 1,82 a 1: reprobaba. Los amarillos y los naranjas claros casi nunca pasan sobre blanco, y eso no se ve mirando la pantalla en una oficina bien iluminada. Se ve en el número, y por eso el color se oscureció hasta #8a6000.

Tres reglas de color que conviene no negociar
  • El color nunca es la única señal. Aproximadamente una de cada doce personas con cromosomas XY tiene alguna deficiencia en la visión del color. Si el color distingue dos series, la forma del marcador o el trazo de la línea también deberían hacerlo.
  • Reservá la intensidad para lo importante. Si todo es saturado, nada resalta. Una serie destacada en color fuerte sobre el resto en gris comunica más que ocho series de ocho colores.
  • Ningún estilo que reduzca el contraste. Los temas oscuros y los fondos de color se ven bien en pantalla y suelen desaparecer impresos.

Acá hay que elegir

Paleta por categoría o por magnitud
  • Cualitativa (un color por categoría, sin orden entre ellos): para regiones, cursos, canales, productos. Máximo siete u ocho colores; más allá, agrupá en «otros».
  • Secuencial (un mismo tono de claro a oscuro): cuando la categoría sí tiene orden, como tramos de edad o niveles de riesgo. Usar colores cualitativos ahí desperdicia el orden que los datos traen.
  • Divergente: solo cuando hay un centro con significado, como en el mapa de calor de la lección 9.2.

Y una recomendación concreta: viridis, cividis y colorblind están diseñadas para ser legibles en escala de grises y con daltonismo. jet y rainbow no, aunque se vean vistosas.

Dónde se rompe

(sin error) la misma categoría cambia de color entre dos gráficos

Causa: paleta por posición. Si en un gráfico falta una categoría, todas las siguientes se corren.

Arreglo: palette como diccionario de valor a color, declarado una vez para todo el informe.

ValueError: The palette dictionary is missing keys

Causa: apareció una categoría nueva en los datos que el diccionario no contempla.

Arreglo: agregala al diccionario. Y si las categorías pueden crecer sin aviso, comprobá set(df["cat"]) - set(PALETA) antes de graficar.

(sin error) la anotación queda fuera del área visible

Causa: el xytext está en coordenadas de datos y se fue del rango de los ejes.

Arreglo: calculá el desplazamiento a partir del rango real (ax.get_xlim()) en vez de restar un número fijo, o usá textcoords="offset points" para desplazar en píxeles.

(sin error) la flecha apunta a un punto que ya no es el máximo

Causa: las coordenadas están escritas a mano y los datos cambiaron.

Arreglo: derivalas siempre de los datos con idxmax, idxmin o un filtro. Una anotación fija sobre datos vivos miente.

(sin error) el gráfico se ve bien en pantalla e ilegible impreso

Causa: contraste insuficiente, o color como única señal.

Arreglo: calculá el contraste contra el fondo antes de fijar la paleta, y probá el gráfico en escala de grises. Si dos series se confunden, agregá forma o trazo.

Recetario

Personalización con intención
ExpresiónQué haceCuándo
sns.set_theme(style="whitegrid")Estilo global de la figuraUna vez, arriba del script
style="ticks", "white", "darkgrid"Otras varianteswhitegrid es buen defecto para informes
palette={"cat": "#hex", ...}Ata cada color a un valorSiempre que el informe tenga más de un gráfico
palette="colorblind"Paleta cualitativa accesibleCuando no hay colores de marca
palette="viridis"Secuencial legible en grisesCategorías ordenadas o magnitudes
df.loc[df["v"].idxmax()]La fila del máximoPara derivar la anotación de los datos
ax.annotate(txt, xy=, xytext=, arrowprops=)Texto con flechaUn hallazgo por figura
arrowprops={"arrowstyle": "->"}Flecha simpleLo más legible
textcoords="offset points"Desplaza en píxeles, no en datosQue la anotación no se escape
ax.set(title=, xlabel=, ylabel=)Varias propiedades juntasSiempre
ax.legend(title=, fontsize=8)Ajusta la leyendaCuando ocupa demasiado
ax.bar_label(ax.containers[0], fmt="%.0f")Valor sobre cada barraCuando el número exacto importa

Ponete a prueba

1. ¿Qué ventaja tiene pasar palette como diccionario en vez de como lista?Nivel 1 · Recordar
2. Tenés un informe con seis gráficos donde aparece la categoría Ventas. ¿Qué hay que garantizar?Nivel 2 · Predecir la salida
3. ¿Por qué conviene derivar las coordenadas de una anotación con idxmax en vez de escribirlas a mano?Nivel 2 · Predecir la salida
4. Una paleta de amarillos claros sobre fondo blanco da 1,15 a 1 de contraste. ¿Qué significa ese número?Nivel 3 · Elegir entre dos casi correctas
5. ¿Por qué la regla dice que el color nunca debe ser la única señal?Nivel 3 · Elegir entre dos casi correctas
6. Tenés que graficar tramos de edad: 18-29, 30-44, 45-59 y 60 o más. ¿Qué tipo de paleta corresponde?Nivel 4 · Encontrar el defecto
7. Un colega dice que su gráfico está bien porque «se ve bien en mi pantalla». ¿Qué le responderías con lo de esta lección?Nivel 4 · Encontrar el defecto

Prácticas de esta lección