Saltar al contenido principal
Capítulo 3.3 intermedio holt-winters ets statsmodels pronóstico intervalos de confianza

Holt-Winters de principio a fin

6 min lectura
Caso completo: selección de modelo Holt-Winters por AIC, diagnóstico de residuos, pronóstico con intervalos vía ETSModel y comparación final contra ARIMA y SARIMA.

Ya viste cada pieza por separado: SES en 3.1, Holt y la tendencia amortiguada en 3.2. Ahora las juntas todas en el flujo completo que usarías en un caso real — el mismo que el capítulo principal resumió con la serie de ventas de la librería online.


Reconstruimos los datos

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

np.random.seed(42)
fechas     = pd.date_range(start="2018-01-01", periods=72, freq="ME")
t          = np.arange(72)
tendencia  = 1500 + 12 * t
estacional = 200 * np.sin(2 * np.pi * (t - 2) / 12)
ruido      = np.random.normal(0, 40, 72)
serie      = pd.Series(tendencia + estacional + ruido, index=fechas, name="ventas_libreria")

train = serie.iloc[:-12]
test  = serie.iloc[-12:]

Paso 1 — Ajustar las variantes candidatas

Con tendencia visible y estacionalidad de amplitud aproximadamente constante, las dos variantes que tiene sentido comparar son aditiva y multiplicativa (no probamos “sin tendencia” ni “sin estacionalidad”: el gráfico de la serie ya descarta esas opciones).

from statsmodels.tsa.holtwinters import ExponentialSmoothing

modelo_add = ExponentialSmoothing(
    train, trend="add", seasonal="add", seasonal_periods=12,
    initialization_method="estimated"
).fit()

modelo_mul = ExponentialSmoothing(
    train, trend="add", seasonal="mul", seasonal_periods=12,
    initialization_method="estimated"
).fit()

print(f"AIC aditivo       : {modelo_add.aic:.1f}")
print(f"AIC multiplicativo: {modelo_mul.aic:.1f}")
AIC aditivo       : 450.5
AIC multiplicativo: 462.2

Paso 2 — Diagnosticar residuos antes de confiar en el AIC

Un AIC más bajo no es suficiente por sí solo — exactamente igual que en el capítulo 2 con ARIMA, hay que confirmar que no queda estructura en los residuos.

from statsmodels.stats.diagnostic import acorr_ljungbox

residuos = train - modelo_add.fittedvalues

fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].plot(residuos, color="#1a1a1a", linewidth=1)
axes[0].axhline(0, color="#ffcc00", linestyle="--", linewidth=2)
axes[0].set_title("Residuos — Holt-Winters aditivo")
axes[0].grid(True, alpha=0.3)

axes[1].hist(residuos, bins=15, color="#ffcc00", edgecolor="#1a1a1a")
axes[1].set_title("Distribución de residuos")
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

ljung = acorr_ljungbox(residuos, lags=[10], return_df=True)
print(ljung)
# p-valor ≈ 0.78 → sin autocorrelación significativa, residuos limpios

Paso 3 — Pronosticar con intervalos: ETSModel

ExponentialSmoothing te da un pronóstico puntual. Para obtener intervalos de confianza —igual que el get_forecast de ARIMA en el capítulo 2— usa la implementación en espacio de estados, ETSModel, que ajusta la misma familia de modelos con una interfaz más completa.

from statsmodels.tsa.exponential_smoothing.ets import ETSModel

modelo_ets = ETSModel(
    train, error="add", trend="add", seasonal="add", seasonal_periods=12
).fit(disp=False)

pronostico = modelo_ets.get_prediction(start=test.index[0], end=test.index[-1])
resumen = pronostico.summary_frame(alpha=0.05)   # columnas: mean, pi_lower, pi_upper

plt.figure(figsize=(12, 5))
plt.plot(train.index[-24:], train.iloc[-24:], color="#1a1a1a", label="Histórico")
plt.plot(test.index, test, color="#a8e6cf", linewidth=2.5, label="Real")
plt.plot(test.index, resumen["mean"], color="#ffcc00", linewidth=2.5, linestyle="--", label="Pronóstico ETS")
plt.fill_between(test.index, resumen["pi_lower"], resumen["pi_upper"],
                  color="#ffcc00", alpha=0.25, label="Intervalo 95%")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

Paso 4 — La comparación final

from sklearn.metrics import mean_absolute_error

naive          = pd.Series(train.iloc[-1], index=test.index)
seasonal_naive = pd.Series(train.iloc[-12:].values, index=test.index)
pred_hw_add    = modelo_add.forecast(12)

