18. Análise de experimentos com SQL

Objetivo e requisitos. Gravar vários experimentos num banco SQLite com ExperimentStore e analisar os resultados com consultas: campeões, erro por família, decisões da seleção, custo, erro por origem e avaliação externa. Duas séries offline (uma com estrutura, outra só ruído) nos protocolos A e B dão quatro execuções. Extras stats e plot; nenhum download.

import warnings
from pathlib import Path

import matplotlib.pyplot as plt

import autohybts
from autohybts import AutoHybridExperiment, ExperimentStore
from autohybts.datasets import get_data

warnings.filterwarnings("ignore")
print("AutoHybTS", autohybts.__version__)
AutoHybTS 1.0.0rc3

1. Dados e protocolo

synthetic_daily tem tendência, ciclo semanal e ruído AR(1), estrutura que um corretor de resíduos pode aproveitar; white_noise_daily é nível mais ruído branco, em que nada além da média é previsível. Cada série roda nos protocolos A (7 passos de uma vez) e B (um passo por vez), com o mesmo espaço de busca pequeno.

SERIES = {
    "estruturada": get_data("synthetic_daily", n=400),
    "ruido": get_data("white_noise_daily", n=400),
}
SETTINGS = {
    "fh": 7,
    "session_id": 42,
    "budget": "smoke",
    "base_models": ["naive", "seasonal_naive", "linear", "knn", "exponential_smoothing"],
    "correctors": ["linear", "knn"],
}
fig, axes = plt.subplots(2, 1, figsize=(9, 4.5), sharex=True)
for ax, (name, y) in zip(axes, SERIES.items(), strict=True):
    ax.plot(y.index, y.to_numpy(), linewidth=0.8)
    ax.set(title=name, ylabel="valor")
fig.tight_layout()
plt.show()
../../_images/81f0c3ff430f5fe132c2f88403c77f402f11bc17858a7e096df64e01b84f33ea.png

2. Registro dos experimentos

O banco fica em artifacts/, fora do git, e é recriado a cada execução do notebook. Cada experimento é comparado, finalizado, avaliado uma vez no teste selado e registrado com etiquetas; values=True grava também as previsões e as observações do teste, usadas na seção 4.

path = Path("artifacts/experimentos.db")
path.unlink(missing_ok=True)
store = ExperimentStore(path)

for name, y in SERIES.items():
    for variant in ("A", "B"):
        exp = AutoHybridExperiment(variant=variant, **SETTINGS).fit(y)
        best = exp.compare_models().best
        final = exp.finalize_model(best)
        exp.evaluate_external(final)
        store.log(
            exp,
            name=f"{name}-{variant}",
            tags={"serie": name, "protocolo": variant},
            models=[final],
            values=True,
        )
store.runs()[["name", "variant", "n_origins", "ranking_metric", "champion_label"]]
name variant n_origins ranking_metric champion_label
0 ruido-B B 14 MASE exponential_smoothing(season_length=7)
1 ruido-A A 14 MASE exponential_smoothing(season_length=7)
2 estruturada-B B 14 MASE exponential_smoothing(season_length=7) + linea...
3 estruturada-A A 14 MASE exponential_smoothing(season_length=7)
store.query(
    """
    SELECT name AS tabela FROM sqlite_master WHERE type = 'table' ORDER BY name
    """
)
tabela
0 candidates
1 decisions
2 evaluations
3 events
4 external
5 ledger
6 meta
7 metrics
8 models
9 predictions
10 runs
11 series
12 splits
13 trial_metrics
14 trials

3. Consultas

Campeões e placar

Para cada execução, o campeão, o seu erro médio na camada de seleção e quantas candidatas a busca avaliou.

store.query(
    """
    SELECT r.name,
           r.champion_label AS campeao,
           ROUND(m.value, 3) AS mase_selecao,
           (SELECT COUNT(*) FROM ledger l
             WHERE l.run_id = r.run_id AND l.status = 'EVALUATED') AS avaliadas
    FROM runs r
    JOIN metrics m
      ON m.run_id = r.run_id AND m.candidate_id = r.champion_id
     AND m.layer = 'select' AND m.metric = r.ranking_metric
    ORDER BY r.name
    """
)
name campeao mase_selecao avaliadas
0 estruturada-A exponential_smoothing(season_length=7) 0.790 9
1 estruturada-B exponential_smoothing(season_length=7) + linea... 0.652 9
2 ruido-A exponential_smoothing(season_length=7) 0.865 9
3 ruido-B exponential_smoothing(season_length=7) 0.865 9
run_id = store.query("SELECT run_id FROM runs WHERE name = ?", ("estruturada-A",)).run_id[0]
store.query(
    """
    SELECT c.label, c.family, ROUND(m.value, 3) AS mase, e.coverage, e.is_champion
    FROM metrics m
    JOIN candidates c USING (run_id, candidate_id)
    JOIN evaluations e USING (run_id, candidate_id, layer)
    WHERE m.run_id = ? AND m.layer = 'select' AND m.metric = 'MASE'
    ORDER BY mase
    """,
    (run_id,),
)
label family mase coverage is_champion
0 exponential_smoothing(season_length=7) statistical 0.790 1.0 1
1 exponential_smoothing(season_length=7) + linea... residual 0.794 1.0 0
2 knn(lags=7) kernel 0.969 1.0 0
3 linear(lags=7) linear 0.987 1.0 0

