Saltar al contenido

Indice / Visualizacion con Matplotlib / 8.3

Líneas: tendencia en el tiempo

La línea es el gráfico que más afirma y el que más fácil se equivoca, porque une puntos que quizás no están en el orden que vos creés y salta huecos que quizás no viste.

Para qué sirve

La pregunta es «¿cómo cambia esto con el tiempo?» y la respuesta que interesa casi nunca es un valor: es una forma. Sube, baja, oscila, se aplana, tiene un salto en una fecha concreta. Eso se ve en un segundo en una línea y no se ve nunca en una tabla de noventa filas.

El precio es que la línea hace dos promesas fuertes sin avisar: que el eje horizontal está ordenado y que entre dos puntos no falta nada. Las dos se rompen con datos reales, y ninguna de las dos roturas produce un error.

ELI5Explicado facil

Una línea es como el recorrido de una excursión dibujado en un mapa.

  • Si numerás mal las paradas, el recorrido va y vuelve como loco aunque las paradas estén todas en el lugar correcto.
  • Si un día no anotaste dónde estuviste, el mapa dibuja una recta desde el día anterior al siguiente, como si hubieras ido derecho. Y no hay nada en el dibujo que diga que ese tramo es inventado.

El ejemplo mínimo

EjemploSerie diaria y media movil: el suavizado separa tendencia de ruido
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

consumo = pd.DataFrame({
    "dia": range(1, 21),
    "kwh": [210, 218, 215, 225, 232, 228, 240, 247, 243, 252,
            260, 258, 269, 275, 271, 282, 289, 286, 295, 304]})

# mode="valid" devuelve 17 valores, no 20: el promedio de cuatro dias no
# existe hasta el cuarto dia. Hay que alinearlo a mano, y esa incomodidad es
# preferible a que la linea diga que el dia 1 tiene una media de cuatro dias.
movil = np.convolve(consumo["kwh"], np.ones(4) / 4, mode="valid")
dias_movil = consumo["dia"].iloc[3:]

fig, ax = plt.subplots()
ax.plot(consumo["dia"], consumo["kwh"], color="0.6", marker="o",
        markersize=4, label="Consumo diario")
ax.plot(dias_movil, movil, color="crimson", linewidth=3,
        label="Media movil de 4 dias")
ax.set_title("Consumo del edificio y su tendencia")
ax.set_xlabel("Dia")
ax.set_ylabel("kWh")
ax.set_xticks(range(1, 21, 2))
ax.legend()
fig.tight_layout()
plt.show()

print("puntos de la serie:", len(consumo), " de la media movil:", len(movil))
print()
print(pd.DataFrame({"dia": dias_movil.to_numpy(),
                    "kwh": consumo["kwh"].iloc[3:].to_numpy(),
                    "movil": movil.round(2)}).to_string(index=False))
print()
print("La serie sube y baja todos los dias; la media movil solo sube.")
print("El suavizado no agrega datos: saca ruido para que la tendencia se vea.")
el consumo diario sube de 210 a 304 kWh con oscilaciones, y una linea mas gruesa de media movil de cuatro dias sigue la misma subida sin los saltos
puntos de la serie: 20  de la media movil: 17

 dia  kwh  movil
   4  225 217.00
   5  232 222.50
   6  228 225.00
   7  240 231.25
   8  247 236.75
   9  243 239.50
  10  252 245.50
  11  260 250.50
  12  258 253.25
  13  269 259.75
  14  275 265.50
  15  271 268.25
  16  282 274.25
  17  289 279.25
  18  286 282.00
  19  295 288.00
  20  304 293.50

La serie sube y baja todos los dias; la media movil solo sube.
El suavizado no agrega datos: saca ruido para que la tendencia se vea.
La media móvil devuelve menos puntos, y eso es correcto

np.convolve(..., mode="valid") con ventana 4 sobre 20 días devuelve 17 valores: el promedio de cuatro días no existe hasta el cuarto día. Hay que alinear a mano con dias.iloc[3:].

El atajo tentador es mode="same", que devuelve 20 y evita el trabajo. Lo que hace es rellenar los bordes con ventanas incompletas, así que los primeros valores están calculados con menos días de los que dicen y la curva queda corrida respecto de la serie. Es un error que nadie ve y que corre la tendencia justo donde suele estar la conclusión.

Sobre datos de verdad

Lo que pasa si graficás la columna tal como viene

