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
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()
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.
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
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()
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
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()
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.
- 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
- 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
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.
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.
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.
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.
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
| Expresión | Qué hace | Cuándo |
|---|---|---|
sns.set_theme(style="whitegrid") | Estilo global de la figura | Una vez, arriba del script |
style="ticks", "white", "darkgrid" | Otras variantes | whitegrid es buen defecto para informes |
palette={"cat": "#hex", ...} | Ata cada color a un valor | Siempre que el informe tenga más de un gráfico |
palette="colorblind" | Paleta cualitativa accesible | Cuando no hay colores de marca |
palette="viridis" | Secuencial legible en grises | Categorías ordenadas o magnitudes |
df.loc[df["v"].idxmax()] | La fila del máximo | Para derivar la anotación de los datos |
ax.annotate(txt, xy=, xytext=, arrowprops=) | Texto con flecha | Un hallazgo por figura |
arrowprops={"arrowstyle": "->"} | Flecha simple | Lo más legible |
textcoords="offset points" | Desplaza en píxeles, no en datos | Que la anotación no se escape |
ax.set(title=, xlabel=, ylabel=) | Varias propiedades juntas | Siempre |
ax.legend(title=, fontsize=8) | Ajusta la leyenda | Cuando ocupa demasiado |
ax.bar_label(ax.containers[0], fmt="%.0f") | Valor sobre cada barra | Cuando el número exacto importa |