Holt-Winters de principio a fin
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}")
| Modelo | MAE aprox. |
|---|---|
| Naive | 248 |
| Seasonal naive | 148 |
| ARIMA(2,1,2) (capítulo 2) | 74 |
| SARIMA(1,1,1)(1,1,1,12) (capítulo 2) | 33 |
| Holt-Winters aditivo | 30 |
| 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.
ExponentialSmoothingda pronósticos puntuales rápido;ETSModelañ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.