EjemploLa linea sigue el orden de las filas, no el del calendario
import pandas as pd
import matplotlib.pyplot as plt

ventas = pd.read_csv("ventas_mensuales.csv")
ventas["total"] = ventas["unidades"] * ventas["precio_unitario"]

# Las primeras 30 filas, tal como vienen en el archivo. La columna fecha es
# texto y el archivo no esta ordenado por fecha.
primeras = ventas.head(30)

fig, ax = plt.subplots()
ax.plot(primeras["fecha"], primeras["total"], marker="o", markersize=4)
ax.set_title("Ventas por fecha, en el orden del archivo")
ax.set_xlabel("Fecha")
ax.set_ylabel("Importe")
ax.tick_params(axis="x", labelrotation=90, labelsize=6)
fig.tight_layout()
plt.show()

print("primeras diez fechas del archivo:")
print(primeras["fecha"].head(10).to_list())
print()
print("La columna fecha es de tipo:", ventas["fecha"].dtype)
print()
print("Dos cosas rotas de una vez:")
print("  1. matplotlib trata cada fecha como una categoria y las pone en el")
print("     orden en que aparecen, asi que la linea va de marzo a febrero.")
print("  2. cada fila es una venta, no un dia: hay fechas repetidas y")
print("     el grafico dibuja una posicion distinta para cada una.")
una linea quebrada que va y vuelve entre marzo, febrero y enero porque une las ventas en el orden en que estan escritas en el archivo
primeras diez fechas del archivo:
['2026-03-02', '2026-03-26', '2026-03-31', '2026-02-09', '2026-03-27', '2026-03-14', '2026-01-18', '2026-01-09', '2026-03-22', '2026-02-28']

La columna fecha es de tipo: str

Dos cosas rotas de una vez:
  1. matplotlib trata cada fecha como una categoria y las pone en el
     orden en que aparecen, asi que la linea va de marzo a febrero.
  2. cada fila es una venta, no un dia: hay fechas repetidas y
     el grafico dibuja una posicion distinta para cada una.

La línea vuelve para atrás varias veces. No es un error de Matplotlib: hizo exactamente lo que se le pidió. La columna fecha es texto, así que cada valor distinto es una categoría, y las categorías se ubican en el orden en que aparecen en los datos. El archivo no está ordenado por fecha, y encima hay fechas repetidas, porque cada fila es una venta y no un día.

Son dos problemas y hay que resolver los dos: el tipo de la columna y el grano de la tabla.

Convertir, agrupar y recién ahí dibujar

EjemploConvertir, agrupar por dia y recien ahi graficar
import pandas as pd
import matplotlib.pyplot as plt

ventas = pd.read_csv("ventas_mensuales.csv")
ventas["total"] = ventas["unidades"] * ventas["precio_unitario"]

# Tres pasos antes de dibujar, y ninguno es opcional:
#   convertir a fecha real, descartar lo que no es una fecha, y agrupar al
#   grano que el grafico necesita, que es un punto por dia.
fecha = pd.to_datetime(ventas["fecha"], format="%Y-%m-%d", errors="coerce")
print("fechas que no se pudieron convertir:", int(fecha.isna().sum()))
print(ventas.loc[fecha.isna(), "fecha"].to_list())

diario = (ventas.assign(fecha=fecha)
          .dropna(subset=["fecha"])
          .groupby("fecha")["total"].sum()
          .sort_index())

fig, ax = plt.subplots()
ax.plot(diario.index, diario.to_numpy(), linewidth=1)
ax.set_title("Ventas diarias del primer trimestre")
ax.set_xlabel("Fecha")
ax.set_ylabel("Importe del dia")
ax.grid(axis="y", alpha=0.3)
fig.tight_layout()
plt.show()

print()
print("dias con al menos una venta:", len(diario))
print("del", diario.index.min().date(), "al", diario.index.max().date())
print("dias del periodo:", (diario.index.max() - diario.index.min()).days + 1)
print()
print(diario.describe().round(2).to_string())
print()
print("El eje ya esta en fechas de verdad: matplotlib reparte enero, febrero y")
print("marzo segun cuantos dias tiene cada uno, no segun cuantas filas hay.")
las ventas diarias del trimestre saltan entre casi cero y unos diez mil pesos sin tendencia clara, con el eje horizontal en fechas reales de enero a marzo
fechas que no se pudieron convertir: 6
[nan, '2026-02-30', nan, '15/03/2026', nan, nan]

