HungryTorch commited on
Commit
1ff84d2
·
verified ·
1 Parent(s): b316bb4

feat(primo): add task tooltips and custom social preview

Browse files
Files changed (14) hide show
  1. README.md +9 -63
  2. app.py +8 -12
  3. assets/primo-social-preview.png +0 -0
  4. boards.py +2 -9
  5. evaluator.py +18 -286
  6. leaderboard.py +45 -102
  7. pages/about.md +12 -18
  8. pages/leaderboard.md +3 -10
  9. pages/submit.md +4 -4
  10. pages/tasks.md +2 -3
  11. primo.css +7 -0
  12. render.py +49 -7
  13. results.py +0 -1
  14. scoring.py +7 -54
README.md CHANGED
@@ -3,6 +3,7 @@ title: PRIMO Benchmark
3
  emoji: 🧬
4
  colorFrom: indigo
5
  colorTo: blue
 
6
  sdk: gradio
7
  sdk_version: 5.50.0
8
  python_version: "3.10"
@@ -17,7 +18,7 @@ hf_oauth: true
17
 
18
  PRIMO grades how well a model turns a patient's omics data into a useful
19
  **patient embedding**. You embed every dataset and upload **one** file; a fixed
20
- fixed probe scores each hidden task, and the results roll up into a blind,
21
  per-category leaderboard. The datasets are opaque (`d001`, `d002`…) and you never
22
  see the disease, tissue, or target, which leaves you grading the *embedding*
23
  itself with no room for per-task tuning.
@@ -41,16 +42,13 @@ that does not exist.
41
 
42
  ## What's in the data
43
 
44
- PRIMO benchmarks any omics modality. Today's datasets are all **bulk RNA**,
45
  covering **immune-mediated inflammatory diseases (IMIDs)** with real clinical
46
- labels or treatment-induced expression responses from published cohorts:
47
 
48
  - **Gastroenterology**: Crohn's disease, ulcerative colitis (anti-TNF response, severity scores)
49
  - **Dermatology**: atopic dermatitis, psoriasis (severity scores)
50
  - **Rheumatology**: rheumatoid arthritis (joint counts, molecular endotype)
51
- - **Perturbation response**: adalimumab transfer across inflammatory skin
52
- diseases, rituximab response in Sjögren salivary gland, and mouse intestinal
53
- anti-TNF response
54
 
55
  ## Submission format
56
 
@@ -78,40 +76,17 @@ partitions: **AUROC** (classification) or **Pearson r** (regression). A few
78
  tasks instead use a fixed train/test split: the probe is fit once on the training
79
  portion and scored on the held-out portion.
80
 
81
- Tasks are grouped into **four families**, each reported in its own native
82
  metric. Two families are never merged into one column:
83
 
84
  - **Treatment outcome**: response to anti-TNF therapy (**AUROC**)
85
  - **Clinical scores**: disease-severity regression (**Pearson r**)
86
  - **Endotype**: molecular-subtype classification (**AUROC**)
87
- - **Perturbation response**: hidden post-minus-pre DEG decoding (**centered
88
- Spearman**), where 1 is perfect, 0 is uninformative, and -1 is an inverse gene
89
- ranking.
90
-
91
- Perturbation inputs contain baseline expression only. Participants still submit
92
- one embedding per public sample. The evaluator independently applies
93
- `log2(CPM + 1)` to baseline and post-treatment profiles, selects the top 600
94
- DEGs inside each outer training fold, standardizes embeddings on the training
95
- side, and fits a multi-output `RidgeCV` decoder to the hidden response matrix.
96
- Test coordinates are clipped to ±20 training standard deviations to limit
97
- cross-cohort extrapolation. Directional transfer tasks learn standardization,
98
- DEGs, and the decoder solely from their source disease. The mouse IBD task is a
99
- regular weighted task: three public baseline animals are reused across nine
100
- private control×perturbed responses, evaluated over all six specimen-isolated
101
- permutations.
102
-
103
- For ordinary paired and same-species transfer tasks, the evaluator subtracts the
104
- training-fold mean response from the observed and decoded responses. For
105
- mouse-to-human transfer, it instead subtracts each gene's mean across the human
106
- target cohort, separately for observations and predictions. It then computes
107
- Spearman across the 600 genes for every held-out patient. The task score is the
108
- mean Spearman itself: inverse rankings score -1, an uninformative shared response
109
- scores 0, and perfect rankings score 1.
110
 
111
  A leaderboard shows one column per family. A board holding more than one family
112
  also shows **Mean**, the average of those columns. It is what orders the rows,
113
- but it does mix AUROC, Pearson and centered Spearman, so treat it as a tie-break and
114
- compare models on the family columns.
115
 
116
  ## Boards
117
 
@@ -145,35 +120,6 @@ export HF_TOKEN=... # read access to the PRIMO datasets
145
  python evaluator.py --submission my_embeddings.parquet
146
  ```
147
 
148
- Run the complete perturbation artifact and baseline validation harness from the
149
- repository root. Synthetic mode needs no private data or network access:
150
-
151
- ```bash
152
- uv run python -m benchmark.public_benchmark.validate_perturbation
153
- ```
154
-
155
- To validate against datasets already present in the local Scienta Hub cache:
156
-
157
- ```bash
158
- uv run python -m benchmark.public_benchmark.validate_perturbation \
159
- --source cache --output .context/perturbation-real
160
- ```
161
-
162
- The harness writes public/private artifacts, raw-expression, HVG-1200, random,
163
- and constant submissions, plus `report.json`, beneath `.context/`. It checks the
164
- privacy boundary and zero anchor, and reports fold-local DEG overlap, score
165
- distributions, ridge conditioning, and all mouse permutations. It never
166
- downloads, uploads, publishes, or deploys anything. Pass `--overwrite` to
167
- replace an existing output directory.
168
-
169
- ## Perturbation rollout
170
-
171
- Keep the live benchmark unchanged until the local artifact and baseline checks
172
- pass. Then deploy in this order: the backward-compatible scorer, public baseline
173
- inputs, private targets and registry, and finally refreshed baselines. Existing
174
- task-result rows stay in place; their submissions simply lack coverage for the
175
- new datasets until their owners submit embeddings for `d011`–`d016`.
176
-
177
  ## Baselines
178
 
179
  `task_results.csv` carries an `is_baseline` flag. Reference submissions we
@@ -192,10 +138,10 @@ dataset, so read both as a floor set by the raw features.
192
  - Set an **`HF_TOKEN`** Space secret (fine-grained) with: **read** on
193
  `ScientaLab/primo` (the public `datasets.yaml` manifest) and
194
  `ScientaLab/primo-labels` (the private `tasks.yaml` registry +
195
- `<task_id>/labels.csv` and perturbation `<task_id>/targets.npz`), and **read + write** on
196
  `ScientaLab/primo-results` (the persisted leaderboard).
197
  - Results persist as one normalized `task_results.csv` (`model_name, task_id,
198
- score, repeat_scores, diagnostics, submitted_at, is_baseline, hf_username`) in the results dataset; the
199
  leaderboard is recomputed from it by joining the registry, so it survives
200
  Space restarts.
201
  - A board keeps each name's **latest** submission, so a model name is owned by
 
3
  emoji: 🧬
4
  colorFrom: indigo
5
  colorTo: blue
6
+ thumbnail: https://huggingface.co/spaces/ScientaLab/primo-eval/resolve/main/assets/primo-social-preview.png
7
  sdk: gradio
8
  sdk_version: 5.50.0
9
  python_version: "3.10"
 
18
 
19
  PRIMO grades how well a model turns a patient's omics data into a useful
20
  **patient embedding**. You embed every dataset and upload **one** file; a fixed
21
+ linear probe scores each hidden task, and the results roll up into a blind,
22
  per-category leaderboard. The datasets are opaque (`d001`, `d002`…) and you never
23
  see the disease, tissue, or target, which leaves you grading the *embedding*
24
  itself with no room for per-task tuning.
 
42
 
43
  ## What's in the data
44
 
45
+ PRIMO benchmarks any omics modality. Today's datasets are all **bulk RNA-seq**,
46
  covering **immune-mediated inflammatory diseases (IMIDs)** with real clinical
47
+ labels from published cohorts:
48
 
49
  - **Gastroenterology**: Crohn's disease, ulcerative colitis (anti-TNF response, severity scores)
50
  - **Dermatology**: atopic dermatitis, psoriasis (severity scores)
51
  - **Rheumatology**: rheumatoid arthritis (joint counts, molecular endotype)
 
 
 
52
 
53
  ## Submission format
54
 
 
76
  tasks instead use a fixed train/test split: the probe is fit once on the training
77
  portion and scored on the held-out portion.
78
 
79
+ Tasks are grouped into **three families**, each reported in its own native
80
  metric. Two families are never merged into one column:
81
 
82
  - **Treatment outcome**: response to anti-TNF therapy (**AUROC**)
83
  - **Clinical scores**: disease-severity regression (**Pearson r**)
84
  - **Endotype**: molecular-subtype classification (**AUROC**)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
  A leaderboard shows one column per family. A board holding more than one family
87
  also shows **Mean**, the average of those columns. It is what orders the rows,
88
+ but it does mix AUROC with Pearson, so treat it as a tie-break and compare models
89
+ on the family columns.
90
 
91
  ## Boards
92
 
 
120
  python evaluator.py --submission my_embeddings.parquet
121
  ```
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  ## Baselines
124
 
125
  `task_results.csv` carries an `is_baseline` flag. Reference submissions we
 
138
  - Set an **`HF_TOKEN`** Space secret (fine-grained) with: **read** on
139
  `ScientaLab/primo` (the public `datasets.yaml` manifest) and
140
  `ScientaLab/primo-labels` (the private `tasks.yaml` registry +
141
+ `<task_id>/labels.csv`), and **read + write** on
142
  `ScientaLab/primo-results` (the persisted leaderboard).
143
  - Results persist as one normalized `task_results.csv` (`model_name, task_id,
144
+ score, repeat_scores, submitted_at, is_baseline, hf_username`) in the results dataset; the
145
  leaderboard is recomputed from it by joining the registry, so it survives
