15. Modelos automáticos do StatsForecast

Os modelos AutoARIMA, AutoETS, AutoTheta e AutoCES do StatsForecast (Nixtla) entram como modelos-base opcionais: sf_auto_arima, sf_auto_ets, sf_auto_theta e sf_auto_ces. Este notebook compara esses modelos com os equivalentes do statsmodels e com baselines nas mesmas origens, mostra que os parâmetros ficam congelados depois do ajuste (requisito da variante B) e reproduz os híbridos estatístico-ANN do projeto de replicação.

Requisitos: extra nixtla-stats (pip install "autohybts[nixtla-stats]"). O StatsForecast exige pandas<3, então o extra instala o pandas 2.x; num ambiente com pandas 3, use um segundo ambiente virtual. Guia: user_guide/nixtla.

import time
import warnings

import matplotlib.pyplot as plt
import pandas as pd

import autohybts
from autohybts import AutoHybridExperiment
from autohybts.datasets import get_data
from autohybts.domain.fit_count import count_fits
from autohybts.residuals.generation import local_context

warnings.filterwarnings("ignore")
pd.set_option("display.width", 160)
pd.set_option("display.max_columns", 20)


def clean(ax, grid_axis="both"):
    """Recessive grid and axes; text in neutral ink."""
    ax.grid(axis=grid_axis, color="#e4e3df", linewidth=0.8)
    ax.set_axisbelow(True)
    for side in ("top", "right"):
        ax.spines[side].set_visible(False)
    ax.tick_params(colors="#52514e")


import statsforecast  # noqa: E402

print(
    "AutoHybTS",
    autohybts.__version__,
    "| StatsForecast",
    statsforecast.__version__,
    "| pandas",
    pd.__version__,
)
AutoHybTS 0.0.1 | StatsForecast 2.1.1 | pandas 2.3.3

1. Dados e protocolo

Série sintética diária (tendência, sazonalidade semanal, ruído AR(0,7)), fh=14, 3 origens em V_search e 3 em V_select, semente 42. O teste selado (últimos 14 dias) só é lido no fim.

y = get_data("synthetic_daily", n=500)
exp = AutoHybridExperiment(fh=14, fold=3, session_id=42).fit(y)
exp.splits
layer train_start origin first_target last_target n_train horizon
fold_id
search-0 search 2024-01-01 2025-02-05 2025-02-06 2025-02-19 402 14
search-1 search 2024-01-01 2025-02-19 2025-02-20 2025-03-05 416 14
search-2 search 2024-01-01 2025-03-05 2025-03-06 2025-03-19 430 14
select-0 select 2024-01-01 2025-03-19 2025-03-20 2025-04-02 444 14
select-1 select 2024-01-01 2025-04-02 2025-04-03 2025-04-16 458 14
select-2 select 2024-01-01 2025-04-16 2025-04-17 2025-04-30 472 14

2. Cada modelo nas mesmas origens

Os quatro do StatsForecast com season_length=7, contra os modelos do statsmodels (arima, exponential_smoothing com Holt-Winters semanal) e o seasonal_naive. MASE média por camada e segundos de avaliação (as 6 origens).

candidates = {
    "seasonal_naive": {},
    "exponential_smoothing": {"season_length": 7},
    "arima": {},
    "sf_auto_ets": {"season_length": 7},
    "sf_auto_arima": {"season_length": 7},
    "sf_auto_theta": {"season_length": 7},
    "sf_auto_ces": {"season_length": 7},
}
rows = []
for name, params in candidates.items():
    started = time.perf_counter()
    frame = exp.create_model(name, **params).evaluation.to_frame()
    rows.append(
        {
            "modelo": name,
            "MASE V_search": frame.loc[frame["layer"] == "search", "MASE"].mean(),
            "MASE V_select": frame.loc[frame["layer"] == "select", "MASE"].mean(),
            "segundos": time.perf_counter() - started,
        }
    )
