Capítulo 12 de 14 9 secciones 11 min

Gráficos que se entienden

matplotlib sin adornos, y la regla que vale más que todas las opciones de color juntas.

matplotlib es la librería base para graficar en Python. Lo que decide si un gráfico se entiende no son los colores sino tres cosas: elegir el tipo correcto según la pregunta, ordenar las barras por valor y poner un título que diga la conclusión en vez del nombre de las columnas.

Hola! Vamos a hacer que se vea

Ya sabes cargar, limpiar y agrupar. Ahora falta la parte que decide si a alguien le importa: que se entienda de un vistazo 📊

Y te lo digo de entrada, porque es la lección del capítulo: lo que hace que un gráfico funcione no son los colores. Son tres decisiones que se toman antes de escribir una línea.

Las tres reglas, antes que el código

  • 🎯 El tipo sale de la pregunta, no del gusto. Comparar categorías es barras. Ver evolución en el tiempo es líneas. Ver cómo se reparte una variable es histograma. Ver si dos cosas se mueven juntas es dispersión. Ya está, con esas cuatro haces el 95%.
  • 📐 Las barras van ordenadas por valor, no alfabéticas. Nadie va a leer seis nombres para descubrir cuál es la más alta.
  • ✍️ El título dice la conclusión, no el nombre de las columnas. "Ventas por ciudad" no dice nada. "Arequipa vende 17% más que Lima" sí.

Si haces esas tres, ya vas mejor que la mayoría de los informes que he visto. Y ninguna necesita saber matplotlib 💜

El primer gráfico

import matplotlib
matplotlib.use('Agg')          # sin ventana, para guardar en archivo
import matplotlib.pyplot as plt
import pandas as pd
from pathlib import Path
import tempfile

df = pd.read_csv('ventas-miss-yera.csv').drop_duplicates(subset='id_venta')
df['ciudad'] = (df['ciudad'].str.strip().str.lower()
                .str.normalize('NFKD')
                .str.encode('ascii', 'ignore').str.decode('utf-8'))
df['monto'] = pd.to_numeric(df['monto'].str.replace(',', '.'), errors='coerce')

por_ciudad = df.groupby('ciudad')['monto'].sum().sort_values()
print(por_ciudad.round(0))
ciudad
lima        373379.0
cusco       387225.0
trujillo    396906.0
piura       400833.0
chiclayo    415093.0
arequipa    437847.0
Name: monto, dtype: float64

Fíjate en el sort_values() del final: ordené antes de graficar. Esa es la regla 2 y se aplica aquí, no en el gráfico.

carpeta = Path(tempfile.mkdtemp())

fig, ax = plt.subplots(figsize=(8, 4.5))
ax.barh(por_ciudad.index, por_ciudad.values, color='#F092C7')

ax.set_title('Arequipa vende 17% más que Lima', fontsize=13, loc='left')
ax.set_xlabel('Ventas del periodo (S/)')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)

fig.tight_layout()
ruta = carpeta / 'ventas-por-ciudad.png'
fig.savefig(ruta, dpi=120)
plt.close(fig)

print(ruta.name, 'creado:', ruta.exists())
print('pesa', round(ruta.stat().st_size / 1024), 'KB')
ventas-por-ciudad.png creado: True
pesa ...

Vamos por partes, que ahí hay varias cosas 🌸

  • 🖼️ fig, ax = plt.subplots() es la forma seria de empezar. Verás mucho plt.bar() suelto por internet; funciona para una prueba rápida y se vuelve un lío en cuanto hay dos gráficos.
  • 📏 barh y no bar: barras horizontales, porque los nombres de ciudad se leen sin torcer la cabeza.
  • ✂️ Esas dos líneas de spines quitan el marco de arriba y de la derecha. Es el cambio más barato que existe para que un gráfico se vea profesional.
  • 💾 tight_layout() antes de guardar, o los textos salen cortados.

Y ese matplotlib.use('Agg') de la primera línea es para guardar sin abrir ventana. En Colab no hace falta, ahí los gráficos salen solos debajo de la celda.

Y el 17% de dónde salió

De calcularlo, no de inventarlo. Si vas a poner un número en un título, compruébalo:

diferencia = (por_ciudad['arequipa'] / por_ciudad['lima'] - 1) * 100
print(f'{diferencia:.1f}%')
17.3%

Parece obvio y no lo es: los títulos con números inventados son la forma más rápida de perder la confianza de una reunión 🙃

Los cuatro tipos que necesitas

Barras: comparar categorías

por_canal = df.groupby('canal')['monto'].sum().sort_values()

