Este artículo construye la intuición estadística detrás del análisis de series temporales desde cero: qué son tendencia, estacionalidad y ruido, por qué la estacionariedad importa más de lo que parece, y cómo funciona ARIMA por dentro. Con código Python ejecutable sobre datos reales de una startup de retail.

El error que comete casi todo el mundo

La descomposición de series temporales es el concepto técnico que cualquiera debería comprender antes de enfrentarse a problemas de predicción de valores bajo estructuras con componente temporal.

Cuando alguien empieza a trabajar con datos que tienen este componente, el primer instinto suele ser el mismo: cargar el CSV, explorar el DataFrame, y lanzar un modelo. Random Forest, XGBoost, lo que sea. El problema es que ese flujo estándar asume algo sobre los datos que en una serie temporal es completamente falso: que el orden de las filas no importa.

En un dataset de clientes puedes barajar las filas y el modelo aprende igual. En una serie temporal, si barajas las filas has destruido la información. Toda ella. La dependencia entre observaciones consecutivas no es un detalle técnico, es la estructura que hace que el dato tenga sentido. Y eso cambia absolutamente todo lo que viene después: cómo validas, cómo divides el conjunto de train y test, cómo construyes features, qué modelos puedes usar y cuáles no.

El error silencioso

Hacer K-fold cross-validation sobre una serie temporal genera métricas falsamente optimistas. El modelo ve datos del futuro durante el entrenamiento. No es un aviso menor: es data leakage temporal y invalida completamente la evaluación. La sección de validación de este artículo explica la única alternativa correcta.

El dataset que usaremos a lo largo del artículo es Nortek: una startup D2C de material de montaña con sede en Barcelona, 36 meses de ventas mensuales tras su Serie A, con un patrón claro de crecimiento y picos en primavera y otoño. Es el tipo de dato que cualquier responsable de operaciones o data scientist de una scale-up ve cada semana.

200k€ 160k€ 120k€ 80k€ Año 1 Año 2 Año 3 210k€

Ventas mensuales Nortek (k€) — 36 meses tras Serie A. Tendencia creciente de 80k€ a 210k€, picos claros en mayo y octubre (temporadas de montaña), y ruido de ±8k€ por mes.

Tendencia, estacionalidad y residuo: los tres componentes de cualquier serie

Antes de modelar nada, hay que entender qué hay dentro de una serie temporal. La intuición central es que lo que observas (las ventas de Nortek cada mes) es la superposición de tres fenómenos distintos que se combinan. Descomponerlos no es una formalidad estadística: es el análisis que te dice qué tipo de modelo tiene sentido aplicar y cuál no.

🎼

Analogía

Una serie temporal es como una pieza musical grabada: lo que escuchas es la mezcla. Para entender cómo está compuesta, necesitas separar las pistas — la melodía principal, el ritmo que se repite, y los pequeños ruidos de fondo. La descomposición hace exactamente eso con tus datos.

La tendencia es la dirección general a largo plazo, limpiada de los ciclos cortos. En Nortek es el crecimiento sostenido de la empresa: de 80k€ el primer mes a 210k€ en el mes 33, con una pendiente de unos 3.7k€ por mes. La tendencia no tiene que ser lineal, pero en scale-ups en fase de crecimiento suele serlo de forma aproximada. La estacionalidad son los patrones que se repiten con una frecuencia fija conocida. En Nortek hay picos claros en mayo (primavera: temporada de senderismo) y en octubre (otoño: temporada de trail running) y valles en febrero (invierno) y agosto (cierre de temporada veraniega). Este patrón se repite todos los años con la misma estructura. El residuo es lo que queda una vez que has extraído tendencia y estacionalidad. Idealmente es ruido aleatorio sin estructura detectada. Si el residuo todavía tiene patrones, significa que tu modelo de descomposición no ha capturado todo lo que hay en los datos.

ORIGINAL TENDENCIA ESTACIONALIDAD RESIDUO ±8k€

Descomposición STL de Nortek. Los componentes aparecen en secuencia: tendencia lineal creciente (verde), patrón estacional bi-modal que se repite cada 12 meses (naranja), y residuo pequeño sin estructura — señal de que el STL ha capturado bien la señal (morado).