146
  Space restarts.
147
  - A board keeps each name's **latest** submission, so a model name is owned by
app.py CHANGED
@@ -8,18 +8,17 @@ board and ``?tab=contribute`` opens a tab -- which is what the rail links and th
8
  open cards use. The tab strip is hidden in CSS; the rail is the navigation.
9
 
10
  Upload one embedding file spanning every dataset (rows keyed by ``dataset_id``
11
- + ``sample_id``); a fixed task probe scores each task (a dataset may carry
12
  several hidden targets). Results roll up into boards -- the whole modality, one
13
  therapeutic area, one task family -- and each board ranks the models that
14
- covered every task our baseline scored, one column per category in its native
15
- metric (AUROC, Pearson, or centered Spearman), plus a ``Mean`` of those columns
16
- that orders the rows and is labelled as the cross-metric average it is.
17
 
18
  Disclosure policy -- what the public pages may show:
19
  per task disease, tissue, area, what is predicted, class names, n, metric
20
  aggregated the public archives the cohorts sit in, and their licences
21
- never study accessions, dataset_id -> cohort, hub keys, per-sample labels,
22
- pairing identifiers or post-treatment responses
23
  Naming the accession behind ``d002`` would put every label one GEO download
24
  away, so ``sources`` is collapsed to its archive (NCBI GEO / EMBL-EBI
25
  ArrayExpress) and ``citation`` is never rendered at all. Submitters can publish
@@ -209,9 +208,9 @@ def _summary(result: dict, model_name: str) -> str:
209
  lines.append("\n✅ **full coverage.** You are ranked on every board.")
210
  else:
211
  lines.append(
212
- "\n⚠️ **partial coverage.** You are ranked on the boards whose scored "
213
- "tasks you covered in full, and your scores always appear in each "
214
- "board's **per-task** table."
215
  )
216
  if result["missing"]:
217
  lines.append(f"- missing from file: {result['missing']}")