Erro por família

Erro médio de cada família na camada de busca, por execução. A consulta agrega em SQL e o pandas só gira a tabela para o gráfico.

families = store.query(
    """
    SELECT r.name, c.family, AVG(m.value) AS mase, COUNT(*) AS candidatas
    FROM metrics m
    JOIN candidates c USING (run_id, candidate_id)
    JOIN runs r USING (run_id)
    WHERE m.layer = 'search' AND m.metric = 'MASE'
    GROUP BY r.name, c.family
    """
)
table = families.pivot(index="family", columns="name", values="mase")
ax = table.plot.barh(figsize=(8, 4), width=0.8)
ax.set(xlabel="MASE médio na busca", ylabel="")
plt.show()
table.round(3)
../../_images/e0b9edeb603209feb6c2aa1c6741089ffad6ce911e3c31a5e167bba10387899f.png
name estruturada-A estruturada-B ruido-A ruido-B
family
kernel 1.059 0.931 0.889 0.787
linear 1.049 0.666 0.806 0.794
residual 1.173 0.720 0.791 0.772
statistical 1.173 0.994 0.941 0.980

Decisões da seleção

Cada desafiante comparado com o campeão da vez: aceito ou rejeitado, e por quê. Na série de ruído, um corretor não deveria ser aceito; quando é, o ganho mediano e a taxa de vitórias mostram por quão pouco.

