File size: 7,316 Bytes
e317359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
#!/usr/bin/env python3
"""Export LiveHouse per-dataset paper tables from live aggregate results."""

from __future__ import annotations

import argparse
import csv
import math
from pathlib import Path


MODELS = [
    "Chronos-2",
    "TiRex",
    "TimesFM-2.5",
    "Toto-1.0",
    "Moirai-2.0",
    "Chronos-Bolt",
    "TabPFN-TS",
    "Sundial",
    "Moving-Average",
    "ETS",
    "ARIMA",
    "Seasonal-Naive",
]

MODEL_SEPARATOR_AFTER = "Sundial"

DATASETS = [
    ("BTC", "binance_btcusdt_1h_close/1h/short"),
    ("PM2.5", "open_meteo_shanghai_air_quality_hourly_pm2_5/1h/short"),
    ("Quake", "usgs_earthquake_all_week_hourly_earthquake_count/1h/short"),
    ("Potomac", "usgs_potomac_iv_usgs_00060/15min/short"),
    ("Water", "noaa_coops_sf_water_level_water_level/6min/short"),
    ("Wave", "noaa_ndbc_46013_realtime_wave_height/10min/short"),
    ("T2M", "nasa_power_shanghai_hourly_T2M/1h/short"),
    ("KSFO", "nws_ksfo_observations_temperature/1h/short"),
    ("Temp2m", "open_meteo_shanghai_hourly_temperature_2m/1h/short"),
    ("Wiki", "wikimedia_time_series_hourly_pageviews/1D/short"),
]

METRICS = [
    (
        "RMSE",
        r"Per-dataset RMSE ($\downarrow$) on the online benchmark.",
        "tab_perdataset_rmse",
    ),
    (
        "MAPE",
        r"Per-dataset MAPE ($\downarrow$) on the online benchmark.",
        "tab_perdataset_mape",
    ),
    (
        "CRPS",
        r"Per-dataset CRPS ($\downarrow$) on the online benchmark.",
        "tab_perdataset_crps",
    ),
    (
        "Stability",
        r"Per-dataset Temporal Stability ($\downarrow$) on the online benchmark, computed from release-level MSE histories.",
        "tab_perdataset_stability",
    ),
    (
        "Improvement",
        r"Per-dataset Improvement ($\downarrow$; more negative is better) on the online benchmark, computed as Kendall $\tau$ on release-level MSE histories.",
        "tab_perdataset_improvement",
    ),
]

HIGHLIGHT_NOTE = (
    r"In each column the best available model is in \textcolor{red}{\textbf{red bold}} "
    r"and the second best is in \textcolor{blue}{\underline{blue underline}}."
)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--aggregate",
        type=Path,
        default=Path("space/results/aggregates/live_results_by_dataset.csv"),
        help="Path to live_results_by_dataset.csv.",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("paper/livehouse_up_to_now_tables.tex"),
        help="LaTeX output path.",
    )
    parser.add_argument(
        "--fill-single-release-zero",
        action="store_true",
        help="For Stability/Improvement only, write 0.000 when n_releases < 2.",
    )
    return parser.parse_args()


def parse_float(value: str | None) -> float | None:
    if value is None or value == "":
        return None
    try:
        parsed = float(value)
    except ValueError:
        return None
    if not math.isfinite(parsed):
        return None
    return parsed


def load_rows(path: Path) -> dict[tuple[str, str], dict[str, str]]:
    rows: dict[tuple[str, str], dict[str, str]] = {}
    with path.open(newline="", encoding="utf-8") as handle:
        for row in csv.DictReader(handle):
            rows[(row["model"], row["dataset"])] = row
    return rows


def release_count(row: dict[str, str] | None) -> int:
    if not row:
        return 0
    try:
        return int(float(row.get("n_releases", "0") or 0))
    except ValueError:
        return 0


def metric_value(
    rows: dict[tuple[str, str], dict[str, str]],
    model: str,
    dataset: str,
    metric: str,
    fill_single_release_zero: bool,
) -> float | None:
    row = rows.get((model, dataset))
    if row is None:
        return None

    if metric in {"Stability", "Improvement"} and release_count(row) < 2:
        return 0.0 if fill_single_release_zero else None

    return parse_float(row.get(metric))


def format_value(value: float | None) -> str:
    if value is None:
        return "--"
    abs_value = abs(value)
    if abs_value >= 1000 or (0 < abs_value < 0.001):
        return f"{value:.3e}"
    return f"{value:.3f}"


def highlight(formatted: str, rank: int | None) -> str:
    if rank == 1:
        return rf"\textcolor{{red}}{{\textbf{{{formatted}}}}}"
    if rank == 2:
        return rf"\textcolor{{blue}}{{\underline{{{formatted}}}}}"
    return formatted


def ranks_for_column(values: dict[str, float | None]) -> dict[str, int | None]:
    ranked = [
        (model, value)
        for model, value in values.items()
        if value is not None and math.isfinite(value)
    ]
    ranked.sort(key=lambda item: (item[1], MODELS.index(item[0])))
    ranks = {model: None for model in values}
    if ranked:
        ranks[ranked[0][0]] = 1
    if len(ranked) > 1:
        ranks[ranked[1][0]] = 2
    return ranks


def caption_for(metric: str, base_caption: str, fill_single_release_zero: bool) -> str:
    caption = base_caption
    if metric in {"Stability", "Improvement"}:
        if fill_single_release_zero:
            caption += " Cells with fewer than two releases are provisionally assigned 0."
        else:
            caption += " Cells with fewer than two releases are shown as --."
    return f"{caption} {HIGHLIGHT_NOTE}"


def render_metric_table(
    rows: dict[tuple[str, str], dict[str, str]],
    metric: str,
    caption: str,
    label: str,
    fill_single_release_zero: bool,
) -> str:
    values_by_dataset = {
        dataset_id: {
            model: metric_value(rows, model, dataset_id, metric, fill_single_release_zero)
            for model in MODELS
        }
        for _, dataset_id in DATASETS
    }
    ranks_by_dataset = {
        dataset_id: ranks_for_column(values)
        for dataset_id, values in values_by_dataset.items()
    }

    lines = [
        r"\begin{table*}[!t]",
        r"\centering",
        rf"\caption{{{caption_for(metric, caption, fill_single_release_zero)}}}",
        rf"\label{{{label}}}",
        r"\resizebox{0.8 \linewidth}{!}{%",
        r"\begin{tabular}{l rrrrrrrrrr}",
        r"\toprule",
        "Model & " + " & ".join(name for name, _ in DATASETS) + r" \\",
        r"\midrule",
    ]

    for model in MODELS:
        cells = []
        for _, dataset_id in DATASETS:
            value = values_by_dataset[dataset_id][model]
            formatted = format_value(value)
            rank = ranks_by_dataset[dataset_id][model]
            cells.append(highlight(formatted, rank))
        lines.append(model + " & " + " & ".join(cells) + r" \\")
        if model == MODEL_SEPARATOR_AFTER:
            lines.append(r"\midrule")

    lines.extend(
        [
            r"\bottomrule",
            r"\end{tabular}}",
            r"\end{table*}",
        ]
    )
    return "\n".join(lines)


def main() -> None:
    args = parse_args()
    rows = load_rows(args.aggregate)
    tables = [
        render_metric_table(rows, metric, caption, label, args.fill_single_release_zero)
        for metric, caption, label in METRICS
    ]
    args.output.parent.mkdir(parents=True, exist_ok=True)
    args.output.write_text("\n\n".join(tables) + "\n", encoding="utf-8")
    print(f"Wrote {args.output}")


if __name__ == "__main__":
    main()