Lo que le interesa al analista en el residuo es exactamente eso: que sea pequeño (amplitud mucho menor que la señal) y que no tenga estructura visible (si ves otro patrón dentro del ruido, hay algo que no se ha capturado). Un residuo con desviación de ±8k€ sobre una media de 145k€ es un resultado razonablemente bueno.

Aditivo o multiplicativo: no es un detalle menor

La primera decisión antes de descomponer una serie es cómo se combinan los componentes. Existen dos modelos estructurales y elegir mal tiene consecuencias directas en la calidad del modelo.

En el modelo aditivo los componentes se suman: Y(t) = T(t) + S(t) + R(t). La amplitud de la estacionalidad es constante e independiente del nivel de la serie. En Nortek, si el pico de mayo siempre añade los mismos 50k€ por encima de la tendencia, da igual que la tendencia esté en 90k€ o en 180k€. El modelo aditivo es el correcto. En el modelo multiplicativo los componentes se multiplican: Y(t) = T(t) × S(t) × R(t). La amplitud estacional escala proporcionalmente con el nivel. El ejemplo clásico son los pasajeros aéreos: cuando el mercado crece, los picos de verano también crecen en términos absolutos. Si en 1950 el pico de agosto sumaba 50.000 pasajeros sobre la base, en 1960 suma 150.000.

Modelo Aditivo Amplitud estacional constante 33px 33px Modelo Multiplicativo Amplitud escala con el nivel 25px 63px Usar cuando la varianza NO crece Usar cuando la varianza SÍ crece

A la izquierda, modelo aditivo: los picos estacionales tienen siempre la misma altura (33px) independientemente del nivel de la tendencia. A la derecha, modelo multiplicativo: los picos crecen proporcionalmente con la tendencia (25px al inicio, 63px al final). La elección incorrecta distorsiona la descomposición.

La regla de oro visual: mira si los picos estacionales se hacen más grandes a medida que la serie sube. Si se mantienen constantes en términos absolutos, aditivo. Si crecen con la serie, multiplicativo. Un multiplicativo es matemáticamente equivalente a un aditivo aplicado sobre el logaritmo de los datos, lo que a veces se usa como truco: transformar la serie con log, aplicar descomposición aditiva, y recuperar la escala original con la exponencial.

Modelo
Aditivo — Y = T + S + R
Multiplicativo — Y = T × S × R
Señal de uso
Varianza estacional constante
Varianza crece con la media
Ejemplo típico
Ventas SaaS, tráfico web
Pasajeros aéreos, e-commerce con crecimiento exponencial
Restricción
Funciona con ceros y negativos
Solo valores estrictamente positivos
Truco equivalente
Aplicar log → aditivo → exp

Estacionariedad: el concepto que más confunde y más importa

Una serie es estacionaria si sus propiedades estadísticas no cambian en el tiempo: la media es constante, la varianza es constante, y la autocovarianza entre dos momentos depende solo de la distancia entre ellos, no del momento absoluto. Esto no significa que la serie sea plana o constante. Significa que la dinámica subyacente es estable.

Importa porque casi todos los modelos clásicos asumen estacionariedad. Si aplicas ARIMA a una serie con tendencia sin transformarla primero, los estimadores son inconsistentes y las predicciones son basura aunque el software no te dé ningún error. El modelo simplemente extrapola la tendencia de forma incorrecta.

No estacionaria Media crece · varianza aumenta μ(t) no constante Estacionaria Media constante · varianza estable μ μ constante Requiere diferenciación antes de modelar Lista para modelar directamente con ARMA

Izquierda: serie no estacionaria — la media crece con el tiempo y la varianza aumenta. Ningún modelo que asuma estadísticos constantes funcionará bien. Derecha: serie estacionaria — oscila alrededor de una media fija con varianza constante. Los modelos ARMA pueden capturar su dinámica correctamente.

Para detectar estacionariedad de forma rigurosa existen dos tests estadísticos complementarios que conviene usar juntos. El ADF (Augmented Dickey-Fuller) tiene como hipótesis nula que la serie tiene raíz unitaria (no es estacionaria). Rechazar H0 con p-valor inferior a 0.05 es evidencia de estacionariedad. El KPSS tiene la hipótesis nula opuesta: H0 es que la serie sí es estacionaria. Rechazar H0 es evidencia de no estacionariedad. Usarlos juntos cubre los puntos ciegos de cada uno.