store.query(
    """
    SELECT r.name, 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
    """
)
name family desafiante aceito ganho_mediano vitorias motivos
0 estruturada-A linear linear(lags=7) 0 -0.061 0.43 ["median_gain_below_threshold", "win_rate_belo...
1 estruturada-A kernel knn(lags=7) 0 -0.424 0.43 ["median_gain_below_threshold", "win_rate_belo...
2 estruturada-A residual exponential_smoothing(season_length=7) + linea... 0 0.003 0.57 ["median_gain_below_threshold", "win_rate_belo...
3 estruturada-B linear linear(lags=7) 0 -0.076 0.29 ["median_gain_below_threshold", "win_rate_belo...
4 estruturada-B kernel knn(lags=7) 0 -0.035 0.29 ["median_gain_below_threshold", "win_rate_belo...
5 estruturada-B residual exponential_smoothing(season_length=7) + linea... 1 0.052 0.71 ["accepted"]
6 ruido-A linear linear(lags=7) 0 -0.025 0.43 ["median_gain_below_threshold", "win_rate_belo...
7 ruido-A kernel knn(lags=7) 0 -0.040 0.29 ["median_gain_below_threshold", "win_rate_belo...
8 ruido-A residual exponential_smoothing(season_length=7) + linea... 0 0.015 0.57 ["median_gain_below_threshold", "win_rate_belo...
9 ruido-B linear linear(lags=7) 0 -0.055 0.29 ["median_gain_below_threshold", "win_rate_belo...
10 ruido-B kernel knn(lags=7) 0 -0.051 0.43 ["median_gain_below_threshold", "win_rate_belo...
11 ruido-B residual exponential_smoothing(season_length=7) + linea... 0 0.012 0.57 ["median_gain_below_threshold", "win_rate_belo...
store.query(
    """
    SELECT reason_codes AS motivo, COUNT(*) AS n
    FROM decisions WHERE accepted = 0
    GROUP BY reason_codes ORDER BY n DESC
    """
)
motivo n
0 ["median_gain_below_threshold", "win_rate_belo... 11

Custo e erro

Tempo de ajuste somado nas origens contra o erro na busca, uma marca por candidata. Mais custo não garante menos erro.

cost = store.query(
    """
    SELECT r.name, c.label, c.strategy, SUM(t.fit_seconds) AS segundos, m.value AS mase
    FROM trials t
    JOIN candidates c USING (run_id, candidate_id)
    JOIN runs r USING (run_id)
    JOIN metrics m
      ON m.run_id = t.run_id AND m.candidate_id = t.candidate_id
     AND m.layer = 'search' AND m.metric = 'MASE'
    WHERE t.layer = 'search'
    GROUP BY t.run_id, t.candidate_id
    """
)
fig, ax = plt.subplots(figsize=(7, 4))
for (name, strategy), part in cost.groupby(["name", "strategy"]):
    ax.scatter(
        part.segundos,
        part.mase,
        s=22,
        marker="o" if strategy == "standalone" else "^",
        label=f"{name} ({strategy})",
    )
ax.set(xscale="log", xlabel="segundos de ajuste na busca (escala log)", ylabel="MASE na busca")
ax.legend(fontsize=7)
plt.show()
../../_images/8ad35f25418895b6bf16712f413c14dc5f927c7055f149ff2920cdbc88c5e76d.png

Erro por origem

As métricas de cada origem ficam em trial_metrics. Aqui, o campeão contra a persistência em cada origem da camada de seleção: o ganho do campeão é consistente ou depende de poucas origens?

origins = store.query(
    """
    SELECT r.name, t.origin, c.label, tm.value AS mase,
           t.candidate_id = r.champion_id AS campeao
    FROM trial_metrics tm
    JOIN trials t USING (run_id, candidate_id, unit_id)
    JOIN candidates c USING (run_id, candidate_id)
    JOIN runs r USING (run_id)
    WHERE t.layer = 'select' AND tm.metric = 'MASE'
      AND (t.candidate_id = r.champion_id OR c.label = 'naive')
    ORDER BY r.name, t.origin
    """
)
fig, axes = plt.subplots(1, 4, figsize=(12, 3.2), sharey=True)
for ax, (name, part) in zip(axes, origins.groupby("name"), strict=True):
    for is_champion, rows in part.groupby("campeao"):
        ax.plot(
            rows.origin.str[:10], rows.mase, marker="o", label="campeão" if is_champion else "naive"
        )
    ax.set_title(name, fontsize=9)
    ax.tick_params(axis="x", rotation=60, labelsize=7)
axes[0].set_ylabel("MASE por origem")
axes[0].legend(fontsize=7)
fig.tight_layout()
plt.show()
../../_images/9aaccd896aa56e2b902f64d0dbf7cbecfa08f87b1f297e7204b92bd9dd489ee1.png

4. Avaliação externa

O erro do campeão na seleção contra o erro no teste selado. Uma diferença grande indica que a validação foi otimista; o banco permite acompanhar isso em muitas execuções.

store.query(
    """
    SELECT r.name,
           ROUND(m.value, 3) AS mase_selecao,
           ROUND(x.value, 3) AS mase_teste,
           ROUND(x.value - m.value, 3) AS diferenca
    FROM runs r
    JOIN metrics m
      ON m.run_id = r.run_id AND m.candidate_id = r.champion_id
     AND m.layer = 'select' AND m.metric = 'MASE'
    JOIN models mo ON mo.run_id = r.run_id
    JOIN external x
      ON x.run_id = r.run_id AND x.model_id = mo.model_id AND x.metric = 'MASE'
    ORDER BY r.name
    """
)
name mase_selecao mase_teste diferenca
0 estruturada-A 0.790 1.627 0.838
1 estruturada-B 0.652 0.552 -0.101
2 ruido-A 0.865 0.502 -0.362
3 ruido-B 0.865 0.502 -0.362
forecasts = store.query(
    """
    SELECT r.name, p.timestamp, p.y_pred, p.y_true
    FROM predictions p JOIN runs r USING (run_id)
    ORDER BY r.name, p.timestamp
    """
)
fig, axes = plt.subplots(1, 4, figsize=(12, 3.2))
for ax, (name, part) in zip(axes, forecasts.groupby("name"), strict=True):
    ax.plot(part.timestamp.str[5:10], part.y_true, marker="o", color="0.3", label="observado")
    ax.plot(part.timestamp.str[5:10], part.y_pred, marker="x", color="#D55E00", label="previsto")
    ax.set_title(name, fontsize=9)
    ax.tick_params(axis="x", rotation=60, labelsize=7)
axes[0].legend(fontsize=7)
fig.tight_layout()
plt.show()
../../_images/d0d724df401a0cfc6561b9b564280dc15e440019f9b77c08b2b4cf4961593b51.png

5. Leitura

  • Um banco, quatro execuções. As 15 tabelas guardam desde a identidade das séries até cada origem de cada candidata; as perguntas abaixo foram respondidas só com SQL, sem refazer nenhum experimento.

  • Campeões. O ETS foi o campeão em três execuções. Só na série estruturada, no protocolo B, o híbrido ETS + corretor linear entrou: ganho mediano de 0,052 e 71 % de vitórias, logo acima do limiar (0,05 e 60 %).

  • Ruído. Na série de ruído branco, nenhum corretor foi aceito nos dois protocolos: os ganhos medianos ficaram entre 0,012 e 0,015, abaixo do limiar. É o comportamento esperado da regra de aceite.

  • Motivos. Todas as 11 rejeições tiveram o mesmo motivo: ganho mediano e taxa de vitórias abaixo do limiar. Os modelos sobre lags (linear e KNN) perderam do ETS em todas as execuções.

  • Custo. A candidata mais cara de cada execução, o ETS com corretor sobre resíduos multipasso (2,6 a 3,5 s de ajuste na busca), nunca teve o menor erro. Na série estruturada, protocolo B, a regressão linear sobre 7 lags levou 0,01 s e ficou a 0,12 de MASE da melhor.

  • Validação × teste. Na série estruturada, protocolo A, o MASE subiu de 0,79 na seleção para 1,63 no teste selado; nas outras execuções, o teste ficou abaixo da seleção. Com um teste de 7 pontos, a diferença varia muito de uma janela para outra; o banco permite acompanhar essa diferença ao longo de muitas execuções.

  • Uso. Para analisar outros experimentos, basta registrá-los no mesmo arquivo com store.log e repetir as consultas, filtrando por name ou pelas etiquetas em runs.tags.