Spaces:
Running
Running
Add single-cell modality and COVID severity task
Browse files- README.md +20 -10
- app.py +30 -4
- boards.py +3 -3
- evaluator.py +27 -9
- leaderboard.py +1 -1
- pages/submit.md +15 -6
- quickstart.py +74 -29
- render.py +8 -1
README.md
CHANGED
|
@@ -17,8 +17,8 @@ thumbnail: https://huggingface.co/spaces/ScientaLab/primo-eval/resolve/main/asse
|
|
| 17 |
**A blind benchmark for omics foundation models.**
|
| 18 |
|
| 19 |
PRIMO grades how well a model turns a patient's omics data into a useful
|
| 20 |
-
**patient embedding**. You
|
| 21 |
-
fixed 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,20 +42,22 @@ that does not exist.
|
|
| 42 |
|
| 43 |
## What's in the data
|
| 44 |
|
| 45 |
-
PRIMO benchmarks any omics modality.
|
| 46 |
-
|
| 47 |
-
labels or treatment-induced expression
|
|
|
|
| 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 |
- **Perturbation response**: adalimumab transfer across inflammatory skin
|
| 53 |
diseases, rituximab response in Sjögren salivary gland, and mouse intestinal
|
| 54 |
anti-TNF response
|
| 55 |
|
| 56 |
## Submission format
|
| 57 |
|
| 58 |
-
One file, one row per (`dataset_id`, `sample_id`), spanning
|
| 59 |
|
| 60 |
- **CSV / TSV / Parquet**: a `dataset_id` column, a `sample_id` column, and one
|
| 61 |
numeric column per embedding dimension. Embedding dim may differ per dataset
|
|
@@ -68,6 +70,12 @@ listed in the public `datasets.yaml` manifest. Alignment is by join, so row
|
|
| 68 |
order does not matter; every labelled sample of a task must be present with no
|
| 69 |
NaN/inf, or that task is skipped.
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
## How it works
|
| 72 |
|
| 73 |
Each dataset is embedded once and scored on every hidden task defined for it. Per
|
|
@@ -108,14 +116,16 @@ in each board's **per-task** table even when they are not ranked.
|
|
| 108 |
|
| 109 |
## Make a submission
|
| 110 |
|
| 111 |
-
`quickstart.py` is the shortest path: it downloads
|
| 112 |
-
|
|
|
|
|
|
|
| 113 |
function for your encoder and nothing else changes. `example_submission.csv`
|
| 114 |
shows the expected shape in four lines.
|
| 115 |
|
| 116 |
```bash
|
| 117 |
pip install anndata scikit-learn pandas pyyaml huggingface_hub
|
| 118 |
-
python quickstart.py --out submission.parquet
|
| 119 |
```
|
| 120 |
|
| 121 |
## Run the scorer locally
|
|
@@ -123,7 +133,7 @@ python quickstart.py --out submission.parquet
|
|
| 123 |
```bash
|
| 124 |
pip install -r requirements.txt
|
| 125 |
export HF_TOKEN=... # read access to the PRIMO datasets
|
| 126 |
-
python evaluator.py --submission my_embeddings.parquet
|
| 127 |
```
|
| 128 |
|
| 129 |
## Baselines
|
|
|
|
| 17 |
**A blind benchmark for omics foundation models.**
|
| 18 |
|
| 19 |
PRIMO grades how well a model turns a patient's omics data into a useful
|
| 20 |
+
**patient embedding**. You select one modality, embed its datasets, and upload
|
| 21 |
+
**one** file; a fixed 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. It includes **bulk RNA** tasks across
|
| 46 |
+
immune-mediated inflammatory diseases and a **single-cell RNA** COVID-19 PBMC
|
| 47 |
+
severity task, all with real clinical labels or treatment-induced expression
|
| 48 |
+
responses from published cohorts:
|
| 49 |
|
| 50 |
- **Gastroenterology**: Crohn's disease, ulcerative colitis (anti-TNF response, severity scores)
|
| 51 |
- **Dermatology**: atopic dermatitis, psoriasis (severity scores)
|
| 52 |
- **Rheumatology**: rheumatoid arthritis (joint counts, molecular endotype)
|
| 53 |
+
- **Infectious diseases**: COVID-19 severity from single-cell PBMC expression
|
| 54 |
- **Perturbation response**: adalimumab transfer across inflammatory skin
|
| 55 |
diseases, rituximab response in Sjögren salivary gland, and mouse intestinal
|
| 56 |
anti-TNF response
|
| 57 |
|
| 58 |
## Submission format
|
| 59 |
|
| 60 |
+
One file, one row per (`dataset_id`, `sample_id`), spanning one modality:
|
| 61 |
|
| 62 |
- **CSV / TSV / Parquet**: a `dataset_id` column, a `sample_id` column, and one
|
| 63 |
numeric column per embedding dimension. Embedding dim may differ per dataset
|
|
|
|
| 70 |
order does not matter; every labelled sample of a task must be present with no
|
| 71 |
NaN/inf, or that task is skipped.
|
| 72 |
|
| 73 |
+
Bulk H5AD files have one row per required submission sample. Single-cell H5AD
|
| 74 |
+
files have one sparse raw-count row per cell; opaque cell ids are in
|
| 75 |
+
`obs_names`, and the only public cell metadata is `obs["sample_id"]`, which maps
|
| 76 |
+
each cell to its opaque collection sample. Submissions remain sample-level: emit
|
| 77 |
+
exactly one embedding for every unique `sample_id`, not one embedding per cell.
|
| 78 |
+
|
| 79 |
## How it works
|
| 80 |
|
| 81 |
Each dataset is embedded once and scored on every hidden task defined for it. Per
|
|
|
|
| 116 |
|
| 117 |
## Make a submission
|
| 118 |
|
| 119 |
+
`quickstart.py` is the shortest path: it downloads one modality and embeds its
|
| 120 |
+
datasets
|
| 121 |
+
(log2(CPM+1) → PCA for bulk; per-cell log2(CP10K+1) → sample mean → PCA for
|
| 122 |
+
single-cell) and writes the file the Submit tab wants. Swap its `embed`
|
| 123 |
function for your encoder and nothing else changes. `example_submission.csv`
|
| 124 |
shows the expected shape in four lines.
|
| 125 |
|
| 126 |
```bash
|
| 127 |
pip install anndata scikit-learn pandas pyyaml huggingface_hub
|
| 128 |
+
python quickstart.py --modality bulk-rna --out submission.parquet
|
| 129 |
```
|
| 130 |
|
| 131 |
## Run the scorer locally
|
|
|
|
| 133 |
```bash
|
| 134 |
pip install -r requirements.txt
|
| 135 |
export HF_TOKEN=... # read access to the PRIMO datasets
|
| 136 |
+
python evaluator.py --modality bulk-rna --submission my_embeddings.parquet
|
| 137 |
```
|
| 138 |
|
| 139 |
## Baselines
|
app.py
CHANGED
|
@@ -7,7 +7,7 @@ do I enter?"), Contribute ("what is missing, and how do I add it?"), Method
|
|
| 7 |
board and ``?tab=contribute`` opens a tab -- which is what the rail links and the
|
| 8 |
open cards use. The tab strip is hidden in CSS; the rail is the navigation.
|
| 9 |
|
| 10 |
-
|
| 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
|
|
@@ -94,10 +94,15 @@ THEME = gr.themes.Base(
|
|
| 94 |
|
| 95 |
SUBMIT_HEAD = (
|
| 96 |
'<div class="pm-head"><div><h1>Submit a model</h1>'
|
| 97 |
-
"<p>One
|
| 98 |
-
"board you cover in full.</p></div></div>"
|
| 99 |
)
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
TABLE_SORT_JS = """
|
| 102 |
() => {
|
| 103 |
if (window.pmTableSortBound) return;
|
|
@@ -180,6 +185,16 @@ def _page_text(name: str) -> str:
|
|
| 180 |
return (PAGES_DIR / f"{name}.md").read_text()
|
| 181 |
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
def _registry_by_id() -> dict[str, dict]:
|
| 184 |
"""Scoreable tasks keyed by task_id (dataset present in the public manifest).
|
| 185 |
|
|
@@ -270,6 +285,7 @@ def _rendered_board(slug: str | None) -> str:
|
|
| 270 |
|
| 271 |
def evaluate(
|
| 272 |
submission_path: str,
|
|
|
|
| 273 |
model_name: str,
|
| 274 |
institution: str,
|
| 275 |
is_author_submission: bool,
|
|
@@ -285,6 +301,8 @@ def evaluate(
|
|
| 285 |
|
| 286 |
if profile is None:
|
| 287 |
return _refuse("Please sign in with Hugging Face to submit.")
|
|
|
|
|
|
|
| 288 |
if not submission_path:
|
| 289 |
return _refuse("Please upload a submission file.")
|
| 290 |
if not model_name or not model_name.strip():
|
|
@@ -309,7 +327,7 @@ def evaluate(
|
|
| 309 |
"board keeps each name's latest submission. Please pick another name."
|
| 310 |
)
|
| 311 |
try:
|
| 312 |
-
result = score_all(submission_path, TOKEN)
|
| 313 |
except SubmissionError as error:
|
| 314 |
return _refuse(f"❌ {error}")
|
| 315 |
except EvaluatorError as error:
|
|
@@ -441,6 +459,12 @@ def build_demo() -> gr.Blocks:
|
|
| 441 |
gr.Markdown(_page_text("submit"))
|
| 442 |
with gr.Column(scale=2, elem_id="pm-form"):
|
| 443 |
gr.LoginButton()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
model_tb = gr.Textbox(
|
| 445 |
label="Model name",
|
| 446 |
placeholder="e.g. eva-rna-v1",
|
|
@@ -508,6 +532,7 @@ def build_demo() -> gr.Blocks:
|
|
| 508 |
evaluate,
|
| 509 |
[
|
| 510 |
file_in,
|
|
|
|
| 511 |
model_tb,
|
| 512 |
institution_tb,
|
| 513 |
author_submission_cb,
|
|
@@ -525,6 +550,7 @@ def build_demo() -> gr.Blocks:
|
|
| 525 |
status_html,
|
| 526 |
show_progress="hidden",
|
| 527 |
)
|
|
|
|
| 528 |
demo.load(
|
| 529 |
_init,
|
| 530 |
None,
|
|
|
|
| 7 |
board and ``?tab=contribute`` opens a tab -- which is what the rail links and the
|
| 8 |
open cards use. The tab strip is hidden in CSS; the rail is the navigation.
|
| 9 |
|
| 10 |
+
Choose one modality and upload its embedding file (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
|
|
|
|
| 94 |
|
| 95 |
SUBMIT_HEAD = (
|
| 96 |
'<div class="pm-head"><div><h1>Submit a model</h1>'
|
| 97 |
+
"<p>One model, one modality. Partial coverage is fine within that modality. "
|
| 98 |
+
"You are ranked on every board you cover in full.</p></div></div>"
|
| 99 |
)
|
| 100 |
|
| 101 |
+
MODALITY_CHOICES = [
|
| 102 |
+
("Bulk RNAseq", "bulk-rna"),
|
| 103 |
+
("Single-cell RNAseq", "single-cell-rna"),
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
TABLE_SORT_JS = """
|
| 107 |
() => {
|
| 108 |
if (window.pmTableSortBound) return;
|
|
|
|
| 185 |
return (PAGES_DIR / f"{name}.md").read_text()
|
| 186 |
|
| 187 |
|
| 188 |
+
def _download_help(modality: str | None) -> str:
|
| 189 |
+
"""Show the quickstart command for the selected modality."""
|
| 190 |
+
if not modality:
|
| 191 |
+
return "Select a modality to get its download command."
|
| 192 |
+
return (
|
| 193 |
+
f"```bash\npython quickstart.py --modality {modality} "
|
| 194 |
+
"--out submission.parquet\n```"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
def _registry_by_id() -> dict[str, dict]:
|
| 199 |
"""Scoreable tasks keyed by task_id (dataset present in the public manifest).
|
| 200 |
|
|
|
|
| 285 |
|
| 286 |
def evaluate(
|
| 287 |
submission_path: str,
|
| 288 |
+
modality: str | None,
|
| 289 |
model_name: str,
|
| 290 |
institution: str,
|
| 291 |
is_author_submission: bool,
|
|
|
|
| 301 |
|
| 302 |
if profile is None:
|
| 303 |
return _refuse("Please sign in with Hugging Face to submit.")
|
| 304 |
+
if not modality:
|
| 305 |
+
return _refuse("Please select a modality.")
|
| 306 |
if not submission_path:
|
| 307 |
return _refuse("Please upload a submission file.")
|
| 308 |
if not model_name or not model_name.strip():
|
|
|
|
| 327 |
"board keeps each name's latest submission. Please pick another name."
|
| 328 |
)
|
| 329 |
try:
|
| 330 |
+
result = score_all(submission_path, TOKEN, modality=modality)
|
| 331 |
except SubmissionError as error:
|
| 332 |
return _refuse(f"❌ {error}")
|
| 333 |
except EvaluatorError as error:
|
|
|
|
| 459 |
gr.Markdown(_page_text("submit"))
|
| 460 |
with gr.Column(scale=2, elem_id="pm-form"):
|
| 461 |
gr.LoginButton()
|
| 462 |
+
modality_in = gr.Radio(
|
| 463 |
+
MODALITY_CHOICES,
|
| 464 |
+
label="Modality",
|
| 465 |
+
info="One model submission covers one modality.",
|
| 466 |
+
)
|
| 467 |
+
download_md = gr.Markdown(_download_help(None))
|
| 468 |
model_tb = gr.Textbox(
|
| 469 |
label="Model name",
|
| 470 |
placeholder="e.g. eva-rna-v1",
|
|
|
|
| 532 |
evaluate,
|
| 533 |
[
|
| 534 |
file_in,
|
| 535 |
+
modality_in,
|
| 536 |
model_tb,
|
| 537 |
institution_tb,
|
| 538 |
author_submission_cb,
|
|
|
|
| 550 |
status_html,
|
| 551 |
show_progress="hidden",
|
| 552 |
)
|
| 553 |
+
modality_in.change(_download_help, modality_in, download_md)
|
| 554 |
demo.load(
|
| 555 |
_init,
|
| 556 |
None,
|
boards.py
CHANGED
|
@@ -39,7 +39,7 @@ METRIC_LABEL = {
|
|
| 39 |
}
|
| 40 |
MODALITY_LABEL = {
|
| 41 |
"bulk RNA": "Bulk RNAseq",
|
| 42 |
-
"
|
| 43 |
}
|
| 44 |
|
| 45 |
MODALITY_GROUP = "Per modality"
|
|
@@ -165,8 +165,8 @@ def _cohort_stats(tasks: list[dict]) -> tuple[int, int, int]:
|
|
| 165 |
diseases: set[str] = set()
|
| 166 |
for task in tasks:
|
| 167 |
cohort = str(task.get("cohort_id") or _norm_id(task.get("dataset_id", "")))
|
| 168 |
-
|
| 169 |
-
patients_by_cohort[cohort] = max(patients_by_cohort.get(cohort, 0),
|
| 170 |
diseases.update(str(d) for d in task.get("diseases") or [])
|
| 171 |
return (
|
| 172 |
len(patients_by_cohort),
|
|
|
|
| 39 |
}
|
| 40 |
MODALITY_LABEL = {
|
| 41 |
"bulk RNA": "Bulk RNAseq",
|
| 42 |
+
"Single Cell RNA": "Single-cell RNAseq",
|
| 43 |
}
|
| 44 |
|
| 45 |
MODALITY_GROUP = "Per modality"
|
|
|
|
| 165 |
diseases: set[str] = set()
|
| 166 |
for task in tasks:
|
| 167 |
cohort = str(task.get("cohort_id") or _norm_id(task.get("dataset_id", "")))
|
| 168 |
+
n_subjects = int(task.get("n_subjects") or task.get("n_samples") or 0)
|
| 169 |
+
patients_by_cohort[cohort] = max(patients_by_cohort.get(cohort, 0), n_subjects)
|
| 170 |
diseases.update(str(d) for d in task.get("diseases") or [])
|
| 171 |
return (
|
| 172 |
len(patients_by_cohort),
|
evaluator.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""Standalone probe for the PRIMO public benchmark.
|
| 2 |
|
| 3 |
-
Loads one embedding submission
|
| 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
|
|
@@ -71,6 +71,7 @@ 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
|
|
@@ -652,9 +653,13 @@ def fetch_manifest(token: str | None = None) -> list[dict]:
|
|
| 652 |
return data or []
|
| 653 |
|
| 654 |
|
| 655 |
-
def manifest_ids(manifest: list[dict]) -> set[str]:
|
| 656 |
-
"""
|
| 657 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
|
| 659 |
|
| 660 |
def scoreable_tasks(tasks: list[dict], valid_ids: set[str]) -> list[dict]:
|
|
@@ -765,6 +770,7 @@ def score_all(
|
|
| 765 |
path: str | Path,
|
| 766 |
token: str | None = None,
|
| 767 |
*,
|
|
|
|
| 768 |
datasets: list[dict] | None = None,
|
| 769 |
tasks: list[dict] | None = None,
|
| 770 |
fetch_labels=None,
|
|
@@ -784,11 +790,21 @@ def score_all(
|
|
| 784 |
raise EvaluatorError(
|
| 785 |
f"could not load the dataset manifest: {error}"
|
| 786 |
) from error
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
if unknown:
|
| 791 |
raise SubmissionError(f"unknown dataset_id(s) not in the benchmark: {unknown}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 792 |
|
| 793 |
if tasks is None:
|
| 794 |
try:
|
|
@@ -816,7 +832,8 @@ def score_all(
|
|
| 816 |
fetch_targets,
|
| 817 |
token,
|
| 818 |
)
|
| 819 |
-
for task in scoreable_tasks(tasks,
|
|
|
|
| 820 |
]
|
| 821 |
return _summarize(outcomes)
|
| 822 |
|
|
@@ -868,11 +885,12 @@ def _dataset_status(outcomes: list[TaskOutcome]) -> dict[str, str]:
|
|
| 868 |
def _cli() -> None:
|
| 869 |
parser = argparse.ArgumentParser(description="Score a PRIMO submission locally.")
|
| 870 |
parser.add_argument("--submission", required=True, help="CSV/TSV/Parquet/NPZ file")
|
|
|
|
| 871 |
parser.add_argument("--token", default=None, help="HF token (else env HF_TOKEN)")
|
| 872 |
args = parser.parse_args()
|
| 873 |
|
| 874 |
token = args.token or os.environ.get("HF_TOKEN")
|
| 875 |
-
result = score_all(args.submission, token)
|
| 876 |
print(
|
| 877 |
f"scored : {result['n_datasets_scored']}/{result['n_datasets_total']} "
|
| 878 |
f"datasets, {result['n_scored']}/{result['n_total']} tasks "
|
|
|
|
| 1 |
"""Standalone probe for the PRIMO public benchmark.
|
| 2 |
|
| 3 |
+
Loads one embedding submission for one modality (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
|
|
|
|
| 71 |
TASKS_FILENAME = "tasks.yaml"
|
| 72 |
LABELS_FILENAME = "labels.csv"
|
| 73 |
TARGETS_FILENAME = "targets.npz"
|
| 74 |
+
MODALITIES = {"bulk-rna": "bulk RNA", "single-cell-rna": "Single Cell RNA"}
|
| 75 |
|
| 76 |
RIDGE_ALPHAS = np.logspace(-3.0, 6.0, 19)
|
| 77 |
PERTURBATION_TEST_Z_CLIP = 20.0
|
|
|
|
| 653 |
return data or []
|
| 654 |
|
| 655 |
|
| 656 |
+
def manifest_ids(manifest: list[dict], modality: str | None = None) -> set[str]:
|
| 657 |
+
"""Return manifest dataset ids, optionally for one modality."""
|
| 658 |
+
return {
|
| 659 |
+
_norm_id(entry["id"])
|
| 660 |
+
for entry in manifest
|
| 661 |
+
if modality is None or entry.get("modality") == modality
|
| 662 |
+
}
|
| 663 |
|
| 664 |
|
| 665 |
def scoreable_tasks(tasks: list[dict], valid_ids: set[str]) -> list[dict]:
|
|
|
|
| 770 |
path: str | Path,
|
| 771 |
token: str | None = None,
|
| 772 |
*,
|
| 773 |
+
modality: str | None = None,
|
| 774 |
datasets: list[dict] | None = None,
|
| 775 |
tasks: list[dict] | None = None,
|
| 776 |
fetch_labels=None,
|
|
|
|
| 790 |
raise EvaluatorError(
|
| 791 |
f"could not load the dataset manifest: {error}"
|
| 792 |
) from error
|
| 793 |
+
if modality is not None and modality not in MODALITIES:
|
| 794 |
+
raise SubmissionError(f"unknown or unavailable modality: {modality}")
|
| 795 |
+
all_ids = manifest_ids(datasets)
|
| 796 |
+
valid = manifest_ids(datasets, MODALITIES.get(modality))
|
| 797 |
+
if modality is not None and not valid:
|
| 798 |
+
raise SubmissionError(f"unknown or unavailable modality: {modality}")
|
| 799 |
+
|
| 800 |
+
unknown = sorted(set(blocks) - all_ids)
|
| 801 |
if unknown:
|
| 802 |
raise SubmissionError(f"unknown dataset_id(s) not in the benchmark: {unknown}")
|
| 803 |
+
wrong_modality = sorted(set(blocks) - valid)
|
| 804 |
+
if wrong_modality:
|
| 805 |
+
raise SubmissionError(
|
| 806 |
+
f"dataset_id(s) outside the selected modality: {wrong_modality}"
|
| 807 |
+
)
|
| 808 |
|
| 809 |
if tasks is None:
|
| 810 |
try:
|
|
|
|
| 832 |
fetch_targets,
|
| 833 |
token,
|
| 834 |
)
|
| 835 |
+
for task in scoreable_tasks(tasks, all_ids)
|
| 836 |
+
if _norm_id(task[DATASET_ID]) in valid
|
| 837 |
]
|
| 838 |
return _summarize(outcomes)
|
| 839 |
|
|
|
|
| 885 |
def _cli() -> None:
|
| 886 |
parser = argparse.ArgumentParser(description="Score a PRIMO submission locally.")
|
| 887 |
parser.add_argument("--submission", required=True, help="CSV/TSV/Parquet/NPZ file")
|
| 888 |
+
parser.add_argument("--modality", required=True, choices=MODALITIES)
|
| 889 |
parser.add_argument("--token", default=None, help="HF token (else env HF_TOKEN)")
|
| 890 |
args = parser.parse_args()
|
| 891 |
|
| 892 |
token = args.token or os.environ.get("HF_TOKEN")
|
| 893 |
+
result = score_all(args.submission, token, modality=args.modality)
|
| 894 |
print(
|
| 895 |
f"scored : {result['n_datasets_scored']}/{result['n_datasets_total']} "
|
| 896 |
f"datasets, {result['n_scored']}/{result['n_total']} tasks "
|
leaderboard.py
CHANGED
|
@@ -505,7 +505,7 @@ def tasks_table(tasks: list[dict]) -> pd.DataFrame:
|
|
| 505 |
"Area": str(task.get("therapeutic_area", "")),
|
| 506 |
"Disease": ", ".join(str(d) for d in task.get("diseases") or []),
|
| 507 |
"Tissue": str(task.get("tissue", "")),
|
| 508 |
-
"Patients": task.get("n_samples"),
|
| 509 |
"Metric": metric_label(str(task.get("metric", ""))),
|
| 510 |
}
|
| 511 |
)
|
|
|
|
| 505 |
"Area": str(task.get("therapeutic_area", "")),
|
| 506 |
"Disease": ", ".join(str(d) for d in task.get("diseases") or []),
|
| 507 |
"Tissue": str(task.get("tissue", "")),
|
| 508 |
+
"Patients": task.get("n_subjects") or task.get("n_samples"),
|
| 509 |
"Metric": metric_label(str(task.get("metric", ""))),
|
| 510 |
}
|
| 511 |
)
|
pages/submit.md
CHANGED
|
@@ -1,22 +1,24 @@
|
|
| 1 |
**The fastest way in.** Two files, both in this [Space's repo](https://huggingface.co/spaces/ScientaLab/primo-eval/blob/main/quickstart.py):
|
| 2 |
|
| 3 |
- 📥 [`quickstart.py`](https://huggingface.co/spaces/ScientaLab/primo-eval/blob/main/quickstart.py):
|
| 4 |
-
downloads
|
| 5 |
-
`embed` function for your model and you are done.
|
| 6 |
- 📄 [`example_submission.csv`](https://huggingface.co/spaces/ScientaLab/primo-eval/blob/main/example_submission.csv):
|
| 7 |
four lines, fake numbers, the exact shape we expect.
|
| 8 |
|
| 9 |
```bash
|
| 10 |
pip install anndata scikit-learn pandas pyyaml huggingface_hub
|
| 11 |
-
python quickstart.py --out submission.parquet
|
|
|
|
| 12 |
```
|
| 13 |
|
| 14 |
---
|
| 15 |
|
| 16 |
Or do it by hand, in **three steps**:
|
| 17 |
|
| 18 |
-
1. **
|
| 19 |
-
|
|
|
|
| 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**
|
|
@@ -27,7 +29,14 @@ d001,S1,0.12,-0.44,0.98
|
|
| 27 |
d002,S1,0.31,0.02,-0.15
|
| 28 |
```
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 1 |
**The fastest way in.** Two files, both in this [Space's repo](https://huggingface.co/spaces/ScientaLab/primo-eval/blob/main/quickstart.py):
|
| 2 |
|
| 3 |
- 📥 [`quickstart.py`](https://huggingface.co/spaces/ScientaLab/primo-eval/blob/main/quickstart.py):
|
| 4 |
+
downloads the selected modality, embeds it, and writes a valid submission.
|
| 5 |
+
Swap its `embed` function for your model and you are done.
|
| 6 |
- 📄 [`example_submission.csv`](https://huggingface.co/spaces/ScientaLab/primo-eval/blob/main/example_submission.csv):
|
| 7 |
four lines, fake numbers, the exact shape we expect.
|
| 8 |
|
| 9 |
```bash
|
| 10 |
pip install anndata scikit-learn pandas pyyaml huggingface_hub
|
| 11 |
+
python quickstart.py --modality bulk-rna --out submission.parquet
|
| 12 |
+
# or: --modality single-cell-rna
|
| 13 |
```
|
| 14 |
|
| 15 |
---
|
| 16 |
|
| 17 |
Or do it by hand, in **three steps**:
|
| 18 |
|
| 19 |
+
1. **Choose one modality** → use the selector in the form. The quickstart
|
| 20 |
+
downloads only its datasets from [ScientaLab/primo](https://huggingface.co/datasets/ScientaLab/primo).
|
| 21 |
+
2. **Embed that modality** → build **one** file: `dataset_id`, `sample_id`, then one column per embedding dim (`e0`, `e1`, …). CSV / TSV / Parquet, or NPZ.
|
| 22 |
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.
|
| 23 |
|
| 24 |
**Example file**
|
|
|
|
| 29 |
d002,S1,0.31,0.02,-0.15
|
| 30 |
```
|
| 31 |
|
| 32 |
+
For bulk datasets, each H5AD row is one submission sample. For single-cell
|
| 33 |
+
datasets, H5AD rows are cells and `obs["sample_id"]` maps them to opaque
|
| 34 |
+
collection samples. Aggregate the cells however your model requires and submit
|
| 35 |
+
exactly one embedding per unique `sample_id`; the submission schema is unchanged.
|
| 36 |
+
|
| 37 |
+
Files containing dataset IDs from another modality are rejected. Reusing a model
|
| 38 |
+
name for another modality replaces its previous leaderboard entry. **Partial
|
| 39 |
+
submissions are welcome.** Cover fewer datasets within the selected modality and you are still
|
| 40 |
scored: you get ranked on every **board** whose scored tasks you covered in
|
| 41 |
full, and your numbers still show up in each board's **per-task** table, so
|
| 42 |
nothing you send is thrown away.
|
quickstart.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
"""Produce a valid PRIMO submission in one command, then swap in your own model.
|
| 2 |
|
| 3 |
-
Downloads
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
your encoder and nothing else
|
|
|
|
| 7 |
|
| 8 |
pip install anndata scikit-learn pandas pyyaml huggingface_hub
|
| 9 |
-
python quickstart.py --out submission.parquet
|
| 10 |
|
| 11 |
Standalone on purpose: no import from this Space and none from our monorepo, so
|
| 12 |
it keeps working if you copy the file into your own project.
|
|
@@ -19,64 +20,106 @@ import anndata as ad
|
|
| 19 |
import numpy as np
|
| 20 |
import pandas as pd
|
| 21 |
import yaml
|
| 22 |
-
from huggingface_hub import snapshot_download
|
| 23 |
from sklearn.decomposition import PCA
|
| 24 |
|
| 25 |
PUBLIC_REPO = "ScientaLab/primo"
|
| 26 |
MANIFEST_FILENAME = "datasets.yaml"
|
|
|
|
| 27 |
|
| 28 |
DATASET_ID = "dataset_id"
|
| 29 |
SAMPLE_ID = "sample_id"
|
| 30 |
|
| 31 |
TARGET_SUM = 1_000_000
|
|
|
|
| 32 |
N_COMPONENTS = 50
|
| 33 |
RANDOM_STATE = 0
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
def embed(adata: ad.AnnData) -> np.ndarray:
|
| 37 |
-
"""One dataset's raw counts -> one vector per
|
| 38 |
|
| 39 |
-
Whatever you return, the contract is
|
| 40 |
-
``
|
| 41 |
-
and may differ from one dataset to the next.
|
| 42 |
"""
|
| 43 |
-
x = adata
|
| 44 |
-
x = x.toarray() if hasattr(x, "toarray") else np.asarray(x)
|
| 45 |
-
x = x.astype(float)
|
| 46 |
-
counts = x.sum(axis=1, keepdims=True)
|
| 47 |
-
x = np.log2(x / np.where(counts == 0, 1, counts) * TARGET_SUM + 1)
|
| 48 |
k = min(N_COMPONENTS, x.shape[0] - 1, x.shape[1])
|
| 49 |
return PCA(n_components=k, random_state=RANDOM_STATE).fit_transform(x)
|
| 50 |
|
| 51 |
|
| 52 |
-
def download(token: str | None) -> Path:
|
| 53 |
-
"""
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
-
def
|
| 58 |
-
"""
|
| 59 |
-
manifest = yaml.safe_load(
|
| 60 |
entries = manifest.get("datasets", []) if isinstance(manifest, dict) else manifest
|
| 61 |
-
return [
|
| 62 |
|
| 63 |
|
| 64 |
-
def build(root: Path) -> pd.DataFrame:
|
| 65 |
-
"""Embed
|
| 66 |
|
| 67 |
Datasets of different widths stack into one table; the extra columns of a
|
| 68 |
narrower dataset stay empty and the evaluator drops them per dataset, so each
|
| 69 |
dataset keeps its own embedding size.
|
| 70 |
"""
|
| 71 |
blocks = []
|
| 72 |
-
for
|
| 73 |
-
|
|
|
|
|
|
|
| 74 |
vectors = embed(adata)
|
| 75 |
-
print(
|
|
|
|
|
|
|
|
|
|
| 76 |
block = pd.DataFrame(
|
| 77 |
vectors, columns=[f"e{i}" for i in range(vectors.shape[1])]
|
| 78 |
)
|
| 79 |
-
block.insert(0, SAMPLE_ID,
|
| 80 |
block.insert(0, DATASET_ID, dataset_id)
|
| 81 |
blocks.append(block)
|
| 82 |
return pd.concat(blocks, ignore_index=True)
|
|
@@ -84,11 +127,13 @@ def build(root: Path) -> pd.DataFrame:
|
|
| 84 |
|
| 85 |
def main() -> None:
|
| 86 |
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
|
|
| 87 |
parser.add_argument("--out", type=Path, default=Path("submission.parquet"))
|
| 88 |
parser.add_argument("--token", default=None, help="HF token, if you need one.")
|
| 89 |
args = parser.parse_args()
|
| 90 |
|
| 91 |
-
|
|
|
|
| 92 |
if args.out.suffix == ".csv":
|
| 93 |
submission.to_csv(args.out, index=False)
|
| 94 |
else:
|
|
|
|
| 1 |
"""Produce a valid PRIMO submission in one command, then swap in your own model.
|
| 2 |
|
| 3 |
+
Downloads one modality's public datasets and writes the file the Submit tab
|
| 4 |
+
expects. The embedding here is deliberately dumb: bulk data uses
|
| 5 |
+
log2(CPM+1); single-cell data uses per-cell log2(CP10K+1), mean pooling by
|
| 6 |
+
collection sample, then PCA. Replace ``embed`` with your encoder and nothing else
|
| 7 |
+
changes.
|
| 8 |
|
| 9 |
pip install anndata scikit-learn pandas pyyaml huggingface_hub
|
| 10 |
+
python quickstart.py --modality bulk-rna --out submission.parquet
|
| 11 |
|
| 12 |
Standalone on purpose: no import from this Space and none from our monorepo, so
|
| 13 |
it keeps working if you copy the file into your own project.
|
|
|
|
| 20 |
import numpy as np
|
| 21 |
import pandas as pd
|
| 22 |
import yaml
|
| 23 |
+
from huggingface_hub import hf_hub_download, snapshot_download
|
| 24 |
from sklearn.decomposition import PCA
|
| 25 |
|
| 26 |
PUBLIC_REPO = "ScientaLab/primo"
|
| 27 |
MANIFEST_FILENAME = "datasets.yaml"
|
| 28 |
+
MODALITIES = {"bulk-rna": "bulk RNA", "single-cell-rna": "Single Cell RNA"}
|
| 29 |
|
| 30 |
DATASET_ID = "dataset_id"
|
| 31 |
SAMPLE_ID = "sample_id"
|
| 32 |
|
| 33 |
TARGET_SUM = 1_000_000
|
| 34 |
+
SINGLE_CELL_TARGET_SUM = 10_000
|
| 35 |
N_COMPONENTS = 50
|
| 36 |
RANDOM_STATE = 0
|
| 37 |
|
| 38 |
|
| 39 |
+
def prediction_ids(adata: ad.AnnData) -> np.ndarray:
|
| 40 |
+
"""Opaque collection-sample ids required in the submission."""
|
| 41 |
+
if SAMPLE_ID in adata.obs:
|
| 42 |
+
return pd.unique(adata.obs[SAMPLE_ID].astype(str)).astype(str)
|
| 43 |
+
return adata.obs_names.astype(str).to_numpy()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _log_normalize(x, target_sum: int) -> np.ndarray:
|
| 47 |
+
"""Return dense log2 counts-per-target expression."""
|
| 48 |
+
x = x.toarray() if hasattr(x, "toarray") else np.asarray(x)
|
| 49 |
+
totals = x.sum(axis=1, keepdims=True)
|
| 50 |
+
return np.log2(x / np.where(totals == 0, 1, totals) * target_sum + 1)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _sample_expression(adata: ad.AnnData) -> tuple[np.ndarray, np.ndarray]:
|
| 54 |
+
"""Normalize expression and mean-pool cells into collection samples."""
|
| 55 |
+
ids = prediction_ids(adata)
|
| 56 |
+
if SAMPLE_ID not in adata.obs:
|
| 57 |
+
return ids, _log_normalize(adata.X, TARGET_SUM)
|
| 58 |
+
samples = adata.obs[SAMPLE_ID].astype(str).to_numpy()
|
| 59 |
+
pooled = [
|
| 60 |
+
_log_normalize(adata.X[samples == sample_id], SINGLE_CELL_TARGET_SUM).mean(0)
|
| 61 |
+
for sample_id in ids
|
| 62 |
+
]
|
| 63 |
+
return ids, np.asarray(pooled)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
def embed(adata: ad.AnnData) -> np.ndarray:
|
| 67 |
+
"""One dataset's raw counts -> one vector per collection sample. Replace me.
|
| 68 |
|
| 69 |
+
Whatever you return, the contract is one finite row per id returned by
|
| 70 |
+
``prediction_ids``. The embedding width may differ between datasets.
|
|
|
|
| 71 |
"""
|
| 72 |
+
_, x = _sample_expression(adata)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
k = min(N_COMPONENTS, x.shape[0] - 1, x.shape[1])
|
| 74 |
return PCA(n_components=k, random_state=RANDOM_STATE).fit_transform(x)
|
| 75 |
|
| 76 |
|
| 77 |
+
def download(modality: str, token: str | None) -> Path:
|
| 78 |
+
"""Download the manifest and expression files for one modality."""
|
| 79 |
+
manifest = Path(
|
| 80 |
+
hf_hub_download(
|
| 81 |
+
PUBLIC_REPO, MANIFEST_FILENAME, repo_type="dataset", token=token
|
| 82 |
+
)
|
| 83 |
+
)
|
| 84 |
+
paths = [
|
| 85 |
+
MANIFEST_FILENAME,
|
| 86 |
+
*[str(entry["path"]) for entry in datasets(manifest, modality)],
|
| 87 |
+
]
|
| 88 |
+
return Path(
|
| 89 |
+
snapshot_download(
|
| 90 |
+
PUBLIC_REPO, repo_type="dataset", token=token, allow_patterns=paths
|
| 91 |
+
)
|
| 92 |
+
)
|
| 93 |
|
| 94 |
|
| 95 |
+
def datasets(manifest_path: Path, modality: str) -> list[dict]:
|
| 96 |
+
"""Manifest entries for one modality."""
|
| 97 |
+
manifest = yaml.safe_load(manifest_path.read_text())
|
| 98 |
entries = manifest.get("datasets", []) if isinstance(manifest, dict) else manifest
|
| 99 |
+
return [entry for entry in entries if entry["modality"] == modality]
|
| 100 |
|
| 101 |
|
| 102 |
+
def build(root: Path, modality: str) -> pd.DataFrame:
|
| 103 |
+
"""Embed one modality into the frame the Submit tab expects.
|
| 104 |
|
| 105 |
Datasets of different widths stack into one table; the extra columns of a
|
| 106 |
narrower dataset stay empty and the evaluator drops them per dataset, so each
|
| 107 |
dataset keeps its own embedding size.
|
| 108 |
"""
|
| 109 |
blocks = []
|
| 110 |
+
for entry in datasets(root / MANIFEST_FILENAME, modality):
|
| 111 |
+
dataset_id = str(entry["id"])
|
| 112 |
+
adata = ad.read_h5ad(root / entry["path"])
|
| 113 |
+
sample_ids = prediction_ids(adata)
|
| 114 |
vectors = embed(adata)
|
| 115 |
+
print(
|
| 116 |
+
f"{dataset_id}: {adata.n_obs} observations, {len(sample_ids)} samples "
|
| 117 |
+
f"-> {vectors.shape[1]} dims"
|
| 118 |
+
)
|
| 119 |
block = pd.DataFrame(
|
| 120 |
vectors, columns=[f"e{i}" for i in range(vectors.shape[1])]
|
| 121 |
)
|
| 122 |
+
block.insert(0, SAMPLE_ID, sample_ids)
|
| 123 |
block.insert(0, DATASET_ID, dataset_id)
|
| 124 |
blocks.append(block)
|
| 125 |
return pd.concat(blocks, ignore_index=True)
|
|
|
|
| 127 |
|
| 128 |
def main() -> None:
|
| 129 |
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
| 130 |
+
parser.add_argument("--modality", required=True, choices=MODALITIES)
|
| 131 |
parser.add_argument("--out", type=Path, default=Path("submission.parquet"))
|
| 132 |
parser.add_argument("--token", default=None, help="HF token, if you need one.")
|
| 133 |
args = parser.parse_args()
|
| 134 |
|
| 135 |
+
modality = MODALITIES[args.modality]
|
| 136 |
+
submission = build(download(modality, args.token), modality)
|
| 137 |
if args.out.suffix == ".csv":
|
| 138 |
submission.to_csv(args.out, index=False)
|
| 139 |
else:
|
render.py
CHANGED
|
@@ -343,12 +343,19 @@ def _task_tooltip(task: dict) -> str:
|
|
| 343 |
diseases = (
|
| 344 |
", ".join(str(d) for d in task.get("diseases") or []) or "the listed cohort"
|
| 345 |
)
|
| 346 |
-
patients = task.get("n_samples")
|
| 347 |
patient_text = (
|
| 348 |
f"{patients:,} patients"
|
| 349 |
if isinstance(patients, int)
|
| 350 |
else "an unspecified number of patients"
|
| 351 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
modality = str(task.get("modality") or "omics")
|
| 353 |
tissue = str(task.get("tissue") or "unspecified tissue")
|
| 354 |
target = str(task.get("target") or task.get("title") or "the task target")
|
|
|
|
| 343 |
diseases = (
|
| 344 |
", ".join(str(d) for d in task.get("diseases") or []) or "the listed cohort"
|
| 345 |
)
|
| 346 |
+
patients = task.get("n_subjects") or task.get("n_samples")
|
| 347 |
patient_text = (
|
| 348 |
f"{patients:,} patients"
|
| 349 |
if isinstance(patients, int)
|
| 350 |
else "an unspecified number of patients"
|
| 351 |
)
|
| 352 |
+
n_samples = task.get("n_samples")
|
| 353 |
+
if (
|
| 354 |
+
isinstance(n_samples, int)
|
| 355 |
+
and isinstance(patients, int)
|
| 356 |
+
and n_samples != patients
|
| 357 |
+
):
|
| 358 |
+
patient_text += f" ({n_samples:,} collection samples)"
|
| 359 |
modality = str(task.get("modality") or "omics")
|
| 360 |
tissue = str(task.get("tissue") or "unspecified tissue")
|
| 361 |
target = str(task.get("target") or task.get("title") or "the task target")
|