scores = pd.DataFrame(rows).set_index("modelo")
scores.round(3).sort_values("MASE V_select")
MASE V_search MASE V_select segundos
modelo
arima 0.916 0.877 47.166
exponential_smoothing 0.914 0.881 8.885
sf_auto_arima 0.874 0.888 162.075
sf_auto_ets 0.888 0.891 13.675
sf_auto_ces 0.914 0.912 0.382
sf_auto_theta 0.908 0.964 0.638
seasonal_naive 1.288 1.522 0.022
order = scores.sort_values("MASE V_select").index[::-1]
colors = ["#2a78d6" if name.startswith("sf_") else "#c3c2b7" for name in order]
fig, ax = plt.subplots(figsize=(8, 3.6))
ax.barh(order, scores.loc[order, "MASE V_select"], color=colors, height=0.6)
for row, value in enumerate(scores.loc[order, "MASE V_select"]):
    ax.text(value, row, f" {value:.2f}", va="center", fontsize=8, color="#0b0b0b")
ax.set_xlabel("MASE média em V_select (azul: StatsForecast; cinza: referências)", color="#52514e")
ax.set_title("Mesmas origens, mesmo horizonte", loc="left", fontsize=10)
clean(ax, grid_axis="x")
fig.tight_layout()
../../_images/cb26e46331231daf1de70448d2255ecf71f1f205e038567c921d602046216247.png

3. Parâmetros congelados

Na variante B e nos resíduos fora da amostra, um modelo ajustado prevê a partir de contextos mais longos sem ser reajustado. O adaptador usa forward do StatsForecast: no próprio treino ele reproduz a previsão do ajuste; com 20 observações a mais, só os estados mudam (nenhum ajuste é contado), e o resultado difere de um reajuste completo.

values = y.to_numpy()
rows = []
for name in ["sf_auto_ets", "sf_auto_arima"]:
    model = exp.registry_.make_base(name, {"season_length": 7}).fit(values[:400])
    with count_fits() as fits:
        frozen = model.predict(7, local_context(values, 420))
    refit = exp.registry_.make_base(name, {"season_length": 7}).fit(values[:420])
    rows.append(
        {
            "modelo": name,
            "ajustes ao prever": fits.total,
            "previsão congelada (h=1)": frozen[0],
            "após reajuste (h=1)": refit.predict(7, local_context(values, 420))[0],
            "observado": values[420],
        }
    )
pd.DataFrame(rows).set_index("modelo").round(3)
ajustes ao prever previsão congelada (h=1) após reajuste (h=1) observado
modelo
sf_auto_ets 0 64.702 64.738 64.164
sf_auto_arima 0 64.239 64.267 64.164

4. Os híbridos estatístico-ANN da replicação

O projeto de replicação usa AutoETS, AutoARIMA e AutoSARIMA com um MLP de 5 neurônios logísticos sobre os resíduos fora da amostra. As mesmas configurações viram especificações da AutoHybTS; scale="minmax" e feature_order="lag" reproduzem o corretor do projeto (a paridade exata é testada em tests/regression/test_legacy_parity.py).

ann = {
    "scale": "minmax",
    "feature_order": "lag",
    "random_state": 42,
    "estimator_params": {
        "hidden_layer_sizes": [5],
        "activation": "logistic",
        "solver": "lbfgs",
        "max_iter": 2000,
        "alpha": 0.0001,
        "tol": 0.0001,
    },
}
bases = {
    "ETS-ANN": ("sf_auto_ets", {"season_length": 7, "model_params": {"model": "ZZZ"}}),
    "ARIMA-ANN": ("sf_auto_arima", {"season_length": 1, "model_params": {"seasonal": False}}),
    "SARIMA-ANN": ("sf_auto_arima", {"season_length": 7, "model_params": {"seasonal": True}}),
}
rows = []
for label, (base, params) in bases.items():
    alone = exp.create_model(base, **params).evaluation.to_frame()
    hybrid = exp.create_hybrid(base, "mlp", window=7, base_params=params, corrector_params=ann)
    frame = hybrid.evaluation.to_frame()
    rows.append(
        {
            "híbrido": label,
            "MASE base (V_select)": alone.loc[alone["layer"] == "select", "MASE"].mean(),
            "MASE híbrido (V_select)": frame.loc[frame["layer"] == "select", "MASE"].mean(),
            "variabilidade": hybrid.evaluation.trials[0].diagnostics["correction_std_ratio"],
        }
    )