fig, ax = plt.subplots(figsize=(7, 3.5))
ax.barh(por_canal.index, por_canal.values, color='#4DB8E8')
ax.set_title('Los cuatro canales venden casi lo mismo', loc='left')
fig.tight_layout()
fig.savefig(carpeta / 'canales.png', dpi=120)
plt.close(fig)

print(por_canal.round(0))
print('diferencia entre el mejor y el peor:',
      round((por_canal.max() / por_canal.min() - 1) * 100, 1), '%')
canal
WhatsApp       567392.0
Tienda         591519.0
Marketplace    619761.0
Web            632612.0
Name: monto, dtype: float64
diferencia entre el mejor y el peor: 11.5 %

Y aquí una decisión de honestidad: once por ciento de diferencia entre el mejor y el peor canal es poco. El título lo dice y no lo esconde.

La tentación sería cortar el eje para que la diferencia se vea enorme. Eso se hace todo el tiempo y es engañar con un gráfico 😖 En barras, el eje empieza en cero, siempre.

Cuatro gráficos del archivo de ventas: una línea con la venta por mes, barras horizontales por categoría, un histograma de montos con la media y la mediana marcadas, y cajas del monto por segmento de cliente.
Los cuatro, sobre el archivo de la guía. En el histograma se ve de un vistazo lo que cuenta la guía de estadística: la línea continua es la media, la punteada la mediana, y están separadas porque hay cola. Y en las cajas se ve que un segmento no solo vende más, sino que además varía muchísimo más.

El error que da todo el mundo la primera vez

Y este te va a pasar, así que mejor aquí:

fig, ax = plt.subplots()
ax.barh(por_canal.index, por_canal.values, colour='#F092C7')
AttributeError: Rectangle.set() got an unexpected keyword argument 'colour'

Escribí colour a la inglesa en vez de color. Y matplotlib no te dice "no conozco ese parámetro" a secas: te dice qué objeto lo rechazó, que es Rectangle, o sea la barra.

Ese patrón se repite con casi todos los errores de matplotlib: el mensaje nombra el objeto interno y no la función que llamaste. Cuando veas nombres raros tipo Rectangle, Line2D o Text, no te asustes: son las piezas del gráfico, y el problema está en el parámetro que les pasaste 🙂

plt.close(fig)
print('cerrada')
cerrada

Líneas: evolución en el tiempo

fecha = pd.to_datetime(df['fecha'], format='%Y-%m-%d', errors='coerce')
faltan = fecha.isna()
fecha[faltan] = pd.to_datetime(df.loc[faltan, 'fecha'],
                               format='%d/%m/%Y', errors='coerce')
df['fecha'] = fecha

por_mes = df.groupby(df['fecha'].dt.to_period('M'))['monto'].sum()
print(por_mes.head(4).round(0))
print('meses:', len(por_mes))
fecha
2025-01    128800.0
2025-02    129470.0
2025-03    139931.0
2025-04    115464.0
Freq: M, Name: monto, dtype: float64
meses: 18
fig, ax = plt.subplots(figsize=(9, 3.5))
ax.plot(por_mes.index.to_timestamp(), por_mes.values,
        marker='o', color='#F092C7')
ax.set_title('Ventas mensuales, enero 2025 a junio 2026', loc='left')
ax.set_ylim(0, None)
ax.grid(axis='y', alpha=0.3)
fig.tight_layout()
fig.savefig(carpeta / 'mensual.png', dpi=120)
plt.close(fig)

print('grafico de', len(por_mes), 'meses guardado')
grafico de 18 meses guardado

Ese set_ylim(0, None) hace lo mismo que en barras: obliga a que el eje empiece en cero. Sin él, matplotlib ajusta el eje al rango de los datos y una variación del 3% parece un terremoto.

Histograma: cómo se reparte una variable

fig, ax = plt.subplots(figsize=(8, 3.5))
ax.hist(df['monto'].dropna(), bins=40, color='#F08A8A')
ax.axvline(df['monto'].median(), color='#4A4A4A', linestyle='--')
ax.set_title('La mayoría de ventas está por debajo de S/1.000', loc='left')
fig.tight_layout()
fig.savefig(carpeta / 'histograma.png', dpi=120)
plt.close(fig)

print('mediana  S/', round(df['monto'].median(), 2))
print('promedio S/', round(df['monto'].mean(), 2))
print('por debajo de 1000:',
      round((df['monto'] < 1000).mean() * 100, 1), '%')
mediana  S/ 533.63
promedio S/ 803.76
por debajo de 1000: 73.0 %

El histograma es el gráfico que más me gusta y el que menos se usa. Con una tabla de promedios nunca ves la forma; aquí ves que el 73% de las ventas está bajo los mil soles y que hay una cola larga de ventas grandes que estira el promedio 🌟