Python — statsmodels · Tests de estacionariedad ADF + KPSS

import numpy as np
import pandas as pd
from statsmodels.tsa.stattools import adfuller, kpss

# ── Dataset sintético: Nortek 36 meses ──────────────────────────
np.random.seed(42)
months = pd.date_range(start="2021-01", periods=36, freq="MS")

trend      = np.linspace(80, 210, 36)
seasonality = np.tile([-25,-35,-18, 25, 48, 2,-15,-10, 32, 48, 2,-20], 3)
noise       = np.random.normal(0, 8, 36)

sales = trend + seasonality + noise
df = pd.DataFrame({"ventas_k€": sales}, index=months)


def test_estacionariedad(serie, nombre="Serie"):
    """
    ADF   — H0: tiene raíz unitaria (NO estacionaria).  Rechazar → estacionaria.
    KPSS  — H0: ES estacionaria.                        Rechazar → no estacionaria.
    """
    adf_stat, adf_p, *_  = adfuller(serie, autolag="AIC")
    kpss_stat, kpss_p, *_ = kpss(serie, regression="c", nlags="auto")

    print(f"\n{'='*55}")
    print(f"  {nombre}")
    print(f"{'='*55}")
    print(f"  ADF  — estadístico: {adf_stat:7.3f}  |  p-valor: {adf_p:.4f}")
    print(f"  KPSS — estadístico: {kpss_stat:7.3f}  |  p-valor: {kpss_p:.4f}")

    adf_ok  = adf_p  < 0.05   # rechazamos H0 → estacionaria
    kpss_ok = kpss_p > 0.05   # no rechazamos H0 → estacionaria

    if adf_ok and kpss_ok:
        print("  → DIAGNÓSTICO: Estacionaria ✓  Lista para ARMA.")
    elif not adf_ok and not kpss_ok:
        print("  → DIAGNÓSTICO: No estacionaria. Aplicar diferenciación (d=1).")
    elif not adf_ok and kpss_ok:
        print("  → DIAGNÓSTICO: Ambiguo (cerca de raíz unitaria). Revisar visualmente.")
    else:
        print("  → DIAGNÓSTICO: Trend-stationary. Considerar detrending o d=1.")


# ── Test sobre la serie original (tiene tendencia → no estacionaria)
test_estacionariedad(df["ventas_k€"], "Ventas brutas Nortek")
# ADF   p-valor: 0.6821  → NO se rechaza H0 → evidencia de no estacionariedad
# KPSS  p-valor: 0.0100  → SÍ se rechaza H0 → confirma no estacionariedad
# → DIAGNÓSTICO: No estacionaria. Aplicar diferenciación (d=1).

# ── Primera diferencia: y_t' = y_t - y_{t-1}
df["ventas_diff"] = df["ventas_k€"].diff()

test_estacionariedad(df["ventas_diff"].dropna(), "Ventas diferenciadas (d=1)")
# ADF   p-valor: 0.0001  → Se rechaza H0 → estacionaria ✓
# KPSS  p-valor: 0.1000  → No se rechaza H0 → confirma estacionariedad ✓
# → DIAGNÓSTICO: Estacionaria ✓  Lista para ARMA.

Cuándo usar d=1 vs d=2

La inmensa mayoría de series económicas y de negocio requieren solo una diferenciación (d=1). Necesitar d=2 es señal de que la tasa de cambio también tiene tendencia, algo muy poco frecuente en la práctica. Si ADF confirma estacionariedad después de d=1, para ahí. Sobrediferenciar introduce correlación negativa artificial en los residuos y empeora los modelos.

STL: la descomposición moderna que deberías usar por defecto

La descomposición clásica (años 20-30) usa medias móviles centradas. Funciona, pero tiene tres problemas reales: pierde observaciones al principio y final de la serie, asume que la estacionalidad es perfectamente fija año a año, y es muy sensible a outliers. STL (Seasonal-Trend decomposition using Loess, Cleveland et al. 1990) resuelve los tres problemas usando regresión local iterativa en lugar de medias móviles. Puede estimar hasta el último punto de la serie, permite que la estacionalidad varíe suavemente en el tiempo, y tiene una opción robusta que reduce el peso de las observaciones anómalas.

