22. Projeto com o M4 diário: seleção guardada, retomada e custo

Objetivo e requisitos. Conduzir um pequeno projeto de previsão com séries reais do M4 diário, do sorteio da amostra à avaliação: preparar a validação temporal, comparar a seleção guardada da AutoHybTS com a escolha pelo menor erro, registrar cada série num banco SQLite (o que permite retomar um projeto interrompido sem refazer o que já terminou) e medir o custo. O projeto usa só o treino oficial: os últimos 14 pontos dele ficam selados para a avaliação interna, e o teste oficial não é baixado. Extras stats e plot; a primeira execução baixa o treino do M4 diário.

import hashlib
import json
import subprocess
import sys
import time
import warnings
from collections import Counter
from pathlib import Path

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

import autohybts
from autohybts import AutoHybridExperiment, ExperimentStore, load_model, read_manifest, save_model
from autohybts.benchmarks import benchmark_cases
from autohybts.domain.identity import stable_hash
from autohybts.exceptions import ConfigurationError
from autohybts.persistence.manifest import environment, json_safe, source_fingerprint

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

1. Dados e protocolo

A amostra são 3 séries sorteadas com a semente 0 entre as 4227 do M4 diário; ninguém escolhe as séries à mão. De cada uma usamos só o treino oficial, cortado nos últimos 730 dias para controlar o custo. O horizonte é o do M4 diário, 14 passos, previstos de uma vez (protocolo A); o período de escala do MASE é o do M4 (1).

cases = list(benchmark_cases("m4", "daily", 3, 0, development_only=True, development_tail=730))
H = cases[0].horizon
pd.DataFrame(
    [
        {
            "serie": case.series_id,
            "treino_oficial": case.source["original_train_length"],
            "pontos_usados": len(case.values),
            "inicio": case.values.index[0].date(),
            "fim": case.values.index[-1].date(),
        }
        for case in cases
    ]
).set_index("serie")
assert all(case.source["official_test_accessed"] is False for case in cases)
fig, axes = plt.subplots(len(cases), 1, figsize=(10, 2.2 * len(cases)), squeeze=False)
for ax, case in zip(axes.flat, cases, strict=True):
    ax.plot(case.values.index, case.values.to_numpy(), linewidth=0.8, color="#0072B2")
    ax.axvspan(case.values.index[-H], case.values.index[-1], color="0.85", label="bloco selado")
    ax.set_title(case.series_id, fontsize=9)
axes.flat[0].legend(fontsize=8)
fig.tight_layout()
plt.show()
../../_images/384b6df845fcf84bec31d2e2c19063f7deaa39239d5020ec1c1db594545d01cd.png

2. Exploração causal

fit não busca nada: valida a série, sela os últimos 14 pontos e fixa as origens de V_search e V_select. A tabela mostra quantas origens cada série comporta por camada (a regra de aceite foi calibrada com 7) e quantas diferenças o ADF e o KPSS, juntos, pedem no trecho de desenvolvimento.

SETTINGS = {
    "fh": H,
    "test_size": H,
    "seasonal_period": cases[0].seasonal_period,
    "session_id": 42,
    "budget": "small",
    "base_models": ["naive", "random_walk_drift", "linear", "exponential_smoothing"],
    "correctors": ["linear", "knn"],
}
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    experiments = {
        case.series_id: AutoHybridExperiment(**SETTINGS).fit(case.values) for case in cases
    }
for message, count in Counter(str(item.message) for item in caught).items():
    print(f"WARNING ({count}): {message}")
pd.DataFrame(
    {
        sid: {
            "development_points": len(exp.history_),
            "origins_per_layer": exp.fold_,
            "suggested_differences": int(exp.stationarity_report()["d_consensus"].iloc[0]),
        }
        for sid, exp in experiments.items()
    }
).T
WARNING (1): fold='auto': o histórico comporta 4 origem(ns) por camada, menos que as 7 da calibração da regra de aceite; com menos unidades a regra aceita estruturas espúrias com mais frequência.
development_points origins_per_layer suggested_differences
D2692 716 7 1
D2161 158 4 1
D3594 716 7 1

3. Controles e modelos propostos

A mesma busca (persistência, passeio aleatório com tendência, regressão linear, suavização exponencial e correções residuais linear e KNN) alimenta três escolhas:

  • seleção guardada (compare_models().best): só aceita complexidade que passa na regra de aceite;

  • menor erro: a finalista de menor MASE em V_select, sem limiar;

  • persistência: o controle mais simples.

Cada série vira uma execução no banco artifacts/m4_project/m4.db. A pasta é preservada: reexecutar o notebook reutiliza casos compatíveis. Dados, parâmetros, ambiente e fontes da biblioteca entram na identidade; mudanças exigem outra pasta. A versão do fluxo fica explícita no esquema. Execute um único processo escritor por pasta.

