Series Temporales con Pandas y Python: Guía Práctica Completa

Aprende a analizar series temporales con Pandas 3.0 y Python. Desde DatetimeIndex y resample hasta rolling windows, descomposición STL y pronóstico con ARIMA y SARIMA, todo con código funcional y ejemplos actualizados.

Introducción: ¿por qué deberías dominar las series temporales en Python?

Los datos temporales están literalmente en todas partes: precios de acciones, registros climáticos, métricas de servidores, ventas diarias, consumo energético. Si trabajas en ciencia de datos, saber analizarlos no es algo opcional — es una habilidad que vas a necesitar sí o sí.

Y la buena noticia es que Pandas, especialmente en su versión 3.0 lanzada en enero de 2026, trae un conjunto de herramientas potente y maduro para trabajar con series temporales. Para la mayor parte del análisis, ni siquiera necesitas recurrir a bibliotecas externas.

En esta guía vamos a recorrer todo el flujo de trabajo: desde importar y preparar datos temporales, hasta visualizarlos, descomponerlos en sus componentes y hacer un pronóstico con ARIMA. Todo con código funcional que puedes copiar y adaptar a tus proyectos. Así que, vamos a ello.

Configuración del entorno

Antes de empezar, asegúrate de tener las librerías necesarias. Trabajaremos con pandas 3.0+, NumPy, Matplotlib y statsmodels:

pip install pandas numpy matplotlib statsmodels

Verifica las versiones para asegurarte de que todo está al día:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from statsmodels.tsa.seasonal import seasonal_decompose
from statsmodels.tsa.arima.model import ARIMA

print(f"pandas: {pd.__version__}")
print(f"numpy: {np.__version__}")

Si usas pandas 3.0 o superior, ya cuentas con la resolución temporal automática y Copy-on-Write activado por defecto. Esto hace que trabajar con series temporales sea bastante más predecible y eficiente (y menos propenso a esos bugs silenciosos que tanto dolor de cabeza dan).

Crear y cargar datos de series temporales

Crear un DatetimeIndex desde cero

El primer paso para trabajar con series temporales en Pandas es tener un DatetimeIndex. Lo creas con pd.date_range():

# Crear un rango de fechas diario
fechas = pd.date_range(start="2023-01-01", end="2025-12-31", freq="D")
print(f"Total de fechas: {len(fechas)}")
print(f"Tipo: {type(fechas)}")
print(f"Frecuencia: {fechas.freq}")

# Crear una serie temporal con datos aleatorios
np.random.seed(42)
valores = np.cumsum(np.random.randn(len(fechas))) + 100
serie = pd.Series(valores, index=fechas, name="valor")
print(serie.head())

Los códigos de frecuencia que más vas a usar son: "D" (diario), "W" (semanal), "ME" (fin de mes), "MS" (inicio de mes), "QE" (fin de trimestre), "YE" (fin de año), "h" (por hora) y "min" (por minuto).

Cargar datos temporales desde un CSV

En la práctica, lo más habitual es cargar datos desde un archivo. El truco está en usar parse_dates e index_col para que Pandas interprete las fechas correctamente:

# Cargar CSV con columna de fecha como índice
df = pd.read_csv(
    "ventas_diarias.csv",
    parse_dates=["fecha"],
    index_col="fecha"
)

# Verificar que el índice sea DatetimeIndex
print(type(df.index))
print(df.index.dtype)
print(df.head())

Algo importante en pandas 3.0: la resolución temporal ya no es siempre nanosegundos. Ahora se infiere automáticamente. Si tus fechas tienen formato YYYY-MM-DD, se usa resolución de microsegundos por defecto, lo que te permite manejar rangos de fechas mucho más amplios sin errores de desbordamiento.

Convertir columnas existentes a datetime

Si ya tienes un DataFrame con una columna de texto que representa fechas, la conviertes con pd.to_datetime():

# Convertir columna de texto a datetime
df["fecha"] = pd.to_datetime(df["fecha"], format="%Y-%m-%d")

# Establecer como índice
df = df.set_index("fecha")

# Ordenar por fecha (importante para series temporales)
df = df.sort_index()

