06. Comparar modelos e validação cruzada temporal

Como ler o resultado da busca e comparar modelos com rigor:

  1. Ranking das finalistas na camada de seleção (V_select).

  2. Validação cruzada temporal (rolling origin) com dobras próprias, para comparar vários modelos nas mesmas origens.

  3. Testes estatísticos (Wilcoxon e Diebold-Mariano com Holm; Friedman com diferença crítica de Nemenyi).

  4. Comparação final no teste selado, uma avaliação por modelo.

  5. Validação cruzada aninhada do procedimento inteiro: quanto a seleção automática generaliza.

Tabelas e testes servem ao relatório: a escolha do campeão segue a regra do protocolo, e o teste selado nunca volta para a busca.

import autohybts

print("AutoHybTS", autohybts.__version__)
AutoHybTS 1.0.0rc4

1. Experimento e busca

from autohybts import AutoHybridExperiment
from autohybts.datasets import get_data

y = get_data("synthetic_daily")
exp = AutoHybridExperiment(
    fh=14,
    session_id=42,
    progress=True,
    fold=3,
    base_models=["naive", "seasonal_naive", "linear", "knn", "mlp"],
    correctors=["linear", "knn"],
    residual_windows=[7],
).fit(y)
result = exp.compare_models()
result.best
busca S0 bases:   0%|          | 0/5 [00:00<?, ?cand/s]
busca S0 bases:  60%|██████    | 3/5 [00:01<00:01,  1.72cand/s]
busca S0 bases:  80%|████████  | 4/5 [00:01<00:00,  2.32cand/s]
busca S0 bases: 100%|██████████| 5/5 [00:02<00:00,  1.58cand/s]
busca S0 bases: 100%|██████████| 5/5 [00:02<00:00,  1.70cand/s]

busca S2 resíduos:   0%|          | 0/11 [00:00<?, ?cand/s]
busca S2 resíduos:   9%|▉         | 1/11 [00:02<00:29,  2.94s/cand]
busca S2 resíduos:  18%|█▊        | 2/11 [00:05<00:23,  2.57s/cand]
busca S2 resíduos:  27%|██▋       | 3/11 [00:05<00:13,  1.64s/cand]
busca S2 resíduos:  36%|███▋      | 4/11 [00:06<00:09,  1.34s/cand]
busca S2 resíduos:  45%|████▌     | 5/11 [00:07<00:06,  1.03s/cand]
busca S2 resíduos:  55%|█████▍    | 6/11 [00:09<00:08,  1.61s/cand]
busca S2 resíduos:  64%|██████▎   | 7/11 [00:11<00:06,  1.55s/cand]
busca S2 resíduos:  73%|███████▎  | 8/11 [00:11<00:03,  1.14s/cand]
busca S2 resíduos:  82%|████████▏ | 9/11 [00:12<00:01,  1.07cand/s]
busca S2 resíduos:  91%|█████████ | 10/11 [00:12<00:00,  1.41cand/s]
busca S2 resíduos: 100%|██████████| 11/11 [00:14<00:00,  1.18s/cand]
busca S2 resíduos: 100%|██████████| 11/11 [00:14<00:00,  1.32s/cand]

busca S3 refino:   0%|          | 0/2 [00:00<?, ?cand/s]
busca S3 refino:  50%|█████     | 1/2 [00:02<00:02,  2.11s/cand]
busca S3 refino: 100%|██████████| 2/2 [00:04<00:00,  2.44s/cand]
busca S3 refino: 100%|██████████| 2/2 [00:04<00:00,  2.39s/cand]

seleção V_select:   0%|          | 0/5 [00:00<?, ?cand/s]
seleção V_select:  20%|██        | 1/5 [00:00<00:01,  2.03cand/s]
seleção V_select:  40%|████      | 2/5 [00:01<00:03,  1.00s/cand]
seleção V_select: 100%|██████████| 5/5 [00:01<00:00,  2.64cand/s]

CandidateSpec(family='residual', strategy='residual_additive', base=ModelSpec(name='mlp', params_json='{"lags":7}'), corrector=ModelSpec(name='linear', params_json='{}'), window_size=7, alpha=0.5, residual_source='historical_oof_1step', variant='A', members=(), options_json='{}', components=(), exog=(), candidate_id='9fd962cca51322c0e3697027ac96d1fa8d35c2672647b0030737ef663dde6df6')

2. Ranking das finalistas em V_select

Uma finalista por família, com o campeão como referência (primeira linha da comparação). mean_rank é o posto médio entre as origens de seleção.