print(f"MAE naive          : {mean_absolute_error(test, naive):.0f}")
print(f"MAE seasonal naive : {mean_absolute_error(test, seasonal_naive):.0f}")
print(f"MAE Holt-Winters   : {mean_absolute_error(test, pred_hw_add):.0f}")
print(f"MAE ETS            : {mean_absolute_error(test, resumen['mean']):.0f}")
ModeloMAE aprox.
Naive248
Seasonal naive148
ARIMA(2,1,2) (capítulo 2)74
SARIMA(1,1,1)(1,1,1,12) (capítulo 2)33
Holt-Winters aditivo30
ETS(A,A,A)33

Holt-Winters y su versión en espacio de estados (ETS) quedan a la altura de SARIMA en esta serie, sin haber necesitado comprobar estacionariedad, leer ACF/PACF ni buscar p, d, q estacionales.


Ponte a prueba

Genera esta serie —crecimiento moderado, estacionalidad trimestral, algo de ruido— y decide tú mismo el flujo completo: qué variantes probar, cómo elegir entre ellas y cómo validar el resultado.

import numpy as np
import pandas as pd

np.random.seed(77)
n      = 60
t      = np.arange(n)
fechas = pd.date_range(start="2019-01-01", periods=n, freq="ME")

serie_reto = pd.Series(
    400 + 5 * t
    + 60 * np.sin(2 * np.pi * t / 4)     # estacionalidad cada 4 meses
    + np.random.normal(0, 15, n),
    index=fechas
)

train_r = serie_reto.iloc[:-8]
test_r  = serie_reto.iloc[-8:]

# Paso 1: ajusta Holt-Winters aditivo y multiplicativo con seasonal_periods=4
# Paso 2: compara AIC y elige
# Paso 3: valida residuos con Ljung-Box
# Paso 4: calcula el MAE del ganador contra el naive y el seasonal naive
Ver solución
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from statsmodels.stats.diagnostic import acorr_ljungbox
from sklearn.metrics import mean_absolute_error

modelo_add_r = ExponentialSmoothing(
    train_r, trend="add", seasonal="add", seasonal_periods=4,
    initialization_method="estimated"
).fit()

modelo_mul_r = ExponentialSmoothing(
    train_r, trend="add", seasonal="mul", seasonal_periods=4,
    initialization_method="estimated"
).fit()

print(f"AIC aditivo: {modelo_add_r.aic:.1f}  |  AIC multiplicativo: {modelo_mul_r.aic:.1f}")

# Con estacionalidad generada de forma aditiva, el aditivo debería ganar
ganador = modelo_add_r if modelo_add_r.aic < modelo_mul_r.aic else modelo_mul_r

residuos_r = train_r - ganador.fittedvalues
print(acorr_ljungbox(residuos_r, lags=[8], return_df=True))

pred_r = ganador.forecast(8)
naive_r = pd.Series(train_r.iloc[-1], index=test_r.index)
seasonal_naive_r = pd.Series(train_r.iloc[-4:].values.tolist() * 2, index=test_r.index)

print(f"MAE ganador        : {mean_absolute_error(test_r, pred_r):.1f}")
print(f"MAE naive          : {mean_absolute_error(test_r, naive_r):.1f}")
print(f"MAE seasonal naive : {mean_absolute_error(test_r, seasonal_naive_r):.1f}")

El modelo aditivo debería ganar en AIC (la serie se construyó con amplitud estacional constante) y su MAE debería batir claramente a ambos baselines, confirmando que el modelo capturó tanto la tendencia como el ciclo trimestral.


Para llevarte

  • El flujo completo de Holt-Winters es corto: ajustar 2-3 variantes plausibles, elegir por AIC, confirmar con Ljung-Box, pronosticar.
  • ExponentialSmoothing da pronósticos puntuales rápido; ETSModel añade intervalos de confianza cuando los necesitas, con la misma familia de modelos por debajo.
  • Los intervalos de ETS no siempre se ensanchan con el horizonte igual que los de ARIMA — es una diferencia estructural entre familias, no un defecto.
  • En igualdad de condiciones de precisión, la ventaja decisiva de Holt-Winters frente a SARIMA es lo poco que cuesta ajustarlo: nada de identificación manual, lo que lo hace la opción natural para pronosticar muchas series a la vez.

Con esto se cierra el capítulo 3. Tienes ahora dos familias completas de modelos clásicos —ARIMA/SARIMA y suavizado exponencial— y un criterio claro (precisión vs. coste de ajuste) para elegir entre ellas según el problema que tengas delante.