# Verificar si hay huecos en las fechas
rango_completo = pd.date_range(start=df.index.min(), end=df.index.max(), freq="D")
fechas_faltantes = rango_completo.difference(df.index)
print(f"Fechas faltantes: {len(fechas_faltantes)}")

Selección y filtrado temporal

Una de las grandes ventajas de usar un DatetimeIndex es que puedes seleccionar periodos de tiempo de forma muy intuitiva. Sinceramente, una vez que te acostumbras a esto, no quieres volver atrás:

# Generar datos de ejemplo
np.random.seed(42)
fechas = pd.date_range("2023-01-01", periods=1000, freq="D")
df = pd.DataFrame({
    "ventas": np.random.poisson(lam=50, size=1000) + np.sin(np.arange(1000) * 2 * np.pi / 365) * 20,
    "temperatura": 20 + 10 * np.sin(np.arange(1000) * 2 * np.pi / 365) + np.random.randn(1000) * 3
}, index=fechas)

# Seleccionar un año completo
datos_2024 = df.loc["2024"]
print(f"Registros en 2024: {len(datos_2024)}")

# Seleccionar un mes específico
enero_2024 = df.loc["2024-01"]
print(f"Registros en enero 2024: {len(enero_2024)}")

# Seleccionar un rango de fechas
primer_trimestre = df.loc["2024-01-01":"2024-03-31"]
print(f"Registros Q1 2024: {len(primer_trimestre)}")

# Filtrar por día de la semana (0=lunes, 6=domingo)
solo_lunes = df[df.index.dayofweek == 0]
print(f"Total de lunes: {len(solo_lunes)}")

Este tipo de indexación parcial por cadenas es exclusiva de DatetimeIndex. Simplifica muchísimo el trabajo con datos temporales.

Extraer componentes de fecha

Del índice temporal puedes extraer información muy útil para análisis posteriores. Por ejemplo, saber en qué día de la semana cae cada registro o a qué trimestre pertenece:

# Extraer componentes temporales
df["anio"] = df.index.year
df["mes"] = df.index.month
df["dia_semana"] = df.index.dayofweek
df["dia_anio"] = df.index.dayofyear
df["trimestre"] = df.index.quarter
df["es_fin_semana"] = df.index.dayofweek >= 5

# Ver distribución por mes
print(df.groupby("mes")["ventas"].mean().round(1))

Remuestreo (Resampling): cambiar la frecuencia temporal

El método resample() es, probablemente, una de las herramientas más potentes de Pandas para series temporales. Funciona como un groupby basado en intervalos de tiempo, y te permite agregar datos a frecuencias mayores (downsampling) o menores (upsampling).

Downsampling: de diario a semanal o mensual

# Ventas semanales (suma)
ventas_semanales = df["ventas"].resample("W").sum()
print("Ventas semanales:")
print(ventas_semanales.head())

# Estadísticas mensuales (múltiples agregaciones)
resumen_mensual = df["ventas"].resample("ME").agg(["sum", "mean", "std", "min", "max"])
print("\nResumen mensual:")
print(resumen_mensual.head())

# Temperatura media trimestral
temp_trimestral = df["temperatura"].resample("QE").mean()
print("\nTemperatura media trimestral:")
print(temp_trimestral.head())

Upsampling: de mensual a diario con interpolación

# Crear datos mensuales
datos_mensuales = df["ventas"].resample("ME").sum()

# Upsampling a frecuencia diaria
diario_relleno = datos_mensuales.resample("D").asfreq()
print(f"Valores nulos tras upsampling: {diario_relleno.isna().sum()}")

# Rellenar con interpolación lineal
diario_interpolado = datos_mensuales.resample("D").interpolate(method="linear")

# Rellenar con forward fill (propagar último valor conocido)
diario_ffill = datos_mensuales.resample("D").ffill()

# Comparar los tres métodos
fig, axes = plt.subplots(3, 1, figsize=(12, 8), sharex=True)
diario_relleno.plot(ax=axes[0], title="Sin rellenar (con NaN)", style="o", markersize=2)
diario_interpolado.plot(ax=axes[1], title="Interpolación lineal")
diario_ffill.plot(ax=axes[2], title="Forward fill")
plt.tight_layout()
plt.savefig("upsampling_comparacion.png", dpi=150, bbox_inches="tight")
plt.show()