Um caso só é registrado depois de buscar, avaliar as escolhas distintas e salvar seus modelos. Os artefatos são criados por esta execução; por isso o carregamento abaixo declara confiança explicitamente. A equivalência das previsões é conferida antes do registro. Se houver interrupção antes dele, esse caso será refeito; casos já registrados permanecem intactos.

folder = Path("artifacts/m4_project")
store = ExperimentStore(folder / "m4.db")
identity = {
    "schema": "m4-project/3",
    "settings": SETTINGS,
    "data": {
        case.series_id: hashlib.sha256(
            pd.util.hash_pandas_object(case.values, index=True).to_numpy().tobytes()
        ).hexdigest()
        for case in cases
    },
    "source": source_fingerprint(),
    "environment": {key: value for key, value in environment().items() if key != "git_commit"},
}
project_id = stable_hash(json_safe(identity))


def verify_saved(tags):
    """Check saved models against forecasts already in SQLite, without reading targets again."""
    predictions = store.query("SELECT model_id, timestamp, y_pred FROM predictions")
    for choice in {item["model_id"]: item for item in tags["choices"].values()}.values():
        model = load_model(folder / choice["artifact"], trusted=True, sha256=choice["sha256"])
        recorded = predictions[predictions.model_id == choice["model_id"]].sort_values("timestamp")
        if len(recorded) != H:
            raise ConfigurationError("Incomplete saved forecasts; restore the project artifacts.")
        np.testing.assert_allclose(model.predict(H).y_pred, recorded.y_pred, rtol=1e-12, atol=1e-12)


def run_project():
    """Persist evaluated cases and reuse compatible complete results."""
    saved = (
        store.query("SELECT name, tags FROM runs")
        if store.path.is_file()
        else pd.DataFrame(columns=["name", "tags"])
    )
    completed = {}
    for row in saved.itertuples():
        tags = json.loads(row.tags)
        if tags.get("project_id") != project_id:
            raise ConfigurationError("Incompatible project: use a different output directory.")
        verify_saved(tags)
        completed[row.name] = tags
    new = []
    for sid, exp in experiments.items():
        if sid in completed:
            continue
        started = time.perf_counter()
        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            result = exp.compare_models()
            search_seconds = time.perf_counter() - started
            lowest_id = result.selection_leaderboard.MASE.idxmin()
            lowest = next(
                r.candidate for r in result.selection.finalists if r.candidate_id == lowest_id
            )
            picks = {
                "guarded": result.best,
                "lowest_error": lowest,
                "persistence": exp.create_model("naive").candidate,
            }
            models, choices = {}, {}
            for label, spec in picks.items():
                if spec.candidate_id not in models:
                    model = exp.finalize_model(spec)
                    exp.evaluate_external(model)
                    relative = Path("models") / sid / model.model_id
                    save_model(model, folder / relative)
                    restored = load_model(folder / relative, trusted=True)
                    pd.testing.assert_frame_equal(model.predict(H), restored.predict(H))
                    models[spec.candidate_id] = model
                model = models[spec.candidate_id]
                relative = Path("models") / sid / model.model_id
                choices[label] = {
                    "model_id": model.model_id,
                    "artifact": relative.as_posix(),
                    "sha256": read_manifest(folder / relative)["payload"]["sha256"],
                }
        messages = Counter(str(item.message) for item in caught)
        for message, count in messages.items():
            print(f"WARNING ({count}): {message}")
        tags = {
            "project_id": project_id,
            "identity": identity,
            "choices": choices,
            "search_seconds": search_seconds,
            "case_seconds": time.perf_counter() - started,
            "warnings": dict(messages),
        }
        store.log(exp, name=sid, tags=tags, models=list(models.values()), values=True)
        verify_saved(tags)
        new.append(sid)
    return new

4. Busca e seleção

A primeira chamada executa somente as séries ainda não concluídas. A segunda verifica que os registros não mudam. Ao executar novamente após reiniciar o kernel, a mesma função consulta o banco, confere identidade e artefatos e reutiliza os casos completos. As etapas seguintes leem tabelas persistidas, sem depender dos objetos de resultado da busca. Avisos são exibidos e registrados; não são descartados.

print("New cases:", run_project())
before = store.query("SELECT run_id, logged_utc FROM runs ORDER BY run_id")
assert run_project() == []
pd.testing.assert_frame_equal(
    before, store.query("SELECT run_id, logged_utc FROM runs ORDER BY run_id")
)
print("Resume: no new cases; database records unchanged.")
saved_runs = store.query("SELECT name, tags FROM runs ORDER BY name")
pd.DataFrame(
    [
        {
            "series": row.name,
            **{key: json.loads(row.tags)[key] for key in ("search_seconds", "case_seconds")},
        }
        for row in saved_runs.itertuples()
    ]
).set_index("series").round(2)
New cases: ['D2692', 'D2161', 'D3594']
Resume: no new cases; database records unchanged.
search_seconds case_seconds
series
D2161 3.54 3.75
D2692 26.18 26.71
D3594 21.46 21.65