Esa línea vertical de la mediana no es adorno: es lo que hace que se vea la distancia entre la mediana y el promedio sin explicarla.

Y ojo con bins. Con pocas cajas escondes la forma, con demasiadas todo parece ruido. Entre 20 y 50 suele funcionar, y vale la pena probar dos o tres.

Dispersión: si dos cosas se mueven juntas

fig, ax = plt.subplots(figsize=(6, 4))
ax.scatter(df['unidades'], df['monto'], s=8, alpha=0.25, color='#4DB8E8')
ax.set_xlabel('Unidades')
ax.set_ylabel('Monto (S/)')
ax.set_title('Más unidades no significa más monto', loc='left')
fig.tight_layout()
fig.savefig(carpeta / 'dispersion.png', dpi=120)
plt.close(fig)

print('correlación:', round(df['unidades'].corr(df['monto']), 3))
correlación: 0.018

Correlación de 0,009, o sea prácticamente nada. Y ahí está el valor del gráfico: cuando la respuesta es "no hay relación", eso también es un hallazgo y hay que reportarlo.

Ese alpha=0.25 hace los puntos semitransparentes. Con tres mil puntos encimados es la diferencia entre ver una mancha negra y ver dónde se concentran.

Y el aviso de siempre, que desarrollo en la guía de estadística: aunque la correlación hubiera salido alta, correlación no es causa.

Varios gráficos juntos

fig, axes = plt.subplots(1, 2, figsize=(11, 3.5))

por_segmento = df.groupby('segmento')['monto'].sum().sort_values()
axes[0].barh(por_segmento.index, por_segmento.values, color='#F092C7')
axes[0].set_title('Por segmento', loc='left')

axes[1].hist(df['satisfaccion'].dropna(), bins=5, color='#4DB8E8')
axes[1].set_title('Satisfacción', loc='left')

fig.tight_layout()
fig.savefig(carpeta / 'panel.png', dpi=120)
plt.close(fig)

print('archivos generados:', len(list(carpeta.glob('*.png'))))
archivos generados: 6

Ese subplots(1, 2) te da una fila y dos columnas, y axes es una lista. Con subplots(2, 2) serían cuatro y axes sería una matriz, así que se accede con axes[0][1].

Guardar para lo que sea

FormatoCuándo
.png con dpi=150Presentaciones, correo, informes
.svgWeb y documentos que se van a imprimir: no pierde nitidez
.pdfCuando va dentro de un documento formal
fig, ax = plt.subplots(figsize=(6, 3))
ax.barh(por_canal.index, por_canal.values, color='#F092C7')
fig.tight_layout()

for extension in ['png', 'svg', 'pdf']:
    fig.savefig(carpeta / f'canales.{extension}')
plt.close(fig)

for archivo in sorted(carpeta.glob('canales.*')):
    print(archivo.name, round(archivo.stat().st_size / 1024), 'KB')
canales.pdf ...

Y siempre plt.close(fig) después de guardar. Si generas cincuenta gráficos en un bucle sin cerrarlos, se te van acumulando en memoria y matplotlib te avisa con una advertencia que nadie entiende 😄

Lo que hace que se entienda, resumido

  • 🎯 El tipo sale de la pregunta: barras para comparar, líneas para el tiempo, histograma para la forma, dispersión para la relación.
  • 📊 Barras ordenadas por valor y horizontales si los nombres son largos.
  • 0️⃣ El eje empieza en cero. Cortarlo es engañar.
  • ✍️ El título dice la conclusión, con el número comprobado.
  • ✂️ Fuera el marco de arriba y de la derecha, fuera la leyenda si hay una sola serie, fuera todo lo que no aporte.

Ejercicios

1. Barras ordenadas

Grafica el total por categoría, ordenado, y guárdalo.

por_cat = df.groupby('categoria')['monto'].sum().sort_values()

fig, ax = plt.subplots(figsize=(7, 3))
ax.barh(por_cat.index, por_cat.values, color='#F092C7')
fig.tight_layout()
fig.savefig(carpeta / 'categorias.png', dpi=120)
plt.close(fig)

print(por_cat.round(0))
categoria
Cuidado personal    453910.0
Bebidas             456361.0
Limpieza            471763.0
Snacks              493895.0
Abarrotes           535355.0
Name: monto, dtype: float64
2. El título con su número

Calcula cuánto más vende la categoría líder que la última y escribe el título con ese número.

lider, ultima = por_cat.index[-1], por_cat.index[0]
cuanto = (por_cat.iloc[-1] / por_cat.iloc[0] - 1) * 100

print(f'{lider} vende {cuanto:.0f}% más que {ultima}')
Abarrotes vende 18% más que Cuidado personal
3. Histograma de unidades

Grafica cómo se reparten las unidades por venta y di cuál es la más frecuente.