Ventanas móviles: rolling, expanding y ewm

Las ventanas móviles te permiten calcular estadísticas sobre un subconjunto deslizante de los datos. Son esenciales para suavizar ruido, detectar tendencias y construir indicadores técnicos. En mi experiencia, es de lo que más se usa en análisis de series temporales del día a día.

Media móvil con rolling()

# Media móvil de 7 y 30 días
df["ma_7"] = df["ventas"].rolling(window=7).mean()
df["ma_30"] = df["ventas"].rolling(window=30).mean()

# Desviación estándar móvil (volatilidad)
df["std_30"] = df["ventas"].rolling(window=30).std()

# Visualizar
fig, ax = plt.subplots(figsize=(14, 6))
df["ventas"].plot(ax=ax, alpha=0.3, label="Ventas diarias")
df["ma_7"].plot(ax=ax, label="Media móvil 7 días", linewidth=2)
df["ma_30"].plot(ax=ax, label="Media móvil 30 días", linewidth=2)
ax.set_title("Ventas diarias con medias móviles")
ax.set_xlabel("Fecha")
ax.set_ylabel("Ventas")
ax.legend()
plt.tight_layout()
plt.savefig("medias_moviles.png", dpi=150, bbox_inches="tight")
plt.show()

Ventana expansiva con expanding()

A diferencia de rolling(), la ventana expansiva crece con cada nueva observación. Viene muy bien para calcular estadísticas acumuladas:

# Media acumulada (expanding)
df["media_acumulada"] = df["ventas"].expanding().mean()

# Máximo acumulado
df["max_acumulado"] = df["ventas"].expanding().max()

# Visualizar
fig, ax = plt.subplots(figsize=(14, 5))
df["ventas"].plot(ax=ax, alpha=0.3, label="Ventas diarias")
df["media_acumulada"].plot(ax=ax, label="Media acumulada", linewidth=2)
ax.set_title("Media acumulada de ventas")
ax.legend()
plt.tight_layout()
plt.show()

Media móvil exponencial con ewm()

La media móvil exponencial (EMA) da más peso a las observaciones recientes. Esto la hace más sensible a cambios de tendencia, lo cual puede ser bueno o malo dependiendo de lo que busques:

# EMA con span de 7 y 30 días
df["ema_7"] = df["ventas"].ewm(span=7).mean()
df["ema_30"] = df["ventas"].ewm(span=30).mean()

# Comparar SMA vs EMA
fig, ax = plt.subplots(figsize=(14, 6))
df.loc["2024-06":"2024-09", "ventas"].plot(ax=ax, alpha=0.3, label="Ventas diarias")
df.loc["2024-06":"2024-09", "ma_7"].plot(ax=ax, label="SMA 7 días", linewidth=2)
df.loc["2024-06":"2024-09", "ema_7"].plot(ax=ax, label="EMA 7 días", linewidth=2, linestyle="--")
ax.set_title("SMA vs EMA (7 días)")
ax.legend()
plt.tight_layout()
plt.show()

La EMA reacciona más rápido ante cambios bruscos, mientras que la SMA (media móvil simple) ofrece una visión más suavizada. ¿Cuál usar? Pues depende: si necesitas detectar cambios rápidos, EMA; si buscas tendencias de largo plazo, SMA.

Visualización de series temporales con Matplotlib

Visualizar correctamente una serie temporal es tan importante como analizarla con números. Un buen gráfico te puede revelar patrones que las estadísticas descriptivas por sí solas no muestran. Veamos las técnicas más útiles.

Gráfico de línea con anotaciones

fig, ax = plt.subplots(figsize=(14, 6))
df["ventas"].plot(ax=ax, alpha=0.5)
df["ma_30"].plot(ax=ax, linewidth=2, color="red", label="Tendencia (MA 30)")

# Anotar el máximo
idx_max = df["ventas"].idxmax()
ax.annotate(
    f"Máximo: {df.loc[idx_max, 'ventas']:.0f}",
    xy=(idx_max, df.loc[idx_max, "ventas"]),
    xytext=(idx_max + pd.Timedelta(days=60), df["ventas"].max() + 5),
    arrowprops=dict(arrowstyle="->", color="black"),
    fontsize=10
)