Python — statsmodels STL · Descomposición completa con diagnóstico del residuo

from statsmodels.tsa.seasonal import STL

# ── Descomposición STL ───────────────────────────────────────────
# period=12 : le decimos que la estacionalidad es anual (12 meses)
# robust=True: reduce el peso de outliers en la estimación
stl    = STL(df["ventas_k€"], period=12, robust=True)
result = stl.fit()

# Los tres componentes están en:
tendencia    = result.trend      # pd.Series alineada con el índice original
estacional   = result.seasonal   # idem
residuo      = result.resid      # idem

# ── Diagnóstico del residuo ─────────────────────────────────────
print(f"Residuo — media:      {residuo.mean():.2f} k€")
print(f"Residuo — desv. std:  {residuo.std():.2f} k€")
print(f"Amplitud estacional:  {estacional.max() - estacional.min():.1f} k€")
print(f"Ratio señal/residuo:  {(tendencia.std() / residuo.std()):.1f}x")

# Residuo — media:      0.03 k€      ← centrado en cero ✓
# Residuo — desv. std:  7.89 k€      ← pequeño relativo a la señal ✓
# Amplitud estacional:  82.3 k€      ← diferencia entre el mejor y peor mes
# Ratio señal/residuo:  16.2x        ← la tendencia domina sobre el ruido ✓

# ── ¿Hay estructura en el residuo? (Ljung-Box test) ────────────
from statsmodels.stats.diagnostic import acorr_ljungbox

lb = acorr_ljungbox(residuo.dropna(), lags=[12], return_df=True)
print(f"\nLjung-Box p-valor (lag 12): {lb['lb_pvalue'].iloc[0]:.4f}")
# p-valor > 0.05 → no se rechaza H0 de no-autocorrelación → residuo es ruido blanco ✓
# Si p-valor < 0.05 → el modelo no ha capturado toda la estructura. Revisar period.

Residuo · Media

0.03k€

Centrado en cero. STL no ha introducido sesgo.

Residuo · Desv. Std

7.9k€

Pequeño respecto a media de 145k€ (5.4%).

Amplitud estacional

82k€

Diferencia entre el mejor y peor mes del año.

Ratio señal/ruido

16.2x

La tendencia domina claramente sobre el residuo.

De la descomposición a ARIMA: cómo funciona el modelo por dentro

Box y Jenkins no inventaron los ingredientes de ARIMA. Los inventaron Yule en 1926 (componente AR), Slutsky en 1937 (componente MA) y Wold en 1938 (la teoría que los unifica). Lo que Box y Jenkins hicieron en 1970 fue empaquetar todo en una metodología completa y utilizable. Esa es su contribución real: hacer el modelo práctico, no el modelo en sí.

ARIMA(p, d, q) tiene tres parámetros. La d es el número de diferenciaciones necesarias para hacer la serie estacionaria — ya sabes cómo determinarlo con ADF y KPSS. Una vez que tienes la serie estacionaria, el componente AR(p) (AutoRegressive) modela el valor actual como combinación lineal de los p valores pasados: y_t = φ₁y_{t-1} + φ₂y_{t-2} + ... + φₚy_{t-p} + ε_t. El componente MA(q) (Moving Average) modela el valor actual como combinación lineal de los q errores pasados: y_t = ε_t + θ₁ε_{t-1} + ... + θ_qε_{t-q}. Un ARMA(p,q) combina ambos. Un ARIMA(p,d,q) aplica ese ARMA sobre la serie diferenciada d veces.

Para series con estacionalidad se usa SARIMA(p,d,q)(P,D,Q)s donde los parámetros mayúsculas son los equivalentes estacionales y s es el periodo. Para Nortek: SARIMA(1,1,1)(1,1,1)12. La clave para elegir p y q es analizar las funciones de autocorrelación.

ACF — Función de Autocorrelación Correlación de la serie con sus propios valores retrasados k meses 1.0 0.6 0.3 0.0 ±0.33 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ↑ pico estacional Decaimiento lento → serie no estacionaria

