OpenMHC / leaderboard_compute.py
YuzeBai's picture
Update leaderboard_compute.py
59c00a9 verified
Raw
History Blame Contribute Delete
16.2 kB
"""Compute the imputation leaderboard table live from the per-user substrate.
Downloads each method's per-user error substrate from the OpenMHC leaderboard
dataset, then reuses the canonical point-flow reducers from ``openmhc`` to
produce paired skill scores, cross-method ranks, and disparity-ratio (MAPD)
fair skill scores. No bootstrap (point only).
LOCF is the Track-2 baseline, so when it is the only method present the skill
table is empty by construction (baseline-vs-self) — the baseline row is emitted
with skill 0.0 and rank 1.
"""
from __future__ import annotations
import glob
import json
import os
import pandas as pd
DATASET_REPO = "MyHeartCounts/OpenMHC-leaderboard-data"
BASELINE = "locf"
HEADLINE_SCOPE = "overall"
# skill-score scope -> leaderboard subgroup column
SUBGROUP_SCOPES = {
"cat:activity": "activity",
"cat:physiology": "physiology",
"cat:sleep": "sleep",
"cat:workouts": "workout",
"semantic": "semantic",
}
# Columns identifying one (method, task, subgroup) cell. The fairness reducer
# consumes per-cell MEAN errors, not raw per-user rows.
PER_CELL_COLS = [
"method",
"scenario",
"channel",
"channel_type",
"subgroup_attr",
"subgroup_value",
]
# Hardcoded HF model links, keyed by internal method id. Only methods with a
# published checkpoint appear here — statistical baselines (locf / mean / mode /
# linear / temporal_* / personalized_*) have none, and the forecasting (-fc)
# models belong to a different track.
MODEL_URLS = {
"brits": "https://huggingface.co/MyHeartCounts/openmhc-brits-imp",
"dlinear": "https://huggingface.co/MyHeartCounts/openmhc-dlinear-imp",
"dlinear_weekly": "https://huggingface.co/MyHeartCounts/openmhc-dlinear-7day-imp",
"fedformer": "https://huggingface.co/MyHeartCounts/openmhc-fedformer-imp",
"lsm2": "https://huggingface.co/MyHeartCounts/openmhc-lsm2-daily",
"lsm2_weekly": "https://huggingface.co/MyHeartCounts/openmhc-lsm2-weekly",
"lsm2_weekly_sparse": "https://huggingface.co/MyHeartCounts/openmhc-lsm2-weekly-sparse",
"timesnet": "https://huggingface.co/MyHeartCounts/openmhc-timesnet-imp",
}
def load_substrate(track: str = "imputation") -> tuple[pd.DataFrame, str]:
"""Download + concat every method's per-user substrate parquet for a track.
Returns ``(df, local_root)``; the per-method display sidecars
(``<method>.meta.json``) are downloaded alongside into ``local_root``.
"""
from huggingface_hub import snapshot_download
root = snapshot_download(
DATASET_REPO,
repo_type="dataset",
allow_patterns=[f"{track}/*.parquet", f"{track}/*.meta.json"],
)
files = sorted(glob.glob(os.path.join(root, track, "*.parquet")))
if not files:
raise RuntimeError(f"No substrate under {track}/ in {DATASET_REPO}")
df = pd.concat((pd.read_parquet(f) for f in files), ignore_index=True)
return df, root
def read_method_meta(root: str, track: str, method: str) -> tuple[str, str, str, str, float | None]:
"""Return ``(display_name, type, submitter, subtrack, fallback_rate)`` from the sidecar.
``fallback_rate`` is the fraction of predictions substituted by the fallback
baseline (``None`` when the sidecar omits it). Falls back to
``(raw_id, "—", "—", "other", None)`` when no sidecar is present.
"""
path = os.path.join(root, track, f"{method}.meta.json")
if os.path.exists(path):
with open(path) as f:
m = json.load(f)
fr = m.get("fallback_rate")
return (
m.get("display_name", method),
m.get("type", "—"),
m.get("submitter", "—"),
m.get("subtrack", "other"),
float(fr) if isinstance(fr, (int, float)) else None,
)
return method, "—", "—", "other", None
def compute_imputation_rows() -> list[dict]:
"""Return leaderboard rows (sorted by skill desc) for the imputation track."""
from imputation_evaluation.evaluation.bootstrap_skill_rank import (
compute_per_task_paired_R,
)
from imputation_evaluation.evaluation.paper_metrics_core import (
compute_average_rankings,
compute_fair_skill_scores,
compute_skill_scores,
)
df_full, root = load_substrate("imputation")
df = df_full.rename(columns={"E_per_user": "E"})
# skill + rank use the global ("all") cell; demographic cells are for fairness.
df_all = df[df["subgroup_attr"] == "all"].copy()
methods = sorted(df_all["method"].unique())
per_task_r = compute_per_task_paired_R(df_all, baseline_method=BASELINE)
skill = compute_skill_scores(per_task_r, mode="paired") # [method, scope, skill_score, ...]
ranks = compute_average_rankings(df_all) # [method, scope, avg_rank, ...]
# Disparity-ratio fair skill (MAPD across age_group + sex). The reducer
# expects per-cell mean errors; feeding raw per-user rows triggers a
# cartesian-merge blow-up, so collapse users to the cell mean first.
per_cell = df.groupby(PER_CELL_COLS, observed=True)["E"].mean().reset_index()
fair = compute_fair_skill_scores(per_cell, baseline_method=BASELINE) # [method, scope, fair_skill_score, ...]
def skill_at(method: str, scope: str) -> float | None:
hit = skill[(skill["method"] == method) & (skill["scope"] == scope)]
if not hit.empty:
return float(hit["skill_score"].iloc[0])
return 0.0 if method == BASELINE else None # baseline-vs-self -> 0
def rank_at(method: str, scope: str) -> float | None:
hit = ranks[(ranks["method"] == method) & (ranks["scope"] == scope)]
return float(hit["avg_rank"].iloc[0]) if not hit.empty else None
def fair_at(method: str, scope: str) -> float | None:
hit = fair[(fair["method"] == method) & (fair["scope"] == scope)]
if not hit.empty:
return float(hit["fair_skill_score"].iloc[0])
return 0.0 if method == BASELINE else None # baseline-vs-self -> 0
rows = []
for m in methods:
name, mtype, submitter, subtrack, fallback = read_method_meta(root, "imputation", m)
rows.append(
{
"method": name,
"mtype": mtype,
"submitter": submitter,
"subtrack": subtrack,
"model_url": MODEL_URLS.get(m),
"skill": skill_at(m, HEADLINE_SCOPE),
"fair_skill": fair_at(m, HEADLINE_SCOPE),
"rank": rank_at(m, HEADLINE_SCOPE),
"fallback": fallback,
**{col: skill_at(m, scope) for scope, col in SUBGROUP_SCOPES.items()},
}
)
rows.sort(key=lambda r: (r["skill"] is None, -(r["skill"] or 0.0)))
return rows
# ---------------------------------------------------------------------------
# Track 3 — forecasting
# ---------------------------------------------------------------------------
FC_BASELINE = "seasonal_naive"
# Published HF checkpoints (MyHeartCounts org), keyed by internal method id. Only
# methods with a trained MyHeartCounts checkpoint appear: the deep-learning models
# and the *fine-tuned* foundation variants. Zero-shot foundation runs use the
# off-the-shelf base model (no MyHeartCounts checkpoint), and the statistical
# baselines (seasonal_naive / autoARIMA / autoETS) have none.
FC_MODEL_URLS: dict[str, str] = {
"chronos2_finetuned": "https://huggingface.co/MyHeartCounts/openmhc-chronos2-fc",
"toto_finetuned_ctx4096": "https://huggingface.co/MyHeartCounts/openmhc-toto-fc",
"dlinear": "https://huggingface.co/MyHeartCounts/openmhc-dlinear-fc",
"mixlinear": "https://huggingface.co/MyHeartCounts/openmhc-mixlinear-fc",
"segrnn": "https://huggingface.co/MyHeartCounts/openmhc-segrnn-fc",
}
def _load_demographics() -> dict[str, dict[str, str]]:
"""Build ``{user_id: {age_group, sex}}`` from the imputation substrate.
The forecasting per-user substrate carries no demographic columns, but every
forecasting user is also in the imputation substrate (which bakes per-user
``age_group`` / ``sex`` subgroup rows under the canonical 18/30/40/50/60 age
bins). Reusing that partition lets forecasting fairness use the *identical*
user->subgroup mapping as imputation fairness, with no extra artifact.
"""
from huggingface_hub import hf_hub_download
path = hf_hub_download(DATASET_REPO, "imputation/locf.parquet", repo_type="dataset")
imp = pd.read_parquet(path, columns=["subgroup_attr", "subgroup_value", "user_id"])
demo: dict[str, dict[str, str]] = {}
for attr in ("age_group", "sex"):
sub = imp[imp["subgroup_attr"].astype(str) == attr][
["user_id", "subgroup_value"]
].drop_duplicates()
for uid, val in sub.itertuples(index=False):
demo.setdefault(str(uid), {})[attr] = str(val)
return demo
def compute_forecasting_rows() -> list[dict]:
"""Return leaderboard rows (sorted by skill desc) for the forecasting track."""
from forecasting_evaluation.metrics import metric_spec as spec
from forecasting_evaluation.metrics.fair_skill_score import (
compute_fair_skill_scores_from_errors,
)
from forecasting_evaluation.metrics.grouped_metric_rank_summary import (
build_grouped_metric_rank_tables,
)
from forecasting_evaluation.metrics.per_user_errors import to_error_df
from forecasting_evaluation.metrics.skill_score_summary import compute_skill_score_tables
df, root = load_substrate("forecasting")
methods = sorted(df["model"].astype(str).unique())
models = {m: {"path": "", "display_name": m} for m in methods}
# Skill: overall (category-balanced) + per-sensor-category scopes, vs the
# Seasonal Naive baseline. summary_df is one row per model with *_score cols.
_, summary, _ = compute_skill_score_tables(
models=models,
baseline_model=FC_BASELINE,
continuous_metrics=list(spec.PAPER_CONTINUOUS_METRICS),
binary_metrics=list(spec.PAPER_BINARY_METRICS),
continuous_channel_indices=spec.CONTINUOUS_CHANNELS,
binary_channel_indices=spec.BINARY_CHANNELS,
clip_lower=spec.PAPER_CLIP_LOWER,
clip_upper=spec.PAPER_CLIP_UPPER,
min_pairs=spec.PAPER_MIN_PAIRS,
aggregation_unit="user",
within_user_aggregation="micro",
per_user_metrics=df,
)
summary["model"] = summary["model"].astype(str)
summary = summary.set_index("model")
# Cross-method average rank; the headline is the category-balanced "overall".
_, rank_long, _ = build_grouped_metric_rank_tables(
models=models,
continuous_metrics=list(spec.PAPER_CONTINUOUS_METRICS),
binary_metrics=list(spec.PAPER_BINARY_METRICS),
continuous_channel_indices=spec.CONTINUOUS_CHANNELS,
binary_groups=[(name, tuple(idx)) for name, idx in spec.BINARY_GROUPS],
within_user_aggregation="micro",
per_user_metrics=df,
)
rank_long["model"] = rank_long["model"].astype(str)
overall_rank = rank_long[rank_long["scope"] == "overall"].set_index("model")["rank"]
# Disparity-ratio (MAPD) fair skill across age_group + sex. Demographics come
# from the imputation substrate (same user->subgroup partition); the reducer
# is the canonical forecasting one (point estimate == leaderboard value).
err = to_error_df(df, user_col="user_id")
fair = compute_fair_skill_scores_from_errors(
err, _load_demographics(), baseline_method=FC_BASELINE
)
fair_overall = fair[fair["scope"] == "overall"].set_index("model")["fair_skill_score"]
def num(value) -> float | None:
if value is None or (isinstance(value, float) and pd.isna(value)):
return None
return float(value)
rows = []
for m in methods:
name, mtype, submitter, subtrack, fallback = read_method_meta(root, "forecasting", m)
srow = summary.loc[m] if m in summary.index else None
rows.append(
{
"method": name,
"mtype": mtype,
"submitter": submitter,
"subtrack": subtrack,
"model_url": FC_MODEL_URLS.get(m),
"skill": num(srow["overall_score"]) if srow is not None else None,
"fair_skill": num(fair_overall.get(m)),
"rank": num(overall_rank.get(m)),
"activity": num(srow["activity_score"]) if srow is not None else None,
"physiology": num(srow["physiology_score"]) if srow is not None else None,
"sleep": num(srow["sleep_score"]) if srow is not None else None,
"workout": num(srow["workout_score"]) if srow is not None else None,
"fallback": num(fallback),
}
)
rows.sort(key=lambda r: (r["skill"] is None, -(r["skill"] or 0.0)))
return rows
# ---------------------------------------------------------------------------
# Track 1 — predictive tasks (downstream)
# ---------------------------------------------------------------------------
DOWNSTREAM_BASELINE = "linear"
# skill-score scope -> leaderboard subgroup column
DOWNSTREAM_SUBGROUP_SCOPES = {
"Demographics": "demographics",
"Medical conditions": "conditions",
"Body metrics and biomarkers": "vitals",
"Mental well-being": "mental",
"Sleep and lifestyle": "lifestyle",
}
# Display grouping + model link per OpenMHC-team method. The substrate sidecar's `type`
# is the raw model family; the board groups methods into these display categories. A
# new submitter's numbers come straight from the reduced substrate; their label/link
# default to their own sidecar `type` + no url.
DOWNSTREAM_META = {
"lsm2": ("Wearable Foundation Models", "https://huggingface.co/MyHeartCounts/openmhc-lsm2-daily"),
"wbm": ("Wearable Foundation Models", "https://huggingface.co/MyHeartCounts/openmhc-wbm-dp"),
"gru_d": ("Supervised Neural Models", None),
"xgboost": ("Statistical Models", None),
"multirocket": ("Statistical Models", None),
"linear": ("Statistical Models", None),
"chronos2": ("Time-Series Foundation Models", "https://huggingface.co/MyHeartCounts/openmhc-chronos2-fc"),
"toto": ("Time-Series Foundation Models", "https://huggingface.co/MyHeartCounts/openmhc-toto-fc"),
}
def compute_downstream_rows() -> list[dict]:
"""Return leaderboard rows (sorted by skill desc) for the Track-1 (predictive tasks) track.
Reduces the per-user-pairs substrate live — point skill / rank / disparity-ratio
fairness vs the Linear baseline — mirroring the imputation/forecasting tracks, so a
newly-uploaded substrate appears without a maintainer regenerating a precomputed
rows file. Point only (the bootstrap CIs live under downstream/bootstrap/); reuses
the paper's reducers, so the board equals the paper's point estimate.
"""
from downstream_evaluation.evaluation.bootstrap_skill_rank import reduce_substrate_to_point
df, root = load_substrate("downstream")
point = reduce_substrate_to_point(df, baseline=DOWNSTREAM_BASELINE)
rows = []
for m in sorted(point):
name, meta_type, submitter, subtrack, fallback = read_method_meta(root, "downstream", m)
mtype, model_url = DOWNSTREAM_META.get(m, (meta_type, None))
skill = point[m]["skill"]
rows.append({
"method": name,
"mtype": mtype,
"submitter": submitter,
"subtrack": subtrack,
"model_url": model_url,
"skill": skill.get("Overall"),
"fair_skill": point[m]["fair_skill"],
"rank": point[m]["rank"].get("Overall"),
"fallback": fallback,
**{col: skill.get(scope) for scope, col in DOWNSTREAM_SUBGROUP_SCOPES.items()},
})
rows.sort(key=lambda r: (r["skill"] is None, -(r["skill"] or 0.0)))
return rows
if __name__ == "__main__":
import json
print(json.dumps({
"downstream": compute_downstream_rows(),
"imputation": compute_imputation_rows(),
"forecasting": compute_forecasting_rows(),
}, indent=2))