Indice / Visualizacion con Seaborn / 9.6
La imagen que sale del script
El gráfico se va a ver sin vos al lado. Esta lección es sobre la mitad que casi nunca se enseña: qué archivo producir, con qué resolución, y qué tiene que estar adentro de la imagen para que se entienda sola.
Para qué sirve
La lección 8.8 ya cerró la unidad de Matplotlib guardando un panel. Acá se retoma con lo que agregan Seaborn y el trabajo real: una figura que va a un informe, a una presentación o a un repositorio, y que alguien va a abrir dentro de seis meses sin poder preguntarte nada.
Son tres decisiones: qué formato, con qué resolución
y qué contiene la figura. Las tres se toman antes de escribir
savefig.
El ejemplo mínimo
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
ventas = pd.DataFrame({
"mes": ["Ene", "Feb", "Mar", "Abr", "May", "Jun"],
"ventas": [82, 88, 95, 93, 105, 117]})
print("ventas mes a mes:")
print(ventas.to_string(index=False))
variacion = ventas["ventas"].diff()
print("\nvariacion contra el mes anterior:")
for mes, v in zip(ventas["mes"][1:], variacion[1:]):
print(" %s %+.0f" % (mes, v))
print("\nunico retroceso:", ventas.loc[variacion.idxmin(), "mes"])
sns.set_theme(style="whitegrid")
fig, ax = plt.subplots()
sns.lineplot(data=ventas, x="mes", y="ventas", marker="o",
errorbar=None, ax=ax)
ax.set(title="Ventas mensuales del primer semestre",
xlabel="Mes", ylabel="Ventas (unidades)")
fig.tight_layout()
# tight_layout acomoda los margenes ANTES de guardar, y a diferencia del
# recorte automatico al contenido no cambia el tamano del lienzo: el
# archivo mide lo mismo en cualquier maquina. La leccion lo explica.
fig.savefig("informe_ventas.png", dpi=300)
plt.show()
ventas mes a mes:
mes ventas
Ene 82
Feb 88
Mar 95
Abr 93
May 105
Jun 117
variacion contra el mes anterior:
Feb +6
Mar +7
Abr -2
May +12
Jun +12
unico retroceso: Abr
sns.lineplot con errorbar=None es el caso más simple: una
serie, un punto por mes. El errorbar=None conviene explicarlo, porque es una
diferencia real con Matplotlib.
Si hay más de un valor de y para el mismo x, lineplot
no dibuja todos los puntos: calcula la media y le pone alrededor una
banda de confianza estimada por bootstrap. Es útil y es una decisión estadística que
tomó la biblioteca por vos.
errorbar=None apaga la banda. Y si no esperabas una media, el problema no
es la banda: es que tu tabla tiene un grano distinto del que creías. Comprobalo con
df.groupby("x").size().max() antes de graficar.
Acá hay que elegir: el formato
- PNG es una grilla de píxeles. Va bien en Word, en PowerPoint y en la web, y se ve pixelado si alguien lo agranda. Para impresión, 300 dpi.
- PDF es vectorial: se agranda sin perder nitidez y conserva el texto como texto. Es lo que corresponde en un informe académico o una publicación.
- SVG también es vectorial y además es editable: se abre en Inkscape o Illustrator para retocar una etiqueta sin volver a correr el script. Es el formato para la web moderna y para cuando un diseñador va a meter mano.
Con un vectorial y decenas de miles de puntos, el archivo puede quedar enorme porque guarda cada punto. Ahí el PNG a 300 dpi suele ser mejor idea.
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
ANCHO_PULGADAS = 7.2
ALTO_PULGADAS = 4.0
tabla = pd.DataFrame({"dpi": [72, 100, 150, 300]})
tabla["ancho_px"] = (tabla["dpi"] * ANCHO_PULGADAS).astype(int)
tabla["alto_px"] = (tabla["dpi"] * ALTO_PULGADAS).astype(int)
tabla["destino"] = ["Borrador en pantalla", "Pantalla y web",
"Presentacion proyectada", "Informe impreso"]
print("lienzo de %.1f por %.1f pulgadas" % (ANCHO_PULGADAS, ALTO_PULGADAS))
print(tabla.to_string(index=False))
print("\nEl dpi NO cambia el tamano del texto respecto del grafico:")
print("una figura a 300 dpi tiene las mismas proporciones que a 100,")
print("con cuatro veces mas pixeles. Para agrandar la letra se cambia")
print("font.size o figsize, no el dpi.")
sns.set_theme(style="whitegrid")
fig, ax = plt.subplots()
sns.barplot(data=tabla, x="dpi", y="ancho_px", hue="dpi",
palette="viridis", legend=False, ax=ax)
ax.bar_label(ax.containers[0], fmt="%d px", padding=2, fontsize=9)
ax.set(title="Pixeles de ancho segun el dpi, con figsize fijo",
xlabel="dpi", ylabel="Ancho en pixeles")
ax.margins(y=0.15)
fig.tight_layout()
plt.show()
lienzo de 7.2 por 4.0 pulgadas
dpi ancho_px alto_px destino
72 518 288 Borrador en pantalla
100 720 400 Pantalla y web
150 1080 600 Presentacion proyectada
300 2160 1200 Informe impreso
El dpi NO cambia el tamano del texto respecto del grafico:
una figura a 300 dpi tiene las mismas proporciones que a 100,
con cuatro veces mas pixeles. Para agrandar la letra se cambia
font.size o figsize, no el dpi.
El dpi multiplica el lienzo: 7,2 pulgadas a 300 dpi son
2160 píxeles de ancho. Lo que no hace es agrandar la
letra respecto del gráfico: las proporciones son idénticas a 100 y a 300 dpi, solo que
con cuatro veces más píxeles. Si el texto se ve chico, lo que hay que cambiar es
font.size o figsize, no el dpi.
El caso bbox_inches="tight"
La presentación de la cátedra guarda así:
plt.savefig("informe_notas.png", dpi=300, bbox_inches="tight", facecolor="white")
Vas a ver esa línea en el material y en medio internet, así que conviene saber
exactamente qué hace. bbox_inches="tight" recorta el lienzo hasta el
contenido: mide dónde termina el texto más externo y corta ahí. Resuelve de un saque el
clásico «se me cortó la etiqueta del eje».
Recorta según el texto medido. El ancho del archivo pasa a depender de la tipografía instalada, de su versión y del motor de fuentes del sistema. El mismo script produce un PNG de 2160 por 1200 en tu máquina y de 2143 por 1187 en la de al lado, y el sitio no puede prometer que la figura publicada sea la que vos generás.
Por eso verificar_sitio.py lo rechaza, junto con
plt.style.use, datetime.now y default_rng() sin
semilla. No es una opinión sobre el recorte: es que el tamaño del archivo deje de ser
determinístico.
Cuándo sí usarlo: en una entrega de una vez, donde el archivo se genera, se mira y se manda. Ahí el recorte automático es cómodo y el determinismo no importa. Cuándo no: en cualquier proceso que se corra más de una vez y cuya salida se compare, se versione o se publique.
La alternativa determinística es la que usan todos los ejemplos de esta unidad:
fig.tight_layout() antes de guardar. Acomoda los márgenes dentro del
lienzo sin cambiar su tamaño.
Lo que tiene que estar adentro de la imagen
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
entregas = pd.DataFrame({
"zona": ["Norte", "Sur", "Centro", "Oeste"],
"demora": [22.4, 41.8, 19.1, 25.6]})
print("demora promedio por zona, en minutos:")
print(entregas.to_string(index=False))
peor = entregas.loc[entregas["demora"].idxmax()]
print("\npeor zona:", peor["zona"], "con", peor["demora"], "minutos")
print("diferencia contra la mejor: %.1f minutos"
% (entregas["demora"].max() - entregas["demora"].min()))
print("\nLos numeros son identicos en los dos paneles. Lo que cambia es")
print("cuanto trabajo tiene que hacer quien mira para entenderlos.")
sns.set_theme(style="whitegrid")
fig, axes = plt.subplots(2, 1, figsize=(7.2, 5.2))
sns.barplot(data=entregas, x="zona", y="demora", hue="zona",
palette="Set2", legend=False, ax=axes[0])
axes[0].set_xlabel("")
axes[0].set_ylabel("")
# Gris para todo menos la zona que el grafico viene a senalar.
destacado = ["#c9ccd1" if z != peor["zona"] else "#d7263d"
for z in entregas["zona"]]
sns.barplot(data=entregas, x="zona", y="demora", hue="zona",
palette=destacado, legend=False, ax=axes[1])
axes[1].bar_label(axes[1].containers[0], fmt="%.1f", padding=2, fontsize=9)
axes[1].set(title="El Sur demora el doble que el Centro (agosto 2026)",
xlabel="Zona de reparto", ylabel="Demora promedio (minutos)")
axes[1].margins(y=0.15)
fig.tight_layout()
plt.show()
demora promedio por zona, en minutos:
zona demora
Norte 22.4
Sur 41.8
Centro 19.1
Oeste 25.6
peor zona: Sur con 41.8 minutos
diferencia contra la mejor: 22.7 minutos
Los numeros son identicos en los dos paneles. Lo que cambia es
cuanto trabajo tiene que hacer quien mira para entenderlos.
Los dos paneles tienen los mismos cuatro números. El de arriba obliga a preguntar qué mide el eje, en qué unidad, de cuándo son los datos y qué habría que mirar primero. El de abajo contesta esas cuatro preguntas sin que nadie las haga.
Fijate en el color: todas las barras en gris menos una. El gráfico vino a decir que el Sur demora el doble que el Centro, así que el único color fuerte es el del Sur. Es la regla de la lección anterior aplicada al cierre: reservar la intensidad para lo importante.
- ¿El gráfico responde una pregunta concreta?
- ¿El título dice qué, quién y cuándo? Mejor todavía: ¿dice la conclusión?
- ¿Los ejes tienen rótulo y unidad?
- ¿La leyenda hace falta, o el color ya está explicado en el título?
- ¿Los colores son accesibles, consistentes con el resto del informe, y funcionan en escala de grises?
- ¿La interpretación evita confundir correlación con causalidad?
- ¿El formato y la resolución corresponden al destino?
- ¿Se declaró qué filas quedaron afuera y por qué?
El último punto no estaba en la diapositiva de la cátedra y es el que más veces salvó un análisis en esta unidad: en 9.1 eran nueve notas imposibles, en 9.2 una venta de siete dígitos, en 9.3 seis importes ilegibles y en 9.4 veinticinco ventas huérfanas.
Dónde se rompe
Causa: savefig después de show. En algunos backends
show libera la figura y lo que se guarda es un lienzo vacío.
Arreglo: guardar siempre antes de mostrar. En un script que corre solo,
show directamente no hace falta.
Causa: el PNG se guardó con fondo transparente y se pegó sobre una diapositiva oscura.
Arreglo: facecolor="white" al guardar, o dejar el fondo por
defecto de la figura, que ya es blanco.
Causa: bbox_inches="tight". El recorte depende del texto medido
y por lo tanto de la tipografía instalada.
Arreglo: figsize explícito más fig.tight_layout()
antes de guardar.
Causa: subiste el dpi esperando agrandar la letra. El dpi
multiplica todo por igual.
Arreglo: agrandá font.size o achicá figsize. La
letra se mide en puntos respecto del lienzo, no en píxeles.
Causa: hay más de un valor de y por cada x, y Seaborn promedia y estima un intervalo por bootstrap.
Arreglo: errorbar=None si querés solo la media. Y revisá el
grano de la tabla con df.groupby("x").size().max(): puede que estuvieras
graficando otra cosa de la que creías.
Causa: es vectorial y guardó cada uno de los cien mil puntos de la dispersión.
Arreglo: PNG a 300 dpi para nubes densas, o reducí los puntos con una
muestra o un hexbin.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
fig.tight_layout() | Acomoda márgenes dentro del lienzo | Siempre, antes de guardar |
fig.savefig("g.png", dpi=300) | PNG de alta resolución | Informe impreso |
fig.savefig("g.png", dpi=150) | PNG intermedio | Presentación proyectada |
fig.savefig("g.pdf") | Vectorial, texto como texto | Informe académico |
fig.savefig("g.svg") | Vectorial y editable | Web, o retoque posterior |
facecolor="white" | Fondo blanco explícito | Si el destino es oscuro |
bbox_inches="tight" | Recorta al contenido medido | Entrega de una vez; nunca en un pipeline |
plt.subplots(figsize=(7.2, 4.0)) | Lienzo explícito | Siempre, para que el tamaño sea tuyo |
sns.lineplot(..., errorbar=None) | Sin banda de confianza | Una sola observación por x |
marker="o" | Marca cada observación | Series cortas |
ax.bar_label(ax.containers[0], fmt="%.1f") | Valor sobre cada barra | Cuando el número exacto importa |
ax.margins(y=0.15) | Aire arriba para las etiquetas | Junto con bar_label |
df.groupby("x").size().max() | Cuántas filas hay por valor de x | Antes de un lineplot |