Spaces:
Running
Running
feat(primo): add task tooltips and custom social preview
Browse files- README.md +9 -63
- app.py +8 -12
- assets/primo-social-preview.png +0 -0
- boards.py +2 -9
- evaluator.py +18 -286
- leaderboard.py +45 -102
- pages/about.md +12 -18
- pages/leaderboard.md +3 -10
- pages/submit.md +4 -4
- pages/tasks.md +2 -3
- primo.css +7 -0
- render.py +49 -7
- results.py +0 -1
- 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 |
-
|
| 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
|
| 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 **
|
| 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
|
| 114 |
-
|
| 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`
|
| 196 |
`ScientaLab/primo-results` (the persisted leaderboard).
|
| 197 |
- Results persist as one normalized `task_results.csv` (`model_name, task_id,
|
| 198 |
-
score, repeat_scores,
|
| 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
|
| 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
|
| 15 |
-
|
| 16 |
-
|
| 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
|
| 213 |
-
"
|
| 214 |
-
"
|
| 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
|
| 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
|
| 7 |
-
|
| 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
|
| 18 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 =
|
| 164 |
-
|
| 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
|
| 172 |
-
"split, or Cartesian pairing."
|
| 173 |
)
|
| 174 |
-
if
|
| 175 |
-
|
| 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
|
| 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 |
-
|
|
|
|
| 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
|
| 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
|
| 10 |
-
|
| 11 |
-
|
| 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 |
-
|
| 182 |
-
|
| 183 |
-
|
| 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
|
| 208 |
-
if
|
|
|
|
| 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 |
-
|
| 282 |
-
|
|
|
|
| 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 |
-
|
| 296 |
-
key=lambda
|
| 297 |
-
-entries[index]["game_scores"][task_id],
|
| 298 |
-
entries[index]["model_name"],
|
| 299 |
-
),
|
| 300 |
)
|
| 301 |
-
for position,
|
| 302 |
-
score =
|
| 303 |
tied_positions = [
|
| 304 |
-
|
| 305 |
-
for
|
| 306 |
-
if
|
| 307 |
]
|
| 308 |
-
ranks[
|
| 309 |
for opponent in ordered[position:]:
|
| 310 |
-
|
|
|
|
| 311 |
task, _ = task_id.rsplit(":", maxsplit=1)
|
| 312 |
battles.append(
|
| 313 |
(
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 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.
|
| 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),
|
| 344 |
|
| 345 |
-
if battles and len(
|
| 346 |
fit = minimize(
|
| 347 |
objective,
|
| 348 |
-
np.zeros(len(
|
| 349 |
jac=True,
|
| 350 |
method="BFGS",
|
| 351 |
)
|
| 352 |
-
theta = np.
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
ratings = ELO_START + ELO_SCALE * theta
|
| 356 |
else:
|
| 357 |
-
ratings =
|
| 358 |
-
for
|
| 359 |
-
entry["
|
|
|
|
| 360 |
entry["mean_rank"] = (
|
| 361 |
-
float(np.mean(ranks[
|
| 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``
|
| 370 |
|
| 371 |
-
One column per task category, each in its native metric (AUROC
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 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
|
| 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
|
| 17 |
-
2. **We fit the probe**: ridge for
|
| 18 |
-
|
| 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
|
| 23 |
-
|
| 24 |
-
|
| 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.
|
| 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
|
| 41 |
-
|
| 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`,
|
| 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
|
| 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
|
| 7 |
-
|
| 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
|
| 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
|
| 32 |
-
|
| 33 |
-
|
| 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
|
| 2 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 395 |
-
"
|
| 396 |
-
"
|
| 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
|
| 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`` (
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 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
|
| 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
|
| 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
|
| 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 |
|