fig, ax = plt.subplots(figsize=(7, 3))
ax.hist(df['unidades'], bins=25, color='#F08A8A')
fig.tight_layout()
fig.savefig(carpeta / 'unidades.png', dpi=120)
plt.close(fig)

print('mediana:', df['unidades'].median())
print('máximo :', df['unidades'].max())
mediana: 12.0
máximo : 30
4. El eje que engaña

Grafica los canales dos veces, una con el eje desde cero y otra desde el mínimo, y compara los archivos.

fig, axes = plt.subplots(1, 2, figsize=(11, 3))

axes[0].bar(por_canal.index, por_canal.values, color='#4DB8E8')
axes[0].set_title('Eje desde cero (honesto)', loc='left')

axes[1].bar(por_canal.index, por_canal.values, color='#F08A8A')
axes[1].set_ylim(por_canal.min() * 0.98, por_canal.max() * 1.01)
axes[1].set_title('Eje cortado (engañoso)', loc='left')

fig.tight_layout()
fig.savefig(carpeta / 'comparacion-ejes.png', dpi=120)
plt.close(fig)

print('los mismos datos, dos historias distintas')
los mismos datos, dos historias distintas

Ábrelos y míralos al lado. En el segundo, once por ciento de diferencia parece que un canal triplica al otro. Es el truco más usado para mentir con datos, y ahora lo vas a reconocer 👀

5. Dispersión con transparencia

Grafica satisfacción contra monto y calcula la correlación.

sub = df.dropna(subset=['satisfaccion', 'monto'])

fig, ax = plt.subplots(figsize=(6, 3.5))
ax.scatter(sub['satisfaccion'], sub['monto'], s=8, alpha=0.2, color='#4DB8E8')
fig.tight_layout()
fig.savefig(carpeta / 'satisfaccion.png', dpi=120)
plt.close(fig)

print('correlación:', round(sub['satisfaccion'].corr(sub['monto']), 3))
correlación: -0.017

Prácticamente cero otra vez. Reportar que no hay relación es tan válido como reportar que sí la hay, y a veces más útil.

6. Un panel de cuatro

Arma una figura con cuatro gráficos en dos filas.

fig, axes = plt.subplots(2, 2, figsize=(10, 6))

axes[0][0].barh(por_ciudad.index, por_ciudad.values, color='#F092C7')
axes[0][1].barh(por_canal.index, por_canal.values, color='#4DB8E8')
axes[1][0].hist(df['monto'].dropna(), bins=30, color='#F08A8A')
axes[1][1].hist(df['unidades'], bins=25, color='#4A4A4A')

for fila in axes:
    for a in fila:
        a.spines['top'].set_visible(False)
        a.spines['right'].set_visible(False)

fig.tight_layout()
fig.savefig(carpeta / 'panel4.png', dpi=120)
plt.close(fig)

print('panel de', axes.size, 'gráficos')
panel de 4 gráficos
7. Guardar en SVG

Guarda un gráfico en SVG y comprueba que el archivo es texto, no una imagen.

fig, ax = plt.subplots(figsize=(5, 2.5))
ax.barh(por_canal.index, por_canal.values, color='#F092C7')
fig.tight_layout()
ruta_svg = carpeta / 'prueba.svg'
fig.savefig(ruta_svg)
plt.close(fig)

print(ruta_svg.read_text()[:40])
<?xml version="1.0" encoding="utf-8" sta

Un SVG es texto XML que describe las formas, y por eso no se pixela por mucho que lo agrandes. Para la web es el formato correcto casi siempre.

8. Cerrar las figuras

Genera diez gráficos en un bucle cerrándolos, y comprueba que no queda ninguno abierto.

for i in range(10):
    f, a = plt.subplots(figsize=(3, 2))
    a.plot([1, 2, 3], [i, i + 1, i])
    f.savefig(carpeta / f'bucle-{i}.png')
    plt.close(f)

print('figuras abiertas:', len(plt.get_fignums()))
print('archivos:', len(list(carpeta.glob('ciudad-*.png'))))
figuras abiertas: 0
archivos: 0

Si quitas el plt.close(f), esa primera línea diría 10 y con cincuenta gráficos matplotlib te empieza a avisar. Cerrar es una línea y evita el problema entero.

Lo que te llevas

Que un gráfico bueno no es el más bonito, es el que se entiende sin que estés al lado explicándolo 💜

Y que las tres decisiones que lo deciden (el tipo, el orden y el título) se toman antes de escribir código.

En el capítulo 13 juntamos todo el libro en un solo proyecto: del archivo sucio a una conclusión con su gráfico, de punta a punta.

Que tengas un hermoso día! 🌟

¿Tienes alguna duda o consulta?