result.comparison.summary
mean std median n_valid n_units mean_rank
model
mlp-linear 1.608653 0.449332 1.689824 3 3 2.333333
seasonal_naive 1.741253 0.454019 1.500052 3 3 2.666667
mlp 1.706069 0.583338 1.571487 3 3 3.000000
linear 1.791337 0.556332 1.639575 3 3 3.333333
knn 1.861741 0.935903 1.742845 3 3 3.666667
result.comparison.plot("leaderboard")
../../_images/0ce5f3945641b9fc16a4b27611752a21d250f446d81d4a3f3fb06bb33beae29b.png

3. Validação cruzada temporal

Dobras próprias sobre o histórico de desenvolvimento (o teste selado fica de fora): 8 origens, uma a cada 7 dias, cada uma prevendo 14 passos. Todos os modelos são avaliados nas mesmas origens. Opções: folds, step, fold_strategy ("expanding" ou "rolling"), window_size e horizon.

cv = exp.cross_validate(folds=8, step=7)
cv.splits
validação cruzada:   0%|          | 0/5 [00:00<?, ?cand/s]
validação cruzada:  20%|██        | 1/5 [00:03<00:12,  3.19s/cand]
validação cruzada:  40%|████      | 2/5 [00:04<00:06,  2.00s/cand]
validação cruzada: 100%|██████████| 5/5 [00:04<00:00,  1.67cand/s]
validação cruzada: 100%|██████████| 5/5 [00:04<00:00,  1.12cand/s]

layer train_start origin first_target last_target n_train horizon
fold_id
cv-0 cv 2024-01-01 2025-10-14 2025-10-15 2025-10-28 653 14
cv-1 cv 2024-01-01 2025-10-21 2025-10-22 2025-11-04 660 14
cv-2 cv 2024-01-01 2025-10-28 2025-10-29 2025-11-11 667 14
cv-3 cv 2024-01-01 2025-11-04 2025-11-05 2025-11-18 674 14
cv-4 cv 2024-01-01 2025-11-11 2025-11-12 2025-11-25 681 14
cv-5 cv 2024-01-01 2025-11-18 2025-11-19 2025-12-02 688 14
cv-6 cv 2024-01-01 2025-11-25 2025-11-26 2025-12-09 695 14
cv-7 cv 2024-01-01 2025-12-02 2025-12-03 2025-12-16 702 14
cv.summary
mean std median n_valid n_units mean_rank
model
mlp 1.294039 0.592503 1.221063 8 8 2.250
linear 1.391870 0.503800 1.351495 8 8 2.875
seasonal_naive 1.401389 0.399431 1.349723 8 8 3.000
mlp-linear 1.500562 0.613733 1.445802 8 8 3.250
knn 1.464256 0.645633 1.111622 8 8 3.625
cv.units  # MASE por origem e modelo
mlp-linear mlp knn seasonal_naive linear
cv-0 0.752621 0.585961 1.106167 0.973660 0.848262
cv-1 2.097688 1.129454 1.081310 1.372579 1.376356
cv-2 1.266502 1.312672 1.793738 1.326866 1.545608
cv-3 2.512821 2.470001 2.851411 2.264966 2.407803
cv-4 0.959965 0.772428 1.117078 1.000031 0.845120
cv-5 1.789340 1.647708 0.990967 1.500052 1.639575
cv-6 1.625103 0.955602 1.030533 1.314213 1.145602
cv-7 1.000457 1.478483 1.742845 1.458741 1.326634

Visualizações

cv.plot("leaderboard")
../../_images/3926a66d47c72322af6b29f3c4a5226a47176c0f60dfdf7c05b2eb71304414ed.png
cv.plot("boxplot")
../../_images/bafc0960bcfae684e75cb3628848fed8547fad367ef34cc85ee7b910cd342a1d.png
cv.plot("metric_by_fold")
../../_images/ed03cf456780ecd8ca9665a00d6c9ba84859bdc273721fa2b11710e35303415e.png
cv.plot("win_rate")
../../_images/a86528e966cd0df2f5c7d01b91f49d755d1e88cd781fe721e6f63661583bc7f9.png
cv.plot("forecasts")
../../_images/8759a1e3973d1d3857087083500df804dfb3e0d54ab5eae3df1a57de71cf0701.png

Testes estatísticos

Cada modelo contra a referência (o campeão): Wilcoxon pareado e Diebold-Mariano (variância de Newey-West, para origens com alvos sobrepostos), com correção de Holm. O teste de Friedman e a diferença crítica de Nemenyi comparam todos ao mesmo tempo.

Com poucas origens os testes têm pouco poder: “sem diferença significativa” não quer dizer que os modelos são equivalentes.

