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 muchoplt.bar()suelto por internet; funciona para una prueba rápida y se vuelve un lío en cuanto hay dos gráficos. - 📏
barhy nobar: barras horizontales, porque los nombres de ciudad se leen sin torcer la cabeza. - ✂️ Esas dos líneas de
spinesquitan 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.
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
| Formato | Cuándo |
|---|---|
.png con dpi=150 | Presentaciones, correo, informes |
.svg | Web y documentos que se van a imprimir: no pierde nitidez |
.pdf | Cuando 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! 🌟