ax.set_title("Serie temporal de ventas con tendencia")
ax.set_xlabel("Fecha")
ax.set_ylabel("Ventas")
ax.legend()
plt.tight_layout()
plt.savefig("serie_temporal_anotada.png", dpi=150, bbox_inches="tight")
plt.show()

Gráfico estacional (comparativa por año)

Este tipo de gráfico es muy revelador. Superpones los datos de cada año para ver si hay patrones que se repiten:

fig, ax = plt.subplots(figsize=(12, 6))

for anio in df.index.year.unique():
    datos_anio = df.loc[str(anio), "ventas"].reset_index(drop=True)
    ax.plot(datos_anio.values, label=str(anio), alpha=0.7)

ax.set_title("Comparativa estacional de ventas por año")
ax.set_xlabel("Día del año")
ax.set_ylabel("Ventas")
ax.legend()
plt.tight_layout()
plt.show()

Heatmap mensual

Los mapas de calor son otra forma genial de visualizar patrones estacionales. Aquí cruzamos meses contra años:

# Crear tabla pivote para heatmap
pivot = df.pivot_table(
    values="ventas",
    index=df.index.month,
    columns=df.index.year,
    aggfunc="mean"
)
pivot.index = ["Ene", "Feb", "Mar", "Abr", "May", "Jun",
               "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"]

fig, ax = plt.subplots(figsize=(10, 6))
im = ax.imshow(pivot.values, cmap="YlOrRd", aspect="auto")
ax.set_xticks(range(len(pivot.columns)))
ax.set_xticklabels(pivot.columns)
ax.set_yticks(range(len(pivot.index)))
ax.set_yticklabels(pivot.index)

# Añadir valores en cada celda
for i in range(len(pivot.index)):
    for j in range(len(pivot.columns)):
        ax.text(j, i, f"{pivot.values[i, j]:.1f}", ha="center", va="center", fontsize=9)

plt.colorbar(im, ax=ax, label="Ventas promedio")
ax.set_title("Mapa de calor: ventas promedio por mes y año")
plt.tight_layout()
plt.savefig("heatmap_estacional.png", dpi=150, bbox_inches="tight")
plt.show()

Descomposición de series temporales

Descomponer una serie temporal significa separarla en sus componentes fundamentales: tendencia, estacionalidad y residuo. Esto te ayuda a entender qué fuerzas están detrás de los datos y, además, es un paso previo esencial antes de aplicar cualquier modelo de pronóstico.

Descomposición aditiva vs. multiplicativa

La regla general es sencilla. Usa el modelo aditivo cuando la amplitud de la estacionalidad se mantiene constante a lo largo del tiempo. Usa el multiplicativo cuando esa amplitud crece o decrece proporcionalmente a la tendencia.

from statsmodels.tsa.seasonal import seasonal_decompose

# Resamplear a mensual para descomposición más clara
mensual = df["ventas"].resample("ME").mean()

# Descomposición aditiva
decomp_aditiva = seasonal_decompose(mensual, model="additive", period=12)

fig = decomp_aditiva.plot()
fig.set_size_inches(14, 10)
fig.suptitle("Descomposición aditiva de ventas mensuales", fontsize=14, y=1.02)
plt.tight_layout()
plt.savefig("descomposicion_aditiva.png", dpi=150, bbox_inches="tight")
plt.show()

Descomposición STL (más robusta)

Si quieres algo más robusto, la descomposición STL (Seasonal-Trend decomposition using LOESS) es tu mejor opción. Funciona especialmente bien cuando hay valores atípicos o la estacionalidad cambia con el tiempo:

from statsmodels.tsa.seasonal import STL

# STL sobre datos mensuales
stl = STL(mensual, period=12, robust=True)
resultado = stl.fit()

fig = resultado.plot()
fig.set_size_inches(14, 10)
fig.suptitle("Descomposición STL de ventas mensuales", fontsize=14, y=1.02)
plt.tight_layout()
plt.savefig("descomposicion_stl.png", dpi=150, bbox_inches="tight")
plt.show()