pd.DataFrame(rows).set_index("híbrido").round(3)
MASE base (V_select) MASE híbrido (V_select) variabilidade
híbrido
ETS-ANN 0.891 0.882 0.303
ARIMA-ANN 1.284 1.317 0.293
SARIMA-ANN 0.888 0.875 0.280

5. Busca com os modelos do StatsForecast

Passados em base_models, entram na busca como os demais: o período sazonal vem do experimento.

search = AutoHybridExperiment(
    fh=14,
    fold=3,
    session_id=42,
    budget="small",
    base_models=["seasonal_naive", "linear", "sf_auto_ets", "sf_auto_theta", "sf_auto_ces"],
    correctors=["linear", "mlp"],
    residual_windows=[14],
    alphas=[1.0],
).fit(y)
result = search.compare_models()
print(f"candidatas: {result.search.trial_count}, ajustes: {result.search.fit_count}")
print("campeão:", result.best.label)
result.decisions[
    [
        "family",
        "champion_before",
        "challenger",
        "accepted",
        "reason_codes",
        "median_gain",
        "win_rate",
    ]
]
candidatas: 13, ajustes: 93
campeão: sf_auto_ets(season_length=7)
family champion_before challenger accepted reason_codes median_gain win_rate
0 linear sf_auto_ets linear False (median_gain_below_threshold, win_rate_below_t... -0.636219 0.333333
1 residual sf_auto_ets sf_auto_ets-linear False (median_gain_below_threshold,) 0.009861 0.666667

6. Avaliação externa

Uma única leitura do teste selado, depois de congelar a escolha.

final = search.finalize_model(result.best)
search.evaluate_external(final).metrics.round(3)
MASE MAE RMSE sMAPE
model
sf_auto_ets(season_length=7) 0.68 1.064 1.529 0.016

7. Leitura

Nesta execução (500 dias, fh=14, 3 origens por camada — fold=3 explícito, para o notebook ser rápido —, StatsForecast 2.1.1 com pandas 2.3):

  • Os automáticos do StatsForecast empatam com os do statsmodels. Em V_select: arima (statsmodels) 0,877, exponential_smoothing 0,881, sf_auto_arima 0,888, sf_auto_ets 0,891, sf_auto_ces 0,912 e sf_auto_theta 0,964, todos muito à frente do seasonal_naive (1,52). Diferenças de 1% com 3 origens não separam os modelos.

  • Custo. O sf_auto_arima levou 162 s nas 6 origens (busca de ordem sazonal em cada uma), contra 47 s do arima do statsmodels; sf_auto_ets e exponential_smoothing levaram 9–14 s; sf_auto_ces e sf_auto_theta, menos de 1 s. Os tempos foram medidos com outras execuções em paralelo; servem para comparar os modelos entre si.

  • Parâmetros congelados. Prevendo 20 dias depois do treino, nenhum ajuste é feito; a previsão congelada difere da de um reajuste completo em ~0,03 (ETS 64,70 contra 64,74; observado 64,16): o modelo incorpora as observações novas pelos estados, como a variante B exige.

  • Híbridos estatístico-ANN da replicação. O MLP residual melhorou pouco as bases sazonais (ETS 0,891 → 0,882; SARIMA 0,888 → 0,875, ~1–1,5%) e piorou a não sazonal (ARIMA 1,284 → 1,317): sem o termo sazonal, o erro semanal sobra para um corretor de janela 7 que não o recupera.

  • Na busca (13 candidatas, 93 ajustes), o campeão foi sf_auto_ets. O híbrido sf_auto_ets-linear venceu 2 de 3 unidades, mas com ganho mediano de 0,99%, abaixo do mínimo de 5% da regra padrão (ADR-0008): recusado. No teste selado, o campeão teve MASE 0,68.

Os modelos do StatsForecast ampliam as bases estatísticas sem mudar o protocolo; nesta série, não superaram os equivalentes já presentes. Use-os quando a busca automática de ordem e de componentes compensar o custo, sempre comparados com as referências nas mesmas origens.