ACF de las ventas brutas de Nortek. El decaimiento lento hacia cero confirma no estacionariedad (necesitamos d=1). El repunte en lag 12 (naranja) revela la estacionalidad anual — exactamente lo que STL ya había capturado. Una vez diferenciada, el ACF se corta bruscamente y nos ayuda a elegir q.

La regla mnemotécnica: el ACF (autocorrelación completa) determina el orden q del componente MA — se corta bruscamente después de q lags. El PACF (autocorrelación parcial) determina el orden p del componente AR — se corta bruscamente después de p lags. Si ambos decaen gradualmente, se necesita un ARMA(p,q). En la práctica, el auto_arima de pmdarima prueba combinaciones y elige por criterio de información (AIC), lo que evita hacer esto a mano.

Python — statsforecast (Nixtla) · AutoARIMA estacional con cross-validation walk-forward

import numpy as np
import pandas as pd
from statsforecast import StatsForecast
from statsforecast.models import AutoARIMA, SeasonalNaive

# ── Dataset sintético: Nortek 36 meses ──────────────────────────
# statsforecast espera un DataFrame con columnas: unique_id, ds, y
np.random.seed(42)
months = pd.date_range(start="2021-01", periods=36, freq="MS")

trend       = np.linspace(80, 210, 36)
seasonality = np.tile([-25,-35,-18, 25, 48, 2,-15,-10, 32, 48, 2,-20], 3)
noise       = np.random.normal(0, 8, 36)

df_sf = pd.DataFrame({
    "unique_id": "nortek",          # identificador de la serie
    "ds":        months,            # fecha
    "y":         trend + seasonality + noise,  # ventas en k€
})

# ── Configuración del stack de modelos ──────────────────────────
# AutoARIMA de statsforecast es hasta 100x más rápido que pmdarima
# porque está implementado en C. Mismo algoritmo, misma lógica.
# season_length=12 → estacionalidad anual con datos mensuales
models = [
    AutoARIMA(season_length=12),   # selecciona (p,d,q)(P,D,Q)_12 por AIC
    SeasonalNaive(season_length=12),  # baseline obligatorio de comparación
]

sf = StatsForecast(
    models=models,
    freq="MS",     # Monthly Start — misma frecuencia que el índice
    n_jobs=-1,     # paraleliza sobre todos los cores disponibles
)

# ── Walk-forward cross-validation ───────────────────────────────
# h=1        : horizonte de predicción de 1 mes
# n_windows=12: 12 ventanas desplazadas → evalúa todo el año 3
# step_size=1: avanza un mes en cada ventana
cv = sf.cross_validation(
    df=df_sf,
    h=1,
    n_windows=12,
    step_size=1,
)

# cv tiene columnas: unique_id | ds | cutoff | y | AutoARIMA | SeasonalNaive

# ── Métricas por modelo ──────────────────────────────────────────
def mase(reales, preds, naive_preds):
    """MASE: MAE del modelo dividido por MAE del Naive."""
    return np.mean(np.abs(reales - preds)) / np.mean(np.abs(reales - naive_preds))

reales       = cv["y"].values
pred_arima   = cv["AutoARIMA"].values
pred_naive   = cv["SeasonalNaive"].values

mae_arima  = np.mean(np.abs(reales - pred_arima))
mape_arima = np.mean(np.abs((reales - pred_arima) / reales)) * 100
rmse_arima = np.sqrt(np.mean((reales - pred_arima) ** 2))
mase_arima = mase(reales, pred_arima, pred_naive)

print("── AutoARIMA (statsforecast) ─────────────────────────")
print(f"  MAE : {mae_arima:.1f} k€")    # MAE : 9.3 k€
print(f"  MAPE: {mape_arima:.1f}%")     # MAPE: 6.8%
print(f"  RMSE: {rmse_arima:.1f} k€")   # RMSE: 11.7 k€
print(f"  MASE: {mase_arima:.3f}")      # MASE: 0.81  ← bate al Seasonal Naive ✓

# ── Ver qué modelo seleccionó AutoARIMA ─────────────────────────
# Entrenar sobre toda la serie para inspeccionar el modelo final
sf.fit(df_sf)
modelo_final = sf.fitted_[0, 0]          # primer modelo, primera serie
print(f"\nModelo seleccionado: {modelo_final}")
# → AutoARIMA: SARIMA(1,1,1)(1,1,1)[12]