@@ -305,9 +304,6 @@ def evaluate(
305
  "repeat_scores": json.dumps(
306
  [round(value, 4) for value in task.repeat_scores]
307
  ),
308
- "diagnostics": json.dumps(
309
- {key: round(value, 4) for key, value in task.diagnostics.items()}
310
- ),
311
  "submitted_at": submitted_at,
312
  IS_BASELINE: False,
313
  OWNER: profile.username,
 
8
  open cards use. The tab strip is hidden in CSS; the rail is the navigation.
9
 
10
  Upload one embedding file spanning every dataset (rows keyed by ``dataset_id``
11
+ + ``sample_id``); a fixed linear probe scores each task (a dataset may carry
12
  several hidden targets). Results roll up into boards -- the whole modality, one
13
  therapeutic area, one task family -- and each board ranks the models that
14
+ covered all of its tasks, one column per category in its native metric (AUROC or
15
+ Pearson), plus a ``Mean`` of those columns that orders the rows and is labelled
16
+ as the cross-metric average it is.
17
 
18
  Disclosure policy -- what the public pages may show:
19
  per task disease, tissue, area, what is predicted, class names, n, metric
20
  aggregated the public archives the cohorts sit in, and their licences
21
+ never study accessions, dataset_id -> cohort, hub keys, per-sample labels
 
22
  Naming the accession behind ``d002`` would put every label one GEO download
23
  away, so ``sources`` is collapsed to its archive (NCBI GEO / EMBL-EBI
24
  ArrayExpress) and ``citation`` is never rendered at all. Submitters can publish
 
208
  lines.append("\n✅ **full coverage.** You are ranked on every board.")
209
  else:
210
  lines.append(
211
+ "\n⚠️ **partial coverage.** You are ranked on the boards whose tasks you "
212
+ "covered in full, and your scores always appear in each board's "
213
+ "**per-task** table."
214
  )
215
  if result["missing"]:
216
  lines.append(f"- missing from file: {result['missing']}")
 
304
  "repeat_scores": json.dumps(
305
  [round(value, 4) for value in task.repeat_scores]
306
  ),
 
 
 
307
  "submitted_at": submitted_at,
308
  IS_BASELINE: False,
309
  OWNER: profile.username,
assets/primo-social-preview.png ADDED
boards.py CHANGED
@@ -31,12 +31,7 @@ from dataclasses import dataclass
31
 
32
  from evaluator import _norm_id
33
 
34
- METRIC_LABEL = {
35
- "auroc": "AUROC",
36
- "pearson": "Pearson",
37
- "residual_spearman": "Residual Spearman score",
38
- "centered_spearman": "Centered Spearman score",
39
- }
40
  MODALITY_LABEL = {
41
  "bulk RNA": "bulk RNAseq",
42
  "single-cell RNA": "single-cell RNAseq",
@@ -54,13 +49,11 @@ GROUP_NOTE = {
54
 
55
  CODE_LENGTH = 3
56
  FALLBACK_CODE = "n/a"
57
- DISPLAY_LABEL = {"perturbation_prediction": "Perturbation response"}
58
 
59
  CATEGORY_BLURB = {
60
  "treatment_outcome": "Will this patient respond to the drug?",
61
  "clinical_scores": "How severe is this patient's disease?",
62
  "endotype": "Which molecular subtype is this patient?",
63
- "perturbation_prediction": "How will treatment change this patient's expression?",
64
  }
65
 
66
  N_FEATURED = 3
@@ -69,7 +62,7 @@ MAX_LISTED = 3
69
 
70
  def label(value: str) -> str:
71
  """``treatment_outcome`` -> ``Treatment outcome``; leaves free text alone."""
72
- return DISPLAY_LABEL.get(value, value.replace("_", " ").capitalize())
73
 
74
 
75
  def metric_label(metric: str) -> str:
 
31
 
32
  from evaluator import _norm_id
33
 
34
+ METRIC_LABEL = {"auroc": "AUROC", "pearson": "Pearson"}
 
 
 
 
 
35
  MODALITY_LABEL = {
36
  "bulk RNA": "bulk RNAseq",
37
  "single-cell RNA": "single-cell RNAseq",
 
49
 
50
  CODE_LENGTH = 3
51
  FALLBACK_CODE = "n/a"
 
52
 
53
  CATEGORY_BLURB = {
54
  "treatment_outcome": "Will this patient respond to the drug?",
55
  "clinical_scores": "How severe is this patient's disease?",
56
  "endotype": "Which molecular subtype is this patient?",
 
57
  }
58
 
59
  N_FEATURED = 3
 
62
 
63
  def label(value: str) -> str:
64
  """``treatment_outcome`` -> ``Treatment outcome``; leaves free text alone."""
65
+ return value.replace("_", " ").capitalize()
66
 
67
 
68
  def metric_label(metric: str) -> str:
evaluator.py CHANGED
@@ -3,8 +3,8 @@
3
  Loads one embedding submission that spans every dataset (rows keyed by
4
  ``dataset_id`` + ``sample_id``), then scores each TASK = (dataset, target): a
5
  dataset is embedded once and reused across all its tasks. Per task it fits a
6
- fixed probe—linear for scalar targets and multi-output ridge for response
7
- matrices—over repeated frozen CV folds or a fixed transfer split. Turning those
8
  predictions into scores + leaderboard aggregates is the job of ``scoring.py``;
9
  this module only reads data and runs the probe.
10
 
@@ -14,9 +14,8 @@ failed Hugging Face fetch or an unexpected bug, raises ``EvaluatorError`` so it
14
  is never silently charged against the submitter.
15
 
16
  Reads the public ``datasets.yaml`` manifest (what the submitter embeds) plus the
17
- private ``tasks.yaml`` registry, each task's private ``labels.csv``, and hidden
18
- perturbation ``targets.npz`` matrices where applicable. No monorepo imports, so
19
- it runs unchanged inside a public Hugging Face Space.
20
 
21
  Deps: numpy, pandas, scikit-learn, pyyaml. ``huggingface_hub`` is used only by
22
  the fetch helpers / CLI, imported lazily.
@@ -33,13 +32,7 @@ from pathlib import Path
33
  import numpy as np
34
  import pandas as pd
35
  import yaml
36
- from scoring import (
37
- METRICS,
38
- TaskScore,
39
- category_means,
40
- compute_residual_sample_spearman,
41
- compute_target_centered_sample_spearman,
42
- )
43
  from sklearn.linear_model import LogisticRegressionCV, RidgeCV
44
  from sklearn.model_selection import StratifiedKFold
45
  from sklearn.preprocessing import StandardScaler
@@ -53,15 +46,7 @@ FOLD_PREFIX = f"{FOLD}_"
53
  SPLIT = "split"
54
  SPLIT_TRAIN = "train"
55
  SPLIT_TEST = "test"
56
- PAIR_ID = "pair_id"
57
- CONTROL_ID = "control_id"
58
- PERTURBED_ID = "perturbed_id"
59
- PAIRING = "pairing"
60
- PAIRED = "paired"
61
- CARTESIAN = "cartesian"
62
- PERTURBATION = "perturbation"
63
- RESIDUAL_SPEARMAN = "residual_spearman"
64
- CENTERED_SPEARMAN = "centered_spearman"
65
  ORG = "ScientaLab"
66
  PUBLIC_REPO = f"{ORG}/primo"
67
  LABELS_REPO = f"{ORG}/primo-labels"
@@ -70,10 +55,8 @@ RESULTS_REPO = "ScientaLab/primo-results"
70
  MANIFEST_FILENAME = "datasets.yaml"
71
  TASKS_FILENAME = "tasks.yaml"
72
  LABELS_FILENAME = "labels.csv"
73
- TARGETS_FILENAME = "targets.npz"
74
 
75
  RIDGE_ALPHAS = np.logspace(-3.0, 6.0, 19)
76
- PERTURBATION_TEST_Z_CLIP = 20.0
77
  LOGREG_CS = 10
78
  INNER_CV = 5
79
  MAX_ITER = 5000
@@ -111,14 +94,6 @@ class TaskOutcome:
111
  reason: str | None = None
112
 
113
 
114
- @dataclass(frozen=True)
115
- class PerturbationTargets:
116
- """Hidden response vectors and their aligned gene identifiers."""
117
-
118
- delta: np.ndarray
119
- gene_ids: np.ndarray
120
-
121
-
122
  def _norm_id(value: object) -> str:
123
  """Normalise an id so int/str/float spellings of the same id join."""
124
  if isinstance(value, float) and value.is_integer():
@@ -145,9 +120,8 @@ def _fold_columns(labels: pd.DataFrame) -> list[str]:
145
  ]
146
  if invalid:
147
  raise EvaluatorError(f"labels.csv has invalid repeat-fold columns: {invalid}")
148
- first = [FOLD] if FOLD in labels.columns else []
149
  return [
150
- *first,
151
  *sorted(repeated, key=lambda column: int(column.removeprefix(FOLD_PREFIX))),
152
  ]
153
 
@@ -155,48 +129,19 @@ def _fold_columns(labels: pd.DataFrame) -> list[str]:
155
  def load_labels(path: str | Path) -> pd.DataFrame:
156
  """Read private labels with repeated CV folds or one transfer split."""
157
  df = pd.read_csv(path)
158
- is_perturbation = PAIR_ID in df.columns
159
- required = {SAMPLE_ID, PAIR_ID} if is_perturbation else {SAMPLE_ID, LABEL}
160
- missing = required - set(df.columns)
161
  if missing:
162
  raise EvaluatorError(f"labels.csv missing columns: {sorted(missing)}")
163
- has_folds = bool(_fold_columns(df))
164
- has_split = SPLIT in df.columns
165
- pairing_values = (
166
- set(df[PAIRING].astype(str)) if is_perturbation and PAIRING in df else set()
167
- )
168
- is_cartesian = pairing_values == {CARTESIAN}
169
- if sum((has_folds, has_split, is_cartesian)) != 1:
170
  raise EvaluatorError(
171
- "labels.csv needs exactly one evaluation assignment: folds, transfer "
172
- "split, or Cartesian pairing."
173
  )
174
- if is_cartesian:
175
- missing = {CONTROL_ID, PERTURBED_ID, PAIRING} - set(df.columns)
176
- if missing:
177
- raise EvaluatorError(
178
- f"Cartesian labels.csv missing columns: {sorted(missing)}"
179
- )
180
  return df
181
 
182
 
183
- def load_targets(path: str | Path) -> PerturbationTargets:
184
- """Read and validate one task's hidden perturbation responses."""
185
- with np.load(path, allow_pickle=False) as data:
186
- missing = {"delta", "gene_ids"} - set(data.files)
187
- if missing:
188
- raise EvaluatorError(f"targets.npz missing arrays: {sorted(missing)}")
189
- delta = np.asarray(data["delta"], dtype=float)
190
- gene_ids = np.asarray(data["gene_ids"]).astype(str)
191
- if delta.ndim != 2:
192
- raise EvaluatorError(f"targets delta must be 2D, got shape {delta.shape}")
193
- if gene_ids.ndim != 1 or len(gene_ids) != delta.shape[1]:
194
- raise EvaluatorError("targets gene_ids must align with delta columns")
195
- if not np.isfinite(delta).all():
196
- raise EvaluatorError("targets delta contains NaN or inf values")
197
- return PerturbationTargets(delta=delta, gene_ids=gene_ids)
198
-
199
-
200
  def load_submission(path: str | Path) -> dict[str, pd.DataFrame]:
201
  """Load a multi-dataset submission into ``{dataset_id: raw_block_frame}``."""
202
  ext = Path(path).suffix.lower()
@@ -407,198 +352,13 @@ def _transfer_predict(
407
  return y[test], preds
408
 
409
 
410
- def _select_degs(delta_train: np.ndarray, n_top: int) -> np.ndarray:
411
- """Select the strongest mean absolute responses using training rows only."""
412
- n_keep = min(int(n_top), delta_train.shape[1])
413
- if n_keep <= 0:
414
- raise EvaluatorError("perturbation task has no response genes to select")
415
- effect = np.abs(np.mean(delta_train, axis=0))
416
- return np.argsort(effect, kind="stable")[-n_keep:]
417
-
418
-
419
- def _mean_scores(scores: list[float]) -> float:
420
- """Average finite fold scores."""
421
- finite = np.asarray(scores)[np.isfinite(scores)]
422
- return float(np.mean(finite)) if len(finite) else float("nan")
423
-
424
-
425
- def _perturbation_fold(
426
- matrix: np.ndarray,
427
- delta: np.ndarray,
428
- train: np.ndarray,
429
- test: np.ndarray,
430
- n_top: int,
431
- target_centered: bool,
432
- ) -> float:
433
- """Fit and score one leakage-safe perturbation fold."""
434
- if not train.any() or not test.any():
435
- raise EvaluatorError("perturbation fold has an empty train or test side")
436
- genes = _select_degs(delta[train], n_top)
437
- y_train = delta[train][:, genes]
438
- y_test = delta[test][:, genes]
439
- scaler = StandardScaler().fit(matrix[train])
440
- x_train = scaler.transform(matrix[train])
441
- x_test = np.clip(
442
- scaler.transform(matrix[test]),
443
- -PERTURBATION_TEST_Z_CLIP,
444
- PERTURBATION_TEST_Z_CLIP,
445
- )
446
- predictions = RidgeCV(alphas=RIDGE_ALPHAS).fit(x_train, y_train).predict(x_test)
447
- if target_centered:
448
- correlation = compute_target_centered_sample_spearman(y_test, predictions)
449
- else:
450
- training_mean = np.mean(y_train, axis=0)
451
- correlation = compute_residual_sample_spearman(
452
- y_test, predictions, training_mean
453
- )
454
- if not np.isfinite(correlation):
455
- raise SubmissionError("a perturbation fold has a degenerate Spearman score")
456
- return float(np.clip(correlation, -1.0, 1.0))
457
-
458
-
459
- def _paired_perturbation_scores(
460
- matrix: np.ndarray,
461
- delta: np.ndarray,
462
- labels: pd.DataFrame,
463
- n_top: int,
464
- ) -> tuple[float, ...]:
465
- """Score three frozen subject-grouped CV partitions."""
466
- repeat_scores = []
467
- for column in _fold_columns(labels):
468
- fold_metrics = []
469
- folds = labels[column].to_numpy()
470
- for fold in sorted(np.unique(folds)):
471
- test = folds == fold
472
- fold_metrics.append(
473
- _perturbation_fold(matrix, delta, ~test, test, n_top, False)
474
- )
475
- repeat_scores.append(_mean_scores(fold_metrics))
476
- return tuple(repeat_scores)
477
-
478
-
479
- def _transfer_perturbation_scores(
480
- matrix: np.ndarray,
481
- delta: np.ndarray,
482
- labels: pd.DataFrame,
483
- n_top: int,
484
- target_centered: bool,
485
- ) -> tuple[tuple[float, ...], int]:
486
- """Train every response-decoding component on the source disease only."""
487
- split = labels[SPLIT].astype(str).to_numpy()
488
- train, test = split == SPLIT_TRAIN, split == SPLIT_TEST
489
- metrics = _perturbation_fold(matrix, delta, train, test, n_top, target_centered)
490
- return (metrics,), int(test.sum())
491
-
492
-
493
- def _cartesian_partitions(
494
- control_ids: np.ndarray, perturbed_ids: np.ndarray
495
- ) -> list[list[tuple[np.ndarray, np.ndarray]]]:
496
- """Build six 3-fold specimen-isolated partitions for a 3x3 response grid."""
497
- from itertools import permutations
498
-
499
- controls = np.unique(control_ids)
500
- perturbed = np.unique(perturbed_ids)
501
- if len(controls) != 3 or len(perturbed) != 3:
502
- raise EvaluatorError(
503
- "Cartesian perturbation evaluation requires exactly three controls "
504
- "and three perturbed specimens."
505
- )
506
- repeats = []
507
- for assignment in permutations(perturbed):
508
- folds = []
509
- for control_id, perturbed_id in zip(controls, assignment):
510
- test = (control_ids == control_id) & (perturbed_ids == perturbed_id)
511
- train = (control_ids != control_id) & (perturbed_ids != perturbed_id)
512
- if test.sum() != 1 or train.sum() != 4:
513
- raise EvaluatorError("Cartesian labels do not form a complete 3x3 grid")
514
- folds.append((train, test))
515
- repeats.append(folds)
516
- return repeats
517
-
518
-
519
- def _cartesian_perturbation_scores(
520
- matrix: np.ndarray,
521
- delta: np.ndarray,
522
- labels: pd.DataFrame,
523
- n_top: int,
524
- ) -> tuple[float, ...]:
525
- """Evaluate all control-to-perturbed permutations without specimen leakage."""
526
- repeats = _cartesian_partitions(
527
- labels[CONTROL_ID].astype(str).to_numpy(),
528
- labels[PERTURBED_ID].astype(str).to_numpy(),
529
- )
530
- repeat_scores = []
531
- for folds in repeats:
532
- fold_metrics = []
533
- for train, test in folds:
534
- fold_metrics.append(
535
- _perturbation_fold(matrix, delta, train, test, n_top, False)
536
- )
537
- repeat_scores.append(_mean_scores(fold_metrics))
538
- return tuple(repeat_scores)
539
-
540
-
541
- def _score_perturbation_task(
542
- task: dict,
543
- labels: pd.DataFrame,
544
- emb: pd.DataFrame,
545
- targets: PerturbationTargets,
546
- ) -> TaskScore:
547
- """Decode one hidden response matrix from submitted baseline embeddings."""
548
- if len(labels) != len(targets.delta):
549
- raise EvaluatorError("labels.csv and targets.npz have different row counts")
550
- matrix = _align(labels, emb)
551
- n_top = int(task.get("n_top_de_genes", 0))
552
- pairing = str(task.get(PAIRING, labels.get(PAIRING, pd.Series([PAIRED])).iloc[0]))
553
- if SPLIT in labels:
554
- repeat_scores, n_samples = _transfer_perturbation_scores(
555
- matrix,
556
- targets.delta,
557
- labels,
558
- n_top,
559
- bool(task.get("target_centered", False)),
560
- )
561
- elif pairing == CARTESIAN:
562
- repeat_scores = _cartesian_perturbation_scores(
563
- matrix, targets.delta, labels, n_top
564
- )
565
- n_samples = len(labels)
566
- else:
567
- repeat_scores = _paired_perturbation_scores(
568
- matrix, targets.delta, labels, n_top
569
- )
570
- n_samples = len(labels)
571
- return TaskScore(
572
- task_id=_norm_id(task[TASK_ID]),
573
- dataset_id=_norm_id(task[DATASET_ID]),
574
- category=str(task["category"]),
575
- metric=str(task.get("metric", RESIDUAL_SPEARMAN)),
576
- score=float(np.mean(repeat_scores)),
577
- n_samples=n_samples,
578
- repeat_scores=repeat_scores,
579
- )
580
-
581
-
582
- def score_task(
583
- task: dict,
584
- labels: pd.DataFrame,
585
- emb: pd.DataFrame,
586
- targets: PerturbationTargets | None = None,
587
- ) -> TaskScore:
588
  """Score one task: repeated fold-wise CV, or a fixed transfer split.
589
 
590
- Private biology (disease, tissue, and target names) never leaks in.
 
591
  """
592
  metric = task["metric"]
593
- if task.get("task_type") == PERTURBATION:
594
- if metric not in {CENTERED_SPEARMAN, RESIDUAL_SPEARMAN}:
595
- raise EvaluatorError(
596
- f"perturbation task {task.get(TASK_ID)} needs metric "
597
- f"'{CENTERED_SPEARMAN}'"
598
- )
599
- if targets is None:
600
- raise EvaluatorError("perturbation task is missing targets.npz")
601
- return _score_perturbation_task(task, labels, emb, targets)
602
  if metric not in METRICS:
603
  raise EvaluatorError(f"unknown metric '{metric}' for task {task.get(TASK_ID)}")
604
  task_type = task["task_type"]
@@ -695,16 +455,6 @@ def fetch_task_labels(task_id: str, token: str | None = None) -> pd.DataFrame:
695
  return load_labels(path)
696
 
697
 
698
- def fetch_task_targets(task_id: str, token: str | None = None) -> PerturbationTargets:
699
- """Download a task's private compressed response matrix."""
700
- from huggingface_hub import hf_hub_download
701
-
702
- path = hf_hub_download(
703
- LABELS_REPO, f"{task_id}/{TARGETS_FILENAME}", repo_type="dataset", token=token
704
- )
705
- return load_targets(path)
706
-
707
-
708
  def _retry(fn):
709
  """Call ``fn``, retrying transient failures with linear backoff."""
710
  last: Exception | None = None
@@ -718,14 +468,7 @@ def _retry(fn):
718
  raise last
719
 
720
 
721
- def _task_outcome(
722
- task: dict,
723
- emb,
724
- block_error,
725
- fetch_labels,
726
- fetch_targets,
727
- token,
728
- ) -> TaskOutcome:
729
  """Score one task, mapping each failure to the right category."""
730
  task_id = _norm_id(task[TASK_ID])
731
  dataset_id = _norm_id(task[DATASET_ID])
@@ -739,16 +482,8 @@ def _task_outcome(
739
  raise EvaluatorError(
740
  f"could not load evaluation data for a task: {error}"
741
  ) from error
742
- targets = None
743
- if task.get("task_type") == PERTURBATION:
744
- try:
745
- targets = _retry(lambda: fetch_targets(task_id, token))
746
- except Exception as error: # noqa: BLE001
747
- raise EvaluatorError(
748
- f"could not load perturbation targets for a task: {error}"
749
- ) from error
750
  try:
751
- score = score_task(task, labels, emb, targets)
752
  except SubmissionError as error:
753
  return TaskOutcome(task_id, dataset_id, "invalid", reason=str(error))
754
  if not np.isfinite(score.score):
@@ -768,7 +503,6 @@ def score_all(
768
  datasets: list[dict] | None = None,
769
  tasks: list[dict] | None = None,
770
  fetch_labels=None,
771
- fetch_targets=None,
772
  ) -> dict:
773
  """Score a submission per task and roll it up into a blind result.
774
 
@@ -798,7 +532,6 @@ def score_all(
798
  f"could not load the task registry: {error}"
799
  ) from error
800
  fetch_labels = fetch_labels or fetch_task_labels
801
- fetch_targets = fetch_targets or fetch_task_targets
802
 
803
  emb_by_ds, block_error = {}, {}
804
  for dataset_id, block in blocks.items():
@@ -813,7 +546,6 @@ def score_all(
813
  emb_by_ds.get(_norm_id(task[DATASET_ID])),
814
  block_error.get(_norm_id(task[DATASET_ID])),
815
  fetch_labels,
816
- fetch_targets,
817
  token,
818
  )
819
  for task in scoreable_tasks(tasks, valid)
 
3
  Loads one embedding submission that spans every dataset (rows keyed by
4
  ``dataset_id`` + ``sample_id``), then scores each TASK = (dataset, target): a
5
  dataset is embedded once and reused across all its tasks. Per task it fits a
6
+ fixed linear probe, averaged over repeated frozen CV folds or fit once on a
7
+ fixed train/test split (transfer), and produces predictions. Turning those
8
  predictions into scores + leaderboard aggregates is the job of ``scoring.py``;
9
  this module only reads data and runs the probe.
10
 
 
14
  is never silently charged against the submitter.
15
 
16
  Reads the public ``datasets.yaml`` manifest (what the submitter embeds) plus the
17
+ private ``tasks.yaml`` registry and each task's private ``labels.csv``. No
18
+ monorepo imports, so it runs unchanged inside a public Hugging Face Space.
 
19
 
20
  Deps: numpy, pandas, scikit-learn, pyyaml. ``huggingface_hub`` is used only by
21
  the fetch helpers / CLI, imported lazily.
 
32
  import numpy as np
33
  import pandas as pd
34
  import yaml
35
+ from scoring import METRICS, TaskScore, category_means
 
 
 
 
 
 
36
  from sklearn.linear_model import LogisticRegressionCV, RidgeCV
37
  from sklearn.model_selection import StratifiedKFold
38
  from sklearn.preprocessing import StandardScaler
 
46
  SPLIT = "split"
47
  SPLIT_TRAIN = "train"
48
  SPLIT_TEST = "test"
49
+
 
 
 
 
 
 
 
 
50
  ORG = "ScientaLab"
51
  PUBLIC_REPO = f"{ORG}/primo"
52
  LABELS_REPO = f"{ORG}/primo-labels"
 
55
  MANIFEST_FILENAME = "datasets.yaml"
56
  TASKS_FILENAME = "tasks.yaml"
57
  LABELS_FILENAME = "labels.csv"
 
58
 
59
  RIDGE_ALPHAS = np.logspace(-3.0, 6.0, 19)
 
60
  LOGREG_CS = 10
61
  INNER_CV = 5
62
  MAX_ITER = 5000
 
94
  reason: str | None = None
95
 
96
 
 
 
 
 
 
 
 
 
97
  def _norm_id(value: object) -> str:
98
  """Normalise an id so int/str/float spellings of the same id join."""
99
  if isinstance(value, float) and value.is_integer():
 
120
  ]
121
  if invalid:
122
  raise EvaluatorError(f"labels.csv has invalid repeat-fold columns: {invalid}")
 
123
  return [
124
+ FOLD,
125
  *sorted(repeated, key=lambda column: int(column.removeprefix(FOLD_PREFIX))),
126
  ]
127
 
 
129
  def load_labels(path: str | Path) -> pd.DataFrame:
130
  """Read private labels with repeated CV folds or one transfer split."""
131
  df = pd.read_csv(path)
132
+ missing = {SAMPLE_ID, LABEL} - set(df.columns)
 
 
133
  if missing:
134
  raise EvaluatorError(f"labels.csv missing columns: {sorted(missing)}")
135
+ has_folds = FOLD in df.columns
136
+ if has_folds == (SPLIT in df.columns):
 
 
 
 
 
137
  raise EvaluatorError(
138
+ f"labels.csv needs exactly one of '{FOLD}' (CV) or '{SPLIT}' (transfer)."
 
139
  )
140
+ if has_folds:
141
+ _fold_columns(df)
 
 
 
 
142
  return df
143
 
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  def load_submission(path: str | Path) -> dict[str, pd.DataFrame]:
146
  """Load a multi-dataset submission into ``{dataset_id: raw_block_frame}``."""
147
  ext = Path(path).suffix.lower()
 
352
  return y[test], preds
353
 
354
 
355
+ def score_task(task: dict, labels: pd.DataFrame, emb: pd.DataFrame) -> TaskScore:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  """Score one task: repeated fold-wise CV, or a fixed transfer split.
357
 
358
+ Reads only ``task_id``, ``dataset_id``, ``metric``, ``task_type`` and
359
+ ``category``. Private biology (disease/tissue/target) never leaks in.
360
  """
361
  metric = task["metric"]
 
 
 
 
 
 
 
 
 
362
  if metric not in METRICS:
363
  raise EvaluatorError(f"unknown metric '{metric}' for task {task.get(TASK_ID)}")
364
  task_type = task["task_type"]
 
455
  return load_labels(path)
456
 
457
 
 
 
 
 
 
 
 
 
 
 
458
  def _retry(fn):
459
  """Call ``fn``, retrying transient failures with linear backoff."""
460
  last: Exception | None = None
 
468
  raise last
469
 
470
 
471
+ def _task_outcome(task: dict, emb, block_error, fetch_labels, token) -> TaskOutcome:
 
 
 
 
 
 
 
472
  """Score one task, mapping each failure to the right category."""
473
  task_id = _norm_id(task[TASK_ID])
474
  dataset_id = _norm_id(task[DATASET_ID])
 
482
  raise EvaluatorError(
483
  f"could not load evaluation data for a task: {error}"
484
  ) from error
 
 
 
 
 
 
 
 
485
  try:
486
+ score = score_task(task, labels, emb)
487
  except SubmissionError as error:
488
  return TaskOutcome(task_id, dataset_id, "invalid", reason=str(error))
489
  if not np.isfinite(score.score):
 
503
  datasets: list[dict] | None = None,
504
  tasks: list[dict] | None = None,
505
  fetch_labels=None,
 
506
  ) -> dict:
507
  """Score a submission per task and roll it up into a blind result.
508
 
 
532
  f"could not load the task registry: {error}"
533
  ) from error
534
  fetch_labels = fetch_labels or fetch_task_labels
 
535
 
536
  emb_by_ds, block_error = {}, {}
537
  for dataset_id, block in blocks.items():
 
546
  emb_by_ds.get(_norm_id(task[DATASET_ID])),
547
  block_error.get(_norm_id(task[DATASET_ID])),
548
  fetch_labels,
 
549
  token,
550
  )
551
  for task in scoreable_tasks(tasks, valid)
leaderboard.py CHANGED
@@ -6,9 +6,9 @@ apart from Gradio and from any I/O so it unit-tests without a network
6
 
7
  Two tables per board, deliberately different:
8
 
9
- ``ranked_table`` the leaderboard. Only models that covered EVERY task the Elo
10
- anchor scored are listed -- a partial run cannot win by
11
- skipping the hard cohorts.
12
  ``per_task_table`` one row per task, one column per model. Keeps partial
13
  submissions, so a newcomer who covered three cohorts sees
14
  their numbers instead of vanishing. Never a ranking.
@@ -53,7 +53,6 @@ SCORE_DECIMALS = 3
53
  ELO_START = 1000.0
54
  ELO_SCALE = 400 / math.log(10)
55
  ELO_REGULARIZATION = 0.1
56
- ELO_ANCHOR_MODEL = "HVG-1200-genes"
57
 
58
 
59
  @dataclass(frozen=True)
@@ -163,58 +162,28 @@ def _repeat_scores(row: pd.Series) -> tuple[float, ...]:
163
  return (float(row["score"]),)
164
 
165
 
166
- def _is_anchor(model: str, is_baseline: bool) -> bool:
167
- """Is this entry the Elo anchor?
168
-
169
- Matched on ``(name, is_baseline)``, never the name alone. A published
170
- baseline carries no ``hf_username``, which deliberately leaves its name free
171
- for a submitter to reuse -- ``latest_only`` then keeps the two as separate
172
- entries. Matching on the name alone would let that submission take over the
173
- anchor and redefine the scale.
174
- """
175
- return is_baseline and model == ELO_ANCHOR_MODEL
176
-
177
-
178
  def _entries(df: pd.DataFrame, by_id: dict[str, dict], board: Board) -> list[dict]:
179
  """Per-category means for every model covering the board's scored tasks.
180
 
181
- The ANCHOR decides which tasks are ranked: a task it did not score is not a
182
- leaderboard task, however many submissions covered it. That is what keeps the
183
- anchor at full coverage on every board, hence always present to hold the Elo
184
- scale down. The task stays visible in the per-task table either way.
185
-
186
- Without the anchor the task set falls back to every task with a finite score
187
- from any latest submission, and the Elo scale to a mean-centred one (see
188
- ``_comparison_stats``). Ranking a board is worth more than ranking it on a
189
- fixed scale. That fallback is decided PER BOARD, not globally: a board the
190
- anchor has not been scored on yet -- a task family added since the baseline
191
- last ran -- still ranks the models that covered it.
192
  """
193
  scoped = _board_registry(by_id, board)
194
  latest = latest_only(df)
195
- if latest.empty:
196
- return []
197
- on_board = latest[latest["task_id"].map(_norm_id).isin(list(board.task_ids))]
198
- anchor_rows = on_board[
199
- [
200
- _is_anchor(str(model), bool(flag))
201
- for model, flag in zip(on_board[MODEL_NAME], on_board[IS_BASELINE])
202
- ]
203
- ]
204
- ranked_rows = on_board if anchor_rows.empty else anchor_rows
205
  scored_tasks = {
206
  _norm_id(row["task_id"])
207
- for _, row in ranked_rows.iterrows()
208
- if math.isfinite(float(row["score"]))
 
209
  }
210
  if not scored_tasks:
211
  return []
212
 
213
  entries = []
214
- for (model, is_baseline, submission_id), rows in latest.groupby(
215
- [MODEL_NAME, IS_BASELINE, SUBMISSION_ID]
216
  ):
217
- submitted = rows["submitted_at"].max()
218
  task_rows = rows[
219
  rows["task_id"].map(_norm_id).isin(list(scored_tasks))
220
  & rows["score"].map(lambda score: math.isfinite(float(score)))
@@ -260,26 +229,12 @@ def _comparison_stats(entries: list[dict], task_ids: set[str]) -> None:
260
  updating a rating sequentially. Thus the result is invariant to the order
261
  in which tasks or models are visited. Repeats of one task share total weight
262
  one, matching TabArena's equal-per-dataset treatment.
263
-
264
- The scale is ANCHORED: ``ELO_ANCHOR_MODEL`` is held at ``ELO_START`` the way
265
- TabArena calibrates 1000 Elo to its default random forest. That is what gives
266
- a rating a meaning of its own -- "beats the 1,200 highest-variance genes this
267
- often" -- and keeps it steady as submissions arrive; centring on the mean
268
- instead moves every model each time a new one is published.
269
-
270
- The anchor is pinned DURING the fit rather than subtracted after it, because
271
- the ridge penalty is not gauge-invariant: it shrinks toward whichever model
272
- the parameterization pins, and a later shift cannot undo that. Pinning the
273
- anchor aims the shrinkage at it, so a model with little evidence reads as the
274
- baseline rather than as an alphabetical accident.
275
-
276
- Entries are keyed by POSITION, never by name -- see ``_is_anchor`` for why two
277
- entries can share one.
278
  """
279
  if not entries:
280
  return
281
- indices = list(range(len(entries)))
282
- ranks: dict[int, list[float]] = {index: [] for index in indices}
 
283
  valid_games = [
284
  task_id
285
  for task_id in task_ids
@@ -292,44 +247,32 @@ def _comparison_stats(entries: list[dict], task_ids: set[str]) -> None:
292
  battles: list[tuple[int, int, float, float]] = []
293
  for task_id in sorted(valid_games):
294
  ordered = sorted(
295
- indices,
296
- key=lambda index: (
297
- -entries[index]["game_scores"][task_id],
298
- entries[index]["model_name"],
299
- ),
300
  )
301
- for position, index in enumerate(ordered, start=1):
302
- score = entries[index]["game_scores"][task_id]
303
  tied_positions = [
304
- rank
305
- for rank, other in enumerate(ordered, start=1)
306
- if entries[other]["game_scores"][task_id] == score
307
  ]
308
- ranks[index].append(sum(tied_positions) / len(tied_positions))
309
  for opponent in ordered[position:]:
310
- other_score = entries[opponent]["game_scores"][task_id]
 
311
  task, _ = task_id.rsplit(":", maxsplit=1)
312
  battles.append(
313
  (
314
- index,
315
- opponent,
316
- 1.0 if score > other_score else 0.5,
317
  1 / repeats_per_task[task],
318
  )
319
  )
320
 
321
- anchor = next(
322
- (
323
- index
324
- for index, entry in enumerate(entries)
325
- if _is_anchor(entry["model_name"], entry["is_baseline"])
326
- ),
327
- None,
328
- )
329
- pinned = len(entries) - 1 if anchor is None else anchor
330
-
331
  def objective(theta_free: np.ndarray) -> tuple[float, np.ndarray]:
332
- theta = np.insert(theta_free, pinned, 0.0)
333
  loss = 0.0
334
  gradient = np.zeros_like(theta)
335
  for left, right, outcome, weight in battles:
@@ -340,25 +283,25 @@ def _comparison_stats(entries: list[dict], task_ids: set[str]) -> None:
340
  gradient[right] -= error
341
  loss += ELO_REGULARIZATION * float(np.dot(theta, theta)) / 2
342
  gradient += ELO_REGULARIZATION * theta
343
- return float(loss), np.delete(gradient, pinned)
344
 
345
- if battles and len(entries) > 1:
346
  fit = minimize(
347
  objective,
348
- np.zeros(len(entries) - 1),
349
  jac=True,
350
  method="BFGS",
351
  )
352
- theta = np.insert(fit.x, pinned, 0.0) if fit.success else np.zeros(len(entries))
353
- if anchor is None:
354
- theta -= theta.mean()
355
- ratings = ELO_START + ELO_SCALE * theta
356
  else:
357
- ratings = np.full(len(entries), ELO_START)
358
- for index, entry in enumerate(entries):
359
- entry["elo"] = float(ratings[index])
 
360
  entry["mean_rank"] = (
361
- float(np.mean(ranks[index])) if ranks[index] else float("nan")
362
  )
363
  entry["mean_score"] = float(np.mean(list(entry["task_scores"].values())))
364
 
@@ -366,13 +309,13 @@ def _comparison_stats(entries: list[dict], task_ids: set[str]) -> None:
366
  def ranked_table(
367
  df: pd.DataFrame, by_id: dict[str, dict], board: Board
368
  ) -> pd.DataFrame:
369
- """Rank the models that covered every task of ``board`` the anchor scored.
370
 
371
- One column per task category, each in its native metric (AUROC, Pearson, or
372
- centered Spearman); two metrics never share a column. ``Mean`` averages those
373
- columns -- it does cross metrics, which is why the pages call it a tie-break
374
- rather than a score -- and is dropped when the board holds a single category,
375
- where it would just repeat it.
376
  """
377
  entries = _entries(df, by_id, board)
378
  if not entries:
 
6
 
7
  Two tables per board, deliberately different:
8
 
9
+ ``ranked_table`` the leaderboard. Only models that covered EVERY task of the
10
+ board are listed -- a partial run cannot win by skipping the
11
+ hard cohorts.
12
  ``per_task_table`` one row per task, one column per model. Keeps partial
13
  submissions, so a newcomer who covered three cohorts sees
14
  their numbers instead of vanishing. Never a ranking.
 
53
  ELO_START = 1000.0
54
  ELO_SCALE = 400 / math.log(10)
55
  ELO_REGULARIZATION = 0.1
 
56
 
57
 
58
  @dataclass(frozen=True)
 
162
  return (float(row["score"]),)
163
 
164
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  def _entries(df: pd.DataFrame, by_id: dict[str, dict], board: Board) -> list[dict]:
166
  """Per-category means for every model covering the board's scored tasks.
167
 
168
+ A task with no finite score from any latest submission is not a leaderboard
169
+ task yet. Keeping it out of the coverage check lets the rest of the board be
170
+ ranked while still leaving the task visible in the per-task table.
 
 
 
 
 
 
 
 
171
  """
172
  scoped = _board_registry(by_id, board)
173
  latest = latest_only(df)
 
 
 
 
 
 
 
 
 
 
174
  scored_tasks = {
175
  _norm_id(row["task_id"])
176
+ for _, row in latest.iterrows()
177
+ if _norm_id(row["task_id"]) in board.task_ids
178
+ and math.isfinite(float(row["score"]))
179
  }
180
  if not scored_tasks:
181
  return []
182
 
183
  entries = []
184
+ for (model, is_baseline, submitted, submission_id), rows in latest.groupby(
185
+ [MODEL_NAME, IS_BASELINE, "submitted_at", SUBMISSION_ID]
186
  ):
 
187
  task_rows = rows[
188
  rows["task_id"].map(_norm_id).isin(list(scored_tasks))
189
  & rows["score"].map(lambda score: math.isfinite(float(score)))
 
229
  updating a rating sequentially. Thus the result is invariant to the order
230
  in which tasks or models are visited. Repeats of one task share total weight
231
  one, matching TabArena's equal-per-dataset treatment.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  """
233
  if not entries:
234
  return
235
+ models = [entry["model_name"] for entry in entries]
236
+ model_index = {model: index for index, model in enumerate(models)}
237
+ ranks = {model: [] for model in models}
238
  valid_games = [
239
  task_id
240
  for task_id in task_ids
 
247
  battles: list[tuple[int, int, float, float]] = []
248
  for task_id in sorted(valid_games):
249
  ordered = sorted(
250
+ entries,
251
+ key=lambda entry: (-entry["game_scores"][task_id], entry["model_name"]),
 
 
 
252
  )
253
+ for position, entry in enumerate(ordered, start=1):
254
+ model, score = entry["model_name"], entry["game_scores"][task_id]
255
  tied_positions = [
256
+ index
257
+ for index, other in enumerate(ordered, start=1)
258
+ if other["game_scores"][task_id] == score
259
  ]
260
+ ranks[model].append(sum(tied_positions) / len(tied_positions))
261
  for opponent in ordered[position:]:
262
+ other = opponent["model_name"]
263
+ outcome = 1.0 if score > opponent["game_scores"][task_id] else 0.5
264
  task, _ = task_id.rsplit(":", maxsplit=1)
265
  battles.append(
266
  (
267
+ model_index[model],
268
+ model_index[other],
269
+ outcome,
270
  1 / repeats_per_task[task],
271
  )
272
  )
273
 
 
 
 
 
 
 
 
 
 
 
274
  def objective(theta_free: np.ndarray) -> tuple[float, np.ndarray]:
275
+ theta = np.append(theta_free, 0.0)
276
  loss = 0.0
277
  gradient = np.zeros_like(theta)
278
  for left, right, outcome, weight in battles:
 
283
  gradient[right] -= error
284
  loss += ELO_REGULARIZATION * float(np.dot(theta, theta)) / 2
285
  gradient += ELO_REGULARIZATION * theta
286
+ return float(loss), gradient[:-1]
287
 
288
+ if battles and len(models) > 1:
289
  fit = minimize(
290
  objective,
291
+ np.zeros(len(models) - 1),
292
  jac=True,
293
  method="BFGS",
294
  )
295
+ theta = np.append(fit.x, 0.0) if fit.success else np.zeros(len(models))
296
+ theta -= theta.mean()
297
+ ratings = dict(zip(models, ELO_START + ELO_SCALE * theta))
 
298
  else:
299
+ ratings = {model: ELO_START for model in models}
300
+ for entry in entries:
301
+ model = entry["model_name"]
302
+ entry["elo"] = ratings[model]
303
  entry["mean_rank"] = (
304
+ float(np.mean(ranks[model])) if ranks[model] else float("nan")
305
  )
306
  entry["mean_score"] = float(np.mean(list(entry["task_scores"].values())))
307
 
 
309
  def ranked_table(
310
  df: pd.DataFrame, by_id: dict[str, dict], board: Board
311
  ) -> pd.DataFrame:
312
+ """Rank the models that covered every task of ``board``.
313
 
314
+ One column per task category, each in its native metric (AUROC or Pearson);
315
+ two metrics never share a column. ``Mean`` averages those columns -- it does
316
+ cross metrics, which is why the pages call it a tie-break rather than a
317
+ score -- and is dropped when the board holds a single category, where it
318
+ would just repeat it.
319
  """
320
  entries = _entries(df, by_id, board)
321
  if not entries:
pages/about.md CHANGED
@@ -3,7 +3,7 @@
3
  **Can a foundation model turn a patient's omics data into an embedding that
4
  predicts meaningful outcomes?**
5
 
6
- You embed each patient. We fit a fixed probe on top and check whether that
7
  embedding predicts a real clinical outcome. You never see the labels, so you
8
  cannot tune to them, and when a score comes out high the credit belongs to the
9
  *embedding*.
@@ -13,32 +13,26 @@ representation is any use in the clinic, and that is the gap PRIMO tries to fill
13
 
14
  ## How a submission is scored
15
 
16
- 1. **You send embeddings, never predictions**: one vector per baseline sample.
17
- 2. **We fit the probe**: ridge for scalar regression, logistic for
18
- classification, or multi-output ridge regression for perturbation
19
- responses. Hyperparameters are picked by inner cross-validation.
20
  3. **Scores use repeated subject-grouped cross-validation.** We average the
21
  fold-level metric over three frozen five-fold partitions. Transfer tasks use
22
- a fixed split instead: train on one source cohort, test on the target cohort.
23
- Perturbation tasks decode hidden post-minus-pre expression responses; the
24
- mouse IBD task reuses three baseline embeddings across its hidden 3×3 response
25
- grid and isolates both specimens in every held-out fold.
26
- 4. **Each task family keeps its own metric.** AUROC, Pearson r and centered
27
- Spearman never share a category column.
28
 
29
  The probe is identical for everyone, which is what makes the numbers comparable:
30
  what changes between two rows is the representation behind them. The rows marked
31
  `(baseline)` are our own submissions, scored by that same probe on the log-CPM
32
  expression itself, whole or cut to its most variable genes. That cut never reads
33
  a label, but it is made over every sample, so read those rows as a floor set by
34
- the raw features rather than as a competing model. Perturbation centered Spearman
35
- is reported directly on `[-1, 1]`, with 0 as the uninformative shared-response
36
- anchor.
37
 
38
  A board covering more than one family also shows a **Mean** of the family
39
- columns. It is there to give the table an order, but it does average AUROC,
40
- Pearson and centered Spearman, so use it as a tie-break and do the real
41
- comparison on the family columns.
42
 
43
  ## How the results are organised
44
 
@@ -62,7 +56,7 @@ embeddings.
62
  |---|---|
63
  | **Per task** | disease, tissue, therapeutic area, what is predicted, class names, patient count, metric |
64
  | **In aggregate** | the public archives the cohorts sit in |
65
- | **Never** | study accessions, which cohort is behind which `dataset_id`, labels, pairing identifiers, and post-treatment responses |
66
 
67
  Every cohort comes from a published study deposited in {repositories}. We name
68
  the archive but never the accession, as publishing the study id behind a
 
3
  **Can a foundation model turn a patient's omics data into an embedding that
4
  predicts meaningful outcomes?**
5
 
6
+ You embed each patient. We fit a linear probe on top and check whether that
7
  embedding predicts a real clinical outcome. You never see the labels, so you
8
  cannot tune to them, and when a score comes out high the credit belongs to the
9
  *embedding*.
 
13
 
14
  ## How a submission is scored
15
 
16
+ 1. **You send embeddings, never predictions**: one vector per sample.
17
+ 2. **We fit the probe**: ridge for regression, logistic for classification,
18
+ regularisation picked by inner cross-validation.
 
19
  3. **Scores use repeated subject-grouped cross-validation.** We average the
20
  fold-level metric over three frozen five-fold partitions. Transfer tasks use
21
+ a fixed split instead: train on one drug's patients, test on the other's.
22
+ 4. **Each task family keeps its own metric.** We never average an AUROC with a
23
+ Pearson r inside one task score, and the two never share a column.
 
 
 
24
 
25
  The probe is identical for everyone, which is what makes the numbers comparable:
26
  what changes between two rows is the representation behind them. The rows marked
27
  `(baseline)` are our own submissions, scored by that same probe on the log-CPM
28
  expression itself, whole or cut to its most variable genes. That cut never reads
29
  a label, but it is made over every sample, so read those rows as a floor set by
30
+ the raw features rather than as a competing model.
 
 
31
 
32
  A board covering more than one family also shows a **Mean** of the family
33
+ columns. It is there to give the table an order, but it does average AUROC with
34
+ Pearson, so use it as a tie-break and do the real comparison on the family
35
+ columns.
36
 
37
  ## How the results are organised
38
 
 
56
  |---|---|
57
  | **Per task** | disease, tissue, therapeutic area, what is predicted, class names, patient count, metric |
58
  | **In aggregate** | the public archives the cohorts sit in |
59
+ | **Never** | study accessions, which cohort is behind which `dataset_id`, and the labels |
60
 
61
  Every cohort comes from a published study deposited in {repositories}. We name
62
  the archive but never the accession, as publishing the study id behind a
pages/leaderboard.md CHANGED
@@ -1,13 +1,6 @@
1
- **🏆 Ranked**: only models that covered every task of this board the
2
- `HVG-1200-genes` baseline scored.
3
  **🔍 Per task**: everyone, partial submissions included.
4
 
5
  One column per task family, each in its own metric. **Mean** is the average of
6
- those columns: it orders the table, but it averages AUROC, Pearson and residual
7
- Spearman, so read it as a tie-break and compare models on the family columns.
8
-
9
- **Elo** is pinned to our `HVG-1200-genes` baseline, which sits at exactly 1000:
10
- above that line a model beats the 1,200 highest-variance genes, below it it does
11
- not. +400 Elo is a 10-to-1 expected win rate. The scale does not move when a new
12
- model is submitted, so ratings stay comparable over time — but only within one
13
- board, since each board is fitted on its own tasks.
 
1
+ **🏆 Ranked**: only models that covered every task of this board.
 
2
  **🔍 Per task**: everyone, partial submissions included.
3
 
4
  One column per task family, each in its own metric. **Mean** is the average of
5
+ those columns: it orders the table, but it averages AUROC with Pearson, so read
6
+ it as a tie-break and compare models on the family columns.
 
 
 
 
 
 
pages/submit.md CHANGED
@@ -17,7 +17,7 @@ Or do it by hand, in **three steps**:
17
 
18
  1. **Get the data** → download the datasets from [ScientaLab/primo](https://huggingface.co/datasets/ScientaLab/primo) (start with its `datasets.yaml`).
19
  2. **Embed every dataset** → build **one** file: `dataset_id`, `sample_id`, then one column per embedding dim (`e0`, `e1`, …). CSV / TSV / Parquet, or NPZ.
20
- 3. **Sign in, fill the form, and hit Evaluate.** Add an institution for group submissions, check **Submitted by the model's authors** when applicable, and provide a paper link to make the model name clickable. A fixed task probe scores each hidden task (AUROC, Pearson or centered Spearman), reported per task category in its native metric.
21
 
22
  **Example file**
23
 
@@ -28,9 +28,9 @@ d002,S1,0.31,0.02,-0.15
28
  ```
29
 
30
  **Partial submissions are welcome.** Cover fewer datasets and you are still
31
- scored: you get ranked on every **board** whose scored tasks you covered in
32
- full, and your numbers still show up in each board's **per-task** table, so
33
- nothing you send is thrown away.
34
 
35
  **Your first target is the baselines.** We run our own reference submissions on
36
  the log-CPM expression itself, whole or cut down to its most variable genes, and
 
17
 
18
  1. **Get the data** → download the datasets from [ScientaLab/primo](https://huggingface.co/datasets/ScientaLab/primo) (start with its `datasets.yaml`).
19
  2. **Embed every dataset** → build **one** file: `dataset_id`, `sample_id`, then one column per embedding dim (`e0`, `e1`, …). CSV / TSV / Parquet, or NPZ.
20
+ 3. **Sign in, fill the form, and hit Evaluate.** Add an institution for group submissions, check **Submitted by the model's authors** when applicable, and provide a paper link to make the model name clickable. A fixed linear probe scores each hidden task (AUROC or Pearson), reported per task category in its native metric.
21
 
22
  **Example file**
23
 
 
28
  ```
29
 
30
  **Partial submissions are welcome.** Cover fewer datasets and you are still
31
+ scored: you get ranked on every **board** whose tasks you covered in full, and
32
+ your numbers still show up in each board's **per-task** table, so nothing you
33
+ send is thrown away.
34
 
35
  **Your first target is the baselines.** We run our own reference submissions on
36
  the log-CPM expression itself, whole or cut down to its most variable genes, and
pages/tasks.md CHANGED
@@ -1,3 +1,2 @@
1
- Every scoreable task. Each row is a hidden clinical target or perturbation
2
- response, scored by the same fixed task probe. The cohorts stay anonymous, but the
3
- biology is on the table.
 
1
+ Every scoreable task. Each row is a hidden clinical target, scored by the same
2
+ linear probe. The cohorts stay anonymous, but the biology is on the table.
 
primo.css CHANGED
@@ -194,6 +194,13 @@ a:hover { color: var(--marine-700); }
194
  background: var(--marine-700); color: #fff; box-shadow: var(--shadow-sm); font: 400 12px/1.4 var(--font-sans);
195
  text-align: left; white-space: normal; opacity: 0; pointer-events: none; transform: translateY(3px); transition: opacity .12s ease, transform .12s ease; }
196
  .pm-score-guide:hover::after, .pm-score-guide:focus::after { opacity: 1; transform: translateY(0); }
 
 
 
 
 
 
 
197
  .pm-table .pm-score { text-align: right; font-family: var(--font-mono); font-size: .9375rem; color: var(--marine-700); }
198
  .pm-table .pm-metric { font-family: var(--font-mono); font-size: 12px; color: var(--marine-700); }
199
  .pm-table .pm-groupline td { padding-top: 22px; border-bottom: 0;
 
194
  background: var(--marine-700); color: #fff; box-shadow: var(--shadow-sm); font: 400 12px/1.4 var(--font-sans);
195
  text-align: left; white-space: normal; opacity: 0; pointer-events: none; transform: translateY(3px); transition: opacity .12s ease, transform .12s ease; }
196
  .pm-score-guide:hover::after, .pm-score-guide:focus::after { opacity: 1; transform: translateY(0); }
197
+ .pm-task-guide { position: relative; display: inline-block; cursor: help; border-bottom: 1px dotted var(--marine-200); }
198
+ .pm-task-guide:focus-visible { outline: 2px solid var(--marine-400); outline-offset: 2px; }
199
+ .pm-task-guide::after { content: attr(data-tooltip); position: absolute; z-index: 10; left: 0; bottom: calc(100% + 7px); width: min(360px, 72vw);
200
+ padding: 9px 11px; border: 1px solid var(--marine-200); border-radius: var(--radius-sm); background: var(--marine-700); color: #fff;
201
+ box-shadow: var(--shadow-sm); font: 400 12px/1.45 var(--font-sans); text-align: left; white-space: normal;
202
+ opacity: 0; pointer-events: none; transform: translateY(3px); transition: opacity .12s ease, transform .12s ease; }
203
+ .pm-task-guide:hover::after, .pm-task-guide:focus::after { opacity: 1; transform: translateY(0); }
204
  .pm-table .pm-score { text-align: right; font-family: var(--font-mono); font-size: .9375rem; color: var(--marine-700); }
205
  .pm-table .pm-metric { font-family: var(--font-mono); font-size: 12px; color: var(--marine-700); }
206
  .pm-table .pm-groupline td { padding-top: 22px; border-bottom: 0;
render.py CHANGED
@@ -268,6 +268,28 @@ def _score_guide(col: str, row: pd.Series) -> str | None:
268
  return METRIC_GUIDES.get(metric) if metric is not None else None
269
 
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  def _bold_cells(df: pd.DataFrame, score_cols: list[str], axis: int) -> set:
272
  """Which ``(row, col)`` cells hold the best value.
273
 
@@ -305,7 +327,12 @@ def _cell_class(col: str, is_score: bool, blank: bool) -> str:
305
  return ""
306
 
307
 
308
- def _df_to_table(df: pd.DataFrame, bold_axis: int | None, empty: str) -> str:
 
 
 
 
 
309
  """Render a DataFrame as a ``pm-table``, escaping every header and cell."""
310
  if df.empty:
311
  return f'<p class="pm-caption">{escape(empty)}</p>'
@@ -350,6 +377,18 @@ def _df_to_table(df: pd.DataFrame, bold_axis: int | None, empty: str) -> str:
350
  f'aria-label="{score_label}" '
351
  f'data-tooltip="{escaped_guide}">{shown}</span>'
352
  )
 
 
 
 
 
 
 
 
 
 
 
 
353
  cells.append(f'<td class="{cls}"{weight}>{shown}</td>')
354
  body.append(f"<tr>{''.join(cells)}</tr>")
355
  return (
@@ -384,6 +423,11 @@ def render_board(board: Board | None, df: pd.DataFrame, by_id: dict[str, dict])
384
  per_task_table(df, by_id, board),
385
  bold_axis=1,
386
  empty="No submission has scored on this board yet.",
 
 
 
 
 
387
  )
388
  return (
389
  '<div class="pm-head"><div><p class="pm-over">Board</p>'
@@ -391,11 +435,9 @@ def render_board(board: Board | None, df: pd.DataFrame, by_id: dict[str, dict])
391
  '<div class="pm-body">'
392
  '<div class="pm-group"><div class="pm-group-head">'
393
  '<p class="pm-over pm-over--marine">Ranked</p>'
394
- '<p class="pm-note">Only models that covered every task scored by the '
395
- "<code>HVG-1200-genes</code> baseline are ranked. Elo compares models "
396
- "pairwise within each task and never compares AUROC, Pearson and "
397
- "centered Spearman directly; the baseline holds 1000, so a rating reads "
398
- "as how far a model sits above or below the raw expression.</p>"
399
  f"</div>{ranked}</div>"
400
  '<div class="pm-group"><div class="pm-group-head">'
401
  '<p class="pm-over pm-over--marine">Per task</p>'
@@ -414,7 +456,7 @@ def render_tasks(by_id: dict[str, dict]) -> str:
414
  )
415
  return (
416
  '<div class="pm-head"><div><h1>Tasks</h1>'
417
- f"<p>{len(df)} hidden targets. One fixed task probe reads each "
418
  "one out of your embedding; the cohorts stay anonymous, the biology does "
419
  "not.</p></div></div>"
420
  f'<div class="pm-body">{table}</div>'
 
268
  return METRIC_GUIDES.get(metric) if metric is not None else None
269
 
270
 
271
+ def _task_tooltip(task: dict) -> str:
272
+ """Summarize a task's cohort, input profiles, and prediction target."""
273
+ patients = int(task.get("n_samples") or 0)
274
+ patient_label = "patient" if patients == 1 else "patients"
275
+ diseases = ", ".join(str(d) for d in task.get("diseases") or [])
276
+ tissue = str(task.get("tissue") or "unknown tissue")
277
+ modality = "bulk RNAseq" if task.get("modality") == "bulk RNA" else str(
278
+ task.get("modality") or "omics"
279
+ )
280
+ target = str(task.get("title") or "this clinical target").lower()
281
+ description = str(task.get("description") or "").strip().rstrip(".")
282
+ cohort = (
283
+ f"{patients:,} {patient_label} with {diseases}"
284
+ if diseases
285
+ else f"{patients:,} {patient_label}"
286
+ )
287
+ return (
288
+ f"{description}. {cohort}; uses {modality} profiles from {tissue.lower()} tissue; "
289
+ f"predicts the target: {target}."
290
+ )
291
+
292
+
293
  def _bold_cells(df: pd.DataFrame, score_cols: list[str], axis: int) -> set:
294
  """Which ``(row, col)`` cells hold the best value.
295
 
 
327
  return ""
328
 
329
 
330
+ def _df_to_table(
331
+ df: pd.DataFrame,
332
+ bold_axis: int | None,
333
+ empty: str,
334
+ task_tooltips: dict[str, str] | None = None,
335
+ ) -> str:
336
  """Render a DataFrame as a ``pm-table``, escaping every header and cell."""
337
  if df.empty:
338
  return f'<p class="pm-caption">{escape(empty)}</p>'
 
377
  f'aria-label="{score_label}" '
378
  f'data-tooltip="{escaped_guide}">{shown}</span>'
379
  )
380
+ if col == "Task" and text is not None and task_tooltips:
381
+ tooltip = task_tooltips.get(str(text))
382
+ if tooltip:
383
+ escaped_tooltip = escape(tooltip, quote=True)
384
+ task_label = escape(
385
+ f"{text}. {tooltip}", quote=True
386
+ )
387
+ shown = (
388
+ '<span class="pm-task-guide" tabindex="0" '
389
+ f'aria-label="{task_label}" '
390
+ f'data-tooltip="{escaped_tooltip}">{shown}</span>'
391
+ )
392
  cells.append(f'<td class="{cls}"{weight}>{shown}</td>')
393
  body.append(f"<tr>{''.join(cells)}</tr>")
394
  return (
 
423
  per_task_table(df, by_id, board),
424
  bold_axis=1,
425
  empty="No submission has scored on this board yet.",
426
+ task_tooltips={
427
+ str(task.get("title") or task_id): _task_tooltip(task)
428
+ for task_id, task in by_id.items()
429
+ if task_id in board.task_ids
430
+ },
431
  )
432
  return (
433
  '<div class="pm-head"><div><p class="pm-over">Board</p>'
 
435
  '<div class="pm-body">'
436
  '<div class="pm-group"><div class="pm-group-head">'
437
  '<p class="pm-over pm-over--marine">Ranked</p>'
438
+ f'<p class="pm-note">Only models that covered all {board.n_tasks} tasks are '
439
+ "ranked by Elo. Elo compares models pairwise within each task and never "
440
+ "compares AUROC with Pearson directly.</p>"
 
 
441
  f"</div>{ranked}</div>"
442
  '<div class="pm-group"><div class="pm-group-head">'
443
  '<p class="pm-over pm-over--marine">Per task</p>'
 
456
  )
457
  return (
458
  '<div class="pm-head"><div><h1>Tasks</h1>'
459
+ f"<p>{len(df)} hidden clinical targets. One fixed linear probe reads each "
460
  "one out of your embedding; the cohorts stay anonymous, the biology does "
461
  "not.</p></div></div>"
462
  f'<div class="pm-body">{table}</div>'
results.py CHANGED
@@ -44,7 +44,6 @@ RESULT_COLUMNS = [
44
  "task_id",
45
  "score",
46
  "repeat_scores",
47
- "diagnostics",
48
  "submitted_at",
49
  IS_BASELINE,
50
  OWNER,
 
44
  "task_id",
45
  "score",
46
  "repeat_scores",
 
47
  "submitted_at",
48
  IS_BASELINE,
49
  OWNER,
scoring.py CHANGED
@@ -5,10 +5,10 @@ numbers lives here, kept apart from the probe and from any I/O so it stays easy
5
  to change as the benchmark grows.
6
 
7
  The scoring unit is a task = (dataset, target). Tasks are grouped by their
8
- ``category`` (including treatment outcome, clinical scores, endotype, and
9
- perturbation response). A category uses a SINGLE metric (enforced in the registry),
10
- so its leaderboard number is a plain mean of that metric -- AUROC, Pearson and
11
- centered Spearman are never averaged together inside a category column.
12
 
13
  ``sort_key`` is the one place they are averaged, to give the board a single
14
  order. It is shown as the ``Mean`` column, labelled as a cross-metric average so
@@ -21,7 +21,7 @@ without a network and is safe to rework mid-project.
21
 
22
  from collections import defaultdict
23
  from collections.abc import Callable
24
- from dataclasses import dataclass, field
25
 
26
  import numpy as np
27
  from sklearn.metrics import roc_auc_score
@@ -66,52 +66,6 @@ def compute_pearson(
66
  return float(np.corrcoef(y_pred, y_true)[0, 1])
67
 
68
 
69
- def _average_ranks(values: np.ndarray) -> np.ndarray:
70
- """Return stable, one-based average ranks with deterministic tie handling."""
71
- values = np.asarray(values)
72
- order = np.argsort(values, kind="stable")
73
- sorted_values = values[order]
74
- sorted_ranks = np.empty(len(values), dtype=float)
75
- start = 0
76
- while start < len(values):
77
- stop = start + 1
78
- while stop < len(values) and sorted_values[stop] == sorted_values[start]:
79
- stop += 1
80
- sorted_ranks[start:stop] = 0.5 * (start + stop - 1) + 1.0
81
- start = stop
82
- ranks = np.empty(len(values), dtype=float)
83
- ranks[order] = sorted_ranks
84
- return ranks
85
-
86
-
87
- def _mean_sample_spearman(y_true: np.ndarray, y_pred: np.ndarray) -> float:
88
- """Return mean row-wise rank correlation, scoring degenerate rows at zero."""
89
- correlations = []
90
- for truth, prediction in zip(y_true, y_pred):
91
- correlation = compute_pearson(_average_ranks(truth), _average_ranks(prediction))
92
- correlations.append(correlation if np.isfinite(correlation) else 0.0)
93
- return float(np.mean(correlations)) if correlations else float("nan")
94
-
95
-
96
- def compute_residual_sample_spearman(
97
- y_true: np.ndarray,
98
- y_pred: np.ndarray,
99
- training_mean: np.ndarray,
100
- ) -> float:
101
- """Mean sample-wise Spearman beyond the training-fold mean response."""
102
- return _mean_sample_spearman(y_true - training_mean, y_pred - training_mean)
103
-
104
-
105
- def compute_target_centered_sample_spearman(
106
- y_true: np.ndarray,
107
- y_pred: np.ndarray,
108
- ) -> float:
109
- """Mean sample-wise Spearman after removing each target-cohort gene mean."""
110
- truth_centered = y_true - np.mean(y_true, axis=0)
111
- prediction_centered = y_pred - np.mean(y_pred, axis=0)
112
- return _mean_sample_spearman(truth_centered, prediction_centered)
113
-
114
-
115
  METRICS: dict[str, Callable[[np.ndarray, np.ndarray, np.ndarray | None], float]] = {
116
  "auroc": compute_auroc,
117
  "pearson": compute_pearson,
@@ -129,14 +83,13 @@ class TaskScore:
129
  score: float
130
  n_samples: int
131
  repeat_scores: tuple[float, ...] = ()
132
- diagnostics: dict[str, float] = field(default_factory=dict)
133
 
134
 
135
  def category_means(scores: list[TaskScore]) -> dict[str, dict]:
136
  """Mean of the native metric per task category.
137
 
138
  A category uses one metric, so this is a plain mean of that metric -- never a
139
- mix of native metrics. Degenerate (non-finite) task scores are dropped from
140
  the mean. Returns ``{category: {metric, mean, n_tasks}}`` for the categories
141
  present in ``scores``.
142
  """
@@ -158,7 +111,7 @@ def sort_key(categories: dict[str, dict]) -> float:
158
  """Leaderboard ranking key: mean of the per-category means.
159
 
160
  Orders the rows, and is shown as the ``Mean`` column on boards holding more
161
- than one category. It does average across native metrics, a
162
  deliberate compromise for a single order; swap for a per-category-normalized
163
  mean if the ranking needs to be metric-fair.
164
 
 
5
  to change as the benchmark grows.
6
 
7
  The scoring unit is a task = (dataset, target). Tasks are grouped by their
8
+ ``category`` (``treatment_outcome`` / ``clinical_scores`` / ``endotype``). A
9
+ category uses a SINGLE metric (enforced in the registry), so its leaderboard
10
+ number is a plain mean of that metric -- AUROC and Pearson are never averaged
11
+ together inside a category column.
12
 
13
  ``sort_key`` is the one place they are averaged, to give the board a single
14
  order. It is shown as the ``Mean`` column, labelled as a cross-metric average so
 
21
 
22
  from collections import defaultdict
23
  from collections.abc import Callable
24
+ from dataclasses import dataclass
25
 
26
  import numpy as np
27
  from sklearn.metrics import roc_auc_score
 
66
  return float(np.corrcoef(y_pred, y_true)[0, 1])
67
 
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  METRICS: dict[str, Callable[[np.ndarray, np.ndarray, np.ndarray | None], float]] = {
70
  "auroc": compute_auroc,
71
  "pearson": compute_pearson,
 
83
  score: float
84
  n_samples: int
85
  repeat_scores: tuple[float, ...] = ()
 
86
 
87
 
88
  def category_means(scores: list[TaskScore]) -> dict[str, dict]:
89
  """Mean of the native metric per task category.
90
 
91
  A category uses one metric, so this is a plain mean of that metric -- never a
92
+ mix of AUROC and Pearson. Degenerate (non-finite) task scores are dropped from
93
  the mean. Returns ``{category: {metric, mean, n_tasks}}`` for the categories
94
  present in ``scores``.
95
  """
 
111
  """Leaderboard ranking key: mean of the per-category means.
112
 
113
  Orders the rows, and is shown as the ``Mean`` column on boards holding more
114
+ than one category. It does average across metrics (AUROC + Pearson), a
115
  deliberate compromise for a single order; swap for a per-category-normalized
116
  mean if the ranking needs to be metric-fair.
117