# Acceder a los componentes individuales
tendencia = resultado.trend
estacionalidad = resultado.seasonal
residuo = resultado.resid

print("Fuerza de la estacionalidad:")
print(f"  Varianza estacional / Varianza total: {estacionalidad.var() / mensual.var():.2%}")

Pronóstico con ARIMA

ARIMA (AutoRegressive Integrated Moving Average) es uno de los modelos clásicos para pronóstico de series temporales. Combina tres componentes: autoregresión (AR), diferenciación (I) y media móvil (MA). No es el modelo más sofisticado que existe, pero sigue siendo un punto de partida muy sólido.

Verificar estacionariedad

Antes de aplicar ARIMA, tu serie tiene que ser estacionaria (media y varianza constantes en el tiempo). Para comprobarlo, usamos el test de Dickey-Fuller aumentado:

from statsmodels.tsa.stattools import adfuller

def test_estacionariedad(serie, nombre=""):
    resultado = adfuller(serie.dropna())
    print(f"Test ADF para {nombre}:")
    print(f"  Estadístico ADF: {resultado[0]:.4f}")
    print(f"  p-valor: {resultado[1]:.4f}")
    print(f"  Valores críticos:")
    for clave, valor in resultado[4].items():
        print(f"    {clave}: {valor:.4f}")
    if resultado[1] < 0.05:
        print(f"  La serie ES estacionaria (p < 0.05)")
    else:
        print(f"  La serie NO es estacionaria (p >= 0.05)")
    print()

# Test sobre la serie original
test_estacionariedad(mensual, "Ventas mensuales")

# Test sobre la serie diferenciada
test_estacionariedad(mensual.diff().dropna(), "Ventas diferenciadas (d=1)")

Ajustar y predecir con ARIMA

from statsmodels.tsa.arima.model import ARIMA

# Dividir en entrenamiento y prueba
train = mensual[:"2025-06"]
test = mensual["2025-07":]

# Ajustar modelo ARIMA(1,1,1)
modelo = ARIMA(train, order=(1, 1, 1))
resultado_arima = modelo.fit()

print(resultado_arima.summary())

# Pronóstico
n_pasos = len(test)
pronostico = resultado_arima.forecast(steps=n_pasos)

# Visualizar
fig, ax = plt.subplots(figsize=(14, 6))
train.plot(ax=ax, label="Entrenamiento")
test.plot(ax=ax, label="Datos reales (test)")
pronostico.plot(ax=ax, label="Pronóstico ARIMA(1,1,1)", style="--", color="red")
ax.fill_between(
    pronostico.index,
    resultado_arima.get_forecast(steps=n_pasos).conf_int().iloc[:, 0],
    resultado_arima.get_forecast(steps=n_pasos).conf_int().iloc[:, 1],
    alpha=0.2, color="red", label="Intervalo de confianza 95%"
)
ax.set_title("Pronóstico de ventas mensuales con ARIMA")
ax.set_xlabel("Fecha")
ax.set_ylabel("Ventas promedio")
ax.legend()
plt.tight_layout()
plt.savefig("pronostico_arima.png", dpi=150, bbox_inches="tight")
plt.show()

# Calcular error
from sklearn.metrics import mean_absolute_error, mean_squared_error
mae = mean_absolute_error(test, pronostico)
rmse = np.sqrt(mean_squared_error(test, pronostico))
print(f"MAE: {mae:.2f}")
print(f"RMSE: {rmse:.2f}")

SARIMA para datos con estacionalidad

Si tu serie tiene un patrón estacional claro (y muchas lo tienen), SARIMA es la extensión natural de ARIMA. Añade parámetros estacionales (P, D, Q, s) donde s es el periodo estacional:

from statsmodels.tsa.statespace.sarimax import SARIMAX

# SARIMA con estacionalidad mensual (periodo=12)
modelo_sarima = SARIMAX(
    train,
    order=(1, 1, 1),
    seasonal_order=(1, 1, 1, 12),
    enforce_stationarity=False,
    enforce_invertibility=False
)
resultado_sarima = modelo_sarima.fit(disp=False)