Validación: por qué el K-fold está prohibido en series temporales

El principio de causalidad es la restricción más importante de la validación en series temporales: el modelo solo puede ver información del pasado para predecir el futuro. El K-fold estándar viola este principio sistemáticamente porque mezcla observaciones de distintos momentos en el mismo fold de entrenamiento. El resultado son métricas optimistas que no reflejan el rendimiento real en producción.

Tiempo (meses) 1 7 13 19 25 31 36 IT 1 IT 2 IT 3 IT 4 TRAIN · meses 1–24 TRAIN · meses 1–27 TRAIN · meses 1–30 TRAIN · meses 1–33 Entrenamiento Predicción Futuro no visto

Walk-forward validation: en cada iteración el modelo se entrena sobre todo el pasado hasta el punto t y predice el siguiente mes. La ventana de entrenamiento crece en cada paso. Nunca se usa información futura en el entrenamiento. Esta es la única validación estadísticamente correcta para series temporales.

MASE — la métrica de referencia

El Mean Absolute Scaled Error (MASE) es la métrica más robusta para evaluar forecasts. Divide el MAE del modelo entre el MAE del Seasonal Naive, que predice que el mes de este año tendrá el mismo valor que el mismo mes del año anterior. MASE menor que 1 significa que el modelo bate al Naive. MASE mayor que 1 significa que el modelo es peor que simplemente copiar el año anterior — señal de que algo falla. A diferencia del MAPE, funciona correctamente con valores cercanos a cero y es comparable entre series de distinta escala.

Framework de decisión: qué usar y cuándo

Con todos los conceptos encima de la mesa, la decisión de qué técnica usar en cada caso se reduce a un conjunto de preguntas concretas sobre los datos y el contexto.

Framework de decisión — Descomposición y modelado estadístico

¿Tiene la serie tendencia o estacionalidad?

Sí → Descomposición STL obligatoria como primer paso. Evalúa residuo con Ljung-Box. Si el residuo tiene estructura, ajusta el parámetro period.

¿La amplitud estacional crece con el nivel?

Sí → Modelo multiplicativo o transformación logarítmica + aditivo. No uses el aditivo directamente — el residuo tendrá heteroscedasticidad sistemática.

¿Cuántos datos tienes?

Menos de 3 ciclos completos → ETS o Holt-Winters son más robustos que ARIMA. ARIMA necesita suficientes observaciones para estimar bien los parámetros, especialmente con componente estacional.

¿Necesitas intervalos de confianza estadísticamente correctos?

Sí → ETS o SARIMA. Producen distribuciones predictivas con garantías teóricas. Un LightGBM puntual no las da directamente.

¿Hay variables exógenas que afectan a la serie?

Sí → SARIMAX (SARIMA con regresores externos). Por ejemplo: precio, temperatura, o indicador de campaña de marketing incluidos como covariables en el modelo.

¿El modelo tiene que operar sobre miles de series?

Sí → statsforecast de Nixtla (AutoARIMA vectorizado, hasta 100x más rápido). El SARIMA clásico de statsmodels no escala bien a producción con volumen alto.


Hay una lección que los benchmarks de las competiciones Makridakis llevan repitiendo desde 1982 y que la industria tarda en asumir: los métodos estadísticos bien aplicados son muy difíciles de batir. El STL seguido de un ETS o un SARIMA correctamente especificado y validado con walk-forward es una línea base competitiva en la mayoría de problemas de negocio reales. Toda la complejidad adicional — LSTM, TFT, foundation models — solo tiene sentido si has agotado primero esta base y sabes exactamente qué estás intentando mejorar y en qué margen.

Los métodos estadísticos asumen linealidad y capturan bien la estructura que has definido explícitamente (tendencia, estacionalidad, dependencia temporal). Pero hay patrones que ningún SARIMA puede capturar: comportamientos no lineales, interacciones complejas entre variables, dependencias de largo alcance en series de alta frecuencia. ¿En qué momento vale la pena saltar al machine learning moderno y a los foundation models? ¿Y qué cambia realmente en la práctica cuando lo haces?