dias con al menos una venta: 75
del 2026-01-01 al 2026-03-31
dias del periodo: 90

count       75.00
mean      2932.26
std       2954.29
min         15.62
25%        754.64
50%       1862.41
75%       4216.37
max      10489.38

El eje ya esta en fechas de verdad: matplotlib reparte enero, febrero y
marzo segun cuantos dias tiene cada uno, no segun cuantas filas hay.

Seis fechas no se pudieron convertir: cuatro vacías, un 2026-02-30 que no existe y un 15/03/2026 escrito en otro formato. Con errors="coerce" quedan como NaT y se descartan, y eso hay que informarlo: seis ventas quedaron fuera del gráfico. Un gráfico que tapa seis filas sin decirlo es exactamente el tipo de error que después nadie encuentra.

Los días que no están en el archivo

EjemploLos dias sin venta y la media movil de siete dias
import pandas as pd
import matplotlib.pyplot as plt

ventas = pd.read_csv("ventas_mensuales.csv")
ventas["total"] = ventas["unidades"] * ventas["precio_unitario"]
fecha = pd.to_datetime(ventas["fecha"], format="%Y-%m-%d", errors="coerce")

diario = (ventas.assign(fecha=fecha)
          .dropna(subset=["fecha"])
          .groupby("fecha")["total"].sum())

# groupby solo devuelve los dias que aparecen en el archivo. La linea los une
# igual, y al hacerlo dibuja una recta sobre los dias que faltan como si no
# hubiera pasado nada. resample("D") pone esos dias en la serie.
completo = diario.resample("D").sum()
print("dias con venta:", len(diario), " dias del calendario:", len(completo))
print("dias sin ninguna venta:", int((completo == 0).sum()))

movil = completo.rolling(7, min_periods=7).mean()

fig, ax = plt.subplots()
ax.plot(completo.index, completo.to_numpy(), color="0.7", linewidth=1,
        label="Ventas del dia")
ax.plot(movil.index, movil.to_numpy(), color="crimson", linewidth=2.5,
        label="Media movil de 7 dias")
ax.set_title("Ventas diarias y tendencia semanal")
ax.set_xlabel("Fecha")
ax.set_ylabel("Importe del dia")
ax.legend()
fig.tight_layout()
plt.show()

print()
print("dias sin venta, primeros cinco:")
print([str(d.date()) for d in completo.index[completo == 0][:5]])
print()
print("media movil, ultimos siete valores:")
print(movil.dropna().tail(7).round(2).to_string())
print()
print("La serie gris no tiene forma. La media movil dice que, con todo el")
print("ruido de los dias sueltos, el nivel del trimestre casi no se mueve.")
la serie diaria gris cae a cero en los dias sin venta y llega a diez mil en los picos, mientras la media movil de siete dias se mueve suave entre mil y cinco mil
dias con venta: 75  dias del calendario: 90
dias sin ninguna venta: 15

dias sin venta, primeros cinco:
['2026-01-10', '2026-01-13', '2026-01-14', '2026-01-16', '2026-02-01']

media movil, ultimos siete valores:
fecha
2026-03-25    2747.61
2026-03-26    1846.01
2026-03-27    2647.11
2026-03-28    1963.05
2026-03-29    1681.82
2026-03-30    2786.77
2026-03-31    3459.31
Freq: D

La serie gris no tiene forma. La media movil dice que, con todo el
ruido de los dias sueltos, el nivel del trimestre casi no se mueve.
El hueco invisible

groupby("fecha") devuelve 75 días porque son los que aparecen en el archivo. El período tiene 90. Los 15 días sin ventas no se dibujan como huecos: se dibujan como una recta que va del día anterior al siguiente, y esa recta parece un dato.

resample("D").sum() pone esos días en la serie con valor cero. Ahora bien, cuidado: un cero solo es correcto si «no hay fila» significa «no hubo venta». Si significa «ese día no se cargaron los datos», el valor correcto es NaN, que Matplotlib dibuja como hueco de verdad. Los dos casos existen y el archivo no te lo dice: lo tenés que saber vos.

Acá hay que elegir

Qué ventana usar para suavizar

Una ventana chica sigue el ruido; una grande borra los cambios reales. Siete días es la elección obvia cuando hay efecto de día de semana, porque cada ventana contiene exactamente un sábado y un domingo. Doce meses cumple el mismo papel con datos mensuales estacionales.

