NSchuetz commited on
Commit
2147343
·
verified ·
1 Parent(s): 9c2c935

Add Track 3 (forecasting) leaderboard section: live skill/rank/fair-skill vs seasonal_naive; pin openmhc@991caf5

Browse files
Files changed (3) hide show
  1. Dockerfile +5 -3
  2. app.py +146 -88
  3. 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). Installed WITHOUT
 
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@bd5a102 carries the MAPD fair-skill reducer.
 
22
  RUN pip install --no-cache-dir --no-deps \
23
- "openmhc @ git+https://github.com/AshleyLab/myheartcounts-dataset@bd5a1028a19e4ec2eb6965853c538fad71188558"
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 the result in-process.
26
- _CACHE: dict = {"rows": None, "error": None}
 
 
 
27
 
28
- # (row key, header label). Order + arrows mirror the paper table.
29
- COLUMNS = [
30
  ("method", "Method"),
31
  ("mtype", "Type"),
32
  ("rank", "R ↓"),
@@ -39,12 +42,42 @@ COLUMNS = [
39
  ("semantic", "Semantic ↑"),
40
  ("submitter", "Submitter"),
41
  ]
42
- NCOLS = len(COLUMNS)
43
-
44
- # sub-track key -> section heading
45
- SUBTRACKS = [
46
- ("single-day", "Single-day imputation"),
47
- ("long-context", "Long-context imputation (≥ 7×1440 time steps)"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Imputation Leaderboard</h1>
145
- <p class="lede">Track 2a &mdash; consists of two parts. (1) Reconstructing masked daily, minute-level mobile/wearable signals. (2) Reconstructing masked daily, minute-level mobile/wearable signals given all other days a user has available (neural methods were restricted to 7 days for computational reasons &mdash; 1440&times;19&times;7 is a lot). Ranked by skill score vs the LOCF (last observation carried forward) baseline, computed live from per-user evaluation substrate.</p>
146
  <div class="pills">
147
  <a class="pill" href="%%CODE%%">&#9881;&#65039; Code</a>
148
  <span class="pill disabled">&#128202; Dataset &middot; 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
- <div class="section">
156
- <p class="section-title">Track 2a &middot; Imputation</p>
157
- %%CONTENT%%
158
- <p class="note">Skill (S) &amp; 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
- function sortBy(i,d){
172
- table.querySelectorAll('tbody').forEach(function(tb){
173
- var rows=[].slice.call(tb.querySelectorAll('tr.mrow'));
174
- rows.sort(function(a,b){
175
- var A=cell(a.children[i]),B=cell(b.children[i]);
176
- if(A.num){
177
- if(A.v===null&&B.v===null)return 0;
178
- if(A.v===null)return 1; if(B.v===null)return -1;
179
- return (A.v-B.v)*d;
180
- }
181
- return String(A.v).localeCompare(String(B.v))*d;
 
 
 
 
 
 
182
  });
183
- rows.forEach(function(r){tb.appendChild(r);});
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
- ths.forEach(function(th,i){
205
- if(th.dataset.filter){ // Type = filter dropdown, not sortable
206
- th.addEventListener('click',function(e){
207
- if(e.target.closest('.dropdown')) return; // clicks inside menu don't toggle
208
- th.classList.toggle('open');
 
 
 
 
 
 
 
209
  });
210
- return;
211
  }
212
- th.classList.add('sortable');
213
- th.addEventListener('click',function(){
214
- var d=(cur.i===i)?-cur.d:(th.dataset.def==='asc'?1:-1);
215
- cur={i:i,d:d};
216
- ths.forEach(function(h){h.classList.remove('s-asc','s-desc');});
217
- th.classList.add(d===1?'s-asc':'s-desc');
218
- sortBy(i,d);
 
 
 
 
 
 
 
 
 
 
 
219
  });
220
  });
221
 
 
222
  document.addEventListener('click',function(e){
223
  if(!e.target.closest('.filter-th')){
224
- table.querySelectorAll('.filter-th.open').forEach(function(t){t.classList.remove('open');});
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
- if _CACHE["rows"] is None and _CACHE["error"] is None:
 
236
  try:
237
- _CACHE["rows"] = compute_imputation_rows()
238
  except Exception as e: # surface the failure on the page; don't hide it
239
- _CACHE["error"] = f"{type(e).__name__}: {e}"
240
- return _CACHE["rows"], _CACHE["error"]
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 COLUMNS:
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 COLUMNS)
341
  stats = _col_stats(rows)
342
- known = {k for k, _ in SUBTRACKS}
343
- sections = list(SUBTRACKS)
 
 
 
 
 
 
 
 
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="{NCOLS}">{html.escape(label)}</td></tr>'
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
- @app.get("/", response_class=HTMLResponse)
365
- def index() -> str:
366
- rows, error = _rows()
367
  if error is not None:
368
- content = f'<div class="err">Failed to compute leaderboard:\n{html.escape(error)}</div>'
369
  elif not rows:
370
- content = '<div class="err">No methods found in the substrate dataset.</div>'
371
  else:
372
- content = _table(rows)
 
 
 
 
 
 
 
 
 
 
 
 
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
- rows, error = _rows()
384
- return {"status": "ok" if error is None else "error", "methods": len(rows or []), "error": error}
 
 
 
 
 
 
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) &amp; 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) &amp; 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 &amp; 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%%">&#9881;&#65039; Code</a>
181
  <span class="pill disabled">&#128202; Dataset &middot; 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(compute_imputation_rows(), indent=2))
 
 
 
 
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))