cv.tests()
model reference n_units mean_difference median_difference wins wilcoxon_p dm_statistic dm_p wilcoxon_p_holm dm_p_holm
0 mlp mlp-linear 8 0.206523 0.154146 6 0.195312 1.807143 0.113690 0.78125 0.454758
1 knn mlp-linear 8 0.036306 -0.247851 3 0.843750 0.191534 0.853544 1.00000 0.853544
2 seasonal_naive mlp-linear 8 0.099174 0.103894 4 0.546875 1.117511 0.300657 1.00000 0.617275
3 linear mlp-linear 8 0.108692 0.109931 5 0.460938 1.394727 0.205758 1.00000 0.617275
friedman = cv.friedman()
p_value, cd = friedman["p_value"], friedman["critical_difference"]
print(f"Friedman p = {p_value:.3f}; diferença crítica = {cd:.2f}")
friedman["mean_ranks"].sort_values()
Friedman p = 0.509; diferença crítica = 2.16
mlp               2.250
linear            2.875
seasonal_naive    3.000
mlp-linear        3.250
knn               3.625
dtype: float64
cv.plot("critical_difference")
../../_images/f88e7a44f8691b1af6df93b14aaf6440244841761771240131bac648b06a7509.png

4. Comparação final no teste selado

Cada modelo é finalizado (reajuste no histórico anterior ao teste) e avaliado uma única vez. É um relato terminal: mudar a configuração por causa dele transformaria o teste em validação.

external = exp.compare_external()
external.table
teste externo:   0%|          | 0/5 [00:00<?, ?cand/s]
teste externo:  20%|██        | 1/5 [00:00<00:01,  2.18cand/s]
teste externo:  40%|████      | 2/5 [00:00<00:00,  3.56cand/s]
teste externo: 100%|██████████| 5/5 [00:00<00:00,  7.63cand/s]

MASE MAE RMSE sMAPE ganho_MASE_vs_ref
model
mlp(lags=7) + linear [w=7, alpha=0.5, historical_oof_1step] 0.703257 1.101274 1.288316 0.015685 0.000000
mlp(lags=7) 1.193216 1.868531 2.137723 0.026300 -0.696699
knn(lags=7) 0.549934 0.861176 1.036616 0.012319 0.218018
seasonal_naive(season_length=7) 0.826938 1.294953 1.560511 0.018222 -0.175868
linear(lags=7) 0.660951 1.035024 1.157793 0.014720 0.060158
external.tests
model dm_statistic dm_p dm_p_holm
0 mlp(lags=7) -6.490174 0.000020 0.000081
1 knn(lags=7) 1.611410 0.131092 0.279398
2 seasonal_naive(season_length=7) -1.812039 0.093133 0.279398
3 linear(lags=7) 1.527034 0.150710 0.279398

5. Validação cruzada aninhada do procedimento

Em cada dobra externa, a AutoHybTS refaz busca e seleção usando só o passado e é medida no bloco seguinte, ao lado de referências. Responde à pergunta: escolher automaticamente compensa?

  • best_standalone é a melhor base da seleção interna de cada dobra, escolhida sem o bloco externo: a referência justa para saber se a hibridação compensa.

  • naive e seasonal_naive são referências fixas.

  • As buscas internas repetem as famílias do último compare_models (nested.include), e nested.cost mostra o trabalho de cada dobra.

nested = exp.nested_cross_validate(outer_folds=3, budget="smoke")
print("famílias das buscas internas:", nested.include)
nested.folds[
    ["champion", "best_standalone", "autohybts_MASE", "best_standalone_MASE", "naive_MASE"]
]
CV aninhada:   0%|          | 0/3 [00:00<?, ?cand/s]
CV aninhada:  33%|███▎      | 1/3 [00:02<00:04,  2.43s/cand]
CV aninhada:  67%|██████▋   | 2/3 [00:04<00:02,  2.34s/cand]
CV aninhada: 100%|██████████| 3/3 [00:07<00:00,  2.42s/cand]
CV aninhada: 100%|██████████| 3/3 [00:07<00:00,  2.41s/cand]
famílias das buscas internas: ('standalone', 'residual')

champion best_standalone autohybts_MASE best_standalone_MASE naive_MASE
fold_id
cv-0 mlp(lags=7) linear(lags=7) 2.373193 2.407803 1.399396
cv-1 seasonal_naive(season_length=7) seasonal_naive(season_length=7) 1.500052 1.500052 3.438511
cv-2 mlp(lags=7) seasonal_naive(season_length=7) 1.230691 1.458741 1.422165
nested.summary
mean std n_folds autohybts_wins
autohybts_MASE 1.701312 0.597250 3 NaN
best_standalone_MASE 1.788865 0.536414 3 0.666667
naive_MASE 2.086690 1.170766 3 0.666667
seasonal_naive_MASE 1.741253 0.454019 3 0.333333
nested.cost[["search_candidates", "search_units", "select_units", "stop_reason", "seconds_total"]]
search_candidates search_units select_units stop_reason seconds_total
fold_id
cv-0 9 27 15 budget_limited 2.430689
cv-1 9 27 15 budget_limited 2.281348
cv-2 9 27 15 budget_limited 2.502493