Spaces:
Running
Running
Download src/tsfm_bench/eval/gift_eval_aggregation.py from ThinkcatLab/LiveHouse-TS: direct link, hf CLI and curl.
- Browser
- Download file 15 kB
-
https://huggingface.co/spaces/ThinkcatLab/LiveHouse-TS/resolve/main/src/tsfm_bench/eval/gift_eval_aggregation.py
- Command line
-
hf download hf://spaces/ThinkcatLab/LiveHouse-TS/src/tsfm_bench/eval/gift_eval_aggregation.py
-
curl -L -o gift_eval_aggregation.py https://huggingface.co/spaces/ThinkcatLab/LiveHouse-TS/resolve/main/src/tsfm_bench/eval/gift_eval_aggregation.py
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 | |