Spaces:
Running
Running
fix(primo): expose branded social preview metadata
Browse files- README.md +10 -20
- app.py +21 -92
- boards.py +8 -9
- evaluator.py +9 -27
- leaderboard.py +1 -1
- pages/submit.md +6 -15
- primo.css +1 -17
- quickstart.py +29 -74
- render.py +4 -43
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 |
-
|
| 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,22 +42,20 @@ that does not exist.
|
|
| 42 |
|
| 43 |
## What's in the data
|
| 44 |
|
| 45 |
-
PRIMO benchmarks any omics modality.
|
| 46 |
-
immune-mediated inflammatory diseases
|
| 47 |
-
|
| 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
|
| 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,12 +68,6 @@ listed in the public `datasets.yaml` manifest. Alignment is by join, so row
|
|
| 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,16 +108,14 @@ in each board's **per-task** table even when they are not ranked.
|
|
| 116 |
|
| 117 |
## Make a submission
|
| 118 |
|
| 119 |
-
`quickstart.py` is the shortest path: it downloads
|
| 120 |
-
|
| 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 --
|
| 129 |
```
|
| 130 |
|
| 131 |
## Run the scorer locally
|
|
@@ -133,7 +123,7 @@ python quickstart.py --modality bulk-rna --out submission.parquet
|
|
| 133 |
```bash
|
| 134 |
pip install -r requirements.txt
|
| 135 |
export HF_TOKEN=... # read access to the PRIMO datasets
|
| 136 |
-
python evaluator.py --
|
| 137 |
```
|
| 138 |
|
| 139 |
## 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 embed every dataset and upload **one** file; a fixed
|
| 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 |
|
| 43 |
## What's in the data
|
| 44 |
|
| 45 |
+
PRIMO benchmarks any omics modality. Today's datasets are all **bulk RNA**,
|
| 46 |
+
covering **immune-mediated inflammatory diseases (IMIDs)** with real clinical
|
| 47 |
+
labels or treatment-induced expression responses 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 |
- **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 all datasets:
|
| 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 |
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 |
|
| 109 |
## Make a submission
|
| 110 |
|
| 111 |
+
`quickstart.py` is the shortest path: it downloads every dataset, embeds each one
|
| 112 |
+
(log2(CPM+1) → PCA) and writes the file the Submit tab wants. Swap its `embed`
|
|
|
|
|
|
|
| 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 |
```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
|
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
|
|
@@ -56,14 +56,7 @@ from evaluator import (
|
|
| 56 |
scoreable_tasks,
|
| 57 |
)
|
| 58 |
from leaderboard import RESERVED_COLUMNS, source_repositories
|
| 59 |
-
from render import
|
| 60 |
-
evaluation_confirmation,
|
| 61 |
-
evaluation_status,
|
| 62 |
-
rail_html,
|
| 63 |
-
render_board,
|
| 64 |
-
render_boards,
|
| 65 |
-
render_tasks,
|
| 66 |
-
)
|
| 67 |
from results import (
|
| 68 |
BASELINE_TAG,
|
| 69 |
INSTITUTION,
|
|
@@ -83,6 +76,19 @@ HERE = Path(__file__).parent
|
|
| 83 |
PAGES_DIR = HERE / "pages"
|
| 84 |
CSS_PATH = str(HERE / "primo.css")
|
| 85 |
ALLOWED_PATHS = [str(HERE / "assets"), str(HERE / "fonts")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
TOKEN = os.environ.get("HF_TOKEN")
|
| 88 |
TAB_IDS = ("boards", "board", "tasks", "submit", "contribute", "method")
|
|
@@ -94,15 +100,10 @@ THEME = gr.themes.Base(
|
|
| 94 |
|
| 95 |
SUBMIT_HEAD = (
|
| 96 |
'<div class="pm-head"><div><h1>Submit a model</h1>'
|
| 97 |
-
"<p>One
|
| 98 |
-
"
|
| 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;
|
|
@@ -143,28 +144,6 @@ TABLE_SORT_JS = """
|
|
| 143 |
const header = event.target.closest("th.pm-sort");
|
| 144 |
if (header) sort(header);
|
| 145 |
});
|
| 146 |
-
document.addEventListener("click", (event) => {
|
| 147 |
-
const evaluateButton = event.target.closest(
|
| 148 |
-
"#pm-evaluate button, button#pm-evaluate"
|
| 149 |
-
);
|
| 150 |
-
if (!evaluateButton) return;
|
| 151 |
-
if (evaluateButton.dataset.confirmed === "true") {
|
| 152 |
-
delete evaluateButton.dataset.confirmed;
|
| 153 |
-
return;
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
-
event.preventDefault();
|
| 157 |
-
event.stopImmediatePropagation();
|
| 158 |
-
const dialog = document.querySelector("#pm-evaluation-confirmation");
|
| 159 |
-
if (!dialog) return;
|
| 160 |
-
dialog.returnValue = "";
|
| 161 |
-
dialog.addEventListener("close", () => {
|
| 162 |
-
if (dialog.returnValue !== "confirm") return;
|
| 163 |
-
evaluateButton.dataset.confirmed = "true";
|
| 164 |
-
evaluateButton.click();
|
| 165 |
-
}, { once: true });
|
| 166 |
-
dialog.showModal();
|
| 167 |
-
}, true);
|
| 168 |
document.addEventListener("keydown", (event) => {
|
| 169 |
if (event.key !== "Enter" && event.key !== " ") return;
|
| 170 |
const header = event.target.closest("th.pm-sort");
|
|
@@ -185,16 +164,6 @@ def _page_text(name: str) -> str:
|
|
| 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,7 +254,6 @@ def _rendered_board(slug: str | None) -> str:
|
|
| 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,8 +269,6 @@ def evaluate(
|
|
| 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,7 +293,7 @@ def evaluate(
|
|
| 327 |
"board keeps each name's latest submission. Please pick another name."
|
| 328 |
)
|
| 329 |
try:
|
| 330 |
-
result = score_all(submission_path, TOKEN
|
| 331 |
except SubmissionError as error:
|
| 332 |
return _refuse(f"❌ {error}")
|
| 333 |
except EvaluatorError as error:
|
|
@@ -365,13 +331,6 @@ def evaluate(
|
|
| 365 |
}
|
| 366 |
for task in result["per_task"]
|
| 367 |
]
|
| 368 |
-
if not rows:
|
| 369 |
-
summary += (
|
| 370 |
-
"\n\n⚠️ No valid scores were produced, so the model was not added to "
|
| 371 |
-
"any board."
|
| 372 |
-
)
|
| 373 |
-
return summary, _rendered_board(slug)
|
| 374 |
-
|
| 375 |
meta = {
|
| 376 |
"model_name": model,
|
| 377 |
"submitted_at": submitted_at,
|
|
@@ -386,17 +345,10 @@ def evaluate(
|
|
| 386 |
}
|
| 387 |
try:
|
| 388 |
append_results(rows, TOKEN)
|
| 389 |
-
except Exception as error: # noqa: BLE001
|
| 390 |
-
traceback.print_exc()
|
| 391 |
-
summary += f"\n\n⚠️ scored, but the leaderboard was not saved: {error}"
|
| 392 |
-
return summary, _rendered_board(slug)
|
| 393 |
-
|
| 394 |
-
summary += f"\n\n✅ **{model} is now shown on the relevant boards.**"
|
| 395 |
-
try:
|
| 396 |
append_submission(meta, TOKEN)
|
| 397 |
except Exception as error: # noqa: BLE001
|
| 398 |
traceback.print_exc()
|
| 399 |
-
summary += f"\n\n⚠️
|
| 400 |
return summary, _rendered_board(slug)
|
| 401 |
|
| 402 |
|
|
@@ -435,6 +387,7 @@ def build_demo() -> gr.Blocks:
|
|
| 435 |
with gr.Blocks(
|
| 436 |
title="PRIMO Benchmark",
|
| 437 |
theme=THEME,
|
|
|
|
| 438 |
css_paths=[CSS_PATH],
|
| 439 |
js=TABLE_SORT_JS,
|
| 440 |
fill_width=True,
|
|
@@ -459,12 +412,6 @@ def build_demo() -> gr.Blocks:
|
|
| 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",
|
|
@@ -504,17 +451,12 @@ def build_demo() -> gr.Blocks:
|
|
| 504 |
)
|
| 505 |
run_btn = gr.Button(
|
| 506 |
"Evaluate",
|
| 507 |
-
elem_id="pm-evaluate",
|
| 508 |
elem_classes=[
|
| 509 |
"pm-btn",
|
| 510 |
"pm-btn--primary",
|
| 511 |
"pm-btn--block",
|
| 512 |
],
|
| 513 |
)
|
| 514 |
-
status_html = gr.HTML(
|
| 515 |
-
evaluation_status(), visible=False
|
| 516 |
-
)
|
| 517 |
-
gr.HTML(evaluation_confirmation())
|
| 518 |
result_md = gr.Markdown()
|
| 519 |
with gr.Tab("Contribute", id="contribute"):
|
| 520 |
with gr.Column(elem_classes=["pm-body", "pm-prose"]):
|
|
@@ -523,16 +465,10 @@ def build_demo() -> gr.Blocks:
|
|
| 523 |
with gr.Column(elem_classes=["pm-body", "pm-prose"]):
|
| 524 |
about_md = gr.Markdown()
|
| 525 |
|
| 526 |
-
|
| 527 |
-
lambda: gr.update(visible=True),
|
| 528 |
-
None,
|
| 529 |
-
status_html,
|
| 530 |
-
show_progress="hidden",
|
| 531 |
-
).then(
|
| 532 |
evaluate,
|
| 533 |
[
|
| 534 |
file_in,
|
| 535 |
-
modality_in,
|
| 536 |
model_tb,
|
| 537 |
institution_tb,
|
| 538 |
author_submission_cb,
|
|
@@ -544,13 +480,6 @@ def build_demo() -> gr.Blocks:
|
|
| 544 |
],
|
| 545 |
[result_md, board_html],
|
| 546 |
)
|
| 547 |
-
evaluation_event.then(
|
| 548 |
-
lambda: gr.update(visible=False),
|
| 549 |
-
None,
|
| 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,
|
|
|
|
| 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 |
+
Upload one embedding file spanning every dataset (rows keyed by ``dataset_id``
|
| 11 |
+ ``sample_id``); a fixed task probe scores each task (a dataset may carry
|
| 12 |
several hidden targets). Results roll up into boards -- the whole modality, one
|
| 13 |
therapeutic area, one task family -- and each board ranks the models that
|
|
|
|
| 56 |
scoreable_tasks,
|
| 57 |
)
|
| 58 |
from leaderboard import RESERVED_COLUMNS, source_repositories
|
| 59 |
+
from render import rail_html, render_board, render_boards, render_tasks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
from results import (
|
| 61 |
BASELINE_TAG,
|
| 62 |
INSTITUTION,
|
|
|
|
| 76 |
PAGES_DIR = HERE / "pages"
|
| 77 |
CSS_PATH = str(HERE / "primo.css")
|
| 78 |
ALLOWED_PATHS = [str(HERE / "assets"), str(HERE / "fonts")]
|
| 79 |
+
SOCIAL_PREVIEW_URL = (
|
| 80 |
+
"https://huggingface.co/spaces/ScientaLab/primo-eval/resolve/main/"
|
| 81 |
+
"assets/primo-social-preview.png"
|
| 82 |
+
)
|
| 83 |
+
PAGE_HEAD = f"""
|
| 84 |
+
<meta property="og:title" content="PRIMO Benchmark" />
|
| 85 |
+
<meta property="og:description" content="A blind benchmark for omics foundation models." />
|
| 86 |
+
<meta property="og:image" content="{SOCIAL_PREVIEW_URL}" />
|
| 87 |
+
<meta name="twitter:card" content="summary_large_image" />
|
| 88 |
+
<meta name="twitter:title" content="PRIMO Benchmark" />
|
| 89 |
+
<meta name="twitter:description" content="A blind benchmark for omics foundation models." />
|
| 90 |
+
<meta name="twitter:image" content="{SOCIAL_PREVIEW_URL}" />
|
| 91 |
+
"""
|
| 92 |
|
| 93 |
TOKEN = os.environ.get("HF_TOKEN")
|
| 94 |
TAB_IDS = ("boards", "board", "tasks", "submit", "contribute", "method")
|
|
|
|
| 100 |
|
| 101 |
SUBMIT_HEAD = (
|
| 102 |
'<div class="pm-head"><div><h1>Submit a model</h1>'
|
| 103 |
+
"<p>One embedding file. Partial coverage is fine. You are ranked on every "
|
| 104 |
+
"board you cover in full.</p></div></div>"
|
| 105 |
)
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
TABLE_SORT_JS = """
|
| 108 |
() => {
|
| 109 |
if (window.pmTableSortBound) return;
|
|
|
|
| 144 |
const header = event.target.closest("th.pm-sort");
|
| 145 |
if (header) sort(header);
|
| 146 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
document.addEventListener("keydown", (event) => {
|
| 148 |
if (event.key !== "Enter" && event.key !== " ") return;
|
| 149 |
const header = event.target.closest("th.pm-sort");
|
|
|
|
| 164 |
return (PAGES_DIR / f"{name}.md").read_text()
|
| 165 |
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
def _registry_by_id() -> dict[str, dict]:
|
| 168 |
"""Scoreable tasks keyed by task_id (dataset present in the public manifest).
|
| 169 |
|
|
|
|
| 254 |
|
| 255 |
def evaluate(
|
| 256 |
submission_path: str,
|
|
|
|
| 257 |
model_name: str,
|
| 258 |
institution: str,
|
| 259 |
is_author_submission: bool,
|
|
|
|
| 269 |
|
| 270 |
if profile is None:
|
| 271 |
return _refuse("Please sign in with Hugging Face to submit.")
|
|
|
|
|
|
|
| 272 |
if not submission_path:
|
| 273 |
return _refuse("Please upload a submission file.")
|
| 274 |
if not model_name or not model_name.strip():
|
|
|
|
| 293 |
"board keeps each name's latest submission. Please pick another name."
|
| 294 |
)
|
| 295 |
try:
|
| 296 |
+
result = score_all(submission_path, TOKEN)
|
| 297 |
except SubmissionError as error:
|
| 298 |
return _refuse(f"❌ {error}")
|
| 299 |
except EvaluatorError as error:
|
|
|
|
| 331 |
}
|
| 332 |
for task in result["per_task"]
|
| 333 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
meta = {
|
| 335 |
"model_name": model,
|
| 336 |
"submitted_at": submitted_at,
|
|
|
|
| 345 |
}
|
| 346 |
try:
|
| 347 |
append_results(rows, TOKEN)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
append_submission(meta, TOKEN)
|
| 349 |
except Exception as error: # noqa: BLE001
|
| 350 |
traceback.print_exc()
|
| 351 |
+
summary += f"\n\n⚠️ scored, but the leaderboard was not saved: {error}"
|
| 352 |
return summary, _rendered_board(slug)
|
| 353 |
|
| 354 |
|
|
|
|
| 387 |
with gr.Blocks(
|
| 388 |
title="PRIMO Benchmark",
|
| 389 |
theme=THEME,
|
| 390 |
+
head=PAGE_HEAD,
|
| 391 |
css_paths=[CSS_PATH],
|
| 392 |
js=TABLE_SORT_JS,
|
| 393 |
fill_width=True,
|
|
|
|
| 412 |
gr.Markdown(_page_text("submit"))
|
| 413 |
with gr.Column(scale=2, elem_id="pm-form"):
|
| 414 |
gr.LoginButton()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
model_tb = gr.Textbox(
|
| 416 |
label="Model name",
|
| 417 |
placeholder="e.g. eva-rna-v1",
|
|
|
|
| 451 |
)
|
| 452 |
run_btn = gr.Button(
|
| 453 |
"Evaluate",
|
|
|
|
| 454 |
elem_classes=[
|
| 455 |
"pm-btn",
|
| 456 |
"pm-btn--primary",
|
| 457 |
"pm-btn--block",
|
| 458 |
],
|
| 459 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
result_md = gr.Markdown()
|
| 461 |
with gr.Tab("Contribute", id="contribute"):
|
| 462 |
with gr.Column(elem_classes=["pm-body", "pm-prose"]):
|
|
|
|
| 465 |
with gr.Column(elem_classes=["pm-body", "pm-prose"]):
|
| 466 |
about_md = gr.Markdown()
|
| 467 |
|
| 468 |
+
run_btn.click(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
evaluate,
|
| 470 |
[
|
| 471 |
file_in,
|
|
|
|
| 472 |
model_tb,
|
| 473 |
institution_tb,
|
| 474 |
author_submission_cb,
|
|
|
|
| 480 |
],
|
| 481 |
[result_md, board_html],
|
| 482 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
demo.load(
|
| 484 |
_init,
|
| 485 |
None,
|
boards.py
CHANGED
|
@@ -38,8 +38,8 @@ METRIC_LABEL = {
|
|
| 38 |
"centered_spearman": "Centered Spearman score",
|
| 39 |
}
|
| 40 |
MODALITY_LABEL = {
|
| 41 |
-
"bulk RNA": "
|
| 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),
|
|
@@ -177,8 +177,7 @@ def _cohort_stats(tasks: list[dict]) -> tuple[int, int, int]:
|
|
| 177 |
|
| 178 |
def _blurb(group: str, name: str, tasks: list[dict]) -> str:
|
| 179 |
if group == MODALITY_GROUP:
|
| 180 |
-
|
| 181 |
-
return f"Every PRIMO task, scored from {display[:1].lower()}{display[1:]} profiles."
|
| 182 |
if group == CATEGORY_GROUP:
|
| 183 |
return CATEGORY_BLURB.get(name, label(name))
|
| 184 |
families = [label(c) for c in distinct(tasks, "category")]
|
|
@@ -241,17 +240,17 @@ class OpenBoard:
|
|
| 241 |
OPEN_BOARDS: tuple[OpenBoard, ...] = (
|
| 242 |
OpenBoard(
|
| 243 |
MODALITY_GROUP,
|
| 244 |
-
"
|
| 245 |
"Dissociated tissue, labelled at the patient level. No cohort yet.",
|
| 246 |
),
|
| 247 |
OpenBoard(
|
| 248 |
MODALITY_GROUP,
|
| 249 |
-
"
|
| 250 |
"Plasma or tissue proteins paired with clinical follow-up. No cohort yet.",
|
| 251 |
),
|
| 252 |
OpenBoard(
|
| 253 |
MODALITY_GROUP,
|
| 254 |
-
"
|
| 255 |
"Expression kept in place in the tissue, with patient outcomes. No cohort yet.",
|
| 256 |
),
|
| 257 |
OpenBoard(
|
|
|
|
| 38 |
"centered_spearman": "Centered Spearman score",
|
| 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_samples = int(task.get("n_samples") or 0)
|
| 169 |
+
patients_by_cohort[cohort] = max(patients_by_cohort.get(cohort, 0), n_samples)
|
| 170 |
diseases.update(str(d) for d in task.get("diseases") or [])
|
| 171 |
return (
|
| 172 |
len(patients_by_cohort),
|
|
|
|
| 177 |
|
| 178 |
def _blurb(group: str, name: str, tasks: list[dict]) -> str:
|
| 179 |
if group == MODALITY_GROUP:
|
| 180 |
+
return f"Every PRIMO task, scored from {modality_label(name)} profiles."
|
|
|
|
| 181 |
if group == CATEGORY_GROUP:
|
| 182 |
return CATEGORY_BLURB.get(name, label(name))
|
| 183 |
families = [label(c) for c in distinct(tasks, "category")]
|
|
|
|
| 240 |
OPEN_BOARDS: tuple[OpenBoard, ...] = (
|
| 241 |
OpenBoard(
|
| 242 |
MODALITY_GROUP,
|
| 243 |
+
"single-cell RNAseq",
|
| 244 |
"Dissociated tissue, labelled at the patient level. No cohort yet.",
|
| 245 |
),
|
| 246 |
OpenBoard(
|
| 247 |
MODALITY_GROUP,
|
| 248 |
+
"proteomics",
|
| 249 |
"Plasma or tissue proteins paired with clinical follow-up. No cohort yet.",
|
| 250 |
),
|
| 251 |
OpenBoard(
|
| 252 |
MODALITY_GROUP,
|
| 253 |
+
"spatial transcriptomics",
|
| 254 |
"Expression kept in place in the tissue, with patient outcomes. No cohort yet.",
|
| 255 |
),
|
| 256 |
OpenBoard(
|
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,7 +71,6 @@ MANIFEST_FILENAME = "datasets.yaml"
|
|
| 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,13 +652,9 @@ def fetch_manifest(token: str | None = None) -> list[dict]:
|
|
| 653 |
return data or []
|
| 654 |
|
| 655 |
|
| 656 |
-
def manifest_ids(manifest: list[dict]
|
| 657 |
-
"""
|
| 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,7 +765,6 @@ def score_all(
|
|
| 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,21 +784,11 @@ def score_all(
|
|
| 790 |
raise EvaluatorError(
|
| 791 |
f"could not load the dataset manifest: {error}"
|
| 792 |
) from error
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 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,8 +816,7 @@ def score_all(
|
|
| 832 |
fetch_targets,
|
| 833 |
token,
|
| 834 |
)
|
| 835 |
-
for task in scoreable_tasks(tasks,
|
| 836 |
-
if _norm_id(task[DATASET_ID]) in valid
|
| 837 |
]
|
| 838 |
return _summarize(outcomes)
|
| 839 |
|
|
@@ -885,12 +868,11 @@ def _dataset_status(outcomes: list[TaskOutcome]) -> dict[str, str]:
|
|
| 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
|
| 894 |
print(
|
| 895 |
f"scored : {result['n_datasets_scored']}/{result['n_datasets_total']} "
|
| 896 |
f"datasets, {result['n_scored']}/{result['n_total']} tasks "
|
|
|
|
| 1 |
"""Standalone probe for the PRIMO public benchmark.
|
| 2 |
|
| 3 |
+
Loads one embedding submission that spans every dataset (rows keyed by
|
| 4 |
``dataset_id`` + ``sample_id``), then scores each TASK = (dataset, target): a
|
| 5 |
dataset is embedded once and reused across all its tasks. Per task it fits a
|
| 6 |
fixed probe—linear for scalar targets and multi-output ridge for response
|
|
|
|
| 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 |
return data or []
|
| 653 |
|
| 654 |
|
| 655 |
+
def manifest_ids(manifest: list[dict]) -> set[str]:
|
| 656 |
+
"""The canonical set of valid dataset ids from the manifest."""
|
| 657 |
+
return {_norm_id(entry["id"]) for entry in manifest}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
|
| 659 |
|
| 660 |
def scoreable_tasks(tasks: list[dict], valid_ids: set[str]) -> list[dict]:
|
|
|
|
| 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 |
raise EvaluatorError(
|
| 785 |
f"could not load the dataset manifest: {error}"
|
| 786 |
) from error
|
| 787 |
+
valid = manifest_ids(datasets)
|
| 788 |
+
|
| 789 |
+
unknown = sorted(set(blocks) - valid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
fetch_targets,
|
| 817 |
token,
|
| 818 |
)
|
| 819 |
+
for task in scoreable_tasks(tasks, valid)
|
|
|
|
| 820 |
]
|
| 821 |
return _summarize(outcomes)
|
| 822 |
|
|
|
|
| 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 "
|
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("
|
| 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_samples"),
|
| 509 |
"Metric": metric_label(str(task.get("metric", ""))),
|
| 510 |
}
|
| 511 |
)
|
pages/submit.md
CHANGED
|
@@ -1,24 +1,22 @@
|
|
| 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 |
-
|
| 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 --
|
| 12 |
-
# or: --modality single-cell-rna
|
| 13 |
```
|
| 14 |
|
| 15 |
---
|
| 16 |
|
| 17 |
Or do it by hand, in **three steps**:
|
| 18 |
|
| 19 |
-
1. **
|
| 20 |
-
|
| 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,14 +27,7 @@ d001,S1,0.12,-0.44,0.98
|
|
| 29 |
d002,S1,0.31,0.02,-0.15
|
| 30 |
```
|
| 31 |
|
| 32 |
-
|
| 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.
|
|
|
|
| 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 every dataset, embeds them, writes a valid submission. Swap its
|
| 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. **Get the data** → download the datasets from [ScientaLab/primo](https://huggingface.co/datasets/ScientaLab/primo) (start with its `datasets.yaml`).
|
| 19 |
+
2. **Embed every dataset** → build **one** file: `dataset_id`, `sample_id`, then one column per embedding dim (`e0`, `e1`, …). CSV / TSV / Parquet, or NPZ.
|
|
|
|
| 20 |
3. **Sign in, fill the form, and hit Evaluate.** Add an institution for group submissions, check **Submitted by the model's authors** when applicable, and provide a paper link to make the model name clickable. A fixed task probe scores each hidden task (AUROC, Pearson or centered Spearman), reported per task category in its native metric.
|
| 21 |
|
| 22 |
**Example file**
|
|
|
|
| 27 |
d002,S1,0.31,0.02,-0.15
|
| 28 |
```
|
| 29 |
|
| 30 |
+
**Partial submissions are welcome.** Cover fewer datasets and you are still
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
scored: you get ranked on every **board** whose scored tasks you covered in
|
| 32 |
full, and your numbers still show up in each board's **per-task** table, so
|
| 33 |
nothing you send is thrown away.
|
primo.css
CHANGED
|
@@ -98,7 +98,7 @@ footer { display: none !important; }
|
|
| 98 |
.pm-rail-summary::-webkit-details-marker { display: none; }
|
| 99 |
.pm-rail-summary:hover { background: var(--sidebar-highlight); }
|
| 100 |
.pm-rail-summary.pm-active { background: var(--sidebar-accent); color: #fff; }
|
| 101 |
-
.pm-rail-summary .pm-chevron { flex: none; color:
|
| 102 |
.pm-rail-section[open] > .pm-rail-summary .pm-chevron { transform: rotate(90deg); }
|
| 103 |
|
| 104 |
.pm-rail-group { padding: 8px 16px 0; }
|
|
@@ -264,22 +264,6 @@ code, .pm-inline-code { font-family: var(--font-mono); font-size: .95em; }
|
|
| 264 |
#pm-form .upload-container, #pm-form .file-preview { border: 1px dashed var(--marine-200) !important;
|
| 265 |
border-radius: var(--radius-md) !important; background: var(--surface-raised, #fff) !important; }
|
| 266 |
|
| 267 |
-
/* ---------- evaluation confirmation ---------- */
|
| 268 |
-
.pm-dialog { width: min(440px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--border);
|
| 269 |
-
border-radius: var(--radius-lg); background: #fff; color: var(--text-body); box-shadow: 0 24px 64px rgb(17 27 68 / .24); }
|
| 270 |
-
.pm-dialog::backdrop { background: rgb(17 27 68 / .55); }
|
| 271 |
-
.pm-dialog-surface { padding: 24px; }
|
| 272 |
-
.pm-dialog h2 { margin: 8px 0 0; font: 600 1.25rem/1.2 var(--font-title); letter-spacing: -.03em; color: var(--marine-700); }
|
| 273 |
-
.pm-dialog-surface > p:not(.pm-over) { margin: 12px 0 0; font-size: 14px; line-height: 1.6; color: var(--text-secondary); }
|
| 274 |
-
.pm-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
|
| 275 |
-
.pm-evaluation-status { display: flex; align-items: center; gap: 10px; font-size: 13px; line-height: 1.45; color: var(--text-secondary); }
|
| 276 |
-
.pm-spinner { width: 18px; height: 18px; flex: none; animation: pm-spin .8s linear infinite; }
|
| 277 |
-
.pm-spinner circle, .pm-spinner path { fill: none; stroke-width: 3; }
|
| 278 |
-
.pm-spinner circle { stroke: var(--marine-100); }
|
| 279 |
-
.pm-spinner path { stroke: var(--marine-500); stroke-linecap: round; }
|
| 280 |
-
@keyframes pm-spin { to { transform: rotate(360deg); } }
|
| 281 |
-
@media (prefers-reduced-motion: reduce) { .pm-spinner { animation-duration: 1.6s; } }
|
| 282 |
-
|
| 283 |
/* ---------- routes (Contribute) ---------- */
|
| 284 |
.pm-route { display: block; padding: 20px; margin-bottom: 12px; border: 1px solid var(--border);
|
| 285 |
border-radius: var(--radius-xl); box-shadow: var(--shadow-xs); text-decoration: none; transition: border-color .15s ease, box-shadow .15s ease; }
|
|
|
|
| 98 |
.pm-rail-summary::-webkit-details-marker { display: none; }
|
| 99 |
.pm-rail-summary:hover { background: var(--sidebar-highlight); }
|
| 100 |
.pm-rail-summary.pm-active { background: var(--sidebar-accent); color: #fff; }
|
| 101 |
+
.pm-rail-summary .pm-chevron { flex: none; color: var(--sidebar-sub-fg); transition: transform .15s ease; }
|
| 102 |
.pm-rail-section[open] > .pm-rail-summary .pm-chevron { transform: rotate(90deg); }
|
| 103 |
|
| 104 |
.pm-rail-group { padding: 8px 16px 0; }
|
|
|
|
| 264 |
#pm-form .upload-container, #pm-form .file-preview { border: 1px dashed var(--marine-200) !important;
|
| 265 |
border-radius: var(--radius-md) !important; background: var(--surface-raised, #fff) !important; }
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
/* ---------- routes (Contribute) ---------- */
|
| 268 |
.pm-route { display: block; padding: 20px; margin-bottom: 12px; border: 1px solid var(--border);
|
| 269 |
border-radius: var(--radius-xl); box-shadow: var(--shadow-xs); text-decoration: none; transition: border-color .15s ease, box-shadow .15s ease; }
|
quickstart.py
CHANGED
|
@@ -1,13 +1,12 @@
|
|
| 1 |
"""Produce a valid PRIMO submission in one command, then swap in your own model.
|
| 2 |
|
| 3 |
-
Downloads
|
| 4 |
-
expects. The embedding here is deliberately dumb
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
changes.
|
| 8 |
|
| 9 |
pip install anndata scikit-learn pandas pyyaml huggingface_hub
|
| 10 |
-
python quickstart.py --
|
| 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,106 +19,64 @@ import anndata as ad
|
|
| 20 |
import numpy as np
|
| 21 |
import pandas as pd
|
| 22 |
import yaml
|
| 23 |
-
from huggingface_hub import
|
| 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
|
| 68 |
|
| 69 |
-
Whatever you return, the contract is
|
| 70 |
-
``
|
|
|
|
| 71 |
"""
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 78 |
-
"""
|
| 79 |
-
|
| 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
|
| 96 |
-
"""
|
| 97 |
-
manifest = yaml.safe_load(
|
| 98 |
entries = manifest.get("datasets", []) if isinstance(manifest, dict) else manifest
|
| 99 |
-
return [entry for entry in entries
|
| 100 |
|
| 101 |
|
| 102 |
-
def build(root: Path
|
| 103 |
-
"""Embed
|
| 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
|
| 111 |
-
|
| 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,
|
| 123 |
block.insert(0, DATASET_ID, dataset_id)
|
| 124 |
blocks.append(block)
|
| 125 |
return pd.concat(blocks, ignore_index=True)
|
|
@@ -127,13 +84,11 @@ def build(root: Path, modality: str) -> pd.DataFrame:
|
|
| 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 |
-
|
| 136 |
-
submission = build(download(modality, args.token), modality)
|
| 137 |
if args.out.suffix == ".csv":
|
| 138 |
submission.to_csv(args.out, index=False)
|
| 139 |
else:
|
|
|
|
| 1 |
"""Produce a valid PRIMO submission in one command, then swap in your own model.
|
| 2 |
|
| 3 |
+
Downloads every public dataset, embeds each one, and writes the single file the
|
| 4 |
+
Submit tab expects. The embedding here is deliberately dumb -- log2(CPM+1) then
|
| 5 |
+
PCA -- because the point is the plumbing, not the score: replace ``embed`` with
|
| 6 |
+
your encoder and nothing else changes.
|
|
|
|
| 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 |
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 patient. Replace me.
|
| 38 |
|
| 39 |
+
Whatever you return, the contract is the same: one row per sample, in
|
| 40 |
+
``adata.obs_names`` order, all finite. The embedding width is yours to pick
|
| 41 |
+
and may differ from one dataset to the next.
|
| 42 |
"""
|
| 43 |
+
x = adata.X
|
| 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 |
+
"""Pull the public benchmark (manifest + every ``expression.h5ad``)."""
|
| 54 |
+
return Path(snapshot_download(PUBLIC_REPO, repo_type="dataset", token=token))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
+
def dataset_ids(root: Path) -> list[str]:
|
| 58 |
+
"""The opaque ids to embed, read off the public manifest."""
|
| 59 |
+
manifest = yaml.safe_load((root / MANIFEST_FILENAME).read_text())
|
| 60 |
entries = manifest.get("datasets", []) if isinstance(manifest, dict) else manifest
|
| 61 |
+
return [str(entry["id"]) for entry in entries]
|
| 62 |
|
| 63 |
|
| 64 |
+
def build(root: Path) -> pd.DataFrame:
|
| 65 |
+
"""Embed every dataset into the one frame the Submit tab expects.
|
| 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 dataset_id in dataset_ids(root):
|
| 73 |
+
adata = ad.read_h5ad(root / dataset_id / "expression.h5ad")
|
|
|
|
|
|
|
| 74 |
vectors = embed(adata)
|
| 75 |
+
print(f"{dataset_id}: {adata.n_obs} samples -> {vectors.shape[1]} dims")
|
|
|
|
|
|
|
|
|
|
| 76 |
block = pd.DataFrame(
|
| 77 |
vectors, columns=[f"e{i}" for i in range(vectors.shape[1])]
|
| 78 |
)
|
| 79 |
+
block.insert(0, SAMPLE_ID, adata.obs_names.to_numpy())
|
| 80 |
block.insert(0, DATASET_ID, dataset_id)
|
| 81 |
blocks.append(block)
|
| 82 |
return pd.concat(blocks, ignore_index=True)
|
|
|
|
| 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 |
+
submission = build(download(args.token))
|
|
|
|
| 92 |
if args.out.suffix == ".csv":
|
| 93 |
submission.to_csv(args.out, index=False)
|
| 94 |
else:
|
render.py
CHANGED
|
@@ -67,42 +67,10 @@ METRIC_GUIDES = {
|
|
| 67 |
"Centered Spearman score": "Range: -1 to 1 · Random predictor: 0 (expected)",
|
| 68 |
}
|
| 69 |
|
| 70 |
-
|
| 71 |
-
def evaluation_confirmation() -> str:
|
| 72 |
-
"""Render the confirmation shown before a model evaluation starts."""
|
| 73 |
-
return (
|
| 74 |
-
'<dialog id="pm-evaluation-confirmation" class="pm-dialog" '
|
| 75 |
-
'aria-labelledby="pm-evaluation-confirmation-title">'
|
| 76 |
-
'<form method="dialog" class="pm-dialog-surface">'
|
| 77 |
-
'<p class="pm-over">Confirm submission</p>'
|
| 78 |
-
'<h2 id="pm-evaluation-confirmation-title">'
|
| 79 |
-
"Evaluate and submit this model?</h2>"
|
| 80 |
-
"<p>Evaluation can take several minutes. If it succeeds, this submission "
|
| 81 |
-
"will update the public leaderboards.</p>"
|
| 82 |
-
'<div class="pm-dialog-actions">'
|
| 83 |
-
'<button class="pm-btn pm-btn--outline" value="cancel">Cancel</button>'
|
| 84 |
-
'<button class="pm-btn pm-btn--primary" value="confirm">'
|
| 85 |
-
"Evaluate model</button>"
|
| 86 |
-
"</div></form></dialog>"
|
| 87 |
-
)
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def evaluation_status() -> str:
|
| 91 |
-
"""Render the progress message shown while an evaluation runs."""
|
| 92 |
-
return (
|
| 93 |
-
'<div class="pm-evaluation-status" role="status" aria-live="polite">'
|
| 94 |
-
'<svg class="pm-spinner" aria-hidden="true" viewBox="0 0 24 24">'
|
| 95 |
-
'<circle cx="12" cy="12" r="9"></circle>'
|
| 96 |
-
'<path d="M12 3a9 9 0 0 1 9 9"></path></svg>'
|
| 97 |
-
"<span>Your embeddings are being evaluated... This can take a few "
|
| 98 |
-
"minutes.</span></div>"
|
| 99 |
-
)
|
| 100 |
-
|
| 101 |
-
|
| 102 |
CHEVRON = (
|
| 103 |
-
'<svg class="pm-chevron" viewBox="0 0 16 16" width="
|
| 104 |
-
'aria-hidden="true"><path d="M6 4l4 4-4 4" fill="none" stroke="
|
| 105 |
-
'stroke-width="
|
| 106 |
)
|
| 107 |
|
| 108 |
|
|
@@ -343,19 +311,12 @@ 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("
|
| 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")
|
|
|
|
| 67 |
"Centered Spearman score": "Range: -1 to 1 · Random predictor: 0 (expected)",
|
| 68 |
}
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
CHEVRON = (
|
| 71 |
+
'<svg class="pm-chevron" viewBox="0 0 16 16" width="12" height="12" '
|
| 72 |
+
'aria-hidden="true"><path d="M6 4l4 4-4 4" fill="none" stroke="currentColor" '
|
| 73 |
+
'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>'
|
| 74 |
)
|
| 75 |
|
| 76 |
|
|
|
|
| 311 |
diseases = (
|
| 312 |
", ".join(str(d) for d in task.get("diseases") or []) or "the listed cohort"
|
| 313 |
)
|
| 314 |
+
patients = task.get("n_samples")
|
| 315 |
patient_text = (
|
| 316 |
f"{patients:,} patients"
|
| 317 |
if isinstance(patients, int)
|
| 318 |
else "an unspecified number of patients"
|
| 319 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
modality = str(task.get("modality") or "omics")
|
| 321 |
tissue = str(task.get("tissue") or "unspecified tissue")
|
| 322 |
target = str(task.get("target") or task.get("title") or "the task target")
|