5. Resultados e gráficos

O banco responde quem venceu em cada série e por quê: cada desafiante comparado com o campeão da vez, com ganho mediano, taxa de vitórias e motivo.

store.query(
    """
    SELECT r.name AS serie, d.family, d.challenger_label AS desafiante, d.accepted AS aceito,
           ROUND(d.median_gain, 3) AS ganho_mediano, ROUND(d.win_rate, 2) AS vitorias,
           d.reason_codes AS motivos
    FROM decisions d JOIN runs r USING (run_id)
    ORDER BY r.name, d.step
    """
)
serie family desafiante aceito ganho_mediano vitorias motivos
0 D2161 linear linear(lags=7) 0 -0.639 0.00 ["median_gain_below_threshold", "win_rate_belo...
1 D2161 residual naive + linear [w=7, alpha=0.5, historical_oof... 0 0.037 1.00 ["median_gain_below_threshold"]
2 D2692 linear linear(lags=7) 0 0.064 0.57 ["win_rate_below_threshold"]
3 D2692 residual random_walk_drift + linear [w=7, alpha=0.5, hi... 0 -0.008 0.29 ["median_gain_below_threshold", "win_rate_belo...
4 D3594 linear linear(lags=7) 0 -0.184 0.29 ["median_gain_below_threshold", "win_rate_belo...
5 D3594 residual random_walk_drift + linear [w=7, alpha=1.0, hi... 0 -0.002 0.43 ["median_gain_below_threshold", "win_rate_belo...
board = store.query(
    """
    SELECT r.name AS series, c.label, m.value AS MASE, e.is_champion
    FROM metrics m JOIN runs r USING (run_id)
    JOIN candidates c USING (run_id, candidate_id)
    JOIN evaluations e USING (run_id, candidate_id, layer)
    WHERE m.layer = 'select' AND m.metric = 'MASE'
    ORDER BY r.name, m.value
    """
)
fig, axes = plt.subplots(1, len(cases), figsize=(11, 3.4), squeeze=False)
for ax, case in zip(axes.flat, cases, strict=True):
    part = board[board.series == case.series_id]
    ax.barh(
        part.label, part.MASE, color=["#D55E00" if value else "0.6" for value in part.is_champion]
    )
    ax.invert_yaxis()
    ax.set(title=case.series_id, xlabel="MASE in V_select")
    ax.tick_params(axis="y", labelsize=7)
fig.tight_layout()
plt.show()
../../_images/2222769b639d223b566781e777f948f52731fedda008e1e4f9643ee636abac35.png

6. Diagnóstico e limites

Uma série curta comporta menos origens que as 7 da calibração da regra de aceite; com menos unidades, a regra aceita estruturas espúrias com mais frequência, e o experimento avisa. Três séries, uma semente e o orçamento small dimensionam o projeto, mas não sustentam conclusões sobre o M4 inteiro.

store.query(
    """
    SELECT r.name AS serie, r.n_origins AS origens_duas_camadas, COUNT(l.seq) AS propostas,
           SUM(l.status = 'EVALUATED') AS avaliadas, ROUND(SUM(l.seconds), 1) AS segundos_busca
    FROM runs r JOIN ledger l USING (run_id)
    GROUP BY r.name ORDER BY r.name
    """
)
serie origens_duas_camadas propostas avaliadas segundos_busca
0 D2161 8 32 17 3.1
1 D2692 14 32 17 25.0
2 D3594 14 32 17 20.6

7. Avaliação interna e custo

Cada escolha distinta foi reajustada e avaliada uma única vez no bloco interno durante run_project, antes do registro final do caso. Aqui lemos métricas e previsões do SQLite; não reabrimos o bloco reservado. A tabela é exportada em CSV. search_seconds mede busca/seleção; case_seconds inclui também finalização, avaliação e persistência. Custos compartilhados não são somados entre escolhas.

rows, paths = [], {}
external = store.query(
    "SELECT run_id, model_id, value FROM external WHERE metric = 'MASE' AND series_id IS NOT NULL"
)
predictions = store.query(
    "SELECT model_id, timestamp, y_pred, y_true FROM predictions ORDER BY timestamp"
)
for run in store.query("SELECT run_id, name, tags FROM runs ORDER BY name").itertuples():
    for label, choice in json.loads(run.tags)["choices"].items():
        score = external[
            (external.run_id == run.run_id) & (external.model_id == choice["model_id"])
        ]
        if len(score) != 1:
            raise ConfigurationError("Missing or ambiguous saved evaluation.")
        rows.append({"series": run.name, "choice": label, "MASE": score.value.iloc[0]})
        path = predictions[predictions.model_id == choice["model_id"]].copy()
        path["timestamp"] = pd.to_datetime(path.timestamp)
        paths[run.name, label] = path.set_index("timestamp")
internal = pd.DataFrame(rows)
internal.to_csv(folder / "internal_metrics.csv", index=False)
internal.pivot(index="series", columns="choice", values="MASE").round(3)
choice guarded lowest_error persistence
series
D2161 2.591 2.509 2.591
D2692 3.366 2.724 3.058
D3594 0.836 0.836 0.791
fig, axes = plt.subplots(1, len(cases), figsize=(11, 3.2), squeeze=False)
for ax, case in zip(axes.flat, cases, strict=True):
    sid = case.series_id
    recent = case.values.iloc[-4 * H :]
    ax.plot(recent.index, recent.to_numpy(), color="0.3", linewidth=0.9, label="observado")
    for label, style in (("guarded", "-"), ("lowest_error", "--"), ("persistence", ":")):
        table = paths[sid, label]
        ax.plot(table.index, table["y_pred"], linestyle=style, label=label)
    ax.set_title(sid, fontsize=9)
    ax.tick_params(axis="x", labelrotation=30, labelsize=7)
axes.flat[0].legend(fontsize=7)
fig.tight_layout()
plt.show()
../../_images/2a03eae52cc9b13270bc95f52540cd6f1687d435ac9267d393aae43b34251dde.png

Verificação em outro processo

Um novo interpretador abre o banco e carrega os modelos salvos, sem objetos desta sessão. As previsões são comparadas às já registradas, sem nova leitura dos alvos.

verification = """
import json
import sys
from pathlib import Path
import numpy as np
from autohybts import ExperimentStore, load_model
folder = Path(sys.argv[1])
store = ExperimentStore(folder / 'm4.db')
runs = store.query('SELECT tags FROM runs')
predictions = store.query('SELECT model_id, timestamp, y_pred FROM predictions ORDER BY timestamp')
checked = set()
for row in runs.itertuples():
    for choice in json.loads(row.tags)['choices'].values():
        if choice['model_id'] in checked:
            continue
        model = load_model(folder / choice['artifact'], trusted=True, sha256=choice['sha256'])
        recorded = predictions[predictions.model_id == choice['model_id']]
        np.testing.assert_allclose(
            model.predict(len(recorded)).y_pred, recorded.y_pred, rtol=1e-12, atol=1e-12
        )
        checked.add(choice['model_id'])
print(f'Independent process: {len(runs)} cases and {len(checked)} models verified.')
"""
verified = subprocess.run(
    [sys.executable, "-c", verification, str(folder.resolve())],
    check=True,
    capture_output=True,
    text=True,
)
print(verified.stdout.strip())
Independent process: 3 cases and 7 models verified.

8. Leitura

  • Dados. O sorteio trouxe D2692, D2161 e D3594. A D2161 tem só 172 dias de treino oficial: comporta 4 origens por camada, menos que as 7 da calibração, e o experimento avisou. Nas três séries, o ADF e o KPSS pedem uma diferença.

  • Seleção guardada. Ficou com um modelo individual nas três: persistência na D2161 e passeio aleatório com tendência nas outras duas. Nenhum desafiante passou na regra de aceite, por motivos diferentes. Na D2161, o híbrido persistência + corretor linear venceu nas 4 unidades, mas com ganho mediano de 3,7 %, abaixo dos 5 %. Na D2692, a regressão linear teve ganho de 6,4 %, mas só 57 % de vitórias, abaixo dos 60 %.

  • Menor erro. Escolheria esse híbrido na D2161 e a regressão linear na D2692. No bloco selado de 14 dias, essas escolhas tiveram MASE menor que a seleção guardada: 2,51 contra 2,59 e 2,72 contra 3,37. Na D3594 as duas escolheram o mesmo modelo, e a persistência foi a melhor (0,79 contra 0,84).

  • Como ler isso. Nesta amostra, a cautela da regra não se pagou: as candidatas que ela recusou estavam perto do limiar e acertaram no bloco interno. Mas cada série teve uma única janela de 14 dias, e três séries não sustentam uma comparação. A regra foi calibrada para errar pouco por excesso de complexidade em muitas séries, não para vencer em cada uma. Um estudo com mais séries e sementes é o que decide se ela é conservadora demais no M4 diário.

  • Retomada e custo. A busca/seleção somou 51.2 s e os casos, incluindo finalização, avaliação e persistência, 52.1 s nesta execução. A segunda chamada não criou casos nem alterou os registros. Outro processo carregou os modelos e reproduziu as previsões salvas; ao reexecutar o notebook, as tabelas são reconstruídas do banco. Esses tempos dependem da máquina e dos caches. Um caso interrompido antes do registro final será refeito; os demais são preservados.