LiveHouse-TS / src /tsfm_bench /eval /gift_eval_aggregation.py
ziyuzhou02's picture
Deploy GitHub main 3feb6cda1511
e317359 verified
Raw History Blame Contribute Delete
15 kB
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Final
import numpy as np
import pandas as pd
from scipy import stats
MSE_COLUMN: Final = "eval_metrics/MSE[mean]"
CRPS_COLUMN: Final = "eval_metrics/mean_weighted_sum_quantile_loss"
DEFAULT_BASELINE_MODEL: Final = "Seasonal-Naive"
GEOMEAN_EPSILON: Final = 1e-12
CONFIG_COLUMNS: Final = ["dataset", "term_length", "frequency", "prediction_length"]
DOMAIN_DISPLAY: Final = {
"Finance": "Finance",
"Econ/Fin": "Finance",
"Sales": "Finance",
"Climate": "Climate",
"Weather": "Climate",
"Air Quality": "Climate",
"Nature": "Climate",
"Energy": "Climate",
"Ocean": "Ocean",
"Hydrology": "Hydrology",
"Transport": "Transportation",
"Transportation": "Transportation",
"Web": "Web",
"Web/CloudOps": "Web",
"Hazards": "Hazards",
"Healthcare": "Hazards",
}
AGGREGATE_COLUMNS: Final = [
"model",
"MSE",
"CRPS",
"Rank",
"n_configs",
"n_group_configs",
"coverage",
]
RESULT_CONFIG_COLUMNS: Final = [
"dataset",
"model",
"domain",
MSE_COLUMN,
CRPS_COLUMN,
"prediction_length",
]
GROUP_SPECS: Final = {
"prediction_length": ("prediction_length", "results_by_prediction_length.csv"),
"domain": ("domain", "results_by_domain.csv"),
"frequency": ("frequency", "results_by_frequency.csv"),
}
def _model_key(value: object) -> str:
return re.sub(r"[^a-z0-9]+", "", str(value).lower())
def _load_model_allowlist(root: Path) -> set[str]:
path = root / "model_allowlist.json"
if not path.exists():
return set()
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return set()
models = payload.get("models", payload) if isinstance(payload, dict) else payload
if not isinstance(models, list):
return set()
return {_model_key(model) for model in models if str(model).strip()}
def _geometric_mean(series: pd.Series) -> float:
values = pd.to_numeric(series, errors="coerce").dropna().to_numpy(dtype=float)
values = values[np.isfinite(values) & (values >= 0.0)]
if values.size == 0:
return np.nan
values = np.clip(values, GEOMEAN_EPSILON, None)
return float(stats.gmean(values))
def _split_dataset_config(value: object) -> tuple[str, str, str]:
parts = str(value).rsplit("/", 2)
if len(parts) == 3:
return parts[0], parts[1], parts[2]
if len(parts) == 2:
return parts[0], parts[1], "short"
return parts[0], "unknown", "short"
def canonical_domain(value: object) -> str:
domain = str(value).strip()
if domain in DOMAIN_DISPLAY:
return DOMAIN_DISPLAY[domain]
lowered = domain.lower()
if any(token in lowered for token in ("finance", "econ", "sales")):
return "Finance"
if any(token in lowered for token in ("climate", "weather", "air", "nature", "energy")):
return "Climate"
if "ocean" in lowered:
return "Ocean"
if "hydro" in lowered:
return "Hydrology"
if any(token in lowered for token in ("transport", "taxi")):
return "Transportation"
if any(token in lowered for token in ("web", "cloud", "wiki")):
return "Web"
return "Hazards"
def _load_prediction_lengths(model_dir: Path) -> dict[str, int]:
"""Load current numeric horizons from per-dataset forecast snapshots."""
lengths: dict[str, int] = {}
forecasts_dir = model_dir / "forecasts"
if not forecasts_dir.exists():
return lengths
for snapshot_path in sorted(forecasts_dir.glob("*.json")):
try:
snapshot = json.loads(snapshot_path.read_text())
dataset_config = str(snapshot["dataset"])
prediction_length = int(snapshot["prediction_length"])
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError):
continue
if prediction_length > 0:
lengths[dataset_config] = prediction_length
return lengths
def _read_result_config_csv(path: Path) -> pd.DataFrame:
return pd.read_csv(
path,
usecols=lambda column: column in RESULT_CONFIG_COLUMNS,
index_col=False,
)
def _dataset_key(value: object) -> str:
return str(value).split("/")[0].strip().lower()
def _load_live_dataset_keys(root: Path) -> set[str]:
path = root / "dataset_properties.csv"
if not path.exists():
return set()
try:
props = pd.read_csv(path)
except (OSError, pd.errors.ParserError, pd.errors.EmptyDataError):
return set()
if "dataset" not in props.columns:
return set()
return {_dataset_key(value) for value in props["dataset"].dropna()}
def load_result_configs(root_dir: str | Path) -> pd.DataFrame:
"""Load one result row per model and GIFT-Eval-style dataset config."""
root = Path(root_dir)
frames: list[pd.DataFrame] = []
live_dataset_keys = _load_live_dataset_keys(root)
for csv_path in sorted(root.glob("*/all_results.csv")):
try:
frame = _read_result_config_csv(csv_path)
except (OSError, pd.errors.ParserError, pd.errors.EmptyDataError):
continue
required = {"dataset", "model", "domain", MSE_COLUMN, CRPS_COLUMN}
if frame.empty or not required.issubset(frame.columns):
continue
snapshot_lengths = _load_prediction_lengths(csv_path.parent)
existing_lengths = (
pd.to_numeric(frame["prediction_length"], errors="coerce")
if "prediction_length" in frame.columns
else pd.Series(np.nan, index=frame.index, dtype=float)
)
snapshot_series = frame["dataset"].astype(str).map(snapshot_lengths)
frame["prediction_length"] = existing_lengths.fillna(snapshot_series)
frames.append(frame)
if not frames:
return pd.DataFrame(
columns=[
*CONFIG_COLUMNS,
"prediction_length",
"config_id",
"model",
"domain",
MSE_COLUMN,
CRPS_COLUMN,
]
)
df = pd.concat(frames, ignore_index=True)
configs = df["dataset"].map(_split_dataset_config)
df[["dataset", "frequency", "term_length"]] = pd.DataFrame(
configs.tolist(),
index=df.index,
)
model_allowlist = _load_model_allowlist(root)
if model_allowlist:
df = df[df["model"].map(_model_key).isin(model_allowlist)].copy()
if live_dataset_keys:
df = df[df["dataset"].map(_dataset_key).isin(live_dataset_keys)].copy()
df["frequency"] = df["frequency"].astype(str).str.strip()
df["term_length"] = df["term_length"].astype(str).str.strip().str.lower()
df["domain"] = df["domain"].map(canonical_domain)
df["model"] = df["model"].astype(str).str.strip()
df["prediction_length"] = pd.to_numeric(df["prediction_length"], errors="coerce").astype(
"Int64"
)
df["config_id"] = df[CONFIG_COLUMNS].astype(str).agg("/".join, axis=1)
for metric in (MSE_COLUMN, CRPS_COLUMN):
df[metric] = pd.to_numeric(df[metric], errors="coerce")
df.loc[~np.isfinite(df[metric]), metric] = np.nan
# A model should contribute only once to a dataset configuration. Keeping
# the last row makes the loader robust to accidentally concatenated files.
return (
df.dropna(subset=["dataset", "model"])
.drop_duplicates(subset=[*CONFIG_COLUMNS, "model"], keep="last")
.reset_index(drop=True)
)
def normalize_against_seasonal_naive(
df: pd.DataFrame,
baseline_model: str = DEFAULT_BASELINE_MODEL,
) -> pd.DataFrame:
"""Apply the GIFT-Eval per-configuration baseline normalization.
MSE and CRPS are divided by the Seasonal-Naive value from the same
``(dataset, term_length, frequency)`` configuration. Rank follows
GIFT-Eval and is computed from CRPS before group aggregation.
"""
if df.empty:
result = df.copy()
result["MSE"] = pd.Series(dtype=float)
result["CRPS"] = pd.Series(dtype=float)
result["Rank"] = pd.Series(dtype=float)
return result
baseline_key = _model_key(baseline_model)
baseline_rows = df[df["model"].map(_model_key) == baseline_key]
if baseline_rows.empty:
available = ", ".join(sorted(df["model"].dropna().unique()))
raise ValueError(
f"Normalization baseline {baseline_model!r} was not found. Available models: {available}"
)
baseline = (
baseline_rows.groupby(CONFIG_COLUMNS, as_index=False)[[MSE_COLUMN, CRPS_COLUMN]]
.mean()
.rename(columns={MSE_COLUMN: "__baseline_mse", CRPS_COLUMN: "__baseline_crps"})
)
normalized = df.merge(baseline, on=CONFIG_COLUMNS, how="left", validate="many_to_one")
valid_mse = (
np.isfinite(normalized["__baseline_mse"])
& normalized["__baseline_mse"].ge(0)
& np.isfinite(normalized[MSE_COLUMN])
& normalized[MSE_COLUMN].ge(0)
)
valid_crps = (
np.isfinite(normalized["__baseline_crps"])
& normalized["__baseline_crps"].ge(0)
& np.isfinite(normalized[CRPS_COLUMN])
& normalized[CRPS_COLUMN].ge(0)
)
mse_denominator = normalized["__baseline_mse"].clip(lower=GEOMEAN_EPSILON)
crps_denominator = normalized["__baseline_crps"].clip(lower=GEOMEAN_EPSILON)
mse_numerator = normalized[MSE_COLUMN].clip(lower=GEOMEAN_EPSILON)
crps_numerator = normalized[CRPS_COLUMN].clip(lower=GEOMEAN_EPSILON)
normalized["MSE"] = np.where(
valid_mse,
mse_numerator / mse_denominator,
np.nan,
)
normalized["CRPS"] = np.where(
valid_crps,
crps_numerator / crps_denominator,
np.nan,
)
rank_metric = normalized[CRPS_COLUMN].where(
normalized[CRPS_COLUMN].notna(),
normalized[MSE_COLUMN],
)
normalized["Rank"] = rank_metric.groupby(
[normalized[column] for column in CONFIG_COLUMNS],
dropna=False,
).rank(
method="average",
ascending=True,
na_option="keep",
)
return normalized.drop(columns=["__baseline_mse", "__baseline_crps"])
def aggregate_by(normalized: pd.DataFrame, group_column: str) -> pd.DataFrame:
"""Aggregate normalized configuration scores using the GIFT-Eval rule.
Per-configuration ranks are averaged for ordering, then written as
positive integer leaderboard ranks within each group.
"""
output_columns = [group_column, *AGGREGATE_COLUMNS]
if normalized.empty:
return pd.DataFrame(columns=output_columns)
if group_column not in normalized.columns:
raise ValueError(f"Unknown aggregation column: {group_column}")
source = normalized
if group_column == "prediction_length":
source = normalized.dropna(subset=[group_column])
if source.empty:
return pd.DataFrame(columns=output_columns)
group_config_counts = source.groupby(group_column, dropna=False)["config_id"].nunique()
grouped = (
source.groupby([group_column, "model"], dropna=False)
.agg(
MSE=("MSE", _geometric_mean),
CRPS=("CRPS", _geometric_mean),
Rank=("Rank", "mean"),
n_configs=("config_id", "nunique"),
)
.reset_index()
)
grouped["n_group_configs"] = grouped[group_column].map(group_config_counts).astype(int)
grouped["coverage"] = grouped["n_configs"] / grouped["n_group_configs"]
rank_sort_columns = ["Rank", "MSE", "model"]
if group_column == "term_length":
term_order = {"short": 0, "medium": 1, "long": 2}
grouped["__order"] = grouped[group_column].map(term_order).fillna(99)
grouped = grouped.sort_values(["__order", group_column, *rank_sort_columns]).drop(
columns="__order"
)
elif group_column == "prediction_length":
grouped = grouped.sort_values([group_column, *rank_sort_columns], na_position="last")
else:
grouped = grouped.sort_values([group_column, *rank_sort_columns], na_position="last")
grouped["Rank"] = grouped.groupby(group_column, dropna=False).cumcount().add(1).astype(int)
return grouped.loc[:, output_columns].reset_index(drop=True)
def build_aggregated_results(
df: pd.DataFrame,
baseline_model: str = DEFAULT_BASELINE_MODEL,
) -> dict[str, pd.DataFrame]:
df = df.copy()
if "domain" in df.columns:
df["domain"] = df["domain"].map(canonical_domain)
normalized = normalize_against_seasonal_naive(df, baseline_model)
tables = {
name: aggregate_by(normalized, column).rename(columns={column: name})
for name, (column, _) in GROUP_SPECS.items()
}
overall_source = normalized.assign(overall="Overall")
tables["overall"] = aggregate_by(overall_source, "overall")
return tables
def _write_csv_atomic(df: pd.DataFrame, path: Path) -> None:
tmp_path = path.with_suffix(path.suffix + ".tmp")
df.to_csv(tmp_path, index=False)
tmp_path.replace(path)
def write_aggregated_results(
root_dir: str | Path,
baseline_model: str = DEFAULT_BASELINE_MODEL,
) -> dict[str, Path]:
"""Build and persist current GIFT-style aggregate tables."""
root = Path(root_dir)
aggregate_dir = root / "aggregates"
aggregate_dir.mkdir(parents=True, exist_ok=True)
configs = load_result_configs(root)
tables = build_aggregated_results(configs, baseline_model)
paths: dict[str, Path] = {}
for name, (_, filename) in GROUP_SPECS.items():
path = aggregate_dir / filename
_write_csv_atomic(tables[name], path)
paths[name] = path
overall_path = aggregate_dir / "results_overall.csv"
_write_csv_atomic(tables["overall"], overall_path)
paths["overall"] = overall_path
metadata = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"baseline_model": baseline_model,
"normalization": "Per-config metric / Seasonal-Naive metric",
"metric_aggregation": "Geometric mean over dataset configurations",
"rank_aggregation": (
"Positive integer order by mean per-config CRPS rank; "
"falls back to MSE when CRPS is unavailable"
),
"config_key": CONFIG_COLUMNS,
"num_configs": int(configs["config_id"].nunique()) if not configs.empty else 0,
"num_models": int(configs["model"].nunique()) if not configs.empty else 0,
"num_rows_missing_prediction_length": (
int(configs["prediction_length"].isna().sum()) if not configs.empty else 0
),
}
metadata_path = aggregate_dir / "metadata.json"
tmp_metadata_path = metadata_path.with_suffix(".json.tmp")
tmp_metadata_path.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
tmp_metadata_path.replace(metadata_path)
paths["metadata"] = metadata_path
return paths