Cuando no hay un ciclo natural, no hay respuesta correcta: hay una elección que se declara. «Media móvil de 7 días» escrito en la leyenda es suficiente; «serie suavizada» no lo es.

Dónde se rompe

(sin error) la línea va y vuelve, cruzándose a sí misma

Causa: el eje horizontal es texto y las filas no están ordenadas. Cada fecha es una categoría en el orden de aparición.

Arreglo: pd.to_datetime(...) y sort_index(). Con fechas de verdad, Matplotlib ordena y reparte por tiempo real.

(sin error) enero ocupa más ancho que febrero aunque tengan la misma cantidad de días graficados

Causa: eje categórico. Cada categoría ocupa el mismo ancho, así que el espacio depende de cuántas filas hay, no de cuánto tiempo pasó.

Arreglo: el mismo. Con un eje de fechas, tres días sin datos ocupan tres días de ancho.

(sin error) la serie parece continua y faltan quince días

Causa: groupby solo devuelve las claves que existen. La línea une el día 9 con el día 11 sin marcar nada.

Arreglo: resample("D"). Con .sum() si el hueco es un cero real; dejando NaN si el hueco es falta de dato.

ValueError: x and y must have same first dimension, but have shapes (20,) and (17,)

Causa: la media móvil con mode="valid" devuelve menos puntos que la serie.

Arreglo: recortar el eje horizontal igual: dias.iloc[3:] con ventana 4. El error acá es una buena noticia: te obliga a pensar en la alineación.

(sin error) la tendencia cambió de sentido al cambiar la ventana

Causa: la ventana es tan grande que se comió el período que estás mirando, o tan chica que estás leyendo ruido como tendencia.

Arreglo: probá dos o tres ventanas y mirá cuál sobrevive. Si la conclusión depende de la ventana, la conclusión es que no hay tendencia clara, y eso también es un resultado.

Recetario

De una columna de texto a una serie graficable
ExpresiónQué haceCuándo
pd.to_datetime(s, format=..., errors="coerce")Texto a fecha; lo que falla queda NaTSiempre, antes de graficar
s.isna().sum()Cuántas no se pudieron convertirPara informarlo, no para taparlo
df.groupby("fecha")["v"].sum()Un punto por díaCuando cada fila es un hecho, no un día
serie.sort_index()Orden cronológicoDespués de agrupar
serie.resample("D").sum()Agrega los días faltantes con ceroSi «sin fila» significa «sin actividad»
serie.resample("D").asfreq()Agrega los faltantes como NaNSi «sin fila» significa «sin dato»
serie.resample("ME").sum()Un punto por mesCuando el día es demasiado ruido
serie.rolling(7, min_periods=7).mean()Media móvil alineada solaLa forma cómoda, con índice de fecha
np.convolve(v, np.ones(4)/4, mode="valid")Media móvil sobre un arraySin índice; hay que alinear a mano
ax.plot(x, y, linewidth=1)Línea fina para la serie crudaCuando hay una línea gruesa encima
ax.axvline(fecha, linestyle="--")Marca una fechaSeñalar cuándo cambió algo
fig.autofmt_xdate()Inclina las fechas del ejeCuando se pisan

Ponete a prueba

1. ¿Qué dos cosas afirma una línea que no afirman las barras?Nivel 1 · Recordar
2. Una columna fecha quedó como texto y se grafica directamente. ¿Qué hace Matplotlib?Nivel 1 · Recordar
3. np.convolve(kwh, np.ones(4)/4, mode="valid") sobre 20 días devuelve 17 valores. ¿Por qué?Nivel 2 · Predecir la salida
4. groupby("fecha") sobre el archivo de ventas devuelve 75 días, pero el período tiene 90. ¿Qué dibuja la línea en los 15 días que faltan?Nivel 2 · Predecir la salida
5. Un archivo de sensores no tiene filas para el 12 de marzo porque ese día el equipo estuvo apagado. ¿Con qué se completa la serie?Nivel 3 · Elegir entre dos casi correctas
6. Seis fechas del archivo no se convierten y quedan como NaT. ¿Qué corresponde hacer?Nivel 3 · Elegir entre dos casi correctas
7. Este código corre y la media móvil sale desplazada respecto de la serie. ¿Cuál es el defecto?
diario = ventas.groupby("fecha")["total"].sum()
movil = diario.rolling(7).mean()
ax.plot(diario.index, diario.values)
ax.plot(diario.index, movil.values)
Nivel 4 · Encontrar el defecto

Prácticas de esta lección