Spaces:
Running
Running
Add Track 3 (forecasting) leaderboard section: live skill/rank/fair-skill vs seasonal_naive; pin openmhc@991caf5
Browse files- Dockerfile +5 -3
- app.py +146 -88
- leaderboard_compute.py +123 -1
Dockerfile
CHANGED
|
@@ -14,13 +14,15 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|
| 14 |
RUN apt-get update && apt-get install -y --no-install-recommends git \
|
| 15 |
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
|
| 17 |
-
# Canonical point-flow reducers (skill / rank / fair skill)
|
|
|
|
| 18 |
# deps so the heavy base deps (torch, xgboost, ...) are skipped — only the slim
|
| 19 |
# runtime set in requirements.txt is used. Pinned to a commit (current main
|
| 20 |
# HEAD) for reproducibility + reliable Docker-layer cache busting; bump this
|
| 21 |
-
# when the reducers change. main@
|
|
|
|
| 22 |
RUN pip install --no-cache-dir --no-deps \
|
| 23 |
-
"openmhc @ git+https://github.com/AshleyLab/myheartcounts-dataset@
|
| 24 |
|
| 25 |
COPY app.py leaderboard_compute.py logo.png ./
|
| 26 |
|
|
|
|
| 14 |
RUN apt-get update && apt-get install -y --no-install-recommends git \
|
| 15 |
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
|
| 17 |
+
# Canonical point-flow reducers (skill / rank / fair skill) for both the
|
| 18 |
+
# imputation (Track 2) and forecasting (Track 3) leaderboards. Installed WITHOUT
|
| 19 |
# deps so the heavy base deps (torch, xgboost, ...) are skipped — only the slim
|
| 20 |
# runtime set in requirements.txt is used. Pinned to a commit (current main
|
| 21 |
# HEAD) for reproducibility + reliable Docker-layer cache busting; bump this
|
| 22 |
+
# when the reducers change. main@991caf5 adds the unified forecasting per-user
|
| 23 |
+
# substrate reducers (PR #35).
|
| 24 |
RUN pip install --no-cache-dir --no-deps \
|
| 25 |
+
"openmhc @ git+https://github.com/AshleyLab/myheartcounts-dataset@991caf54fc728b8f0b53873f3bc589ac3cb3c1bd"
|
| 26 |
|
| 27 |
COPY app.py leaderboard_compute.py logo.png ./
|
| 28 |
|
app.py
CHANGED
|
@@ -18,15 +18,18 @@ from pathlib import Path
|
|
| 18 |
from fastapi import FastAPI
|
| 19 |
from fastapi.responses import FileResponse, HTMLResponse
|
| 20 |
|
| 21 |
-
from leaderboard_compute import compute_imputation_rows
|
| 22 |
|
| 23 |
app = FastAPI(title="OpenMHC Leaderboard", docs_url=None, redoc_url=None)
|
| 24 |
|
| 25 |
-
# Compute is mildly expensive (download + reduce); cache
|
| 26 |
-
_CACHE: dict = {
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
# (row key, header label). Order + arrows mirror the paper
|
| 29 |
-
|
| 30 |
("method", "Method"),
|
| 31 |
("mtype", "Type"),
|
| 32 |
("rank", "R ↓"),
|
|
@@ -39,12 +42,42 @@ COLUMNS = [
|
|
| 39 |
("semantic", "Semantic ↑"),
|
| 40 |
("submitter", "Submitter"),
|
| 41 |
]
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
]
|
| 49 |
|
| 50 |
CODE_URL = "https://github.com/AshleyLab/myheartcounts-dataset"
|
|
@@ -141,8 +174,8 @@ code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em;colo
|
|
| 141 |
|
| 142 |
<div class="hero">
|
| 143 |
<p class="eyebrow">OpenMHC Benchmark</p>
|
| 144 |
-
<h1>
|
| 145 |
-
<p class="lede">
|
| 146 |
<div class="pills">
|
| 147 |
<a class="pill" href="%%CODE%%">⚙️ Code</a>
|
| 148 |
<span class="pill disabled">📊 Dataset · coming soon</span>
|
|
@@ -152,76 +185,74 @@ code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em;colo
|
|
| 152 |
</div>
|
| 153 |
</div>
|
| 154 |
|
| 155 |
-
|
| 156 |
-
<p class="section-title">Track 2a · Imputation</p>
|
| 157 |
-
%%CONTENT%%
|
| 158 |
-
<p class="note">Skill (S) & fairness (S_fair) are % error reduction vs the LOCF baseline (paired per-user geometric mean); R is the mean cross-method rank (lower is better). Computed live from <code>MyHeartCounts/OpenMHC-leaderboard-data</code>.</p>
|
| 159 |
-
</div>
|
| 160 |
</div>
|
| 161 |
<script>
|
| 162 |
(function(){
|
| 163 |
-
var table=document.querySelector('table');
|
| 164 |
-
if(!table) return;
|
| 165 |
-
var ths=table.querySelectorAll('thead th');
|
| 166 |
-
var cur={i:-1,d:1};
|
| 167 |
function cell(td){
|
| 168 |
if('v' in td.dataset){var n=parseFloat(td.dataset.v);return {num:true,v:isNaN(n)?null:n};}
|
| 169 |
return {num:false,v:td.textContent.trim().toLowerCase()};
|
| 170 |
}
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
});
|
| 183 |
-
|
| 184 |
-
});
|
| 185 |
-
}
|
| 186 |
-
|
| 187 |
-
// Type column: checkbox filter (does NOT sort). Others: click to sort.
|
| 188 |
-
var boxes=table.querySelectorAll('input.tfilter');
|
| 189 |
-
function applyFilter(){
|
| 190 |
-
var checked={}, any=false;
|
| 191 |
-
boxes.forEach(function(cb){ if(cb.checked){checked[cb.value]=1; any=true;} });
|
| 192 |
-
var partial=any && checked && (Object.keys(checked).length < boxes.length);
|
| 193 |
-
document.querySelectorAll('tr.mrow').forEach(function(r){
|
| 194 |
-
r.style.display=(!any||checked[r.dataset.type])?'':'none';
|
| 195 |
-
});
|
| 196 |
-
table.querySelectorAll('tbody').forEach(function(tb){
|
| 197 |
-
var vis=[].some.call(tb.querySelectorAll('tr.mrow'),function(r){return r.style.display!=='none';});
|
| 198 |
-
var sec=tb.querySelector('tr.sec'); if(sec) sec.style.display=vis?'':'none';
|
| 199 |
-
});
|
| 200 |
-
table.querySelectorAll('.filter-th').forEach(function(t){t.classList.toggle('active', partial);});
|
| 201 |
-
}
|
| 202 |
-
boxes.forEach(function(cb){ cb.addEventListener('change', applyFilter); });
|
| 203 |
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
});
|
| 210 |
-
|
| 211 |
}
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
});
|
| 220 |
});
|
| 221 |
|
|
|
|
| 222 |
document.addEventListener('click',function(e){
|
| 223 |
if(!e.target.closest('.filter-th')){
|
| 224 |
-
|
| 225 |
}
|
| 226 |
});
|
| 227 |
})();
|
|
@@ -231,13 +262,14 @@ code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em;colo
|
|
| 231 |
"""
|
| 232 |
|
| 233 |
|
| 234 |
-
def _rows() -> tuple[list[dict] | None, str | None]:
|
| 235 |
-
|
|
|
|
| 236 |
try:
|
| 237 |
-
|
| 238 |
except Exception as e: # surface the failure on the page; don't hide it
|
| 239 |
-
|
| 240 |
-
return
|
| 241 |
|
| 242 |
|
| 243 |
# Per-column heatmap (paper-style blue gradient): better -> more saturated blue.
|
|
@@ -297,9 +329,9 @@ def _skill_key(r: dict):
|
|
| 297 |
return (s is None, -(s if s is not None else 0.0))
|
| 298 |
|
| 299 |
|
| 300 |
-
def _method_row(r: dict, section_key: str, stats: dict) -> str:
|
| 301 |
cells = []
|
| 302 |
-
for k, _ in
|
| 303 |
if k == "method":
|
| 304 |
name = html.escape(str(r.get(k)))
|
| 305 |
url = r.get("model_url")
|
|
@@ -335,12 +367,21 @@ def _th(key: str, label: str, type_values: list[str]) -> str:
|
|
| 335 |
return f'<th data-def="{default}">{html.escape(label)}</th>'
|
| 336 |
|
| 337 |
|
| 338 |
-
def _table(rows: list[dict]) -> str:
|
|
|
|
| 339 |
type_values = sorted({str(r["mtype"]) for r in rows if r.get("mtype")})
|
| 340 |
-
head = "".join(_th(k, label, type_values) for k, label in
|
| 341 |
stats = _col_stats(rows)
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
if any(r.get("subtrack") not in known for r in rows):
|
| 345 |
sections = sections + [("other", "Other")]
|
| 346 |
|
|
@@ -353,23 +394,34 @@ def _table(rows: list[dict]) -> str:
|
|
| 353 |
if not sub_rows:
|
| 354 |
continue
|
| 355 |
sub_rows = sorted(sub_rows, key=_skill_key) # default order; JS re-sorts on click
|
| 356 |
-
sec = f'<tr class="sec"><td colspan="{
|
| 357 |
-
mrows = "".join(_method_row(r, key, stats) for r in sub_rows)
|
| 358 |
tbodies += f'<tbody data-subtrack="{html.escape(key)}">{sec}{mrows}</tbody>'
|
| 359 |
|
| 360 |
table = f'<div class="card"><table><thead><tr>{head}</tr></thead>{tbodies}</table></div>'
|
| 361 |
return table
|
| 362 |
|
| 363 |
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
rows, error = _rows()
|
| 367 |
if error is not None:
|
| 368 |
-
|
| 369 |
elif not rows:
|
| 370 |
-
|
| 371 |
else:
|
| 372 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
return (
|
| 374 |
PAGE.replace("%%CONTENT%%", content)
|
| 375 |
.replace("%%CODE%%", CODE_URL)
|
|
@@ -380,8 +432,14 @@ def index() -> str:
|
|
| 380 |
|
| 381 |
@app.get("/health")
|
| 382 |
def health() -> dict:
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
|
| 386 |
|
| 387 |
@app.get("/logo.png")
|
|
|
|
| 18 |
from fastapi import FastAPI
|
| 19 |
from fastapi.responses import FileResponse, HTMLResponse
|
| 20 |
|
| 21 |
+
from leaderboard_compute import compute_forecasting_rows, compute_imputation_rows
|
| 22 |
|
| 23 |
app = FastAPI(title="OpenMHC Leaderboard", docs_url=None, redoc_url=None)
|
| 24 |
|
| 25 |
+
# Compute is mildly expensive (download + reduce); cache per track in-process.
|
| 26 |
+
_CACHE: dict = {
|
| 27 |
+
"imputation": {"rows": None, "error": None},
|
| 28 |
+
"forecasting": {"rows": None, "error": None},
|
| 29 |
+
}
|
| 30 |
|
| 31 |
+
# (row key, header label). Order + arrows mirror the paper tables.
|
| 32 |
+
_IMPUTATION_COLUMNS = [
|
| 33 |
("method", "Method"),
|
| 34 |
("mtype", "Type"),
|
| 35 |
("rank", "R ↓"),
|
|
|
|
| 42 |
("semantic", "Semantic ↑"),
|
| 43 |
("submitter", "Submitter"),
|
| 44 |
]
|
| 45 |
+
# Forecasting has the same shape minus the imputation-only "semantic" scope.
|
| 46 |
+
_FORECASTING_COLUMNS = [c for c in _IMPUTATION_COLUMNS if c[0] != "semantic"]
|
| 47 |
+
|
| 48 |
+
_IMPUTATION_NOTE = (
|
| 49 |
+
"Skill (S) & fairness (S_fair) are % error reduction vs the LOCF baseline "
|
| 50 |
+
"(paired per-user geometric mean); R is the mean cross-method rank (lower is "
|
| 51 |
+
"better). Computed live from <code>MyHeartCounts/OpenMHC-leaderboard-data</code>."
|
| 52 |
+
)
|
| 53 |
+
_FORECASTING_NOTE = (
|
| 54 |
+
"Skill (S) & fairness (S_fair) are % error reduction vs the Seasonal Naive "
|
| 55 |
+
"baseline (paired per-user geometric mean, category-balanced); R is the mean "
|
| 56 |
+
"cross-method rank (lower is better). Computed live from "
|
| 57 |
+
"<code>MyHeartCounts/OpenMHC-leaderboard-data</code>."
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# Ordered leaderboard sections rendered on the page (one table each).
|
| 61 |
+
TRACKS = [
|
| 62 |
+
{
|
| 63 |
+
"key": "imputation",
|
| 64 |
+
"compute": compute_imputation_rows,
|
| 65 |
+
"columns": _IMPUTATION_COLUMNS,
|
| 66 |
+
"subtracks": [
|
| 67 |
+
("single-day", "Single-day imputation"),
|
| 68 |
+
("long-context", "Long-context imputation (≥ 7×1440 time steps)"),
|
| 69 |
+
],
|
| 70 |
+
"title": "Track 2a · Imputation",
|
| 71 |
+
"note": _IMPUTATION_NOTE,
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"key": "forecasting",
|
| 75 |
+
"compute": compute_forecasting_rows,
|
| 76 |
+
"columns": _FORECASTING_COLUMNS,
|
| 77 |
+
"subtracks": [],
|
| 78 |
+
"title": "Track 3 · Forecasting",
|
| 79 |
+
"note": _FORECASTING_NOTE,
|
| 80 |
+
},
|
| 81 |
]
|
| 82 |
|
| 83 |
CODE_URL = "https://github.com/AshleyLab/myheartcounts-dataset"
|
|
|
|
| 174 |
|
| 175 |
<div class="hero">
|
| 176 |
<p class="eyebrow">OpenMHC Benchmark</p>
|
| 177 |
+
<h1>OpenMHC Leaderboard</h1>
|
| 178 |
+
<p class="lede">Wearable & mobile health benchmark on MyHeartCounts. Track 2a (imputation) reconstructs masked daily, minute-level signals; Track 3 (forecasting) predicts future hourly signals. Each method is ranked by skill score vs a track baseline, computed live from the per-user evaluation substrate.</p>
|
| 179 |
<div class="pills">
|
| 180 |
<a class="pill" href="%%CODE%%">⚙️ Code</a>
|
| 181 |
<span class="pill disabled">📊 Dataset · coming soon</span>
|
|
|
|
| 185 |
</div>
|
| 186 |
</div>
|
| 187 |
|
| 188 |
+
%%CONTENT%%
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
</div>
|
| 190 |
<script>
|
| 191 |
(function(){
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
function cell(td){
|
| 193 |
if('v' in td.dataset){var n=parseFloat(td.dataset.v);return {num:true,v:isNaN(n)?null:n};}
|
| 194 |
return {num:false,v:td.textContent.trim().toLowerCase()};
|
| 195 |
}
|
| 196 |
+
// Each table (one per track) gets its own independent sort + Type filter.
|
| 197 |
+
document.querySelectorAll('table').forEach(function(table){
|
| 198 |
+
var ths=table.querySelectorAll('thead th');
|
| 199 |
+
var cur={i:-1,d:1};
|
| 200 |
+
function sortBy(i,d){
|
| 201 |
+
table.querySelectorAll('tbody').forEach(function(tb){
|
| 202 |
+
var rows=[].slice.call(tb.querySelectorAll('tr.mrow'));
|
| 203 |
+
rows.sort(function(a,b){
|
| 204 |
+
var A=cell(a.children[i]),B=cell(b.children[i]);
|
| 205 |
+
if(A.num){
|
| 206 |
+
if(A.v===null&&B.v===null)return 0;
|
| 207 |
+
if(A.v===null)return 1; if(B.v===null)return -1;
|
| 208 |
+
return (A.v-B.v)*d;
|
| 209 |
+
}
|
| 210 |
+
return String(A.v).localeCompare(String(B.v))*d;
|
| 211 |
+
});
|
| 212 |
+
rows.forEach(function(r){tb.appendChild(r);});
|
| 213 |
});
|
| 214 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
+
// Type column: checkbox filter (does NOT sort). Others: click to sort.
|
| 217 |
+
var boxes=table.querySelectorAll('input.tfilter');
|
| 218 |
+
function applyFilter(){
|
| 219 |
+
var checked={}, any=false;
|
| 220 |
+
boxes.forEach(function(cb){ if(cb.checked){checked[cb.value]=1; any=true;} });
|
| 221 |
+
var partial=any && (Object.keys(checked).length < boxes.length);
|
| 222 |
+
table.querySelectorAll('tr.mrow').forEach(function(r){
|
| 223 |
+
r.style.display=(!any||checked[r.dataset.type])?'':'none';
|
| 224 |
+
});
|
| 225 |
+
table.querySelectorAll('tbody').forEach(function(tb){
|
| 226 |
+
var vis=[].some.call(tb.querySelectorAll('tr.mrow'),function(r){return r.style.display!=='none';});
|
| 227 |
+
var sec=tb.querySelector('tr.sec'); if(sec) sec.style.display=vis?'':'none';
|
| 228 |
});
|
| 229 |
+
table.querySelectorAll('.filter-th').forEach(function(t){t.classList.toggle('active', partial);});
|
| 230 |
}
|
| 231 |
+
boxes.forEach(function(cb){ cb.addEventListener('change', applyFilter); });
|
| 232 |
+
|
| 233 |
+
ths.forEach(function(th,i){
|
| 234 |
+
if(th.dataset.filter){ // Type = filter dropdown, not sortable
|
| 235 |
+
th.addEventListener('click',function(e){
|
| 236 |
+
if(e.target.closest('.dropdown')) return; // clicks inside menu don't toggle
|
| 237 |
+
th.classList.toggle('open');
|
| 238 |
+
});
|
| 239 |
+
return;
|
| 240 |
+
}
|
| 241 |
+
th.classList.add('sortable');
|
| 242 |
+
th.addEventListener('click',function(){
|
| 243 |
+
var d=(cur.i===i)?-cur.d:(th.dataset.def==='asc'?1:-1);
|
| 244 |
+
cur={i:i,d:d};
|
| 245 |
+
ths.forEach(function(h){h.classList.remove('s-asc','s-desc');});
|
| 246 |
+
th.classList.add(d===1?'s-asc':'s-desc');
|
| 247 |
+
sortBy(i,d);
|
| 248 |
+
});
|
| 249 |
});
|
| 250 |
});
|
| 251 |
|
| 252 |
+
// One global handler closes any open Type dropdown across all tables.
|
| 253 |
document.addEventListener('click',function(e){
|
| 254 |
if(!e.target.closest('.filter-th')){
|
| 255 |
+
document.querySelectorAll('.filter-th.open').forEach(function(t){t.classList.remove('open');});
|
| 256 |
}
|
| 257 |
});
|
| 258 |
})();
|
|
|
|
| 262 |
"""
|
| 263 |
|
| 264 |
|
| 265 |
+
def _rows(track_key: str, compute) -> tuple[list[dict] | None, str | None]:
|
| 266 |
+
cache = _CACHE[track_key]
|
| 267 |
+
if cache["rows"] is None and cache["error"] is None:
|
| 268 |
try:
|
| 269 |
+
cache["rows"] = compute()
|
| 270 |
except Exception as e: # surface the failure on the page; don't hide it
|
| 271 |
+
cache["error"] = f"{type(e).__name__}: {e}"
|
| 272 |
+
return cache["rows"], cache["error"]
|
| 273 |
|
| 274 |
|
| 275 |
# Per-column heatmap (paper-style blue gradient): better -> more saturated blue.
|
|
|
|
| 329 |
return (s is None, -(s if s is not None else 0.0))
|
| 330 |
|
| 331 |
|
| 332 |
+
def _method_row(r: dict, section_key: str, stats: dict, columns: list) -> str:
|
| 333 |
cells = []
|
| 334 |
+
for k, _ in columns:
|
| 335 |
if k == "method":
|
| 336 |
name = html.escape(str(r.get(k)))
|
| 337 |
url = r.get("model_url")
|
|
|
|
| 367 |
return f'<th data-def="{default}">{html.escape(label)}</th>'
|
| 368 |
|
| 369 |
|
| 370 |
+
def _table(rows: list[dict], columns: list, subtracks: list) -> str:
|
| 371 |
+
ncols = len(columns)
|
| 372 |
type_values = sorted({str(r["mtype"]) for r in rows if r.get("mtype")})
|
| 373 |
+
head = "".join(_th(k, label, type_values) for k, label in columns)
|
| 374 |
stats = _col_stats(rows)
|
| 375 |
+
|
| 376 |
+
if not subtracks:
|
| 377 |
+
# Single ungrouped table (no sub-track headers).
|
| 378 |
+
sub_rows = sorted(rows, key=_skill_key) # default order; JS re-sorts on click
|
| 379 |
+
mrows = "".join(_method_row(r, "all", stats, columns) for r in sub_rows)
|
| 380 |
+
tbodies = f'<tbody data-subtrack="all">{mrows}</tbody>'
|
| 381 |
+
return f'<div class="card"><table><thead><tr>{head}</tr></thead>{tbodies}</table></div>'
|
| 382 |
+
|
| 383 |
+
known = {k for k, _ in subtracks}
|
| 384 |
+
sections = list(subtracks)
|
| 385 |
if any(r.get("subtrack") not in known for r in rows):
|
| 386 |
sections = sections + [("other", "Other")]
|
| 387 |
|
|
|
|
| 394 |
if not sub_rows:
|
| 395 |
continue
|
| 396 |
sub_rows = sorted(sub_rows, key=_skill_key) # default order; JS re-sorts on click
|
| 397 |
+
sec = f'<tr class="sec"><td colspan="{ncols}">{html.escape(label)}</td></tr>'
|
| 398 |
+
mrows = "".join(_method_row(r, key, stats, columns) for r in sub_rows)
|
| 399 |
tbodies += f'<tbody data-subtrack="{html.escape(key)}">{sec}{mrows}</tbody>'
|
| 400 |
|
| 401 |
table = f'<div class="card"><table><thead><tr>{head}</tr></thead>{tbodies}</table></div>'
|
| 402 |
return table
|
| 403 |
|
| 404 |
|
| 405 |
+
def _render_section(cfg: dict) -> str:
|
| 406 |
+
rows, error = _rows(cfg["key"], cfg["compute"])
|
|
|
|
| 407 |
if error is not None:
|
| 408 |
+
body = f'<div class="err">Failed to compute leaderboard:\n{html.escape(error)}</div>'
|
| 409 |
elif not rows:
|
| 410 |
+
body = '<div class="err">No methods found in the substrate dataset.</div>'
|
| 411 |
else:
|
| 412 |
+
body = _table(rows, cfg["columns"], cfg["subtracks"])
|
| 413 |
+
return (
|
| 414 |
+
'<div class="section">'
|
| 415 |
+
f'<p class="section-title">{html.escape(cfg["title"])}</p>'
|
| 416 |
+
f"{body}"
|
| 417 |
+
f'<p class="note">{cfg["note"]}</p>'
|
| 418 |
+
"</div>"
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
@app.get("/", response_class=HTMLResponse)
|
| 423 |
+
def index() -> str:
|
| 424 |
+
content = "\n".join(_render_section(cfg) for cfg in TRACKS)
|
| 425 |
return (
|
| 426 |
PAGE.replace("%%CONTENT%%", content)
|
| 427 |
.replace("%%CODE%%", CODE_URL)
|
|
|
|
| 432 |
|
| 433 |
@app.get("/health")
|
| 434 |
def health() -> dict:
|
| 435 |
+
tracks: dict = {}
|
| 436 |
+
ok = True
|
| 437 |
+
for cfg in TRACKS:
|
| 438 |
+
rows, error = _rows(cfg["key"], cfg["compute"])
|
| 439 |
+
tracks[cfg["key"]] = {"methods": len(rows or []), "error": error}
|
| 440 |
+
if error is not None:
|
| 441 |
+
ok = False
|
| 442 |
+
return {"status": "ok" if ok else "error", "tracks": tracks}
|
| 443 |
|
| 444 |
|
| 445 |
@app.get("/logo.png")
|
leaderboard_compute.py
CHANGED
|
@@ -154,7 +154,129 @@ def compute_imputation_rows() -> list[dict]:
|
|
| 154 |
return rows
|
| 155 |
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
if __name__ == "__main__":
|
| 158 |
import json
|
| 159 |
|
| 160 |
-
print(json.dumps(
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
return rows
|
| 155 |
|
| 156 |
|
| 157 |
+
# ---------------------------------------------------------------------------
|
| 158 |
+
# Track 3 — forecasting
|
| 159 |
+
# ---------------------------------------------------------------------------
|
| 160 |
+
|
| 161 |
+
FC_BASELINE = "seasonal_naive"
|
| 162 |
+
# Forecasting has no published HF checkpoints wired yet; fill in as they land
|
| 163 |
+
# (keyed by internal method id, e.g. "dlinear": "https://huggingface.co/...").
|
| 164 |
+
FC_MODEL_URLS: dict[str, str] = {}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _load_demographics() -> dict[str, dict[str, str]]:
|
| 168 |
+
"""Build ``{user_id: {age_group, sex}}`` from the imputation substrate.
|
| 169 |
+
|
| 170 |
+
The forecasting per-user substrate carries no demographic columns, but every
|
| 171 |
+
forecasting user is also in the imputation substrate (which bakes per-user
|
| 172 |
+
``age_group`` / ``sex`` subgroup rows under the canonical 18/30/40/50/60 age
|
| 173 |
+
bins). Reusing that partition lets forecasting fairness use the *identical*
|
| 174 |
+
user->subgroup mapping as imputation fairness, with no extra artifact.
|
| 175 |
+
"""
|
| 176 |
+
from huggingface_hub import hf_hub_download
|
| 177 |
+
|
| 178 |
+
path = hf_hub_download(DATASET_REPO, "imputation/locf.parquet", repo_type="dataset")
|
| 179 |
+
imp = pd.read_parquet(path, columns=["subgroup_attr", "subgroup_value", "user_id"])
|
| 180 |
+
demo: dict[str, dict[str, str]] = {}
|
| 181 |
+
for attr in ("age_group", "sex"):
|
| 182 |
+
sub = imp[imp["subgroup_attr"].astype(str) == attr][
|
| 183 |
+
["user_id", "subgroup_value"]
|
| 184 |
+
].drop_duplicates()
|
| 185 |
+
for uid, val in sub.itertuples(index=False):
|
| 186 |
+
demo.setdefault(str(uid), {})[attr] = str(val)
|
| 187 |
+
return demo
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def compute_forecasting_rows() -> list[dict]:
|
| 191 |
+
"""Return leaderboard rows (sorted by skill desc) for the forecasting track."""
|
| 192 |
+
from forecasting_evaluation.metrics import metric_spec as spec
|
| 193 |
+
from forecasting_evaluation.metrics.fair_skill_score import (
|
| 194 |
+
compute_fair_skill_scores_from_errors,
|
| 195 |
+
)
|
| 196 |
+
from forecasting_evaluation.metrics.grouped_metric_rank_summary import (
|
| 197 |
+
build_grouped_metric_rank_tables,
|
| 198 |
+
)
|
| 199 |
+
from forecasting_evaluation.metrics.per_user_errors import to_error_df
|
| 200 |
+
from forecasting_evaluation.metrics.skill_score_summary import compute_skill_score_tables
|
| 201 |
+
|
| 202 |
+
df, root = load_substrate("forecasting")
|
| 203 |
+
methods = sorted(df["model"].astype(str).unique())
|
| 204 |
+
models = {m: {"path": "", "display_name": m} for m in methods}
|
| 205 |
+
|
| 206 |
+
# Skill: overall (category-balanced) + per-sensor-category scopes, vs the
|
| 207 |
+
# Seasonal Naive baseline. summary_df is one row per model with *_score cols.
|
| 208 |
+
_, summary, _ = compute_skill_score_tables(
|
| 209 |
+
models=models,
|
| 210 |
+
baseline_model=FC_BASELINE,
|
| 211 |
+
continuous_metrics=list(spec.PAPER_CONTINUOUS_METRICS),
|
| 212 |
+
binary_metrics=list(spec.PAPER_BINARY_METRICS),
|
| 213 |
+
continuous_channel_indices=spec.CONTINUOUS_CHANNELS,
|
| 214 |
+
binary_channel_indices=spec.BINARY_CHANNELS,
|
| 215 |
+
clip_lower=spec.PAPER_CLIP_LOWER,
|
| 216 |
+
clip_upper=spec.PAPER_CLIP_UPPER,
|
| 217 |
+
min_pairs=spec.PAPER_MIN_PAIRS,
|
| 218 |
+
aggregation_unit="user",
|
| 219 |
+
within_user_aggregation="micro",
|
| 220 |
+
per_user_metrics=df,
|
| 221 |
+
)
|
| 222 |
+
summary["model"] = summary["model"].astype(str)
|
| 223 |
+
summary = summary.set_index("model")
|
| 224 |
+
|
| 225 |
+
# Cross-method average rank; the headline is the category-balanced "overall".
|
| 226 |
+
_, rank_long, _ = build_grouped_metric_rank_tables(
|
| 227 |
+
models=models,
|
| 228 |
+
continuous_metrics=list(spec.PAPER_CONTINUOUS_METRICS),
|
| 229 |
+
binary_metrics=list(spec.PAPER_BINARY_METRICS),
|
| 230 |
+
continuous_channel_indices=spec.CONTINUOUS_CHANNELS,
|
| 231 |
+
binary_groups=[(name, tuple(idx)) for name, idx in spec.BINARY_GROUPS],
|
| 232 |
+
within_user_aggregation="micro",
|
| 233 |
+
per_user_metrics=df,
|
| 234 |
+
)
|
| 235 |
+
rank_long["model"] = rank_long["model"].astype(str)
|
| 236 |
+
overall_rank = rank_long[rank_long["scope"] == "overall"].set_index("model")["rank"]
|
| 237 |
+
|
| 238 |
+
# Disparity-ratio (MAPD) fair skill across age_group + sex. Demographics come
|
| 239 |
+
# from the imputation substrate (same user->subgroup partition); the reducer
|
| 240 |
+
# is the canonical forecasting one (point estimate == leaderboard value).
|
| 241 |
+
err = to_error_df(df, user_col="user_id")
|
| 242 |
+
fair = compute_fair_skill_scores_from_errors(
|
| 243 |
+
err, _load_demographics(), baseline_method=FC_BASELINE
|
| 244 |
+
)
|
| 245 |
+
fair_overall = fair[fair["scope"] == "overall"].set_index("model")["fair_skill_score"]
|
| 246 |
+
|
| 247 |
+
def num(value) -> float | None:
|
| 248 |
+
if value is None or (isinstance(value, float) and pd.isna(value)):
|
| 249 |
+
return None
|
| 250 |
+
return float(value)
|
| 251 |
+
|
| 252 |
+
rows = []
|
| 253 |
+
for m in methods:
|
| 254 |
+
name, mtype, submitter, subtrack = read_method_meta(root, "forecasting", m)
|
| 255 |
+
srow = summary.loc[m] if m in summary.index else None
|
| 256 |
+
rows.append(
|
| 257 |
+
{
|
| 258 |
+
"method": name,
|
| 259 |
+
"mtype": mtype,
|
| 260 |
+
"submitter": submitter,
|
| 261 |
+
"subtrack": subtrack,
|
| 262 |
+
"model_url": FC_MODEL_URLS.get(m),
|
| 263 |
+
"skill": num(srow["overall_score"]) if srow is not None else None,
|
| 264 |
+
"fair_skill": num(fair_overall.get(m)),
|
| 265 |
+
"rank": num(overall_rank.get(m)),
|
| 266 |
+
"activity": num(srow["activity_score"]) if srow is not None else None,
|
| 267 |
+
"physiology": num(srow["physiology_score"]) if srow is not None else None,
|
| 268 |
+
"sleep": num(srow["sleep_score"]) if srow is not None else None,
|
| 269 |
+
"workout": num(srow["workout_score"]) if srow is not None else None,
|
| 270 |
+
}
|
| 271 |
+
)
|
| 272 |
+
rows.sort(key=lambda r: (r["skill"] is None, -(r["skill"] or 0.0)))
|
| 273 |
+
return rows
|
| 274 |
+
|
| 275 |
+
|
| 276 |
if __name__ == "__main__":
|
| 277 |
import json
|
| 278 |
|
| 279 |
+
print(json.dumps({
|
| 280 |
+
"imputation": compute_imputation_rows(),
|
| 281 |
+
"forecasting": compute_forecasting_rows(),
|
| 282 |
+
}, indent=2))
|