HungryTorch commited on
Commit
34e889f
·
verified ·
1 Parent(s): dfc6762

Add single-cell modality and COVID severity task

Browse files
Files changed (8) hide show
  1. README.md +20 -10
  2. app.py +30 -4
  3. boards.py +3 -3
  4. evaluator.py +27 -9
  5. leaderboard.py +1 -1
  6. pages/submit.md +15 -6
  7. quickstart.py +74 -29
  8. 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 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,20 +42,22 @@ that does not exist.
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,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 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,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
- 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
@@ -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 embedding file. Partial coverage is fine. You are ranked on every "
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
- "single-cell RNA": "Single-cell RNAseq",
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
- 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),
 
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 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,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
- """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,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
- 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,7 +832,8 @@ def score_all(
816
  fetch_targets,
817
  token,
818
  )
819
- for task in scoreable_tasks(tasks, valid)
 
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 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,7 +29,14 @@ d001,S1,0.12,-0.44,0.98
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.
 
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 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,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 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,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
- submission = build(download(args.token))
 
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")