Spaces:
Running
Running
Commit ·
feb0811
0
Parent(s):
Deploy v0.1.2
Browse filesCo-authored-by: HungryTorch <HungryTorch@users.noreply.huggingface.co>
Co-authored-by: scienta-hermes[bot] <scienta-hermes[bot]@users.noreply.huggingface.co>
- .gitattributes +35 -0
- LICENSE-STRABLE +29 -0
- README.md +285 -0
- app.py +884 -0
- assets/hugging-face.svg +9 -0
- assets/primo-mark.webp +0 -0
- assets/primo-social-preview.png +0 -0
- assets/scienta-lab.svg +21 -0
- boards.py +375 -0
- evaluator.py +1725 -0
- example_submission.csv +5 -0
- leaderboard.py +643 -0
- pages/about.md +123 -0
- pages/contribute.md +56 -0
- pages/submit.md +60 -0
- pages/tasks.md +3 -0
- primo.css +396 -0
- quickstart.py +164 -0
- render.py +873 -0
- requirements.txt +9 -0
- rescore.py +465 -0
- results.py +458 -0
- scoring.py +171 -0
- submit_api.py +123 -0
- versions.py +57 -0
- versions.yaml +9 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
LICENSE-STRABLE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
BSD 3-Clause License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 The STRABLE developers.
|
| 4 |
+
All rights reserved.
|
| 5 |
+
|
| 6 |
+
Redistribution and use in source and binary forms, with or without
|
| 7 |
+
modification, are permitted provided that the following conditions are met:
|
| 8 |
+
|
| 9 |
+
* Redistributions of source code must retain the above copyright notice, this
|
| 10 |
+
list of conditions and the following disclaimer.
|
| 11 |
+
|
| 12 |
+
* Redistributions in binary form must reproduce the above copyright notice,
|
| 13 |
+
this list of conditions and the following disclaimer in the documentation
|
| 14 |
+
and/or other materials provided with the distribution.
|
| 15 |
+
|
| 16 |
+
* Neither the name of the copyright holder nor the names of its
|
| 17 |
+
contributors may be used to endorse or promote products derived from
|
| 18 |
+
this software without specific prior written permission.
|
| 19 |
+
|
| 20 |
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
| 21 |
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
| 22 |
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
| 23 |
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
| 24 |
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
| 25 |
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
| 26 |
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
| 27 |
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
| 28 |
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
| 29 |
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
README.md
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: PRIMO Benchmark
|
| 3 |
+
emoji: 🧬
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.50.0
|
| 8 |
+
python_version: "3.13"
|
| 9 |
+
app_file: app.py
|
| 10 |
+
pinned: false
|
| 11 |
+
hf_oauth: true
|
| 12 |
+
thumbnail: https://huggingface.co/spaces/PRIMOmics/primo-eval/resolve/main/assets/primo-social-preview.png
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
# PRIMO: Patient Representations in Multi-Omics
|
| 16 |
+
|
| 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 (`d002`, `d003`…) 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.
|
| 25 |
+
|
| 26 |
+
A top bar navigates five pages: **Leaderboard** (one ranking at a time, picked
|
| 27 |
+
with the Modality, Therapeutic area and Task pills), **Tasks**, a **Submit** form
|
| 28 |
+
(sign in with Hugging Face), **Contribute**, and **Method**. Every page has its
|
| 29 |
+
own URL, as `?tab=contribute`, and every view too, as
|
| 30 |
+
`?modality=bulk-rna&area=oncology&task=all`, so both are shareable deep links.
|
| 31 |
+
Old `?board=oncology-bulk-rna` links still open their view.
|
| 32 |
+
|
| 33 |
+
The last panel of the leaderboard names the omics layers and the therapeutic
|
| 34 |
+
areas PRIMO does not cover yet, and links to Contribute. They are declared in
|
| 35 |
+
`boards.py` (`OPEN_BOARDS`) and drop out on their own once the registry covers
|
| 36 |
+
that slice. Task categories deliberately get none, because a
|
| 37 |
+
category is pinned to a single metric, so an open one would advertise a probe
|
| 38 |
+
that does not exist.
|
| 39 |
+
|
| 40 |
+
🌐 Website: http://primomics.org/ ·
|
| 41 |
+
📦 Data: https://huggingface.co/datasets/PRIMOmics/primo
|
| 42 |
+
|
| 43 |
+
## What's in the data
|
| 44 |
+
|
| 45 |
+
PRIMO benchmarks any omics modality. It includes **bulk RNA** and **single-cell
|
| 46 |
+
RNA** tasks, all with real clinical labels or treatment-induced expression
|
| 47 |
+
responses from published cohorts. Tasks are grouped in four therapeutic areas:
|
| 48 |
+
|
| 49 |
+
- **Immunology & inflammation**: Crohn's disease, ulcerative colitis, celiac
|
| 50 |
+
disease, psoriasis, atopic dermatitis, hidradenitis suppurativa, rheumatoid
|
| 51 |
+
arthritis, systemic sclerosis, Sjögren's disease and multiple sclerosis
|
| 52 |
+
- **Oncology**: breast, ovarian, prostate, bladder, lung and rectal cancer,
|
| 53 |
+
melanoma, and acute leukaemias
|
| 54 |
+
- **Infectious diseases**: sepsis, COVID-19 and tuberculosis
|
| 55 |
+
- **Metabolic diseases**: fatty liver disease (MASH)
|
| 56 |
+
|
| 57 |
+
A task that fits no area is marked **Other**: today COPD, kidney graft
|
| 58 |
+
rejection, graft-versus-host disease and lung fibrosis. It has no area board,
|
| 59 |
+
but it is still ranked on its modality and category boards.
|
| 60 |
+
|
| 61 |
+
Perturbation-response tasks cover adalimumab transfer between inflammatory skin
|
| 62 |
+
diseases, mouse-to-human dupilumab transfer, and paired pre/post treatment
|
| 63 |
+
cohorts in COPD (inhaled corticosteroids), prostate (enzalutamide), rectal
|
| 64 |
+
(galunisertib) and breast cancer (neoadjuvant chemotherapy).
|
| 65 |
+
|
| 66 |
+
## Submission format
|
| 67 |
+
|
| 68 |
+
One file, one row per (`dataset_id`, `sample_id`), spanning one modality:
|
| 69 |
+
|
| 70 |
+
- **CSV / TSV / Parquet**: a `dataset_id` column, a `sample_id` column, and one
|
| 71 |
+
numeric column per embedding dimension. Embedding dim may differ per dataset
|
| 72 |
+
(pad short datasets with blank columns; blank/NaN padding columns are dropped
|
| 73 |
+
per dataset).
|
| 74 |
+
- **NPZ**: `dataset_ids`, `sample_ids`, and a 2-D `embeddings` array.
|
| 75 |
+
|
| 76 |
+
The valid `dataset_id`s and how to download each dataset's `expression.h5ad` are
|
| 77 |
+
listed in the public `datasets.yaml` manifest. Alignment is by join, so row
|
| 78 |
+
order does not matter; every labelled sample of a task must be present with no
|
| 79 |
+
NaN/inf, or that task is skipped.
|
| 80 |
+
|
| 81 |
+
Bulk H5AD files have one row per required submission sample. Single-cell H5AD
|
| 82 |
+
files have one sparse raw-count row per cell; opaque cell ids are in
|
| 83 |
+
`obs_names`, and the only public cell metadata is `obs["sample_id"]`, which maps
|
| 84 |
+
each cell to its opaque collection sample. Submissions remain sample-level: emit
|
| 85 |
+
exactly one embedding for every unique `sample_id`, not one embedding per cell.
|
| 86 |
+
|
| 87 |
+
## How it works
|
| 88 |
+
|
| 89 |
+
Each dataset is embedded once and scored on every hidden task defined for it.
|
| 90 |
+
Learned embedding rows are scaled to unit length; gene-space baselines keep
|
| 91 |
+
their row scale. Then, per task: standardise on the outer training fold, fit
|
| 92 |
+
logistic regression (classification) or ridge regression with the penalty
|
| 93 |
+
chosen by subject-grouped inner cross-validation, and predict each held-out
|
| 94 |
+
fold. Ridge penalties use an exact spectral search over the fixed grid; when
|
| 95 |
+
regression tasks have identical samples and fold assignments, their matrix
|
| 96 |
+
factorizations are shared while each target still selects its own penalty. We
|
| 97 |
+
calculate the native metric per fold and average it over frozen,
|
| 98 |
+
subject-grouped partitions: **AUROC**
|
| 99 |
+
(classification) or **Pearson r** (regression). Binary tasks with fewer than 30
|
| 100 |
+
subjects use leave-pair-out instead: each positive-negative pair of subjects is
|
| 101 |
+
held out once, the probe is fit on all other subjects, and the AUROC is the
|
| 102 |
+
share of held-out sample pairs ranked correctly. A few tasks instead use a
|
| 103 |
+
fixed train/test split: the probe is fit once on the training portion and
|
| 104 |
+
scored on the held-out portion, with the same subject-grouped penalty
|
| 105 |
+
selection.
|
| 106 |
+
|
| 107 |
+
Tasks are grouped into **four families**, each reported in its own native
|
| 108 |
+
metric. Two families are never merged into one column:
|
| 109 |
+
|
| 110 |
+
- **Treatment outcome**: response to anti-TNF therapy (**AUROC**)
|
| 111 |
+
- **Clinical scores**: disease-severity regression (**Pearson r**)
|
| 112 |
+
- **Endotype**: molecular-subtype classification (**AUROC**)
|
| 113 |
+
- **Perturbation response**: hidden post-minus-pre DEG decoding (**centered
|
| 114 |
+
Spearman**), where 1 is perfect, 0 is uninformative, and -1 is an inverse gene
|
| 115 |
+
ranking.
|
| 116 |
+
|
| 117 |
+
A leaderboard shows one column per family, in its own metric. Elo orders the
|
| 118 |
+
rows, and no column averages across metrics.
|
| 119 |
+
|
| 120 |
+
## Boards
|
| 121 |
+
|
| 122 |
+
Results are shown as **boards**, self-contained leaderboards over a slice of the
|
| 123 |
+
registry: the whole modality, one therapeutic area, one task family. A board
|
| 124 |
+
ranks only the models that covered **all of its tasks**, so a submission that
|
| 125 |
+
skipped Infectious diseases is still ranked on Oncology. Modality is where we draw
|
| 126 |
+
the line: an area or category board never spans two modalities, because an AUROC
|
| 127 |
+
on bulk RNA and an AUROC on single-cell are not measuring the same thing.
|
| 128 |
+
|
| 129 |
+
Partial and failed submissions still get feedback, and their scores always appear
|
| 130 |
+
in each board's **per-task** table even when they are not ranked.
|
| 131 |
+
|
| 132 |
+
## Public tasks
|
| 133 |
+
|
| 134 |
+
The tasks listed in `public_tasks/manifest.yaml` of the data repo have
|
| 135 |
+
downloadable labels. They are tagged **Public** in the task tables and are
|
| 136 |
+
scored like every other task.
|
| 137 |
+
|
| 138 |
+
When the version has public tasks, a **Public tasks** pill shows. Set to
|
| 139 |
+
**Hide**, it re-ranks the view on its private tasks only, without a page reload.
|
| 140 |
+
It shows every task by default. The Tasks page always shows every task.
|
| 141 |
+
|
| 142 |
+
## Make a submission
|
| 143 |
+
|
| 144 |
+
`quickstart.py` is the shortest path: it downloads one modality and embeds its
|
| 145 |
+
datasets
|
| 146 |
+
(log2(CPM+1) → PCA for bulk; per-cell log2(CP10K+1) → sample mean → PCA for
|
| 147 |
+
single-cell) and writes the file the Submit tab wants. Swap its `embed`
|
| 148 |
+
function for your encoder and nothing else changes. `example_submission.csv`
|
| 149 |
+
shows the expected shape in four lines.
|
| 150 |
+
|
| 151 |
+
```bash
|
| 152 |
+
pip install anndata scikit-learn pandas pyarrow pyyaml huggingface_hub
|
| 153 |
+
python quickstart.py --modality bulk-rna --out submission.parquet
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
## Run the scorer locally
|
| 157 |
+
|
| 158 |
+
```bash
|
| 159 |
+
pip install -r requirements.txt
|
| 160 |
+
export HF_TOKEN=... # read access to the PRIMO datasets
|
| 161 |
+
python evaluator.py --modality bulk-rna --submission my_embeddings.parquet
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
It reads the data commits of the current board version (see "Board versions"
|
| 165 |
+
below). Long local runs can be resumed task by task with `--checkpoint-dir PATH`.
|
| 166 |
+
Checkpoints are namespaced by the submission contents, representation,
|
| 167 |
+
reference features and scoring configuration, and are invalidated when private
|
| 168 |
+
task inputs change.
|
| 169 |
+
|
| 170 |
+
`--reference PATH` scores the `+ HVG` variant: the submission rows (after their
|
| 171 |
+
usual normalisation) are joined by sample with a gene-space file of the same
|
| 172 |
+
layout, and every concatenated row is L2-normalised.
|
| 173 |
+
|
| 174 |
+
## Baselines
|
| 175 |
+
|
| 176 |
+
`task_results.csv` carries an `is_baseline` flag. Reference submissions we
|
| 177 |
+
produce ourselves are published with it set, rendered as `name (baseline)`, and
|
| 178 |
+
**ranked in place**. The published `HVG-1200-genes` entry is the **HVG-1200
|
| 179 |
+
log-normalized linear baseline**: bulk samples use log2(CPM+1), while single-cell
|
| 180 |
+
profiles use per-cell log2(CP10K+1) followed by sample-level mean pooling. It
|
| 181 |
+
keeps 1,200 highest-variance genes and the resulting row scale rather than
|
| 182 |
+
receiving the L2 row normalisation applied to learned embeddings, then uses
|
| 183 |
+
train-fold standardisation and the regularisation-tuned linear probe. Gene
|
| 184 |
+
selection is label-blind but fit over every sample of a dataset, so this is an
|
| 185 |
+
information-rich transductive reference rather than a naïve raw-count baseline
|
| 186 |
+
or an expected lower bound. `scripts/baselines.py` builds it from the public data.
|
| 187 |
+
|
| 188 |
+
## `+ HVG` entries
|
| 189 |
+
|
| 190 |
+
Every Space submission is scored a second time concatenated with the
|
| 191 |
+
`HVG-1200-genes` features and stored as `<model> + HVG`, with the parent's
|
| 192 |
+
owner, credits and `submission_id`. The features are kept privately at
|
| 193 |
+
`baselines/HVG-1200-genes/submission.parquet` in `PRIMOmics/primo-results`.
|
| 194 |
+
`scripts/baselines.py --publish` uploads them and logs the commit that holds them.
|
| 195 |
+
The reference is pinned by the board version. A new reference opens a new
|
| 196 |
+
version, and the rescore below scores the baseline and every `+ HVG` entry again
|
| 197 |
+
with it.
|
| 198 |
+
|
| 199 |
+
## Board versions
|
| 200 |
+
|
| 201 |
+
A board version is a minor release of primo-eval: version `0.1` is scored by the
|
| 202 |
+
`v0.1.x` releases. `versions.yaml` lists every version with its release tag, the
|
| 203 |
+
day it opened, the Hub commits its scores read, and its `notes`: one line on what
|
| 204 |
+
changed. The commits are `data` in `PRIMOmics/primo`, `labels` in
|
| 205 |
+
`PRIMOmics/primo-labels`, and `reference`, the commit of `PRIMOmics/primo-results`
|
| 206 |
+
that holds the HVG features. `current` is the version this code scores.
|
| 207 |
+
|
| 208 |
+
The scorer reads these commits, not the latest ones, so a push to a data repo
|
| 209 |
+
changes no score until a release opens a new version. `requirements.txt` pins the
|
| 210 |
+
packages that can move a score (numpy, pandas, joblib, scikit-learn, scipy) to the
|
| 211 |
+
versions the Space runs.
|
| 212 |
+
|
| 213 |
+
Each result row records the version that scored it, and a board shows only the
|
| 214 |
+
rows of its version. Rows written before versions existed carry none, so they are
|
| 215 |
+
shown on no board.
|
| 216 |
+
|
| 217 |
+
The "Release notes" section, at the bottom of the leaderboard, lists every version
|
| 218 |
+
with its notes and links to the board of each one. `?version=0.1` opens a closed
|
| 219 |
+
version, read-only: its registry at its pinned commits and the rows it scored,
|
| 220 |
+
with a notice under the top bar that links back to the current version. Every link keeps
|
| 221 |
+
the version, except Submit: a submission always goes to the current version.
|
| 222 |
+
|
| 223 |
+
When a release opens a new version, `rescore.py` fills its board from the archived
|
| 224 |
+
embedding files of the previous version, so nobody has to submit again. Each live
|
| 225 |
+
submission is scored again, plain and `+ HVG`, and keeps its name, credits,
|
| 226 |
+
submission id and date. The `HVG-1200-genes` baseline is scored again from the
|
| 227 |
+
reference features. An entry keeps its tasks, plus the tasks the new version adds.
|
| 228 |
+
If an entry fails, or gets no score on a task it held, nothing is published. The
|
| 229 |
+
release steps are in `CONTRIBUTING.md`.
|
| 230 |
+
|
| 231 |
+
## Space configuration
|
| 232 |
+
|
| 233 |
+
- **`hf_oauth: true`** (set above) turns on the Submit tab's *Sign in with
|
| 234 |
+
Hugging Face* button; submitting requires a logged-in HF account.
|
| 235 |
+
- `primo submit` calls the **`/submit`** endpoint of `submit_api.py`, with the
|
| 236 |
+
fields of the form and the caller's token. The endpoint reads the token only
|
| 237 |
+
to learn the account name, and never stores it. The form and the endpoint
|
| 238 |
+
share the same checks and one queue, so one submission runs at a time.
|
| 239 |
+
- Set an **`HF_TOKEN`** Space secret (fine-grained) with: **read** on
|
| 240 |
+
`PRIMOmics/primo` (the public `datasets.yaml` manifest) and
|
| 241 |
+
`PRIMOmics/primo-labels` (the private `tasks.yaml` registry +
|
| 242 |
+
`<task_id>/labels.csv` and perturbation `<task_id>/targets.npz`), and **read + write** on
|
| 243 |
+
`PRIMOmics/primo-results` (the persisted leaderboard).
|
| 244 |
+
- Results persist as one normalized `task_results.csv` (`model_name, task_id,
|
| 245 |
+
score, repeat_scores, diagnostics, submitted_at, is_baseline, hf_username,
|
| 246 |
+
version`) in the results dataset; the
|
| 247 |
+
leaderboard is recomputed from it by joining the registry, so it survives
|
| 248 |
+
Space restarts.
|
| 249 |
+
- Among the rows of its version, a board keeps the **latest result for each
|
| 250 |
+
`(model name, is_baseline, task)`**,
|
| 251 |
+
so submissions accumulate: a later one fills in the tasks it covers and leaves
|
| 252 |
+
the rest standing, and resubmitting a task replaces just that result. Because a
|
| 253 |
+
task belongs to exactly one modality, one name can hold bulk and single-cell
|
| 254 |
+
results at once without either displacing the other.
|
| 255 |
+
- A model name is owned by the account that first submitted it: `hf_username`
|
| 256 |
+
locks it, and Submit refuses a name somebody else holds. It is never rendered
|
| 257 |
+
anywhere: it works as a lock rather than as a credit. Names colliding with a
|
| 258 |
+
per-task column (`Task`, `Family`, `Area`, `Modality`, `Metric`, `Best`) are
|
| 259 |
+
refused too, since model names become column headers, and so are names
|
| 260 |
+
containing the `+ HVG` tag.
|
| 261 |
+
- The boards show a **Submissions count** per model — the most times any one of
|
| 262 |
+
its task results was resubmitted in the board's version — so a task tuned
|
| 263 |
+
against by repeated resubmission is visible. An account can publish 4
|
| 264 |
+
submissions in 24 hours, and a file can weigh 500 MB at most.
|
| 265 |
+
- Submitter contact metadata (HF username, email, paper / model links, notes)
|
| 266 |
+
persists to a separate `submissions.csv` in the same **private** results
|
| 267 |
+
dataset, and never reaches the public leaderboard.
|
| 268 |
+
|
| 269 |
+
## Moving to another Hugging Face org
|
| 270 |
+
|
| 271 |
+
The three dataset repos are derived from one constant, `ORG` in `evaluator.py`.
|
| 272 |
+
The rest of the org name is spelled out and has to be changed by hand:
|
| 273 |
+
|
| 274 |
+
- `PUBLIC_REPO` in `quickstart.py` (standalone, so it cannot import `ORG`)
|
| 275 |
+
- `SOCIAL_PREVIEW_URL` in `app.py`
|
| 276 |
+
- the links in this file, including the YAML `thumbnail:` above, and in
|
| 277 |
+
`pages/*.md`
|
| 278 |
+
- outside this folder: `DATA_REPO` in `src/primomics/data.py`, `SPACE` in
|
| 279 |
+
`src/primomics/submit.py`, the links of the top-level `README.md`, and
|
| 280 |
+
the deploy command in `CONTRIBUTING.md`
|
| 281 |
+
|
| 282 |
+
The theme follows the PRIMO charter: Funnel Display for headings, Funnel Sans for
|
| 283 |
+
everything else, Scienta Navy `#080F5F` / PRIMO Cyan `#16B3C0` on Paper
|
| 284 |
+
`#F3F8F8`. `colorFrom`/`colorTo` above stay `indigo`/`blue` because Hugging Face
|
| 285 |
+
only accepts eight named colours and none of them is cyan.
|
app.py
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio front-end for the PRIMO public benchmark.
|
| 2 |
+
|
| 3 |
+
A top bar navigates five pages: Leaderboard (one ranking, chosen with the
|
| 4 |
+
Modality, Therapeutic area and Task pills), Tasks ("what is actually being
|
| 5 |
+
tested?"), Submit ("how do I enter?"), Contribute ("what is missing, and how do
|
| 6 |
+
I add it?"), Method ("can I trust this?"). Every page is addressable --
|
| 7 |
+
``?tab=contribute`` opens a tab and ``?modality=bulk-rna&area=oncology&task=all``
|
| 8 |
+
a view; old ``?board=<slug>`` links still open their view. The tab strip is
|
| 9 |
+
hidden in CSS; the top bar is the navigation.
|
| 10 |
+
``&version=<number>`` shows a closed board version, read-only: its registry at
|
| 11 |
+
its pinned commits and the rows it scored. Submit always shows the current one.
|
| 12 |
+
|
| 13 |
+
Choose one modality and upload its embedding file (rows keyed by ``dataset_id``
|
| 14 |
+
+ ``sample_id``); a fixed task probe scores each task (a dataset may carry
|
| 15 |
+
several hidden targets). Results roll up into boards -- the whole modality, one
|
| 16 |
+
therapeutic area, one task family -- and each board ranks the models that
|
| 17 |
+
covered every task our baseline scored, one column per category in its native
|
| 18 |
+
metric (AUROC, Pearson, or centered Spearman). The rows are ordered by Elo; no
|
| 19 |
+
column averages across metrics.
|
| 20 |
+
|
| 21 |
+
Disclosure policy -- what the public pages may show:
|
| 22 |
+
per task disease, tissue, area, modality, what is predicted, class names, n, metric
|
| 23 |
+
aggregated the public archives the cohorts sit in, and their licences
|
| 24 |
+
never study accessions, dataset_id -> cohort, hub keys, per-sample labels,
|
| 25 |
+
pairing identifiers or post-treatment responses
|
| 26 |
+
The public tasks are the exception: their labels and responses are published in
|
| 27 |
+
the public data repo, and the pages tag them ``Public``.
|
| 28 |
+
Naming the accession behind ``d002`` would put every label one GEO download
|
| 29 |
+
away, so ``sources`` is collapsed to its archive (NCBI GEO / EMBL-EBI
|
| 30 |
+
ArrayExpress) and ``citation`` is never rendered at all. Submitters can publish
|
| 31 |
+
an optional institution, paper link and an author-submission check; legacy private
|
| 32 |
+
usernames remain hidden.
|
| 33 |
+
|
| 34 |
+
Failures are surfaced by who owns them: a bad file tells the submitter exactly
|
| 35 |
+
what to fix; an evaluator-side failure says "our side, please retry" and logs
|
| 36 |
+
the traceback rather than blaming the submission.
|
| 37 |
+
|
| 38 |
+
A page load is ONE fetch: ``_init`` pulls the registry, the boards and the
|
| 39 |
+
persisted results once and threads that state into every page, rather than each
|
| 40 |
+
page fetching for itself. The state stays in the session, so a click on a pill
|
| 41 |
+
re-renders the view from it, with no reload and no new fetch.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
import os
|
| 45 |
+
import traceback
|
| 46 |
+
from datetime import datetime, timezone
|
| 47 |
+
from pathlib import Path
|
| 48 |
+
from uuid import uuid4
|
| 49 |
+
|
| 50 |
+
import gradio as gr
|
| 51 |
+
import pandas as pd
|
| 52 |
+
from boards import (
|
| 53 |
+
ALL,
|
| 54 |
+
PUBLIC,
|
| 55 |
+
Board,
|
| 56 |
+
area_choices,
|
| 57 |
+
board_view,
|
| 58 |
+
build_boards,
|
| 59 |
+
category_choices,
|
| 60 |
+
modality_choices,
|
| 61 |
+
view_board,
|
| 62 |
+
without_public,
|
| 63 |
+
)
|
| 64 |
+
from evaluator import (
|
| 65 |
+
EMBEDDING,
|
| 66 |
+
EvaluatorError,
|
| 67 |
+
SubmissionError,
|
| 68 |
+
_norm_id,
|
| 69 |
+
fetch_manifest,
|
| 70 |
+
fetch_public_task_ids,
|
| 71 |
+
fetch_tasks_registry,
|
| 72 |
+
manifest_ids,
|
| 73 |
+
score_all,
|
| 74 |
+
scoreable_tasks,
|
| 75 |
+
)
|
| 76 |
+
from leaderboard import RESERVED_COLUMNS, source_repositories
|
| 77 |
+
from render import (
|
| 78 |
+
BOARD_SUB,
|
| 79 |
+
CREDIT,
|
| 80 |
+
VERSION_PARAM,
|
| 81 |
+
cta_html,
|
| 82 |
+
evaluation_confirmation,
|
| 83 |
+
evaluation_status,
|
| 84 |
+
hero_html,
|
| 85 |
+
history_html,
|
| 86 |
+
nav_html,
|
| 87 |
+
render_tasks,
|
| 88 |
+
render_view,
|
| 89 |
+
)
|
| 90 |
+
from results import (
|
| 91 |
+
BASELINE_TAG,
|
| 92 |
+
HVG_TAG,
|
| 93 |
+
INSTITUTION,
|
| 94 |
+
IS_AUTHOR_SUBMISSION,
|
| 95 |
+
OWNER,
|
| 96 |
+
PAPER_LINK,
|
| 97 |
+
RESULT_COLUMNS,
|
| 98 |
+
SUBMISSION_ID,
|
| 99 |
+
append_results,
|
| 100 |
+
append_submission,
|
| 101 |
+
archive_submission,
|
| 102 |
+
fetch_hvg_reference,
|
| 103 |
+
hvg_name,
|
| 104 |
+
owner_of,
|
| 105 |
+
read_results,
|
| 106 |
+
task_rows,
|
| 107 |
+
version_rows,
|
| 108 |
+
)
|
| 109 |
+
from submit_api import (
|
| 110 |
+
ERROR,
|
| 111 |
+
MAX_FILE_SIZE,
|
| 112 |
+
PUBLISHED,
|
| 113 |
+
REFUSED,
|
| 114 |
+
SUBMISSIONS,
|
| 115 |
+
SUBMISSIONS_PER_DAY,
|
| 116 |
+
SUBMITTED_AT,
|
| 117 |
+
recent_submissions,
|
| 118 |
+
reply,
|
| 119 |
+
route,
|
| 120 |
+
)
|
| 121 |
+
from versions import CURRENT, VERSIONS, Version
|
| 122 |
+
|
| 123 |
+
HERE = Path(__file__).parent
|
| 124 |
+
PAGES_DIR = HERE / "pages"
|
| 125 |
+
CSS_PATH = str(HERE / "primo.css")
|
| 126 |
+
ALLOWED_PATHS = [str(HERE / "assets")]
|
| 127 |
+
SOCIAL_PREVIEW_URL = (
|
| 128 |
+
"https://huggingface.co/spaces/PRIMOmics/primo-eval/resolve/main/"
|
| 129 |
+
"assets/primo-social-preview.png"
|
| 130 |
+
)
|
| 131 |
+
PAGE_HEAD = f"""
|
| 132 |
+
<meta property="og:title" content="PRIMO Benchmark" />
|
| 133 |
+
<meta property="og:description" content="A blind benchmark for omics foundation models." />
|
| 134 |
+
<meta property="og:image" content="{SOCIAL_PREVIEW_URL}" />
|
| 135 |
+
<meta name="twitter:card" content="summary_large_image" />
|
| 136 |
+
<meta name="twitter:title" content="PRIMO Benchmark" />
|
| 137 |
+
<meta name="twitter:description" content="A blind benchmark for omics foundation models." />
|
| 138 |
+
<meta name="twitter:image" content="{SOCIAL_PREVIEW_URL}" />
|
| 139 |
+
"""
|
| 140 |
+
|
| 141 |
+
TOKEN = os.environ.get("HF_TOKEN")
|
| 142 |
+
TAB_IDS = ("leaderboard", "tasks", "submit", "contribute", "method")
|
| 143 |
+
LEGACY_TABS = {"boards": "leaderboard", "board": "leaderboard"}
|
| 144 |
+
DEFAULT_MODALITY = "bulk-rna"
|
| 145 |
+
SHOW = "show"
|
| 146 |
+
HIDE = "hide"
|
| 147 |
+
TOGGLE_CHOICES = [("Show", SHOW), ("Hide", HIDE)]
|
| 148 |
+
|
| 149 |
+
THEME = gr.themes.Base()
|
| 150 |
+
|
| 151 |
+
VIEW_URL_JS = """
|
| 152 |
+
(modality, area, task, hvg, pub) => {
|
| 153 |
+
const url = new URL(window.location.href);
|
| 154 |
+
const values = { modality, area, task, hvg, public: pub };
|
| 155 |
+
const defaults = {
|
| 156 |
+
modality: "bulk-rna", area: "all", task: "all", hvg: "show", public: "show",
|
| 157 |
+
};
|
| 158 |
+
for (const [key, value] of Object.entries(values)) {
|
| 159 |
+
if (!value || value === defaults[key]) url.searchParams.delete(key);
|
| 160 |
+
else url.searchParams.set(key, value);
|
| 161 |
+
}
|
| 162 |
+
url.searchParams.delete("board");
|
| 163 |
+
window.history.replaceState(null, "", url);
|
| 164 |
+
}
|
| 165 |
+
"""
|
| 166 |
+
|
| 167 |
+
SUBMIT_HEAD = (
|
| 168 |
+
'<div class="pm-head"><div><h1>Submit a model</h1>'
|
| 169 |
+
"<p>One model, one modality. Partial coverage is fine within that modality. "
|
| 170 |
+
"You are ranked on every board you cover in full.</p></div></div>"
|
| 171 |
+
)
|
| 172 |
+
PAGE_HEADS = {
|
| 173 |
+
"contribute": (
|
| 174 |
+
'<div class="pm-head"><div><h1>Contribute</h1>'
|
| 175 |
+
"<p>What PRIMO is missing, and how to add it.</p></div></div>"
|
| 176 |
+
),
|
| 177 |
+
"method": (
|
| 178 |
+
'<div class="pm-head"><div><h1>Method</h1>'
|
| 179 |
+
"<p>How a model is scored, and what the pages show.</p></div></div>"
|
| 180 |
+
),
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
MODALITY_CHOICES = [
|
| 184 |
+
("Bulk RNAseq", "bulk-rna"),
|
| 185 |
+
("Single-cell RNAseq", "single-cell-rna"),
|
| 186 |
+
]
|
| 187 |
+
|
| 188 |
+
TABLE_SORT_JS = """
|
| 189 |
+
() => {
|
| 190 |
+
if (window.pmTableSortBound) return;
|
| 191 |
+
window.pmTableSortBound = true;
|
| 192 |
+
|
| 193 |
+
const value = (cell) => {
|
| 194 |
+
const text = cell.dataset.sortValue || cell.textContent.trim();
|
| 195 |
+
if (!text || text.toLowerCase() === "n/a") return { missing: true, text };
|
| 196 |
+
const number = Number(text.replaceAll(",", ""));
|
| 197 |
+
return Number.isFinite(number) ? { missing: false, number, text } : { missing: false, text };
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
const sort = (header) => {
|
| 201 |
+
const table = header.closest("table.pm-table");
|
| 202 |
+
const body = table?.tBodies[0];
|
| 203 |
+
if (!body) return;
|
| 204 |
+
const index = Number(header.dataset.sortIndex);
|
| 205 |
+
const direction = header.dataset.sortDirection === "asc" ? -1 : 1;
|
| 206 |
+
const rows = Array.from(body.rows);
|
| 207 |
+
|
| 208 |
+
rows.sort((left, right) => {
|
| 209 |
+
const a = value(left.cells[index]);
|
| 210 |
+
const b = value(right.cells[index]);
|
| 211 |
+
if (a.missing || b.missing) return a.missing === b.missing ? 0 : a.missing ? 1 : -1;
|
| 212 |
+
if (a.number !== undefined && b.number !== undefined) return direction * (a.number - b.number);
|
| 213 |
+
return direction * a.text.localeCompare(b.text, undefined, { numeric: true });
|
| 214 |
+
});
|
| 215 |
+
rows.forEach((row) => body.append(row));
|
| 216 |
+
table.querySelectorAll("th.pm-sort").forEach((cell) => {
|
| 217 |
+
cell.dataset.sortDirection = "";
|
| 218 |
+
cell.setAttribute("aria-sort", "none");
|
| 219 |
+
});
|
| 220 |
+
header.dataset.sortDirection = direction === 1 ? "asc" : "desc";
|
| 221 |
+
table.dataset.sorted = index === 0 && direction === 1 ? "" : "true";
|
| 222 |
+
header.setAttribute("aria-sort", direction === 1 ? "ascending" : "descending");
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
document.addEventListener("click", (event) => {
|
| 226 |
+
const header = event.target.closest("th.pm-sort");
|
| 227 |
+
if (header) sort(header);
|
| 228 |
+
});
|
| 229 |
+
document.addEventListener("click", (event) => {
|
| 230 |
+
if (!event.target.closest("#pm-cite-btn")) return;
|
| 231 |
+
document.querySelector("#pm-cite-dialog")?.showModal();
|
| 232 |
+
});
|
| 233 |
+
document.addEventListener("click", (event) => {
|
| 234 |
+
const evaluateButton = event.target.closest(
|
| 235 |
+
"#pm-evaluate button, button#pm-evaluate"
|
| 236 |
+
);
|
| 237 |
+
if (!evaluateButton) return;
|
| 238 |
+
if (evaluateButton.dataset.confirmed === "true") {
|
| 239 |
+
delete evaluateButton.dataset.confirmed;
|
| 240 |
+
return;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
event.preventDefault();
|
| 244 |
+
event.stopImmediatePropagation();
|
| 245 |
+
const dialog = document.querySelector("#pm-evaluation-confirmation");
|
| 246 |
+
if (!dialog) return;
|
| 247 |
+
dialog.returnValue = "";
|
| 248 |
+
dialog.addEventListener("close", () => {
|
| 249 |
+
if (dialog.returnValue !== "confirm") return;
|
| 250 |
+
evaluateButton.dataset.confirmed = "true";
|
| 251 |
+
evaluateButton.click();
|
| 252 |
+
}, { once: true });
|
| 253 |
+
dialog.showModal();
|
| 254 |
+
}, true);
|
| 255 |
+
document.addEventListener("keydown", (event) => {
|
| 256 |
+
if (event.key !== "Enter" && event.key !== " ") return;
|
| 257 |
+
const header = event.target.closest("th.pm-sort");
|
| 258 |
+
if (!header) return;
|
| 259 |
+
event.preventDefault();
|
| 260 |
+
sort(header);
|
| 261 |
+
});
|
| 262 |
+
}
|
| 263 |
+
"""
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def _page_text(name: str) -> str:
|
| 267 |
+
"""One ``pages/<tab>.md`` per prose tab, named after the tab it fills.
|
| 268 |
+
|
| 269 |
+
Editing the site's words never means touching Python. The data pages (Boards,
|
| 270 |
+
Board, Tasks) are generated markup instead.
|
| 271 |
+
"""
|
| 272 |
+
return (PAGES_DIR / f"{name}.md").read_text()
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _download_help(modality: str | None) -> str:
|
| 276 |
+
"""Show the quickstart command for the selected modality."""
|
| 277 |
+
if not modality:
|
| 278 |
+
return "Select a modality to get its download command."
|
| 279 |
+
return (
|
| 280 |
+
f"```bash\npython quickstart.py --modality {modality} "
|
| 281 |
+
"--out submission.parquet\n```"
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def _registry_by_id(version: Version) -> dict[str, dict]:
|
| 286 |
+
"""The scoreable tasks of ``version`` by task_id, flagged ``public`` if so.
|
| 287 |
+
|
| 288 |
+
``PRIMO_FIXTURE=1`` swaps the token-gated fetch for the test registry, so the
|
| 289 |
+
app runs offline for local smoke-testing.
|
| 290 |
+
"""
|
| 291 |
+
if os.environ.get("PRIMO_FIXTURE"):
|
| 292 |
+
from conftest import REGISTRY
|
| 293 |
+
|
| 294 |
+
return {task_id: dict(task) for task_id, task in REGISTRY.items()}
|
| 295 |
+
datasets = manifest_ids(fetch_manifest(TOKEN, version.data))
|
| 296 |
+
registry = scoreable_tasks(fetch_tasks_registry(TOKEN, version.labels), datasets)
|
| 297 |
+
by_id = {_norm_id(task["task_id"]): task for task in registry}
|
| 298 |
+
public = fetch_public_task_ids(TOKEN, version.data)
|
| 299 |
+
return {
|
| 300 |
+
task_id: {**task, PUBLIC: task_id in public} for task_id, task in by_id.items()
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
PageState = tuple[dict[str, dict], list[Board], pd.DataFrame, Version]
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _page_state(version: Version = CURRENT) -> PageState:
|
| 308 |
+
"""Registry, boards and results of ``version`` -- one fetch per render.
|
| 309 |
+
|
| 310 |
+
A failed fetch yields empty structures so the page renders a "come back
|
| 311 |
+
later" state instead of a stack trace. Every page is built from one of these,
|
| 312 |
+
threaded through rather than refetched, so a page load is one round trip.
|
| 313 |
+
"""
|
| 314 |
+
try:
|
| 315 |
+
by_id = _registry_by_id(version)
|
| 316 |
+
rows = version_rows(read_results(TOKEN), version.number)
|
| 317 |
+
return by_id, build_boards(by_id), rows, version
|
| 318 |
+
except Exception: # noqa: BLE001
|
| 319 |
+
traceback.print_exc()
|
| 320 |
+
return {}, [], pd.DataFrame(columns=RESULT_COLUMNS), version
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def _about_text(by_id: dict[str, dict]) -> str:
|
| 324 |
+
"""Methodology + the archives the cohorts live in, never their accessions.
|
| 325 |
+
|
| 326 |
+
The placeholder is substituted, not ``.format``-ed: a page of prose is free
|
| 327 |
+
to contain a brace, and a stray one must not blow up the tab. An unreachable
|
| 328 |
+
registry leaves ``by_id`` empty and the sentence falls back to a generic one.
|
| 329 |
+
"""
|
| 330 |
+
repositories = source_repositories(list(by_id.values()))
|
| 331 |
+
return _page_text("about").replace(
|
| 332 |
+
"{repositories}", " and ".join(repositories) or "public archives"
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def _summary(result: dict, model_name: str) -> str:
|
| 337 |
+
lines = [
|
| 338 |
+
f"**{model_name}**: covered {result['n_datasets_scored']}/"
|
| 339 |
+
f"{result['n_datasets_total']} datasets",
|
| 340 |
+
"",
|
| 341 |
+
]
|
| 342 |
+
for category, stats in sorted(result["categories"].items()):
|
| 343 |
+
lines.append(f"- **{category}** ({stats['metric']}) = {stats['mean']:.3f}")
|
| 344 |
+
if result["full_coverage"]:
|
| 345 |
+
lines.append("\n✅ **full coverage.** You are ranked on every board.")
|
| 346 |
+
else:
|
| 347 |
+
lines.append(
|
| 348 |
+
"\n⚠️ **partial coverage.** You are ranked on the boards whose scored "
|
| 349 |
+
"tasks you covered in full, and your scores always appear in each "
|
| 350 |
+
"board's **per-task** table."
|
| 351 |
+
)
|
| 352 |
+
if result["missing"]:
|
| 353 |
+
lines.append(f"- missing from file: {result['missing']}")
|
| 354 |
+
if result["incomplete"]:
|
| 355 |
+
lines.append(f"- could not score: {result['incomplete']}")
|
| 356 |
+
return "\n".join(lines)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def _results_frame() -> pd.DataFrame:
|
| 360 |
+
"""Every published row, or an empty frame if the fetch fails.
|
| 361 |
+
|
| 362 |
+
A Hugging Face hiccup must not block a submission, so an empty frame leaves
|
| 363 |
+
the name free rather than raising: fail-open on the ownership check and
|
| 364 |
+
on the limit of submissions per day.
|
| 365 |
+
"""
|
| 366 |
+
try:
|
| 367 |
+
return read_results(TOKEN)
|
| 368 |
+
except Exception: # noqa: BLE001
|
| 369 |
+
traceback.print_exc()
|
| 370 |
+
return pd.DataFrame(columns=RESULT_COLUMNS)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def _claimed_by(df: pd.DataFrame, model: str) -> str:
|
| 374 |
+
"""Who already owns this model name, or ``""``."""
|
| 375 |
+
return owner_of(df, model)
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def _score_with_hvg(submission_path: str, modality: str) -> dict | None:
|
| 379 |
+
"""Score the submission concatenated with the HVG features, or None on failure.
|
| 380 |
+
|
| 381 |
+
The plain scoring already validated the file, so a failure here is ours: it
|
| 382 |
+
is logged and the plain entry is saved alone rather than blocked.
|
| 383 |
+
"""
|
| 384 |
+
try:
|
| 385 |
+
return score_all(
|
| 386 |
+
submission_path,
|
| 387 |
+
TOKEN,
|
| 388 |
+
modality=modality,
|
| 389 |
+
representation=EMBEDDING,
|
| 390 |
+
reference=fetch_hvg_reference(TOKEN),
|
| 391 |
+
)
|
| 392 |
+
except Exception: # noqa: BLE001
|
| 393 |
+
traceback.print_exc()
|
| 394 |
+
return None
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def run_submission(
|
| 398 |
+
submission_path: str | None,
|
| 399 |
+
modality: str | None,
|
| 400 |
+
model_name: str,
|
| 401 |
+
institution: str,
|
| 402 |
+
is_author_submission: bool,
|
| 403 |
+
email: str,
|
| 404 |
+
paper_link: str,
|
| 405 |
+
hf_model_link: str,
|
| 406 |
+
notes: str,
|
| 407 |
+
username: str | None,
|
| 408 |
+
) -> dict:
|
| 409 |
+
"""Check, score and save one submission, for the web form and ``/submit``.
|
| 410 |
+
|
| 411 |
+
The reply holds the ``status``, ``message`` and ``submission_id`` that
|
| 412 |
+
``primo submit`` reads.
|
| 413 |
+
"""
|
| 414 |
+
|
| 415 |
+
def _refuse(message: str) -> dict:
|
| 416 |
+
return reply(REFUSED, message)
|
| 417 |
+
|
| 418 |
+
if not username:
|
| 419 |
+
return _refuse("Please sign in with Hugging Face to submit.")
|
| 420 |
+
if not modality:
|
| 421 |
+
return _refuse("Please select a modality.")
|
| 422 |
+
if not submission_path:
|
| 423 |
+
return _refuse("Please upload a submission file.")
|
| 424 |
+
if not model_name or not model_name.strip():
|
| 425 |
+
return _refuse("Please enter a model name.")
|
| 426 |
+
if not email or not email.strip():
|
| 427 |
+
return _refuse("Please enter a contact email.")
|
| 428 |
+
model = model_name.strip()
|
| 429 |
+
if BASELINE_TAG in model.lower():
|
| 430 |
+
return _refuse(
|
| 431 |
+
f"`{BASELINE_TAG}` is reserved for our reference submissions. Please "
|
| 432 |
+
"pick another model name."
|
| 433 |
+
)
|
| 434 |
+
if HVG_TAG.lower() in model.lower():
|
| 435 |
+
return _refuse(
|
| 436 |
+
f"`{HVG_TAG}` is reserved for the entry we derive from every "
|
| 437 |
+
"submission. Please pick another model name."
|
| 438 |
+
)
|
| 439 |
+
if model in RESERVED_COLUMNS:
|
| 440 |
+
return _refuse(
|
| 441 |
+
f"`{model}` is a column of the per-task table. Please pick another "
|
| 442 |
+
"model name."
|
| 443 |
+
)
|
| 444 |
+
published = _results_frame()
|
| 445 |
+
claimed = _claimed_by(published, model)
|
| 446 |
+
if claimed and claimed != username:
|
| 447 |
+
return _refuse(
|
| 448 |
+
f"The model name `{model}` is already submitted by another HF account. "
|
| 449 |
+
"Resubmission of the same model is allowed only from the same HF account. "
|
| 450 |
+
"Please pick another name."
|
| 451 |
+
)
|
| 452 |
+
if (
|
| 453 |
+
recent_submissions(published, username, datetime.now(timezone.utc))
|
| 454 |
+
>= SUBMISSIONS_PER_DAY
|
| 455 |
+
):
|
| 456 |
+
return _refuse(
|
| 457 |
+
f"An account can publish {SUBMISSIONS_PER_DAY} submissions in 24 "
|
| 458 |
+
"hours. Please try again later."
|
| 459 |
+
)
|
| 460 |
+
try:
|
| 461 |
+
result = score_all(
|
| 462 |
+
submission_path, TOKEN, modality=modality, representation=EMBEDDING
|
| 463 |
+
)
|
| 464 |
+
except SubmissionError as error:
|
| 465 |
+
return _refuse(f"❌ {error}")
|
| 466 |
+
except EvaluatorError as error:
|
| 467 |
+
traceback.print_exc()
|
| 468 |
+
return reply(
|
| 469 |
+
ERROR,
|
| 470 |
+
"⚠️ We couldn't evaluate your submission. This is on our side, not your "
|
| 471 |
+
f"file. Please try again in a moment.\n\n`{error}`",
|
| 472 |
+
)
|
| 473 |
+
except Exception as error: # noqa: BLE001
|
| 474 |
+
traceback.print_exc()
|
| 475 |
+
return reply(ERROR, f"⚠️ Unexpected evaluation error (our side): {error}")
|
| 476 |
+
|
| 477 |
+
summary = _summary(result, model)
|
| 478 |
+
now = datetime.now(timezone.utc)
|
| 479 |
+
submitted_at = now.strftime(SUBMITTED_AT)
|
| 480 |
+
submission_id = f"{now.strftime('%Y%m%dT%H%M%S.%fZ')}-{uuid4().hex}"
|
| 481 |
+
credits = {
|
| 482 |
+
OWNER: username,
|
| 483 |
+
INSTITUTION: (institution or "").strip(),
|
| 484 |
+
IS_AUTHOR_SUBMISSION: bool(is_author_submission),
|
| 485 |
+
PAPER_LINK: (paper_link or "").strip(),
|
| 486 |
+
}
|
| 487 |
+
rows = task_rows(model, result, submitted_at, submission_id, credits)
|
| 488 |
+
if not rows:
|
| 489 |
+
summary += (
|
| 490 |
+
"\n\n⚠️ No valid scores were produced, so the model was not added to "
|
| 491 |
+
"any board."
|
| 492 |
+
)
|
| 493 |
+
return _refuse(summary)
|
| 494 |
+
|
| 495 |
+
hvg_result = _score_with_hvg(submission_path, modality)
|
| 496 |
+
hvg_rows = (
|
| 497 |
+
[]
|
| 498 |
+
if hvg_result is None
|
| 499 |
+
else task_rows(
|
| 500 |
+
hvg_name(model), hvg_result, submitted_at, submission_id, credits
|
| 501 |
+
)
|
| 502 |
+
)
|
| 503 |
+
if hvg_result is not None:
|
| 504 |
+
summary += "\n\n" + _summary(hvg_result, hvg_name(model))
|
| 505 |
+
if not hvg_rows:
|
| 506 |
+
summary += (
|
| 507 |
+
f"\n\n⚠️ The `{hvg_name(model)}` entry could not be scored (our side), "
|
| 508 |
+
"so only the plain entry is saved; an earlier "
|
| 509 |
+
f"`{hvg_name(model)}` entry may still show older scores."
|
| 510 |
+
)
|
| 511 |
+
rows += hvg_rows
|
| 512 |
+
|
| 513 |
+
meta = {
|
| 514 |
+
"model_name": model,
|
| 515 |
+
"submitted_at": submitted_at,
|
| 516 |
+
SUBMISSION_ID: submission_id,
|
| 517 |
+
"email": email.strip(),
|
| 518 |
+
**credits,
|
| 519 |
+
"hf_model_link": (hf_model_link or "").strip(),
|
| 520 |
+
"notes": (notes or "").strip(),
|
| 521 |
+
}
|
| 522 |
+
try:
|
| 523 |
+
archive_submission(
|
| 524 |
+
submission_path, model, submission_id, modality, submitted_at, TOKEN
|
| 525 |
+
)
|
| 526 |
+
except Exception as error: # noqa: BLE001
|
| 527 |
+
traceback.print_exc()
|
| 528 |
+
summary += f"\n\n⚠️ scored, but the embedding archive was not saved: {error}"
|
| 529 |
+
return reply(ERROR, summary)
|
| 530 |
+
try:
|
| 531 |
+
append_results(rows, TOKEN)
|
| 532 |
+
except Exception as error: # noqa: BLE001
|
| 533 |
+
traceback.print_exc()
|
| 534 |
+
summary += f"\n\n⚠️ archived, but the leaderboard was not saved: {error}"
|
| 535 |
+
return reply(ERROR, summary, submission_id)
|
| 536 |
+
|
| 537 |
+
summary += f"\n\n✅ **{model} is now shown on the relevant boards.**"
|
| 538 |
+
if hvg_rows:
|
| 539 |
+
summary += f" So is **{hvg_name(model)}**."
|
| 540 |
+
try:
|
| 541 |
+
append_submission(meta, TOKEN)
|
| 542 |
+
except Exception as error: # noqa: BLE001
|
| 543 |
+
traceback.print_exc()
|
| 544 |
+
summary += f"\n\n⚠️ Contact metadata was not saved: {error}"
|
| 545 |
+
return reply(PUBLISHED, summary, submission_id)
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def evaluate(
|
| 549 |
+
submission_path: str,
|
| 550 |
+
modality: str | None,
|
| 551 |
+
model_name: str,
|
| 552 |
+
institution: str,
|
| 553 |
+
is_author_submission: bool,
|
| 554 |
+
email: str,
|
| 555 |
+
paper_link: str,
|
| 556 |
+
hf_model_link: str,
|
| 557 |
+
notes: str,
|
| 558 |
+
profile: gr.OAuthProfile | None,
|
| 559 |
+
):
|
| 560 |
+
"""The web form: the shared submission path, then the page's answer."""
|
| 561 |
+
return run_submission(
|
| 562 |
+
submission_path,
|
| 563 |
+
modality,
|
| 564 |
+
model_name,
|
| 565 |
+
institution,
|
| 566 |
+
is_author_submission,
|
| 567 |
+
email,
|
| 568 |
+
paper_link,
|
| 569 |
+
hf_model_link,
|
| 570 |
+
notes,
|
| 571 |
+
None if profile is None else profile.username,
|
| 572 |
+
)["message"]
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
def _landing_tab(params: dict) -> str:
|
| 576 |
+
"""Which page a visitor lands on: ``?tab=``, old board links included.
|
| 577 |
+
|
| 578 |
+
An unknown ``?tab=`` falls through to the leaderboard rather than selecting
|
| 579 |
+
nothing, which would render the Space with every panel collapsed.
|
| 580 |
+
"""
|
| 581 |
+
tab = params.get("tab")
|
| 582 |
+
tab = LEGACY_TABS.get(tab, tab)
|
| 583 |
+
return tab if tab in TAB_IDS else "leaderboard"
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
def _initial_view(
|
| 587 |
+
params: dict, by_id: dict[str, dict], boards: list[Board]
|
| 588 |
+
) -> tuple[str, str, str]:
|
| 589 |
+
"""The pills a visitor lands on: an old ``?board=``, else the query, else Bulk."""
|
| 590 |
+
old = next((b for b in boards if b.slug == params.get("board")), None)
|
| 591 |
+
if old is not None:
|
| 592 |
+
return board_view(old, by_id)
|
| 593 |
+
modalities = [slug for _, slug in modality_choices(by_id)]
|
| 594 |
+
modality = params.get("modality")
|
| 595 |
+
if modality not in modalities:
|
| 596 |
+
modality = (
|
| 597 |
+
DEFAULT_MODALITY
|
| 598 |
+
if DEFAULT_MODALITY in modalities or not modalities
|
| 599 |
+
else modalities[0]
|
| 600 |
+
)
|
| 601 |
+
areas = [slug for _, slug in area_choices(by_id, modality)]
|
| 602 |
+
categories = [slug for _, slug in category_choices(by_id, modality)]
|
| 603 |
+
area = params.get("area") if params.get("area") in areas else ALL
|
| 604 |
+
category = params.get("task") if params.get("task") in categories else ALL
|
| 605 |
+
return modality, area, category
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
def _view_name(by_id: dict[str, dict], modality: str, area: str, category: str) -> str:
|
| 609 |
+
"""The view as its pills read, ``Bulk RNAseq · Oncology · All tasks``."""
|
| 610 |
+
modalities = {slug: name for name, slug in modality_choices(by_id)}
|
| 611 |
+
areas = {slug: name for name, slug in area_choices(by_id, modality)}
|
| 612 |
+
categories = {slug: name for name, slug in category_choices(by_id, modality)}
|
| 613 |
+
return " · ".join(
|
| 614 |
+
(
|
| 615 |
+
modalities.get(modality, modality),
|
| 616 |
+
"All areas" if area == ALL else areas.get(area, area),
|
| 617 |
+
"All tasks" if category == ALL else categories.get(category, category),
|
| 618 |
+
)
|
| 619 |
+
)
|
| 620 |
+
|
| 621 |
+
|
| 622 |
+
def _render_view(
|
| 623 |
+
state: PageState, modality: str, area: str, category: str, hvg: str, public: str
|
| 624 |
+
) -> str:
|
| 625 |
+
"""The tables of the view the pills select, on the version on screen.
|
| 626 |
+
|
| 627 |
+
A pill clicked before the page has loaded has no state yet, and changes nothing.
|
| 628 |
+
"""
|
| 629 |
+
if state is None:
|
| 630 |
+
return gr.skip()
|
| 631 |
+
by_id, _, df, _ = state
|
| 632 |
+
board = view_board(by_id, modality, area, category)
|
| 633 |
+
if public == HIDE:
|
| 634 |
+
board = without_public(board, by_id)
|
| 635 |
+
name = _view_name(by_id, modality, area, category)
|
| 636 |
+
return render_view(board, df, by_id, name, hvg != HIDE)
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def _on_modality(state: PageState, modality: str, hvg: str, public: str):
|
| 640 |
+
"""A new modality brings its own area and task pills, both back on All."""
|
| 641 |
+
by_id = state[0]
|
| 642 |
+
return (
|
| 643 |
+
gr.Radio(choices=area_choices(by_id, modality), value=ALL),
|
| 644 |
+
gr.Radio(choices=category_choices(by_id, modality), value=ALL),
|
| 645 |
+
_render_view(state, modality, ALL, ALL, hvg, public),
|
| 646 |
+
)
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def _viewed_version(params: dict) -> Version:
|
| 650 |
+
"""The version a visitor asked for; Submit and unknown numbers get the current."""
|
| 651 |
+
if params.get("tab") == "submit":
|
| 652 |
+
return CURRENT
|
| 653 |
+
number = params.get(VERSION_PARAM)
|
| 654 |
+
return next((listed for listed in VERSIONS if listed.number == number), CURRENT)
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
def _init(request: gr.Request):
|
| 658 |
+
"""Render every page from one fetch, on the tab and the view the query asks.
|
| 659 |
+
|
| 660 |
+
The fetched state is returned too, so a click on a pill re-renders the view
|
| 661 |
+
without a second round trip.
|
| 662 |
+
"""
|
| 663 |
+
params = dict(request.query_params) if request else {}
|
| 664 |
+
state = _page_state(_viewed_version(params))
|
| 665 |
+
by_id, boards, df, version = state
|
| 666 |
+
tab = _landing_tab(params)
|
| 667 |
+
modality, area, category = _initial_view(params, by_id, boards)
|
| 668 |
+
hvg = HIDE if params.get("hvg") == HIDE else SHOW
|
| 669 |
+
public = HIDE if params.get("public") == HIDE else SHOW
|
| 670 |
+
return (
|
| 671 |
+
gr.Tabs(selected=tab),
|
| 672 |
+
nav_html(tab, version),
|
| 673 |
+
hero_html(boards, df, version),
|
| 674 |
+
_render_view(state, modality, area, category, hvg, public),
|
| 675 |
+
gr.Radio(choices=modality_choices(by_id), value=modality),
|
| 676 |
+
gr.Radio(choices=area_choices(by_id, modality), value=area),
|
| 677 |
+
gr.Radio(choices=category_choices(by_id, modality), value=category),
|
| 678 |
+
gr.Radio(value=hvg),
|
| 679 |
+
gr.Radio(value=public, visible=any(t.get(PUBLIC) for t in by_id.values())),
|
| 680 |
+
state,
|
| 681 |
+
render_tasks(by_id),
|
| 682 |
+
_about_text(by_id),
|
| 683 |
+
history_html(df, version),
|
| 684 |
+
cta_html(boards, version),
|
| 685 |
+
)
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
def build_demo() -> gr.Blocks:
|
| 689 |
+
with gr.Blocks(
|
| 690 |
+
title="PRIMO Benchmark",
|
| 691 |
+
theme=THEME,
|
| 692 |
+
head=PAGE_HEAD,
|
| 693 |
+
css_paths=[CSS_PATH],
|
| 694 |
+
js=TABLE_SORT_JS,
|
| 695 |
+
fill_width=True,
|
| 696 |
+
) as demo:
|
| 697 |
+
page_state = gr.State(None)
|
| 698 |
+
with gr.Column(elem_id="pm-site"):
|
| 699 |
+
nav = gr.HTML(padding=False)
|
| 700 |
+
with gr.Tabs(elem_id="pm-pages") as pages:
|
| 701 |
+
with gr.Tab("Leaderboard", id="leaderboard"):
|
| 702 |
+
hero = gr.HTML(padding=False)
|
| 703 |
+
gr.HTML(BOARD_SUB, padding=False)
|
| 704 |
+
with gr.Row(elem_id="pm-controls"):
|
| 705 |
+
modality_f = gr.Radio(
|
| 706 |
+
label="Modality", elem_classes=["pm-seg", "pm-seg--main"]
|
| 707 |
+
)
|
| 708 |
+
area_f = gr.Radio(
|
| 709 |
+
label="Therapeutic area",
|
| 710 |
+
elem_classes=["pm-seg", "pm-seg--line"],
|
| 711 |
+
)
|
| 712 |
+
task_f = gr.Radio(
|
| 713 |
+
label="Task", elem_classes=["pm-seg", "pm-seg--line"]
|
| 714 |
+
)
|
| 715 |
+
hvg_f = gr.Radio(
|
| 716 |
+
TOGGLE_CHOICES,
|
| 717 |
+
value=SHOW,
|
| 718 |
+
label="+ HVG models",
|
| 719 |
+
elem_classes=["pm-seg", "pm-seg--minor"],
|
| 720 |
+
)
|
| 721 |
+
public_f = gr.Radio(
|
| 722 |
+
TOGGLE_CHOICES,
|
| 723 |
+
value=SHOW,
|
| 724 |
+
label="Public tasks",
|
| 725 |
+
visible=False,
|
| 726 |
+
elem_classes=["pm-seg", "pm-seg--minor"],
|
| 727 |
+
)
|
| 728 |
+
board_html = gr.HTML(padding=False)
|
| 729 |
+
history = gr.HTML(padding=False)
|
| 730 |
+
cta = gr.HTML(padding=False)
|
| 731 |
+
with gr.Tab("Tasks", id="tasks"):
|
| 732 |
+
tasks_html = gr.HTML(padding=False)
|
| 733 |
+
with gr.Tab("Submit", id="submit"):
|
| 734 |
+
gr.HTML(SUBMIT_HEAD, padding=False)
|
| 735 |
+
with gr.Column(elem_classes=["pm-body"]):
|
| 736 |
+
with gr.Row(elem_id="pm-submit"):
|
| 737 |
+
with gr.Column(scale=3):
|
| 738 |
+
gr.Markdown(
|
| 739 |
+
_page_text("submit"),
|
| 740 |
+
elem_classes=["pm-prose", "pm-submit-text"],
|
| 741 |
+
)
|
| 742 |
+
with gr.Column(scale=2, elem_id="pm-form"):
|
| 743 |
+
gr.LoginButton()
|
| 744 |
+
modality_in = gr.Radio(
|
| 745 |
+
MODALITY_CHOICES,
|
| 746 |
+
label="Modality",
|
| 747 |
+
info="One model submission covers one modality.",
|
| 748 |
+
elem_classes=["pm-seg", "pm-seg--form"],
|
| 749 |
+
)
|
| 750 |
+
download_md = gr.Markdown(
|
| 751 |
+
_download_help(None), elem_classes=["pm-hint"]
|
| 752 |
+
)
|
| 753 |
+
model_tb = gr.Textbox(
|
| 754 |
+
label="Model name",
|
| 755 |
+
placeholder="e.g. my-model-v1",
|
| 756 |
+
info="Shown on the leaderboard.",
|
| 757 |
+
)
|
| 758 |
+
institution_tb = gr.Textbox(
|
| 759 |
+
label="Institution (optional)",
|
| 760 |
+
placeholder="e.g. Example University",
|
| 761 |
+
info="Shown on the leaderboard.",
|
| 762 |
+
)
|
| 763 |
+
author_submission_cb = gr.Checkbox(
|
| 764 |
+
label="Submitted by the model's authors",
|
| 765 |
+
info="Adds an Authors check next to the model name.",
|
| 766 |
+
)
|
| 767 |
+
email_tb = gr.Textbox(
|
| 768 |
+
label="Email address",
|
| 769 |
+
placeholder="Your contact email",
|
| 770 |
+
info="Contact for this submission, kept private.",
|
| 771 |
+
)
|
| 772 |
+
notes_tb = gr.Textbox(
|
| 773 |
+
label="Training data / notes (optional)",
|
| 774 |
+
placeholder="e.g. pretrained on atlas X",
|
| 775 |
+
info="About the model or its training data.",
|
| 776 |
+
)
|
| 777 |
+
paper_tb = gr.Textbox(
|
| 778 |
+
label="Paper link (optional)",
|
| 779 |
+
placeholder="https://arxiv.org/abs/...",
|
| 780 |
+
info="Links the model name on the leaderboard.",
|
| 781 |
+
)
|
| 782 |
+
hf_tb = gr.Textbox(
|
| 783 |
+
label="Hugging Face model link (optional)",
|
| 784 |
+
placeholder="https://huggingface.co/...",
|
| 785 |
+
)
|
| 786 |
+
file_in = gr.File(
|
| 787 |
+
label="Submission (.csv / .tsv / .parquet / .npz)",
|
| 788 |
+
type="filepath",
|
| 789 |
+
)
|
| 790 |
+
run_btn = gr.Button(
|
| 791 |
+
"Evaluate",
|
| 792 |
+
elem_id="pm-evaluate",
|
| 793 |
+
elem_classes=[
|
| 794 |
+
"pm-btn",
|
| 795 |
+
"pm-btn--primary",
|
| 796 |
+
"pm-btn--block",
|
| 797 |
+
],
|
| 798 |
+
)
|
| 799 |
+
status_html = gr.HTML(
|
| 800 |
+
evaluation_status(), visible=False
|
| 801 |
+
)
|
| 802 |
+
gr.HTML(evaluation_confirmation())
|
| 803 |
+
result_md = gr.Markdown()
|
| 804 |
+
with gr.Tab("Contribute", id="contribute"):
|
| 805 |
+
gr.HTML(PAGE_HEADS["contribute"], padding=False)
|
| 806 |
+
with gr.Column(elem_classes=["pm-body", "pm-prose"]):
|
| 807 |
+
gr.Markdown(_page_text("contribute"))
|
| 808 |
+
with gr.Tab("Method", id="method"):
|
| 809 |
+
gr.HTML(PAGE_HEADS["method"], padding=False)
|
| 810 |
+
with gr.Column(elem_classes=["pm-body", "pm-prose"]):
|
| 811 |
+
about_md = gr.Markdown()
|
| 812 |
+
gr.HTML(CREDIT, padding=False)
|
| 813 |
+
|
| 814 |
+
evaluation_event = run_btn.click(
|
| 815 |
+
lambda: gr.update(visible=True),
|
| 816 |
+
None,
|
| 817 |
+
status_html,
|
| 818 |
+
show_progress="hidden",
|
| 819 |
+
).then(
|
| 820 |
+
evaluate,
|
| 821 |
+
[
|
| 822 |
+
file_in,
|
| 823 |
+
modality_in,
|
| 824 |
+
model_tb,
|
| 825 |
+
institution_tb,
|
| 826 |
+
author_submission_cb,
|
| 827 |
+
email_tb,
|
| 828 |
+
paper_tb,
|
| 829 |
+
hf_tb,
|
| 830 |
+
notes_tb,
|
| 831 |
+
],
|
| 832 |
+
result_md,
|
| 833 |
+
concurrency_id=SUBMISSIONS,
|
| 834 |
+
)
|
| 835 |
+
gr.api(route(run_submission), api_name="submit", concurrency_id=SUBMISSIONS)
|
| 836 |
+
evaluation_event.then(
|
| 837 |
+
lambda: gr.update(visible=False),
|
| 838 |
+
None,
|
| 839 |
+
status_html,
|
| 840 |
+
show_progress="hidden",
|
| 841 |
+
)
|
| 842 |
+
modality_in.change(_download_help, modality_in, download_md)
|
| 843 |
+
|
| 844 |
+
pills = [modality_f, area_f, task_f, hvg_f, public_f]
|
| 845 |
+
modality_f.input(
|
| 846 |
+
_on_modality,
|
| 847 |
+
[page_state, modality_f, hvg_f, public_f],
|
| 848 |
+
[area_f, task_f, board_html],
|
| 849 |
+
show_progress="hidden",
|
| 850 |
+
).then(None, pills, None, js=VIEW_URL_JS)
|
| 851 |
+
for pill in pills[1:]:
|
| 852 |
+
pill.input(
|
| 853 |
+
_render_view,
|
| 854 |
+
[page_state, *pills],
|
| 855 |
+
board_html,
|
| 856 |
+
show_progress="hidden",
|
| 857 |
+
).then(None, pills, None, js=VIEW_URL_JS)
|
| 858 |
+
demo.load(
|
| 859 |
+
_init,
|
| 860 |
+
None,
|
| 861 |
+
[
|
| 862 |
+
pages,
|
| 863 |
+
nav,
|
| 864 |
+
hero,
|
| 865 |
+
board_html,
|
| 866 |
+
*pills,
|
| 867 |
+
page_state,
|
| 868 |
+
tasks_html,
|
| 869 |
+
about_md,
|
| 870 |
+
history,
|
| 871 |
+
cta,
|
| 872 |
+
],
|
| 873 |
+
)
|
| 874 |
+
return demo
|
| 875 |
+
|
| 876 |
+
|
| 877 |
+
if __name__ == "__main__":
|
| 878 |
+
build_demo().launch(
|
| 879 |
+
server_name="0.0.0.0",
|
| 880 |
+
server_port=7860,
|
| 881 |
+
ssr_mode=False,
|
| 882 |
+
allowed_paths=ALLOWED_PATHS,
|
| 883 |
+
max_file_size=MAX_FILE_SIZE,
|
| 884 |
+
)
|
assets/hugging-face.svg
ADDED
|
|
assets/primo-mark.webp
ADDED
|
assets/primo-social-preview.png
ADDED
|
assets/scienta-lab.svg
ADDED
|
|
boards.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The slices of PRIMO a visitor can rank models on: registry tasks -> boards.
|
| 2 |
+
|
| 3 |
+
A *board* is a self-contained leaderboard over a subset of the registry -- every
|
| 4 |
+
task of a modality, of a therapeutic area, or of a task family. Coverage is
|
| 5 |
+
judged INSIDE a board, so a model that covered every Oncology task is ranked
|
| 6 |
+
on the Oncology board even though it skipped Infectious diseases. Each board is
|
| 7 |
+
a real leaderboard, not a view filter.
|
| 8 |
+
|
| 9 |
+
Modality stays the wall for every board, so models from different omics layers
|
| 10 |
+
are never ranked together. The leaderboard page picks a board with three pills:
|
| 11 |
+
modality, therapeutic area and task family. ``view_board`` builds the board of
|
| 12 |
+
any such view, area and family crossed included.
|
| 13 |
+
|
| 14 |
+
``OPEN_BOARDS`` names the slices PRIMO does NOT cover, so the page states its own
|
| 15 |
+
gaps instead of implying the registry is the whole territory. They are a
|
| 16 |
+
hand-written constant, not a roadmap: a slice belongs here when a contributor
|
| 17 |
+
could plausibly bring it, and it disappears on its own the day the registry
|
| 18 |
+
covers it.
|
| 19 |
+
|
| 20 |
+
Modalities and therapeutic areas get open slices; task categories deliberately do
|
| 21 |
+
not. A category is an enum whose members are each pinned to a single metric, so
|
| 22 |
+
naming an open one ("biomarker discovery") would advertise a scoring rule that
|
| 23 |
+
does not exist. Modalities and areas only need a cohort.
|
| 24 |
+
|
| 25 |
+
Pure functions over registry dicts -- no Gradio, no network, no HTML.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
import re
|
| 29 |
+
from dataclasses import dataclass, replace
|
| 30 |
+
|
| 31 |
+
from evaluator import _norm_id
|
| 32 |
+
|
| 33 |
+
METRIC_LABEL = {
|
| 34 |
+
"auroc": "AUROC",
|
| 35 |
+
"pearson": "Pearson",
|
| 36 |
+
"residual_spearman": "Residual Spearman score",
|
| 37 |
+
"centered_spearman": "Centered Spearman score",
|
| 38 |
+
}
|
| 39 |
+
MODALITY_LABEL = {
|
| 40 |
+
"bulk RNA": "Bulk RNAseq",
|
| 41 |
+
"Single Cell RNA": "Single-cell RNAseq",
|
| 42 |
+
"single-cell RNA": "Single-cell RNAseq",
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
MODALITY_GROUP = "Per modality"
|
| 46 |
+
AREA_GROUP = "Per therapeutic areas"
|
| 47 |
+
CATEGORY_GROUP = "Task Category"
|
| 48 |
+
OTHER_AREA = "Other"
|
| 49 |
+
PUBLIC = "public"
|
| 50 |
+
|
| 51 |
+
CODE_LENGTH = 3
|
| 52 |
+
FALLBACK_CODE = "n/a"
|
| 53 |
+
DISPLAY_LABEL = {"perturbation_prediction": "Perturbation response"}
|
| 54 |
+
|
| 55 |
+
CATEGORY_BLURB = {
|
| 56 |
+
"treatment_outcome": "Will this patient respond to the drug?",
|
| 57 |
+
"clinical_scores": "How severe is this patient's disease?",
|
| 58 |
+
"prognosis": "How will this patient's disease turn out?",
|
| 59 |
+
"endotype": "Which molecular subtype is this patient?",
|
| 60 |
+
"perturbation_prediction": "How will treatment change this patient's expression?",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
MAX_LISTED = 3
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def label(value: str) -> str:
|
| 67 |
+
"""``treatment_outcome`` -> ``Treatment outcome``; leaves free text alone."""
|
| 68 |
+
return DISPLAY_LABEL.get(value, value.replace("_", " ").capitalize())
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def metric_label(metric: str) -> str:
|
| 72 |
+
return METRIC_LABEL.get(metric, metric)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def modality_label(modality: str) -> str:
|
| 76 |
+
"""Return the visitor-facing label for a stored modality identifier."""
|
| 77 |
+
return MODALITY_LABEL.get(modality, modality)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def short_code(name: str) -> str:
|
| 81 |
+
"""The board's letter tag, standing in for what used to be a per-board emoji.
|
| 82 |
+
|
| 83 |
+
Derived from the name rather than looked up, because a lookup table is what
|
| 84 |
+
breaks: spatial transcriptomics or metabolomics would land on a shrug the day
|
| 85 |
+
somebody adds them. Colour carries the group; these letters only carry the
|
| 86 |
+
board.
|
| 87 |
+
"""
|
| 88 |
+
letters = re.sub(r"[^a-z]", "", name.lower())
|
| 89 |
+
return letters[:CODE_LENGTH].upper() or FALLBACK_CODE
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def slugify(*parts: str) -> str:
|
| 93 |
+
"""URL-safe key for a board, stable enough to paste into a link."""
|
| 94 |
+
joined = "-".join(str(p) for p in parts)
|
| 95 |
+
return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", joined.lower())).strip("-")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def distinct(tasks, key: str) -> list[str]:
|
| 99 |
+
"""Distinct non-empty values of ``key`` across registry tasks, case-insensitive."""
|
| 100 |
+
return sorted({str(t[key]) for t in tasks if t.get(key)}, key=str.lower)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def distinct_listed(tasks, key: str) -> list[str]:
|
| 104 |
+
"""Distinct values of a list-valued ``key`` (a cohort can span several diseases)."""
|
| 105 |
+
values: set[str] = set()
|
| 106 |
+
for task in tasks:
|
| 107 |
+
values.update(str(value) for value in task.get(key) or [])
|
| 108 |
+
return sorted(values, key=str.lower)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@dataclass(frozen=True)
|
| 112 |
+
class Board:
|
| 113 |
+
"""One enterable leaderboard: a named task set plus the numbers on its card."""
|
| 114 |
+
|
| 115 |
+
slug: str
|
| 116 |
+
group: str
|
| 117 |
+
name: str
|
| 118 |
+
code: str
|
| 119 |
+
blurb: str
|
| 120 |
+
modality: str
|
| 121 |
+
task_ids: frozenset[str]
|
| 122 |
+
n_cohorts: int
|
| 123 |
+
n_patients: int
|
| 124 |
+
n_diseases: int
|
| 125 |
+
|
| 126 |
+
@property
|
| 127 |
+
def n_tasks(self) -> int:
|
| 128 |
+
return len(self.task_ids)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _join(values: list[str]) -> str:
|
| 132 |
+
"""``a, b and c``, truncated -- card blurbs must not wrap forever.
|
| 133 |
+
|
| 134 |
+
A truncated list drops the "and": ``a, b and c…`` reads as an ellipsis stuck
|
| 135 |
+
to ``c``, where ``a, b, c…`` reads as the list continuing.
|
| 136 |
+
"""
|
| 137 |
+
shown = values[:MAX_LISTED]
|
| 138 |
+
if not shown:
|
| 139 |
+
return "n/a"
|
| 140 |
+
if len(values) > MAX_LISTED:
|
| 141 |
+
return f"{', '.join(shown)}…"
|
| 142 |
+
if len(shown) == 1:
|
| 143 |
+
return shown[0]
|
| 144 |
+
return f"{', '.join(shown[:-1])} and {shown[-1]}"
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _cohort_stats(tasks: list[dict]) -> tuple[int, int, int]:
|
| 148 |
+
"""Cohorts, patients and diseases behind a task set.
|
| 149 |
+
|
| 150 |
+
Counted per COHORT, not per task and not per dataset. Three clinical scores
|
| 151 |
+
read off the same biopsies are one cohort, and so are the two treatment arms
|
| 152 |
+
and the transfer split cut from one anti-TNF trial -- summing datasets would
|
| 153 |
+
advertise those patients twice. Entries on one cohort can differ in
|
| 154 |
+
``n_samples`` (labels get dropped, an arm is a subset), so the widest one
|
| 155 |
+
stands for it.
|
| 156 |
+
|
| 157 |
+
A registry written before ``cohort_id`` existed falls back to ``dataset_id``,
|
| 158 |
+
which is the old behaviour rather than a crash.
|
| 159 |
+
"""
|
| 160 |
+
patients_by_cohort: dict[str, int] = {}
|
| 161 |
+
diseases: set[str] = set()
|
| 162 |
+
for task in tasks:
|
| 163 |
+
cohort = str(task.get("cohort_id") or _norm_id(task.get("dataset_id", "")))
|
| 164 |
+
n_subjects = int(task.get("n_subjects") or task.get("n_samples") or 0)
|
| 165 |
+
patients_by_cohort[cohort] = max(patients_by_cohort.get(cohort, 0), n_subjects)
|
| 166 |
+
diseases.update(str(d) for d in task.get("diseases") or [])
|
| 167 |
+
return (
|
| 168 |
+
len(patients_by_cohort),
|
| 169 |
+
sum(patients_by_cohort.values()),
|
| 170 |
+
len(diseases),
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _blurb(group: str, name: str, tasks: list[dict]) -> str:
|
| 175 |
+
if group == MODALITY_GROUP:
|
| 176 |
+
display = modality_label(name)
|
| 177 |
+
return f"Every PRIMO task, scored from {display[:1].lower()}{display[1:]} profiles."
|
| 178 |
+
if group == CATEGORY_GROUP:
|
| 179 |
+
return CATEGORY_BLURB.get(name, label(name))
|
| 180 |
+
families = [label(c) for c in distinct(tasks, "category")]
|
| 181 |
+
return f"{_join(families)} across {_join(distinct_listed(tasks, 'diseases'))}."
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _board(group: str, name: str, modality: str, tasks: list[dict]) -> Board:
|
| 185 |
+
"""Build one board from a task slice."""
|
| 186 |
+
n_cohorts, n_patients, n_diseases = _cohort_stats(tasks)
|
| 187 |
+
display = modality_label(name) if group == MODALITY_GROUP else label(name)
|
| 188 |
+
return Board(
|
| 189 |
+
slug=slugify(modality) if group == MODALITY_GROUP else slugify(name, modality),
|
| 190 |
+
group=group,
|
| 191 |
+
name=display,
|
| 192 |
+
code=short_code(display),
|
| 193 |
+
blurb=_blurb(group, name, tasks),
|
| 194 |
+
modality=modality,
|
| 195 |
+
task_ids=frozenset(_norm_id(t["task_id"]) for t in tasks),
|
| 196 |
+
n_cohorts=n_cohorts,
|
| 197 |
+
n_patients=n_patients,
|
| 198 |
+
n_diseases=n_diseases,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def without_public(board: Board, by_id: dict[str, dict]) -> Board:
|
| 203 |
+
"""The same board restricted to its private tasks, card numbers recounted.
|
| 204 |
+
|
| 205 |
+
Parameters
|
| 206 |
+
----------
|
| 207 |
+
board : Board
|
| 208 |
+
Board built from ``by_id``.
|
| 209 |
+
by_id : dict[str, dict]
|
| 210 |
+
Task registry keyed by task id, public tasks flagged.
|
| 211 |
+
|
| 212 |
+
Returns
|
| 213 |
+
-------
|
| 214 |
+
Board
|
| 215 |
+
The board with the same slug and name; its task set may be empty.
|
| 216 |
+
"""
|
| 217 |
+
private = frozenset(t for t in board.task_ids if not by_id[t].get(PUBLIC))
|
| 218 |
+
n_cohorts, n_patients, n_diseases = _cohort_stats([by_id[t] for t in private])
|
| 219 |
+
return replace(
|
| 220 |
+
board,
|
| 221 |
+
task_ids=private,
|
| 222 |
+
n_cohorts=n_cohorts,
|
| 223 |
+
n_patients=n_patients,
|
| 224 |
+
n_diseases=n_diseases,
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def build_boards(by_id: dict[str, dict]) -> list[Board]:
|
| 229 |
+
"""Every board the registry supports, modality boards first.
|
| 230 |
+
|
| 231 |
+
A facet value that yields no task simply yields no card, so a registry
|
| 232 |
+
written before therapeutic areas existed degrades to modality boards only
|
| 233 |
+
instead of breaking the page.
|
| 234 |
+
|
| 235 |
+
The "Other" area gets no board: it mixes unrelated diseases, so its ranking
|
| 236 |
+
would mean nothing. Its tasks are still ranked on the modality and category
|
| 237 |
+
boards.
|
| 238 |
+
"""
|
| 239 |
+
boards: list[Board] = []
|
| 240 |
+
for modality in distinct(by_id.values(), "modality"):
|
| 241 |
+
within = [t for t in by_id.values() if str(t.get("modality")) == modality]
|
| 242 |
+
boards.append(_board(MODALITY_GROUP, modality, modality, within))
|
| 243 |
+
for area in distinct(within, "therapeutic_area"):
|
| 244 |
+
if area == OTHER_AREA:
|
| 245 |
+
continue
|
| 246 |
+
tasks = [t for t in within if str(t.get("therapeutic_area")) == area]
|
| 247 |
+
boards.append(_board(AREA_GROUP, area, modality, tasks))
|
| 248 |
+
for category in distinct(within, "category"):
|
| 249 |
+
tasks = [t for t in within if str(t.get("category")) == category]
|
| 250 |
+
boards.append(_board(CATEGORY_GROUP, category, modality, tasks))
|
| 251 |
+
return boards
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@dataclass(frozen=True)
|
| 255 |
+
class OpenBoard:
|
| 256 |
+
"""A slice nobody can be ranked on yet: a stated gap, not a leaderboard.
|
| 257 |
+
|
| 258 |
+
Deliberately not a ``Board``: it has no tasks, no cohorts and no patients, and
|
| 259 |
+
zeroing those fields would print "0 patients" where the page invites a cohort.
|
| 260 |
+
"""
|
| 261 |
+
|
| 262 |
+
group: str
|
| 263 |
+
name: str
|
| 264 |
+
blurb: str
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
OPEN_BOARDS: tuple[OpenBoard, ...] = (
|
| 268 |
+
OpenBoard(
|
| 269 |
+
MODALITY_GROUP,
|
| 270 |
+
"Single-cell RNAseq",
|
| 271 |
+
"Dissociated tissue, labelled at the patient level. No cohort yet.",
|
| 272 |
+
),
|
| 273 |
+
OpenBoard(
|
| 274 |
+
MODALITY_GROUP,
|
| 275 |
+
"Proteomics",
|
| 276 |
+
"Plasma or tissue proteins paired with clinical follow-up. No cohort yet.",
|
| 277 |
+
),
|
| 278 |
+
OpenBoard(
|
| 279 |
+
MODALITY_GROUP,
|
| 280 |
+
"Spatial transcriptomics",
|
| 281 |
+
"Expression kept in place in the tissue, with patient outcomes. No cohort yet.",
|
| 282 |
+
),
|
| 283 |
+
OpenBoard(
|
| 284 |
+
AREA_GROUP,
|
| 285 |
+
"Neuroscience",
|
| 286 |
+
"Neurodegenerative cohorts, such as Alzheimer's or Parkinson's. No cohort yet.",
|
| 287 |
+
),
|
| 288 |
+
OpenBoard(
|
| 289 |
+
AREA_GROUP,
|
| 290 |
+
"Cardiovascular",
|
| 291 |
+
"Heart or vessel disease with outcomes or treatment response. No cohort yet.",
|
| 292 |
+
),
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def in_group(boards: list[Board], group: str) -> list[Board]:
|
| 297 |
+
"""The boards of one group, biggest first."""
|
| 298 |
+
return sorted(
|
| 299 |
+
(b for b in boards if b.group == group), key=lambda b: (-b.n_tasks, b.name)
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def open_in_group(boards: list[Board], group: str) -> list[OpenBoard]:
|
| 304 |
+
"""Open slices of one group, minus any slice the registry has since covered.
|
| 305 |
+
|
| 306 |
+
The day a single-cell cohort lands, its board is built from the registry and
|
| 307 |
+
the matching open slice drops out with no edit here.
|
| 308 |
+
"""
|
| 309 |
+
covered = {board.name.lower() for board in in_group(boards, group)}
|
| 310 |
+
return [
|
| 311 |
+
board
|
| 312 |
+
for board in OPEN_BOARDS
|
| 313 |
+
if board.group == group and board.name.lower() not in covered
|
| 314 |
+
]
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
ALL = "all"
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def _within(by_id: dict[str, dict], modality: str) -> list[dict]:
|
| 321 |
+
"""The tasks of the modality whose slug is ``modality``."""
|
| 322 |
+
return [t for t in by_id.values() if slugify(str(t.get("modality"))) == modality]
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def modality_choices(by_id: dict[str, dict]) -> list[tuple[str, str]]:
|
| 326 |
+
"""One ``(label, slug)`` pill per modality of the registry."""
|
| 327 |
+
choices = {
|
| 328 |
+
slugify(m): modality_label(m) for m in distinct(by_id.values(), "modality")
|
| 329 |
+
}
|
| 330 |
+
return [(name, slug) for slug, name in choices.items()]
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def area_choices(by_id: dict[str, dict], modality: str) -> list[tuple[str, str]]:
|
| 334 |
+
"""The therapeutic-area pills of a modality, "All" first and no "Other"."""
|
| 335 |
+
areas = distinct(_within(by_id, modality), "therapeutic_area")
|
| 336 |
+
return [("All", ALL)] + [(a, slugify(a)) for a in areas if a != OTHER_AREA]
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def category_choices(by_id: dict[str, dict], modality: str) -> list[tuple[str, str]]:
|
| 340 |
+
"""The task-category pills of a modality, "All" first."""
|
| 341 |
+
categories = distinct(_within(by_id, modality), "category")
|
| 342 |
+
return [("All", ALL)] + [(label(c), slugify(c)) for c in categories]
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def view_board(
|
| 346 |
+
by_id: dict[str, dict], modality: str, area: str, category: str
|
| 347 |
+
) -> Board:
|
| 348 |
+
"""The board of one view: a modality's tasks, narrowed by area and category."""
|
| 349 |
+
within = _within(by_id, modality)
|
| 350 |
+
tasks = [
|
| 351 |
+
t
|
| 352 |
+
for t in within
|
| 353 |
+
if area in (ALL, slugify(str(t.get("therapeutic_area"))))
|
| 354 |
+
and category in (ALL, slugify(str(t.get("category"))))
|
| 355 |
+
]
|
| 356 |
+
raw_modality = str(within[0]["modality"]) if within else modality
|
| 357 |
+
if area == ALL and category == ALL:
|
| 358 |
+
return _board(MODALITY_GROUP, raw_modality, raw_modality, tasks)
|
| 359 |
+
if category == ALL:
|
| 360 |
+
name = str(tasks[0]["therapeutic_area"]) if tasks else area
|
| 361 |
+
return _board(AREA_GROUP, name, raw_modality, tasks)
|
| 362 |
+
name = str(tasks[0]["category"]) if tasks else category
|
| 363 |
+
return _board(CATEGORY_GROUP, name, raw_modality, tasks)
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def board_view(board: Board, by_id: dict[str, dict]) -> tuple[str, str, str]:
|
| 367 |
+
"""The ``(modality, area, category)`` pills that show an old ``?board=`` link."""
|
| 368 |
+
task = next((by_id[t] for t in sorted(board.task_ids) if t in by_id), {})
|
| 369 |
+
area = ALL
|
| 370 |
+
category = ALL
|
| 371 |
+
if board.group == AREA_GROUP and task:
|
| 372 |
+
area = slugify(str(task.get("therapeutic_area")))
|
| 373 |
+
if board.group == CATEGORY_GROUP and task:
|
| 374 |
+
category = slugify(str(task.get("category")))
|
| 375 |
+
return slugify(board.modality), area, category
|
evaluator.py
ADDED
|
@@ -0,0 +1,1725 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 7 |
+
matrices—over repeated frozen CV folds or a fixed transfer split. Binary tasks with
|
| 8 |
+
fewer than 30 subjects use leave-pair-out over subject pairs instead. Turning those
|
| 9 |
+
predictions into scores + leaderboard aggregates is the job of ``scoring.py``;
|
| 10 |
+
this module only reads data and runs the probe.
|
| 11 |
+
|
| 12 |
+
Failures are split by who caused them: a bad submission raises/records a
|
| 13 |
+
``SubmissionError`` (the submitter fixes it); anything on our side, such as a
|
| 14 |
+
failed Hugging Face fetch or an unexpected bug, raises ``EvaluatorError`` so it
|
| 15 |
+
is never silently charged against the submitter.
|
| 16 |
+
|
| 17 |
+
Reads the public ``datasets.yaml`` manifest (what the submitter embeds) plus the
|
| 18 |
+
private ``tasks.yaml`` registry, each task's private ``labels.csv``, and hidden
|
| 19 |
+
perturbation ``targets.npz`` matrices where applicable. Each read is at the commit
|
| 20 |
+
that ``versions.py`` pins for the current board version, unless a caller passes
|
| 21 |
+
another ``revision``. It imports only ``scoring.py``, ``versions.py`` and the
|
| 22 |
+
packages of ``requirements.txt``, so it runs unchanged inside a public Hugging Face
|
| 23 |
+
Space.
|
| 24 |
+
|
| 25 |
+
Deps: numpy, pandas, joblib, scikit-learn, pyyaml. ``huggingface_hub`` is used
|
| 26 |
+
only by the fetch helpers / CLI, imported lazily.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import argparse
|
| 30 |
+
import hashlib
|
| 31 |
+
import itertools
|
| 32 |
+
import json
|
| 33 |
+
import logging
|
| 34 |
+
import os
|
| 35 |
+
import time
|
| 36 |
+
import zipfile
|
| 37 |
+
from collections import defaultdict
|
| 38 |
+
from dataclasses import dataclass
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
|
| 41 |
+
import numpy as np
|
| 42 |
+
import pandas as pd
|
| 43 |
+
import yaml
|
| 44 |
+
from joblib import Parallel, cpu_count, delayed, parallel_backend
|
| 45 |
+
from scoring import (
|
| 46 |
+
METRICS,
|
| 47 |
+
TaskScore,
|
| 48 |
+
category_means,
|
| 49 |
+
compute_residual_sample_spearman,
|
| 50 |
+
compute_target_centered_sample_spearman,
|
| 51 |
+
)
|
| 52 |
+
from sklearn.linear_model import LogisticRegressionCV, Ridge
|
| 53 |
+
from sklearn.model_selection import GroupKFold, StratifiedGroupKFold
|
| 54 |
+
from sklearn.preprocessing import StandardScaler
|
| 55 |
+
from versions import CURRENT
|
| 56 |
+
|
| 57 |
+
SAMPLE_ID = "sample_id"
|
| 58 |
+
SUBJECT_ID = "subject_id"
|
| 59 |
+
DATASET_ID = "dataset_id"
|
| 60 |
+
TASK_ID = "task_id"
|
| 61 |
+
LABEL = "label"
|
| 62 |
+
FOLD = "fold"
|
| 63 |
+
FOLD_PREFIX = f"{FOLD}_"
|
| 64 |
+
SPLIT = "split"
|
| 65 |
+
SPLIT_TRAIN = "train"
|
| 66 |
+
SPLIT_TEST = "test"
|
| 67 |
+
PAIR_ID = "pair_id"
|
| 68 |
+
CONTROL_ID = "control_id"
|
| 69 |
+
PERTURBED_ID = "perturbed_id"
|
| 70 |
+
PAIRING = "pairing"
|
| 71 |
+
PAIRED = "paired"
|
| 72 |
+
CARTESIAN = "cartesian"
|
| 73 |
+
PERTURBATION = "perturbation"
|
| 74 |
+
RESIDUAL_SPEARMAN = "residual_spearman"
|
| 75 |
+
CENTERED_SPEARMAN = "centered_spearman"
|
| 76 |
+
ORG = "PRIMOmics"
|
| 77 |
+
PUBLIC_REPO = f"{ORG}/primo"
|
| 78 |
+
LABELS_REPO = f"{ORG}/primo-labels"
|
| 79 |
+
RESULTS_REPO = f"{ORG}/primo-results"
|
| 80 |
+
MANIFEST_FILENAME = "datasets.yaml"
|
| 81 |
+
PUBLIC_TASKS_MANIFEST = "public_tasks/manifest.yaml"
|
| 82 |
+
TASKS_FILENAME = "tasks.yaml"
|
| 83 |
+
LABELS_FILENAME = "labels.csv"
|
| 84 |
+
TARGETS_FILENAME = "targets.npz"
|
| 85 |
+
MODALITIES = {"bulk-rna": "bulk RNA", "single-cell-rna": "Single Cell RNA"}
|
| 86 |
+
|
| 87 |
+
RIDGE_ALPHAS = np.logspace(-6.0, 6.0, 25)
|
| 88 |
+
PERTURBATION_TEST_Z_CLIP = 20.0
|
| 89 |
+
LOGREG_CS = np.logspace(-6.0, 6.0, 13)
|
| 90 |
+
INNER_CV = 5
|
| 91 |
+
INNER_CV_JOBS = 4
|
| 92 |
+
OUTER_CV_JOBS = min(10, cpu_count())
|
| 93 |
+
OUTER_PARALLEL_MIN_SAMPLES = 30
|
| 94 |
+
LOGREG_TOL = 1e-3
|
| 95 |
+
MAX_ITER = 5000
|
| 96 |
+
RANDOM_STATE = 0
|
| 97 |
+
MIN_CV_SUBJECTS = 30
|
| 98 |
+
CHECKPOINT_PROTOCOL = "grouped-inner-cv-spectral-ridge-v2"
|
| 99 |
+
|
| 100 |
+
EMBEDDING = "embedding"
|
| 101 |
+
GENE_SPACE = "gene_space"
|
| 102 |
+
REPRESENTATIONS = (EMBEDDING, GENE_SPACE)
|
| 103 |
+
|
| 104 |
+
FETCH_ATTEMPTS = 3
|
| 105 |
+
FETCH_BACKOFF = 1.0
|
| 106 |
+
|
| 107 |
+
NPZ_ID_KEYS = ("sample_ids", "sample_id", "ids")
|
| 108 |
+
NPZ_EMB_KEYS = ("embeddings", "embedding", "emb", "X")
|
| 109 |
+
NPZ_DSID_KEYS = ("dataset_ids", "dataset_id")
|
| 110 |
+
NOT_NPZ = "not a .npz archive: save it with np.savez, or use .csv/.parquet"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class SubmissionError(ValueError):
|
| 114 |
+
"""A submission the evaluator cannot score (bad format, missing samples...)."""
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class EvaluatorError(RuntimeError):
|
| 118 |
+
"""A failure on our side (data fetch, unexpected bug), not the submitter's.
|
| 119 |
+
|
| 120 |
+
Raised instead of being recorded as a per-dataset skip, so a transient
|
| 121 |
+
Hugging Face hiccup or an evaluator bug never silently costs a submitter
|
| 122 |
+
their coverage.
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@dataclass
|
| 127 |
+
class TaskOutcome:
|
| 128 |
+
"""What happened to one scoreable task: scored / missing / invalid."""
|
| 129 |
+
|
| 130 |
+
task_id: str
|
| 131 |
+
dataset_id: str
|
| 132 |
+
status: str
|
| 133 |
+
score: TaskScore | None = None
|
| 134 |
+
reason: str | None = None
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@dataclass(frozen=True)
|
| 138 |
+
class PerturbationTargets:
|
| 139 |
+
"""Hidden response vectors and their aligned gene identifiers."""
|
| 140 |
+
|
| 141 |
+
delta: np.ndarray
|
| 142 |
+
gene_ids: np.ndarray
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@dataclass(frozen=True)
|
| 146 |
+
class _PendingTask:
|
| 147 |
+
"""Fetched task inputs awaiting either individual or compatible batch scoring."""
|
| 148 |
+
|
| 149 |
+
position: int
|
| 150 |
+
task: dict
|
| 151 |
+
emb: pd.DataFrame
|
| 152 |
+
labels: pd.DataFrame
|
| 153 |
+
targets: PerturbationTargets | None
|
| 154 |
+
fingerprint: str
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _digest_file(digest: "hashlib._Hash", path: str | Path) -> None:
|
| 158 |
+
"""Feed a file's bytes into ``digest`` in chunks."""
|
| 159 |
+
with open(path, "rb") as handle:
|
| 160 |
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
| 161 |
+
digest.update(chunk)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
class _TaskCheckpoints:
|
| 165 |
+
"""Persist completed task outcomes under one submission-scoped directory."""
|
| 166 |
+
|
| 167 |
+
def __init__(
|
| 168 |
+
self,
|
| 169 |
+
directory: str | Path,
|
| 170 |
+
submission: str | Path,
|
| 171 |
+
modality: str | None,
|
| 172 |
+
representation: str,
|
| 173 |
+
reference: str | Path | None = None,
|
| 174 |
+
) -> None:
|
| 175 |
+
digest = hashlib.sha256()
|
| 176 |
+
_digest_file(digest, submission)
|
| 177 |
+
digest.update(f"{CHECKPOINT_PROTOCOL}\0{modality}\0{representation}".encode())
|
| 178 |
+
if reference is not None:
|
| 179 |
+
digest.update(b"\0reference\0")
|
| 180 |
+
_digest_file(digest, reference)
|
| 181 |
+
digest.update(
|
| 182 |
+
json.dumps(
|
| 183 |
+
{
|
| 184 |
+
"ridge_alphas": RIDGE_ALPHAS.tolist(),
|
| 185 |
+
"logreg_cs": LOGREG_CS.tolist(),
|
| 186 |
+
"inner_cv": INNER_CV,
|
| 187 |
+
"logreg_tol": LOGREG_TOL,
|
| 188 |
+
"max_iter": MAX_ITER,
|
| 189 |
+
"min_cv_subjects": MIN_CV_SUBJECTS,
|
| 190 |
+
"random_state": RANDOM_STATE,
|
| 191 |
+
},
|
| 192 |
+
sort_keys=True,
|
| 193 |
+
).encode()
|
| 194 |
+
)
|
| 195 |
+
self.directory = Path(directory) / digest.hexdigest()
|
| 196 |
+
self.directory.mkdir(parents=True, exist_ok=True)
|
| 197 |
+
|
| 198 |
+
def _path(self, task_id: str) -> Path:
|
| 199 |
+
safe_id = hashlib.sha256(task_id.encode()).hexdigest()[:16]
|
| 200 |
+
return self.directory / f"task-{safe_id}.json"
|
| 201 |
+
|
| 202 |
+
def load(self, task_id: str, fingerprint: str) -> TaskOutcome | None:
|
| 203 |
+
path = self._path(task_id)
|
| 204 |
+
if not path.exists():
|
| 205 |
+
return None
|
| 206 |
+
try:
|
| 207 |
+
payload = json.loads(path.read_text())
|
| 208 |
+
if payload["fingerprint"] != fingerprint:
|
| 209 |
+
return None
|
| 210 |
+
raw_score = payload.get("score")
|
| 211 |
+
score = None
|
| 212 |
+
if raw_score is not None:
|
| 213 |
+
raw_score["repeat_scores"] = tuple(raw_score["repeat_scores"])
|
| 214 |
+
score = TaskScore(**raw_score)
|
| 215 |
+
return TaskOutcome(
|
| 216 |
+
task_id=payload["task_id"],
|
| 217 |
+
dataset_id=payload["dataset_id"],
|
| 218 |
+
status=payload["status"],
|
| 219 |
+
score=score,
|
| 220 |
+
reason=payload.get("reason"),
|
| 221 |
+
)
|
| 222 |
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
| 223 |
+
logging.warning("ignoring invalid task checkpoint: %s", path)
|
| 224 |
+
return None
|
| 225 |
+
|
| 226 |
+
def save(self, outcome: TaskOutcome, fingerprint: str) -> None:
|
| 227 |
+
path = self._path(outcome.task_id)
|
| 228 |
+
score = None
|
| 229 |
+
if outcome.score is not None:
|
| 230 |
+
score = {
|
| 231 |
+
"task_id": outcome.score.task_id,
|
| 232 |
+
"dataset_id": outcome.score.dataset_id,
|
| 233 |
+
"category": outcome.score.category,
|
| 234 |
+
"metric": outcome.score.metric,
|
| 235 |
+
"score": outcome.score.score,
|
| 236 |
+
"n_samples": outcome.score.n_samples,
|
| 237 |
+
"repeat_scores": list(outcome.score.repeat_scores),
|
| 238 |
+
"diagnostics": outcome.score.diagnostics,
|
| 239 |
+
}
|
| 240 |
+
payload = {
|
| 241 |
+
"fingerprint": fingerprint,
|
| 242 |
+
"task_id": outcome.task_id,
|
| 243 |
+
"dataset_id": outcome.dataset_id,
|
| 244 |
+
"status": outcome.status,
|
| 245 |
+
"score": score,
|
| 246 |
+
"reason": outcome.reason,
|
| 247 |
+
}
|
| 248 |
+
temporary = path.with_suffix(".tmp")
|
| 249 |
+
temporary.write_text(json.dumps(payload, sort_keys=True) + "\n")
|
| 250 |
+
temporary.replace(path)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _task_fingerprint(
|
| 254 |
+
task: dict,
|
| 255 |
+
labels: pd.DataFrame,
|
| 256 |
+
targets: PerturbationTargets | None,
|
| 257 |
+
) -> str:
|
| 258 |
+
"""Fingerprint every private input that can affect a task checkpoint."""
|
| 259 |
+
digest = hashlib.sha256(
|
| 260 |
+
json.dumps(task, sort_keys=True, separators=(",", ":")).encode()
|
| 261 |
+
)
|
| 262 |
+
digest.update(json.dumps(list(labels.columns)).encode())
|
| 263 |
+
digest.update(json.dumps([str(dtype) for dtype in labels.dtypes]).encode())
|
| 264 |
+
digest.update(pd.util.hash_pandas_object(labels, index=True).values.tobytes())
|
| 265 |
+
if targets is not None:
|
| 266 |
+
digest.update(targets.delta.tobytes())
|
| 267 |
+
digest.update(targets.gene_ids.astype(str).tobytes())
|
| 268 |
+
return digest.hexdigest()
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _regression_geometry(labels: pd.DataFrame) -> str:
|
| 272 |
+
"""Identify tasks whose design matrices and evaluation splits are identical."""
|
| 273 |
+
columns = [SAMPLE_ID, SUBJECT_ID, *_fold_columns(labels)]
|
| 274 |
+
if SPLIT in labels:
|
| 275 |
+
columns.append(SPLIT)
|
| 276 |
+
frame = labels[columns]
|
| 277 |
+
digest = hashlib.sha256(json.dumps(columns).encode())
|
| 278 |
+
digest.update(pd.util.hash_pandas_object(frame, index=True).values.tobytes())
|
| 279 |
+
return digest.hexdigest()
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _outcome_from_score(task: dict, score: TaskScore) -> TaskOutcome:
|
| 283 |
+
"""Map a completed score to the public scored/invalid outcome contract."""
|
| 284 |
+
task_id = _norm_id(task[TASK_ID])
|
| 285 |
+
dataset_id = _norm_id(task[DATASET_ID])
|
| 286 |
+
if not np.isfinite(score.score):
|
| 287 |
+
return TaskOutcome(
|
| 288 |
+
task_id,
|
| 289 |
+
dataset_id,
|
| 290 |
+
"invalid",
|
| 291 |
+
reason="degenerate score (constant predictions)",
|
| 292 |
+
)
|
| 293 |
+
return TaskOutcome(task_id, dataset_id, "scored", score=score)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _norm_id(value: object) -> str:
|
| 297 |
+
"""Normalise an id so int/str/float spellings of the same id join."""
|
| 298 |
+
if isinstance(value, float) and value.is_integer():
|
| 299 |
+
return str(int(value))
|
| 300 |
+
return str(value).strip()
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def load_tasks_registry(path: str | Path) -> list[dict]:
|
| 304 |
+
"""Read the private ``tasks.yaml`` registry (one entry per task)."""
|
| 305 |
+
with open(path) as handle:
|
| 306 |
+
data = yaml.safe_load(handle)
|
| 307 |
+
tasks = data.get("tasks", []) if isinstance(data, dict) else (data or [])
|
| 308 |
+
ids = [_norm_id(t[TASK_ID]) for t in tasks]
|
| 309 |
+
if len(set(ids)) != len(ids):
|
| 310 |
+
raise EvaluatorError(f"duplicate task_id in the registry: {ids}")
|
| 311 |
+
return tasks
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def _fold_columns(labels: pd.DataFrame) -> list[str]:
|
| 315 |
+
"""Return the ordered external-CV fold columns in private labels."""
|
| 316 |
+
repeated = [column for column in labels if column.startswith(FOLD_PREFIX)]
|
| 317 |
+
invalid = [
|
| 318 |
+
column for column in repeated if not column.removeprefix(FOLD_PREFIX).isdigit()
|
| 319 |
+
]
|
| 320 |
+
if invalid:
|
| 321 |
+
raise EvaluatorError(f"labels.csv has invalid repeat-fold columns: {invalid}")
|
| 322 |
+
first = [FOLD] if FOLD in labels.columns else []
|
| 323 |
+
return [
|
| 324 |
+
*first,
|
| 325 |
+
*sorted(repeated, key=lambda column: int(column.removeprefix(FOLD_PREFIX))),
|
| 326 |
+
]
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def load_labels(path: str | Path) -> pd.DataFrame:
|
| 330 |
+
"""Read private labels with repeated CV folds or one transfer split."""
|
| 331 |
+
df = pd.read_csv(path)
|
| 332 |
+
is_perturbation = PAIR_ID in df.columns
|
| 333 |
+
required = (
|
| 334 |
+
{SAMPLE_ID, SUBJECT_ID, PAIR_ID}
|
| 335 |
+
if is_perturbation
|
| 336 |
+
else {SAMPLE_ID, SUBJECT_ID, LABEL}
|
| 337 |
+
)
|
| 338 |
+
missing = required - set(df.columns)
|
| 339 |
+
if missing:
|
| 340 |
+
raise EvaluatorError(f"labels.csv missing columns: {sorted(missing)}")
|
| 341 |
+
_subject_groups(df)
|
| 342 |
+
has_folds = bool(_fold_columns(df))
|
| 343 |
+
has_split = SPLIT in df.columns
|
| 344 |
+
pairing_values = (
|
| 345 |
+
set(df[PAIRING].astype(str)) if is_perturbation and PAIRING in df else set()
|
| 346 |
+
)
|
| 347 |
+
is_cartesian = pairing_values == {CARTESIAN}
|
| 348 |
+
if sum((has_folds, has_split, is_cartesian)) != 1:
|
| 349 |
+
raise EvaluatorError(
|
| 350 |
+
"labels.csv needs exactly one evaluation assignment: folds, transfer "
|
| 351 |
+
"split, or Cartesian pairing."
|
| 352 |
+
)
|
| 353 |
+
if is_cartesian:
|
| 354 |
+
missing = {CONTROL_ID, PERTURBED_ID, PAIRING} - set(df.columns)
|
| 355 |
+
if missing:
|
| 356 |
+
raise EvaluatorError(
|
| 357 |
+
f"Cartesian labels.csv missing columns: {sorted(missing)}"
|
| 358 |
+
)
|
| 359 |
+
return df
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def load_targets(path: str | Path) -> PerturbationTargets:
|
| 363 |
+
"""Read and validate one task's hidden perturbation responses."""
|
| 364 |
+
with np.load(path, allow_pickle=False) as data:
|
| 365 |
+
missing = {"delta", "gene_ids"} - set(data.files)
|
| 366 |
+
if missing:
|
| 367 |
+
raise EvaluatorError(f"targets.npz missing arrays: {sorted(missing)}")
|
| 368 |
+
delta = np.asarray(data["delta"], dtype=float)
|
| 369 |
+
gene_ids = np.asarray(data["gene_ids"]).astype(str)
|
| 370 |
+
if delta.ndim != 2:
|
| 371 |
+
raise EvaluatorError(f"targets delta must be 2D, got shape {delta.shape}")
|
| 372 |
+
if gene_ids.ndim != 1 or len(gene_ids) != delta.shape[1]:
|
| 373 |
+
raise EvaluatorError("targets gene_ids must align with delta columns")
|
| 374 |
+
if not np.isfinite(delta).all():
|
| 375 |
+
raise EvaluatorError("targets delta contains NaN or inf values")
|
| 376 |
+
return PerturbationTargets(delta=delta, gene_ids=gene_ids)
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def load_submission(path: str | Path) -> dict[str, pd.DataFrame]:
|
| 380 |
+
"""Load a multi-dataset submission into ``{dataset_id: raw_block_frame}``."""
|
| 381 |
+
ext = Path(path).suffix.lower()
|
| 382 |
+
if ext == ".npz":
|
| 383 |
+
frame = _npz_to_frame(path)
|
| 384 |
+
elif ext in (".parquet", ".pq"):
|
| 385 |
+
frame = pd.read_parquet(path)
|
| 386 |
+
elif ext in (".tsv", ".txt"):
|
| 387 |
+
frame = pd.read_csv(path, sep="\t")
|
| 388 |
+
elif ext == ".csv":
|
| 389 |
+
frame = pd.read_csv(path)
|
| 390 |
+
else:
|
| 391 |
+
raise SubmissionError(
|
| 392 |
+
f"unsupported submission type '{ext}'. Use .csv/.tsv/.parquet/.npz"
|
| 393 |
+
)
|
| 394 |
+
return _split_by_dataset(frame)
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def _npz_to_frame(path: str | Path) -> pd.DataFrame:
|
| 398 |
+
try:
|
| 399 |
+
data = np.load(path, allow_pickle=False)
|
| 400 |
+
except (ValueError, EOFError, zipfile.BadZipFile) as err:
|
| 401 |
+
raise SubmissionError(NOT_NPZ) from err
|
| 402 |
+
if not isinstance(data, np.lib.npyio.NpzFile):
|
| 403 |
+
raise SubmissionError(NOT_NPZ)
|
| 404 |
+
ds_key = next((k for k in NPZ_DSID_KEYS if k in data), None)
|
| 405 |
+
id_key = next((k for k in NPZ_ID_KEYS if k in data), None)
|
| 406 |
+
emb_key = next((k for k in NPZ_EMB_KEYS if k in data), None)
|
| 407 |
+
if ds_key is None or id_key is None or emb_key is None:
|
| 408 |
+
raise SubmissionError(
|
| 409 |
+
f".npz needs a dataset-id array {NPZ_DSID_KEYS}, a sample-id array "
|
| 410 |
+
f"{NPZ_ID_KEYS}, and an embedding array {NPZ_EMB_KEYS}; "
|
| 411 |
+
f"found {list(data.keys())}"
|
| 412 |
+
)
|
| 413 |
+
try:
|
| 414 |
+
emb = np.asarray(data[emb_key])
|
| 415 |
+
sample_ids = np.asarray(data[id_key])
|
| 416 |
+
dataset_ids = np.asarray(data[ds_key])
|
| 417 |
+
except (ValueError, zipfile.BadZipFile) as err:
|
| 418 |
+
raise SubmissionError(
|
| 419 |
+
f"cannot read the .npz arrays ({err}). Save ids as text, e.g. "
|
| 420 |
+
"np.asarray(ids, dtype=str), or use .csv/.parquet"
|
| 421 |
+
) from err
|
| 422 |
+
if emb.ndim != 2:
|
| 423 |
+
raise SubmissionError(f"embeddings must be 2D, got shape {emb.shape}")
|
| 424 |
+
frame = pd.DataFrame(emb)
|
| 425 |
+
frame.insert(0, SAMPLE_ID, sample_ids)
|
| 426 |
+
frame.insert(0, DATASET_ID, dataset_ids)
|
| 427 |
+
return frame
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def _split_by_dataset(frame: pd.DataFrame) -> dict[str, pd.DataFrame]:
|
| 431 |
+
"""Group a submission by ``dataset_id`` into per-dataset blocks (raw frames)."""
|
| 432 |
+
lower = {str(c).lower(): c for c in frame.columns}
|
| 433 |
+
if DATASET_ID not in lower:
|
| 434 |
+
raise SubmissionError(
|
| 435 |
+
f"submission needs a '{DATASET_ID}' column; found {list(frame.columns)}"
|
| 436 |
+
)
|
| 437 |
+
ds_col = lower[DATASET_ID]
|
| 438 |
+
blocks: dict[str, pd.DataFrame] = {}
|
| 439 |
+
for raw_ds, group in frame.groupby(ds_col, sort=False):
|
| 440 |
+
dataset_id = _norm_id(raw_ds)
|
| 441 |
+
if dataset_id in blocks:
|
| 442 |
+
raise SubmissionError(
|
| 443 |
+
f"dataset_id '{dataset_id}' appears under multiple spellings"
|
| 444 |
+
)
|
| 445 |
+
blocks[dataset_id] = group.drop(columns=[ds_col]).dropna(axis=1, how="all")
|
| 446 |
+
if not blocks:
|
| 447 |
+
raise SubmissionError("empty submission")
|
| 448 |
+
return blocks
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def _to_embedding_frame(frame: pd.DataFrame) -> pd.DataFrame:
|
| 452 |
+
lower = {str(c).lower(): c for c in frame.columns}
|
| 453 |
+
if SAMPLE_ID not in lower:
|
| 454 |
+
raise SubmissionError(
|
| 455 |
+
f"submission needs a '{SAMPLE_ID}' column; found {list(frame.columns)}"
|
| 456 |
+
)
|
| 457 |
+
id_col = lower[SAMPLE_ID]
|
| 458 |
+
ids = [_norm_id(v) for v in frame[id_col]]
|
| 459 |
+
emb = frame.drop(columns=[id_col])
|
| 460 |
+
non_numeric = [c for c in emb.columns if not pd.api.types.is_numeric_dtype(emb[c])]
|
| 461 |
+
if non_numeric:
|
| 462 |
+
raise SubmissionError(
|
| 463 |
+
f"embedding columns must be numeric; non-numeric: {non_numeric[:5]}"
|
| 464 |
+
)
|
| 465 |
+
if emb.shape[1] == 0:
|
| 466 |
+
raise SubmissionError("submission has no embedding columns")
|
| 467 |
+
out = emb.astype(float)
|
| 468 |
+
out.index = ids
|
| 469 |
+
if out.index.has_duplicates:
|
| 470 |
+
dups = out.index[out.index.duplicated()].unique().tolist()
|
| 471 |
+
raise SubmissionError(f"duplicate sample_ids in submission: {dups[:5]}")
|
| 472 |
+
return out
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def _l2_normalize_rows(emb: pd.DataFrame) -> pd.DataFrame:
|
| 476 |
+
"""Put every sample on the unit sphere; an embedding dimension has no unit."""
|
| 477 |
+
norms = np.linalg.norm(emb.to_numpy(dtype=float), axis=1, keepdims=True)
|
| 478 |
+
return pd.DataFrame(
|
| 479 |
+
emb.to_numpy(dtype=float) / np.where(norms == 0.0, 1.0, norms),
|
| 480 |
+
index=emb.index,
|
| 481 |
+
columns=emb.columns,
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def _load_reference(path: str | Path) -> dict[str, pd.DataFrame]:
|
| 486 |
+
"""Read the gene-space reference concatenated to submissions, by dataset.
|
| 487 |
+
|
| 488 |
+
The reference is ours, so any defect in it is an ``EvaluatorError``.
|
| 489 |
+
"""
|
| 490 |
+
try:
|
| 491 |
+
return {
|
| 492 |
+
dataset_id: _to_embedding_frame(block)
|
| 493 |
+
for dataset_id, block in load_submission(path).items()
|
| 494 |
+
}
|
| 495 |
+
except (OSError, ValueError) as error:
|
| 496 |
+
message = f"could not load the reference features: {error}"
|
| 497 |
+
raise EvaluatorError(message) from error
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
def _concat_reference(emb: pd.DataFrame, reference: pd.DataFrame) -> pd.DataFrame:
|
| 501 |
+
"""Append the reference columns to each sample, then L2-normalize the row.
|
| 502 |
+
|
| 503 |
+
Samples absent from either side are dropped, so a labelled sample the
|
| 504 |
+
submission lacks still fails ``_align`` exactly as without a reference. One
|
| 505 |
+
the reference lacks is caught first by ``_check_reference_covers``.
|
| 506 |
+
"""
|
| 507 |
+
joined = emb.add_prefix("model_").join(
|
| 508 |
+
reference.add_prefix("reference_"), how="inner"
|
| 509 |
+
)
|
| 510 |
+
return _l2_normalize_rows(joined)
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def _check_reference_covers(
|
| 514 |
+
labels: pd.DataFrame, reference: pd.DataFrame, task_id: str
|
| 515 |
+
) -> None:
|
| 516 |
+
"""Raise if the reference lacks a labelled sample: that defect is ours."""
|
| 517 |
+
lab_ids = [_norm_id(v) for v in labels[SAMPLE_ID]]
|
| 518 |
+
present = set(reference.index)
|
| 519 |
+
missing = [s for s in lab_ids if s not in present]
|
| 520 |
+
if missing:
|
| 521 |
+
raise EvaluatorError(
|
| 522 |
+
f"reference features lack {len(missing)}/{len(lab_ids)} labelled "
|
| 523 |
+
f"samples of {task_id}, e.g. {missing[:5]}"
|
| 524 |
+
)
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def _align(labels: pd.DataFrame, emb: pd.DataFrame) -> np.ndarray:
|
| 528 |
+
"""Return the embedding matrix in labels order, or raise if a sample is missing."""
|
| 529 |
+
lab_ids = [_norm_id(v) for v in labels[SAMPLE_ID]]
|
| 530 |
+
present = set(emb.index)
|
| 531 |
+
missing = [s for s in lab_ids if s not in present]
|
| 532 |
+
if missing:
|
| 533 |
+
raise SubmissionError(
|
| 534 |
+
f"{len(missing)}/{len(lab_ids)} labelled samples missing from "
|
| 535 |
+
f"submission, e.g. {missing[:5]}"
|
| 536 |
+
)
|
| 537 |
+
matrix = emb.loc[lab_ids].to_numpy(dtype=float)
|
| 538 |
+
if not np.isfinite(matrix).all():
|
| 539 |
+
raise SubmissionError("submission contains NaN or inf values")
|
| 540 |
+
return matrix
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
def _subject_groups(labels: pd.DataFrame) -> np.ndarray:
|
| 544 |
+
"""Return normalized private subject ids, rejecting unusable metadata."""
|
| 545 |
+
if SUBJECT_ID not in labels:
|
| 546 |
+
raise EvaluatorError(f"labels.csv missing columns: ['{SUBJECT_ID}']")
|
| 547 |
+
if labels[SUBJECT_ID].isna().any():
|
| 548 |
+
raise EvaluatorError("labels.csv has missing subject_id values")
|
| 549 |
+
groups = np.array([_norm_id(value) for value in labels[SUBJECT_ID]])
|
| 550 |
+
if np.any(groups == ""):
|
| 551 |
+
raise EvaluatorError("labels.csv has empty subject_id values")
|
| 552 |
+
return groups
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
def _group_inner_cv(groups_train: np.ndarray) -> list[tuple[np.ndarray, np.ndarray]]:
|
| 556 |
+
"""Subject-grouped inner folds for ridge penalty selection."""
|
| 557 |
+
n_groups = len(np.unique(groups_train))
|
| 558 |
+
n_splits = min(INNER_CV, n_groups)
|
| 559 |
+
if n_splits < 2:
|
| 560 |
+
raise SubmissionError("an outer training fold has fewer than two subjects")
|
| 561 |
+
splitter = GroupKFold(n_splits=n_splits)
|
| 562 |
+
rows = np.zeros(len(groups_train))
|
| 563 |
+
return list(splitter.split(rows, groups=groups_train))
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
def _ridge_fold_losses(
|
| 567 |
+
x_train: np.ndarray,
|
| 568 |
+
y_train: np.ndarray,
|
| 569 |
+
x_test: np.ndarray,
|
| 570 |
+
y_test: np.ndarray,
|
| 571 |
+
) -> np.ndarray:
|
| 572 |
+
"""Return validation MSE for every alpha from one exact SVD."""
|
| 573 |
+
return np.mean(_ridge_fold_target_losses(x_train, y_train, x_test, y_test), axis=1)
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def _ridge_fold_target_losses(
|
| 577 |
+
x_train: np.ndarray,
|
| 578 |
+
y_train: np.ndarray,
|
| 579 |
+
x_test: np.ndarray,
|
| 580 |
+
y_test: np.ndarray,
|
| 581 |
+
) -> np.ndarray:
|
| 582 |
+
"""Return per-target validation MSE while sharing one factorization."""
|
| 583 |
+
y_train = np.asarray(y_train)
|
| 584 |
+
y_test = np.asarray(y_test)
|
| 585 |
+
if y_train.ndim == 1:
|
| 586 |
+
y_train = y_train[:, None]
|
| 587 |
+
y_test = y_test[:, None]
|
| 588 |
+
x_offset = np.mean(x_train, axis=0)
|
| 589 |
+
y_offset = np.mean(y_train, axis=0)
|
| 590 |
+
x_centered = x_train - x_offset
|
| 591 |
+
y_centered = y_train - y_offset
|
| 592 |
+
u, singular_values, vt = np.linalg.svd(x_centered, full_matrices=False)
|
| 593 |
+
projected_x = (x_test - x_offset) @ vt.T
|
| 594 |
+
projected_y = u.T @ y_centered
|
| 595 |
+
shrinkage = singular_values[:, None] / (
|
| 596 |
+
singular_values[:, None] ** 2 + RIDGE_ALPHAS[None, :]
|
| 597 |
+
)
|
| 598 |
+
losses = []
|
| 599 |
+
for index in range(len(RIDGE_ALPHAS)):
|
| 600 |
+
predictions = projected_x @ (shrinkage[:, index, None] * projected_y) + y_offset
|
| 601 |
+
losses.append(np.mean((predictions - y_test) ** 2, axis=0))
|
| 602 |
+
return np.asarray(losses)
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def _ridge_alpha(matrix: np.ndarray, target: np.ndarray, groups: np.ndarray) -> float:
|
| 606 |
+
"""Select the grouped-CV ridge penalty without refitting for every alpha."""
|
| 607 |
+
losses = [
|
| 608 |
+
_ridge_fold_losses(matrix[train], target[train], matrix[test], target[test])
|
| 609 |
+
for train, test in _group_inner_cv(groups)
|
| 610 |
+
]
|
| 611 |
+
return float(RIDGE_ALPHAS[np.argmin(np.mean(losses, axis=0))])
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
def _ridge_alphas_by_target(
|
| 615 |
+
matrix: np.ndarray, targets: np.ndarray, groups: np.ndarray
|
| 616 |
+
) -> np.ndarray:
|
| 617 |
+
"""Select independent penalties while sharing each fold's factorization."""
|
| 618 |
+
losses = [
|
| 619 |
+
_ridge_fold_target_losses(
|
| 620 |
+
matrix[train], targets[train], matrix[test], targets[test]
|
| 621 |
+
)
|
| 622 |
+
for train, test in _group_inner_cv(groups)
|
| 623 |
+
]
|
| 624 |
+
mean_losses = np.mean(losses, axis=0)
|
| 625 |
+
return RIDGE_ALPHAS[np.argmin(mean_losses, axis=0)]
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
def _ridge_predict(
|
| 629 |
+
x_train: np.ndarray,
|
| 630 |
+
y_train: np.ndarray,
|
| 631 |
+
x_test: np.ndarray,
|
| 632 |
+
groups_train: np.ndarray,
|
| 633 |
+
) -> np.ndarray:
|
| 634 |
+
"""Fit ridge with an exact grouped-CV spectral alpha search."""
|
| 635 |
+
alpha = _ridge_alpha(x_train, y_train, groups_train)
|
| 636 |
+
return Ridge(alpha=alpha).fit(x_train, y_train).predict(x_test)
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def _ridge_predict_by_target(
|
| 640 |
+
x_train: np.ndarray,
|
| 641 |
+
y_train: np.ndarray,
|
| 642 |
+
x_test: np.ndarray,
|
| 643 |
+
groups_train: np.ndarray,
|
| 644 |
+
) -> np.ndarray:
|
| 645 |
+
"""Fit independent targets while reusing their spectral CV decompositions."""
|
| 646 |
+
alphas = _ridge_alphas_by_target(x_train, y_train, groups_train)
|
| 647 |
+
return Ridge(alpha=alphas).fit(x_train, y_train).predict(x_test)
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
def _relabelled_groups(groups: np.ndarray, seed: int) -> np.ndarray:
|
| 651 |
+
"""Reorder group tie-breaks without shuffling label counts between groups."""
|
| 652 |
+
unique, codes = np.unique(groups, return_inverse=True)
|
| 653 |
+
return np.random.default_rng(seed).permutation(len(unique))[codes]
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
def _inner_cv(
|
| 657 |
+
y_train: np.ndarray, groups_train: np.ndarray
|
| 658 |
+
) -> list[tuple[np.ndarray, np.ndarray]]:
|
| 659 |
+
"""Stratified subject-grouped folds for logistic penalty selection."""
|
| 660 |
+
classes = np.unique(y_train)
|
| 661 |
+
support = min(len(np.unique(groups_train[y_train == label])) for label in classes)
|
| 662 |
+
n_splits = min(INNER_CV, support)
|
| 663 |
+
if n_splits < 2:
|
| 664 |
+
raise SubmissionError(
|
| 665 |
+
"an outer training fold has fewer than two subjects in a class"
|
| 666 |
+
)
|
| 667 |
+
rows = np.zeros(len(y_train))
|
| 668 |
+
relabelled_groups = _relabelled_groups(groups_train, RANDOM_STATE)
|
| 669 |
+
splitter = StratifiedGroupKFold(n_splits=n_splits)
|
| 670 |
+
splits = list(splitter.split(rows, y_train, relabelled_groups))
|
| 671 |
+
expected_classes = set(classes)
|
| 672 |
+
if not all(
|
| 673 |
+
set(np.unique(y_train[train])) == expected_classes
|
| 674 |
+
and set(np.unique(y_train[test])) == expected_classes
|
| 675 |
+
for train, test in splits
|
| 676 |
+
):
|
| 677 |
+
raise SubmissionError(
|
| 678 |
+
"could not construct inner subject-grouped folds with every class"
|
| 679 |
+
)
|
| 680 |
+
return splits
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
def fit_predict(
|
| 684 |
+
task_type: str,
|
| 685 |
+
x_train: np.ndarray,
|
| 686 |
+
y_train: np.ndarray,
|
| 687 |
+
x_test: np.ndarray,
|
| 688 |
+
classes: np.ndarray,
|
| 689 |
+
groups_train: np.ndarray,
|
| 690 |
+
inner_cv_jobs: int = INNER_CV_JOBS,
|
| 691 |
+
logreg_tol: float | None = None,
|
| 692 |
+
) -> np.ndarray:
|
| 693 |
+
"""Fit the fixed linear probe on train, return predictions for test."""
|
| 694 |
+
scaler = StandardScaler().fit(x_train)
|
| 695 |
+
x_train = scaler.transform(x_train)
|
| 696 |
+
x_test = scaler.transform(x_test)
|
| 697 |
+
logreg_tol = LOGREG_TOL if logreg_tol is None else logreg_tol
|
| 698 |
+
if task_type == "classification":
|
| 699 |
+
if len(np.unique(y_train)) < 2:
|
| 700 |
+
raise SubmissionError("a training fold has a single class")
|
| 701 |
+
scoring = "roc_auc" if len(classes) == 2 else "roc_auc_ovr_weighted"
|
| 702 |
+
clf = LogisticRegressionCV(
|
| 703 |
+
Cs=LOGREG_CS,
|
| 704 |
+
cv=_inner_cv(y_train, groups_train),
|
| 705 |
+
scoring=scoring,
|
| 706 |
+
solver="lbfgs",
|
| 707 |
+
max_iter=MAX_ITER,
|
| 708 |
+
n_jobs=inner_cv_jobs,
|
| 709 |
+
random_state=RANDOM_STATE,
|
| 710 |
+
tol=logreg_tol,
|
| 711 |
+
).fit(x_train, y_train)
|
| 712 |
+
proba = clf.predict_proba(x_test)
|
| 713 |
+
col = {c: i for i, c in enumerate(clf.classes_)}
|
| 714 |
+
out = np.zeros((x_test.shape[0], len(classes)))
|
| 715 |
+
for j, c in enumerate(classes):
|
| 716 |
+
if c in col:
|
| 717 |
+
out[:, j] = proba[:, col[c]]
|
| 718 |
+
return out
|
| 719 |
+
return _ridge_predict(x_train, y_train, x_test, groups_train)
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
def _fold_score(
|
| 723 |
+
metric: str,
|
| 724 |
+
task_type: str,
|
| 725 |
+
y: np.ndarray,
|
| 726 |
+
folds: np.ndarray,
|
| 727 |
+
matrix: np.ndarray,
|
| 728 |
+
classes: np.ndarray,
|
| 729 |
+
groups: np.ndarray,
|
| 730 |
+
inner_cv_jobs: int = INNER_CV_JOBS,
|
| 731 |
+
logreg_tol: float | None = None,
|
| 732 |
+
) -> float:
|
| 733 |
+
"""Return the mean native metric across one external CV partition."""
|
| 734 |
+
scores = []
|
| 735 |
+
for fold in sorted(np.unique(folds)):
|
| 736 |
+
test = folds == fold
|
| 737 |
+
predictions = fit_predict(
|
| 738 |
+
task_type,
|
| 739 |
+
matrix[~test],
|
| 740 |
+
y[~test],
|
| 741 |
+
matrix[test],
|
| 742 |
+
classes,
|
| 743 |
+
groups[~test],
|
| 744 |
+
inner_cv_jobs,
|
| 745 |
+
logreg_tol,
|
| 746 |
+
)
|
| 747 |
+
try:
|
| 748 |
+
scores.append(METRICS[metric](y[test], predictions, classes))
|
| 749 |
+
except ValueError as error:
|
| 750 |
+
raise SubmissionError(
|
| 751 |
+
f"CV fold {fold} is not scoreable: {error}"
|
| 752 |
+
) from error
|
| 753 |
+
if not np.isfinite(scores).all():
|
| 754 |
+
raise SubmissionError("a CV fold produced a non-finite score")
|
| 755 |
+
return float(np.mean(scores))
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
def _parallel_fold_score(
|
| 759 |
+
metric: str,
|
| 760 |
+
task_type: str,
|
| 761 |
+
y: np.ndarray,
|
| 762 |
+
folds: np.ndarray,
|
| 763 |
+
matrix: np.ndarray,
|
| 764 |
+
classes: np.ndarray,
|
| 765 |
+
groups: np.ndarray,
|
| 766 |
+
logreg_tol: float,
|
| 767 |
+
) -> float:
|
| 768 |
+
"""Score one repeat in an outer worker without nested inner processes."""
|
| 769 |
+
return _fold_score(
|
| 770 |
+
metric,
|
| 771 |
+
task_type,
|
| 772 |
+
y,
|
| 773 |
+
folds,
|
| 774 |
+
matrix,
|
| 775 |
+
classes,
|
| 776 |
+
groups,
|
| 777 |
+
inner_cv_jobs=1,
|
| 778 |
+
logreg_tol=logreg_tol,
|
| 779 |
+
)
|
| 780 |
+
|
| 781 |
+
|
| 782 |
+
def _repeated_cv_scores(
|
| 783 |
+
metric: str,
|
| 784 |
+
task_type: str,
|
| 785 |
+
y: np.ndarray,
|
| 786 |
+
labels: pd.DataFrame,
|
| 787 |
+
matrix: np.ndarray,
|
| 788 |
+
classes: np.ndarray,
|
| 789 |
+
) -> tuple[float, ...]:
|
| 790 |
+
"""Return one mean fold metric for every external CV repeat."""
|
| 791 |
+
groups = _subject_groups(labels)
|
| 792 |
+
columns = _fold_columns(labels)
|
| 793 |
+
workers = min(OUTER_CV_JOBS, len(columns))
|
| 794 |
+
if workers == 1:
|
| 795 |
+
scores = [
|
| 796 |
+
_fold_score(
|
| 797 |
+
metric,
|
| 798 |
+
task_type,
|
| 799 |
+
y,
|
| 800 |
+
labels[column].to_numpy(),
|
| 801 |
+
matrix,
|
| 802 |
+
classes,
|
| 803 |
+
groups,
|
| 804 |
+
)
|
| 805 |
+
for column in columns
|
| 806 |
+
]
|
| 807 |
+
else:
|
| 808 |
+
with parallel_backend("loky", inner_max_num_threads=1):
|
| 809 |
+
scores = Parallel(n_jobs=workers)(
|
| 810 |
+
delayed(_parallel_fold_score)(
|
| 811 |
+
metric,
|
| 812 |
+
task_type,
|
| 813 |
+
y,
|
| 814 |
+
labels[column].to_numpy(),
|
| 815 |
+
matrix,
|
| 816 |
+
classes,
|
| 817 |
+
groups,
|
| 818 |
+
LOGREG_TOL,
|
| 819 |
+
)
|
| 820 |
+
for column in columns
|
| 821 |
+
)
|
| 822 |
+
return tuple(float(score) for score in scores)
|
| 823 |
+
|
| 824 |
+
|
| 825 |
+
def _repeated_cv_score(
|
| 826 |
+
metric: str,
|
| 827 |
+
task_type: str,
|
| 828 |
+
y: np.ndarray,
|
| 829 |
+
labels: pd.DataFrame,
|
| 830 |
+
matrix: np.ndarray,
|
| 831 |
+
classes: np.ndarray,
|
| 832 |
+
) -> float:
|
| 833 |
+
"""Return the aggregate repeated-CV score for legacy callers."""
|
| 834 |
+
return float(
|
| 835 |
+
np.mean(_repeated_cv_scores(metric, task_type, y, labels, matrix, classes))
|
| 836 |
+
)
|
| 837 |
+
|
| 838 |
+
|
| 839 |
+
def _regression_fold_scores(
|
| 840 |
+
metrics: tuple[str, ...],
|
| 841 |
+
targets: np.ndarray,
|
| 842 |
+
folds: np.ndarray,
|
| 843 |
+
matrix: np.ndarray,
|
| 844 |
+
groups: np.ndarray,
|
| 845 |
+
) -> tuple[float, ...]:
|
| 846 |
+
"""Score compatible regression targets with shared ridge factorizations."""
|
| 847 |
+
fold_scores: list[list[float]] = [[] for _ in metrics]
|
| 848 |
+
for fold in sorted(np.unique(folds)):
|
| 849 |
+
test = folds == fold
|
| 850 |
+
scaler = StandardScaler().fit(matrix[~test])
|
| 851 |
+
x_train = scaler.transform(matrix[~test])
|
| 852 |
+
x_test = scaler.transform(matrix[test])
|
| 853 |
+
predictions = _ridge_predict_by_target(
|
| 854 |
+
x_train, targets[~test], x_test, groups[~test]
|
| 855 |
+
)
|
| 856 |
+
for index, metric in enumerate(metrics):
|
| 857 |
+
fold_scores[index].append(
|
| 858 |
+
METRICS[metric](targets[test, index], predictions[:, index], None)
|
| 859 |
+
)
|
| 860 |
+
if not all(np.isfinite(scores).all() for scores in fold_scores):
|
| 861 |
+
raise SubmissionError("a CV fold produced a non-finite score")
|
| 862 |
+
return tuple(float(np.mean(scores)) for scores in fold_scores)
|
| 863 |
+
|
| 864 |
+
|
| 865 |
+
def _repeated_regression_scores(
|
| 866 |
+
metrics: tuple[str, ...],
|
| 867 |
+
targets: np.ndarray,
|
| 868 |
+
labels: pd.DataFrame,
|
| 869 |
+
matrix: np.ndarray,
|
| 870 |
+
) -> tuple[tuple[float, ...], ...]:
|
| 871 |
+
"""Return repeat scores per target for compatible regression tasks."""
|
| 872 |
+
groups = _subject_groups(labels)
|
| 873 |
+
columns = _fold_columns(labels)
|
| 874 |
+
workers = min(OUTER_CV_JOBS, len(columns))
|
| 875 |
+
if workers == 1:
|
| 876 |
+
by_repeat = [
|
| 877 |
+
_regression_fold_scores(
|
| 878 |
+
metrics, targets, labels[column].to_numpy(), matrix, groups
|
| 879 |
+
)
|
| 880 |
+
for column in columns
|
| 881 |
+
]
|
| 882 |
+
else:
|
| 883 |
+
with parallel_backend("loky", inner_max_num_threads=1):
|
| 884 |
+
by_repeat = Parallel(n_jobs=workers)(
|
| 885 |
+
delayed(_regression_fold_scores)(
|
| 886 |
+
metrics,
|
| 887 |
+
targets,
|
| 888 |
+
labels[column].to_numpy(),
|
| 889 |
+
matrix,
|
| 890 |
+
groups,
|
| 891 |
+
)
|
| 892 |
+
for column in columns
|
| 893 |
+
)
|
| 894 |
+
return tuple(
|
| 895 |
+
tuple(float(scores[index]) for scores in by_repeat)
|
| 896 |
+
for index in range(len(metrics))
|
| 897 |
+
)
|
| 898 |
+
|
| 899 |
+
|
| 900 |
+
def _score_regression_group(
|
| 901 |
+
tasks: list[dict], labels_by_task: list[pd.DataFrame], emb: pd.DataFrame
|
| 902 |
+
) -> list[TaskScore]:
|
| 903 |
+
"""Score regression tasks that have identical samples and assignments."""
|
| 904 |
+
labels = labels_by_task[0]
|
| 905 |
+
matrix = _align(labels, emb)
|
| 906 |
+
targets = np.column_stack([frame[LABEL].to_numpy() for frame in labels_by_task])
|
| 907 |
+
metrics = tuple(str(task["metric"]) for task in tasks)
|
| 908 |
+
unknown = set(metrics) - set(METRICS)
|
| 909 |
+
if unknown:
|
| 910 |
+
raise EvaluatorError(f"unknown metric(s) for regression tasks: {unknown}")
|
| 911 |
+
if SPLIT in labels:
|
| 912 |
+
split = labels[SPLIT].to_numpy()
|
| 913 |
+
train, test = split == SPLIT_TRAIN, split == SPLIT_TEST
|
| 914 |
+
if not train.any() or not test.any():
|
| 915 |
+
raise EvaluatorError("transfer labels.csv has an empty train or test side")
|
| 916 |
+
scaler = StandardScaler().fit(matrix[train])
|
| 917 |
+
predictions = _ridge_predict_by_target(
|
| 918 |
+
scaler.transform(matrix[train]),
|
| 919 |
+
targets[train],
|
| 920 |
+
scaler.transform(matrix[test]),
|
| 921 |
+
_subject_groups(labels)[train],
|
| 922 |
+
)
|
| 923 |
+
repeat_scores = tuple(
|
| 924 |
+
(METRICS[metric](targets[test, index], predictions[:, index], None),)
|
| 925 |
+
for index, metric in enumerate(metrics)
|
| 926 |
+
)
|
| 927 |
+
n_samples = int(test.sum())
|
| 928 |
+
else:
|
| 929 |
+
repeat_scores = _repeated_regression_scores(metrics, targets, labels, matrix)
|
| 930 |
+
n_samples = len(labels)
|
| 931 |
+
return [
|
| 932 |
+
TaskScore(
|
| 933 |
+
task_id=_norm_id(task[TASK_ID]),
|
| 934 |
+
dataset_id=_norm_id(task[DATASET_ID]),
|
| 935 |
+
category=str(task["category"]),
|
| 936 |
+
metric=metrics[index],
|
| 937 |
+
score=float(np.mean(repeat_scores[index])),
|
| 938 |
+
n_samples=n_samples,
|
| 939 |
+
repeat_scores=repeat_scores[index],
|
| 940 |
+
)
|
| 941 |
+
for index, task in enumerate(tasks)
|
| 942 |
+
]
|
| 943 |
+
|
| 944 |
+
|
| 945 |
+
def _uses_leave_pair_out(metric: str, y: np.ndarray, groups: np.ndarray) -> bool:
|
| 946 |
+
"""Is this a binary AUROC task with fewer than ``MIN_CV_SUBJECTS`` subjects?
|
| 947 |
+
|
| 948 |
+
Paired tasks, where a subject carries both labels, stay on CV.
|
| 949 |
+
"""
|
| 950 |
+
return (
|
| 951 |
+
metric == "auroc"
|
| 952 |
+
and len(np.unique(y)) == 2
|
| 953 |
+
and len(np.unique(groups)) < MIN_CV_SUBJECTS
|
| 954 |
+
and pd.Series(y).groupby(groups).nunique().max() == 1
|
| 955 |
+
)
|
| 956 |
+
|
| 957 |
+
|
| 958 |
+
def _subject_labels(y: np.ndarray, subjects: np.ndarray) -> pd.Series:
|
| 959 |
+
"""Return one label per subject, indexed by subject id."""
|
| 960 |
+
per_subject = pd.Series(y).groupby(subjects)
|
| 961 |
+
if (per_subject.nunique() > 1).any():
|
| 962 |
+
raise EvaluatorError("a subject carries more than one label")
|
| 963 |
+
return per_subject.first()
|
| 964 |
+
|
| 965 |
+
|
| 966 |
+
def _ranked_pairs(scores: np.ndarray, is_positive: np.ndarray) -> tuple[float, int]:
|
| 967 |
+
"""Count correctly ordered positive-negative sample pairs, ties as half."""
|
| 968 |
+
gaps = scores[is_positive][:, None] - scores[~is_positive][None, :]
|
| 969 |
+
if not np.isfinite(gaps).all():
|
| 970 |
+
raise SubmissionError("a leave-pair-out fit produced a non-finite score")
|
| 971 |
+
return float((gaps > 0).sum() + 0.5 * (gaps == 0).sum()), int(gaps.size)
|
| 972 |
+
|
| 973 |
+
|
| 974 |
+
def _pair_ranks(
|
| 975 |
+
y: np.ndarray,
|
| 976 |
+
subjects: np.ndarray,
|
| 977 |
+
matrix: np.ndarray,
|
| 978 |
+
classes: np.ndarray,
|
| 979 |
+
pair: tuple[str, str],
|
| 980 |
+
) -> tuple[float, int]:
|
| 981 |
+
"""Fit without one subject pair, then count its sample pairs ranked right."""
|
| 982 |
+
positive, negative = pair
|
| 983 |
+
held_out = np.isin(subjects, [positive, negative])
|
| 984 |
+
scores = fit_predict(
|
| 985 |
+
"classification",
|
| 986 |
+
matrix[~held_out],
|
| 987 |
+
y[~held_out],
|
| 988 |
+
matrix[held_out],
|
| 989 |
+
classes,
|
| 990 |
+
subjects[~held_out],
|
| 991 |
+
inner_cv_jobs=1,
|
| 992 |
+
)[:, 1]
|
| 993 |
+
return _ranked_pairs(scores, subjects[held_out] == positive)
|
| 994 |
+
|
| 995 |
+
|
| 996 |
+
def _leave_pair_out_score(
|
| 997 |
+
y: np.ndarray, subjects: np.ndarray, matrix: np.ndarray, classes: np.ndarray
|
| 998 |
+
) -> float:
|
| 999 |
+
"""Fraction of sample pairs ranked right, each subject pair held out once."""
|
| 1000 |
+
per_subject = _subject_labels(y, subjects)
|
| 1001 |
+
pairs = list(
|
| 1002 |
+
itertools.product(
|
| 1003 |
+
per_subject.index[per_subject == classes[1]],
|
| 1004 |
+
per_subject.index[per_subject == classes[0]],
|
| 1005 |
+
)
|
| 1006 |
+
)
|
| 1007 |
+
workers = min(OUTER_CV_JOBS, len(pairs))
|
| 1008 |
+
if workers == 1:
|
| 1009 |
+
counts = [_pair_ranks(y, subjects, matrix, classes, pair) for pair in pairs]
|
| 1010 |
+
else:
|
| 1011 |
+
with parallel_backend("loky", inner_max_num_threads=1):
|
| 1012 |
+
counts = Parallel(n_jobs=workers)(
|
| 1013 |
+
delayed(_pair_ranks)(y, subjects, matrix, classes, pair)
|
| 1014 |
+
for pair in pairs
|
| 1015 |
+
)
|
| 1016 |
+
correct = sum(pair_correct for pair_correct, _ in counts)
|
| 1017 |
+
total = sum(pair_total for _, pair_total in counts)
|
| 1018 |
+
return correct / total
|
| 1019 |
+
|
| 1020 |
+
|
| 1021 |
+
def _transfer_predict(
|
| 1022 |
+
task_type: str,
|
| 1023 |
+
y: np.ndarray,
|
| 1024 |
+
split: np.ndarray,
|
| 1025 |
+
matrix: np.ndarray,
|
| 1026 |
+
classes: np.ndarray,
|
| 1027 |
+
groups: np.ndarray,
|
| 1028 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 1029 |
+
"""Fit once on the train cohort, predict the test cohort (no pooling)."""
|
| 1030 |
+
train, test = split == SPLIT_TRAIN, split == SPLIT_TEST
|
| 1031 |
+
if not train.any() or not test.any():
|
| 1032 |
+
raise EvaluatorError("transfer labels.csv has an empty train or test side")
|
| 1033 |
+
preds = fit_predict(
|
| 1034 |
+
task_type,
|
| 1035 |
+
matrix[train],
|
| 1036 |
+
y[train],
|
| 1037 |
+
matrix[test],
|
| 1038 |
+
classes,
|
| 1039 |
+
groups[train],
|
| 1040 |
+
)
|
| 1041 |
+
return y[test], preds
|
| 1042 |
+
|
| 1043 |
+
|
| 1044 |
+
def _select_degs(delta_train: np.ndarray, n_top: int) -> np.ndarray:
|
| 1045 |
+
"""Select the strongest mean absolute responses using training rows only."""
|
| 1046 |
+
n_keep = min(int(n_top), delta_train.shape[1])
|
| 1047 |
+
if n_keep <= 0:
|
| 1048 |
+
raise EvaluatorError("perturbation task has no response genes to select")
|
| 1049 |
+
effect = np.abs(np.mean(delta_train, axis=0))
|
| 1050 |
+
return np.argsort(effect, kind="stable")[-n_keep:]
|
| 1051 |
+
|
| 1052 |
+
|
| 1053 |
+
def _mean_scores(scores: list[float]) -> float:
|
| 1054 |
+
"""Average finite fold scores."""
|
| 1055 |
+
finite = np.asarray(scores)[np.isfinite(scores)]
|
| 1056 |
+
return float(np.mean(finite)) if len(finite) else float("nan")
|
| 1057 |
+
|
| 1058 |
+
|
| 1059 |
+
def _perturbation_fold(
|
| 1060 |
+
matrix: np.ndarray,
|
| 1061 |
+
delta: np.ndarray,
|
| 1062 |
+
groups: np.ndarray,
|
| 1063 |
+
train: np.ndarray,
|
| 1064 |
+
test: np.ndarray,
|
| 1065 |
+
n_top: int,
|
| 1066 |
+
target_centered: bool,
|
| 1067 |
+
) -> float:
|
| 1068 |
+
"""Fit and score one leakage-safe perturbation fold."""
|
| 1069 |
+
if not train.any() or not test.any():
|
| 1070 |
+
raise EvaluatorError("perturbation fold has an empty train or test side")
|
| 1071 |
+
genes = _select_degs(delta[train], n_top)
|
| 1072 |
+
y_train = delta[train][:, genes]
|
| 1073 |
+
y_test = delta[test][:, genes]
|
| 1074 |
+
scaler = StandardScaler().fit(matrix[train])
|
| 1075 |
+
x_train = scaler.transform(matrix[train])
|
| 1076 |
+
x_test = np.clip(
|
| 1077 |
+
scaler.transform(matrix[test]),
|
| 1078 |
+
-PERTURBATION_TEST_Z_CLIP,
|
| 1079 |
+
PERTURBATION_TEST_Z_CLIP,
|
| 1080 |
+
)
|
| 1081 |
+
predictions = _ridge_predict(x_train, y_train, x_test, groups[train])
|
| 1082 |
+
if target_centered:
|
| 1083 |
+
correlation = compute_target_centered_sample_spearman(y_test, predictions)
|
| 1084 |
+
else:
|
| 1085 |
+
training_mean = np.mean(y_train, axis=0)
|
| 1086 |
+
correlation = compute_residual_sample_spearman(
|
| 1087 |
+
y_test, predictions, training_mean
|
| 1088 |
+
)
|
| 1089 |
+
if not np.isfinite(correlation):
|
| 1090 |
+
raise SubmissionError("a perturbation fold has a degenerate Spearman score")
|
| 1091 |
+
return float(np.clip(correlation, -1.0, 1.0))
|
| 1092 |
+
|
| 1093 |
+
|
| 1094 |
+
def _perturbation_repeat(
|
| 1095 |
+
matrix: np.ndarray,
|
| 1096 |
+
delta: np.ndarray,
|
| 1097 |
+
groups: np.ndarray,
|
| 1098 |
+
partitions: list[tuple[np.ndarray, np.ndarray]],
|
| 1099 |
+
n_top: int,
|
| 1100 |
+
) -> float:
|
| 1101 |
+
"""Score every fold in one perturbation repeat."""
|
| 1102 |
+
return _mean_scores(
|
| 1103 |
+
[
|
| 1104 |
+
_perturbation_fold(matrix, delta, groups, train, test, n_top, False)
|
| 1105 |
+
for train, test in partitions
|
| 1106 |
+
]
|
| 1107 |
+
)
|
| 1108 |
+
|
| 1109 |
+
|
| 1110 |
+
def _parallel_perturbation_repeats(
|
| 1111 |
+
matrix: np.ndarray,
|
| 1112 |
+
delta: np.ndarray,
|
| 1113 |
+
groups: np.ndarray,
|
| 1114 |
+
repeats: list[list[tuple[np.ndarray, np.ndarray]]],
|
| 1115 |
+
n_top: int,
|
| 1116 |
+
) -> tuple[float, ...]:
|
| 1117 |
+
"""Score independent perturbation repeats across available workers."""
|
| 1118 |
+
workers = (
|
| 1119 |
+
min(OUTER_CV_JOBS, len(repeats))
|
| 1120 |
+
if len(matrix) >= OUTER_PARALLEL_MIN_SAMPLES
|
| 1121 |
+
else 1
|
| 1122 |
+
)
|
| 1123 |
+
if workers == 1:
|
| 1124 |
+
scores = [
|
| 1125 |
+
_perturbation_repeat(matrix, delta, groups, folds, n_top)
|
| 1126 |
+
for folds in repeats
|
| 1127 |
+
]
|
| 1128 |
+
else:
|
| 1129 |
+
with parallel_backend("loky", inner_max_num_threads=1):
|
| 1130 |
+
scores = Parallel(n_jobs=workers)(
|
| 1131 |
+
delayed(_perturbation_repeat)(matrix, delta, groups, folds, n_top)
|
| 1132 |
+
for folds in repeats
|
| 1133 |
+
)
|
| 1134 |
+
return tuple(float(score) for score in scores)
|
| 1135 |
+
|
| 1136 |
+
|
| 1137 |
+
def _paired_perturbation_scores(
|
| 1138 |
+
matrix: np.ndarray,
|
| 1139 |
+
delta: np.ndarray,
|
| 1140 |
+
labels: pd.DataFrame,
|
| 1141 |
+
n_top: int,
|
| 1142 |
+
) -> tuple[float, ...]:
|
| 1143 |
+
"""Score each frozen subject-grouped CV partition the labels carry."""
|
| 1144 |
+
groups = _subject_groups(labels)
|
| 1145 |
+
repeats = []
|
| 1146 |
+
for column in _fold_columns(labels):
|
| 1147 |
+
folds = labels[column].to_numpy()
|
| 1148 |
+
repeats.append(
|
| 1149 |
+
[(folds != fold, folds == fold) for fold in sorted(np.unique(folds))]
|
| 1150 |
+
)
|
| 1151 |
+
return _parallel_perturbation_repeats(matrix, delta, groups, repeats, n_top)
|
| 1152 |
+
|
| 1153 |
+
|
| 1154 |
+
def _transfer_perturbation_scores(
|
| 1155 |
+
matrix: np.ndarray,
|
| 1156 |
+
delta: np.ndarray,
|
| 1157 |
+
labels: pd.DataFrame,
|
| 1158 |
+
n_top: int,
|
| 1159 |
+
target_centered: bool,
|
| 1160 |
+
) -> tuple[tuple[float, ...], int]:
|
| 1161 |
+
"""Train every response-decoding component on the source disease only."""
|
| 1162 |
+
groups = _subject_groups(labels)
|
| 1163 |
+
split = labels[SPLIT].astype(str).to_numpy()
|
| 1164 |
+
train, test = split == SPLIT_TRAIN, split == SPLIT_TEST
|
| 1165 |
+
metrics = _perturbation_fold(
|
| 1166 |
+
matrix, delta, groups, train, test, n_top, target_centered
|
| 1167 |
+
)
|
| 1168 |
+
return (metrics,), int(test.sum())
|
| 1169 |
+
|
| 1170 |
+
|
| 1171 |
+
def _cartesian_partitions(
|
| 1172 |
+
control_ids: np.ndarray, perturbed_ids: np.ndarray
|
| 1173 |
+
) -> list[list[tuple[np.ndarray, np.ndarray]]]:
|
| 1174 |
+
"""Build six 3-fold specimen-isolated partitions for a 3x3 response grid."""
|
| 1175 |
+
controls = np.unique(control_ids)
|
| 1176 |
+
perturbed = np.unique(perturbed_ids)
|
| 1177 |
+
if len(controls) != 3 or len(perturbed) != 3:
|
| 1178 |
+
raise EvaluatorError(
|
| 1179 |
+
"Cartesian perturbation evaluation requires exactly three controls "
|
| 1180 |
+
"and three perturbed specimens."
|
| 1181 |
+
)
|
| 1182 |
+
repeats = []
|
| 1183 |
+
for assignment in itertools.permutations(perturbed):
|
| 1184 |
+
folds = []
|
| 1185 |
+
for control_id, perturbed_id in zip(controls, assignment):
|
| 1186 |
+
test = (control_ids == control_id) & (perturbed_ids == perturbed_id)
|
| 1187 |
+
train = (control_ids != control_id) & (perturbed_ids != perturbed_id)
|
| 1188 |
+
if test.sum() != 1 or train.sum() != 4:
|
| 1189 |
+
raise EvaluatorError("Cartesian labels do not form a complete 3x3 grid")
|
| 1190 |
+
folds.append((train, test))
|
| 1191 |
+
repeats.append(folds)
|
| 1192 |
+
return repeats
|
| 1193 |
+
|
| 1194 |
+
|
| 1195 |
+
def _cartesian_perturbation_scores(
|
| 1196 |
+
matrix: np.ndarray,
|
| 1197 |
+
delta: np.ndarray,
|
| 1198 |
+
labels: pd.DataFrame,
|
| 1199 |
+
n_top: int,
|
| 1200 |
+
) -> tuple[float, ...]:
|
| 1201 |
+
"""Evaluate all control-to-perturbed permutations without specimen leakage."""
|
| 1202 |
+
groups = _subject_groups(labels)
|
| 1203 |
+
repeats = _cartesian_partitions(
|
| 1204 |
+
labels[CONTROL_ID].astype(str).to_numpy(),
|
| 1205 |
+
labels[PERTURBED_ID].astype(str).to_numpy(),
|
| 1206 |
+
)
|
| 1207 |
+
return _parallel_perturbation_repeats(matrix, delta, groups, repeats, n_top)
|
| 1208 |
+
|
| 1209 |
+
|
| 1210 |
+
def _score_perturbation_task(
|
| 1211 |
+
task: dict,
|
| 1212 |
+
labels: pd.DataFrame,
|
| 1213 |
+
emb: pd.DataFrame,
|
| 1214 |
+
targets: PerturbationTargets,
|
| 1215 |
+
) -> TaskScore:
|
| 1216 |
+
"""Decode one hidden response matrix from submitted baseline embeddings."""
|
| 1217 |
+
if len(labels) != len(targets.delta):
|
| 1218 |
+
raise EvaluatorError("labels.csv and targets.npz have different row counts")
|
| 1219 |
+
matrix = _align(labels, emb)
|
| 1220 |
+
n_top = int(task.get("n_top_de_genes", 0))
|
| 1221 |
+
pairing = str(task.get(PAIRING, labels.get(PAIRING, pd.Series([PAIRED])).iloc[0]))
|
| 1222 |
+
if SPLIT in labels:
|
| 1223 |
+
repeat_scores, n_samples = _transfer_perturbation_scores(
|
| 1224 |
+
matrix,
|
| 1225 |
+
targets.delta,
|
| 1226 |
+
labels,
|
| 1227 |
+
n_top,
|
| 1228 |
+
bool(task.get("target_centered", False)),
|
| 1229 |
+
)
|
| 1230 |
+
elif pairing == CARTESIAN:
|
| 1231 |
+
repeat_scores = _cartesian_perturbation_scores(
|
| 1232 |
+
matrix, targets.delta, labels, n_top
|
| 1233 |
+
)
|
| 1234 |
+
n_samples = len(labels)
|
| 1235 |
+
else:
|
| 1236 |
+
repeat_scores = _paired_perturbation_scores(
|
| 1237 |
+
matrix, targets.delta, labels, n_top
|
| 1238 |
+
)
|
| 1239 |
+
n_samples = len(labels)
|
| 1240 |
+
return TaskScore(
|
| 1241 |
+
task_id=_norm_id(task[TASK_ID]),
|
| 1242 |
+
dataset_id=_norm_id(task[DATASET_ID]),
|
| 1243 |
+
category=str(task["category"]),
|
| 1244 |
+
metric=str(task.get("metric", RESIDUAL_SPEARMAN)),
|
| 1245 |
+
score=float(np.mean(repeat_scores)),
|
| 1246 |
+
n_samples=n_samples,
|
| 1247 |
+
repeat_scores=repeat_scores,
|
| 1248 |
+
)
|
| 1249 |
+
|
| 1250 |
+
|
| 1251 |
+
def score_task(
|
| 1252 |
+
task: dict,
|
| 1253 |
+
labels: pd.DataFrame,
|
| 1254 |
+
emb: pd.DataFrame,
|
| 1255 |
+
targets: PerturbationTargets | None = None,
|
| 1256 |
+
) -> TaskScore:
|
| 1257 |
+
"""Score one task: repeated fold-wise CV, leave-pair-out, or a transfer split.
|
| 1258 |
+
|
| 1259 |
+
Private biology (disease, tissue, and target names) never leaks in.
|
| 1260 |
+
"""
|
| 1261 |
+
metric = task["metric"]
|
| 1262 |
+
if task.get("task_type") == PERTURBATION:
|
| 1263 |
+
if metric not in {CENTERED_SPEARMAN, RESIDUAL_SPEARMAN}:
|
| 1264 |
+
raise EvaluatorError(
|
| 1265 |
+
f"perturbation task {task.get(TASK_ID)} needs metric "
|
| 1266 |
+
f"'{CENTERED_SPEARMAN}'"
|
| 1267 |
+
)
|
| 1268 |
+
if targets is None:
|
| 1269 |
+
raise EvaluatorError("perturbation task is missing targets.npz")
|
| 1270 |
+
return _score_perturbation_task(task, labels, emb, targets)
|
| 1271 |
+
if metric not in METRICS:
|
| 1272 |
+
raise EvaluatorError(f"unknown metric '{metric}' for task {task.get(TASK_ID)}")
|
| 1273 |
+
task_type = task["task_type"]
|
| 1274 |
+
matrix = _align(labels, emb)
|
| 1275 |
+
y = labels[LABEL].to_numpy()
|
| 1276 |
+
y_eval = y
|
| 1277 |
+
classes = np.unique(y) if task_type == "classification" else np.array([])
|
| 1278 |
+
groups = _subject_groups(labels)
|
| 1279 |
+
|
| 1280 |
+
if SPLIT in labels.columns:
|
| 1281 |
+
y_eval, preds = _transfer_predict(
|
| 1282 |
+
task_type,
|
| 1283 |
+
y,
|
| 1284 |
+
labels[SPLIT].to_numpy(),
|
| 1285 |
+
matrix,
|
| 1286 |
+
classes,
|
| 1287 |
+
groups,
|
| 1288 |
+
)
|
| 1289 |
+
try:
|
| 1290 |
+
score = METRICS[metric](y_eval, preds, classes)
|
| 1291 |
+
except ValueError as error:
|
| 1292 |
+
raise SubmissionError(f"transfer task not scoreable: {error}") from error
|
| 1293 |
+
repeat_scores = (score,)
|
| 1294 |
+
elif _uses_leave_pair_out(metric, y, groups):
|
| 1295 |
+
score = _leave_pair_out_score(y, groups, matrix, classes)
|
| 1296 |
+
repeat_scores = (score,)
|
| 1297 |
+
else:
|
| 1298 |
+
repeat_scores = _repeated_cv_scores(
|
| 1299 |
+
task_type=task_type,
|
| 1300 |
+
metric=metric,
|
| 1301 |
+
y=y,
|
| 1302 |
+
labels=labels,
|
| 1303 |
+
matrix=matrix,
|
| 1304 |
+
classes=classes,
|
| 1305 |
+
)
|
| 1306 |
+
score = float(np.mean(repeat_scores))
|
| 1307 |
+
|
| 1308 |
+
return TaskScore(
|
| 1309 |
+
task_id=_norm_id(task[TASK_ID]),
|
| 1310 |
+
dataset_id=_norm_id(task[DATASET_ID]),
|
| 1311 |
+
category=str(task["category"]),
|
| 1312 |
+
metric=metric,
|
| 1313 |
+
score=score,
|
| 1314 |
+
n_samples=int(len(y_eval)),
|
| 1315 |
+
repeat_scores=repeat_scores,
|
| 1316 |
+
)
|
| 1317 |
+
|
| 1318 |
+
|
| 1319 |
+
def fetch_manifest(
|
| 1320 |
+
token: str | None = None, revision: str = CURRENT.data
|
| 1321 |
+
) -> list[dict]:
|
| 1322 |
+
"""Download the public ``datasets.yaml`` registry of opaque dataset ids."""
|
| 1323 |
+
from huggingface_hub import hf_hub_download
|
| 1324 |
+
|
| 1325 |
+
path = hf_hub_download(
|
| 1326 |
+
PUBLIC_REPO,
|
| 1327 |
+
MANIFEST_FILENAME,
|
| 1328 |
+
repo_type="dataset",
|
| 1329 |
+
token=token,
|
| 1330 |
+
revision=revision,
|
| 1331 |
+
)
|
| 1332 |
+
with open(path) as handle:
|
| 1333 |
+
data = yaml.safe_load(handle)
|
| 1334 |
+
if isinstance(data, dict):
|
| 1335 |
+
return data.get("datasets", [])
|
| 1336 |
+
return data or []
|
| 1337 |
+
|
| 1338 |
+
|
| 1339 |
+
def public_task_ids_from_manifest(path: str | Path) -> set[str]:
|
| 1340 |
+
"""Read the task ids of the published public-task manifest."""
|
| 1341 |
+
with open(path) as handle:
|
| 1342 |
+
return {_norm_id(entry[TASK_ID]) for entry in yaml.safe_load(handle)["tasks"]}
|
| 1343 |
+
|
| 1344 |
+
|
| 1345 |
+
def fetch_public_task_ids(
|
| 1346 |
+
token: str | None = None, revision: str = CURRENT.data
|
| 1347 |
+
) -> set[str]:
|
| 1348 |
+
"""Download the public task ids; none until published or if the fetch fails.
|
| 1349 |
+
|
| 1350 |
+
Losing the Public tags beats blanking every page over a Hub hiccup.
|
| 1351 |
+
"""
|
| 1352 |
+
from huggingface_hub import hf_hub_download
|
| 1353 |
+
from huggingface_hub.utils import EntryNotFoundError
|
| 1354 |
+
|
| 1355 |
+
try:
|
| 1356 |
+
path = hf_hub_download(
|
| 1357 |
+
PUBLIC_REPO,
|
| 1358 |
+
PUBLIC_TASKS_MANIFEST,
|
| 1359 |
+
repo_type="dataset",
|
| 1360 |
+
token=token,
|
| 1361 |
+
revision=revision,
|
| 1362 |
+
)
|
| 1363 |
+
return public_task_ids_from_manifest(path)
|
| 1364 |
+
except EntryNotFoundError:
|
| 1365 |
+
return set()
|
| 1366 |
+
except Exception: # noqa: BLE001
|
| 1367 |
+
logging.warning("public task manifest unreadable; no task tagged public")
|
| 1368 |
+
return set()
|
| 1369 |
+
|
| 1370 |
+
|
| 1371 |
+
def manifest_ids(manifest: list[dict], modality: str | None = None) -> set[str]:
|
| 1372 |
+
"""Return manifest dataset ids, optionally for one modality."""
|
| 1373 |
+
return {
|
| 1374 |
+
_norm_id(entry["id"])
|
| 1375 |
+
for entry in manifest
|
| 1376 |
+
if modality is None or entry.get("modality") == modality
|
| 1377 |
+
}
|
| 1378 |
+
|
| 1379 |
+
|
| 1380 |
+
def scoreable_tasks(tasks: list[dict], valid_ids: set[str]) -> list[dict]:
|
| 1381 |
+
"""Registry tasks whose dataset is in the public manifest; orphans are skipped.
|
| 1382 |
+
|
| 1383 |
+
Shared by ``score_all`` and the leaderboard so both agree on what full
|
| 1384 |
+
coverage means. An orphan (a task on a dataset not yet published) is logged
|
| 1385 |
+
and dropped rather than making full coverage unreachable for everyone.
|
| 1386 |
+
"""
|
| 1387 |
+
kept, orphans = [], []
|
| 1388 |
+
for task in tasks:
|
| 1389 |
+
if _norm_id(task[DATASET_ID]) in valid_ids:
|
| 1390 |
+
kept.append(task)
|
| 1391 |
+
else:
|
| 1392 |
+
orphans.append(_norm_id(task[TASK_ID]))
|
| 1393 |
+
if orphans:
|
| 1394 |
+
logging.warning("skipping tasks on unpublished datasets: %s", orphans)
|
| 1395 |
+
return kept
|
| 1396 |
+
|
| 1397 |
+
|
| 1398 |
+
def fetch_tasks_registry(
|
| 1399 |
+
token: str | None = None, revision: str = CURRENT.labels
|
| 1400 |
+
) -> list[dict]:
|
| 1401 |
+
"""Download the private ``tasks.yaml`` registry from the labels repo."""
|
| 1402 |
+
from huggingface_hub import hf_hub_download
|
| 1403 |
+
|
| 1404 |
+
path = hf_hub_download(
|
| 1405 |
+
LABELS_REPO, TASKS_FILENAME, repo_type="dataset", token=token, revision=revision
|
| 1406 |
+
)
|
| 1407 |
+
return load_tasks_registry(path)
|
| 1408 |
+
|
| 1409 |
+
|
| 1410 |
+
def fetch_task_labels(
|
| 1411 |
+
task_id: str, token: str | None = None, revision: str = CURRENT.labels
|
| 1412 |
+
) -> pd.DataFrame:
|
| 1413 |
+
"""Download a task's private ``labels.csv`` from the labels repo."""
|
| 1414 |
+
from huggingface_hub import hf_hub_download
|
| 1415 |
+
|
| 1416 |
+
path = hf_hub_download(
|
| 1417 |
+
LABELS_REPO,
|
| 1418 |
+
f"{task_id}/{LABELS_FILENAME}",
|
| 1419 |
+
repo_type="dataset",
|
| 1420 |
+
token=token,
|
| 1421 |
+
revision=revision,
|
| 1422 |
+
)
|
| 1423 |
+
return load_labels(path)
|
| 1424 |
+
|
| 1425 |
+
|
| 1426 |
+
def fetch_task_targets(
|
| 1427 |
+
task_id: str, token: str | None = None, revision: str = CURRENT.labels
|
| 1428 |
+
) -> PerturbationTargets:
|
| 1429 |
+
"""Download a task's private compressed response matrix."""
|
| 1430 |
+
from huggingface_hub import hf_hub_download
|
| 1431 |
+
|
| 1432 |
+
path = hf_hub_download(
|
| 1433 |
+
LABELS_REPO,
|
| 1434 |
+
f"{task_id}/{TARGETS_FILENAME}",
|
| 1435 |
+
repo_type="dataset",
|
| 1436 |
+
token=token,
|
| 1437 |
+
revision=revision,
|
| 1438 |
+
)
|
| 1439 |
+
return load_targets(path)
|
| 1440 |
+
|
| 1441 |
+
|
| 1442 |
+
def _retry(fn):
|
| 1443 |
+
"""Call ``fn``, retrying transient failures with linear backoff."""
|
| 1444 |
+
last: Exception | None = None
|
| 1445 |
+
for attempt in range(FETCH_ATTEMPTS):
|
| 1446 |
+
try:
|
| 1447 |
+
return fn()
|
| 1448 |
+
except Exception as error: # noqa: BLE001
|
| 1449 |
+
last = error
|
| 1450 |
+
if attempt < FETCH_ATTEMPTS - 1:
|
| 1451 |
+
time.sleep(FETCH_BACKOFF * (attempt + 1))
|
| 1452 |
+
raise last
|
| 1453 |
+
|
| 1454 |
+
|
| 1455 |
+
def score_all(
|
| 1456 |
+
path: str | Path,
|
| 1457 |
+
token: str | None = None,
|
| 1458 |
+
*,
|
| 1459 |
+
modality: str | None = None,
|
| 1460 |
+
representation: str = EMBEDDING,
|
| 1461 |
+
datasets: list[dict] | None = None,
|
| 1462 |
+
tasks: list[dict] | None = None,
|
| 1463 |
+
fetch_labels=None,
|
| 1464 |
+
fetch_targets=None,
|
| 1465 |
+
checkpoint_dir: str | Path | None = None,
|
| 1466 |
+
reference: str | Path | None = None,
|
| 1467 |
+
) -> dict:
|
| 1468 |
+
"""Score a submission per task and roll it up into a blind result.
|
| 1469 |
+
|
| 1470 |
+
Unknown dataset ids and bad files raise/record a ``SubmissionError``; a
|
| 1471 |
+
failed fetch or internal error raises ``EvaluatorError`` (our side, not the
|
| 1472 |
+
submitter's). A dataset is embedded once and reused across all its tasks.
|
| 1473 |
+
|
| 1474 |
+
``EMBEDDING`` columns are put on the unit sphere first, ``GENE_SPACE`` ones
|
| 1475 |
+
keep their scale. A ``reference`` file (same layout as a submission, gene
|
| 1476 |
+
space) is then appended to every sample and the concatenated row put back
|
| 1477 |
+
on the unit sphere.
|
| 1478 |
+
"""
|
| 1479 |
+
if representation not in REPRESENTATIONS:
|
| 1480 |
+
raise SubmissionError(
|
| 1481 |
+
f"unknown representation '{representation}'; expected one of "
|
| 1482 |
+
f"{list(REPRESENTATIONS)}"
|
| 1483 |
+
)
|
| 1484 |
+
blocks = load_submission(path)
|
| 1485 |
+
if datasets is None:
|
| 1486 |
+
try:
|
| 1487 |
+
datasets = fetch_manifest(token)
|
| 1488 |
+
except Exception as error: # noqa: BLE001
|
| 1489 |
+
raise EvaluatorError(
|
| 1490 |
+
f"could not load the dataset manifest: {error}"
|
| 1491 |
+
) from error
|
| 1492 |
+
if modality is not None and modality not in MODALITIES:
|
| 1493 |
+
raise SubmissionError(f"unknown or unavailable modality: {modality}")
|
| 1494 |
+
all_ids = manifest_ids(datasets)
|
| 1495 |
+
valid = manifest_ids(datasets, MODALITIES.get(modality))
|
| 1496 |
+
if modality is not None and not valid:
|
| 1497 |
+
raise SubmissionError(f"unknown or unavailable modality: {modality}")
|
| 1498 |
+
|
| 1499 |
+
unknown = sorted(set(blocks) - all_ids)
|
| 1500 |
+
if unknown:
|
| 1501 |
+
raise SubmissionError(f"unknown dataset_id(s) not in the benchmark: {unknown}")
|
| 1502 |
+
wrong_modality = sorted(set(blocks) - valid)
|
| 1503 |
+
if wrong_modality:
|
| 1504 |
+
raise SubmissionError(
|
| 1505 |
+
f"dataset_id(s) outside the selected modality: {wrong_modality}"
|
| 1506 |
+
)
|
| 1507 |
+
|
| 1508 |
+
if tasks is None:
|
| 1509 |
+
try:
|
| 1510 |
+
tasks = fetch_tasks_registry(token)
|
| 1511 |
+
except Exception as error: # noqa: BLE001
|
| 1512 |
+
raise EvaluatorError(
|
| 1513 |
+
f"could not load the task registry: {error}"
|
| 1514 |
+
) from error
|
| 1515 |
+
fetch_labels = fetch_labels or fetch_task_labels
|
| 1516 |
+
fetch_targets = fetch_targets or fetch_task_targets
|
| 1517 |
+
checkpoints = (
|
| 1518 |
+
_TaskCheckpoints(checkpoint_dir, path, modality, representation, reference)
|
| 1519 |
+
if checkpoint_dir is not None
|
| 1520 |
+
else None
|
| 1521 |
+
)
|
| 1522 |
+
reference_by_ds = _load_reference(reference) if reference is not None else None
|
| 1523 |
+
|
| 1524 |
+
emb_by_ds, block_error = {}, {}
|
| 1525 |
+
for dataset_id, block in blocks.items():
|
| 1526 |
+
try:
|
| 1527 |
+
frame = _to_embedding_frame(block)
|
| 1528 |
+
if representation == EMBEDDING:
|
| 1529 |
+
frame = _l2_normalize_rows(frame)
|
| 1530 |
+
if reference_by_ds is not None:
|
| 1531 |
+
if dataset_id not in reference_by_ds:
|
| 1532 |
+
raise EvaluatorError(
|
| 1533 |
+
f"reference features lack dataset {dataset_id}"
|
| 1534 |
+
)
|
| 1535 |
+
frame = _concat_reference(frame, reference_by_ds[dataset_id])
|
| 1536 |
+
emb_by_ds[dataset_id] = frame
|
| 1537 |
+
except SubmissionError as error:
|
| 1538 |
+
block_error[dataset_id] = str(error)
|
| 1539 |
+
|
| 1540 |
+
selected_tasks = [
|
| 1541 |
+
task
|
| 1542 |
+
for task in scoreable_tasks(tasks, all_ids)
|
| 1543 |
+
if _norm_id(task[DATASET_ID]) in valid
|
| 1544 |
+
]
|
| 1545 |
+
outcomes: list[TaskOutcome | None] = [None] * len(selected_tasks)
|
| 1546 |
+
pending: list[_PendingTask] = []
|
| 1547 |
+
for position, task in enumerate(selected_tasks):
|
| 1548 |
+
task_id = _norm_id(task[TASK_ID])
|
| 1549 |
+
dataset_id = _norm_id(task[DATASET_ID])
|
| 1550 |
+
emb = emb_by_ds.get(dataset_id)
|
| 1551 |
+
error = block_error.get(dataset_id)
|
| 1552 |
+
if emb is None and error is None:
|
| 1553 |
+
outcomes[position] = TaskOutcome(task_id, dataset_id, "missing")
|
| 1554 |
+
continue
|
| 1555 |
+
if error is not None:
|
| 1556 |
+
outcomes[position] = TaskOutcome(
|
| 1557 |
+
task_id, dataset_id, "invalid", reason=error
|
| 1558 |
+
)
|
| 1559 |
+
continue
|
| 1560 |
+
try:
|
| 1561 |
+
labels = _retry(lambda: fetch_labels(task_id, token))
|
| 1562 |
+
except Exception as fetch_error: # noqa: BLE001
|
| 1563 |
+
raise EvaluatorError(
|
| 1564 |
+
f"could not load evaluation data for a task: {fetch_error}"
|
| 1565 |
+
) from fetch_error
|
| 1566 |
+
if reference_by_ds is not None:
|
| 1567 |
+
_check_reference_covers(labels, reference_by_ds[dataset_id], task_id)
|
| 1568 |
+
targets = None
|
| 1569 |
+
if task.get("task_type") == PERTURBATION:
|
| 1570 |
+
try:
|
| 1571 |
+
targets = _retry(lambda: fetch_targets(task_id, token))
|
| 1572 |
+
except Exception as fetch_error: # noqa: BLE001
|
| 1573 |
+
raise EvaluatorError(
|
| 1574 |
+
f"could not load perturbation targets for a task: {fetch_error}"
|
| 1575 |
+
) from fetch_error
|
| 1576 |
+
fingerprint = _task_fingerprint(task, labels, targets)
|
| 1577 |
+
restored = (
|
| 1578 |
+
checkpoints.load(task_id, fingerprint) if checkpoints is not None else None
|
| 1579 |
+
)
|
| 1580 |
+
if restored is not None:
|
| 1581 |
+
outcomes[position] = restored
|
| 1582 |
+
continue
|
| 1583 |
+
pending.append(_PendingTask(position, task, emb, labels, targets, fingerprint))
|
| 1584 |
+
|
| 1585 |
+
groups: dict[tuple[str, str], list[_PendingTask]] = defaultdict(list)
|
| 1586 |
+
for item in pending:
|
| 1587 |
+
if item.task.get("task_type") == "regression":
|
| 1588 |
+
key = (_norm_id(item.task[DATASET_ID]), _regression_geometry(item.labels))
|
| 1589 |
+
else:
|
| 1590 |
+
key = ("individual", _norm_id(item.task[TASK_ID]))
|
| 1591 |
+
groups[key].append(item)
|
| 1592 |
+
|
| 1593 |
+
for items in groups.values():
|
| 1594 |
+
scores = None
|
| 1595 |
+
if len(items) > 1 and all(
|
| 1596 |
+
item.task.get("task_type") == "regression" for item in items
|
| 1597 |
+
):
|
| 1598 |
+
try:
|
| 1599 |
+
scores = _score_regression_group(
|
| 1600 |
+
[item.task for item in items],
|
| 1601 |
+
[item.labels for item in items],
|
| 1602 |
+
items[0].emb,
|
| 1603 |
+
)
|
| 1604 |
+
except SubmissionError:
|
| 1605 |
+
scores = None
|
| 1606 |
+
for index, item in enumerate(items):
|
| 1607 |
+
try:
|
| 1608 |
+
score = (
|
| 1609 |
+
scores[index]
|
| 1610 |
+
if scores is not None
|
| 1611 |
+
else score_task(item.task, item.labels, item.emb, item.targets)
|
| 1612 |
+
)
|
| 1613 |
+
outcome = _outcome_from_score(item.task, score)
|
| 1614 |
+
except SubmissionError as scoring_error:
|
| 1615 |
+
outcome = TaskOutcome(
|
| 1616 |
+
_norm_id(item.task[TASK_ID]),
|
| 1617 |
+
_norm_id(item.task[DATASET_ID]),
|
| 1618 |
+
"invalid",
|
| 1619 |
+
reason=str(scoring_error),
|
| 1620 |
+
)
|
| 1621 |
+
outcomes[item.position] = outcome
|
| 1622 |
+
if checkpoints is not None:
|
| 1623 |
+
checkpoints.save(outcome, item.fingerprint)
|
| 1624 |
+
|
| 1625 |
+
if any(outcome is None for outcome in outcomes):
|
| 1626 |
+
raise EvaluatorError("internal error: a task produced no outcome")
|
| 1627 |
+
return _summarize([outcome for outcome in outcomes if outcome is not None])
|
| 1628 |
+
|
| 1629 |
+
|
| 1630 |
+
def _summarize(outcomes: list[TaskOutcome]) -> dict:
|
| 1631 |
+
"""Combine per-task outcomes into blind per-category numbers + coverage."""
|
| 1632 |
+
scored = [o.score for o in outcomes if o.status == "scored"]
|
| 1633 |
+
dataset_status = _dataset_status(outcomes)
|
| 1634 |
+
n_scored, n_total = len(scored), len(outcomes)
|
| 1635 |
+
n_ds_scored = sum(1 for status in dataset_status.values() if status == "scored")
|
| 1636 |
+
return {
|
| 1637 |
+
"per_task": scored,
|
| 1638 |
+
"categories": category_means(scored),
|
| 1639 |
+
"invalid": [
|
| 1640 |
+
{"task_id": o.task_id, "dataset_id": o.dataset_id, "reason": o.reason}
|
| 1641 |
+
for o in outcomes
|
| 1642 |
+
if o.status == "invalid"
|
| 1643 |
+
],
|
| 1644 |
+
"missing": sorted({o.dataset_id for o in outcomes if o.status == "missing"}),
|
| 1645 |
+
"incomplete": sorted(
|
| 1646 |
+
ds for ds, status in dataset_status.items() if status == "incomplete"
|
| 1647 |
+
),
|
| 1648 |
+
"coverage": n_scored / n_total if n_total else 0.0,
|
| 1649 |
+
"n_scored": n_scored,
|
| 1650 |
+
"n_total": n_total,
|
| 1651 |
+
"full_coverage": n_total > 0 and n_scored == n_total,
|
| 1652 |
+
"n_datasets_scored": n_ds_scored,
|
| 1653 |
+
"n_datasets_total": len(dataset_status),
|
| 1654 |
+
}
|
| 1655 |
+
|
| 1656 |
+
|
| 1657 |
+
def _dataset_status(outcomes: list[TaskOutcome]) -> dict[str, str]:
|
| 1658 |
+
"""Blind per-dataset STATUS only (no score): scored / missing / incomplete."""
|
| 1659 |
+
statuses: dict[str, set[str]] = defaultdict(set)
|
| 1660 |
+
for outcome in outcomes:
|
| 1661 |
+
statuses[outcome.dataset_id].add(outcome.status)
|
| 1662 |
+
out = {}
|
| 1663 |
+
for dataset_id in sorted(statuses):
|
| 1664 |
+
seen = statuses[dataset_id]
|
| 1665 |
+
if seen == {"missing"}:
|
| 1666 |
+
out[dataset_id] = "missing"
|
| 1667 |
+
elif seen == {"scored"}:
|
| 1668 |
+
out[dataset_id] = "scored"
|
| 1669 |
+
else:
|
| 1670 |
+
out[dataset_id] = "incomplete"
|
| 1671 |
+
return out
|
| 1672 |
+
|
| 1673 |
+
|
| 1674 |
+
def _cli() -> None:
|
| 1675 |
+
parser = argparse.ArgumentParser(description="Score a PRIMO submission locally.")
|
| 1676 |
+
parser.add_argument("--submission", required=True, help="CSV/TSV/Parquet/NPZ file")
|
| 1677 |
+
parser.add_argument("--modality", required=True, choices=MODALITIES)
|
| 1678 |
+
parser.add_argument(
|
| 1679 |
+
"--representation",
|
| 1680 |
+
default=EMBEDDING,
|
| 1681 |
+
choices=REPRESENTATIONS,
|
| 1682 |
+
help="what the columns are: learned embedding dims, or gene-space values",
|
| 1683 |
+
)
|
| 1684 |
+
parser.add_argument("--token", default=None, help="HF token (else env HF_TOKEN)")
|
| 1685 |
+
parser.add_argument(
|
| 1686 |
+
"--checkpoint-dir",
|
| 1687 |
+
default=None,
|
| 1688 |
+
help="resume completed tasks from this directory",
|
| 1689 |
+
)
|
| 1690 |
+
parser.add_argument(
|
| 1691 |
+
"--reference",
|
| 1692 |
+
default=None,
|
| 1693 |
+
help="gene-space features to concatenate to every sample before scoring",
|
| 1694 |
+
)
|
| 1695 |
+
args = parser.parse_args()
|
| 1696 |
+
|
| 1697 |
+
token = args.token or os.environ.get("HF_TOKEN")
|
| 1698 |
+
result = score_all(
|
| 1699 |
+
args.submission,
|
| 1700 |
+
token,
|
| 1701 |
+
modality=args.modality,
|
| 1702 |
+
representation=args.representation,
|
| 1703 |
+
checkpoint_dir=args.checkpoint_dir,
|
| 1704 |
+
reference=args.reference,
|
| 1705 |
+
)
|
| 1706 |
+
print(
|
| 1707 |
+
f"scored : {result['n_datasets_scored']}/{result['n_datasets_total']} "
|
| 1708 |
+
f"datasets, {result['n_scored']}/{result['n_total']} tasks "
|
| 1709 |
+
f"(full_coverage={result['full_coverage']})"
|
| 1710 |
+
)
|
| 1711 |
+
for category, stats in sorted(result["categories"].items()):
|
| 1712 |
+
print(
|
| 1713 |
+
f" {category:20s} {stats['metric']} = {stats['mean']:.4f} "
|
| 1714 |
+
f"(n={stats['n_tasks']})"
|
| 1715 |
+
)
|
| 1716 |
+
for bad in result["invalid"]:
|
| 1717 |
+
print(f" INVALID [{bad['dataset_id']}]: {bad['reason']}")
|
| 1718 |
+
if result["missing"]:
|
| 1719 |
+
print(f" missing : {result['missing']}")
|
| 1720 |
+
if result["incomplete"]:
|
| 1721 |
+
print(f" incomplete : {result['incomplete']}")
|
| 1722 |
+
|
| 1723 |
+
|
| 1724 |
+
if __name__ == "__main__":
|
| 1725 |
+
_cli()
|
example_submission.csv
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
dataset_id,sample_id,e0,e1,e2
|
| 2 |
+
d002,SAMPLE_A,0.121,-0.443,0.982
|
| 3 |
+
d002,SAMPLE_B,-0.075,0.310,0.044
|
| 4 |
+
d003,SAMPLE_C,0.512,0.028,-0.157
|
| 5 |
+
d003,SAMPLE_D,-0.301,-0.119,0.663
|
leaderboard.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Table building for the PRIMO front-end: persisted results + registry -> frames.
|
| 2 |
+
|
| 3 |
+
Kept apart from Gradio and from any I/O so it unit-tests without a network
|
| 4 |
+
(``app.py`` fetches and renders, ``boards.py`` decides what a board *is*).
|
| 5 |
+
|
| 6 |
+
``ranked_table`` lists only models that covered EVERY task the Elo anchor
|
| 7 |
+
scored, so a partial run cannot win by skipping the hard cohorts.
|
| 8 |
+
``per_task_table`` keeps partial submissions and is never a ranking.
|
| 9 |
+
|
| 10 |
+
Our baselines rank where their score puts them: pinning one to the bottom would
|
| 11 |
+
hide a foundation model losing to an information-rich expression baseline. Task
|
| 12 |
+
metadata is read defensively, so an old registry yields blank cells rather than
|
| 13 |
+
a broken page.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import math
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
from boards import (
|
| 22 |
+
PUBLIC,
|
| 23 |
+
Board,
|
| 24 |
+
label,
|
| 25 |
+
metric_label,
|
| 26 |
+
modality_label,
|
| 27 |
+
)
|
| 28 |
+
from evaluator import _norm_id
|
| 29 |
+
from results import (
|
| 30 |
+
INSTITUTION,
|
| 31 |
+
IS_AUTHOR_SUBMISSION,
|
| 32 |
+
IS_BASELINE,
|
| 33 |
+
MODEL_NAME,
|
| 34 |
+
PAPER_LINK,
|
| 35 |
+
SUBMISSION_ID,
|
| 36 |
+
display_name,
|
| 37 |
+
split_hvg_name,
|
| 38 |
+
with_author_submission_flag,
|
| 39 |
+
with_baseline_flag,
|
| 40 |
+
with_institution,
|
| 41 |
+
with_paper_link,
|
| 42 |
+
with_submission_id,
|
| 43 |
+
)
|
| 44 |
+
from scipy.optimize import minimize
|
| 45 |
+
from scipy.special import expit
|
| 46 |
+
from scoring import TaskScore, category_means, sort_key
|
| 47 |
+
|
| 48 |
+
EMPTY_RANKED_COLUMNS = ["Model"]
|
| 49 |
+
EMPTY_PER_TASK_COLUMNS = ["Task"]
|
| 50 |
+
SCORE_DECIMALS = 3
|
| 51 |
+
ELO_START = 1000.0
|
| 52 |
+
ELO_SCALE = 400 / math.log(10)
|
| 53 |
+
ELO_REGULARIZATION = 0.1
|
| 54 |
+
ELO_ANCHOR_MODEL = "HVG-1200-genes"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class ModelCell(str):
|
| 58 |
+
"""String-compatible model label with paper-link and chip metadata.
|
| 59 |
+
|
| 60 |
+
``is_hvg`` marks a ``<model> + HVG`` entry, whose label is the parent name.
|
| 61 |
+
``is_baseline`` marks one of our baselines, whose label keeps its
|
| 62 |
+
``(baseline)`` tag so it never merges with a model of the same name. The page
|
| 63 |
+
shows both flags as chips.
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
paper_link: str
|
| 67 |
+
is_author_submission: bool
|
| 68 |
+
is_hvg: bool
|
| 69 |
+
is_baseline: bool
|
| 70 |
+
|
| 71 |
+
def __new__(
|
| 72 |
+
cls,
|
| 73 |
+
label: str,
|
| 74 |
+
paper_link: str,
|
| 75 |
+
is_author_submission: bool,
|
| 76 |
+
is_hvg: bool = False,
|
| 77 |
+
is_baseline: bool = False,
|
| 78 |
+
) -> "ModelCell":
|
| 79 |
+
cell = super().__new__(cls, label)
|
| 80 |
+
cell.paper_link = paper_link
|
| 81 |
+
cell.is_author_submission = is_author_submission
|
| 82 |
+
cell.is_hvg = is_hvg
|
| 83 |
+
cell.is_baseline = is_baseline
|
| 84 |
+
return cell
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class TaskCell(str):
|
| 88 |
+
"""String-compatible task title flagged when its labels are public."""
|
| 89 |
+
|
| 90 |
+
is_public: bool
|
| 91 |
+
|
| 92 |
+
def __new__(cls, title: str, is_public: bool) -> "TaskCell":
|
| 93 |
+
cell = super().__new__(cls, title)
|
| 94 |
+
cell.is_public = is_public
|
| 95 |
+
return cell
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _task_cell(task: dict) -> TaskCell:
|
| 99 |
+
"""The task's title, or its id when it has none."""
|
| 100 |
+
return TaskCell(
|
| 101 |
+
str(task.get("title") or task.get("task_id", "")), bool(task.get(PUBLIC))
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _round(value: float | None) -> float | None:
|
| 106 |
+
"""Round for display; anything non-finite becomes a blank cell, not ``-inf``."""
|
| 107 |
+
if value is None or not math.isfinite(value):
|
| 108 |
+
return None
|
| 109 |
+
return round(value, SCORE_DECIMALS)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _ordered(df: pd.DataFrame) -> pd.DataFrame:
|
| 113 |
+
"""Normalise the credit columns and add the tiebreak key rows are ordered by.
|
| 114 |
+
|
| 115 |
+
``_submission_key`` falls back to the timestamp for rows written before
|
| 116 |
+
``submission_id`` existed, so legacy rows still order deterministically.
|
| 117 |
+
"""
|
| 118 |
+
flagged = with_submission_id(
|
| 119 |
+
with_paper_link(
|
| 120 |
+
with_author_submission_flag(with_institution(with_baseline_flag(df)))
|
| 121 |
+
)
|
| 122 |
+
)
|
| 123 |
+
if flagged.empty:
|
| 124 |
+
return flagged
|
| 125 |
+
key = flagged[SUBMISSION_ID].mask(
|
| 126 |
+
flagged[SUBMISSION_ID] == "", "legacy:" + flagged["submitted_at"].astype(str)
|
| 127 |
+
)
|
| 128 |
+
return flagged.assign(
|
| 129 |
+
_submission_key=key, _task=flagged["task_id"].map(_norm_id)
|
| 130 |
+
).sort_values(["submitted_at", "_submission_key"])
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def latest_only(df: pd.DataFrame) -> pd.DataFrame:
|
| 134 |
+
"""Keeps the most recent result for each ``(name, is_baseline, task)`` slot.
|
| 135 |
+
|
| 136 |
+
An entry is the accumulated set of its slots, not one submission: a later
|
| 137 |
+
submission fills the tasks it covers and leaves the rest standing.
|
| 138 |
+
|
| 139 |
+
Parameters
|
| 140 |
+
----------
|
| 141 |
+
df : pd.DataFrame
|
| 142 |
+
Persisted result rows.
|
| 143 |
+
|
| 144 |
+
Returns
|
| 145 |
+
-------
|
| 146 |
+
pd.DataFrame
|
| 147 |
+
One row per slot, credit columns normalised.
|
| 148 |
+
"""
|
| 149 |
+
ordered = _ordered(df)
|
| 150 |
+
if ordered.empty:
|
| 151 |
+
return ordered
|
| 152 |
+
return (
|
| 153 |
+
ordered.groupby([MODEL_NAME, IS_BASELINE, "_task"], as_index=False)
|
| 154 |
+
.tail(1)
|
| 155 |
+
.drop(columns=["_submission_key", "_task"])
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def submission_counts(df: pd.DataFrame) -> dict[tuple[str, bool], int]:
|
| 160 |
+
"""Counts how many times each entry's most-rewritten task was submitted.
|
| 161 |
+
|
| 162 |
+
Parameters
|
| 163 |
+
----------
|
| 164 |
+
df : pd.DataFrame
|
| 165 |
+
Persisted result rows.
|
| 166 |
+
|
| 167 |
+
Returns
|
| 168 |
+
-------
|
| 169 |
+
dict[tuple[str, bool], int]
|
| 170 |
+
Count keyed by ``(model_name, is_baseline)``.
|
| 171 |
+
"""
|
| 172 |
+
flagged = with_baseline_flag(df)
|
| 173 |
+
if flagged.empty:
|
| 174 |
+
return {}
|
| 175 |
+
per_task = (
|
| 176 |
+
flagged.assign(_task=flagged["task_id"].map(_norm_id))
|
| 177 |
+
.groupby([MODEL_NAME, IS_BASELINE, "_task"])
|
| 178 |
+
.size()
|
| 179 |
+
)
|
| 180 |
+
return {
|
| 181 |
+
(str(model), bool(is_baseline)): int(count)
|
| 182 |
+
for (model, is_baseline), count in per_task.groupby(
|
| 183 |
+
level=[MODEL_NAME, IS_BASELINE]
|
| 184 |
+
)
|
| 185 |
+
.max()
|
| 186 |
+
.items()
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def scores_from_rows(rows: pd.DataFrame, by_id: dict[str, dict]) -> list[TaskScore]:
|
| 191 |
+
"""Rebuilds one submission's task scores from persisted rows and the registry.
|
| 192 |
+
|
| 193 |
+
Parameters
|
| 194 |
+
----------
|
| 195 |
+
rows : pd.DataFrame
|
| 196 |
+
Result rows of a single entry.
|
| 197 |
+
by_id : dict[str, dict]
|
| 198 |
+
Task registry keyed by task id.
|
| 199 |
+
|
| 200 |
+
Returns
|
| 201 |
+
-------
|
| 202 |
+
list[TaskScore]
|
| 203 |
+
One score per row, joined by ``task_id``. Retired tasks are dropped.
|
| 204 |
+
"""
|
| 205 |
+
out = []
|
| 206 |
+
for _, r in rows.iterrows():
|
| 207 |
+
task_id = _norm_id(r["task_id"])
|
| 208 |
+
meta = by_id.get(task_id)
|
| 209 |
+
if meta is None:
|
| 210 |
+
continue
|
| 211 |
+
out.append(
|
| 212 |
+
TaskScore(
|
| 213 |
+
task_id=task_id,
|
| 214 |
+
dataset_id=_norm_id(meta["dataset_id"]),
|
| 215 |
+
category=str(meta["category"]),
|
| 216 |
+
metric=str(meta["metric"]),
|
| 217 |
+
score=float(r["score"]),
|
| 218 |
+
n_samples=0,
|
| 219 |
+
)
|
| 220 |
+
)
|
| 221 |
+
return out
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _board_registry(by_id: dict[str, dict], board: Board) -> dict[str, dict]:
|
| 225 |
+
return {tid: meta for tid, meta in by_id.items() if tid in board.task_ids}
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _stored_repeat_scores(row: pd.Series) -> tuple[float, ...]:
|
| 229 |
+
"""Read finite persisted repeat scores, or return an empty tuple."""
|
| 230 |
+
raw = row.get("repeat_scores")
|
| 231 |
+
if isinstance(raw, str) and raw:
|
| 232 |
+
try:
|
| 233 |
+
parsed = json.loads(raw)
|
| 234 |
+
if not isinstance(parsed, list) or any(
|
| 235 |
+
isinstance(value, bool) or not isinstance(value, (int, float))
|
| 236 |
+
for value in parsed
|
| 237 |
+
):
|
| 238 |
+
return ()
|
| 239 |
+
values = tuple(float(value) for value in parsed)
|
| 240 |
+
if values and all(math.isfinite(value) for value in values):
|
| 241 |
+
return values
|
| 242 |
+
except (
|
| 243 |
+
TypeError,
|
| 244 |
+
ValueError,
|
| 245 |
+
OverflowError,
|
| 246 |
+
RecursionError,
|
| 247 |
+
json.JSONDecodeError,
|
| 248 |
+
):
|
| 249 |
+
pass
|
| 250 |
+
return ()
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _repeat_scores(row: pd.Series) -> tuple[float, ...]:
|
| 254 |
+
"""Read persisted repeat scores, falling back to one legacy task score."""
|
| 255 |
+
values = _stored_repeat_scores(row)
|
| 256 |
+
if values:
|
| 257 |
+
return values
|
| 258 |
+
return (float(row["score"]),)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def _paired_splits(
|
| 262 |
+
left: tuple[float, ...], right: tuple[float, ...]
|
| 263 |
+
) -> list[tuple[float, ...]]:
|
| 264 |
+
"""Lines two entries up split by split; the shorter record plays its mean.
|
| 265 |
+
|
| 266 |
+
Pairwise, never board-wide: aligning on the longest record on the board
|
| 267 |
+
would flatten two three-split entries the day a six-split one is published.
|
| 268 |
+
"""
|
| 269 |
+
splits = max(len(left), len(right))
|
| 270 |
+
return [
|
| 271 |
+
column if len(column) == splits else (float(np.mean(column)),) * splits
|
| 272 |
+
for column in (left, right)
|
| 273 |
+
]
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def _is_anchor(model: str, is_baseline: bool) -> bool:
|
| 277 |
+
"""Is this the Elo anchor? Matched on ``(name, is_baseline)``, not the name."""
|
| 278 |
+
return is_baseline and model == ELO_ANCHOR_MODEL
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _credit_row(rows: pd.DataFrame) -> pd.Series:
|
| 282 |
+
"""The most recent slot carrying an institution or a paper link."""
|
| 283 |
+
credited = rows[(rows[INSTITUTION] != "") | (rows[PAPER_LINK] != "")]
|
| 284 |
+
return (credited if not credited.empty else rows).iloc[-1]
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _entries(df: pd.DataFrame, by_id: dict[str, dict], board: Board) -> list[dict]:
|
| 288 |
+
"""Rank-ready entries for every model covering the board's scored tasks.
|
| 289 |
+
|
| 290 |
+
The anchor decides which tasks count. Without it every finitely-scored task
|
| 291 |
+
counts and the Elo scale falls back to mean-centred, decided per board.
|
| 292 |
+
"""
|
| 293 |
+
scoped = _board_registry(by_id, board)
|
| 294 |
+
latest = latest_only(df)
|
| 295 |
+
if latest.empty:
|
| 296 |
+
return []
|
| 297 |
+
on_board = latest[latest["task_id"].map(_norm_id).isin(list(board.task_ids))]
|
| 298 |
+
anchor_rows = on_board[
|
| 299 |
+
[
|
| 300 |
+
_is_anchor(str(model), bool(flag))
|
| 301 |
+
for model, flag in zip(on_board[MODEL_NAME], on_board[IS_BASELINE])
|
| 302 |
+
]
|
| 303 |
+
]
|
| 304 |
+
ranked_rows = on_board if anchor_rows.empty else anchor_rows
|
| 305 |
+
scored_tasks = {
|
| 306 |
+
_norm_id(row["task_id"])
|
| 307 |
+
for _, row in ranked_rows.iterrows()
|
| 308 |
+
if math.isfinite(float(row["score"]))
|
| 309 |
+
}
|
| 310 |
+
if not scored_tasks:
|
| 311 |
+
return []
|
| 312 |
+
|
| 313 |
+
counts = submission_counts(df)
|
| 314 |
+
entries = []
|
| 315 |
+
for (model, is_baseline), rows in latest.groupby([MODEL_NAME, IS_BASELINE]):
|
| 316 |
+
submitted = rows["submitted_at"].max()
|
| 317 |
+
credits = _credit_row(rows)
|
| 318 |
+
task_rows = rows[
|
| 319 |
+
rows["task_id"].map(_norm_id).isin(list(scored_tasks))
|
| 320 |
+
& rows["score"].map(lambda score: math.isfinite(float(score)))
|
| 321 |
+
]
|
| 322 |
+
if len(task_rows["task_id"].map(_norm_id).unique()) != len(scored_tasks):
|
| 323 |
+
continue
|
| 324 |
+
categories = category_means(scores_from_rows(task_rows, scoped))
|
| 325 |
+
task_scores = {
|
| 326 |
+
_norm_id(row["task_id"]): float(row["score"])
|
| 327 |
+
for _, row in task_rows.iterrows()
|
| 328 |
+
}
|
| 329 |
+
repeat_scores = {
|
| 330 |
+
_norm_id(row["task_id"]): _repeat_scores(row)
|
| 331 |
+
for _, row in task_rows.iterrows()
|
| 332 |
+
}
|
| 333 |
+
entries.append(
|
| 334 |
+
{
|
| 335 |
+
"model_name": model,
|
| 336 |
+
"institution": credits[INSTITUTION],
|
| 337 |
+
"paper_link": credits[PAPER_LINK],
|
| 338 |
+
"is_author_submission": bool(credits[IS_AUTHOR_SUBMISSION]),
|
| 339 |
+
"submission_id": credits[SUBMISSION_ID],
|
| 340 |
+
"submitted_at": submitted,
|
| 341 |
+
"n_submissions": counts.get((str(model), bool(is_baseline)), 1),
|
| 342 |
+
"categories": categories,
|
| 343 |
+
"rank": sort_key(categories),
|
| 344 |
+
"is_baseline": bool(is_baseline),
|
| 345 |
+
"task_scores": task_scores,
|
| 346 |
+
"repeat_scores": repeat_scores,
|
| 347 |
+
}
|
| 348 |
+
)
|
| 349 |
+
if not entries:
|
| 350 |
+
return entries
|
| 351 |
+
shared = set.intersection(*(set(entry["task_scores"]) for entry in entries))
|
| 352 |
+
_comparison_stats(entries, shared)
|
| 353 |
+
return sorted(entries, key=lambda entry: (-entry["elo"], -entry["rank"]))
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def _comparison_stats(entries: list[dict], task_ids: set[str]) -> None:
|
| 357 |
+
"""Add Bradley-Terry Elo and mean rank to ``entries`` in place.
|
| 358 |
+
|
| 359 |
+
Battles are played split by split, a pair's splits sharing weight one per
|
| 360 |
+
task. ``ELO_ANCHOR_MODEL`` is pinned during the fit, not shifted after,
|
| 361 |
+
because the ridge penalty shrinks toward whichever model is pinned. Entries
|
| 362 |
+
are keyed by position: two of them can share a name.
|
| 363 |
+
"""
|
| 364 |
+
if not entries:
|
| 365 |
+
return
|
| 366 |
+
indices = list(range(len(entries)))
|
| 367 |
+
ranks: dict[int, list[float]] = {index: [] for index in indices}
|
| 368 |
+
battles: list[tuple[int, int, float, float]] = []
|
| 369 |
+
for task_id in sorted(task_ids):
|
| 370 |
+
scores = [entry["task_scores"][task_id] for entry in entries]
|
| 371 |
+
ordered = sorted(
|
| 372 |
+
indices, key=lambda index: (-scores[index], entries[index]["model_name"])
|
| 373 |
+
)
|
| 374 |
+
for index in indices:
|
| 375 |
+
tied_positions = [
|
| 376 |
+
rank
|
| 377 |
+
for rank, other in enumerate(ordered, start=1)
|
| 378 |
+
if scores[other] == scores[index]
|
| 379 |
+
]
|
| 380 |
+
ranks[index].append(sum(tied_positions) / len(tied_positions))
|
| 381 |
+
for index in indices:
|
| 382 |
+
for opponent in indices[index + 1 :]:
|
| 383 |
+
left, right = _paired_splits(
|
| 384 |
+
entries[index]["repeat_scores"][task_id],
|
| 385 |
+
entries[opponent]["repeat_scores"][task_id],
|
| 386 |
+
)
|
| 387 |
+
for ours, theirs in zip(left, right):
|
| 388 |
+
outcome = 1.0 if ours > theirs else (0.5 if ours == theirs else 0.0)
|
| 389 |
+
battles.append((index, opponent, outcome, 1 / len(left)))
|
| 390 |
+
|
| 391 |
+
anchor = next(
|
| 392 |
+
(
|
| 393 |
+
index
|
| 394 |
+
for index, entry in enumerate(entries)
|
| 395 |
+
if _is_anchor(entry["model_name"], entry["is_baseline"])
|
| 396 |
+
),
|
| 397 |
+
None,
|
| 398 |
+
)
|
| 399 |
+
pinned = len(entries) - 1 if anchor is None else anchor
|
| 400 |
+
battle_table = np.array(battles, dtype=float).reshape(-1, 4)
|
| 401 |
+
lefts, rights = battle_table[:, 0].astype(int), battle_table[:, 1].astype(int)
|
| 402 |
+
weights = battle_table[:, 3]
|
| 403 |
+
weighted_outcomes = weights * battle_table[:, 2]
|
| 404 |
+
|
| 405 |
+
def objective(theta_free: np.ndarray) -> tuple[float, np.ndarray]:
|
| 406 |
+
theta = np.insert(theta_free, pinned, 0.0)
|
| 407 |
+
delta = theta[lefts] - theta[rights]
|
| 408 |
+
loss = np.sum(weights * np.logaddexp(0.0, delta) - weighted_outcomes * delta)
|
| 409 |
+
error = weights * expit(delta) - weighted_outcomes
|
| 410 |
+
gradient = np.bincount(lefts, error, len(entries)) - np.bincount(
|
| 411 |
+
rights, error, len(entries)
|
| 412 |
+
)
|
| 413 |
+
loss += ELO_REGULARIZATION * float(np.dot(theta, theta)) / 2
|
| 414 |
+
gradient += ELO_REGULARIZATION * theta
|
| 415 |
+
return float(loss), np.delete(gradient, pinned)
|
| 416 |
+
|
| 417 |
+
if battles and len(entries) > 1:
|
| 418 |
+
fit = minimize(
|
| 419 |
+
objective,
|
| 420 |
+
np.zeros(len(entries) - 1),
|
| 421 |
+
jac=True,
|
| 422 |
+
method="L-BFGS-B",
|
| 423 |
+
)
|
| 424 |
+
theta = np.insert(fit.x, pinned, 0.0) if fit.success else np.zeros(len(entries))
|
| 425 |
+
if anchor is None:
|
| 426 |
+
theta -= theta.mean()
|
| 427 |
+
ratings = ELO_START + ELO_SCALE * theta
|
| 428 |
+
else:
|
| 429 |
+
ratings = np.full(len(entries), ELO_START)
|
| 430 |
+
for index, entry in enumerate(entries):
|
| 431 |
+
entry["elo"] = float(ratings[index])
|
| 432 |
+
entry["mean_rank"] = (
|
| 433 |
+
float(np.mean(ranks[index])) if ranks[index] else float("nan")
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def ranked_table(
|
| 438 |
+
df: pd.DataFrame, by_id: dict[str, dict], board: Board
|
| 439 |
+
) -> pd.DataFrame:
|
| 440 |
+
"""Ranks the models that covered every task of ``board`` the anchor scored.
|
| 441 |
+
|
| 442 |
+
Parameters
|
| 443 |
+
----------
|
| 444 |
+
df : pd.DataFrame
|
| 445 |
+
Persisted result rows.
|
| 446 |
+
by_id : dict[str, dict]
|
| 447 |
+
Task registry keyed by task id.
|
| 448 |
+
board : Board
|
| 449 |
+
Board to rank.
|
| 450 |
+
|
| 451 |
+
Returns
|
| 452 |
+
-------
|
| 453 |
+
pd.DataFrame
|
| 454 |
+
One row per ranked model, one column per task category in its native
|
| 455 |
+
metric. Empty when no model has full coverage.
|
| 456 |
+
"""
|
| 457 |
+
entries = _entries(df, by_id, board)
|
| 458 |
+
if not entries:
|
| 459 |
+
return pd.DataFrame(columns=EMPTY_RANKED_COLUMNS)
|
| 460 |
+
|
| 461 |
+
columns = sorted({cat for e in entries for cat in e["categories"]})
|
| 462 |
+
metric_of: dict[str, str] = {}
|
| 463 |
+
for entry in entries:
|
| 464 |
+
for cat, stats in entry["categories"].items():
|
| 465 |
+
metric_of.setdefault(cat, stats["metric"])
|
| 466 |
+
|
| 467 |
+
rows = []
|
| 468 |
+
for position, entry in enumerate(entries, start=1):
|
| 469 |
+
categories = entry["categories"]
|
| 470 |
+
name, is_hvg = split_hvg_name(str(entry["model_name"]))
|
| 471 |
+
model = ModelCell(
|
| 472 |
+
display_name(name, entry["is_baseline"]),
|
| 473 |
+
entry["paper_link"],
|
| 474 |
+
entry["is_author_submission"],
|
| 475 |
+
is_hvg,
|
| 476 |
+
entry["is_baseline"],
|
| 477 |
+
)
|
| 478 |
+
row = {
|
| 479 |
+
"Rank": position,
|
| 480 |
+
"Model": model,
|
| 481 |
+
"Institution": entry["institution"] or None,
|
| 482 |
+
"Elo": round(entry["elo"]),
|
| 483 |
+
"Mean rank": _round(entry["mean_rank"]),
|
| 484 |
+
# Blank for baselines and ``+ HVG`` entries: we republish and re-sync
|
| 485 |
+
# those, so their count measures our release cadence, not anyone tuning.
|
| 486 |
+
"Submissions count": (
|
| 487 |
+
None if entry["is_baseline"] or is_hvg else entry["n_submissions"]
|
| 488 |
+
),
|
| 489 |
+
}
|
| 490 |
+
for cat in columns:
|
| 491 |
+
header = f"{label(cat)} ({metric_label(metric_of[cat])})"
|
| 492 |
+
row[header] = _round(categories[cat]["mean"]) if cat in categories else None
|
| 493 |
+
rows.append(row)
|
| 494 |
+
table = pd.DataFrame(rows)
|
| 495 |
+
table["Submissions count"] = table["Submissions count"].astype("Int64")
|
| 496 |
+
return table
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
RESERVED_COLUMNS = frozenset({"Task", "Family", "Area", "Modality", "Metric", "Best"})
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def per_task_table(
|
| 503 |
+
df: pd.DataFrame, by_id: dict[str, dict], board: Board
|
| 504 |
+
) -> pd.DataFrame:
|
| 505 |
+
"""Builds the per-task table: one row per task, one column per model.
|
| 506 |
+
|
| 507 |
+
Partial submissions are kept, unlike in ``ranked_table``. Model names become
|
| 508 |
+
column names, hence ``RESERVED_COLUMNS``: a model called ``Task`` would
|
| 509 |
+
overwrite every task title.
|
| 510 |
+
|
| 511 |
+
Parameters
|
| 512 |
+
----------
|
| 513 |
+
df : pd.DataFrame
|
| 514 |
+
Persisted result rows.
|
| 515 |
+
by_id : dict[str, dict]
|
| 516 |
+
Task registry keyed by task id.
|
| 517 |
+
board : Board
|
| 518 |
+
Board whose tasks the table is restricted to.
|
| 519 |
+
|
| 520 |
+
Returns
|
| 521 |
+
-------
|
| 522 |
+
pd.DataFrame
|
| 523 |
+
Task rows and model columns ordered by mean over the board, plus a
|
| 524 |
+
``Best`` column naming the leading model.
|
| 525 |
+
"""
|
| 526 |
+
scoped = _board_registry(by_id, board)
|
| 527 |
+
if df.empty or not scoped:
|
| 528 |
+
return pd.DataFrame(columns=EMPTY_PER_TASK_COLUMNS)
|
| 529 |
+
|
| 530 |
+
scores: dict[tuple[str, str], float] = {}
|
| 531 |
+
repeat_sd: dict[tuple[str, str], tuple[float, int]] = {}
|
| 532 |
+
for _, r in latest_only(df).iterrows():
|
| 533 |
+
task_id = _norm_id(r["task_id"])
|
| 534 |
+
if task_id in scoped:
|
| 535 |
+
model = display_name(str(r[MODEL_NAME]), bool(r[IS_BASELINE]))
|
| 536 |
+
scores[(task_id, model)] = float(r["score"])
|
| 537 |
+
repeats = _stored_repeat_scores(r)
|
| 538 |
+
if len(repeats) > 1:
|
| 539 |
+
repeat_sd[(task_id, model)] = (float(np.std(repeats)), len(repeats))
|
| 540 |
+
if not scores:
|
| 541 |
+
return pd.DataFrame(columns=EMPTY_PER_TASK_COLUMNS)
|
| 542 |
+
|
| 543 |
+
by_model: dict[str, list[float]] = {}
|
| 544 |
+
for (_, model), score in scores.items():
|
| 545 |
+
by_model.setdefault(model, []).append(score)
|
| 546 |
+
models = sorted(by_model, key=lambda m: -sum(by_model[m]) / len(by_model[m]))
|
| 547 |
+
|
| 548 |
+
rows = []
|
| 549 |
+
displayed_sd: dict[tuple[int, str], tuple[float, int]] = {}
|
| 550 |
+
families = {str(t.get("category", "")) for t in scoped.values()}
|
| 551 |
+
areas = {str(t.get("therapeutic_area", "")) for t in scoped.values()}
|
| 552 |
+
for task_id, task in sorted(scoped.items()):
|
| 553 |
+
row = {"Task": _task_cell(task)}
|
| 554 |
+
if len(families) > 1:
|
| 555 |
+
row["Family"] = label(str(task.get("category", "")))
|
| 556 |
+
if len(areas) > 1:
|
| 557 |
+
row["Area"] = str(task.get("therapeutic_area", ""))
|
| 558 |
+
row["Metric"] = metric_label(str(task.get("metric", "")))
|
| 559 |
+
present = {m: scores[(task_id, m)] for m in models if (task_id, m) in scores}
|
| 560 |
+
for model in models:
|
| 561 |
+
row[model] = _round(present.get(model))
|
| 562 |
+
if (task_id, model) in repeat_sd:
|
| 563 |
+
displayed_sd[(len(rows), model)] = repeat_sd[(task_id, model)]
|
| 564 |
+
row["Best"] = max(present, key=present.get) if present else None
|
| 565 |
+
rows.append(row)
|
| 566 |
+
table = pd.DataFrame(rows)
|
| 567 |
+
table.attrs["repeat_standard_deviations"] = displayed_sd
|
| 568 |
+
return table
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
TASK_COLUMNS = [
|
| 572 |
+
"Task",
|
| 573 |
+
"Predicts",
|
| 574 |
+
"Family",
|
| 575 |
+
"Area",
|
| 576 |
+
"Modality",
|
| 577 |
+
"Disease",
|
| 578 |
+
"Tissue",
|
| 579 |
+
"Patients",
|
| 580 |
+
"Metric",
|
| 581 |
+
]
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def tasks_table(tasks: list[dict]) -> pd.DataFrame:
|
| 585 |
+
"""Builds the Tasks page table. Provenance is never a column.
|
| 586 |
+
|
| 587 |
+
Parameters
|
| 588 |
+
----------
|
| 589 |
+
tasks : list[dict]
|
| 590 |
+
Registry tasks.
|
| 591 |
+
|
| 592 |
+
Returns
|
| 593 |
+
-------
|
| 594 |
+
pd.DataFrame
|
| 595 |
+
One row per task, sorted by task id, with ``TASK_COLUMNS`` as columns.
|
| 596 |
+
"""
|
| 597 |
+
if not tasks:
|
| 598 |
+
return pd.DataFrame(columns=TASK_COLUMNS)
|
| 599 |
+
rows = []
|
| 600 |
+
for task in sorted(tasks, key=lambda t: str(t.get("task_id", ""))):
|
| 601 |
+
rows.append(
|
| 602 |
+
{
|
| 603 |
+
"Task": _task_cell(task),
|
| 604 |
+
"Predicts": str(task.get("description") or ""),
|
| 605 |
+
"Family": label(str(task.get("category", ""))),
|
| 606 |
+
"Area": str(task.get("therapeutic_area", "")),
|
| 607 |
+
"Modality": modality_label(str(task.get("modality", ""))),
|
| 608 |
+
"Disease": ", ".join(str(d) for d in task.get("diseases") or []),
|
| 609 |
+
"Tissue": str(task.get("tissue", "")),
|
| 610 |
+
"Patients": task.get("n_subjects") or task.get("n_samples"),
|
| 611 |
+
"Metric": metric_label(str(task.get("metric", ""))),
|
| 612 |
+
}
|
| 613 |
+
)
|
| 614 |
+
return pd.DataFrame(rows, columns=TASK_COLUMNS)
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
REPOSITORY_OF_PREFIX = {"GSE": "NCBI GEO", "E-MTAB": "EMBL-EBI ArrayExpress"}
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
def source_repositories(tasks: list[dict]) -> list[str]:
|
| 621 |
+
"""Lists the public archives the cohorts come from, never the accessions.
|
| 622 |
+
|
| 623 |
+
An accession like ``GSE000000`` would let anyone download the study and
|
| 624 |
+
recover the labels. The registry's ``license`` field stays out too: one
|
| 625 |
+
unvalidated line per dataset, and some cohorts pool three studies under it.
|
| 626 |
+
|
| 627 |
+
Parameters
|
| 628 |
+
----------
|
| 629 |
+
tasks : list[dict]
|
| 630 |
+
Registry tasks, each optionally carrying a ``sources`` list.
|
| 631 |
+
|
| 632 |
+
Returns
|
| 633 |
+
-------
|
| 634 |
+
list[str]
|
| 635 |
+
Archive names, sorted.
|
| 636 |
+
"""
|
| 637 |
+
repositories: set[str] = set()
|
| 638 |
+
for task in tasks:
|
| 639 |
+
for source in task.get("sources") or []:
|
| 640 |
+
for prefix, repository in REPOSITORY_OF_PREFIX.items():
|
| 641 |
+
if str(source).upper().startswith(prefix):
|
| 642 |
+
repositories.add(repository)
|
| 643 |
+
return sorted(repositories)
|
pages/about.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## What PRIMO measures
|
| 2 |
+
|
| 3 |
+
**Can a foundation model turn a patient's omics data into an embedding that
|
| 4 |
+
predicts meaningful outcomes?**
|
| 5 |
+
|
| 6 |
+
You embed each patient. We fit a fixed probe on top and check whether that
|
| 7 |
+
embedding predicts a real clinical outcome. You never see the labels, so you
|
| 8 |
+
cannot tune to them, and when a score comes out high the credit belongs to the
|
| 9 |
+
*embedding*.
|
| 10 |
+
|
| 11 |
+
Plenty of benchmarks already score reconstruction. Far fewer ask whether the
|
| 12 |
+
representation is any use in the clinic, and that is the gap PRIMO tries to fill.
|
| 13 |
+
|
| 14 |
+
## How a submission is scored
|
| 15 |
+
|
| 16 |
+
1. **You send embeddings, never predictions**: one vector per baseline sample.
|
| 17 |
+
2. **We fit the probe**: ridge for scalar regression, logistic for
|
| 18 |
+
classification, or multi-output ridge regression for perturbation
|
| 19 |
+
responses. Learned embedding rows are scaled to unit length first, since an
|
| 20 |
+
embedding dimension has no unit; gene-space baselines keep their log-normalized row
|
| 21 |
+
scale. Feature standardisation is fit on each outer training fold, and probe
|
| 22 |
+
hyperparameters are selected with subject-grouped inner cross-validation.
|
| 23 |
+
3. **Scores always hold out whole subjects.** Most tasks use subject-grouped
|
| 24 |
+
cross-validation: we average the fold-level metric over frozen partitions,
|
| 25 |
+
three folds ten times over.
|
| 26 |
+
Binary tasks with fewer than 30 subjects use leave-pair-out: each
|
| 27 |
+
positive-negative pair of subjects is held out in turn, the probe is fit on
|
| 28 |
+
everyone else, and the AUROC is the share of held-out sample pairs ranked
|
| 29 |
+
correctly. These tasks have one score, so no ±.
|
| 30 |
+
Transfer tasks use a fixed split instead: train on one source cohort, test
|
| 31 |
+
on the target cohort. Perturbation tasks decode hidden post-minus-pre
|
| 32 |
+
expression responses.
|
| 33 |
+
4. **Each task family keeps its own metric.** AUROC, Pearson r and centered
|
| 34 |
+
Spearman never share a category column.
|
| 35 |
+
|
| 36 |
+
The probe is identical for everyone, which is what makes the numbers comparable:
|
| 37 |
+
what changes between two rows is the representation behind them. The rows marked
|
| 38 |
+
`(baseline)` are our own submissions. In particular, `HVG-1200-genes` is the
|
| 39 |
+
**HVG-1200 log-normalized linear baseline**: bulk samples use log2(CPM+1), while
|
| 40 |
+
single-cell profiles use per-cell log2(CP10K+1) followed by sample-level mean
|
| 41 |
+
pooling. We retain the 1,200 genes with highest dataset-wide variance, apply
|
| 42 |
+
train-fold standardisation, and fit the same tuned linear probe. Gene selection
|
| 43 |
+
never reads a label, but it is fit over every sample of a dataset. This is an
|
| 44 |
+
information-rich transductive reference, not a naïve raw-count baseline or an
|
| 45 |
+
expected lower bound for a foundation model.
|
| 46 |
+
|
| 47 |
+
Every submission also gets a second row, **`<model> + HVG`**: the same
|
| 48 |
+
embedding concatenated with the `HVG-1200-genes` features of each sample. The
|
| 49 |
+
embedding is scaled to unit length, the 1,200 log-normalized genes are appended,
|
| 50 |
+
and the concatenated row is scaled to unit length again before the same probe.
|
| 51 |
+
It shows whether a model adds information that expression alone does not carry.
|
| 52 |
+
These rows are shown by default; set **+ HVG models** to **Hide** to hide them,
|
| 53 |
+
which re-ranks the board and refits Elo without them.
|
| 54 |
+
|
| 55 |
+
Elo orders the rows. Each task family keeps its own column, in its own metric,
|
| 56 |
+
and no column averages across metrics.
|
| 57 |
+
|
| 58 |
+
**Elo** is pinned to our HVG-1200 log-normalized linear baseline, which sits at
|
| 59 |
+
exactly 1000. It turns every paired repeat into a win, draw, or loss and
|
| 60 |
+
discards the score margin, so a tiny win counts like a large one. A difference
|
| 61 |
+
of only a few Elo points is effectively a tie, not evidence that one
|
| 62 |
+
representation is meaningfully superior. Use mean rank, the family columns, and
|
| 63 |
+
per-task scores to interpret close rows. +400 Elo is a 10-to-1 expected win
|
| 64 |
+
rate. The scale does not move when a new model is submitted, so ratings stay
|
| 65 |
+
comparable over time, but only within one board, since each board is fitted on
|
| 66 |
+
its own tasks.
|
| 67 |
+
|
| 68 |
+
**Subm.** (submissions count) is the most times any *single* task result of
|
| 69 |
+
that model was resubmitted, not how many files were uploaded. A model that
|
| 70 |
+
covered twenty tasks across twenty submissions reads 1, because each task was
|
| 71 |
+
submitted once; a model that sent the same task five times reads 5. It shows
|
| 72 |
+
n/a for our baselines and the `+ HVG` rows, which we republish ourselves. A
|
| 73 |
+
published result can be corrected, so a 2 or 3 is ordinary; a high number on
|
| 74 |
+
one task is worth a second look.
|
| 75 |
+
|
| 76 |
+
## How the results are organised
|
| 77 |
+
|
| 78 |
+
A **board** is a self-contained leaderboard over a slice of the benchmark: the
|
| 79 |
+
whole modality, one therapeutic area, one task family, or an area and a family
|
| 80 |
+
together. Pick it with the pills of the **Leaderboard** page.
|
| 81 |
+
|
| 82 |
+
A board ranks only the models that covered **all of its tasks**. Skipping
|
| 83 |
+
Infectious diseases therefore costs you nothing on the Oncology board.
|
| 84 |
+
|
| 85 |
+
**Boards never cross modalities.** An AUROC on bulk RNA and an AUROC on
|
| 86 |
+
single-cell are not measuring the same thing, so we keep them apart.
|
| 87 |
+
|
| 88 |
+
## What stays hidden
|
| 89 |
+
|
| 90 |
+
The evaluation only stays honest while the labels stay ours, which prevents tuning
|
| 91 |
+
the model directly on the labels. We still publish the omics data, to compute
|
| 92 |
+
embeddings.
|
| 93 |
+
|
| 94 |
+
The exception is the tasks tagged **Public**: their labels are downloadable from
|
| 95 |
+
the [PRIMO dataset](https://huggingface.co/datasets/PRIMOmics/primo), so a model
|
| 96 |
+
may have been trained on them.
|
| 97 |
+
|
| 98 |
+
| Disclosure | What it covers |
|
| 99 |
+
|---|---|
|
| 100 |
+
| **Per task** | disease, tissue, therapeutic area, what is predicted, class names, patient count, metric |
|
| 101 |
+
| **In aggregate** | the public archives the cohorts sit in |
|
| 102 |
+
| **Public tasks** | labels, evaluation folds, post-treatment responses, and the `dataset_id` each is scored on, which can make its source study findable |
|
| 103 |
+
| **Never** | study accessions and, for every other task, which cohort is behind which `dataset_id`, labels, pairing identifiers and post-treatment responses |
|
| 104 |
+
|
| 105 |
+
Every cohort comes from a published study deposited in {repositories}. We name
|
| 106 |
+
the archive but never the accession, as publishing the study id behind a
|
| 107 |
+
`dataset_id` would make the dataset easy to reconstitute.
|
| 108 |
+
|
| 109 |
+
## Cite PRIMO
|
| 110 |
+
|
| 111 |
+
Cite this website and the board version that you used. The **Cite** button, at
|
| 112 |
+
the top of every page, gives the BibTeX entry.
|
| 113 |
+
|
| 114 |
+
Want to add a cohort, propose a task on the cohorts already here, open a new
|
| 115 |
+
modality or therapeutic area, or work with us on the methodology? The
|
| 116 |
+
**Contribute** tab explains how.
|
| 117 |
+
|
| 118 |
+
## Links
|
| 119 |
+
|
| 120 |
+
- 🌐 **Website**: [primomics.org](http://primomics.org/)
|
| 121 |
+
- 📦 **Data**: [PRIMOmics/primo](https://huggingface.co/datasets/PRIMOmics/primo)
|
| 122 |
+
- 📄 **Paper**: [OpenReview](https://openreview.net/forum?id=v2SA8gHwqo)
|
| 123 |
+
- 💬 **Questions**: [karim.elkanbi@scientalab.com](mailto:karim.elkanbi@scientalab.com)
|
pages/contribute.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PRIMO is an open benchmark, and it is **deliberately incomplete**. The
|
| 2 |
+
**Leaderboard** page shows how far it reaches today, and its last panel names
|
| 3 |
+
a few of the gaps.
|
| 4 |
+
|
| 5 |
+
PRIMO was started by **Scienta** and **Hugging Face**. The intent is community governance; how
|
| 6 |
+
that actually works is part of what the first partners will define.
|
| 7 |
+
|
| 8 |
+
## Add a cohort
|
| 9 |
+
|
| 10 |
+
PRIMO has no shortage of models. What is hard to find is **an omics cohort with
|
| 11 |
+
patient-level clinical labels**, whether that is drug response, a severity score
|
| 12 |
+
or a molecular subtype. If you hold one, or know a group that does, tell us:
|
| 13 |
+
|
| 14 |
+
- **modality** and roughly how many patients
|
| 15 |
+
- **what is labelled**, and how many patients carry that label
|
| 16 |
+
- **disease and tissue**
|
| 17 |
+
- **licence**, and whether the data can be redistributed
|
| 18 |
+
|
| 19 |
+
[Propose a cohort](mailto:karim.elkanbi@scientalab.com?subject=PRIMO%20cohort%20proposal&body=Modality%20and%20rough%20patient%20count%3A%0A%0AWhat%20is%20labelled%2C%20and%20how%20many%20patients%20carry%20that%20label%3A%0A%0ADisease%20and%20tissue%3A%0A%0ALicence%2C%20and%20whether%20the%20data%20can%20be%20redistributed%3A%0A)
|
| 20 |
+
|
| 21 |
+
There is no formal acceptance process yet. We are writing it with the first
|
| 22 |
+
partners, and we would rather admit that than invent rules nobody has agreed to.
|
| 23 |
+
What you will get today is a real answer and a technical conversation.
|
| 24 |
+
|
| 25 |
+
## Add a task, without bringing any data
|
| 26 |
+
|
| 27 |
+
This one costs you nothing but an idea. Ask **a better question of the cohorts
|
| 28 |
+
already here**: a label we are not predicting, a transfer split nobody has tried,
|
| 29 |
+
or a metric that would say more than the one we use. Methodology counts here as
|
| 30 |
+
much as data does.
|
| 31 |
+
|
| 32 |
+
[Propose a task](mailto:karim.elkanbi@scientalab.com?subject=PRIMO%20task%20proposal&body=Task%20I%20would%20add%3A%0A%0AWhich%20cohorts%20already%20in%20PRIMO%20it%20uses%3A%0A%0AWhat%20is%20predicted%2C%20and%20with%20which%20metric%3A%0A%0AWhy%20it%20says%20something%20the%20current%20tasks%20do%20not%3A%0A)
|
| 33 |
+
|
| 34 |
+
## Add a modality or a therapeutic area
|
| 35 |
+
|
| 36 |
+
The last panel of the **Leaderboard** page names a few omics layers and clinical
|
| 37 |
+
areas PRIMO does not reach yet. Any other one is welcome too. Read them as gaps
|
| 38 |
+
waiting for someone rather than as a roadmap we are already working through. Opening one takes a single cohort with clinical
|
| 39 |
+
labels, and not a whole atlas.
|
| 40 |
+
|
| 41 |
+
[Propose a modality or an area](mailto:karim.elkanbi@scientalab.com?subject=PRIMO%20modality%20or%20area%20proposal&body=Modality%20or%20area%20I%20would%20add%3A%0A%0AWhat%20I%20can%20bring%20%28cohort%2C%20labels%2C%20licence%29%3A%0A)
|
| 42 |
+
|
| 43 |
+
## Become a founding partner
|
| 44 |
+
|
| 45 |
+
Founding partners shape PRIMO rather than just appear on it: a voice on the
|
| 46 |
+
methodology, co-signature on what we publish, and early access to the results.
|
| 47 |
+
|
| 48 |
+
[Talk to us about partnership](mailto:karim.elkanbi@scientalab.com?subject=PRIMO%20founding%20partner&body=Organisation%3A%0A%0AWhat%20we%20would%20bring%20to%20PRIMO%3A%0A%0AWhat%20we%20would%20want%20from%20it%3A%0A)
|
| 49 |
+
· [primomics.org](http://primomics.org/)
|
| 50 |
+
|
| 51 |
+
## Smaller ways in
|
| 52 |
+
|
| 53 |
+
- **Question the method.** Open a thread in the Space's **Community** tab.
|
| 54 |
+
- **Submit a model.** Baselines are on the leaderboard already; beat them.
|
| 55 |
+
- **Read the paper**: [OpenReview](https://openreview.net/forum?id=v2SA8gHwqo)
|
| 56 |
+
- **Look at the data**: [PRIMOmics/primo](https://huggingface.co/datasets/PRIMOmics/primo)
|
pages/submit.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
### The fastest way in
|
| 2 |
+
|
| 3 |
+
Two files, both in this [Space's repo](https://huggingface.co/spaces/PRIMOmics/primo-eval/blob/main/quickstart.py):
|
| 4 |
+
|
| 5 |
+
- 📥 [`quickstart.py`](https://huggingface.co/spaces/PRIMOmics/primo-eval/blob/main/quickstart.py):
|
| 6 |
+
downloads the selected modality, embeds it, and writes a valid submission.
|
| 7 |
+
Swap its `embed` function for your model and you are done.
|
| 8 |
+
- 📄 [`example_submission.csv`](https://huggingface.co/spaces/PRIMOmics/primo-eval/blob/main/example_submission.csv):
|
| 9 |
+
four lines, fake numbers, the exact shape we expect.
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
pip install anndata scikit-learn pandas pyarrow pyyaml huggingface_hub
|
| 13 |
+
python quickstart.py --modality bulk-rna --out submission.parquet
|
| 14 |
+
# or: --modality single-cell-rna
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
### Or 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 [PRIMOmics/primo](https://huggingface.co/datasets/PRIMOmics/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
|
| 25 |
+
|
| 26 |
+
```
|
| 27 |
+
dataset_id,sample_id,e0,e1,e2
|
| 28 |
+
d002,S1,0.12,-0.44,0.98
|
| 29 |
+
d003,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 |
+
### Good to know
|
| 38 |
+
|
| 39 |
+
Files containing dataset IDs from another modality are rejected. **Partial
|
| 40 |
+
submissions are welcome, and they add up.** Cover fewer datasets and you are still
|
| 41 |
+
scored: you get ranked on every **board** whose scored tasks you covered in
|
| 42 |
+
full, and your numbers still show up in each board's **per-task** table, so
|
| 43 |
+
nothing you send is thrown away.
|
| 44 |
+
|
| 45 |
+
Results are keyed by **model name and task**. Submit under a name you already
|
| 46 |
+
used and it fills in the tasks it covers, leaving the rest of that model's
|
| 47 |
+
results standing — so you can build coverage up one submission at a time, and the
|
| 48 |
+
same name can hold results for both modalities at once. Submitting a task you
|
| 49 |
+
have already covered replaces that one result, which is how a bad run gets fixed.
|
| 50 |
+
A name belongs to whichever HF user submitted it first; nobody else can write under it.
|
| 51 |
+
|
| 52 |
+
**Each submission yields two entries.** We also score your embedding
|
| 53 |
+
concatenated with our `HVG-1200-genes` features (your embedding is put on the
|
| 54 |
+
unit sphere, then the concatenated row) and rank it as `<your model> + HVG`,
|
| 55 |
+
under your name and credits. Scoring therefore takes about twice as long. Names containing `+ HVG` are reserved.
|
| 56 |
+
|
| 57 |
+
**Your first target is the baselines.** We run our own reference submissions on
|
| 58 |
+
log-normalized expression, whole or cut down to its most variable genes, and they
|
| 59 |
+
sit on the boards labelled `(baseline)`. `HVG-1200-genes` is the HVG-1200
|
| 60 |
+
log-normalized linear baseline, not a raw-count baseline.
|
pages/tasks.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Every scoreable task. Each row is a hidden clinical target or perturbation
|
| 2 |
+
response, scored by the same fixed task probe. The cohorts stay anonymous, but the
|
| 3 |
+
biology is on the table.
|
primo.css
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* PRIMO benchmark platform - Gradio stylesheet.
|
| 2 |
+
Load with: gr.Blocks(css_paths=["primo.css"])
|
| 3 |
+
Layout, tokens and type adapted from the STRABLE leaderboard,
|
| 4 |
+
https://github.com/gblayer/strable-leaderboard
|
| 5 |
+
Copyright (c) 2026 The STRABLE developers. BSD 3-Clause License:
|
| 6 |
+
its full text, conditions and disclaimer are in LICENSE-STRABLE. */
|
| 7 |
+
|
| 8 |
+
/* ---------- tokens: PRIMO marine and coral ---------- */
|
| 9 |
+
:root {
|
| 10 |
+
--pm-f-display: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
|
| 11 |
+
--pm-f-body: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
|
| 12 |
+
--pm-f-mono: "IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace;
|
| 13 |
+
--pm-r-card: 16px;
|
| 14 |
+
--pm-bg: #EEF0F7; --pm-surface: #F8F9FC; --pm-surface-2: #E2E6F3;
|
| 15 |
+
--pm-ink: #0D1B62; --pm-ink-2: #2E3A73; --pm-ink-3: #565F8C; --pm-line: #CDD3EA;
|
| 16 |
+
--pm-head: #0D1B62; --pm-accent: #162DA3; --pm-accent-ink: #FFFFFF; --pm-btn-edge: #162DA3;
|
| 17 |
+
--pm-accent-2: #3F53B5; --pm-accent-text: #B5532F;
|
| 18 |
+
--pm-mark: #FD7E56; --pm-mark-muted: #A5ACCB;
|
| 19 |
+
--pm-panel: #0D1B62; --pm-panel-line: #3F53B5; --pm-panel-ink: #FFFFFF; --pm-panel-ink-2: #BCC3EA;
|
| 20 |
+
color-scheme: light;
|
| 21 |
+
}
|
| 22 |
+
.dark {
|
| 23 |
+
--pm-bg: #0A1030; --pm-surface: #111A45; --pm-surface-2: #1A2559;
|
| 24 |
+
--pm-ink: #E6E9F7; --pm-ink-2: #C2C8E4; --pm-ink-3: #959DC4; --pm-line: #28336B;
|
| 25 |
+
--pm-head: #C9D0FF; --pm-accent: #3F53B5; --pm-accent-ink: #FFFFFF; --pm-btn-edge: #3F53B5;
|
| 26 |
+
--pm-accent-2: #9AA8FF; --pm-accent-text: #FD9878;
|
| 27 |
+
--pm-mark: #FD7E56; --pm-mark-muted: #5A6394;
|
| 28 |
+
--pm-panel: #162DA3; --pm-panel-line: #3F53B5; --pm-panel-ink: #FFFFFF; --pm-panel-ink-2: #C9D0FF;
|
| 29 |
+
color-scheme: dark;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/* ---------- Gradio reset: its chrome takes the page tokens ---------- */
|
| 33 |
+
html, body, gradio-app { background: var(--pm-bg) !important; }
|
| 34 |
+
.gradio-container {
|
| 35 |
+
max-width: 100% !important; padding: 0 !important; background: var(--pm-bg) !important;
|
| 36 |
+
font-family: var(--pm-f-body) !important; color: var(--pm-ink);
|
| 37 |
+
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
|
| 38 |
+
--body-background-fill: var(--pm-bg);
|
| 39 |
+
--background-fill-primary: var(--pm-surface);
|
| 40 |
+
--background-fill-secondary: var(--pm-bg);
|
| 41 |
+
--block-background-fill: var(--pm-surface);
|
| 42 |
+
--block-border-color: var(--pm-line);
|
| 43 |
+
--border-color-primary: var(--pm-line);
|
| 44 |
+
--input-background-fill: var(--pm-bg);
|
| 45 |
+
--input-border-color: var(--pm-line);
|
| 46 |
+
--body-text-color: var(--pm-ink);
|
| 47 |
+
--body-text-color-subdued: var(--pm-ink-3);
|
| 48 |
+
--block-label-text-color: var(--pm-ink-2);
|
| 49 |
+
--block-title-text-color: var(--pm-ink-2);
|
| 50 |
+
--block-info-text-color: var(--pm-ink-3);
|
| 51 |
+
--link-text-color: var(--pm-ink);
|
| 52 |
+
--link-text-color-hover: var(--pm-accent-text);
|
| 53 |
+
--color-accent: var(--pm-accent);
|
| 54 |
+
--color-accent-soft: var(--pm-surface-2);
|
| 55 |
+
--checkbox-background-color-selected: var(--pm-accent);
|
| 56 |
+
--button-primary-background-fill: var(--pm-accent);
|
| 57 |
+
--button-primary-text-color: var(--pm-accent-ink);
|
| 58 |
+
}
|
| 59 |
+
footer { display: none !important; }
|
| 60 |
+
#pm-pages > .tab-nav, #pm-pages > .tab-wrapper { display: none !important; }
|
| 61 |
+
#pm-pages, #pm-pages > .tabitem { border: 0 !important; padding: 0 !important; background: transparent !important; }
|
| 62 |
+
#pm-pages > .tabitem > .column { gap: 0 !important; }
|
| 63 |
+
#pm-site { max-width: 1440px; margin: 0 auto !important; padding: 0 clamp(16px, 4vw, 40px) 56px !important; gap: 0 !important; }
|
| 64 |
+
#pm-site .html-container, #pm-site .html-container > .prose { padding: 0 !important; }
|
| 65 |
+
#pm-site .prose { font-family: var(--pm-f-body); font-size: 17px; line-height: 1.55; color: var(--pm-ink); }
|
| 66 |
+
#pm-site a { color: inherit; }
|
| 67 |
+
#pm-site :focus-visible { outline: 2px solid var(--pm-accent-2); outline-offset: 2px; }
|
| 68 |
+
#pm-site code { font-family: var(--pm-f-mono); font-size: .92em; background: none; padding: 0; color: var(--pm-ink-2); }
|
| 69 |
+
|
| 70 |
+
.pm-label { font: 600 12px/1.6 var(--pm-f-body); letter-spacing: .16em; text-transform: uppercase; }
|
| 71 |
+
#pm-site .pm-title { font-family: var(--pm-f-display); font-weight: 700; font-size: 28px; letter-spacing: -.02em; line-height: 1.15; margin: 0; color: var(--pm-ink); text-wrap: balance; }
|
| 72 |
+
|
| 73 |
+
/* ---------- top bar ---------- */
|
| 74 |
+
.pm-nav { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-block: 20px; }
|
| 75 |
+
#pm-site .pm-logo { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--pm-head); border-radius: 6px; }
|
| 76 |
+
#pm-site .pm-logo img { width: 34px; height: 34px; display: block; margin: 0; }
|
| 77 |
+
.pm-logo b { font: 800 22px/1 var(--pm-f-display); letter-spacing: -.03em; }
|
| 78 |
+
.pm-nav-right { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 10px 24px; }
|
| 79 |
+
.pm-nav-links { display: flex; gap: 24px; font-size: 16.5px; }
|
| 80 |
+
#pm-site .pm-nav-links a { color: var(--pm-ink-2); text-decoration: none; }
|
| 81 |
+
#pm-site .pm-nav-links a:hover, #pm-site .pm-nav-links a.pm-active { color: var(--pm-ink); text-decoration: underline; text-underline-offset: 4px; }
|
| 82 |
+
@media (max-width: 760px) { .pm-nav-links { display: none; } }
|
| 83 |
+
|
| 84 |
+
#pm-site .pm-btn {
|
| 85 |
+
appearance: none; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 10px;
|
| 86 |
+
background: var(--pm-accent); color: var(--pm-accent-ink); border: 2px solid var(--pm-btn-edge); border-radius: 999px;
|
| 87 |
+
font: 600 14.5px/1.3 var(--pm-f-body); letter-spacing: .01em; padding: 10px 18px; min-height: 44px; white-space: nowrap;
|
| 88 |
+
transition: transform 120ms ease, filter 120ms ease;
|
| 89 |
+
}
|
| 90 |
+
#pm-site .pm-btn:hover { transform: translateY(-1px); filter: brightness(1.12); }
|
| 91 |
+
#pm-site .pm-btn:active { transform: translateY(1px); }
|
| 92 |
+
#pm-site .pm-btn--outline { background: transparent; color: var(--pm-head); }
|
| 93 |
+
@media (prefers-reduced-motion: reduce) { #pm-site .pm-btn { transition: none; } #pm-site .pm-btn:hover, #pm-site .pm-btn:active { transform: none; } }
|
| 94 |
+
|
| 95 |
+
/* ---------- hero ---------- */
|
| 96 |
+
.pm-hero { display: grid; grid-template-columns: minmax(0, 1.08fr) minmax(0, .92fr); gap: 28px 44px; align-items: center; padding-block: 14px 8px; }
|
| 97 |
+
#pm-site .pm-eyebrow { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 0; color: var(--pm-accent-2); }
|
| 98 |
+
.pm-updated { font: 500 13px/1 var(--pm-f-mono); color: var(--pm-ink-3); }
|
| 99 |
+
.pm-ver { font: 600 12.5px/1 var(--pm-f-mono); color: var(--pm-accent-ink); background: var(--pm-accent); padding: 6px 9px; border-radius: 999px; }
|
| 100 |
+
.pm-ver--closed { color: var(--pm-accent-text); background: transparent; box-shadow: inset 0 0 0 1.5px var(--pm-mark); }
|
| 101 |
+
#pm-site .pm-notice { margin: 0 0 8px; padding: 12px 16px; border: 1px solid var(--pm-line); border-left: 3px solid var(--pm-mark); border-radius: 12px; background: var(--pm-surface); font-size: 15px; color: var(--pm-ink-2); }
|
| 102 |
+
#pm-site .pm-notice b { color: var(--pm-ink); }
|
| 103 |
+
#pm-site .pm-notice a { color: var(--pm-ink); text-decoration: underline; text-underline-offset: 3px; }
|
| 104 |
+
#pm-site .pm-ver-link { font-size: 14px; color: var(--pm-ink-2); text-decoration: underline; text-underline-offset: 3px; }
|
| 105 |
+
#pm-site .pm-ver-link:hover { color: var(--pm-ink); }
|
| 106 |
+
#pm-site .pm-hero h1 {
|
| 107 |
+
font-family: var(--pm-f-display); font-weight: 800; font-size: clamp(58px, 8.6vw, 112px); line-height: .92;
|
| 108 |
+
letter-spacing: -.03em; margin: 14px 0 10px; color: var(--pm-head);
|
| 109 |
+
}
|
| 110 |
+
#pm-site .pm-expand { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; margin: 0 0 22px; font-size: 20px; color: var(--pm-ink-3); }
|
| 111 |
+
.pm-expand .pm-xp { font-weight: 500; letter-spacing: .01em; color: var(--pm-ink-2); }
|
| 112 |
+
.pm-expand b { color: var(--pm-head); font-weight: 800; }
|
| 113 |
+
#pm-site .pm-lede { font-size: 19.5px; line-height: 1.55; color: var(--pm-ink-2); max-width: 34em; margin: 0 0 22px; }
|
| 114 |
+
#pm-site .pm-lede a { color: var(--pm-ink); text-decoration: underline; text-underline-offset: 3px; }
|
| 115 |
+
#pm-site .pm-stats { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
| 116 |
+
#pm-site .pm-stats li { margin: 0; font: 500 14px/1 var(--pm-f-mono); color: var(--pm-ink-2); border: 1px solid var(--pm-line); background: var(--pm-surface); padding: 8px 12px; border-radius: 999px; }
|
| 117 |
+
#pm-site .pm-stats li::marker { content: none; }
|
| 118 |
+
.pm-stats b { color: var(--pm-ink); font-weight: 600; }
|
| 119 |
+
.pm-art-wrap { justify-self: end; width: 100%; max-width: 520px; }
|
| 120 |
+
.pm-art { padding: 14px; border: 1px solid var(--pm-line); border-radius: 14px; background: var(--pm-surface); }
|
| 121 |
+
.pm-hero-art { display: block; width: 100%; height: auto; }
|
| 122 |
+
.pm-hero-art text { font-family: var(--pm-f-body); }
|
| 123 |
+
.pm-art-h { fill: var(--pm-ink); font-size: 11.5px; font-weight: 600; }
|
| 124 |
+
.pm-art-c { fill: var(--pm-ink-3); font-size: 9.5px; font-weight: 500; }
|
| 125 |
+
.pm-art-t { fill: var(--pm-ink); font-size: 10.5px; font-weight: 500; }
|
| 126 |
+
.pm-art-patient { fill: var(--pm-mark); }
|
| 127 |
+
.pm-art-a { fill: var(--pm-accent-2); }
|
| 128 |
+
.pm-art-b { fill: var(--pm-accent); }
|
| 129 |
+
.pm-art-box { fill: var(--pm-surface-2); stroke: var(--pm-accent-2); stroke-width: 1.2; }
|
| 130 |
+
.pm-art-edge { stroke: var(--pm-accent-2); stroke-width: .6; opacity: .45; }
|
| 131 |
+
.pm-art-node { fill: var(--pm-accent-2); }
|
| 132 |
+
.pm-art-line { fill: none; stroke: var(--pm-ink-3); stroke-width: 1.2; }
|
| 133 |
+
.pm-art-head { fill: var(--pm-ink-3); }
|
| 134 |
+
.pm-art-pill { fill: var(--pm-surface-2); stroke: var(--pm-line); stroke-width: 1; }
|
| 135 |
+
@media (max-width: 1000px) { .pm-hero { grid-template-columns: 1fr; } .pm-art-wrap { justify-self: start; max-width: 520px; } }
|
| 136 |
+
|
| 137 |
+
/* ---------- view pills: Gradio radios drawn as segmented pills ---------- */
|
| 138 |
+
#pm-site .pm-board-sub { margin: 40px 0 14px; padding-top: 22px; border-top: 1px solid var(--pm-line); font-size: 14.5px; color: var(--pm-ink-3); }
|
| 139 |
+
#pm-controls { display: block !important; margin: 0 0 20px; }
|
| 140 |
+
#pm-controls > .form {
|
| 141 |
+
display: flex !important; flex-wrap: wrap !important; align-items: center; gap: 12px 22px !important;
|
| 142 |
+
width: 100% !important; background: transparent !important; border: 0 !important; box-shadow: none !important; overflow: visible !important;
|
| 143 |
+
}
|
| 144 |
+
#pm-controls fieldset.pm-seg { flex: 0 0 auto !important; width: auto !important; min-width: 0 !important; }
|
| 145 |
+
#pm-controls fieldset.pm-seg--main { order: 1; flex: 1 1 100% !important; }
|
| 146 |
+
#pm-controls fieldset.pm-seg--line { order: 2; flex: 1 1 100% !important; }
|
| 147 |
+
#pm-controls fieldset.pm-seg--minor { order: 3; }
|
| 148 |
+
#pm-controls .pm-seg:not(.pm-seg--minor) [data-testid="block-info"] { flex: none; width: 168px; }
|
| 149 |
+
#pm-site fieldset.pm-seg {
|
| 150 |
+
display: flex !important; align-items: center; flex-wrap: wrap; gap: 8px; margin: 0 !important;
|
| 151 |
+
padding: 0 !important; border: 0 !important; background: transparent !important; box-shadow: none !important; overflow: visible !important;
|
| 152 |
+
}
|
| 153 |
+
#pm-site .pm-seg [data-testid="block-info"] {
|
| 154 |
+
margin: 0 !important; padding: 0 !important; font: 600 12px/1.2 var(--pm-f-body) !important;
|
| 155 |
+
letter-spacing: .14em; text-transform: uppercase; color: var(--pm-ink-3) !important; background: none !important; border: 0 !important;
|
| 156 |
+
}
|
| 157 |
+
#pm-site .pm-seg .wrap:has(> label) {
|
| 158 |
+
display: inline-flex !important; flex-wrap: wrap; gap: 3px !important; padding: 3px !important;
|
| 159 |
+
border: 1px solid var(--pm-line); border-radius: 999px; background: var(--pm-bg);
|
| 160 |
+
}
|
| 161 |
+
#pm-site .pm-seg label {
|
| 162 |
+
position: relative; display: inline-flex !important; align-items: center; gap: 8px; margin: 0 !important;
|
| 163 |
+
padding: 8px 12px !important; min-height: 34px; border: 0 !important; border-radius: 999px !important;
|
| 164 |
+
background: transparent !important; box-shadow: none !important; cursor: pointer; white-space: nowrap;
|
| 165 |
+
font: 500 13.5px/1 var(--pm-f-mono) !important; color: var(--pm-ink-2) !important;
|
| 166 |
+
}
|
| 167 |
+
#pm-site .pm-seg label:hover { color: var(--pm-ink) !important; background: var(--pm-surface-2) !important; }
|
| 168 |
+
#pm-site .pm-seg label.selected, #pm-site .pm-seg label:has(input:checked) { background: var(--pm-ink) !important; color: var(--pm-bg) !important; }
|
| 169 |
+
#pm-site .pm-seg input[type="radio"] { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; pointer-events: none; }
|
| 170 |
+
#pm-site .pm-seg label > span { margin: 0 !important; color: inherit !important; font: inherit !important; }
|
| 171 |
+
#pm-site .pm-seg--main label { font-size: 16px !important; font-weight: 600 !important; padding: 12px 20px !important; min-height: 46px; }
|
| 172 |
+
#pm-site .pm-seg--main [data-testid="block-info"] { color: var(--pm-ink) !important; }
|
| 173 |
+
#pm-site .pm-seg--minor label { font-size: 12.5px !important; padding: 6px 10px !important; min-height: 28px; }
|
| 174 |
+
#pm-site .pm-seg--minor [data-testid="block-info"] { font-style: italic !important; }
|
| 175 |
+
|
| 176 |
+
/* ---------- tables ---------- */
|
| 177 |
+
.pm-card { background: var(--pm-surface); border: 1px solid var(--pm-line); border-radius: var(--pm-r-card); min-width: 0; }
|
| 178 |
+
.pm-tbl-wrap { overflow: auto; border-radius: 0 0 var(--pm-r-card) var(--pm-r-card); }
|
| 179 |
+
.pm-card-head {
|
| 180 |
+
display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 18px;
|
| 181 |
+
padding: 14px 14px 12px; border-bottom: 1px solid var(--pm-line); border-radius: var(--pm-r-card) var(--pm-r-card) 0 0; background: var(--pm-bg);
|
| 182 |
+
}
|
| 183 |
+
.pm-view { font: 700 12.5px/1.3 var(--pm-f-body); letter-spacing: .14em; text-transform: uppercase; color: var(--pm-ink-2); }
|
| 184 |
+
.pm-view-n { font: 500 13.5px/1.3 var(--pm-f-mono); color: var(--pm-ink-3); }
|
| 185 |
+
.pm-view-n b { font-weight: 700; color: var(--pm-ink); }
|
| 186 |
+
.pm-view-n b:first-child { color: var(--pm-accent-text); font-size: 15px; }
|
| 187 |
+
.pm-table-wrap { width: 100%; overflow-x: auto; border: 1px solid var(--pm-line); border-radius: var(--pm-r-card); background: var(--pm-surface); }
|
| 188 |
+
#pm-site table.pm-table {
|
| 189 |
+
width: 100%; margin: 0; border: 0; border-collapse: collapse; table-layout: auto;
|
| 190 |
+
font-family: var(--pm-f-body); font-size: 14.5px; font-variant-numeric: tabular-nums; background: transparent;
|
| 191 |
+
}
|
| 192 |
+
#pm-site .pm-table td.pm-num, #pm-site .pm-table td.pm-metric, #pm-site .pm-ranked td.pm-name { font-family: var(--pm-f-mono); font-size: 14px; }
|
| 193 |
+
#pm-site .pm-table th {
|
| 194 |
+
padding: 16px 8px 11px; border: 0; border-bottom: 1px solid var(--pm-line); background: var(--pm-surface);
|
| 195 |
+
font: 700 11.5px/1.2 var(--pm-f-body); letter-spacing: .1em; text-transform: uppercase; color: var(--pm-ink-2);
|
| 196 |
+
text-align: left; vertical-align: bottom; white-space: nowrap;
|
| 197 |
+
}
|
| 198 |
+
#pm-site .pm-table th small { display: block; margin-top: 3px; font: 500 12px/1.2 var(--pm-f-body); letter-spacing: .02em; text-transform: none; color: var(--pm-ink-3); }
|
| 199 |
+
#pm-site .pm-table td { padding: 11px 8px; border: 0; border-bottom: 1px solid var(--pm-line); vertical-align: middle; color: var(--pm-ink-2); background: transparent; }
|
| 200 |
+
#pm-site .pm-table tr { border: 0; background: transparent; }
|
| 201 |
+
#pm-site .pm-table tbody tr:last-child td { border-bottom: 0; }
|
| 202 |
+
#pm-site .pm-table tbody tr:hover td { background: var(--pm-surface-2); }
|
| 203 |
+
#pm-site .pm-table th.pm-sort { cursor: pointer; user-select: none; }
|
| 204 |
+
#pm-site .pm-table th.pm-sort:hover { color: var(--pm-ink); }
|
| 205 |
+
#pm-site .pm-table th.pm-sort[data-sort-direction="asc"]::after { content: " ↑"; color: var(--pm-mark); }
|
| 206 |
+
#pm-site .pm-table th.pm-sort[data-sort-direction="desc"]::after { content: " ↓"; color: var(--pm-mark); }
|
| 207 |
+
#pm-site .pm-table .pm-r, #pm-site .pm-table td.pm-num { text-align: right; }
|
| 208 |
+
#pm-site .pm-table td.pm-num { color: var(--pm-ink-2); }
|
| 209 |
+
#pm-site .pm-table td.pm-key { color: var(--pm-ink); font-weight: 600; }
|
| 210 |
+
#pm-site .pm-table td.pm-best, #pm-site .pm-table td[style*="font-weight:700"] { color: var(--pm-ink); font-weight: 700; }
|
| 211 |
+
#pm-site .pm-table .pm-dim, #pm-site .pm-table .pm-num--dim { color: var(--pm-mark-muted); }
|
| 212 |
+
#pm-site .pm-table td.pm-strong { font-family: var(--pm-f-body); color: var(--pm-ink); }
|
| 213 |
+
#pm-site .pm-table td.pm-name { font-weight: 600; color: var(--pm-ink); }
|
| 214 |
+
#pm-site .pm-table td.pm-metric { font-size: 12px; color: var(--pm-ink-3); }
|
| 215 |
+
|
| 216 |
+
/* the ranking: one line per model, the top three framed as on STRABLE */
|
| 217 |
+
#pm-site .pm-ranked td { white-space: nowrap; }
|
| 218 |
+
#pm-site .pm-ranked th { white-space: normal; }
|
| 219 |
+
#pm-site .pm-ranked th small { white-space: nowrap; }
|
| 220 |
+
#pm-site .pm-ranked th:first-child, #pm-site .pm-ranked td:first-child { padding-left: 14px; }
|
| 221 |
+
#pm-site .pm-ranked th:last-child, #pm-site .pm-ranked td:last-child { padding-right: 14px; }
|
| 222 |
+
#pm-site .pm-ranked tr.pm-top td:first-child { border-left: 2px solid var(--pm-mark); }
|
| 223 |
+
#pm-site .pm-ranked tr.pm-top td:last-child { border-right: 2px solid var(--pm-mark); }
|
| 224 |
+
#pm-site .pm-ranked tr.pm-top-first td { border-top: 2px solid var(--pm-mark); }
|
| 225 |
+
#pm-site .pm-ranked tr.pm-top-last td { border-bottom: 2px solid var(--pm-mark); }
|
| 226 |
+
#pm-site .pm-ranked[data-sorted="true"] tr.pm-top td { border-left: 0; border-right: 0; border-top: 0; border-bottom: 1px solid var(--pm-line); }
|
| 227 |
+
#pm-site .pm-ranked .pm-rank { width: 1%; font: 700 14px/1 var(--pm-f-body); letter-spacing: .03em; color: var(--pm-ink-3); }
|
| 228 |
+
#pm-site .pm-ranked .pm-inst { font-family: var(--pm-f-body); font-size: 14.5px; max-width: 9.5em; overflow: hidden; text-overflow: ellipsis; }
|
| 229 |
+
.pm-pipe { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
| 230 |
+
.pm-lrn { color: var(--pm-ink); font-weight: 600; white-space: nowrap; }
|
| 231 |
+
#pm-site .pm-model-link { color: var(--pm-ink); text-decoration: underline; text-decoration-color: var(--pm-line); text-underline-offset: 4px; }
|
| 232 |
+
#pm-site .pm-model-link:hover { text-decoration-color: var(--pm-ink); }
|
| 233 |
+
.pm-leader { flex: 1 1 12px; min-width: 12px; align-self: center; border-bottom: 2px dotted var(--pm-line); transform: translateY(3px); }
|
| 234 |
+
.pm-tag { flex: none; font: 600 9.5px/1 var(--pm-f-mono); letter-spacing: .08em; text-transform: uppercase; color: var(--pm-ink-3); border: 1px solid var(--pm-line); padding: 3px 4px; border-radius: 999px; white-space: nowrap; }
|
| 235 |
+
.pm-tag--hvg { color: var(--pm-accent-2); border-color: var(--pm-accent-2); }
|
| 236 |
+
.pm-tag--author { color: var(--pm-accent-text); border-color: var(--pm-accent-text); }
|
| 237 |
+
.pm-tag--base { color: var(--pm-ink-2); border-style: dashed; border-color: var(--pm-ink-3); }
|
| 238 |
+
#pm-site .pm-note { margin: 12px 0 0; font-size: 13px; color: var(--pm-ink-3); max-width: 64em; }
|
| 239 |
+
#pm-site .pm-empty { margin: 0; padding: 22px 18px; font-size: 15px; color: var(--pm-ink-2); }
|
| 240 |
+
|
| 241 |
+
/* the window of the Cite button, in the top bar */
|
| 242 |
+
#pm-cite-dialog { width: min(640px, calc(100vw - 32px)); }
|
| 243 |
+
#pm-cite-dialog b { color: var(--pm-ink); }
|
| 244 |
+
#pm-cite-dialog pre { margin: 14px 0 0; overflow-x: auto; font: 12.5px/1.55 var(--pm-f-mono); white-space: pre; }
|
| 245 |
+
|
| 246 |
+
/* the per-task scores, folded under the ranking */
|
| 247 |
+
#pm-site .pm-more { margin-top: 36px; }
|
| 248 |
+
#pm-site .pm-more > summary { display: flex; align-items: baseline; gap: 14px; cursor: pointer; list-style: none; }
|
| 249 |
+
#pm-site .pm-more > summary::-webkit-details-marker { display: none; }
|
| 250 |
+
#pm-site .pm-more > summary::before { content: "+"; width: 1em; font: 600 24px/1 var(--pm-f-mono); color: var(--pm-mark); }
|
| 251 |
+
#pm-site .pm-more[open] > summary::before { content: "−"; }
|
| 252 |
+
.pm-more-n { font: 500 13.5px/1 var(--pm-f-mono); color: var(--pm-ink-3); }
|
| 253 |
+
#pm-site .pm-more > .pm-note { margin: 8px 0 14px; }
|
| 254 |
+
#pm-site .pm-more .pm-table td { white-space: nowrap; padding: 9px 10px; font-size: 13.5px; }
|
| 255 |
+
#pm-site .pm-more .pm-table th { padding: 14px 10px 10px; font-size: 11.5px; }
|
| 256 |
+
#pm-site .pm-more .pm-table th:first-child, #pm-site .pm-more .pm-table td:first-child { position: sticky; left: 0; z-index: 1; background: var(--pm-surface); }
|
| 257 |
+
#pm-site .pm-more .pm-table tbody tr:hover td:first-child { background: var(--pm-surface-2); }
|
| 258 |
+
#pm-site .pm-more .pm-task-text { display: inline-block; max-width: 30em; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
|
| 259 |
+
#pm-site .pm-table .pm-sd { display: inline; margin-left: 4px; font-size: 11px; color: var(--pm-ink-3); }
|
| 260 |
+
|
| 261 |
+
/* tooltips */
|
| 262 |
+
.pm-score-guide, .pm-task-guide { position: relative; display: inline-block; cursor: help; }
|
| 263 |
+
.pm-score-guide::after, .pm-task-guide::after {
|
| 264 |
+
content: attr(data-tooltip); position: absolute; z-index: 10; bottom: calc(100% + 7px); width: max-content;
|
| 265 |
+
max-width: min(300px, 70vw); padding: 8px 12px; border-radius: 10px; background: var(--pm-ink); color: var(--pm-bg);
|
| 266 |
+
font: 400 13px/1.45 var(--pm-f-body); text-align: left; white-space: normal; letter-spacing: 0; text-transform: none;
|
| 267 |
+
opacity: 0; pointer-events: none; transform: translateY(3px); transition: opacity .12s ease, transform .12s ease;
|
| 268 |
+
}
|
| 269 |
+
.pm-score-guide::after { right: 0; }
|
| 270 |
+
.pm-task-guide::after { left: 0; width: min(360px, 72vw); }
|
| 271 |
+
.pm-score-guide:hover::after, .pm-score-guide:focus::after, .pm-task-guide:hover::after, .pm-task-guide:focus::after { opacity: 1; transform: translateY(0); }
|
| 272 |
+
.pm-table tbody tr:first-child .pm-task-guide::after, .pm-table tbody tr:first-child .pm-score-guide::after { top: calc(100% + 7px); bottom: auto; }
|
| 273 |
+
.pm-public-chip { display: inline-flex; margin-left: 6px; font: 600 10px/1 var(--pm-f-mono); letter-spacing: .1em; text-transform: uppercase; color: var(--pm-accent-2); border: 1px solid var(--pm-accent-2); padding: 3px 5px; border-radius: 999px; white-space: nowrap; vertical-align: 1px; }
|
| 274 |
+
|
| 275 |
+
/* ---------- version history ---------- */
|
| 276 |
+
.pm-history { margin-top: 44px; display: grid; gap: 14px; }
|
| 277 |
+
#pm-site .pm-history-sub { margin: 6px 0 0; color: var(--pm-ink-3); font-size: 14.5px; }
|
| 278 |
+
.pm-history-sub span { font-family: var(--pm-f-mono); color: var(--pm-ink-2); }
|
| 279 |
+
#pm-site .pm-history-sub a { color: var(--pm-ink-2); text-decoration: underline; text-underline-offset: 3px; }
|
| 280 |
+
#pm-site .pm-history ol { list-style: none; margin: 0; padding: 0; display: grid; border: 1px solid var(--pm-line); border-radius: var(--pm-r-card); background: var(--pm-surface); }
|
| 281 |
+
#pm-site .pm-history li { display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; align-items: baseline; gap: 12px; margin: 0; padding: 13px 16px; border-bottom: 1px solid var(--pm-line); font-size: 16px; color: var(--pm-ink-2); }
|
| 282 |
+
#pm-site .pm-history li.pm-on { box-shadow: inset 3px 0 0 var(--pm-mark); }
|
| 283 |
+
#pm-site .pm-history li p { margin: 0; }
|
| 284 |
+
#pm-site .pm-history .pm-ver-meta { margin: 0 0 4px; font: 500 13px/1.5 var(--pm-f-mono); color: var(--pm-ink-3); }
|
| 285 |
+
#pm-site .pm-history li > a { font-size: 14.5px; color: var(--pm-ink); text-decoration: underline; text-underline-offset: 3px; white-space: nowrap; }
|
| 286 |
+
.pm-chip { display: inline-flex; margin-right: 4px; padding: 2px 7px; border-radius: 999px; font: 600 10.5px/1.4 var(--pm-f-mono); letter-spacing: .08em; text-transform: uppercase; }
|
| 287 |
+
.pm-chip--current { color: var(--pm-accent-ink); background: var(--pm-accent); }
|
| 288 |
+
.pm-chip--closed { color: var(--pm-ink-2); box-shadow: inset 0 0 0 1px var(--pm-ink-3); }
|
| 289 |
+
#pm-site .pm-history li::marker { content: none; }
|
| 290 |
+
#pm-site .pm-history li:last-child { border-bottom: 0; }
|
| 291 |
+
.pm-history li b { font: 600 14px/1.7 var(--pm-f-mono); color: var(--pm-ink); }
|
| 292 |
+
|
| 293 |
+
/* ---------- closing panel and credit ---------- */
|
| 294 |
+
.pm-ask { margin-top: 44px; display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 16px 44px; padding: 22px 28px; border-radius: var(--pm-r-card); background: var(--pm-panel); color: var(--pm-panel-ink); border: 1px solid var(--pm-panel-line); }
|
| 295 |
+
@media (max-width: 860px) { .pm-ask { grid-template-columns: 1fr; padding: 24px 18px; } }
|
| 296 |
+
#pm-site .pm-ask .pm-label { color: var(--pm-panel-ink-2); margin: 0; }
|
| 297 |
+
#pm-site .pm-ask h2 { font-family: var(--pm-f-display); font-weight: 700; font-size: clamp(24px, 2.6vw, 32px); letter-spacing: -.02em; line-height: 1.1; margin: 10px 0 0; color: var(--pm-panel-ink); text-wrap: balance; }
|
| 298 |
+
.pm-ask-go { display: grid; gap: 14px; align-content: center; justify-items: start; min-width: 0; }
|
| 299 |
+
#pm-site .pm-ask-go p { margin: 0; color: var(--pm-panel-ink-2); max-width: 34em; }
|
| 300 |
+
#pm-site .pm-ask .pm-btn { background: var(--pm-panel-ink); color: var(--pm-panel); border-color: var(--pm-panel-ink); }
|
| 301 |
+
|
| 302 |
+
#pm-site .pm-credit { margin: 32px 0 0; text-align: center; font-size: 13.5px; color: var(--pm-ink-3); }
|
| 303 |
+
#pm-site .pm-credit a { color: var(--pm-ink-2); text-decoration: underline; text-underline-offset: 3px; }
|
| 304 |
+
|
| 305 |
+
/* ---------- other pages: Tasks, Submit, Contribute, Method ---------- */
|
| 306 |
+
.pm-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; padding: 18px 0 22px; border-bottom: 1px solid var(--pm-line); }
|
| 307 |
+
#pm-site .pm-head h1 { margin: 0; font-family: var(--pm-f-display); font-weight: 800; font-size: clamp(40px, 5vw, 64px); line-height: .95; letter-spacing: -.03em; color: var(--pm-head); }
|
| 308 |
+
#pm-site .pm-head p { margin: 12px 0 0; font-size: 17px; color: var(--pm-ink-2); max-width: 46em; }
|
| 309 |
+
#pm-site .pm-body { padding: 24px 0 0 !important; }
|
| 310 |
+
.pm-prose { max-width: 860px; }
|
| 311 |
+
#pm-site .pm-prose :where(h1, h2, h3) { font-family: var(--pm-f-display); font-weight: 700; letter-spacing: -.02em; color: var(--pm-ink); }
|
| 312 |
+
#pm-site .pm-prose a { color: var(--pm-ink); text-decoration: underline; text-underline-offset: 3px; }
|
| 313 |
+
#pm-site .pm-caption { margin: 14px 0 0; font-size: 14px; color: var(--pm-ink-3); }
|
| 314 |
+
.pm-over { margin: 0; font: 600 12px/1.4 var(--pm-f-body); letter-spacing: .14em; text-transform: uppercase; color: var(--pm-ink-3); }
|
| 315 |
+
.pm-badge { display: inline-flex; align-items: center; padding: 3px 6px; border: 1px solid var(--pm-line); border-radius: 999px; font: 500 12px/1 var(--pm-f-mono); white-space: nowrap; }
|
| 316 |
+
|
| 317 |
+
/* submit page: the steps on the left, the form on the right */
|
| 318 |
+
#pm-submit { display: grid !important; grid-template-columns: minmax(0, 1fr) minmax(340px, 460px); gap: 28px 56px !important; align-items: start; }
|
| 319 |
+
#pm-submit > .column { width: auto !important; min-width: 0 !important; }
|
| 320 |
+
@media (max-width: 1000px) { #pm-submit { grid-template-columns: minmax(0, 1fr); } }
|
| 321 |
+
#pm-site .pm-submit-text { max-width: 68ch; }
|
| 322 |
+
#pm-site .pm-prose .prose { font-size: 16.5px; line-height: 1.6; color: var(--pm-ink-2); }
|
| 323 |
+
#pm-site .pm-prose .prose strong { color: var(--pm-ink); }
|
| 324 |
+
#pm-site .pm-prose .prose h3 { margin: 34px 0 10px; font: 700 22px/1.2 var(--pm-f-display); letter-spacing: -.01em; color: var(--pm-ink); }
|
| 325 |
+
#pm-site .pm-prose .prose > h3:first-child { margin-top: 4px; }
|
| 326 |
+
#pm-site .pm-prose .prose p { margin: 0 0 14px; }
|
| 327 |
+
#pm-site .pm-submit-text ul { display: grid; gap: 8px; margin: 10px 0 16px; padding: 0; list-style: none; }
|
| 328 |
+
#pm-site .pm-submit-text ul > li { margin: 0; padding: 0; }
|
| 329 |
+
#pm-site .pm-submit-text ul > li::marker { content: none; }
|
| 330 |
+
#pm-site .pm-prose ol { margin: 10px 0 16px; padding-left: 1.4em; }
|
| 331 |
+
#pm-site .pm-prose ol > li { margin: 0 0 10px; padding-left: 4px; }
|
| 332 |
+
#pm-site .prose :not(pre) > code { padding: 1px 6px; border: 0; border-radius: 6px; background: var(--pm-surface-2); box-shadow: none; color: var(--pm-ink); font-size: .86em; }
|
| 333 |
+
#pm-site .prose .code_wrap, #pm-site .prose pre { background: var(--pm-surface) !important; }
|
| 334 |
+
#pm-site .prose pre { margin: 12px 0 18px; padding: 14px 16px !important; border: 1px solid var(--pm-line); border-radius: 12px; }
|
| 335 |
+
#pm-site .prose pre code { padding: 0; background: transparent; color: var(--pm-ink-2); font-size: 13px; line-height: 1.6; }
|
| 336 |
+
#pm-site .prose hr { margin: 28px 0; border: 0; border-top: 1px solid var(--pm-line); }
|
| 337 |
+
|
| 338 |
+
#pm-form { display: flex; flex-direction: column; gap: 2px !important; padding: 22px !important; background: var(--pm-surface); border: 1px solid var(--pm-line); border-radius: var(--pm-r-card); }
|
| 339 |
+
#pm-form .form { background: transparent !important; border: 0 !important; box-shadow: none !important; gap: 4px !important; }
|
| 340 |
+
#pm-form .block { padding: 10px 0 !important; border: 0 !important; background: transparent !important; box-shadow: none !important; }
|
| 341 |
+
#pm-form [data-testid="block-info"], #pm-site #pm-form .pm-seg--form [data-testid="block-info"] {
|
| 342 |
+
display: block; margin: 0 0 2px !important; font: 600 15px/1.35 var(--pm-f-body) !important;
|
| 343 |
+
letter-spacing: 0 !important; text-transform: none !important; color: var(--pm-ink) !important;
|
| 344 |
+
}
|
| 345 |
+
#pm-form [data-testid="block-info"] + div .prose, #pm-form [data-testid="block-info"] + div .prose p {
|
| 346 |
+
margin: 0 0 8px !important; font-size: 13.5px !important; line-height: 1.4 !important; color: var(--pm-ink-3) !important;
|
| 347 |
+
}
|
| 348 |
+
#pm-site #pm-form fieldset.pm-seg--form { display: block !important; }
|
| 349 |
+
#pm-site #pm-form .pm-hint .prose p { margin: 0; font-size: 13.5px; color: var(--pm-ink-3); }
|
| 350 |
+
#pm-site #pm-form .pm-hint .prose pre { margin: 0; }
|
| 351 |
+
#pm-form input[type="text"], #pm-form textarea {
|
| 352 |
+
border: 1px solid var(--pm-line) !important; border-radius: 10px !important; background: var(--pm-bg) !important;
|
| 353 |
+
box-shadow: none !important; font-family: var(--pm-f-body) !important; font-size: 15px !important; color: var(--pm-ink) !important;
|
| 354 |
+
scrollbar-width: none;
|
| 355 |
+
}
|
| 356 |
+
#pm-form textarea::-webkit-scrollbar { display: none; }
|
| 357 |
+
#pm-form input[type="text"]:focus, #pm-form textarea:focus { outline: 2px solid var(--pm-accent-2); outline-offset: 0; }
|
| 358 |
+
#pm-form input[type="checkbox"] { accent-color: var(--pm-accent); }
|
| 359 |
+
#pm-site #pm-form button.huggingface {
|
| 360 |
+
width: 100%; justify-content: center; min-height: 46px; margin-bottom: 6px; border: 1px solid var(--pm-line) !important;
|
| 361 |
+
border-radius: 999px !important; background: var(--pm-bg) !important; box-shadow: none !important;
|
| 362 |
+
font: 600 15px/1.3 var(--pm-f-body) !important; color: var(--pm-ink) !important;
|
| 363 |
+
}
|
| 364 |
+
#pm-site #pm-form button.huggingface:hover { background: var(--pm-surface-2) !important; }
|
| 365 |
+
#pm-form .block:has(input[type="file"]) { margin-top: 6px; padding: 14px !important; border: 2px dashed var(--pm-line) !important; border-radius: 14px !important; background: var(--pm-bg) !important; }
|
| 366 |
+
#pm-form [data-testid="block-label"] {
|
| 367 |
+
position: static !important; display: inline-flex; align-items: center; gap: 6px; margin: 0 0 6px !important; padding: 0 !important;
|
| 368 |
+
border: 0 !important; background: none !important; box-shadow: none !important;
|
| 369 |
+
font: 600 15px/1.35 var(--pm-f-body) !important; color: var(--pm-ink) !important;
|
| 370 |
+
}
|
| 371 |
+
#pm-form .block:has(input[type="file"]) button:has(input[type="file"]) { height: 120px !important; min-height: 0 !important; color: var(--pm-ink-3); }
|
| 372 |
+
#pm-form label:has(input[type="checkbox"]) > span { font: 600 15px/1.35 var(--pm-f-body) !important; color: var(--pm-ink) !important; }
|
| 373 |
+
#pm-form label:has(input[type="checkbox"]) + div .prose, #pm-form label:has(input[type="checkbox"]) + div .prose p {
|
| 374 |
+
margin: 2px 0 0 !important; font-size: 13.5px !important; line-height: 1.4 !important; color: var(--pm-ink-3) !important;
|
| 375 |
+
}
|
| 376 |
+
#pm-site #pm-evaluate { width: 100%; justify-content: center; min-height: 46px; margin-top: 10px; border-radius: 999px !important; border: 2px solid var(--pm-btn-edge) !important; background: var(--pm-accent) !important; color: var(--pm-accent-ink) !important; font: 600 15px/1.3 var(--pm-f-body) !important; }
|
| 377 |
+
#pm-site #pm-evaluate:hover { filter: brightness(1.12); }
|
| 378 |
+
|
| 379 |
+
/* the windows: evaluation confirmation and Cite */
|
| 380 |
+
.pm-dialog { width: min(440px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--pm-line); border-radius: var(--pm-r-card); background: var(--pm-surface); color: var(--pm-ink); box-shadow: 0 24px 64px rgb(19 39 63 / .24); }
|
| 381 |
+
#pm-site .pm-dialog { margin: auto !important; }
|
| 382 |
+
.pm-dialog::backdrop { background: rgb(19 39 63 / .45); }
|
| 383 |
+
.pm-dialog-surface { padding: 24px; }
|
| 384 |
+
.pm-dialog h2 { margin: 8px 0 0; font: 700 1.35rem/1.2 var(--pm-f-display); letter-spacing: -.02em; color: var(--pm-ink); }
|
| 385 |
+
.pm-dialog-surface > p:not(.pm-over) { margin: 12px 0 0; font-size: 15px; line-height: 1.6; color: var(--pm-ink-2); }
|
| 386 |
+
.pm-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
|
| 387 |
+
.pm-dialog .pm-btn { appearance: none; cursor: pointer; border-radius: 999px; padding: 9px 16px; font: 600 14px/1.2 var(--pm-f-body); border: 2px solid var(--pm-btn-edge); }
|
| 388 |
+
.pm-dialog .pm-btn--primary { background: var(--pm-accent); color: var(--pm-accent-ink); }
|
| 389 |
+
.pm-dialog .pm-btn--outline { background: transparent; color: var(--pm-head); }
|
| 390 |
+
.pm-evaluation-status { display: flex; align-items: center; gap: 10px; font-size: 14px; line-height: 1.45; color: var(--pm-ink-2); }
|
| 391 |
+
.pm-spinner { width: 18px; height: 18px; flex: none; animation: pm-spin .8s linear infinite; }
|
| 392 |
+
.pm-spinner circle, .pm-spinner path { fill: none; stroke-width: 3; }
|
| 393 |
+
.pm-spinner circle { stroke: var(--pm-line); }
|
| 394 |
+
.pm-spinner path { stroke: var(--pm-mark); stroke-linecap: round; }
|
| 395 |
+
@keyframes pm-spin { to { transform: rotate(360deg); } }
|
| 396 |
+
@media (prefers-reduced-motion: reduce) { .pm-spinner { animation-duration: 1.6s; } }
|
quickstart.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Produce a valid PRIMO submission in one command, then swap in your own model.
|
| 2 |
+
|
| 3 |
+
Downloads one modality's public datasets, at the data commit that the
|
| 4 |
+
leaderboard scores, and writes the file the Submit tab expects. The embedding
|
| 5 |
+
here is deliberately dumb: bulk data uses log2(CPM+1); single-cell data uses
|
| 6 |
+
per-cell log2(CP10K+1), mean pooling by collection sample, then PCA. Replace
|
| 7 |
+
``embed`` with your encoder and nothing else changes.
|
| 8 |
+
|
| 9 |
+
pip install anndata scikit-learn pandas pyarrow pyyaml huggingface_hub
|
| 10 |
+
python quickstart.py --modality bulk-rna --out submission.parquet
|
| 11 |
+
|
| 12 |
+
Standalone on purpose: no import from this Space, so it keeps working if you
|
| 13 |
+
copy the file into your own project. For the same reason, ``DATA_REVISION``
|
| 14 |
+
repeats the data commit of ``versions.yaml``.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import anndata as ad
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
import yaml
|
| 24 |
+
from huggingface_hub import hf_hub_download, snapshot_download
|
| 25 |
+
from sklearn.decomposition import PCA
|
| 26 |
+
|
| 27 |
+
PUBLIC_REPO = "PRIMOmics/primo"
|
| 28 |
+
DATA_REVISION = "0f452b2fc84e544fb1a1fc5bd42017482e07b43c"
|
| 29 |
+
MANIFEST_FILENAME = "datasets.yaml"
|
| 30 |
+
MODALITIES = {"bulk-rna": "bulk RNA", "single-cell-rna": "Single Cell RNA"}
|
| 31 |
+
|
| 32 |
+
DATASET_ID = "dataset_id"
|
| 33 |
+
SAMPLE_ID = "sample_id"
|
| 34 |
+
|
| 35 |
+
TARGET_SUM = 1_000_000
|
| 36 |
+
SINGLE_CELL_TARGET_SUM = 10_000
|
| 37 |
+
N_COMPONENTS = 50
|
| 38 |
+
RANDOM_STATE = 0
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def prediction_ids(adata: ad.AnnData, cells: bool) -> np.ndarray:
|
| 42 |
+
"""Opaque collection-sample ids required in the submission."""
|
| 43 |
+
if cells:
|
| 44 |
+
return pd.unique(adata.obs[SAMPLE_ID].astype(str)).astype(str)
|
| 45 |
+
return adata.obs_names.astype(str).to_numpy()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _log_normalize(x, target_sum: int) -> np.ndarray:
|
| 49 |
+
"""Return dense log2 counts-per-target expression."""
|
| 50 |
+
x = x.toarray() if hasattr(x, "toarray") else np.asarray(x)
|
| 51 |
+
totals = x.sum(axis=1, keepdims=True)
|
| 52 |
+
return np.log2(x / np.where(totals == 0, 1, totals) * target_sum + 1)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _sample_expression(adata: ad.AnnData, cells: bool) -> tuple[np.ndarray, np.ndarray]:
|
| 56 |
+
"""Normalize expression and mean-pool cells into collection samples."""
|
| 57 |
+
ids = prediction_ids(adata, cells)
|
| 58 |
+
if not cells:
|
| 59 |
+
return ids, _log_normalize(adata.X, TARGET_SUM)
|
| 60 |
+
samples = adata.obs[SAMPLE_ID].astype(str).to_numpy()
|
| 61 |
+
pooled = [
|
| 62 |
+
_log_normalize(adata.X[samples == sample_id], SINGLE_CELL_TARGET_SUM).mean(0)
|
| 63 |
+
for sample_id in ids
|
| 64 |
+
]
|
| 65 |
+
return ids, np.asarray(pooled)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def embed(adata: ad.AnnData, cells: bool) -> np.ndarray:
|
| 69 |
+
"""One dataset's expression -> one vector per collection sample. Replace me.
|
| 70 |
+
|
| 71 |
+
``cells`` is true when the rows of ``adata`` are cells, which
|
| 72 |
+
``obs["sample_id"]`` maps to collection samples.
|
| 73 |
+
|
| 74 |
+
``X`` holds raw counts unless ``adata.uns["input_normalization"]`` names another
|
| 75 |
+
scale (FPKM, microarray intensities or author-normalised counts). The
|
| 76 |
+
log2(CPM + 1) below accepts any non-negative scale.
|
| 77 |
+
|
| 78 |
+
Whatever you return, the contract is one finite row per id returned by
|
| 79 |
+
``prediction_ids``. The embedding width may differ between datasets.
|
| 80 |
+
"""
|
| 81 |
+
_, x = _sample_expression(adata, cells)
|
| 82 |
+
k = min(N_COMPONENTS, x.shape[0] - 1, x.shape[1])
|
| 83 |
+
return PCA(n_components=k, random_state=RANDOM_STATE).fit_transform(x)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def download(modality: str, token: str | None) -> Path:
|
| 87 |
+
"""Download the manifest and expression files for one modality."""
|
| 88 |
+
manifest = Path(
|
| 89 |
+
hf_hub_download(
|
| 90 |
+
PUBLIC_REPO,
|
| 91 |
+
MANIFEST_FILENAME,
|
| 92 |
+
repo_type="dataset",
|
| 93 |
+
token=token,
|
| 94 |
+
revision=DATA_REVISION,
|
| 95 |
+
)
|
| 96 |
+
)
|
| 97 |
+
paths = [
|
| 98 |
+
MANIFEST_FILENAME,
|
| 99 |
+
*[str(entry["path"]) for entry in datasets(manifest, modality)],
|
| 100 |
+
]
|
| 101 |
+
return Path(
|
| 102 |
+
snapshot_download(
|
| 103 |
+
PUBLIC_REPO,
|
| 104 |
+
repo_type="dataset",
|
| 105 |
+
token=token,
|
| 106 |
+
revision=DATA_REVISION,
|
| 107 |
+
allow_patterns=paths,
|
| 108 |
+
)
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def datasets(manifest_path: Path, modality: str) -> list[dict]:
|
| 113 |
+
"""Manifest entries for one modality."""
|
| 114 |
+
manifest = yaml.safe_load(manifest_path.read_text())
|
| 115 |
+
entries = manifest.get("datasets", []) if isinstance(manifest, dict) else manifest
|
| 116 |
+
return [entry for entry in entries if entry["modality"] == modality]
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def build(root: Path, modality: str) -> pd.DataFrame:
|
| 120 |
+
"""Embed one modality into the frame the Submit tab expects.
|
| 121 |
+
|
| 122 |
+
Datasets of different widths stack into one table; the extra columns of a
|
| 123 |
+
narrower dataset stay empty and the evaluator drops them per dataset, so each
|
| 124 |
+
dataset keeps its own embedding size.
|
| 125 |
+
"""
|
| 126 |
+
blocks = []
|
| 127 |
+
for entry in datasets(root / MANIFEST_FILENAME, modality):
|
| 128 |
+
dataset_id = str(entry["id"])
|
| 129 |
+
adata = ad.read_h5ad(root / entry["path"])
|
| 130 |
+
cells = entry.get("observation_level") == "cell"
|
| 131 |
+
sample_ids = prediction_ids(adata, cells)
|
| 132 |
+
vectors = embed(adata, cells)
|
| 133 |
+
print(
|
| 134 |
+
f"{dataset_id}: {adata.n_obs} observations, {len(sample_ids)} samples, "
|
| 135 |
+
f"{entry.get('input_normalization', 'raw counts')} "
|
| 136 |
+
f"-> {vectors.shape[1]} dims"
|
| 137 |
+
)
|
| 138 |
+
block = pd.DataFrame(
|
| 139 |
+
vectors, columns=[f"e{i}" for i in range(vectors.shape[1])]
|
| 140 |
+
)
|
| 141 |
+
block.insert(0, SAMPLE_ID, sample_ids)
|
| 142 |
+
block.insert(0, DATASET_ID, dataset_id)
|
| 143 |
+
blocks.append(block)
|
| 144 |
+
return pd.concat(blocks, ignore_index=True)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def main() -> None:
|
| 148 |
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
| 149 |
+
parser.add_argument("--modality", required=True, choices=MODALITIES)
|
| 150 |
+
parser.add_argument("--out", type=Path, default=Path("submission.parquet"))
|
| 151 |
+
parser.add_argument("--token", default=None, help="HF token, if you need one.")
|
| 152 |
+
args = parser.parse_args()
|
| 153 |
+
|
| 154 |
+
modality = MODALITIES[args.modality]
|
| 155 |
+
submission = build(download(modality, args.token), modality)
|
| 156 |
+
if args.out.suffix == ".csv":
|
| 157 |
+
submission.to_csv(args.out, index=False)
|
| 158 |
+
else:
|
| 159 |
+
submission.to_parquet(args.out, index=False)
|
| 160 |
+
print(f"\nWrote {args.out}: {len(submission)} rows. Upload it on the Submit tab.")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
if __name__ == "__main__":
|
| 164 |
+
main()
|
render.py
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The PRIMO pages, rendered as ``pm-*`` markup for ``gr.HTML`` blocks.
|
| 2 |
+
|
| 3 |
+
Gradio has no card or rich-table component, so every page here is a string of
|
| 4 |
+
HTML styled entirely by ``primo.css`` -- the top bar, the hero, the leaderboard
|
| 5 |
+
tables, the release notes and the tasks table. The functions are pure: they take
|
| 6 |
+
the data the app already fetched (``Board`` objects, the results ``DataFrame``,
|
| 7 |
+
the registry) and return a string, so they unit-test without Gradio or a network.
|
| 8 |
+
|
| 9 |
+
Two rules hold everywhere:
|
| 10 |
+
|
| 11 |
+
* Every string that comes from the registry or from a submission goes through
|
| 12 |
+
``html.escape`` -- board names, blurbs, disease names and, above all, the
|
| 13 |
+
user-chosen model names that become table cells and column headers.
|
| 14 |
+
* New submissions can carry a public institution, author-submission check and paper
|
| 15 |
+
link. Private legacy ``hf_username`` values stay hidden. Public strings and
|
| 16 |
+
links are validated and escaped before rendering.
|
| 17 |
+
|
| 18 |
+
Pages are reload-based ``<a href="?tab=name">`` links in the top bar; the
|
| 19 |
+
leaderboard view is picked with Gradio pills in ``app.py``. On a closed board
|
| 20 |
+
version, every link adds ``&version=<number>`` so the visitor stays on it, except
|
| 21 |
+
Submit: a submission always goes to the current version.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import math
|
| 25 |
+
import numbers
|
| 26 |
+
import re
|
| 27 |
+
from html import escape
|
| 28 |
+
from itertools import pairwise
|
| 29 |
+
from urllib.parse import urlparse
|
| 30 |
+
|
| 31 |
+
import pandas as pd
|
| 32 |
+
from boards import (
|
| 33 |
+
AREA_GROUP,
|
| 34 |
+
CATEGORY_GROUP,
|
| 35 |
+
MODALITY_GROUP,
|
| 36 |
+
Board,
|
| 37 |
+
in_group,
|
| 38 |
+
open_in_group,
|
| 39 |
+
)
|
| 40 |
+
from leaderboard import (
|
| 41 |
+
ELO_ANCHOR_MODEL,
|
| 42 |
+
ModelCell,
|
| 43 |
+
TaskCell,
|
| 44 |
+
per_task_table,
|
| 45 |
+
ranked_table,
|
| 46 |
+
tasks_table,
|
| 47 |
+
)
|
| 48 |
+
from results import (
|
| 49 |
+
BASELINE_TAG,
|
| 50 |
+
IS_BASELINE,
|
| 51 |
+
MODEL_NAME,
|
| 52 |
+
split_hvg_name,
|
| 53 |
+
with_baseline_flag,
|
| 54 |
+
without_hvg,
|
| 55 |
+
)
|
| 56 |
+
from versions import CURRENT, VERSIONS, Version
|
| 57 |
+
|
| 58 |
+
SECTIONS = (MODALITY_GROUP, AREA_GROUP, CATEGORY_GROUP)
|
| 59 |
+
VERSION_PARAM = "version"
|
| 60 |
+
NAME_COLUMNS = frozenset({"Model", "Best"})
|
| 61 |
+
STRONG_COLUMNS = frozenset({"Task"})
|
| 62 |
+
METRIC_COLUMNS = frozenset({"Metric"})
|
| 63 |
+
RIGHT_COLUMNS = frozenset({"Rank", "Patients", "Submissions count"})
|
| 64 |
+
METRIC_GUIDES = {
|
| 65 |
+
"AUROC": "Range: 0 to 1 · Random predictor: 0.5",
|
| 66 |
+
"Pearson": "Range: -1 to 1 · Random predictor: 0 (expected)",
|
| 67 |
+
"Residual Spearman score": "Range: -1 to 1 · Random predictor: 0 (expected)",
|
| 68 |
+
"Centered Spearman score": "Range: -1 to 1 · Random predictor: 0 (expected)",
|
| 69 |
+
}
|
| 70 |
+
PUBLIC_CHIP = (
|
| 71 |
+
' <span class="pm-public-chip" title="Labels downloadable from the PRIMO '
|
| 72 |
+
'dataset" aria-label="Public labels">Public</span>'
|
| 73 |
+
)
|
| 74 |
+
BASELINE_CHIP = (
|
| 75 |
+
'<span class="pm-tag pm-tag--base" title="Our reference model">Baseline</span>'
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def evaluation_confirmation() -> str:
|
| 80 |
+
"""Render the confirmation shown before a model evaluation starts."""
|
| 81 |
+
return (
|
| 82 |
+
'<dialog id="pm-evaluation-confirmation" class="pm-dialog" '
|
| 83 |
+
'aria-labelledby="pm-evaluation-confirmation-title">'
|
| 84 |
+
'<form method="dialog" class="pm-dialog-surface">'
|
| 85 |
+
'<p class="pm-over">Confirm submission</p>'
|
| 86 |
+
'<h2 id="pm-evaluation-confirmation-title">'
|
| 87 |
+
"Evaluate and submit this model?</h2>"
|
| 88 |
+
"<p>Evaluation can take several minutes. If it succeeds, this submission "
|
| 89 |
+
"will update the public leaderboards.</p>"
|
| 90 |
+
'<div class="pm-dialog-actions">'
|
| 91 |
+
'<button class="pm-btn pm-btn--outline" value="cancel">Cancel</button>'
|
| 92 |
+
'<button class="pm-btn pm-btn--primary" value="confirm">'
|
| 93 |
+
"Evaluate model</button>"
|
| 94 |
+
"</div></form></dialog>"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def evaluation_status() -> str:
|
| 99 |
+
"""Render the progress message shown while an evaluation runs."""
|
| 100 |
+
return (
|
| 101 |
+
'<div class="pm-evaluation-status" role="status" aria-live="polite">'
|
| 102 |
+
'<svg class="pm-spinner" aria-hidden="true" viewBox="0 0 24 24">'
|
| 103 |
+
'<circle cx="12" cy="12" r="9"></circle>'
|
| 104 |
+
'<path d="M12 3a9 9 0 0 1 9 9"></path></svg>'
|
| 105 |
+
"<span>Your embeddings are being evaluated... This can take a few "
|
| 106 |
+
"minutes.</span></div>"
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _href(query: str, version: Version = CURRENT) -> str:
|
| 111 |
+
"""A reload link that stays on ``version``; the current one needs no tag."""
|
| 112 |
+
if version != CURRENT:
|
| 113 |
+
query = f"{query}&{VERSION_PARAM}={version.number}"
|
| 114 |
+
return escape(f"?{query}")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _number(version: Version) -> tuple[int, ...]:
|
| 118 |
+
"""The version number as integers, so ``0.10`` comes after ``0.9``."""
|
| 119 |
+
return tuple(int(part) for part in version.number.split("."))
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _closed_on(version: Version) -> str:
|
| 123 |
+
"""The day the version after ``version`` opened."""
|
| 124 |
+
later = [listed for listed in VERSIONS if _number(listed) > _number(version)]
|
| 125 |
+
return min(later, key=_number).opened
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ------------------------------------------------------------- html tables
|
| 129 |
+
def _fmt_value(value, is_score: bool) -> str | None:
|
| 130 |
+
"""Display text for one cell; ``None`` marks a blank (``n/a``) cell."""
|
| 131 |
+
try:
|
| 132 |
+
if pd.isna(value):
|
| 133 |
+
return None
|
| 134 |
+
except (TypeError, ValueError):
|
| 135 |
+
pass
|
| 136 |
+
if isinstance(value, str):
|
| 137 |
+
return value
|
| 138 |
+
if isinstance(value, numbers.Integral):
|
| 139 |
+
return str(int(value))
|
| 140 |
+
if isinstance(value, numbers.Real):
|
| 141 |
+
if not math.isfinite(float(value)):
|
| 142 |
+
return None
|
| 143 |
+
return f"{float(value):.3f}" if is_score else str(value)
|
| 144 |
+
return str(value)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _model_cell_html(cell: ModelCell) -> str:
|
| 148 |
+
"""One model on one line: its name, its chips, then a dotted leader.
|
| 149 |
+
|
| 150 |
+
Only escaped HTTP(S) paper links become links.
|
| 151 |
+
"""
|
| 152 |
+
label = escape(str(cell).removesuffix(f" {BASELINE_TAG}"))
|
| 153 |
+
parsed = urlparse(cell.paper_link)
|
| 154 |
+
if parsed.scheme in {"http", "https"} and parsed.netloc:
|
| 155 |
+
url = escape(cell.paper_link, quote=True)
|
| 156 |
+
label = (
|
| 157 |
+
f'<a class="pm-model-link" href="{url}" target="_blank" '
|
| 158 |
+
f'rel="noopener noreferrer">{label}</a>'
|
| 159 |
+
)
|
| 160 |
+
chips = []
|
| 161 |
+
if cell.is_hvg:
|
| 162 |
+
chips.append(
|
| 163 |
+
'<span class="pm-tag pm-tag--hvg" title="Concatenated with the HVG-1200 '
|
| 164 |
+
'baseline features" aria-label="Concatenated with the HVG-1200 baseline '
|
| 165 |
+
'features">+ HVG</span>'
|
| 166 |
+
)
|
| 167 |
+
if cell.is_baseline:
|
| 168 |
+
chips.append(BASELINE_CHIP)
|
| 169 |
+
if cell.is_author_submission:
|
| 170 |
+
chips.append(
|
| 171 |
+
'<span class="pm-tag pm-tag--author" title="Submitted by the model '
|
| 172 |
+
'authors" aria-label="Submitted by the model authors">✓ Authors</span>'
|
| 173 |
+
)
|
| 174 |
+
return (
|
| 175 |
+
f'<div class="pm-pipe"><span class="pm-lrn">{label}</span>'
|
| 176 |
+
f'{"".join(chips)}<span class="pm-leader"></span></div>'
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _with_baseline_chip(name: str) -> str:
|
| 181 |
+
"""The escaped name, its ``(baseline)`` tag shown as the Baseline chip."""
|
| 182 |
+
if not name.endswith(f" {BASELINE_TAG}"):
|
| 183 |
+
return escape(name)
|
| 184 |
+
return f"{escape(name.removesuffix(f' {BASELINE_TAG}'))} {BASELINE_CHIP}"
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _score_columns(df: pd.DataFrame) -> list[str]:
|
| 188 |
+
"""Numeric columns to format and bold -- ``Rank`` is an index, not a score."""
|
| 189 |
+
return [c for c in df.select_dtypes("number").columns if c not in RIGHT_COLUMNS]
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _score_guide(col: str, row: pd.Series) -> str | None:
|
| 193 |
+
"""Return metric context for a native score cell, when identifiable."""
|
| 194 |
+
metric = next((name for name in METRIC_GUIDES if f"({name})" in col), None)
|
| 195 |
+
if metric is None and col not in {"Elo", "Mean rank"}:
|
| 196 |
+
metric = str(row.get("Metric", ""))
|
| 197 |
+
return METRIC_GUIDES.get(metric) if metric is not None else None
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _bold_cells(df: pd.DataFrame, score_cols: list[str], axis: int) -> set:
|
| 201 |
+
"""Which ``(row, col)`` cells hold the best value.
|
| 202 |
+
|
| 203 |
+
``axis=0`` bolds the best model per column (the ranked table, read down);
|
| 204 |
+
``axis=1`` bolds the best model per row (the per-task table, read across).
|
| 205 |
+
"""
|
| 206 |
+
bold = set()
|
| 207 |
+
if not score_cols:
|
| 208 |
+
return bold
|
| 209 |
+
if axis == 0:
|
| 210 |
+
for col in score_cols:
|
| 211 |
+
best = (
|
| 212 |
+
df[col].min(skipna=True)
|
| 213 |
+
if col == "Mean rank"
|
| 214 |
+
else df[col].max(skipna=True)
|
| 215 |
+
)
|
| 216 |
+
if pd.notna(best):
|
| 217 |
+
for idx, value in df[col].items():
|
| 218 |
+
if pd.notna(value) and value == best:
|
| 219 |
+
bold.add((idx, col))
|
| 220 |
+
else:
|
| 221 |
+
for idx, row in df.iterrows():
|
| 222 |
+
present = {c: row[c] for c in score_cols if pd.notna(row[c])}
|
| 223 |
+
if present:
|
| 224 |
+
best = max(present.values())
|
| 225 |
+
bold.update((idx, c) for c, v in present.items() if v == best)
|
| 226 |
+
return bold
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _cell_class(col: str, is_score: bool, blank: bool) -> str:
|
| 230 |
+
if col in NAME_COLUMNS:
|
| 231 |
+
return "pm-name"
|
| 232 |
+
if col in STRONG_COLUMNS:
|
| 233 |
+
return "pm-strong"
|
| 234 |
+
if col in METRIC_COLUMNS:
|
| 235 |
+
return "pm-metric"
|
| 236 |
+
if is_score or col in RIGHT_COLUMNS:
|
| 237 |
+
return "pm-num pm-num--dim" if blank else "pm-num"
|
| 238 |
+
return ""
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _task_tooltip(task: dict) -> str:
|
| 242 |
+
"""Build the expanded clinical context shown for a task name."""
|
| 243 |
+
description = str(task.get("description") or "").strip().rstrip(".")
|
| 244 |
+
diseases = (
|
| 245 |
+
", ".join(str(d) for d in task.get("diseases") or []) or "the listed cohort"
|
| 246 |
+
)
|
| 247 |
+
patients = task.get("n_subjects") or task.get("n_samples")
|
| 248 |
+
patient_text = (
|
| 249 |
+
f"{patients:,} patients"
|
| 250 |
+
if isinstance(patients, int)
|
| 251 |
+
else "an unspecified number of patients"
|
| 252 |
+
)
|
| 253 |
+
n_samples = task.get("n_samples")
|
| 254 |
+
if (
|
| 255 |
+
isinstance(n_samples, int)
|
| 256 |
+
and isinstance(patients, int)
|
| 257 |
+
and n_samples != patients
|
| 258 |
+
):
|
| 259 |
+
patient_text += f" ({n_samples:,} collection samples)"
|
| 260 |
+
modality = str(task.get("modality") or "omics")
|
| 261 |
+
tissue = str(task.get("tissue") or "unspecified tissue")
|
| 262 |
+
target = str(task.get("target") or task.get("title") or "the task target")
|
| 263 |
+
return (
|
| 264 |
+
f"{description}. Patients: {patient_text} with {diseases}; "
|
| 265 |
+
f"input data: {modality} profiles from {tissue.lower()}; "
|
| 266 |
+
f"outcome: {target}."
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _df_to_table(
|
| 271 |
+
df: pd.DataFrame,
|
| 272 |
+
bold_axis: int | None,
|
| 273 |
+
empty: str,
|
| 274 |
+
task_guides: dict[str, dict] | None = None,
|
| 275 |
+
) -> str:
|
| 276 |
+
"""Render a DataFrame as a ``pm-table``, escaping every header and cell."""
|
| 277 |
+
if df.empty:
|
| 278 |
+
return f'<p class="pm-caption">{escape(empty)}</p>'
|
| 279 |
+
repeat_sd = df.attrs.get("repeat_standard_deviations", {})
|
| 280 |
+
df = df.copy(deep=False)
|
| 281 |
+
df.attrs = {}
|
| 282 |
+
score_cols = _score_columns(df)
|
| 283 |
+
bold = _bold_cells(df, score_cols, bold_axis) if bold_axis is not None else set()
|
| 284 |
+
|
| 285 |
+
head = []
|
| 286 |
+
for index, col in enumerate(df.columns):
|
| 287 |
+
name = _with_baseline_chip(str(col))
|
| 288 |
+
align = (
|
| 289 |
+
' style="text-align:right"'
|
| 290 |
+
if col in score_cols or col in RIGHT_COLUMNS
|
| 291 |
+
else ""
|
| 292 |
+
)
|
| 293 |
+
head.append(
|
| 294 |
+
f'<th class="pm-sort" data-sort-index="{index}" tabindex="0" '
|
| 295 |
+
f'role="button" aria-sort="none" title="Sort by {escape(str(col))}"'
|
| 296 |
+
f"{align}>{name}</th>"
|
| 297 |
+
)
|
| 298 |
+
body = []
|
| 299 |
+
for idx, row in df.iterrows():
|
| 300 |
+
cells = []
|
| 301 |
+
for col in df.columns:
|
| 302 |
+
is_score = col in score_cols
|
| 303 |
+
text = _fmt_value(row[col], is_score)
|
| 304 |
+
cls = _cell_class(col, is_score, text is None)
|
| 305 |
+
weight = ' style="font-weight:700"' if (idx, col) in bold else ""
|
| 306 |
+
guide = (
|
| 307 |
+
_score_guide(str(col), row) if is_score and text is not None else None
|
| 308 |
+
)
|
| 309 |
+
shown = (
|
| 310 |
+
_model_cell_html(row[col])
|
| 311 |
+
if isinstance(row[col], ModelCell)
|
| 312 |
+
else "n/a"
|
| 313 |
+
if text is None
|
| 314 |
+
else _with_baseline_chip(text)
|
| 315 |
+
if col in NAME_COLUMNS
|
| 316 |
+
else escape(text)
|
| 317 |
+
)
|
| 318 |
+
standard_deviation = repeat_sd.get((idx, col))
|
| 319 |
+
uncertainty = ""
|
| 320 |
+
if standard_deviation and text is not None:
|
| 321 |
+
deviation, repeats = standard_deviation
|
| 322 |
+
sd_text = f"{deviation:.3f}"
|
| 323 |
+
uncertainty = (
|
| 324 |
+
f" Standard deviation across {repeats} repeats: {sd_text}."
|
| 325 |
+
)
|
| 326 |
+
shown += f'<span class="pm-sd" aria-hidden="true"> ± {sd_text}</span>'
|
| 327 |
+
if guide:
|
| 328 |
+
escaped_guide = escape(guide, quote=True)
|
| 329 |
+
score_label = escape(f"Score {text}.{uncertainty} {guide}", quote=True)
|
| 330 |
+
shown = (
|
| 331 |
+
'<span class="pm-score-guide" tabindex="0" '
|
| 332 |
+
f'aria-label="{score_label}" '
|
| 333 |
+
f'data-tooltip="{escaped_guide}">{shown}</span>'
|
| 334 |
+
)
|
| 335 |
+
if task_guides and col == "Task" and text is not None:
|
| 336 |
+
task = next(
|
| 337 |
+
(
|
| 338 |
+
candidate
|
| 339 |
+
for candidate in task_guides.values()
|
| 340 |
+
if candidate.get("title") == text
|
| 341 |
+
),
|
| 342 |
+
None,
|
| 343 |
+
)
|
| 344 |
+
if task:
|
| 345 |
+
tooltip = _task_tooltip(task)
|
| 346 |
+
escaped_tooltip = escape(tooltip, quote=True)
|
| 347 |
+
task_label = escape(f"{text}. {tooltip}", quote=True)
|
| 348 |
+
shown = (
|
| 349 |
+
'<span class="pm-task-guide" tabindex="0" '
|
| 350 |
+
f'aria-label="{task_label}" data-tooltip="{escaped_tooltip}">'
|
| 351 |
+
f'<span class="pm-task-text">{shown}</span></span>'
|
| 352 |
+
)
|
| 353 |
+
if isinstance(row[col], TaskCell) and row[col].is_public:
|
| 354 |
+
shown += PUBLIC_CHIP
|
| 355 |
+
sort_value = (
|
| 356 |
+
f' data-sort-value="{escape(text, quote=True)}"'
|
| 357 |
+
if is_score and text is not None
|
| 358 |
+
else ""
|
| 359 |
+
)
|
| 360 |
+
cells.append(f'<td class="{cls}"{sort_value}{weight}>{shown}</td>')
|
| 361 |
+
body.append(f"<tr>{''.join(cells)}</tr>")
|
| 362 |
+
return (
|
| 363 |
+
'<div class="pm-table-wrap"><table class="pm-table" style="table-layout:auto">'
|
| 364 |
+
f"<thead><tr>{''.join(head)}</tr></thead>"
|
| 365 |
+
f"<tbody>{''.join(body)}</tbody></table></div>"
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
# ------------------------------------------------------------------- tasks
|
| 370 |
+
def render_tasks(by_id: dict[str, dict]) -> str:
|
| 371 |
+
"""The Tasks page: one scannable row per target. Provenance is never shown."""
|
| 372 |
+
df = tasks_table(list(by_id.values()))
|
| 373 |
+
table = _df_to_table(
|
| 374 |
+
df,
|
| 375 |
+
bold_axis=None,
|
| 376 |
+
empty="The task registry is unavailable right now.",
|
| 377 |
+
task_guides=by_id,
|
| 378 |
+
)
|
| 379 |
+
return (
|
| 380 |
+
'<div class="pm-head"><div><h1>Tasks</h1>'
|
| 381 |
+
f"<p>{len(df)} targets. One fixed task probe reads each "
|
| 382 |
+
"one out of your embedding; the cohorts stay anonymous, the biology does "
|
| 383 |
+
"not.</p></div></div>"
|
| 384 |
+
f'<div class="pm-body">{table}</div>'
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# ------------------------------------------------------------- leaderboard
|
| 389 |
+
SPACE_URL = "https://huggingface.co/spaces/PRIMOmics/primo-eval"
|
| 390 |
+
DATA_URL = "https://huggingface.co/datasets/PRIMOmics/primo"
|
| 391 |
+
PAPER_URL = "https://openreview.net/forum?id=v2SA8gHwqo"
|
| 392 |
+
|
| 393 |
+
NAV_TABS = (
|
| 394 |
+
("leaderboard", "Leaderboard"),
|
| 395 |
+
("tasks", "Tasks"),
|
| 396 |
+
("method", "Method"),
|
| 397 |
+
("contribute", "Contribute"),
|
| 398 |
+
)
|
| 399 |
+
LEDE = (
|
| 400 |
+
"PRIMO ranks omics foundation models on real "
|
| 401 |
+
'<a target="_self" href="{tasks}">clinical tasks</a>. Your model turns each '
|
| 402 |
+
"patient's omics profile into an embedding. One fixed probe, the same for every "
|
| 403 |
+
"model, then predicts hidden outcomes such as treatment response or prognosis, "
|
| 404 |
+
"so the score measures the embedding."
|
| 405 |
+
)
|
| 406 |
+
RANKED_NOTE = (
|
| 407 |
+
'<p class="pm-note">We score zero-shot representations, so a score is not the '
|
| 408 |
+
"best a model can reach. Only models that covered every task scored by the "
|
| 409 |
+
f"<code>{ELO_ANCHOR_MODEL}</code> baseline are ranked. <code>+ HVG</code> rows "
|
| 410 |
+
"concatenate a model with the baseline's features.</p>"
|
| 411 |
+
)
|
| 412 |
+
N_TOP = 3
|
| 413 |
+
ORDINAL_SUFFIXES = {1: "st", 2: "nd", 3: "rd"}
|
| 414 |
+
NOT_SCORES = frozenset({"Rank", "Submissions count"})
|
| 415 |
+
RANKED_LABELS = {"Submissions count": "Subm."}
|
| 416 |
+
RANKED_NOTES = {"Model": "paper linked when given"}
|
| 417 |
+
RANKED_HINTS = {
|
| 418 |
+
"Elo": f"The {ELO_ANCHOR_MODEL} baseline sits at 1000.",
|
| 419 |
+
"Mean rank": "Lower is better.",
|
| 420 |
+
"Submissions count": (
|
| 421 |
+
"Submissions: the most times one task of this model was submitted."
|
| 422 |
+
),
|
| 423 |
+
}
|
| 424 |
+
SHORT_METRICS = {
|
| 425 |
+
"Centered Spearman score": "centered Spearman",
|
| 426 |
+
"Residual Spearman score": "residual Spearman",
|
| 427 |
+
}
|
| 428 |
+
CATEGORY_HEADER = re.compile(r"^(?P<name>.+) \((?P<metric>[^()]+)\)$")
|
| 429 |
+
STRABLE_URL = "https://gblayer.github.io/strable-leaderboard/"
|
| 430 |
+
BOARD_SUB = (
|
| 431 |
+
'<p class="pm-board-sub">Models are ranked by Elo over the tasks of the view, '
|
| 432 |
+
f"anchored on the {ELO_ANCHOR_MODEL} baseline at 1000. Task columns show the "
|
| 433 |
+
"mean score, each in its own metric.</p>"
|
| 434 |
+
)
|
| 435 |
+
ART_GENES = (0.55, 0.9, 0.4, 0.75, 0.35, 0.95, 0.5, 0.8, 0.45, 0.7, 0.3, 0.85, 0.6, 0.4)
|
| 436 |
+
ART_EMBEDDING = (0.7, 1.0, 0.6, 0.9, 0.75, 0.95)
|
| 437 |
+
ART_LAYERS = ((4, 132), (5, 150), (3, 168))
|
| 438 |
+
ART_TASKS = ("Treatment response", "Prognosis", "Disease severity", "Molecular subtype")
|
| 439 |
+
CREDIT = (
|
| 440 |
+
'<p class="pm-credit">Design inspired by the '
|
| 441 |
+
f'<a href="{STRABLE_URL}" target="_blank" rel="noopener noreferrer">STRABLE '
|
| 442 |
+
"leaderboard</a>.</p>"
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _art_text(x: float, y: float, text: str, cls: str, anchor: str = "middle") -> str:
|
| 447 |
+
return f'<text class="{cls}" x="{x}" y="{y}" text-anchor="{anchor}">{text}</text>'
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def _art_arrow(x1: float, x2: float, y: float) -> str:
|
| 451 |
+
return (
|
| 452 |
+
f'<line class="pm-art-line" x1="{x1}" y1="{y}" x2="{x2 - 6}" y2="{y}"></line>'
|
| 453 |
+
f'<path class="pm-art-head" d="M{x2 - 7} {y - 4} L{x2} {y} L{x2 - 7} {y + 4} Z">'
|
| 454 |
+
"</path>"
|
| 455 |
+
)
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def _hero_art() -> str:
|
| 459 |
+
"""The hero figure: a patient's omics, a model, one embedding, many tasks."""
|
| 460 |
+
parts = [
|
| 461 |
+
_art_text(58, 22, "Patient omics", "pm-art-h"),
|
| 462 |
+
_art_text(162, 22, "Your model", "pm-art-h"),
|
| 463 |
+
_art_text(254, 22, "Embedding", "pm-art-h"),
|
| 464 |
+
_art_text(351, 22, "Clinical tasks", "pm-art-h"),
|
| 465 |
+
'<circle class="pm-art-patient" cx="58" cy="44" r="8"></circle>',
|
| 466 |
+
'<path class="pm-art-patient" d="M45 66 Q58 50 71 66 Z"></path>',
|
| 467 |
+
]
|
| 468 |
+
parts += [
|
| 469 |
+
f'<rect class="pm-art-{"b" if index % 3 == 0 else "a"}" x="47" '
|
| 470 |
+
f'y="{74 + index * 11}" width="22" height="9" rx="1.5" opacity="{level}"></rect>'
|
| 471 |
+
for index, level in enumerate(ART_GENES)
|
| 472 |
+
]
|
| 473 |
+
parts += [
|
| 474 |
+
_art_text(58, 246, "gene expression", "pm-art-c"),
|
| 475 |
+
_art_arrow(78, 112, 150),
|
| 476 |
+
'<rect class="pm-art-box" x="114" y="112" width="96" height="76" rx="14"></rect>',
|
| 477 |
+
]
|
| 478 |
+
nodes = [
|
| 479 |
+
[(162 + (k - (count - 1) / 2) * 16, y) for k in range(count)]
|
| 480 |
+
for count, y in ART_LAYERS
|
| 481 |
+
]
|
| 482 |
+
parts += [
|
| 483 |
+
f'<line class="pm-art-edge" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}"></line>'
|
| 484 |
+
for upper, lower in pairwise(nodes)
|
| 485 |
+
for x1, y1 in upper
|
| 486 |
+
for x2, y2 in lower
|
| 487 |
+
]
|
| 488 |
+
parts += [
|
| 489 |
+
f'<circle class="pm-art-node" cx="{x}" cy="{y}" r="3.2"></circle>'
|
| 490 |
+
for layer in nodes
|
| 491 |
+
for x, y in layer
|
| 492 |
+
]
|
| 493 |
+
parts += [
|
| 494 |
+
_art_text(162, 204, "omics foundation model", "pm-art-c"),
|
| 495 |
+
_art_arrow(212, 244, 150),
|
| 496 |
+
]
|
| 497 |
+
parts += [
|
| 498 |
+
f'<rect class="pm-art-{"a" if index % 2 else "b"}" x="246" y="{100 + index * 17}" '
|
| 499 |
+
f'width="16" height="14" rx="2" opacity="{level}"></rect>'
|
| 500 |
+
for index, level in enumerate(ART_EMBEDDING)
|
| 501 |
+
]
|
| 502 |
+
parts += [
|
| 503 |
+
_art_text(254, 218, "sample", "pm-art-c"),
|
| 504 |
+
_art_text(254, 230, "representation", "pm-art-c"),
|
| 505 |
+
]
|
| 506 |
+
for index, task in enumerate(ART_TASKS):
|
| 507 |
+
y = 74 + index * 44
|
| 508 |
+
parts += [
|
| 509 |
+
f'<path class="pm-art-line" d="M268 150 C 278 150, 278 {y}, 288 {y}"></path>',
|
| 510 |
+
f'<rect class="pm-art-pill" x="290" y="{y - 14}" width="122" height="28" '
|
| 511 |
+
'rx="14"></rect>',
|
| 512 |
+
_art_text(302, y + 4, task, "pm-art-t", anchor="start"),
|
| 513 |
+
]
|
| 514 |
+
return (
|
| 515 |
+
'<div class="pm-art"><svg class="pm-hero-art" viewBox="0 0 420 268" '
|
| 516 |
+
'role="img" aria-labelledby="pm-art-title"><title id="pm-art-title">A patient\'s '
|
| 517 |
+
"omics profile goes through your model, which returns one embedding per "
|
| 518 |
+
"sample. The same fixed probe then scores that embedding on clinical tasks."
|
| 519 |
+
f"</title>{''.join(parts)}</svg></div>"
|
| 520 |
+
)
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
def updated_on(df: pd.DataFrame) -> str:
|
| 524 |
+
"""The day of the latest stored result, or ``""`` when there is none."""
|
| 525 |
+
if df.empty or "submitted_at" not in df:
|
| 526 |
+
return ""
|
| 527 |
+
return str(df["submitted_at"].astype(str).max())[:10]
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def _model_count(df: pd.DataFrame) -> int:
|
| 531 |
+
"""Submitted models, a ``+ HVG`` entry counted with its parent, baselines out."""
|
| 532 |
+
flagged = with_baseline_flag(df)
|
| 533 |
+
if flagged.empty:
|
| 534 |
+
return 0
|
| 535 |
+
return len(
|
| 536 |
+
{
|
| 537 |
+
split_hvg_name(str(model))[0]
|
| 538 |
+
for model, is_baseline in zip(flagged[MODEL_NAME], flagged[IS_BASELINE])
|
| 539 |
+
if not is_baseline
|
| 540 |
+
}
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
def version_notice(version: Version) -> str:
|
| 545 |
+
"""On a closed version, a banner that says so and links to the current one."""
|
| 546 |
+
if version == CURRENT:
|
| 547 |
+
return ""
|
| 548 |
+
return (
|
| 549 |
+
'<div class="pm-notice" role="status">'
|
| 550 |
+
f"<b>Version v{escape(version.number)} is closed.</b> "
|
| 551 |
+
f"Its scores, from {escape(version.opened)} to "
|
| 552 |
+
f"{escape(_closed_on(version))}, are kept read-only. "
|
| 553 |
+
f'<a target="_self" href="{_href("tab=leaderboard")}">'
|
| 554 |
+
f"Go to the current version, v{escape(CURRENT.number)}</a></div>"
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def _cite_dialog(version: Version) -> str:
|
| 559 |
+
"""The window of the Cite button: how to cite PRIMO and the version on screen."""
|
| 560 |
+
bibtex = (
|
| 561 |
+
"@misc{primo2026,\n"
|
| 562 |
+
" title = {{PRIMO}: Patient Representations in Multi-Omics},\n"
|
| 563 |
+
" author = {{PRIMO contributors}},\n"
|
| 564 |
+
" year = {2026},\n"
|
| 565 |
+
f" note = {{Leaderboard version {version.number}}},\n"
|
| 566 |
+
f" howpublished = {{\\url{{{SPACE_URL}}}}}\n"
|
| 567 |
+
"}"
|
| 568 |
+
)
|
| 569 |
+
return (
|
| 570 |
+
'<dialog id="pm-cite-dialog" class="pm-dialog" aria-labelledby="pm-cite-title">'
|
| 571 |
+
'<form method="dialog" class="pm-dialog-surface">'
|
| 572 |
+
'<h2 id="pm-cite-title">Cite PRIMO</h2>'
|
| 573 |
+
"<p><b>Evaluating a model on PRIMO?</b> Please cite PRIMO and give the board "
|
| 574 |
+
"version you used, for example "
|
| 575 |
+
f"<code>PRIMO leaderboard v{escape(version.number)}</code>.</p>"
|
| 576 |
+
f"<pre>{escape(bibtex)}</pre>"
|
| 577 |
+
'<div class="pm-dialog-actions">'
|
| 578 |
+
'<button class="pm-btn pm-btn--outline" value="close">Close</button>'
|
| 579 |
+
"</div></form></dialog>"
|
| 580 |
+
)
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
def nav_html(active_tab: str, version: Version = CURRENT) -> str:
|
| 584 |
+
"""The top bar: the PRIMO mark, the pages, the data, the paper, Submit and Cite.
|
| 585 |
+
|
| 586 |
+
On a closed version, every page link keeps it, except Submit, Cite names it,
|
| 587 |
+
and a banner under the bar says the version is closed.
|
| 588 |
+
"""
|
| 589 |
+
links = [
|
| 590 |
+
f'<a class="{"pm-active" if tab == active_tab else ""}" target="_self" '
|
| 591 |
+
f'href="{_href(f"tab={tab}", version)}">{name}</a>'
|
| 592 |
+
for tab, name in NAV_TABS
|
| 593 |
+
]
|
| 594 |
+
links += [
|
| 595 |
+
f'<a href="{url}" target="_blank" rel="noopener noreferrer">{name}</a>'
|
| 596 |
+
for url, name in ((DATA_URL, "Data"), (PAPER_URL, "Paper"))
|
| 597 |
+
]
|
| 598 |
+
return (
|
| 599 |
+
'<header class="pm-nav"><a class="pm-logo" target="_self" '
|
| 600 |
+
f'href="{_href("tab=leaderboard", version)}" aria-label="PRIMO leaderboard">'
|
| 601 |
+
'<img src="/gradio_api/file=assets/primo-mark.webp" alt="">'
|
| 602 |
+
"<b>PRIMO</b></a>"
|
| 603 |
+
'<div class="pm-nav-right">'
|
| 604 |
+
f'<nav class="pm-nav-links" aria-label="Main">{"".join(links)}</nav>'
|
| 605 |
+
'<a class="pm-btn" target="_self" href="?tab=submit">Submit a model</a>'
|
| 606 |
+
'<button type="button" class="pm-btn pm-btn--outline" id="pm-cite-btn" '
|
| 607 |
+
'aria-haspopup="dialog" aria-controls="pm-cite-dialog">Cite</button>'
|
| 608 |
+
f"</div></header>{_cite_dialog(version)}{version_notice(version)}"
|
| 609 |
+
)
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
def hero_html(boards: list[Board], df: pd.DataFrame, version: Version = CURRENT) -> str:
|
| 613 |
+
"""The title block: version, PRIMO, what it is, its size, and the visual."""
|
| 614 |
+
layers = in_group(boards, MODALITY_GROUP)
|
| 615 |
+
stats = (
|
| 616 |
+
(sum(b.n_tasks for b in layers), "tasks"),
|
| 617 |
+
(sum(b.n_cohorts for b in layers), "cohorts"),
|
| 618 |
+
(sum(b.n_patients for b in layers), "patients"),
|
| 619 |
+
(len(layers), "modalities"),
|
| 620 |
+
(_model_count(df), "models"),
|
| 621 |
+
)
|
| 622 |
+
chips = "".join(f"<li><b>{n:,}</b> {name}</li>" for n, name in stats)
|
| 623 |
+
updated = updated_on(df)
|
| 624 |
+
stamp = (
|
| 625 |
+
f'<span class="pm-updated">Updated {escape(updated)}</span>' if updated else ""
|
| 626 |
+
)
|
| 627 |
+
badge = f'<span class="pm-ver">v{escape(version.number)}</span>'
|
| 628 |
+
if version != CURRENT:
|
| 629 |
+
badge = (
|
| 630 |
+
f'<span class="pm-ver pm-ver--closed">v{escape(version.number)} '
|
| 631 |
+
"· closed</span>"
|
| 632 |
+
)
|
| 633 |
+
return (
|
| 634 |
+
'<section class="pm-hero" aria-labelledby="pm-hero-title"><div>'
|
| 635 |
+
'<p class="pm-eyebrow"><span class="pm-label">Leaderboard</span>'
|
| 636 |
+
f'{badge}<a class="pm-ver-link" href="#pm-history">Release notes</a>'
|
| 637 |
+
f"{stamp}</p>"
|
| 638 |
+
'<h1 id="pm-hero-title">PRIMO</h1>'
|
| 639 |
+
'<p class="pm-expand"><span class="pm-xp"><b>P</b>atient <b>R</b>epresentations '
|
| 640 |
+
"<b>i</b>n <b>M</b>ulti-<b>O</b>mics</span></p>"
|
| 641 |
+
f'<p class="pm-lede">{LEDE.format(tasks=_href("tab=tasks", version))}</p>'
|
| 642 |
+
f'<ul class="pm-stats">{chips}</ul></div>'
|
| 643 |
+
f'<div class="pm-art-wrap">{_hero_art()}</div>'
|
| 644 |
+
"</section>"
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
def _ordinal(rank: int) -> str:
|
| 649 |
+
"""``1`` -> ``1st``, ``12`` -> ``12th``."""
|
| 650 |
+
suffix = "th" if 10 <= rank % 100 <= 20 else ORDINAL_SUFFIXES.get(rank % 10, "th")
|
| 651 |
+
return f"{rank}{suffix}"
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
def _ranked_header(index: int, col: str, numeric: bool) -> str:
|
| 655 |
+
match = CATEGORY_HEADER.match(col)
|
| 656 |
+
if match:
|
| 657 |
+
name = match["name"]
|
| 658 |
+
note = SHORT_METRICS.get(match["metric"], match["metric"])
|
| 659 |
+
else:
|
| 660 |
+
name, note = RANKED_LABELS.get(col, col), RANKED_NOTES.get(col, "")
|
| 661 |
+
align = " pm-r" if numeric else ""
|
| 662 |
+
small = f"<small>{escape(note)}</small>" if note else ""
|
| 663 |
+
hint = escape(
|
| 664 |
+
f"Sort by {name.rstrip('.')}. {RANKED_HINTS.get(col, '')}".strip(), quote=True
|
| 665 |
+
)
|
| 666 |
+
return (
|
| 667 |
+
f'<th class="pm-sort{align}" data-sort-index="{index}" tabindex="0" '
|
| 668 |
+
f'role="button" aria-sort="none" title="{hint}">{escape(name)}{small}</th>'
|
| 669 |
+
)
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
def _ranked_cell(
|
| 673 |
+
table: pd.DataFrame, idx, col: str, bold: set, scores: list, numeric: set
|
| 674 |
+
) -> str:
|
| 675 |
+
"""One cell of the ranked table, on one line."""
|
| 676 |
+
value = table.at[idx, col]
|
| 677 |
+
if col == "Rank":
|
| 678 |
+
return (
|
| 679 |
+
f'<td class="pm-rank" data-sort-value="{int(value)}">'
|
| 680 |
+
f"{_ordinal(int(value))}</td>"
|
| 681 |
+
)
|
| 682 |
+
if isinstance(value, ModelCell):
|
| 683 |
+
return (
|
| 684 |
+
f'<td class="pm-name" data-sort-value="{escape(str(value), quote=True)}">'
|
| 685 |
+
f"{_model_cell_html(value)}</td>"
|
| 686 |
+
)
|
| 687 |
+
is_score = col in scores
|
| 688 |
+
text = _fmt_value(value, is_score)
|
| 689 |
+
if col not in numeric:
|
| 690 |
+
shown = "n/a" if text is None else escape(text)
|
| 691 |
+
dim = " pm-dim" if text is None else ""
|
| 692 |
+
return f'<td class="pm-inst{dim}">{shown}</td>'
|
| 693 |
+
if text is None:
|
| 694 |
+
return '<td class="pm-num pm-dim">n/a</td>'
|
| 695 |
+
shown = escape(text)
|
| 696 |
+
guide = _score_guide(col, table.loc[idx]) if is_score else None
|
| 697 |
+
if guide:
|
| 698 |
+
label = escape(f"Score {text}. {guide}", quote=True)
|
| 699 |
+
shown = (
|
| 700 |
+
f'<span class="pm-score-guide" tabindex="0" aria-label="{label}" '
|
| 701 |
+
f'data-tooltip="{escape(guide, quote=True)}">{shown}</span>'
|
| 702 |
+
)
|
| 703 |
+
cls = "pm-num"
|
| 704 |
+
if col == "Elo":
|
| 705 |
+
cls += " pm-key"
|
| 706 |
+
if (idx, col) in bold:
|
| 707 |
+
cls += " pm-best"
|
| 708 |
+
return (
|
| 709 |
+
f'<td class="{cls}" data-sort-value="{escape(text, quote=True)}">{shown}</td>'
|
| 710 |
+
)
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
def _count(n: int, noun: str) -> str:
|
| 714 |
+
"""``<b>1</b> task``, ``<b>6</b> tasks``."""
|
| 715 |
+
return f"<b>{n:,}</b> {noun}{'' if n == 1 else 's'}"
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
def _view_head(board: Board, view_name: str) -> str:
|
| 719 |
+
"""The head of the ranking card: the view, then how many tasks it holds."""
|
| 720 |
+
counts = " · ".join(
|
| 721 |
+
(
|
| 722 |
+
_count(board.n_tasks, "task"),
|
| 723 |
+
_count(board.n_cohorts, "cohort"),
|
| 724 |
+
_count(board.n_patients, "patient"),
|
| 725 |
+
)
|
| 726 |
+
)
|
| 727 |
+
return (
|
| 728 |
+
f'<div class="pm-card-head"><span class="pm-view">{escape(view_name)}</span>'
|
| 729 |
+
f'<span class="pm-view-n">{counts}</span></div>'
|
| 730 |
+
)
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
def _ranked_html(table: pd.DataFrame) -> str:
|
| 734 |
+
"""The ranked table: one line per model, the top three framed."""
|
| 735 |
+
if table.empty:
|
| 736 |
+
return (
|
| 737 |
+
'<p class="pm-empty">No model has covered every scored task of this view '
|
| 738 |
+
"yet. Be the first to submit.</p>"
|
| 739 |
+
)
|
| 740 |
+
numeric = set(table.select_dtypes("number").columns)
|
| 741 |
+
scores = [c for c in table.columns if c in numeric and c not in NOT_SCORES]
|
| 742 |
+
bold = _bold_cells(table, scores, axis=0)
|
| 743 |
+
head = "".join(
|
| 744 |
+
_ranked_header(index, str(col), col in numeric)
|
| 745 |
+
for index, col in enumerate(table.columns)
|
| 746 |
+
)
|
| 747 |
+
n_top = min(N_TOP, len(table))
|
| 748 |
+
body = []
|
| 749 |
+
for position, idx in enumerate(table.index):
|
| 750 |
+
classes = []
|
| 751 |
+
if position < n_top:
|
| 752 |
+
classes.append("pm-top")
|
| 753 |
+
if position == 0:
|
| 754 |
+
classes.append("pm-top-first")
|
| 755 |
+
if position == n_top - 1:
|
| 756 |
+
classes.append("pm-top-last")
|
| 757 |
+
cells = "".join(
|
| 758 |
+
_ranked_cell(table, idx, str(col), bold, scores, numeric)
|
| 759 |
+
for col in table.columns
|
| 760 |
+
)
|
| 761 |
+
body.append(f'<tr class="{" ".join(classes)}">{cells}</tr>')
|
| 762 |
+
return (
|
| 763 |
+
'<div class="pm-tbl-wrap"><table class="pm-table pm-ranked">'
|
| 764 |
+
f"<thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody>"
|
| 765 |
+
"</table></div>"
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
def _per_task_details(per_task: pd.DataFrame, by_id: dict, board: Board) -> str:
|
| 770 |
+
"""The per-task scores, folded under the ranking."""
|
| 771 |
+
table = _df_to_table(
|
| 772 |
+
per_task,
|
| 773 |
+
bold_axis=1,
|
| 774 |
+
empty="No submission has scored on this view yet.",
|
| 775 |
+
task_guides=by_id,
|
| 776 |
+
)
|
| 777 |
+
return (
|
| 778 |
+
'<details class="pm-more"><summary><span class="pm-title">Scores per task'
|
| 779 |
+
f'</span><span class="pm-more-n">{board.n_tasks} tasks</span></summary>'
|
| 780 |
+
'<p class="pm-note">Every submission, partial ones included. Read across a '
|
| 781 |
+
"row. Scores show mean ± SD when repeat-level results are available.</p>"
|
| 782 |
+
f"{table}</details>"
|
| 783 |
+
)
|
| 784 |
+
|
| 785 |
+
|
| 786 |
+
def render_view(
|
| 787 |
+
board: Board,
|
| 788 |
+
df: pd.DataFrame,
|
| 789 |
+
by_id: dict[str, dict],
|
| 790 |
+
view_name: str,
|
| 791 |
+
show_hvg: bool,
|
| 792 |
+
) -> str:
|
| 793 |
+
"""One view: its size, its ranking, then its per-task scores."""
|
| 794 |
+
rows = df if show_hvg else without_hvg(df)
|
| 795 |
+
head = _view_head(board, view_name)
|
| 796 |
+
if not by_id:
|
| 797 |
+
return (
|
| 798 |
+
f'<div class="pm-card">{head}<p class="pm-empty">The task registry could '
|
| 799 |
+
"not be loaded. Please retry in a moment.</p></div>"
|
| 800 |
+
)
|
| 801 |
+
if not board.task_ids:
|
| 802 |
+
return (
|
| 803 |
+
f'<div class="pm-card">{head}<p class="pm-empty">No task in this view '
|
| 804 |
+
"yet. Pick another therapeutic area or task.</p></div>"
|
| 805 |
+
)
|
| 806 |
+
ranked = _ranked_html(ranked_table(rows, by_id, board))
|
| 807 |
+
return (
|
| 808 |
+
f'<div class="pm-card">{head}{ranked}</div>'
|
| 809 |
+
+ RANKED_NOTE
|
| 810 |
+
+ _per_task_details(per_task_table(rows, by_id, board), by_id, board)
|
| 811 |
+
)
|
| 812 |
+
|
| 813 |
+
|
| 814 |
+
def history_html(df: pd.DataFrame, version: Version = CURRENT) -> str:
|
| 815 |
+
"""The release notes: every board version, what changed, and its board.
|
| 816 |
+
|
| 817 |
+
A version other than the one on screen links to its board, so a closed
|
| 818 |
+
version stays one click away now that no menu lists them.
|
| 819 |
+
"""
|
| 820 |
+
cite = ", ".join(
|
| 821 |
+
part
|
| 822 |
+
for part in (f"PRIMO leaderboard v{version.number}", updated_on(df), SPACE_URL)
|
| 823 |
+
if part
|
| 824 |
+
)
|
| 825 |
+
items = []
|
| 826 |
+
for listed in VERSIONS:
|
| 827 |
+
state = "current" if listed == CURRENT else "closed"
|
| 828 |
+
span = (
|
| 829 |
+
f"since {listed.opened}"
|
| 830 |
+
if listed == CURRENT
|
| 831 |
+
else f"{listed.opened} to {_closed_on(listed)}"
|
| 832 |
+
)
|
| 833 |
+
link = (
|
| 834 |
+
""
|
| 835 |
+
if listed == version
|
| 836 |
+
else f'<a target="_self" href="{_href("tab=leaderboard", listed)}">'
|
| 837 |
+
"View this board</a>"
|
| 838 |
+
)
|
| 839 |
+
items.append(
|
| 840 |
+
f'<li class="{"pm-on" if listed == version else ""}">'
|
| 841 |
+
f"<b>v{escape(listed.number)}</b><div>"
|
| 842 |
+
f'<p class="pm-ver-meta"><span class="pm-chip pm-chip--{state}">{state}'
|
| 843 |
+
f"</span> {escape(span)} · release {escape(listed.release)}</p>"
|
| 844 |
+
f"<p>{escape(listed.notes)}</p></div>{link}</li>"
|
| 845 |
+
)
|
| 846 |
+
return (
|
| 847 |
+
'<section class="pm-history" id="pm-history" '
|
| 848 |
+
'aria-labelledby="pm-history-title"><div>'
|
| 849 |
+
'<h2 class="pm-title" id="pm-history-title">Release notes</h2>'
|
| 850 |
+
'<p class="pm-history-sub">What changed in each board version. A closed '
|
| 851 |
+
"version keeps its board, read-only. To cite a rank: "
|
| 852 |
+
f"<span>{escape(cite)}</span>.</p></div><ol>{''.join(items)}</ol></section>"
|
| 853 |
+
)
|
| 854 |
+
|
| 855 |
+
|
| 856 |
+
def cta_html(boards: list[Board], version: Version = CURRENT) -> str:
|
| 857 |
+
"""The closing panel: an open call for cohorts, with examples of what is missing."""
|
| 858 |
+
gaps = [b.name for group in SECTIONS for b in open_in_group(boards, group)]
|
| 859 |
+
missing = ""
|
| 860 |
+
if gaps:
|
| 861 |
+
listed = gaps[0] if len(gaps) == 1 else f"{', '.join(gaps[:-1])} and {gaps[-1]}"
|
| 862 |
+
missing = f"Still missing, for example: {listed}. "
|
| 863 |
+
return (
|
| 864 |
+
'<section class="pm-ask" aria-labelledby="pm-ask-title"><div>'
|
| 865 |
+
'<p class="pm-label">Help us grow PRIMO</p>'
|
| 866 |
+
'<h2 id="pm-ask-title">Which cohort should PRIMO cover next?</h2></div>'
|
| 867 |
+
'<div class="pm-ask-go"><p>PRIMO is open to any omics layer and any disease '
|
| 868 |
+
f"area. {escape(missing)}Bring a cohort, or propose a task on the cohorts "
|
| 869 |
+
"already here.</p>"
|
| 870 |
+
f'<a class="pm-btn" target="_self" href="{_href("tab=contribute", version)}">'
|
| 871 |
+
"Contribute</a>"
|
| 872 |
+
"</div></section>"
|
| 873 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy==2.5.4
|
| 2 |
+
pandas==2.3.3
|
| 3 |
+
pyarrow
|
| 4 |
+
joblib==1.6.0
|
| 5 |
+
scikit-learn==1.9.1
|
| 6 |
+
scipy==1.18.1
|
| 7 |
+
pyyaml
|
| 8 |
+
huggingface_hub<1.0
|
| 9 |
+
audioop-lts; python_version >= "3.13"
|
rescore.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rescore the live entries of a board version into the current version.
|
| 2 |
+
|
| 3 |
+
A new board version opens with an empty board, as its harness has scored nothing
|
| 4 |
+
yet. This maintainer tool fills it from the archived embedding files, so nobody
|
| 5 |
+
has to submit again. An entry is live when it holds the latest row of a slot of
|
| 6 |
+
the old version, on a task that still exists.
|
| 7 |
+
|
| 8 |
+
- Each live submission is scored again, plain and ``+ HVG``, at the current pins.
|
| 9 |
+
Its rows keep the model name, submission id, credits and the archive's
|
| 10 |
+
``submitted_at``, and carry the current version.
|
| 11 |
+
- The ``HVG-1200-genes`` baseline is scored again from the reference features,
|
| 12 |
+
which are its submission.
|
| 13 |
+
- An entry keeps its task set: the tasks its old rows covered that still exist,
|
| 14 |
+
plus the tasks the new version adds. ``--all-tasks`` scores every task the file
|
| 15 |
+
covers instead.
|
| 16 |
+
- The datasets the current manifest no longer lists are dropped from the file,
|
| 17 |
+
because ``score_all`` would refuse the whole file.
|
| 18 |
+
- A submission is scored again only on the tasks where the current version holds
|
| 19 |
+
no row of its entry from the same time or later. A second run after the deploy
|
| 20 |
+
then only picks up the submissions made in between.
|
| 21 |
+
- An archive whose manifest records no representation is read as an embedding,
|
| 22 |
+
as the Space scored it.
|
| 23 |
+
- An entry that fails, or that gets no score on a task it held, stops the run
|
| 24 |
+
before anything is published, with or without ``--all-tasks``. To publish the
|
| 25 |
+
others, rerun with ``--models`` and the entries to keep.
|
| 26 |
+
|
| 27 |
+
``--from legacy`` reads the rows written before versions existed.
|
| 28 |
+
|
| 29 |
+
HF_TOKEN=... python rescore.py --from 0.1 --dry-run
|
| 30 |
+
HF_TOKEN=... python rescore.py --from 0.1 --checkpoint-dir ckpt --out rows.csv
|
| 31 |
+
HF_TOKEN=... python rescore.py --from 0.1 --checkpoint-dir ckpt --publish
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
import argparse
|
| 35 |
+
import json
|
| 36 |
+
import logging
|
| 37 |
+
import os
|
| 38 |
+
from dataclasses import dataclass, field
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
from tempfile import TemporaryDirectory
|
| 41 |
+
|
| 42 |
+
import pandas as pd
|
| 43 |
+
from evaluator import (
|
| 44 |
+
DATASET_ID,
|
| 45 |
+
EMBEDDING,
|
| 46 |
+
GENE_SPACE,
|
| 47 |
+
RESULTS_REPO,
|
| 48 |
+
TASK_ID,
|
| 49 |
+
EvaluatorError,
|
| 50 |
+
SubmissionError,
|
| 51 |
+
_norm_id,
|
| 52 |
+
fetch_manifest,
|
| 53 |
+
fetch_tasks_registry,
|
| 54 |
+
load_submission,
|
| 55 |
+
manifest_ids,
|
| 56 |
+
score_all,
|
| 57 |
+
scoreable_tasks,
|
| 58 |
+
)
|
| 59 |
+
from leaderboard import latest_only
|
| 60 |
+
from results import (
|
| 61 |
+
ARCHIVE_DIR,
|
| 62 |
+
ARCHIVE_MANIFEST,
|
| 63 |
+
HVG_REFERENCE_MODEL,
|
| 64 |
+
HVG_TAG,
|
| 65 |
+
INSTITUTION,
|
| 66 |
+
IS_AUTHOR_SUBMISSION,
|
| 67 |
+
IS_BASELINE,
|
| 68 |
+
MODEL_NAME,
|
| 69 |
+
OWNER,
|
| 70 |
+
PAPER_LINK,
|
| 71 |
+
SUBMISSION_ID,
|
| 72 |
+
append_results,
|
| 73 |
+
archive_paths,
|
| 74 |
+
fetch_hvg_reference,
|
| 75 |
+
hvg_name,
|
| 76 |
+
read_results,
|
| 77 |
+
task_rows,
|
| 78 |
+
version_rows,
|
| 79 |
+
with_baseline_flag,
|
| 80 |
+
with_owner,
|
| 81 |
+
)
|
| 82 |
+
from versions import CURRENT, VERSIONS
|
| 83 |
+
|
| 84 |
+
LEGACY = "legacy"
|
| 85 |
+
CREDITS = (OWNER, INSTITUTION, IS_AUTHOR_SUBMISSION, PAPER_LINK)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@dataclass(frozen=True)
|
| 89 |
+
class Carry:
|
| 90 |
+
"""One live entry to score again: an archived file, or the HVG baseline.
|
| 91 |
+
|
| 92 |
+
``archive`` is the file's path in the results repo, or None for the baseline,
|
| 93 |
+
whose file is the reference. ``task_ids`` None means every task of the file.
|
| 94 |
+
``held`` are the tasks its old rows covered: each one must score again.
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
model_name: str
|
| 98 |
+
is_baseline: bool
|
| 99 |
+
submission_id: str
|
| 100 |
+
submitted_at: str
|
| 101 |
+
modality: str | None
|
| 102 |
+
representation: str
|
| 103 |
+
archive: str | None
|
| 104 |
+
task_ids: frozenset[str] | None
|
| 105 |
+
held: frozenset[str]
|
| 106 |
+
credits: dict = field(default_factory=dict, compare=False)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def representation_of(manifest: dict) -> str:
|
| 110 |
+
"""How to read an archive: as its manifest says, else as an embedding."""
|
| 111 |
+
return manifest.get("representation", EMBEDDING)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _slots(rows: pd.DataFrame) -> list[tuple[str, bool, str]]:
|
| 115 |
+
"""The ``(model, is_baseline, task)`` slot of each row."""
|
| 116 |
+
return [
|
| 117 |
+
(str(model), bool(is_baseline), _norm_id(task_id))
|
| 118 |
+
for model, is_baseline, task_id in zip(
|
| 119 |
+
rows[MODEL_NAME], rows[IS_BASELINE], rows[TASK_ID]
|
| 120 |
+
)
|
| 121 |
+
]
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _newest(rows: pd.DataFrame) -> dict[tuple[str, bool, str], str]:
|
| 125 |
+
"""The newest ``submitted_at`` of each slot of ``rows``."""
|
| 126 |
+
flagged = with_baseline_flag(rows)
|
| 127 |
+
newest = {}
|
| 128 |
+
for slot, date in zip(_slots(flagged), flagged["submitted_at"].astype(str)):
|
| 129 |
+
newest[slot] = max(newest.get(slot, ""), date)
|
| 130 |
+
return newest
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _open_rows(
|
| 134 |
+
rows: pd.DataFrame, newest: dict, since: str | None = None
|
| 135 |
+
) -> pd.DataFrame:
|
| 136 |
+
"""The ``rows`` whose slot holds no row from ``since`` or later in ``newest``.
|
| 137 |
+
|
| 138 |
+
Without ``since``, each row is compared with its own date.
|
| 139 |
+
"""
|
| 140 |
+
dates = rows["submitted_at"].astype(str) if since is None else [since] * len(rows)
|
| 141 |
+
keep = [newest.get(slot, "") < date for slot, date in zip(_slots(rows), dates)]
|
| 142 |
+
return rows[pd.Series(keep, index=rows.index, dtype=bool)]
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _task_set(
|
| 146 |
+
held: frozenset[str], added: set[str], all_tasks: bool
|
| 147 |
+
) -> frozenset[str] | None:
|
| 148 |
+
"""The held tasks plus the added ones, or None for every task."""
|
| 149 |
+
if all_tasks:
|
| 150 |
+
return None
|
| 151 |
+
return held | frozenset(added)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def carried(
|
| 155 |
+
old: pd.DataFrame,
|
| 156 |
+
new: pd.DataFrame,
|
| 157 |
+
manifests: list[dict],
|
| 158 |
+
task_ids: set[str],
|
| 159 |
+
added: set[str],
|
| 160 |
+
all_tasks: bool = False,
|
| 161 |
+
) -> tuple[list[Carry], list[str]]:
|
| 162 |
+
"""The live entries of ``old`` to score into ``new``, and those with no archive."""
|
| 163 |
+
latest = with_owner(latest_only(old))
|
| 164 |
+
if latest.empty:
|
| 165 |
+
return [], []
|
| 166 |
+
live = latest[latest[TASK_ID].map(_norm_id).isin(task_ids)]
|
| 167 |
+
plain = live[
|
| 168 |
+
~live[IS_BASELINE] & ~live[MODEL_NAME].astype(str).str.endswith(HVG_TAG)
|
| 169 |
+
]
|
| 170 |
+
newest = _newest(new)
|
| 171 |
+
carries = []
|
| 172 |
+
for manifest in manifests:
|
| 173 |
+
model, submission_id = manifest["model_name"], manifest["submission_id"]
|
| 174 |
+
rows = plain[
|
| 175 |
+
(plain[SUBMISSION_ID] == submission_id) & (plain[MODEL_NAME] == model)
|
| 176 |
+
]
|
| 177 |
+
rows = _open_rows(rows, newest, manifest["submitted_at"])
|
| 178 |
+
if rows.empty:
|
| 179 |
+
continue
|
| 180 |
+
parent = rows.iloc[-1]
|
| 181 |
+
held = frozenset(rows[TASK_ID].map(_norm_id))
|
| 182 |
+
carries.append(
|
| 183 |
+
Carry(
|
| 184 |
+
model_name=model,
|
| 185 |
+
is_baseline=False,
|
| 186 |
+
submission_id=submission_id,
|
| 187 |
+
submitted_at=manifest["submitted_at"],
|
| 188 |
+
modality=manifest["modality"],
|
| 189 |
+
representation=representation_of(manifest),
|
| 190 |
+
archive=archive_paths(submission_id, manifest["source_format"])[0],
|
| 191 |
+
task_ids=_task_set(held, added, all_tasks),
|
| 192 |
+
held=held,
|
| 193 |
+
credits={key: parent[key] for key in CREDITS},
|
| 194 |
+
)
|
| 195 |
+
)
|
| 196 |
+
archived = {(m["model_name"], m["submission_id"]) for m in manifests}
|
| 197 |
+
orphans = sorted(
|
| 198 |
+
{
|
| 199 |
+
row[MODEL_NAME]
|
| 200 |
+
for slot, (_, row) in zip(_slots(plain), plain.iterrows())
|
| 201 |
+
if (row[MODEL_NAME], row[SUBMISSION_ID]) not in archived
|
| 202 |
+
and newest.get(slot, "") < str(row["submitted_at"])
|
| 203 |
+
}
|
| 204 |
+
)
|
| 205 |
+
baseline = live[live[IS_BASELINE] & (live[MODEL_NAME] == HVG_REFERENCE_MODEL)]
|
| 206 |
+
if not baseline.empty:
|
| 207 |
+
since = str(baseline["submitted_at"].astype(str).max())
|
| 208 |
+
baseline = _open_rows(baseline, newest)
|
| 209 |
+
if not baseline.empty:
|
| 210 |
+
held = frozenset(baseline[TASK_ID].map(_norm_id))
|
| 211 |
+
carries.append(
|
| 212 |
+
Carry(
|
| 213 |
+
model_name=HVG_REFERENCE_MODEL,
|
| 214 |
+
is_baseline=True,
|
| 215 |
+
submission_id="",
|
| 216 |
+
submitted_at=since,
|
| 217 |
+
modality=None,
|
| 218 |
+
representation=GENE_SPACE,
|
| 219 |
+
archive=None,
|
| 220 |
+
task_ids=_task_set(held, added, all_tasks),
|
| 221 |
+
held=held,
|
| 222 |
+
)
|
| 223 |
+
)
|
| 224 |
+
return carries, orphans
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def without_retired(path: Path, valid: set[str], out: Path) -> tuple[Path, list[str]]:
|
| 228 |
+
"""The file without the datasets ``valid`` no longer lists, and their ids.
|
| 229 |
+
|
| 230 |
+
The file is rewritten to ``out`` only when a dataset has to go.
|
| 231 |
+
"""
|
| 232 |
+
blocks = load_submission(path)
|
| 233 |
+
retired = sorted(set(blocks) - valid)
|
| 234 |
+
if not retired:
|
| 235 |
+
return path, []
|
| 236 |
+
kept = [
|
| 237 |
+
block.assign(**{DATASET_ID: dataset_id})
|
| 238 |
+
for dataset_id, block in blocks.items()
|
| 239 |
+
if dataset_id in valid
|
| 240 |
+
]
|
| 241 |
+
if not kept:
|
| 242 |
+
raise SubmissionError(f"no dataset of the file is still listed: {retired}")
|
| 243 |
+
frame = pd.concat(kept, ignore_index=True)
|
| 244 |
+
frame.columns = frame.columns.map(str)
|
| 245 |
+
frame.to_parquet(out, index=False)
|
| 246 |
+
return out, retired
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def rescore(
|
| 250 |
+
carry: Carry,
|
| 251 |
+
source: Path,
|
| 252 |
+
reference: Path,
|
| 253 |
+
datasets: list[dict],
|
| 254 |
+
registry: list[dict],
|
| 255 |
+
workdir: Path,
|
| 256 |
+
token: str | None,
|
| 257 |
+
checkpoint_dir: Path | None = None,
|
| 258 |
+
) -> list[dict]:
|
| 259 |
+
"""The rows of ``carry`` in the current version, plain and ``+ HVG``."""
|
| 260 |
+
name = carry.submission_id or carry.model_name
|
| 261 |
+
path, retired = without_retired(
|
| 262 |
+
source, manifest_ids(datasets), workdir / f"{name}.parquet"
|
| 263 |
+
)
|
| 264 |
+
if retired:
|
| 265 |
+
logging.warning(
|
| 266 |
+
"%s: dropped the retired datasets %s", carry.model_name, retired
|
| 267 |
+
)
|
| 268 |
+
tasks = [
|
| 269 |
+
task
|
| 270 |
+
for task in registry
|
| 271 |
+
if carry.task_ids is None or _norm_id(task[TASK_ID]) in carry.task_ids
|
| 272 |
+
]
|
| 273 |
+
|
| 274 |
+
def _rows(model: str, features: Path | None) -> list[dict]:
|
| 275 |
+
result = score_all(
|
| 276 |
+
path,
|
| 277 |
+
token,
|
| 278 |
+
modality=carry.modality,
|
| 279 |
+
representation=carry.representation,
|
| 280 |
+
datasets=datasets,
|
| 281 |
+
tasks=tasks,
|
| 282 |
+
checkpoint_dir=checkpoint_dir,
|
| 283 |
+
reference=features,
|
| 284 |
+
)
|
| 285 |
+
for invalid in result["invalid"]:
|
| 286 |
+
logging.warning(
|
| 287 |
+
"%s: no score on %s: %s", model, invalid[TASK_ID], invalid["reason"]
|
| 288 |
+
)
|
| 289 |
+
return task_rows(
|
| 290 |
+
model, result, carry.submitted_at, carry.submission_id, carry.credits
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
if carry.is_baseline:
|
| 294 |
+
return [{**row, IS_BASELINE: True} for row in _rows(carry.model_name, None)]
|
| 295 |
+
return _rows(carry.model_name, None) + _rows(hvg_name(carry.model_name), reference)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def lost_tasks(carry: Carry, rows: list[dict]) -> list[str]:
|
| 299 |
+
"""The tasks ``carry`` held that one of its entries got no row for."""
|
| 300 |
+
names = [carry.model_name]
|
| 301 |
+
if not carry.is_baseline:
|
| 302 |
+
names.append(hvg_name(carry.model_name))
|
| 303 |
+
lost = set()
|
| 304 |
+
for name in names:
|
| 305 |
+
lost |= carry.held - {row[TASK_ID] for row in rows if row[MODEL_NAME] == name}
|
| 306 |
+
return sorted(lost)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def _score_carries(
|
| 310 |
+
carries: list[Carry],
|
| 311 |
+
reference: Path,
|
| 312 |
+
datasets: list[dict],
|
| 313 |
+
registry: list[dict],
|
| 314 |
+
token: str | None,
|
| 315 |
+
checkpoint_dir: Path | None,
|
| 316 |
+
) -> tuple[list[dict], list[str]]:
|
| 317 |
+
"""The rows of every carry, and one line per carry that failed."""
|
| 318 |
+
rows, failures = [], []
|
| 319 |
+
with TemporaryDirectory() as workdir:
|
| 320 |
+
for carry in carries:
|
| 321 |
+
source = (
|
| 322 |
+
reference if carry.archive is None else _download(carry.archive, token)
|
| 323 |
+
)
|
| 324 |
+
try:
|
| 325 |
+
scored = rescore(
|
| 326 |
+
carry,
|
| 327 |
+
source,
|
| 328 |
+
reference,
|
| 329 |
+
datasets,
|
| 330 |
+
registry,
|
| 331 |
+
Path(workdir),
|
| 332 |
+
token,
|
| 333 |
+
checkpoint_dir,
|
| 334 |
+
)
|
| 335 |
+
except (SubmissionError, EvaluatorError) as error:
|
| 336 |
+
failures.append(f"{carry.model_name}: {error}")
|
| 337 |
+
continue
|
| 338 |
+
lost = lost_tasks(carry, scored)
|
| 339 |
+
if lost:
|
| 340 |
+
failures.append(f"{carry.model_name}: no score on held tasks {lost}")
|
| 341 |
+
logging.info("%s: %d rows", carry.model_name, len(scored))
|
| 342 |
+
rows += scored
|
| 343 |
+
return rows, failures
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def _manifests(token: str | None) -> list[dict]:
|
| 347 |
+
"""Every archived submission manifest in the private results dataset."""
|
| 348 |
+
from huggingface_hub import HfApi
|
| 349 |
+
|
| 350 |
+
files = HfApi(token=token).list_repo_files(RESULTS_REPO, repo_type="dataset")
|
| 351 |
+
return [
|
| 352 |
+
json.loads(_download(path, token).read_text())
|
| 353 |
+
for path in files
|
| 354 |
+
if path.startswith(f"{ARCHIVE_DIR}/") and path.endswith(f"/{ARCHIVE_MANIFEST}")
|
| 355 |
+
]
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def _download(path_in_repo: str, token: str | None) -> Path:
|
| 359 |
+
"""One file of the private results dataset, at its latest commit."""
|
| 360 |
+
from huggingface_hub import hf_hub_download
|
| 361 |
+
|
| 362 |
+
return Path(
|
| 363 |
+
hf_hub_download(RESULTS_REPO, path_in_repo, repo_type="dataset", token=token)
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def _task_ids(registry: list[dict], datasets: list[dict]) -> set[str]:
|
| 368 |
+
"""The ids of the tasks of ``registry`` on the datasets the manifest lists."""
|
| 369 |
+
return {
|
| 370 |
+
_norm_id(task[TASK_ID])
|
| 371 |
+
for task in scoreable_tasks(registry, manifest_ids(datasets))
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _describe(carry: Carry) -> str:
|
| 376 |
+
tasks = "every task" if carry.task_ids is None else f"{len(carry.task_ids)} tasks"
|
| 377 |
+
kind = "baseline" if carry.is_baseline else carry.modality
|
| 378 |
+
return f"{carry.model_name} ({kind}, {carry.representation}): {tasks}"
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def _parse_args() -> argparse.Namespace:
|
| 382 |
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
| 383 |
+
parser.add_argument(
|
| 384 |
+
"--from",
|
| 385 |
+
dest="source",
|
| 386 |
+
required=True,
|
| 387 |
+
help=f"the version to rescore, or '{LEGACY}' for rows without one",
|
| 388 |
+
)
|
| 389 |
+
parser.add_argument("--models", nargs="+", default=None, help="only these models")
|
| 390 |
+
parser.add_argument(
|
| 391 |
+
"--all-tasks", action="store_true", help="score every task each file covers"
|
| 392 |
+
)
|
| 393 |
+
parser.add_argument("--checkpoint-dir", type=Path, default=None)
|
| 394 |
+
parser.add_argument("--out", type=Path, default=None, help="write the rows here")
|
| 395 |
+
parser.add_argument("--dry-run", action="store_true", help="list, score nothing")
|
| 396 |
+
parser.add_argument(
|
| 397 |
+
"--publish", action="store_true", help="append the rows to the leaderboard"
|
| 398 |
+
)
|
| 399 |
+
return parser.parse_args()
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def main() -> None:
|
| 403 |
+
"""CLI entry point."""
|
| 404 |
+
args = _parse_args()
|
| 405 |
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
| 406 |
+
token = os.environ.get("HF_TOKEN")
|
| 407 |
+
if args.publish and not token:
|
| 408 |
+
raise SystemExit("--publish needs write access: set HF_TOKEN.")
|
| 409 |
+
if args.source == CURRENT.number:
|
| 410 |
+
raise SystemExit(f"{CURRENT.number} is the current version: pick an older one.")
|
| 411 |
+
old_version = next((v for v in VERSIONS if v.number == args.source), None)
|
| 412 |
+
if args.source != LEGACY and old_version is None:
|
| 413 |
+
raise SystemExit(f"unknown version {args.source}")
|
| 414 |
+
|
| 415 |
+
datasets = fetch_manifest(token)
|
| 416 |
+
registry = fetch_tasks_registry(token)
|
| 417 |
+
task_ids = _task_ids(registry, datasets)
|
| 418 |
+
old_ids = (
|
| 419 |
+
task_ids
|
| 420 |
+
if old_version is None
|
| 421 |
+
else _task_ids(
|
| 422 |
+
fetch_tasks_registry(token, old_version.labels),
|
| 423 |
+
fetch_manifest(token, old_version.data),
|
| 424 |
+
)
|
| 425 |
+
)
|
| 426 |
+
results = read_results(token)
|
| 427 |
+
carries, orphans = carried(
|
| 428 |
+
version_rows(results, "" if old_version is None else old_version.number),
|
| 429 |
+
version_rows(results, CURRENT.number),
|
| 430 |
+
_manifests(token),
|
| 431 |
+
task_ids,
|
| 432 |
+
task_ids - old_ids,
|
| 433 |
+
args.all_tasks,
|
| 434 |
+
)
|
| 435 |
+
carries = [c for c in carries if args.models is None or c.model_name in args.models]
|
| 436 |
+
logging.info("%d entries to rescore into %s:", len(carries), CURRENT.number)
|
| 437 |
+
for carry in carries:
|
| 438 |
+
logging.info(" %s", _describe(carry))
|
| 439 |
+
if orphans:
|
| 440 |
+
logging.warning("no archive, so not carried over: %s", orphans)
|
| 441 |
+
if args.dry_run:
|
| 442 |
+
return
|
| 443 |
+
|
| 444 |
+
rows, failures = _score_carries(
|
| 445 |
+
carries,
|
| 446 |
+
fetch_hvg_reference(token),
|
| 447 |
+
datasets,
|
| 448 |
+
registry,
|
| 449 |
+
token,
|
| 450 |
+
args.checkpoint_dir,
|
| 451 |
+
)
|
| 452 |
+
if args.out is not None:
|
| 453 |
+
pd.DataFrame(rows).to_csv(args.out, index=False)
|
| 454 |
+
if failures:
|
| 455 |
+
raise SystemExit(
|
| 456 |
+
f"Failed entries: {len(failures)}, so nothing is published. Fix them, or "
|
| 457 |
+
"rerun with --models and the entries to keep:\n" + "\n".join(failures)
|
| 458 |
+
)
|
| 459 |
+
if args.publish:
|
| 460 |
+
append_results(rows, token)
|
| 461 |
+
logging.info("published %d rows into version %s", len(rows), CURRENT.number)
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
if __name__ == "__main__":
|
| 465 |
+
main()
|
results.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persisted leaderboard rows: the results-CSV schema and its Hugging Face IO.
|
| 2 |
+
|
| 3 |
+
Kept out of ``app.py`` so the schema has one owner and so the baseline publisher
|
| 4 |
+
can append rows without importing Gradio. ``huggingface_hub`` is imported lazily,
|
| 5 |
+
so the unit tests touch no network.
|
| 6 |
+
|
| 7 |
+
``is_baseline`` marks a reference submission we produced ourselves (log-normalized
|
| 8 |
+
expression, whole or cut to its most variable genes) rather than a model somebody
|
| 9 |
+
sent us. Baselines are ranked in place, never pinned: the point of showing them is
|
| 10 |
+
that a foundation model can lose to an information-rich expression baseline, and
|
| 11 |
+
a row pushed to the bottom of the table would hide exactly that.
|
| 12 |
+
|
| 13 |
+
``hf_username`` privately records who submitted a name, because the board keeps
|
| 14 |
+
each name's LATEST rows: without an owner, anyone could supersede another team's
|
| 15 |
+
ranked entry by sending a one-task file under their model name. The Submit form
|
| 16 |
+
refuses a name somebody else already claimed. New submissions separately store
|
| 17 |
+
an optional public ``institution``, an author-submission flag and a paper link;
|
| 18 |
+
legacy owners remain private. Historical ``team`` values are read as institutions.
|
| 19 |
+
|
| 20 |
+
Rows written before these columns existed carry no column at all, so every
|
| 21 |
+
reader normalises the baseline flag, ownership, public credits and submission
|
| 22 |
+
identity. Missing legacy public-credit values remain private.
|
| 23 |
+
|
| 24 |
+
Each row records the board ``version`` whose harness scored it, which is always
|
| 25 |
+
the current version of this code. A board shows only the rows of its version.
|
| 26 |
+
Rows written before versions existed carry none and are shown on no board.
|
| 27 |
+
|
| 28 |
+
Every submission is also scored concatenated with our ``HVG-1200-genes``
|
| 29 |
+
baseline features and stored as a second entry, ``<model> + HVG``, sharing the
|
| 30 |
+
parent's owner, credits and submission id. It shows what a model adds on top of
|
| 31 |
+
plain expression. The features live privately at ``HVG_REFERENCE_FILE``, next
|
| 32 |
+
to the results. They are read at the commit the board version pins, so a new
|
| 33 |
+
upload is used only once a release pins it.
|
| 34 |
+
|
| 35 |
+
The Space and ``rescore.py`` both append to the same CSV files. An append reads
|
| 36 |
+
a file at the latest commit, adds its rows, and uploads the whole file on top of
|
| 37 |
+
that commit only. If another write lands in between, the Hub refuses the upload,
|
| 38 |
+
and the append reads the file again, up to ``WRITE_ATTEMPTS`` times. So two
|
| 39 |
+
appends that cross never lose a row.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
import hashlib
|
| 43 |
+
import io
|
| 44 |
+
import json
|
| 45 |
+
from collections.abc import Callable
|
| 46 |
+
from http import HTTPStatus
|
| 47 |
+
from pathlib import Path
|
| 48 |
+
|
| 49 |
+
import pandas as pd
|
| 50 |
+
from evaluator import EMBEDDING, RESULTS_REPO, EvaluatorError, load_submission
|
| 51 |
+
from versions import CURRENT
|
| 52 |
+
|
| 53 |
+
RESULTS_FILE = "task_results.csv"
|
| 54 |
+
SUBMISSIONS_FILE = "submissions.csv"
|
| 55 |
+
|
| 56 |
+
MODEL_NAME = "model_name"
|
| 57 |
+
IS_BASELINE = "is_baseline"
|
| 58 |
+
OWNER = "hf_username"
|
| 59 |
+
INSTITUTION = "institution"
|
| 60 |
+
LEGACY_TEAM = "team"
|
| 61 |
+
IS_AUTHOR_SUBMISSION = "is_author_submission"
|
| 62 |
+
PAPER_LINK = "paper_link"
|
| 63 |
+
SUBMISSION_ID = "submission_id"
|
| 64 |
+
VERSION = "version"
|
| 65 |
+
|
| 66 |
+
RESULT_COLUMNS = [
|
| 67 |
+
MODEL_NAME,
|
| 68 |
+
"task_id",
|
| 69 |
+
"score",
|
| 70 |
+
"repeat_scores",
|
| 71 |
+
"diagnostics",
|
| 72 |
+
"submitted_at",
|
| 73 |
+
IS_BASELINE,
|
| 74 |
+
OWNER,
|
| 75 |
+
INSTITUTION,
|
| 76 |
+
IS_AUTHOR_SUBMISSION,
|
| 77 |
+
PAPER_LINK,
|
| 78 |
+
SUBMISSION_ID,
|
| 79 |
+
VERSION,
|
| 80 |
+
]
|
| 81 |
+
SUBMISSION_COLUMNS = [
|
| 82 |
+
MODEL_NAME,
|
| 83 |
+
"submitted_at",
|
| 84 |
+
OWNER,
|
| 85 |
+
INSTITUTION,
|
| 86 |
+
IS_AUTHOR_SUBMISSION,
|
| 87 |
+
SUBMISSION_ID,
|
| 88 |
+
"email",
|
| 89 |
+
PAPER_LINK,
|
| 90 |
+
"hf_model_link",
|
| 91 |
+
"notes",
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
TRUTHY = ("true", "1")
|
| 95 |
+
BASELINE_TAG = "(baseline)"
|
| 96 |
+
HVG_TAG = "+ HVG"
|
| 97 |
+
HVG_REFERENCE_MODEL = "HVG-1200-genes"
|
| 98 |
+
HVG_REFERENCE_FILE = f"baselines/{HVG_REFERENCE_MODEL}/submission.parquet"
|
| 99 |
+
ARCHIVE_DIR = "models"
|
| 100 |
+
ARCHIVE_MANIFEST = "manifest.json"
|
| 101 |
+
WRITE_ATTEMPTS = 3
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def with_baseline_flag(df: pd.DataFrame) -> pd.DataFrame:
|
| 105 |
+
"""Guarantee a boolean ``is_baseline`` column, whatever the CSV held.
|
| 106 |
+
|
| 107 |
+
Read back from CSV the column can be bool, the strings ``True``/``False``, or
|
| 108 |
+
absent on rows written before baselines existed; all of those must collapse
|
| 109 |
+
to a real boolean before anything ranks on it.
|
| 110 |
+
"""
|
| 111 |
+
if IS_BASELINE not in df.columns:
|
| 112 |
+
return df.assign(**{IS_BASELINE: False})
|
| 113 |
+
flags = df[IS_BASELINE].astype(str).str.strip().str.lower().isin(TRUTHY)
|
| 114 |
+
return df.assign(**{IS_BASELINE: flags})
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def with_owner(df: pd.DataFrame) -> pd.DataFrame:
|
| 118 |
+
"""Guarantee a string ``hf_username`` column; an absent one means unclaimed."""
|
| 119 |
+
return _with_text_column(df, OWNER)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def with_institution(df: pd.DataFrame) -> pd.DataFrame:
|
| 123 |
+
"""Guarantee an institution, falling back to the former ``team`` field."""
|
| 124 |
+
normalized = _with_text_column(df, INSTITUTION)
|
| 125 |
+
if LEGACY_TEAM not in normalized.columns:
|
| 126 |
+
return normalized
|
| 127 |
+
legacy = _with_text_column(normalized, LEGACY_TEAM)[LEGACY_TEAM]
|
| 128 |
+
values = normalized[INSTITUTION].mask(normalized[INSTITUTION] == "", legacy)
|
| 129 |
+
return normalized.assign(**{INSTITUTION: values})
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def with_submission_id(df: pd.DataFrame) -> pd.DataFrame:
|
| 133 |
+
"""Guarantee a submission id; legacy rows fall back to their timestamp."""
|
| 134 |
+
return _with_text_column(df, SUBMISSION_ID)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def with_version(df: pd.DataFrame) -> pd.DataFrame:
|
| 138 |
+
"""Guarantee a board-version column; legacy rows have the empty version."""
|
| 139 |
+
return _with_text_column(df, VERSION)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def version_rows(df: pd.DataFrame, number: str) -> pd.DataFrame:
|
| 143 |
+
"""The rows that board version ``number`` scored."""
|
| 144 |
+
versioned = with_version(df)
|
| 145 |
+
return versioned[versioned[VERSION] == number]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def with_paper_link(df: pd.DataFrame) -> pd.DataFrame:
|
| 149 |
+
"""Guarantee a public paper-link column; legacy rows have no public link."""
|
| 150 |
+
return _with_text_column(df, PAPER_LINK)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def with_author_submission_flag(df: pd.DataFrame) -> pd.DataFrame:
|
| 154 |
+
"""Guarantee a boolean author-submission flag; legacy rows are unverified."""
|
| 155 |
+
if IS_AUTHOR_SUBMISSION not in df.columns:
|
| 156 |
+
return df.assign(**{IS_AUTHOR_SUBMISSION: False})
|
| 157 |
+
flags = df[IS_AUTHOR_SUBMISSION].astype(str).str.strip().str.lower().isin(TRUTHY)
|
| 158 |
+
return df.assign(**{IS_AUTHOR_SUBMISSION: flags})
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _with_text_column(df: pd.DataFrame, column: str) -> pd.DataFrame:
|
| 162 |
+
"""Normalize a nullable CSV text column without stringifying missing values."""
|
| 163 |
+
if column not in df.columns:
|
| 164 |
+
return df.assign(**{column: ""})
|
| 165 |
+
values = df[column].map(lambda value: "" if pd.isna(value) else str(value).strip())
|
| 166 |
+
return df.assign(**{column: values})
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def owner_of(df: pd.DataFrame, model: str) -> str:
|
| 170 |
+
"""Who claimed this submitted model name, or ``""`` if it is free.
|
| 171 |
+
|
| 172 |
+
Baselines are ignored: they live in their own namespace (``is_baseline``), so
|
| 173 |
+
publishing a baseline never reserves its name for user submissions.
|
| 174 |
+
"""
|
| 175 |
+
if df.empty:
|
| 176 |
+
return ""
|
| 177 |
+
claimed = with_owner(with_baseline_flag(df))
|
| 178 |
+
rows = claimed[
|
| 179 |
+
(claimed[MODEL_NAME] == model) & ~claimed[IS_BASELINE] & (claimed[OWNER] != "")
|
| 180 |
+
]
|
| 181 |
+
return "" if rows.empty else str(rows.iloc[-1][OWNER])
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def display_name(model: str, is_baseline: bool) -> str:
|
| 185 |
+
"""Leaderboard label: a baseline says so, in the one column everybody reads."""
|
| 186 |
+
return f"{model} {BASELINE_TAG}" if is_baseline else model
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def hvg_name(model: str) -> str:
|
| 190 |
+
"""Name of the entry scoring ``model`` concatenated with the HVG features."""
|
| 191 |
+
return f"{model} {HVG_TAG}"
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def split_hvg_name(model: str) -> tuple[str, bool]:
|
| 195 |
+
"""Split ``<model> + HVG`` into the parent name and whether it is one."""
|
| 196 |
+
suffix = f" {HVG_TAG}"
|
| 197 |
+
if model.endswith(suffix):
|
| 198 |
+
return model.removesuffix(suffix), True
|
| 199 |
+
return model, False
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def without_hvg(df: pd.DataFrame) -> pd.DataFrame:
|
| 203 |
+
"""Drop the rows of every ``<model> + HVG`` entry."""
|
| 204 |
+
return df[~df[MODEL_NAME].astype(str).str.endswith(f" {HVG_TAG}")]
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def fetch_hvg_reference(token: str | None, revision: str = CURRENT.reference) -> Path:
|
| 208 |
+
"""Local path to the HVG-1200 features every submission is concatenated with."""
|
| 209 |
+
from huggingface_hub import hf_hub_download
|
| 210 |
+
|
| 211 |
+
return Path(
|
| 212 |
+
hf_hub_download(
|
| 213 |
+
RESULTS_REPO,
|
| 214 |
+
HVG_REFERENCE_FILE,
|
| 215 |
+
repo_type="dataset",
|
| 216 |
+
token=token,
|
| 217 |
+
revision=revision,
|
| 218 |
+
)
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def upload_hvg_reference(path: str | Path, token: str | None) -> str:
|
| 223 |
+
"""Replace the HVG-1200 features; return the commit that holds them, to pin."""
|
| 224 |
+
from huggingface_hub import HfApi
|
| 225 |
+
|
| 226 |
+
return (
|
| 227 |
+
HfApi(token=token)
|
| 228 |
+
.upload_file(
|
| 229 |
+
path_or_fileobj=str(path),
|
| 230 |
+
path_in_repo=HVG_REFERENCE_FILE,
|
| 231 |
+
repo_id=RESULTS_REPO,
|
| 232 |
+
repo_type="dataset",
|
| 233 |
+
commit_message="Update HVG-1200 reference features",
|
| 234 |
+
)
|
| 235 |
+
.oid
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def task_rows(
|
| 240 |
+
model: str, result: dict, submitted_at: str, submission_id: str, credits: dict
|
| 241 |
+
) -> list[dict]:
|
| 242 |
+
"""One row per task scored in ``result``, credited and tagged with this version."""
|
| 243 |
+
return [
|
| 244 |
+
{
|
| 245 |
+
MODEL_NAME: model,
|
| 246 |
+
"task_id": task.task_id,
|
| 247 |
+
"score": round(float(task.score), 4),
|
| 248 |
+
"repeat_scores": json.dumps(
|
| 249 |
+
[round(float(value), 4) for value in task.repeat_scores]
|
| 250 |
+
),
|
| 251 |
+
"diagnostics": json.dumps(
|
| 252 |
+
{key: round(float(value), 4) for key, value in task.diagnostics.items()}
|
| 253 |
+
),
|
| 254 |
+
"submitted_at": submitted_at,
|
| 255 |
+
IS_BASELINE: False,
|
| 256 |
+
SUBMISSION_ID: submission_id,
|
| 257 |
+
VERSION: CURRENT.number,
|
| 258 |
+
**credits,
|
| 259 |
+
}
|
| 260 |
+
for task in result["per_task"]
|
| 261 |
+
]
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def read_csv(
|
| 265 |
+
filename: str, columns: list[str], token: str | None, revision: str | None = None
|
| 266 |
+
) -> pd.DataFrame:
|
| 267 |
+
"""One CSV of the private results dataset, at ``revision`` or the latest commit.
|
| 268 |
+
|
| 269 |
+
An absent file is an empty table.
|
| 270 |
+
"""
|
| 271 |
+
from huggingface_hub import hf_hub_download
|
| 272 |
+
from huggingface_hub.utils import EntryNotFoundError, RepositoryNotFoundError
|
| 273 |
+
|
| 274 |
+
try:
|
| 275 |
+
path = hf_hub_download(
|
| 276 |
+
RESULTS_REPO, filename, repo_type="dataset", token=token, revision=revision
|
| 277 |
+
)
|
| 278 |
+
except (RepositoryNotFoundError, EntryNotFoundError):
|
| 279 |
+
return pd.DataFrame(columns=columns)
|
| 280 |
+
return pd.read_csv(path, dtype={VERSION: str})
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def upload_csv(
|
| 284 |
+
filename: str, df: pd.DataFrame, token: str | None, parent_commit: str
|
| 285 |
+
) -> None:
|
| 286 |
+
"""Overwrite one CSV, unless a commit landed on top of ``parent_commit``."""
|
| 287 |
+
from huggingface_hub import HfApi
|
| 288 |
+
|
| 289 |
+
buffer = io.BytesIO()
|
| 290 |
+
df.to_csv(buffer, index=False)
|
| 291 |
+
buffer.seek(0)
|
| 292 |
+
HfApi(token=token).upload_file(
|
| 293 |
+
path_or_fileobj=buffer,
|
| 294 |
+
path_in_repo=filename,
|
| 295 |
+
repo_id=RESULTS_REPO,
|
| 296 |
+
repo_type="dataset",
|
| 297 |
+
parent_commit=parent_commit,
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def _append_csv(
|
| 302 |
+
filename: str, extended: Callable[[str], pd.DataFrame], token: str | None
|
| 303 |
+
) -> None:
|
| 304 |
+
"""Upload ``extended(head)``: the CSV read at commit ``head``, plus new rows.
|
| 305 |
+
|
| 306 |
+
When another write lands first, read the CSV again at the new head.
|
| 307 |
+
"""
|
| 308 |
+
from huggingface_hub import HfApi
|
| 309 |
+
from huggingface_hub.utils import HfHubHTTPError
|
| 310 |
+
|
| 311 |
+
api = HfApi(token=token)
|
| 312 |
+
api.create_repo(RESULTS_REPO, repo_type="dataset", private=True, exist_ok=True)
|
| 313 |
+
for _ in range(WRITE_ATTEMPTS):
|
| 314 |
+
head = api.dataset_info(RESULTS_REPO).sha
|
| 315 |
+
try:
|
| 316 |
+
upload_csv(filename, extended(head), token, head)
|
| 317 |
+
return
|
| 318 |
+
except HfHubHTTPError as error:
|
| 319 |
+
if error.response.status_code != HTTPStatus.PRECONDITION_FAILED:
|
| 320 |
+
raise
|
| 321 |
+
raise EvaluatorError(
|
| 322 |
+
f"another write changed {filename} during each of {WRITE_ATTEMPTS} "
|
| 323 |
+
"attempts, so these rows were not saved: try again"
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def archive_paths(submission_id: str, source_format: str) -> tuple[str, str]:
|
| 328 |
+
"""Where a submission's file and its manifest are archived in the results repo."""
|
| 329 |
+
prefix = f"{ARCHIVE_DIR}/{submission_id}"
|
| 330 |
+
return (
|
| 331 |
+
f"{prefix}/submission{source_format or '.bin'}",
|
| 332 |
+
f"{prefix}/{ARCHIVE_MANIFEST}",
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def _embedding_manifest(
|
| 337 |
+
path: str | Path,
|
| 338 |
+
model_name: str,
|
| 339 |
+
submission_id: str,
|
| 340 |
+
modality: str,
|
| 341 |
+
submitted_at: str,
|
| 342 |
+
representation: str = EMBEDDING,
|
| 343 |
+
) -> tuple[dict, bytes]:
|
| 344 |
+
"""Build a privacy-preserving manifest for an embedding submission."""
|
| 345 |
+
source = Path(path)
|
| 346 |
+
digest = hashlib.sha256()
|
| 347 |
+
with source.open("rb") as stream:
|
| 348 |
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
| 349 |
+
digest.update(chunk)
|
| 350 |
+
blocks = load_submission(source)
|
| 351 |
+
frame = pd.concat(
|
| 352 |
+
[block.assign(dataset_id=dataset_id) for dataset_id, block in blocks.items()],
|
| 353 |
+
ignore_index=True,
|
| 354 |
+
)
|
| 355 |
+
counts = frame["dataset_id"].value_counts().sort_index().to_dict()
|
| 356 |
+
embedding_columns = [
|
| 357 |
+
column for column in frame.columns if column not in {"dataset_id", "sample_id"}
|
| 358 |
+
]
|
| 359 |
+
manifest = {
|
| 360 |
+
"model_name": model_name,
|
| 361 |
+
"submission_id": submission_id,
|
| 362 |
+
"submitted_at": submitted_at,
|
| 363 |
+
"modality": modality,
|
| 364 |
+
"representation": representation,
|
| 365 |
+
"source_format": source.suffix.lower(),
|
| 366 |
+
"sha256": digest.hexdigest(),
|
| 367 |
+
"size_bytes": source.stat().st_size,
|
| 368 |
+
"n_rows": len(frame),
|
| 369 |
+
"embedding_dimension": len(embedding_columns),
|
| 370 |
+
"dataset_row_counts": {str(key): int(value) for key, value in counts.items()},
|
| 371 |
+
}
|
| 372 |
+
return manifest, json.dumps(manifest, indent=2).encode()
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def archive_submission(
|
| 376 |
+
path: str | Path,
|
| 377 |
+
model_name: str,
|
| 378 |
+
submission_id: str,
|
| 379 |
+
modality: str,
|
| 380 |
+
submitted_at: str,
|
| 381 |
+
token: str | None,
|
| 382 |
+
representation: str = EMBEDDING,
|
| 383 |
+
) -> None:
|
| 384 |
+
"""Archive an embedding file and its manifest in the private results dataset."""
|
| 385 |
+
from huggingface_hub import CommitOperationAdd, HfApi
|
| 386 |
+
|
| 387 |
+
source = Path(path)
|
| 388 |
+
_, manifest_bytes = _embedding_manifest(
|
| 389 |
+
source, model_name, submission_id, modality, submitted_at, representation
|
| 390 |
+
)
|
| 391 |
+
file_path, manifest_path = archive_paths(submission_id, source.suffix.lower())
|
| 392 |
+
HfApi(token=token).create_commit(
|
| 393 |
+
repo_id=RESULTS_REPO,
|
| 394 |
+
repo_type="dataset",
|
| 395 |
+
operations=[
|
| 396 |
+
CommitOperationAdd(path_in_repo=file_path, path_or_fileobj=source),
|
| 397 |
+
CommitOperationAdd(
|
| 398 |
+
path_in_repo=manifest_path, path_or_fileobj=manifest_bytes
|
| 399 |
+
),
|
| 400 |
+
],
|
| 401 |
+
commit_message=f"Archive embedding submission {submission_id}",
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def read_results(token: str | None, revision: str | None = None) -> pd.DataFrame:
|
| 406 |
+
"""Every persisted task score, with baseline, credit and version normalised."""
|
| 407 |
+
return with_version(
|
| 408 |
+
with_submission_id(
|
| 409 |
+
with_paper_link(
|
| 410 |
+
with_author_submission_flag(
|
| 411 |
+
with_institution(
|
| 412 |
+
with_owner(
|
| 413 |
+
with_baseline_flag(
|
| 414 |
+
read_csv(RESULTS_FILE, RESULT_COLUMNS, token, revision)
|
| 415 |
+
)
|
| 416 |
+
)
|
| 417 |
+
)
|
| 418 |
+
)
|
| 419 |
+
)
|
| 420 |
+
)
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def append_results(rows: list[dict], token: str | None) -> None:
|
| 425 |
+
"""Append scored rows to the leaderboard, keeping the history intact."""
|
| 426 |
+
if not rows:
|
| 427 |
+
return
|
| 428 |
+
|
| 429 |
+
def extended(head: str) -> pd.DataFrame:
|
| 430 |
+
df = pd.concat(
|
| 431 |
+
[read_results(token, head), pd.DataFrame(rows)], ignore_index=True
|
| 432 |
+
)
|
| 433 |
+
return with_version(
|
| 434 |
+
with_submission_id(
|
| 435 |
+
with_paper_link(
|
| 436 |
+
with_author_submission_flag(
|
| 437 |
+
with_institution(with_owner(with_baseline_flag(df)))
|
| 438 |
+
)
|
| 439 |
+
)
|
| 440 |
+
)
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
_append_csv(RESULTS_FILE, extended, token)
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def append_submission(meta: dict, token: str | None) -> None:
|
| 447 |
+
"""Persist private contact metadata with matching public-credit fields."""
|
| 448 |
+
|
| 449 |
+
def extended(head: str) -> pd.DataFrame:
|
| 450 |
+
return pd.concat(
|
| 451 |
+
[
|
| 452 |
+
read_csv(SUBMISSIONS_FILE, SUBMISSION_COLUMNS, token, head),
|
| 453 |
+
pd.DataFrame([meta]),
|
| 454 |
+
],
|
| 455 |
+
ignore_index=True,
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
_append_csv(SUBMISSIONS_FILE, extended, token)
|
scoring.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scoring policy for the PRIMO benchmark: predictions -> per-category numbers.
|
| 2 |
+
|
| 3 |
+
Everything that turns a model's fold-wise cross-validation scores into leaderboard
|
| 4 |
+
numbers lives here, kept apart from the probe and from any I/O so it stays easy
|
| 5 |
+
to change as the benchmark grows.
|
| 6 |
+
|
| 7 |
+
The scoring unit is a task = (dataset, target). Tasks are grouped by their
|
| 8 |
+
``category`` (including treatment outcome, clinical scores, endotype, and
|
| 9 |
+
perturbation response). A category uses a SINGLE metric (enforced in the registry),
|
| 10 |
+
so its leaderboard number is a plain mean of that metric -- AUROC, Pearson and
|
| 11 |
+
centered Spearman are never averaged together inside a category column.
|
| 12 |
+
|
| 13 |
+
``sort_key`` is the one place they are averaged, to give the board a single
|
| 14 |
+
order. It is shown as the ``Mean`` column, labelled as a cross-metric average so
|
| 15 |
+
nobody reads it as a metric in its own right; the per-category columns remain
|
| 16 |
+
the numbers to compare on.
|
| 17 |
+
|
| 18 |
+
Pure numpy / sklearn-metrics -- no huggingface, no file I/O, so it unit-tests
|
| 19 |
+
without a network and is safe to rework mid-project.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from collections import defaultdict
|
| 23 |
+
from collections.abc import Callable
|
| 24 |
+
from dataclasses import dataclass, field
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
from sklearn.metrics import roc_auc_score
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def compute_auroc(
|
| 31 |
+
y_true: np.ndarray, y_pred: np.ndarray, classes: np.ndarray | None = None
|
| 32 |
+
) -> float:
|
| 33 |
+
"""AUROC from class probabilities (``y_pred`` is an ``(n, n_classes)`` matrix).
|
| 34 |
+
|
| 35 |
+
``classes`` names the columns of ``y_pred``. It matters whenever ``y_true``
|
| 36 |
+
holds fewer classes than the task does -- a transfer split whose test cohort
|
| 37 |
+
misses one. The columns of the absent classes are dropped and the rest
|
| 38 |
+
renormalized, so the score never reads a column belonging to another class,
|
| 39 |
+
and never trips sklearn's sum-to-one check.
|
| 40 |
+
"""
|
| 41 |
+
classes = np.unique(y_true) if classes is None else np.asarray(classes)
|
| 42 |
+
present = np.isin(classes, np.unique(y_true))
|
| 43 |
+
scores = np.asarray(y_pred)[:, present]
|
| 44 |
+
totals = scores.sum(axis=1, keepdims=True)
|
| 45 |
+
if not (totals > 0).all():
|
| 46 |
+
raise ValueError(
|
| 47 |
+
"some samples carry no probability on any class present in y_true"
|
| 48 |
+
)
|
| 49 |
+
scores = scores / totals
|
| 50 |
+
kept = classes[present]
|
| 51 |
+
if len(kept) == 2:
|
| 52 |
+
return float(roc_auc_score(y_true, scores[:, 1]))
|
| 53 |
+
return float(
|
| 54 |
+
roc_auc_score(
|
| 55 |
+
y_true, scores, multi_class="ovr", average="weighted", labels=kept
|
| 56 |
+
)
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def compute_pearson(
|
| 61 |
+
y_true: np.ndarray, y_pred: np.ndarray, classes: np.ndarray | None = None
|
| 62 |
+
) -> float:
|
| 63 |
+
"""Pearson r between predictions and targets; NaN if either is constant."""
|
| 64 |
+
if np.std(y_pred) == 0 or np.std(y_true) == 0:
|
| 65 |
+
return float("nan")
|
| 66 |
+
return float(np.corrcoef(y_pred, y_true)[0, 1])
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _average_ranks(values: np.ndarray) -> np.ndarray:
|
| 70 |
+
"""Return stable, one-based average ranks with deterministic tie handling."""
|
| 71 |
+
values = np.asarray(values)
|
| 72 |
+
order = np.argsort(values, kind="stable")
|
| 73 |
+
sorted_values = values[order]
|
| 74 |
+
sorted_ranks = np.empty(len(values), dtype=float)
|
| 75 |
+
start = 0
|
| 76 |
+
while start < len(values):
|
| 77 |
+
stop = start + 1
|
| 78 |
+
while stop < len(values) and sorted_values[stop] == sorted_values[start]:
|
| 79 |
+
stop += 1
|
| 80 |
+
sorted_ranks[start:stop] = 0.5 * (start + stop - 1) + 1.0
|
| 81 |
+
start = stop
|
| 82 |
+
ranks = np.empty(len(values), dtype=float)
|
| 83 |
+
ranks[order] = sorted_ranks
|
| 84 |
+
return ranks
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _mean_sample_spearman(y_true: np.ndarray, y_pred: np.ndarray) -> float:
|
| 88 |
+
"""Return mean row-wise rank correlation, scoring degenerate rows at zero."""
|
| 89 |
+
correlations = []
|
| 90 |
+
for truth, prediction in zip(y_true, y_pred):
|
| 91 |
+
correlation = compute_pearson(_average_ranks(truth), _average_ranks(prediction))
|
| 92 |
+
correlations.append(correlation if np.isfinite(correlation) else 0.0)
|
| 93 |
+
return float(np.mean(correlations)) if correlations else float("nan")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def compute_residual_sample_spearman(
|
| 97 |
+
y_true: np.ndarray,
|
| 98 |
+
y_pred: np.ndarray,
|
| 99 |
+
training_mean: np.ndarray,
|
| 100 |
+
) -> float:
|
| 101 |
+
"""Mean sample-wise Spearman beyond the training-fold mean response."""
|
| 102 |
+
return _mean_sample_spearman(y_true - training_mean, y_pred - training_mean)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def compute_target_centered_sample_spearman(
|
| 106 |
+
y_true: np.ndarray,
|
| 107 |
+
y_pred: np.ndarray,
|
| 108 |
+
) -> float:
|
| 109 |
+
"""Mean sample-wise Spearman after removing each target-cohort gene mean."""
|
| 110 |
+
truth_centered = y_true - np.mean(y_true, axis=0)
|
| 111 |
+
prediction_centered = y_pred - np.mean(y_pred, axis=0)
|
| 112 |
+
return _mean_sample_spearman(truth_centered, prediction_centered)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
METRICS: dict[str, Callable[[np.ndarray, np.ndarray, np.ndarray | None], float]] = {
|
| 116 |
+
"auroc": compute_auroc,
|
| 117 |
+
"pearson": compute_pearson,
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@dataclass(frozen=True)
|
| 122 |
+
class TaskScore:
|
| 123 |
+
"""One task's result: a raw metric plus the category it is grouped under."""
|
| 124 |
+
|
| 125 |
+
task_id: str
|
| 126 |
+
dataset_id: str
|
| 127 |
+
category: str
|
| 128 |
+
metric: str
|
| 129 |
+
score: float
|
| 130 |
+
n_samples: int
|
| 131 |
+
repeat_scores: tuple[float, ...] = ()
|
| 132 |
+
diagnostics: dict[str, float] = field(default_factory=dict)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def category_means(scores: list[TaskScore]) -> dict[str, dict]:
|
| 136 |
+
"""Mean of the native metric per task category.
|
| 137 |
+
|
| 138 |
+
A category uses one metric, so this is a plain mean of that metric -- never a
|
| 139 |
+
mix of native metrics. Degenerate (non-finite) task scores are dropped from
|
| 140 |
+
the mean. Returns ``{category: {metric, mean, n_tasks}}`` for the categories
|
| 141 |
+
present in ``scores``.
|
| 142 |
+
"""
|
| 143 |
+
by_category: dict[str, list[TaskScore]] = defaultdict(list)
|
| 144 |
+
for score in scores:
|
| 145 |
+
by_category[score.category].append(score)
|
| 146 |
+
out = {}
|
| 147 |
+
for category, items in by_category.items():
|
| 148 |
+
finite = [s.score for s in items if np.isfinite(s.score)]
|
| 149 |
+
out[category] = {
|
| 150 |
+
"metric": items[0].metric,
|
| 151 |
+
"mean": float(np.mean(finite)) if finite else float("nan"),
|
| 152 |
+
"n_tasks": len(items),
|
| 153 |
+
}
|
| 154 |
+
return out
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def sort_key(categories: dict[str, dict]) -> float:
|
| 158 |
+
"""Leaderboard ranking key: mean of the per-category means.
|
| 159 |
+
|
| 160 |
+
Orders the rows, and is shown as the ``Mean`` column on boards holding more
|
| 161 |
+
than one category. It does average across native metrics, a
|
| 162 |
+
deliberate compromise for a single order; swap for a per-category-normalized
|
| 163 |
+
mean if the ranking needs to be metric-fair.
|
| 164 |
+
|
| 165 |
+
An entry with nothing finite to average sorts LAST, not at zero: a constant
|
| 166 |
+
embedding scores NaN on every Pearson task, and zero would float it above a
|
| 167 |
+
model that merely correlates negatively -- ranking "no score" over "a bad
|
| 168 |
+
score". Nothing finite means nothing to show, so the table renders it blank.
|
| 169 |
+
"""
|
| 170 |
+
means = [c["mean"] for c in categories.values() if np.isfinite(c["mean"])]
|
| 171 |
+
return float(np.mean(means)) if means else float("-inf")
|
submit_api.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The ``/submit`` endpoint that ``primo submit`` calls.
|
| 2 |
+
|
| 3 |
+
The web form and the endpoint share one path: both call ``run_submission`` in
|
| 4 |
+
``app.py``, so the same checks, scores and records apply to both. The endpoint
|
| 5 |
+
reads the caller's Hugging Face token only to learn the account name, with
|
| 6 |
+
``whoami``. It never stores the token, logs it or puts it in a message. Only a
|
| 7 |
+
non-empty string reaches ``whoami``: for any other value, such as ``""``, ``None``
|
| 8 |
+
or ``True``, ``whoami`` falls back to the token of the Space itself. A 401 or a
|
| 9 |
+
403 from ``whoami`` means a bad token, which the caller fixes. Any other failure,
|
| 10 |
+
such as a timeout or a 5xx, is on our side.
|
| 11 |
+
|
| 12 |
+
Gradio checks that a file comes from an upload only when its payload carries the
|
| 13 |
+
``gradio.FileData`` marker, which ``gradio_client.handle_file`` adds. Without the
|
| 14 |
+
marker, a caller could send the path of any file of the Space, so the endpoint
|
| 15 |
+
refuses such a payload.
|
| 16 |
+
|
| 17 |
+
An account can publish ``SUBMISSIONS_PER_DAY`` submissions in 24 hours, through
|
| 18 |
+
the form and the endpoint together. A file can weigh ``MAX_FILE_SIZE`` at most.
|
| 19 |
+
The form and the endpoint share the ``SUBMISSIONS`` queue, so one submission runs
|
| 20 |
+
at a time.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import logging
|
| 24 |
+
from collections.abc import Callable
|
| 25 |
+
from datetime import datetime, timedelta
|
| 26 |
+
from http import HTTPStatus
|
| 27 |
+
|
| 28 |
+
import pandas as pd
|
| 29 |
+
from results import (
|
| 30 |
+
IS_BASELINE,
|
| 31 |
+
OWNER,
|
| 32 |
+
SUBMISSION_ID,
|
| 33 |
+
with_baseline_flag,
|
| 34 |
+
with_owner,
|
| 35 |
+
with_submission_id,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
PUBLISHED = "published"
|
| 39 |
+
REFUSED = "refused"
|
| 40 |
+
ERROR = "error"
|
| 41 |
+
SUBMISSIONS = "submissions"
|
| 42 |
+
SUBMISSIONS_PER_DAY = 4
|
| 43 |
+
MAX_FILE_SIZE = 500_000_000
|
| 44 |
+
SUBMITTED_AT = "%Y-%m-%d %H:%M:%S.%f"
|
| 45 |
+
FILE_DATA = "gradio.FileData"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def reply(status: str, message: str, submission_id: str = "") -> dict:
|
| 49 |
+
"""The answer to one submission, as ``primo submit`` reads it."""
|
| 50 |
+
return {"status": status, "message": message, "submission_id": submission_id}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def recent_submissions(df: pd.DataFrame, owner: str, now: datetime) -> int:
|
| 54 |
+
"""How many submissions ``owner`` published in the 24 hours before ``now``."""
|
| 55 |
+
since = (now - timedelta(days=1)).strftime(SUBMITTED_AT)
|
| 56 |
+
rows = with_submission_id(with_owner(with_baseline_flag(df)))
|
| 57 |
+
recent = rows[
|
| 58 |
+
(rows[OWNER] == owner)
|
| 59 |
+
& ~rows[IS_BASELINE]
|
| 60 |
+
& (rows["submitted_at"].astype(str) >= since)
|
| 61 |
+
]
|
| 62 |
+
return int(recent[SUBMISSION_ID].nunique())
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def route(core: Callable[..., dict]) -> Callable[..., dict]:
|
| 66 |
+
"""The ``/submit`` endpoint, which checks, scores and saves through ``core``."""
|
| 67 |
+
|
| 68 |
+
def submit(
|
| 69 |
+
submission_path: dict,
|
| 70 |
+
modality: str,
|
| 71 |
+
model_name: str,
|
| 72 |
+
institution: str,
|
| 73 |
+
is_author_submission: bool,
|
| 74 |
+
email: str,
|
| 75 |
+
paper_link: str,
|
| 76 |
+
hf_model_link: str,
|
| 77 |
+
notes: str,
|
| 78 |
+
token: str,
|
| 79 |
+
) -> dict:
|
| 80 |
+
"""Score one submission file that ``primo submit`` sends."""
|
| 81 |
+
from huggingface_hub import HfApi
|
| 82 |
+
|
| 83 |
+
meta = (
|
| 84 |
+
submission_path.get("meta") if isinstance(submission_path, dict) else None
|
| 85 |
+
)
|
| 86 |
+
if not isinstance(meta, dict) or meta.get("_type") != FILE_DATA:
|
| 87 |
+
return reply(
|
| 88 |
+
REFUSED, "Send the submission file with gradio_client.handle_file."
|
| 89 |
+
)
|
| 90 |
+
if not isinstance(token, str) or not token.strip():
|
| 91 |
+
return reply(REFUSED, "Send a Hugging Face token: run `hf auth login`.")
|
| 92 |
+
try:
|
| 93 |
+
username = HfApi().whoami(token=token.strip())["name"]
|
| 94 |
+
except Exception as error: # noqa: BLE001
|
| 95 |
+
status = getattr(getattr(error, "response", None), "status_code", None)
|
| 96 |
+
if status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
|
| 97 |
+
return reply(
|
| 98 |
+
REFUSED,
|
| 99 |
+
"Hugging Face refused the token. Log in again with "
|
| 100 |
+
"`hf auth login`.",
|
| 101 |
+
)
|
| 102 |
+
logging.warning(
|
| 103 |
+
"whoami failed: %s, status %s", type(error).__name__, status
|
| 104 |
+
)
|
| 105 |
+
return reply(
|
| 106 |
+
ERROR,
|
| 107 |
+
"⚠️ We could not check the token with Hugging Face. This is on our "
|
| 108 |
+
"side, not your token. Please try again in a moment.",
|
| 109 |
+
)
|
| 110 |
+
return core(
|
| 111 |
+
submission_path["path"],
|
| 112 |
+
modality,
|
| 113 |
+
model_name,
|
| 114 |
+
institution,
|
| 115 |
+
is_author_submission,
|
| 116 |
+
email,
|
| 117 |
+
paper_link,
|
| 118 |
+
hf_model_link,
|
| 119 |
+
notes,
|
| 120 |
+
username,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
return submit
|
versions.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Board versions: the Hub commits that each version of the leaderboard reads.
|
| 2 |
+
|
| 3 |
+
A board version is a minor release of primo-eval: version ``0.1`` is scored by the
|
| 4 |
+
``v0.1.x`` releases. It pins the commit of every Hub repo that a score reads, so a
|
| 5 |
+
push to one of these repos changes no score until a release opens a new version.
|
| 6 |
+
|
| 7 |
+
``versions.yaml`` lists every version. ``current`` is the version this code scores
|
| 8 |
+
and shows by default. Each version names its release tag, the day it opened, and
|
| 9 |
+
three commits: ``data`` of the public data repo, ``labels`` of the private labels
|
| 10 |
+
repo, and ``reference`` of the results repo, which holds the HVG features. Its
|
| 11 |
+
``notes`` say in one line what changed: the board shows them to every visitor.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import yaml
|
| 18 |
+
|
| 19 |
+
VERSIONS_FILE = Path(__file__).parent / "versions.yaml"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class Version:
|
| 24 |
+
"""One board version, the Hub commits its scores read, and what changed."""
|
| 25 |
+
|
| 26 |
+
number: str
|
| 27 |
+
release: str
|
| 28 |
+
opened: str
|
| 29 |
+
data: str
|
| 30 |
+
labels: str
|
| 31 |
+
reference: str
|
| 32 |
+
notes: str = ""
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _quoted(entry: dict, path: Path) -> dict:
|
| 36 |
+
"""Refuse the values that YAML reads as numbers or dates, such as ``0.10``."""
|
| 37 |
+
unquoted = sorted(key for key, value in entry.items() if not isinstance(value, str))
|
| 38 |
+
if unquoted:
|
| 39 |
+
raise ValueError(f"quote the values of {unquoted} in {path}")
|
| 40 |
+
return entry
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def load_versions(path: str | Path) -> tuple[Version, list[Version]]:
|
| 44 |
+
"""The current version and every version listed in ``path``."""
|
| 45 |
+
path = Path(path)
|
| 46 |
+
raw = yaml.safe_load(path.read_text())
|
| 47 |
+
current = _quoted({"current": raw["current"]}, path)["current"]
|
| 48 |
+
versions = [Version(**_quoted(entry, path)) for entry in raw["versions"]]
|
| 49 |
+
numbers = [version.number for version in versions]
|
| 50 |
+
if len(set(numbers)) != len(numbers):
|
| 51 |
+
raise ValueError(f"a version is listed twice in {path}: {numbers}")
|
| 52 |
+
if current not in numbers:
|
| 53 |
+
raise ValueError(f"the current version {current} is not listed in {path}")
|
| 54 |
+
return versions[numbers.index(current)], versions
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
CURRENT, VERSIONS = load_versions(VERSIONS_FILE)
|
versions.yaml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
current: "0.1"
|
| 2 |
+
versions:
|
| 3 |
+
- number: "0.1"
|
| 4 |
+
release: "v0.1.0"
|
| 5 |
+
opened: "2026-10-19"
|
| 6 |
+
data: "0f452b2fc84e544fb1a1fc5bd42017482e07b43c"
|
| 7 |
+
labels: "b5cd56fc5cec748fba5db6280477167a45d5cc1a"
|
| 8 |
+
reference: "6f3e0b3e9413591ffbf34fc51cf9f0a51c6ab399"
|
| 9 |
+
notes: "First board version: 70 tasks on bulk and single-cell RNAseq. Its scores are the reference for the later versions."
|