# Pronóstico
pronostico_sarima = resultado_sarima.forecast(steps=n_pasos)

# Comparar ARIMA vs SARIMA
fig, ax = plt.subplots(figsize=(14, 6))
test.plot(ax=ax, label="Datos reales", linewidth=2)
pronostico.plot(ax=ax, label="ARIMA(1,1,1)", style="--")
pronostico_sarima.plot(ax=ax, label="SARIMA(1,1,1)(1,1,1,12)", style="--")
ax.set_title("Comparación: ARIMA vs SARIMA")
ax.legend()
plt.tight_layout()
plt.show()

mae_sarima = mean_absolute_error(test, pronostico_sarima)
print(f"MAE ARIMA:  {mae:.2f}")
print(f"MAE SARIMA: {mae_sarima:.2f}")

Consejos prácticos para el día a día

Después de trabajar bastante con series temporales, estos son los consejos que me hubiera gustado recibir al principio:

  • Siempre ordena por fecha antes de hacer cualquier análisis. Un índice temporal desordenado produce resultados silenciosamente incorrectos con rolling() y resample(). Y lo peor es que no te da ningún error.
  • Maneja los huecos temporales de forma explícita. Usa asfreq() para hacer visibles los valores faltantes y luego decide cómo rellenarlos (ffill, interpolate, o dejarlos como NaN).
  • Ojo con las zonas horarias. Si tus datos vienen de diferentes fuentes, normaliza a UTC antes de combinarlos: df.index = df.index.tz_localize("UTC"). Esto te va a ahorrar horas de debugging.
  • Valida con datos fuera de muestra. Nunca evalúes el rendimiento de un pronóstico con los mismos datos de entrenamiento. Parece obvio, pero pasa más de lo que imaginas.
  • Usa min_periods en rolling() para controlar cuántas observaciones mínimas necesitas antes de calcular la estadística. Así evitas esos NaN molestos al inicio de la serie.

Preguntas frecuentes

¿Cuál es la diferencia entre resample() y groupby() en Pandas?

resample() es un método especializado para agrupar datos por intervalos de tiempo sobre un DatetimeIndex. Aunque conceptualmente se parece a groupby(), está optimizado para frecuencias temporales y soporta tanto downsampling como upsampling. Si tus datos tienen un índice temporal, resample() siempre va a ser la opción más limpia.

¿Qué hago si mi serie temporal tiene muchos valores faltantes?

Depende del contexto. Para pocos valores aislados, interpolate(method="time") funciona bien porque respeta el espaciado temporal. Para huecos más largos, ffill() con un límite (limit=n) evita propagar valores obsoletos demasiado lejos. Y si los huecos son realmente grandes, honestamente lo mejor es segmentar la serie y analizar cada tramo por separado.

¿Cuándo usar descomposición aditiva vs. multiplicativa?

Usa aditiva cuando la variación estacional se mantiene constante sin importar el nivel de la tendencia (por ejemplo, la temperatura varía ±10°C sin importar la media anual). Usa multiplicativa cuando la variación es proporcional a la tendencia. Un ejemplo clásico: ventas que suben un 20% cada diciembre — el pico absoluto crece conforme crecen las ventas base.

¿Cómo elijo los parámetros (p, d, q) para ARIMA?

El parámetro d se determina con el test de Dickey-Fuller: diferencia la serie hasta que sea estacionaria (generalmente d=0, 1 o 2). Para p y q, examina los gráficos de autocorrelación (ACF) y autocorrelación parcial (PACF). Como alternativa más práctica, puedes usar pmdarima con su función auto_arima() que busca automáticamente el mejor modelo según criterios como AIC o BIC.

¿Qué cambió en pandas 3.0 para series temporales?

Tres cosas principales. Primero, la resolución temporal ya no es siempre nanosegundos sino que se infiere automáticamente (generalmente microsegundos), permitiendo rangos de fechas mucho más amplios. Segundo, Copy-on-Write viene activado por defecto, lo que evita copias defensivas innecesarias al filtrar series. Y tercero, se eliminaron parámetros obsoletos como closed y normalize del constructor de DatetimeIndex, y kind de resample().

Sobre el Autor Editorial Team

Our team of expert writers and editors.