Spaces:
Running
Running
Add cross-species perturbation task coverage
Browse files- README.md +1 -1
- conftest.py +145 -0
- test_boards.py +207 -0
- test_evaluator.py +787 -0
- test_leaderboard.py +292 -0
- test_render.py +248 -0
- test_results.py +123 -0
- test_scoring.py +133 -0
README.md
CHANGED
|
@@ -170,7 +170,7 @@ Keep the live benchmark unchanged until the local artifact and baseline checks
|
|
| 170 |
pass. Then deploy in this order: the backward-compatible scorer, public baseline
|
| 171 |
inputs, private targets and registry, and finally refreshed baselines. Existing
|
| 172 |
task-result rows stay in place; their submissions simply lack coverage for the
|
| 173 |
-
new datasets until their owners submit embeddings for `d011`–`
|
| 174 |
|
| 175 |
## Baselines
|
| 176 |
|
|
|
|
| 170 |
pass. Then deploy in this order: the backward-compatible scorer, public baseline
|
| 171 |
inputs, private targets and registry, and finally refreshed baselines. Existing
|
| 172 |
task-result rows stay in place; their submissions simply lack coverage for the
|
| 173 |
+
new datasets until their owners submit embeddings for `d011`–`d016`.
|
| 174 |
|
| 175 |
## Baselines
|
| 176 |
|
conftest.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared fixtures for the Space unit tests -- no network, no Gradio.
|
| 2 |
+
|
| 3 |
+
One small registry stands in for the real ``tasks.yaml``: two tasks share a
|
| 4 |
+
dataset (so per-cohort counting is exercised), three therapeutic areas and
|
| 5 |
+
three categories, all on one modality. Every dataset gets its own ``cohort_id``
|
| 6 |
+
here; the tests that care about shared cohorts rewrite one.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import pytest
|
| 11 |
+
from boards import build_boards
|
| 12 |
+
from results import IS_BASELINE
|
| 13 |
+
|
| 14 |
+
RESULT_ROW = ["model_name", "task_id", "score", "submitted_at"]
|
| 15 |
+
|
| 16 |
+
REGISTRY = {
|
| 17 |
+
"t001": {
|
| 18 |
+
"task_id": "t001",
|
| 19 |
+
"dataset_id": "d001",
|
| 20 |
+
"cohort_id": "ibd-antitnf",
|
| 21 |
+
"category": "treatment_outcome",
|
| 22 |
+
"metric": "auroc",
|
| 23 |
+
"modality": "bulk RNA",
|
| 24 |
+
"therapeutic_area": "Gastroenterology",
|
| 25 |
+
"diseases": ["Crohn Disease", "Colitis, Ulcerative"],
|
| 26 |
+
"tissue": "Digestive system",
|
| 27 |
+
"title": "IBD adalimumab remission",
|
| 28 |
+
"description": "Will this patient respond?",
|
| 29 |
+
"n_samples": 90,
|
| 30 |
+
"label": {0: "no", 1: "yes"},
|
| 31 |
+
"sources": ["E-MTAB-7604"],
|
| 32 |
+
"license": "EMBL-EBI Terms of Use",
|
| 33 |
+
},
|
| 34 |
+
"t002": {
|
| 35 |
+
"task_id": "t002",
|
| 36 |
+
"dataset_id": "d002",
|
| 37 |
+
"cohort_id": "cd-mucosa",
|
| 38 |
+
"category": "clinical_scores",
|
| 39 |
+
"metric": "pearson",
|
| 40 |
+
"modality": "bulk RNA",
|
| 41 |
+
"therapeutic_area": "Gastroenterology",
|
| 42 |
+
"diseases": ["Crohn Disease"],
|
| 43 |
+
"tissue": "Digestive system",
|
| 44 |
+
"title": "Crohn SES-CD",
|
| 45 |
+
"description": "How severe?",
|
| 46 |
+
"n_samples": 120,
|
| 47 |
+
"sources": ["GSE193677"],
|
| 48 |
+
"license": "Public (NCBI GEO)",
|
| 49 |
+
},
|
| 50 |
+
"t003": {
|
| 51 |
+
"task_id": "t003",
|
| 52 |
+
"dataset_id": "d002",
|
| 53 |
+
"cohort_id": "cd-mucosa",
|
| 54 |
+
"category": "clinical_scores",
|
| 55 |
+
"metric": "pearson",
|
| 56 |
+
"modality": "bulk RNA",
|
| 57 |
+
"therapeutic_area": "Gastroenterology",
|
| 58 |
+
"diseases": ["Crohn Disease"],
|
| 59 |
+
"tissue": "Digestive system",
|
| 60 |
+
"title": "Crohn HBI",
|
| 61 |
+
"description": "How severe?",
|
| 62 |
+
"n_samples": 110,
|
| 63 |
+
"sources": ["GSE193677"],
|
| 64 |
+
"license": "Public (NCBI GEO)",
|
| 65 |
+
},
|
| 66 |
+
"t004": {
|
| 67 |
+
"task_id": "t004",
|
| 68 |
+
"dataset_id": "d005",
|
| 69 |
+
"cohort_id": "pso-skin",
|
| 70 |
+
"category": "clinical_scores",
|
| 71 |
+
"metric": "pearson",
|
| 72 |
+
"modality": "bulk RNA",
|
| 73 |
+
"therapeutic_area": "Dermatology",
|
| 74 |
+
"diseases": ["Psoriasis"],
|
| 75 |
+
"tissue": "Skin",
|
| 76 |
+
"title": "Psoriasis PASI",
|
| 77 |
+
"description": "How severe?",
|
| 78 |
+
"n_samples": 80,
|
| 79 |
+
"sources": ["GSE54456"],
|
| 80 |
+
"license": "Public (NCBI GEO)",
|
| 81 |
+
},
|
| 82 |
+
"t005": {
|
| 83 |
+
"task_id": "t005",
|
| 84 |
+
"dataset_id": "d007",
|
| 85 |
+
"cohort_id": "ra-cohort",
|
| 86 |
+
"category": "endotype",
|
| 87 |
+
"metric": "auroc",
|
| 88 |
+
"modality": "bulk RNA",
|
| 89 |
+
"therapeutic_area": "Rheumatology",
|
| 90 |
+
"diseases": ["Arthritis, Rheumatoid"],
|
| 91 |
+
"tissue": "Blood",
|
| 92 |
+
"title": "RA blood endotype",
|
| 93 |
+
"description": "Which subtype?",
|
| 94 |
+
"n_samples": 60,
|
| 95 |
+
"label": {0: "fibroid", 1: "lymphoid"},
|
| 96 |
+
"sources": ["E-MTAB-6141"],
|
| 97 |
+
"license": "EMBL-EBI Terms of Use",
|
| 98 |
+
},
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@pytest.fixture
|
| 103 |
+
def registry() -> dict[str, dict]:
|
| 104 |
+
return {task_id: dict(task) for task_id, task in REGISTRY.items()}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@pytest.fixture
|
| 108 |
+
def boards(registry):
|
| 109 |
+
return build_boards(registry)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@pytest.fixture
|
| 113 |
+
def named(boards):
|
| 114 |
+
"""Look a board up by its display name, e.g. ``named("Rheumatology")``."""
|
| 115 |
+
|
| 116 |
+
def _named(name: str):
|
| 117 |
+
return next(
|
| 118 |
+
board for board in boards if board.name == name or board.modality == name
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
return _named
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@pytest.fixture
|
| 125 |
+
def results():
|
| 126 |
+
"""A ``task_results.csv`` frame from ``(model, task_id, score, at)`` rows.
|
| 127 |
+
|
| 128 |
+
Deliberately without ``is_baseline``: this is the shape of every row written
|
| 129 |
+
before baselines existed, so the whole suite keeps exercising that path.
|
| 130 |
+
"""
|
| 131 |
+
|
| 132 |
+
def _results(*rows) -> pd.DataFrame:
|
| 133 |
+
return pd.DataFrame(list(rows), columns=RESULT_ROW)
|
| 134 |
+
|
| 135 |
+
return _results
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@pytest.fixture
|
| 139 |
+
def flagged_results():
|
| 140 |
+
"""The same, with the flag: ``(model, task_id, score, at, is_baseline)`` rows."""
|
| 141 |
+
|
| 142 |
+
def _flagged(*rows) -> pd.DataFrame:
|
| 143 |
+
return pd.DataFrame(list(rows), columns=[*RESULT_ROW, IS_BASELINE])
|
| 144 |
+
|
| 145 |
+
return _flagged
|
test_boards.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for board construction (no network, no Gradio)."""
|
| 2 |
+
|
| 3 |
+
from boards import (
|
| 4 |
+
AREA_GROUP,
|
| 5 |
+
CATEGORY_GROUP,
|
| 6 |
+
MODALITY_GROUP,
|
| 7 |
+
OPEN_BOARDS,
|
| 8 |
+
build_boards,
|
| 9 |
+
by_slug,
|
| 10 |
+
featured,
|
| 11 |
+
in_group,
|
| 12 |
+
metric_label,
|
| 13 |
+
open_in_group,
|
| 14 |
+
short_code,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_modality_board_holds_every_task_of_that_modality(named, registry):
|
| 19 |
+
assert named("bulk RNA").task_ids == set(registry)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_area_and_category_boards_each_partition_the_modality(boards, registry):
|
| 23 |
+
for group in (AREA_GROUP, CATEGORY_GROUP):
|
| 24 |
+
covered = [tid for b in in_group(boards, group) for tid in b.task_ids]
|
| 25 |
+
assert sorted(covered) == sorted(registry)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_patients_are_counted_per_cohort_not_per_task(named):
|
| 29 |
+
"""t002 and t003 read off the same biopsies -- one cohort, not two."""
|
| 30 |
+
gastro = named("Gastroenterology")
|
| 31 |
+
assert gastro.n_tasks == 3
|
| 32 |
+
assert gastro.n_cohorts == 2
|
| 33 |
+
assert gastro.n_patients == 90 + 120
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_board_counts_roll_up_to_the_modality(named):
|
| 37 |
+
board = named("bulk RNA")
|
| 38 |
+
assert (board.n_cohorts, board.n_patients, board.n_diseases) == (4, 350, 4)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_datasets_cut_from_the_same_patients_are_counted_once(registry):
|
| 42 |
+
"""Two arms of one trial are two dataset_ids but one group of people."""
|
| 43 |
+
registry["t005"]["cohort_id"] = registry["t001"]["cohort_id"]
|
| 44 |
+
board = next(b for b in build_boards(registry) if b.name == "bulk RNAseq")
|
| 45 |
+
assert board.n_cohorts == 3
|
| 46 |
+
assert board.n_patients == 90 + 120 + 80
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_a_registry_without_cohorts_still_counts_per_dataset(registry):
|
| 50 |
+
"""Falling back to dataset_id is the old behaviour, not a crash."""
|
| 51 |
+
for task in registry.values():
|
| 52 |
+
del task["cohort_id"]
|
| 53 |
+
board = next(b for b in build_boards(registry) if b.name == "bulk RNAseq")
|
| 54 |
+
assert (board.n_cohorts, board.n_patients) == (4, 350)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_a_board_never_spans_two_modalities(registry):
|
| 58 |
+
registry["t006"] = {
|
| 59 |
+
**registry["t005"],
|
| 60 |
+
"task_id": "t006",
|
| 61 |
+
"modality": "single-cell",
|
| 62 |
+
}
|
| 63 |
+
for board in build_boards(registry):
|
| 64 |
+
modalities = {registry[t]["modality"] for t in board.task_ids}
|
| 65 |
+
assert modalities == {board.modality}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_a_second_modality_adds_cards_instead_of_mixing_them(registry, boards):
|
| 69 |
+
"""One single-cell task earns its own modality, area and category card."""
|
| 70 |
+
registry["t006"] = {
|
| 71 |
+
**registry["t005"],
|
| 72 |
+
"task_id": "t006",
|
| 73 |
+
"modality": "single-cell",
|
| 74 |
+
}
|
| 75 |
+
grown = build_boards(registry)
|
| 76 |
+
assert len(in_group(grown, MODALITY_GROUP)) == 2
|
| 77 |
+
assert len(grown) == len(boards) + 3
|
| 78 |
+
rheumatology = [b for b in grown if b.name == "Rheumatology"]
|
| 79 |
+
assert {b.modality for b in rheumatology} == {"bulk RNA", "single-cell"}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_featured_leads_with_the_modality_board(boards):
|
| 83 |
+
assert [b.name for b in featured(boards)][0] == "bulk RNAseq"
|
| 84 |
+
assert len(featured(boards)) == 3
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_slugs_are_unique_and_url_safe(boards):
|
| 88 |
+
slugs = [b.slug for b in boards]
|
| 89 |
+
assert len(set(slugs)) == len(slugs)
|
| 90 |
+
assert all(s.replace("-", "").isalnum() for s in slugs)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_unknown_slug_falls_back_to_the_hero_board(boards):
|
| 94 |
+
assert by_slug(boards, "does-not-exist").name == "bulk RNAseq"
|
| 95 |
+
assert by_slug(boards, None).name == "bulk RNAseq"
|
| 96 |
+
assert by_slug([], "anything") is None
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_slug_round_trips(boards):
|
| 100 |
+
for board in boards:
|
| 101 |
+
assert by_slug(boards, board.slug) is board
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_registry_without_areas_degrades_to_modality_boards(registry):
|
| 105 |
+
for task in registry.values():
|
| 106 |
+
del task["therapeutic_area"]
|
| 107 |
+
boards = build_boards(registry)
|
| 108 |
+
assert in_group(boards, AREA_GROUP) == []
|
| 109 |
+
assert [b.name for b in in_group(boards, MODALITY_GROUP)] == ["bulk RNAseq"]
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_empty_registry_yields_no_boards():
|
| 113 |
+
assert build_boards({}) == []
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_short_codes_come_from_the_name_not_a_lookup_table():
|
| 117 |
+
"""A modality nobody planned for still gets a tag instead of a fallback."""
|
| 118 |
+
assert short_code("bulk RNA") == "BUL"
|
| 119 |
+
assert short_code("single-cell RNA") == "SIN"
|
| 120 |
+
assert short_code("Treatment outcome") == "TRE"
|
| 121 |
+
assert short_code("spatial metabolomics") == "SPA"
|
| 122 |
+
assert short_code("") == "n/a"
|
| 123 |
+
assert short_code("42 (!)") == "n/a"
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_every_board_carries_a_code(boards):
|
| 127 |
+
assert all(board.code.isalpha() and len(board.code) == 3 for board in boards)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _declared(group: str) -> list[str]:
|
| 131 |
+
return [b.name for b in OPEN_BOARDS if b.group == group]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_open_boards_are_the_slices_the_registry_lacks(boards):
|
| 135 |
+
for group in (MODALITY_GROUP, AREA_GROUP):
|
| 136 |
+
names = [b.name for b in open_in_group(boards, group)]
|
| 137 |
+
assert names == _declared(group)
|
| 138 |
+
assert "bulk RNA" not in _declared(MODALITY_GROUP)
|
| 139 |
+
assert "Gastroenterology" not in _declared(AREA_GROUP)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_an_open_board_drops_out_when_the_registry_covers_it(registry):
|
| 143 |
+
registry["t006"] = {
|
| 144 |
+
**registry["t005"],
|
| 145 |
+
"task_id": "t006",
|
| 146 |
+
"modality": "single-cell RNA",
|
| 147 |
+
}
|
| 148 |
+
boards = build_boards(registry)
|
| 149 |
+
names = [b.name for b in open_in_group(boards, MODALITY_GROUP)]
|
| 150 |
+
assert "single-cell RNAseq" not in names
|
| 151 |
+
assert len(names) == len(_declared(MODALITY_GROUP)) - 1
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_an_open_area_drops_out_when_a_cohort_lands_there(registry):
|
| 155 |
+
registry["t006"] = {
|
| 156 |
+
**registry["t005"],
|
| 157 |
+
"task_id": "t006",
|
| 158 |
+
"therapeutic_area": "Oncology",
|
| 159 |
+
}
|
| 160 |
+
names = [b.name for b in open_in_group(build_boards(registry), AREA_GROUP)]
|
| 161 |
+
assert "Oncology" not in names
|
| 162 |
+
assert names == ["Neurology", "Pulmonology"]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_task_categories_never_get_an_open_card(boards):
|
| 166 |
+
"""A category is pinned to one metric, so an open one would promise a probe."""
|
| 167 |
+
assert open_in_group(boards, CATEGORY_GROUP) == []
|
| 168 |
+
assert _declared(CATEGORY_GROUP) == []
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def test_an_empty_registry_still_states_every_open_slice():
|
| 172 |
+
assert len(open_in_group([], MODALITY_GROUP)) == len(_declared(MODALITY_GROUP))
|
| 173 |
+
assert len(open_in_group([], AREA_GROUP)) == len(_declared(AREA_GROUP))
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_a_truncated_list_drops_the_and(registry):
|
| 177 |
+
"""``a, b and c…`` reads as an ellipsis stuck to c; ``a, b, c…`` reads as more."""
|
| 178 |
+
from boards import _join
|
| 179 |
+
|
| 180 |
+
assert _join(["a", "b"]) == "a and b"
|
| 181 |
+
assert _join(["a", "b", "c"]) == "a, b and c"
|
| 182 |
+
assert _join(["a", "b", "c", "d"]) == "a, b, c…"
|
| 183 |
+
assert _join([]) == "n/a"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_blurbs_describe_what_the_board_asks(named):
|
| 187 |
+
assert "bulk RNAseq" in named("bulk RNA").blurb
|
| 188 |
+
assert "respond" in named("Treatment outcome").blurb
|
| 189 |
+
assert named("Clinical scores").blurb == "How severe is this patient's disease?"
|
| 190 |
+
assert "Psoriasis" in named("Dermatology").blurb
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def test_perturbation_category_is_a_regular_board_with_residual_spearman(registry):
|
| 194 |
+
registry["t018"] = {
|
| 195 |
+
**registry["t005"],
|
| 196 |
+
"task_id": "t018",
|
| 197 |
+
"category": "perturbation_prediction",
|
| 198 |
+
"metric": "residual_spearman",
|
| 199 |
+
}
|
| 200 |
+
board = next(
|
| 201 |
+
board
|
| 202 |
+
for board in build_boards(registry)
|
| 203 |
+
if board.name == "Perturbation response"
|
| 204 |
+
)
|
| 205 |
+
assert board.task_ids == {"t018"}
|
| 206 |
+
assert "treatment" in board.blurb.lower()
|
| 207 |
+
assert metric_label("residual_spearman") == "Residual Spearman score"
|
test_evaluator.py
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the standalone PRIMO probe + score_all (no network)."""
|
| 2 |
+
|
| 3 |
+
import evaluator as ev
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_results_stay_with_scientalab_until_the_history_is_migrated():
|
| 10 |
+
assert ev.RESULTS_REPO == "ScientaLab/primo-results"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _labels(y, folds, ids=None, repeat_folds=None):
|
| 14 |
+
ids = ids if ids is not None else [f"s{i}" for i in range(len(y))]
|
| 15 |
+
labels = pd.DataFrame({ev.SAMPLE_ID: ids, ev.LABEL: y, ev.FOLD: folds})
|
| 16 |
+
if repeat_folds is not None:
|
| 17 |
+
for repeat, values in enumerate(repeat_folds, start=1):
|
| 18 |
+
labels[f"{ev.FOLD}_{repeat}"] = values
|
| 19 |
+
return labels
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _emb_frame(matrix, ids=None):
|
| 23 |
+
ids = ids if ids is not None else [f"s{i}" for i in range(len(matrix))]
|
| 24 |
+
df = pd.DataFrame(matrix)
|
| 25 |
+
df.index = ids
|
| 26 |
+
return df
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _folds(n, k=5):
|
| 30 |
+
return np.array([i % k for i in range(n)])
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _task(task_id, dataset_id, task_type, metric, therapeutic_area, category=None):
|
| 34 |
+
if category is None:
|
| 35 |
+
category = "treatment_outcome" if metric == "auroc" else "clinical_scores"
|
| 36 |
+
return {
|
| 37 |
+
"task_id": task_id,
|
| 38 |
+
"dataset_id": dataset_id,
|
| 39 |
+
"task_type": task_type,
|
| 40 |
+
"metric": metric,
|
| 41 |
+
"category": category,
|
| 42 |
+
"therapeutic_area": therapeutic_area,
|
| 43 |
+
"disease": "PRIVATE",
|
| 44 |
+
"tissue": "PRIVATE",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _classification(n=60, seed=42, signal=True):
|
| 49 |
+
rng = np.random.default_rng(seed)
|
| 50 |
+
y = np.array([0, 1] * (n // 2))
|
| 51 |
+
emb = rng.normal(size=(n, 8))
|
| 52 |
+
if signal:
|
| 53 |
+
emb[:, 0] += 3.0 * y
|
| 54 |
+
ids = [f"s{i}" for i in range(n)]
|
| 55 |
+
task = _task("t001", "d001", "classification", "auroc", "Gastro")
|
| 56 |
+
return task, _labels(y, _folds(n), ids), ids, emb
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _regression(n=60, seed=7, signal=True):
|
| 60 |
+
rng = np.random.default_rng(seed)
|
| 61 |
+
y = rng.normal(size=n)
|
| 62 |
+
emb = rng.normal(size=(n, 8))
|
| 63 |
+
if signal:
|
| 64 |
+
emb[:, 0] += 2.0 * y
|
| 65 |
+
ids = [f"r{i}" for i in range(n)]
|
| 66 |
+
task = _task("t002", "d002", "regression", "pearson", "Derm")
|
| 67 |
+
return task, _labels(y, _folds(n), ids), ids, emb
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _write_csv(path, blocks):
|
| 71 |
+
"""blocks = {dataset_id: (ids, matrix)} -> a multi-dataset CSV (NaN-padded)."""
|
| 72 |
+
frames = []
|
| 73 |
+
for dataset_id, (ids, mat) in blocks.items():
|
| 74 |
+
df = pd.DataFrame(mat, columns=[f"e{j}" for j in range(mat.shape[1])])
|
| 75 |
+
df.insert(0, ev.SAMPLE_ID, ids)
|
| 76 |
+
df.insert(0, ev.DATASET_ID, dataset_id)
|
| 77 |
+
frames.append(df)
|
| 78 |
+
pd.concat(frames, ignore_index=True).to_csv(path, index=False)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _no_fetch(task_id, token=None):
|
| 82 |
+
raise AssertionError("fetch_labels should not have been called")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# --- probe: repeated fold-wise scoring ---
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_repeated_cv_score_averages_external_partitions(monkeypatch):
|
| 89 |
+
labels = _labels(
|
| 90 |
+
np.array([0, 1, 0, 1]),
|
| 91 |
+
np.array([0, 1, 0, 1]),
|
| 92 |
+
repeat_folds=[np.array([1, 0, 1, 0]), np.array([2, 0, 2, 0])],
|
| 93 |
+
)
|
| 94 |
+
seen = []
|
| 95 |
+
|
| 96 |
+
def fold_score(metric, task_type, y, folds, matrix, classes):
|
| 97 |
+
seen.append(folds[0])
|
| 98 |
+
return float(folds[0])
|
| 99 |
+
|
| 100 |
+
monkeypatch.setattr(ev, "_fold_score", fold_score)
|
| 101 |
+
score = ev._repeated_cv_score(
|
| 102 |
+
metric="auroc",
|
| 103 |
+
task_type="classification",
|
| 104 |
+
y=labels[ev.LABEL].to_numpy(),
|
| 105 |
+
labels=labels,
|
| 106 |
+
matrix=np.ones((4, 2)),
|
| 107 |
+
classes=np.array([0, 1]),
|
| 108 |
+
)
|
| 109 |
+
assert seen == [0, 1, 2]
|
| 110 |
+
assert score == 1.0
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_classification_signal_and_null():
|
| 114 |
+
task, labels, ids, emb = _classification(signal=True)
|
| 115 |
+
res = ev.score_task(task, labels, _emb_frame(emb, ids))
|
| 116 |
+
assert res.score > 0.9
|
| 117 |
+
_, _, ids0, null = _classification(signal=False)
|
| 118 |
+
res0 = ev.score_task(task, labels, _emb_frame(null, ids0))
|
| 119 |
+
assert 0.3 < res0.score < 0.7
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_regression_signal_and_null():
|
| 123 |
+
task, labels, ids, emb = _regression(signal=True)
|
| 124 |
+
res = ev.score_task(task, labels, _emb_frame(emb, ids))
|
| 125 |
+
assert res.score > 0.8
|
| 126 |
+
_, _, ids0, null = _regression(signal=False)
|
| 127 |
+
res0 = ev.score_task(task, labels, _emb_frame(null, ids0))
|
| 128 |
+
assert abs(res0.score) < 0.4
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_score_carries_ids_category_and_size():
|
| 132 |
+
task, labels, ids, emb = _classification(n=30)
|
| 133 |
+
res = ev.score_task(task, labels, _emb_frame(emb, ids))
|
| 134 |
+
assert res.task_id == "t001"
|
| 135 |
+
assert res.dataset_id == "d001"
|
| 136 |
+
assert res.n_samples == 30
|
| 137 |
+
assert res.category == "treatment_outcome"
|
| 138 |
+
assert res.metric == "auroc"
|
| 139 |
+
assert np.isfinite(res.score)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_missing_sample_raises():
|
| 143 |
+
emb = _emb_frame(np.random.default_rng(1).normal(size=(5, 3)))
|
| 144 |
+
labels = _labels(np.array([0, 1, 0, 1, 0, 1]), _folds(6))
|
| 145 |
+
with pytest.raises(ev.SubmissionError, match="missing"):
|
| 146 |
+
ev._align(labels, emb)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_nan_raises():
|
| 150 |
+
matrix = np.ones((4, 3))
|
| 151 |
+
matrix[0, 0] = np.nan
|
| 152 |
+
labels = _labels(np.array([0, 1, 0, 1]), np.array([0, 1, 2, 3]))
|
| 153 |
+
with pytest.raises(ev.SubmissionError, match="NaN"):
|
| 154 |
+
ev._align(labels, _emb_frame(matrix))
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_id_normalisation_int_vs_str():
|
| 158 |
+
emb = _emb_frame(np.eye(4), ids=["0", "1", "2", "3"])
|
| 159 |
+
labels = _labels(np.array([0, 1, 0, 1]), np.array([0, 1, 2, 3]), ids=[0, 1, 2, 3])
|
| 160 |
+
assert ev._align(labels, emb).shape == (4, 4)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# --- multi-dataset loader ---
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def test_multidataset_loader_csv(tmp_path):
|
| 167 |
+
rng = np.random.default_rng(0)
|
| 168 |
+
blocks = {
|
| 169 |
+
"d001": (["a", "b"], rng.normal(size=(2, 4))),
|
| 170 |
+
"d002": (["0", "1", "2"], rng.normal(size=(3, 4))),
|
| 171 |
+
}
|
| 172 |
+
_write_csv(tmp_path / "sub.csv", blocks)
|
| 173 |
+
out = ev.load_submission(tmp_path / "sub.csv")
|
| 174 |
+
assert set(out) == {"d001", "d002"}
|
| 175 |
+
assert ev._to_embedding_frame(out["d001"]).shape == (2, 4)
|
| 176 |
+
assert list(ev._to_embedding_frame(out["d002"]).index) == ["0", "1", "2"]
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_multidataset_loader_npz(tmp_path):
|
| 180 |
+
rng = np.random.default_rng(1)
|
| 181 |
+
ids = np.array(["a", "b", "c", "d"])
|
| 182 |
+
dsids = np.array(["d001", "d001", "d002", "d002"])
|
| 183 |
+
emb = rng.normal(size=(4, 5))
|
| 184 |
+
np.savez(tmp_path / "sub.npz", dataset_ids=dsids, sample_ids=ids, embeddings=emb)
|
| 185 |
+
out = ev.load_submission(tmp_path / "sub.npz")
|
| 186 |
+
assert set(out) == {"d001", "d002"}
|
| 187 |
+
assert ev._to_embedding_frame(out["d001"]).shape == (2, 5)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_missing_dataset_id_column_raises(tmp_path):
|
| 191 |
+
df = pd.DataFrame({ev.SAMPLE_ID: ["a", "b"], "e0": [0.1, 0.2]})
|
| 192 |
+
df.to_csv(tmp_path / "sub.csv", index=False)
|
| 193 |
+
with pytest.raises(ev.SubmissionError, match="dataset_id"):
|
| 194 |
+
ev.load_submission(tmp_path / "sub.csv")
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def test_ragged_padding_dims(tmp_path):
|
| 198 |
+
blocks = {
|
| 199 |
+
"d001": (["a", "b"], np.ones((2, 4))),
|
| 200 |
+
"d002": (["a", "b"], np.ones((2, 6))),
|
| 201 |
+
}
|
| 202 |
+
_write_csv(tmp_path / "sub.csv", blocks)
|
| 203 |
+
out = ev.load_submission(tmp_path / "sub.csv")
|
| 204 |
+
assert ev._to_embedding_frame(out["d001"]).shape[1] == 4
|
| 205 |
+
assert ev._to_embedding_frame(out["d002"]).shape[1] == 6
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_sample_id_reused_across_datasets_ok(tmp_path):
|
| 209 |
+
blocks = {
|
| 210 |
+
"d001": (["x", "y"], np.ones((2, 3))),
|
| 211 |
+
"d002": (["x", "y"], np.ones((2, 3))),
|
| 212 |
+
}
|
| 213 |
+
_write_csv(tmp_path / "sub.csv", blocks)
|
| 214 |
+
out = ev.load_submission(tmp_path / "sub.csv")
|
| 215 |
+
assert ev._to_embedding_frame(out["d001"]).shape == (2, 3)
|
| 216 |
+
assert ev._to_embedding_frame(out["d002"]).shape == (2, 3)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def test_duplicate_within_dataset_raises():
|
| 220 |
+
df = pd.DataFrame({ev.SAMPLE_ID: ["a", "a"], "e0": [1.0, 2.0], "e1": [3.0, 4.0]})
|
| 221 |
+
with pytest.raises(ev.SubmissionError, match="duplicate"):
|
| 222 |
+
ev._to_embedding_frame(df)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# --- registry ---
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_load_tasks_registry_duplicate_raises(tmp_path):
|
| 229 |
+
path = tmp_path / "tasks.yaml"
|
| 230 |
+
path.write_text(
|
| 231 |
+
"tasks:\n"
|
| 232 |
+
" - {task_id: t1, dataset_id: d001}\n"
|
| 233 |
+
" - {task_id: t1, dataset_id: d002}\n"
|
| 234 |
+
)
|
| 235 |
+
with pytest.raises(ev.EvaluatorError, match="duplicate"):
|
| 236 |
+
ev.load_tasks_registry(path)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def test_scoreable_tasks_skips_orphans():
|
| 240 |
+
tasks = [
|
| 241 |
+
_task("t1", "d001", "classification", "auroc", "G"),
|
| 242 |
+
_task("t9", "d999", "regression", "pearson", "D"),
|
| 243 |
+
]
|
| 244 |
+
kept = ev.scoreable_tasks(tasks, {"d001"})
|
| 245 |
+
assert [t["task_id"] for t in kept] == ["t1"]
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# --- score_all: offline via injected datasets + tasks + fetch_labels ---
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _two_task_submission(tmp_path, signal=True):
|
| 252 |
+
_, cl, cids, cemb = _classification(signal=signal)
|
| 253 |
+
_, rl, rids, remb = _regression(signal=signal)
|
| 254 |
+
_write_csv(tmp_path / "sub.csv", {"d001": (cids, cemb), "d002": (rids, remb)})
|
| 255 |
+
tasks = [
|
| 256 |
+
_task("t1", "d001", "classification", "auroc", "Gastro"),
|
| 257 |
+
_task("t2", "d002", "regression", "pearson", "Derm"),
|
| 258 |
+
]
|
| 259 |
+
labels_by = {"t1": cl, "t2": rl}
|
| 260 |
+
fetch = lambda tid, token=None: labels_by[tid] # noqa: E731
|
| 261 |
+
datasets = [{"id": "d001"}, {"id": "d002"}]
|
| 262 |
+
return tmp_path / "sub.csv", datasets, tasks, fetch
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def test_score_all_full_coverage_signal(tmp_path):
|
| 266 |
+
path, datasets, tasks, fetch = _two_task_submission(tmp_path, signal=True)
|
| 267 |
+
res = ev.score_all(path, datasets=datasets, tasks=tasks, fetch_labels=fetch)
|
| 268 |
+
assert res["full_coverage"] is True
|
| 269 |
+
assert res["n_scored"] == 2 and res["n_total"] == 2
|
| 270 |
+
assert res["coverage"] == 1.0
|
| 271 |
+
assert res["categories"]["treatment_outcome"]["mean"] > 0.8
|
| 272 |
+
assert res["categories"]["clinical_scores"]["mean"] > 0.6
|
| 273 |
+
assert set(res["categories"]) == {"treatment_outcome", "clinical_scores"}
|
| 274 |
+
assert res["missing"] == [] and res["invalid"] == []
|
| 275 |
+
assert res["incomplete"] == []
|
| 276 |
+
assert res["n_datasets_scored"] == 2 and res["n_datasets_total"] == 2
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def test_score_all_null_is_chance(tmp_path):
|
| 280 |
+
path, datasets, tasks, fetch = _two_task_submission(tmp_path, signal=False)
|
| 281 |
+
res = ev.score_all(path, datasets=datasets, tasks=tasks, fetch_labels=fetch)
|
| 282 |
+
assert res["categories"]["treatment_outcome"]["mean"] < 0.7
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def test_multi_task_per_dataset(tmp_path):
|
| 286 |
+
_, cl, cids, cemb = _classification(signal=True)
|
| 287 |
+
yreg = np.random.default_rng(3).normal(size=len(cids))
|
| 288 |
+
rl = _labels(yreg, _folds(len(cids)), cids)
|
| 289 |
+
_write_csv(tmp_path / "s.csv", {"d001": (cids, cemb)})
|
| 290 |
+
tasks = [
|
| 291 |
+
_task("t1", "d001", "classification", "auroc", "Gastro"),
|
| 292 |
+
_task("t2", "d001", "regression", "pearson", "Gastro"),
|
| 293 |
+
]
|
| 294 |
+
labels_by = {"t1": cl, "t2": rl}
|
| 295 |
+
fetch = lambda tid, token=None: labels_by[tid] # noqa: E731
|
| 296 |
+
res = ev.score_all(
|
| 297 |
+
path=tmp_path / "s.csv",
|
| 298 |
+
datasets=[{"id": "d001"}],
|
| 299 |
+
tasks=tasks,
|
| 300 |
+
fetch_labels=fetch,
|
| 301 |
+
)
|
| 302 |
+
assert res["n_total"] == 2 and res["n_scored"] == 2
|
| 303 |
+
assert res["full_coverage"] is True
|
| 304 |
+
assert res["n_datasets_total"] == 1 and res["n_datasets_scored"] == 1
|
| 305 |
+
assert set(res["categories"]) == {"treatment_outcome", "clinical_scores"}
|
| 306 |
+
assert res["incomplete"] == []
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def test_missing_dataset_makes_all_its_tasks_missing(tmp_path):
|
| 310 |
+
_, cl, cids, cemb = _classification()
|
| 311 |
+
_write_csv(tmp_path / "s.csv", {"d001": (cids, cemb)})
|
| 312 |
+
tasks = [
|
| 313 |
+
_task("t1", "d001", "classification", "auroc", "Gastro"),
|
| 314 |
+
_task("t2", "d002", "regression", "pearson", "Derm"),
|
| 315 |
+
]
|
| 316 |
+
fetch = lambda tid, token=None: {"t1": cl}[tid] # noqa: E731
|
| 317 |
+
res = ev.score_all(
|
| 318 |
+
path=tmp_path / "s.csv",
|
| 319 |
+
datasets=[{"id": "d001"}, {"id": "d002"}],
|
| 320 |
+
tasks=tasks,
|
| 321 |
+
fetch_labels=fetch,
|
| 322 |
+
)
|
| 323 |
+
assert res["missing"] == ["d002"]
|
| 324 |
+
assert res["full_coverage"] is False
|
| 325 |
+
assert res["n_scored"] == 1 and res["n_total"] == 2
|
| 326 |
+
assert res["n_datasets_scored"] == 1
|
| 327 |
+
assert res["incomplete"] == []
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def test_invalid_task_when_dataset_embedded(tmp_path):
|
| 331 |
+
_, cl, cids, cemb = _classification(n=60)
|
| 332 |
+
_write_csv(tmp_path / "s.csv", {"d001": (cids, cemb)})
|
| 333 |
+
tasks = [
|
| 334 |
+
_task("t1", "d001", "classification", "auroc", "Gastro"),
|
| 335 |
+
_task("t2", "d001", "classification", "auroc", "Gastro"),
|
| 336 |
+
]
|
| 337 |
+
bad = _labels(
|
| 338 |
+
np.array([0, 1, 0, 1]), np.array([0, 1, 2, 3]), ids=["z0", "z1", "z2", "z3"]
|
| 339 |
+
)
|
| 340 |
+
fetch = lambda tid, token=None: {"t1": cl, "t2": bad}[tid] # noqa: E731
|
| 341 |
+
res = ev.score_all(
|
| 342 |
+
path=tmp_path / "s.csv",
|
| 343 |
+
datasets=[{"id": "d001"}],
|
| 344 |
+
tasks=tasks,
|
| 345 |
+
fetch_labels=fetch,
|
| 346 |
+
)
|
| 347 |
+
assert res["n_scored"] == 1 and res["full_coverage"] is False
|
| 348 |
+
invalid = res["invalid"][0]
|
| 349 |
+
assert invalid["task_id"] == "t2" and invalid["dataset_id"] == "d001"
|
| 350 |
+
assert res["incomplete"] == ["d001"]
|
| 351 |
+
assert res["n_datasets_scored"] == 0
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def test_block_invalid_fails_all_its_tasks(tmp_path):
|
| 355 |
+
df = pd.DataFrame(
|
| 356 |
+
{
|
| 357 |
+
ev.DATASET_ID: ["d001", "d001"],
|
| 358 |
+
ev.SAMPLE_ID: ["a", "a"],
|
| 359 |
+
"e0": [1.0, 2.0],
|
| 360 |
+
"e1": [3.0, 4.0],
|
| 361 |
+
}
|
| 362 |
+
)
|
| 363 |
+
df.to_csv(tmp_path / "s.csv", index=False)
|
| 364 |
+
tasks = [
|
| 365 |
+
_task("t1", "d001", "classification", "auroc", "Gastro"),
|
| 366 |
+
_task("t2", "d001", "regression", "pearson", "Gastro"),
|
| 367 |
+
]
|
| 368 |
+
res = ev.score_all(
|
| 369 |
+
path=tmp_path / "s.csv",
|
| 370 |
+
datasets=[{"id": "d001"}],
|
| 371 |
+
tasks=tasks,
|
| 372 |
+
fetch_labels=_no_fetch,
|
| 373 |
+
)
|
| 374 |
+
assert res["n_scored"] == 0
|
| 375 |
+
reasons = {i["task_id"]: i["reason"] for i in res["invalid"]}
|
| 376 |
+
assert set(reasons) == {"t1", "t2"}
|
| 377 |
+
assert "duplicate" in reasons["t1"] and reasons["t1"] == reasons["t2"]
|
| 378 |
+
assert res["incomplete"] == ["d001"]
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def test_score_all_unknown_dataset_raises(tmp_path):
|
| 382 |
+
_, cl, cids, cemb = _classification()
|
| 383 |
+
_write_csv(
|
| 384 |
+
tmp_path / "s.csv", {"d001": (cids, cemb), "d999": (["a"], np.ones((1, 8)))}
|
| 385 |
+
)
|
| 386 |
+
tasks = [_task("t1", "d001", "classification", "auroc", "Gastro")]
|
| 387 |
+
with pytest.raises(ev.SubmissionError, match="unknown"):
|
| 388 |
+
ev.score_all(
|
| 389 |
+
path=tmp_path / "s.csv",
|
| 390 |
+
datasets=[{"id": "d001"}],
|
| 391 |
+
tasks=tasks,
|
| 392 |
+
fetch_labels=_no_fetch,
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def test_incomplete_dataset_is_invalid(tmp_path):
|
| 397 |
+
_, cl, cids, cemb = _classification(n=60)
|
| 398 |
+
_write_csv(tmp_path / "s.csv", {"d001": (cids[:-5], cemb[:-5])})
|
| 399 |
+
tasks = [_task("t1", "d001", "classification", "auroc", "Gastro")]
|
| 400 |
+
fetch = lambda tid, token=None: cl # noqa: E731
|
| 401 |
+
res = ev.score_all(
|
| 402 |
+
path=tmp_path / "s.csv",
|
| 403 |
+
datasets=[{"id": "d001"}],
|
| 404 |
+
tasks=tasks,
|
| 405 |
+
fetch_labels=fetch,
|
| 406 |
+
)
|
| 407 |
+
assert res["n_scored"] == 0
|
| 408 |
+
assert res["invalid"][0]["task_id"] == "t1"
|
| 409 |
+
assert "missing" in res["invalid"][0]["reason"]
|
| 410 |
+
assert res["incomplete"] == ["d001"]
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def test_scoring_error_is_invalid(tmp_path):
|
| 414 |
+
n = 20
|
| 415 |
+
folds = np.repeat(np.arange(5), 4)
|
| 416 |
+
y = np.zeros(n, dtype=int)
|
| 417 |
+
y[:2] = 1
|
| 418 |
+
ids = [f"s{i}" for i in range(n)]
|
| 419 |
+
emb = np.random.default_rng(0).normal(size=(n, 6))
|
| 420 |
+
labels = _labels(y, folds, ids)
|
| 421 |
+
_write_csv(tmp_path / "s.csv", {"d001": (ids, emb)})
|
| 422 |
+
tasks = [_task("t1", "d001", "classification", "auroc", "Gastro")]
|
| 423 |
+
fetch = lambda tid, token=None: labels # noqa: E731
|
| 424 |
+
res = ev.score_all(
|
| 425 |
+
path=tmp_path / "s.csv",
|
| 426 |
+
datasets=[{"id": "d001"}],
|
| 427 |
+
tasks=tasks,
|
| 428 |
+
fetch_labels=fetch,
|
| 429 |
+
)
|
| 430 |
+
assert res["n_scored"] == 0
|
| 431 |
+
assert "single class" in res["invalid"][0]["reason"]
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def test_fetch_failure_raises_evaluator_error(tmp_path, monkeypatch):
|
| 435 |
+
monkeypatch.setattr(ev, "FETCH_BACKOFF", 0.0)
|
| 436 |
+
_, cl, cids, cemb = _classification()
|
| 437 |
+
_write_csv(tmp_path / "s.csv", {"d001": (cids, cemb)})
|
| 438 |
+
tasks = [_task("t1", "d001", "classification", "auroc", "Gastro")]
|
| 439 |
+
|
| 440 |
+
def boom(task_id, token=None):
|
| 441 |
+
raise ConnectionError("HF unreachable")
|
| 442 |
+
|
| 443 |
+
with pytest.raises(ev.EvaluatorError, match="could not load"):
|
| 444 |
+
ev.score_all(
|
| 445 |
+
path=tmp_path / "s.csv",
|
| 446 |
+
datasets=[{"id": "d001"}],
|
| 447 |
+
tasks=tasks,
|
| 448 |
+
fetch_labels=boom,
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def test_unknown_metric_raises_evaluator_error(tmp_path):
|
| 453 |
+
_, cl, cids, cemb = _classification()
|
| 454 |
+
_write_csv(tmp_path / "s.csv", {"d001": (cids, cemb)})
|
| 455 |
+
tasks = [_task("t1", "d001", "classification", "bogus", "Gastro")]
|
| 456 |
+
fetch = lambda tid, token=None: cl # noqa: E731
|
| 457 |
+
with pytest.raises(ev.EvaluatorError, match="unknown metric"):
|
| 458 |
+
ev.score_all(
|
| 459 |
+
path=tmp_path / "s.csv",
|
| 460 |
+
datasets=[{"id": "d001"}],
|
| 461 |
+
tasks=tasks,
|
| 462 |
+
fetch_labels=fetch,
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
# --- transfer: fit once on the train cohort, score the test cohort ---
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def _transfer_labels(y, split, ids=None):
|
| 470 |
+
ids = ids if ids is not None else [f"s{i}" for i in range(len(y))]
|
| 471 |
+
return pd.DataFrame({ev.SAMPLE_ID: ids, ev.LABEL: y, ev.SPLIT: split})
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def _transfer_classification(n_train=40, n_test=20, seed=42, signal=True):
|
| 475 |
+
rng = np.random.default_rng(seed)
|
| 476 |
+
n = n_train + n_test
|
| 477 |
+
y = np.array([0, 1] * (n // 2))
|
| 478 |
+
emb = rng.normal(size=(n, 8))
|
| 479 |
+
if signal:
|
| 480 |
+
emb[:, 0] += 3.0 * y
|
| 481 |
+
split = np.array([ev.SPLIT_TRAIN] * n_train + [ev.SPLIT_TEST] * n_test)
|
| 482 |
+
ids = [f"s{i}" for i in range(n)]
|
| 483 |
+
task = _task("t016", "d010", "classification", "auroc", "Gastro")
|
| 484 |
+
return task, _transfer_labels(y, split, ids), ids, emb
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def test_transfer_scoring_signal_and_null():
|
| 488 |
+
task, labels, ids, emb = _transfer_classification(signal=True)
|
| 489 |
+
res = ev.score_task(task, labels, _emb_frame(emb, ids))
|
| 490 |
+
assert res.score > 0.8
|
| 491 |
+
assert res.n_samples == 20
|
| 492 |
+
task0, labels0, ids0, null = _transfer_classification(signal=False)
|
| 493 |
+
res0 = ev.score_task(task0, labels0, _emb_frame(null, ids0))
|
| 494 |
+
assert 0.3 < res0.score < 0.7
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def test_transfer_single_class_train_raises_submission_error():
|
| 498 |
+
y = np.array([0, 0, 0, 0, 1, 0])
|
| 499 |
+
split = np.array([ev.SPLIT_TRAIN] * 4 + [ev.SPLIT_TEST] * 2)
|
| 500 |
+
ids = [f"s{i}" for i in range(6)]
|
| 501 |
+
emb = np.random.default_rng(0).normal(size=(6, 5))
|
| 502 |
+
task = _task("t016", "d010", "classification", "auroc", "Gastro")
|
| 503 |
+
with pytest.raises(ev.SubmissionError):
|
| 504 |
+
ev.score_task(task, _transfer_labels(y, split, ids), _emb_frame(emb, ids))
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
def test_load_labels_accepts_repeated_fold_or_split(tmp_path):
|
| 508 |
+
_labels(
|
| 509 |
+
np.array([0, 1, 0, 1]),
|
| 510 |
+
_folds(4),
|
| 511 |
+
repeat_folds=[np.array([1, 0, 1, 0]), np.array([2, 0, 2, 0])],
|
| 512 |
+
).to_csv(tmp_path / "f.csv", index=False)
|
| 513 |
+
labels = ev.load_labels(tmp_path / "f.csv")
|
| 514 |
+
assert ev._fold_columns(labels) == ["fold", "fold_1", "fold_2"]
|
| 515 |
+
_transfer_labels(np.array([0, 1]), ["train", "test"]).to_csv(
|
| 516 |
+
tmp_path / "s.csv", index=False
|
| 517 |
+
)
|
| 518 |
+
assert ev.SPLIT in ev.load_labels(tmp_path / "s.csv").columns
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
def test_load_labels_rejects_both_and_neither(tmp_path):
|
| 522 |
+
both = pd.DataFrame(
|
| 523 |
+
{ev.SAMPLE_ID: ["a"], ev.LABEL: [0], ev.FOLD: [0], ev.SPLIT: ["train"]}
|
| 524 |
+
)
|
| 525 |
+
both.to_csv(tmp_path / "both.csv", index=False)
|
| 526 |
+
with pytest.raises(ev.EvaluatorError, match="exactly one"):
|
| 527 |
+
ev.load_labels(tmp_path / "both.csv")
|
| 528 |
+
neither = pd.DataFrame({ev.SAMPLE_ID: ["a"], ev.LABEL: [0]})
|
| 529 |
+
neither.to_csv(tmp_path / "neither.csv", index=False)
|
| 530 |
+
with pytest.raises(ev.EvaluatorError, match="exactly one"):
|
| 531 |
+
ev.load_labels(tmp_path / "neither.csv")
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def test_score_all_transfer_two_tasks(tmp_path):
|
| 535 |
+
_, l16, ids, emb = _transfer_classification(signal=True)
|
| 536 |
+
y = l16[ev.LABEL].to_numpy()
|
| 537 |
+
rev = np.where(
|
| 538 |
+
l16[ev.SPLIT].to_numpy() == ev.SPLIT_TRAIN, ev.SPLIT_TEST, ev.SPLIT_TRAIN
|
| 539 |
+
)
|
| 540 |
+
l17 = _transfer_labels(y, rev, ids)
|
| 541 |
+
_write_csv(tmp_path / "sub.csv", {"d010": (ids, emb)})
|
| 542 |
+
tasks = [
|
| 543 |
+
_task("t016", "d010", "classification", "auroc", "Gastro"),
|
| 544 |
+
_task("t017", "d010", "classification", "auroc", "Gastro"),
|
| 545 |
+
]
|
| 546 |
+
labels_by = {"t016": l16, "t017": l17}
|
| 547 |
+
fetch = lambda tid, token=None: labels_by[tid] # noqa: E731
|
| 548 |
+
res = ev.score_all(
|
| 549 |
+
path=tmp_path / "sub.csv",
|
| 550 |
+
datasets=[{"id": "d010"}],
|
| 551 |
+
tasks=tasks,
|
| 552 |
+
fetch_labels=fetch,
|
| 553 |
+
)
|
| 554 |
+
assert res["n_total"] == 2 and res["n_scored"] == 2
|
| 555 |
+
assert res["full_coverage"] is True
|
| 556 |
+
assert res["n_datasets_total"] == 1 and res["n_datasets_scored"] == 1
|
| 557 |
+
assert set(res["categories"]) == {"treatment_outcome"}
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
# --- perturbation response decoding ---
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
def _perturbation_task(task_id="t018", dataset_id="d011", pairing="paired"):
|
| 564 |
+
return {
|
| 565 |
+
"task_id": task_id,
|
| 566 |
+
"dataset_id": dataset_id,
|
| 567 |
+
"task_type": ev.PERTURBATION,
|
| 568 |
+
"metric": ev.RESIDUAL_SPEARMAN,
|
| 569 |
+
"category": "perturbation_prediction",
|
| 570 |
+
"therapeutic_area": "Dermatology",
|
| 571 |
+
"pairing": pairing,
|
| 572 |
+
"n_top_de_genes": 4,
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def _perturbation_labels(ids, folds):
|
| 577 |
+
return pd.DataFrame(
|
| 578 |
+
{
|
| 579 |
+
ev.SAMPLE_ID: ids,
|
| 580 |
+
ev.PAIR_ID: [f"p{i}" for i in range(len(ids))],
|
| 581 |
+
ev.PAIRING: ev.PAIRED,
|
| 582 |
+
ev.FOLD: folds,
|
| 583 |
+
f"{ev.FOLD}_1": np.roll(folds, 1),
|
| 584 |
+
f"{ev.FOLD}_2": np.roll(folds, 2),
|
| 585 |
+
}
|
| 586 |
+
)
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
def test_perturbation_constant_embedding_anchors_at_chance():
|
| 590 |
+
rng = np.random.default_rng(12)
|
| 591 |
+
n = 30
|
| 592 |
+
ids = [f"s{i}" for i in range(n)]
|
| 593 |
+
labels = _perturbation_labels(ids, _folds(n, 3))
|
| 594 |
+
targets = ev.PerturbationTargets(
|
| 595 |
+
delta=rng.normal(size=(n, 8)), gene_ids=np.array([f"g{i}" for i in range(8)])
|
| 596 |
+
)
|
| 597 |
+
result = ev.score_task(
|
| 598 |
+
_perturbation_task(), labels, _emb_frame(np.ones((n, 3)), ids), targets
|
| 599 |
+
)
|
| 600 |
+
assert result.score == pytest.approx(0.5, abs=1e-12)
|
| 601 |
+
assert len(result.repeat_scores) == 3
|
| 602 |
+
assert result.diagnostics == {}
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def test_perturbation_signal_decodes_hidden_response():
|
| 606 |
+
rng = np.random.default_rng(4)
|
| 607 |
+
n = 45
|
| 608 |
+
ids = [f"s{i}" for i in range(n)]
|
| 609 |
+
emb = rng.normal(size=(n, 5))
|
| 610 |
+
weights = rng.normal(size=(5, 8))
|
| 611 |
+
delta = emb @ weights + rng.normal(scale=0.01, size=(n, 8))
|
| 612 |
+
labels = _perturbation_labels(ids, _folds(n, 3))
|
| 613 |
+
targets = ev.PerturbationTargets(delta, np.array([f"g{i}" for i in range(8)]))
|
| 614 |
+
result = ev.score_task(_perturbation_task(), labels, _emb_frame(emb, ids), targets)
|
| 615 |
+
assert result.score > 0.9
|
| 616 |
+
assert result.diagnostics == {}
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
def test_perturbation_decoder_clips_test_coordinates(monkeypatch):
|
| 620 |
+
seen = {}
|
| 621 |
+
|
| 622 |
+
class Decoder:
|
| 623 |
+
def fit(self, matrix, delta):
|
| 624 |
+
self.mean = delta.mean(axis=0)
|
| 625 |
+
return self
|
| 626 |
+
|
| 627 |
+
def predict(self, matrix):
|
| 628 |
+
seen["maximum"] = float(np.max(np.abs(matrix)))
|
| 629 |
+
return np.tile(self.mean, (len(matrix), 1))
|
| 630 |
+
|
| 631 |
+
monkeypatch.setattr(ev, "RidgeCV", lambda alphas: Decoder())
|
| 632 |
+
matrix = np.array([[0.0], [1.0], [1_000_000.0]])
|
| 633 |
+
delta = np.array([[1.0, 2.0], [2.0, 1.0], [3.0, 4.0]])
|
| 634 |
+
score = ev._perturbation_fold(
|
| 635 |
+
matrix,
|
| 636 |
+
delta,
|
| 637 |
+
np.array([True, True, False]),
|
| 638 |
+
np.array([False, False, True]),
|
| 639 |
+
2,
|
| 640 |
+
)
|
| 641 |
+
|
| 642 |
+
assert seen["maximum"] == ev.PERTURBATION_TEST_Z_CLIP
|
| 643 |
+
assert score == pytest.approx(0.5)
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
def test_deg_selection_uses_only_the_supplied_training_rows():
|
| 647 |
+
delta = np.array(
|
| 648 |
+
[
|
| 649 |
+
[8.0, 0.0],
|
| 650 |
+
[8.0, 0.0],
|
| 651 |
+
[0.0, 100.0],
|
| 652 |
+
[0.0, 100.0],
|
| 653 |
+
]
|
| 654 |
+
)
|
| 655 |
+
source = np.array([True, True, False, False])
|
| 656 |
+
assert ev._select_degs(delta[source], 1).tolist() == [0]
|
| 657 |
+
assert ev._select_degs(delta[~source], 1).tolist() == [1]
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def test_directional_transfer_never_fits_on_target_disease(monkeypatch):
|
| 661 |
+
matrix = np.arange(24, dtype=float).reshape(6, 4)
|
| 662 |
+
delta = np.arange(18, dtype=float).reshape(6, 3)
|
| 663 |
+
labels = pd.DataFrame(
|
| 664 |
+
{
|
| 665 |
+
ev.SPLIT: [ev.SPLIT_TRAIN] * 4 + [ev.SPLIT_TEST] * 2,
|
| 666 |
+
}
|
| 667 |
+
)
|
| 668 |
+
seen = {}
|
| 669 |
+
|
| 670 |
+
def fold(matrix_arg, delta_arg, train, test, n_top):
|
| 671 |
+
seen["train"] = np.flatnonzero(train).tolist()
|
| 672 |
+
seen["test"] = np.flatnonzero(test).tolist()
|
| 673 |
+
seen["n_top"] = n_top
|
| 674 |
+
return 0.75
|
| 675 |
+
|
| 676 |
+
monkeypatch.setattr(ev, "_perturbation_fold", fold)
|
| 677 |
+
metrics, n_test = ev._transfer_perturbation_scores(matrix, delta, labels, 2)
|
| 678 |
+
assert seen == {
|
| 679 |
+
"train": [0, 1, 2, 3],
|
| 680 |
+
"test": [4, 5],
|
| 681 |
+
"n_top": 2,
|
| 682 |
+
}
|
| 683 |
+
assert metrics == (0.75,)
|
| 684 |
+
assert n_test == 2
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
def test_repeated_sample_ids_reuse_one_baseline_embedding():
|
| 688 |
+
labels = pd.DataFrame({ev.SAMPLE_ID: ["s1", "s1", "s2", "s1"]})
|
| 689 |
+
embeddings = _emb_frame(np.array([[1.0, 2.0], [3.0, 4.0]]), ["s1", "s2"])
|
| 690 |
+
aligned = ev._align(labels, embeddings)
|
| 691 |
+
np.testing.assert_array_equal(
|
| 692 |
+
aligned,
|
| 693 |
+
np.array([[1.0, 2.0], [1.0, 2.0], [3.0, 4.0], [1.0, 2.0]]),
|
| 694 |
+
)
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
def test_mouse_cartesian_partitions_isolate_both_specimens():
|
| 698 |
+
controls = np.repeat(["c1", "c2", "c3"], 3)
|
| 699 |
+
perturbed = np.tile(["p1", "p2", "p3"], 3)
|
| 700 |
+
repeats = ev._cartesian_partitions(controls, perturbed)
|
| 701 |
+
assert len(repeats) == 6
|
| 702 |
+
assert all(len(folds) == 3 for folds in repeats)
|
| 703 |
+
tested_edges = []
|
| 704 |
+
for folds in repeats:
|
| 705 |
+
for train, test in folds:
|
| 706 |
+
held = np.flatnonzero(test)[0]
|
| 707 |
+
tested_edges.append((controls[held], perturbed[held]))
|
| 708 |
+
assert train.sum() == 4 and test.sum() == 1
|
| 709 |
+
assert not np.any(controls[train] == controls[held])
|
| 710 |
+
assert not np.any(perturbed[train] == perturbed[held])
|
| 711 |
+
assert set(tested_edges) == set(zip(controls, perturbed))
|
| 712 |
+
assert len(tested_edges) == 18
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
def test_mouse_task_has_six_regular_weighted_repeat_scores():
|
| 716 |
+
rng = np.random.default_rng(7)
|
| 717 |
+
controls = np.repeat(["c1", "c2", "c3"], 3)
|
| 718 |
+
perturbed = np.tile(["p1", "p2", "p3"], 3)
|
| 719 |
+
sample_ids = np.repeat(["s1", "s2", "s3"], 3)
|
| 720 |
+
labels = pd.DataFrame(
|
| 721 |
+
{
|
| 722 |
+
ev.SAMPLE_ID: sample_ids,
|
| 723 |
+
ev.PAIR_ID: [f"pair{i}" for i in range(9)],
|
| 724 |
+
ev.CONTROL_ID: controls,
|
| 725 |
+
ev.PERTURBED_ID: perturbed,
|
| 726 |
+
ev.PAIRING: ev.CARTESIAN,
|
| 727 |
+
}
|
| 728 |
+
)
|
| 729 |
+
embeddings = _emb_frame(np.ones((3, 2)), ["s1", "s2", "s3"])
|
| 730 |
+
targets = ev.PerturbationTargets(
|
| 731 |
+
rng.normal(size=(9, 6)), np.array([f"g{i}" for i in range(6)])
|
| 732 |
+
)
|
| 733 |
+
result = ev.score_task(
|
| 734 |
+
_perturbation_task("t020", "d012", ev.CARTESIAN),
|
| 735 |
+
labels,
|
| 736 |
+
embeddings,
|
| 737 |
+
targets,
|
| 738 |
+
)
|
| 739 |
+
assert result.score == pytest.approx(0.5, abs=1e-12)
|
| 740 |
+
assert len(result.repeat_scores) == 6
|
| 741 |
+
assert result.category == "perturbation_prediction"
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def test_load_private_perturbation_artifacts(tmp_path):
|
| 745 |
+
labels = pd.DataFrame(
|
| 746 |
+
{
|
| 747 |
+
ev.SAMPLE_ID: ["s1", "s2"],
|
| 748 |
+
ev.PAIR_ID: ["p1", "p2"],
|
| 749 |
+
ev.PAIRING: [ev.PAIRED, ev.PAIRED],
|
| 750 |
+
ev.FOLD: [0, 1],
|
| 751 |
+
}
|
| 752 |
+
)
|
| 753 |
+
labels.to_csv(tmp_path / ev.LABELS_FILENAME, index=False)
|
| 754 |
+
np.savez_compressed(
|
| 755 |
+
tmp_path / ev.TARGETS_FILENAME,
|
| 756 |
+
delta=np.ones((2, 3)),
|
| 757 |
+
gene_ids=np.array(["1", "2", "3"]),
|
| 758 |
+
)
|
| 759 |
+
loaded_labels = ev.load_labels(tmp_path / ev.LABELS_FILENAME)
|
| 760 |
+
loaded_targets = ev.load_targets(tmp_path / ev.TARGETS_FILENAME)
|
| 761 |
+
assert ev.LABEL not in loaded_labels
|
| 762 |
+
assert loaded_targets.delta.shape == (2, 3)
|
| 763 |
+
|
| 764 |
+
|
| 765 |
+
def test_score_all_fetches_hidden_targets_only_for_perturbation(tmp_path):
|
| 766 |
+
rng = np.random.default_rng(31)
|
| 767 |
+
n = 30
|
| 768 |
+
ids = [f"s{i}" for i in range(n)]
|
| 769 |
+
emb = rng.normal(size=(n, 4))
|
| 770 |
+
targets = ev.PerturbationTargets(
|
| 771 |
+
emb @ rng.normal(size=(4, 6)), np.array([f"g{i}" for i in range(6)])
|
| 772 |
+
)
|
| 773 |
+
labels = _perturbation_labels(ids, _folds(n, 3))
|
| 774 |
+
_write_csv(tmp_path / "submission.csv", {"d011": (ids, emb)})
|
| 775 |
+
result = ev.score_all(
|
| 776 |
+
tmp_path / "submission.csv",
|
| 777 |
+
datasets=[{"id": "d011"}],
|
| 778 |
+
tasks=[_perturbation_task()],
|
| 779 |
+
fetch_labels=lambda task_id, token=None: labels,
|
| 780 |
+
fetch_targets=lambda task_id, token=None: targets,
|
| 781 |
+
)
|
| 782 |
+
assert result["full_coverage"] is True
|
| 783 |
+
assert (
|
| 784 |
+
result["categories"]["perturbation_prediction"]["metric"]
|
| 785 |
+
== ev.RESIDUAL_SPEARMAN
|
| 786 |
+
)
|
| 787 |
+
assert result["per_task"][0].diagnostics == {}
|
test_leaderboard.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the board tables and page copy (no network, no Gradio)."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import pytest
|
| 7 |
+
from conftest import REGISTRY
|
| 8 |
+
from leaderboard import (
|
| 9 |
+
RESERVED_COLUMNS,
|
| 10 |
+
TASK_COLUMNS,
|
| 11 |
+
latest_only,
|
| 12 |
+
per_task_table,
|
| 13 |
+
ranked_table,
|
| 14 |
+
source_repositories,
|
| 15 |
+
tasks_table,
|
| 16 |
+
top_models,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
ALL_SCORES = (0.8, 0.5, 0.6, 0.4, 0.7)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _full(model: str, at: str, scores=ALL_SCORES) -> list[tuple]:
|
| 23 |
+
return [(model, tid, s, at) for tid, s in zip(REGISTRY, scores)]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _flagged(model: str, at: str, baseline: bool, scores=ALL_SCORES) -> list[tuple]:
|
| 27 |
+
return [(model, tid, s, at, baseline) for tid, s in zip(REGISTRY, scores)]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_full_board_coverage_is_ranked_and_partial_is_not(registry, named, results):
|
| 31 |
+
df = results(*_full("full", "2026-01-01"), ("partial", "t001", 0.99, "2026-01-01"))
|
| 32 |
+
board = ranked_table(df, registry, named("bulk RNA"))
|
| 33 |
+
assert list(board["Model"]) == ["full"]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_a_partial_model_is_ranked_on_the_board_it_fully_covered(
|
| 37 |
+
registry, named, results
|
| 38 |
+
):
|
| 39 |
+
"""Covering all of Rheumatology ranks you there, even with Dermatology missing."""
|
| 40 |
+
df = results(
|
| 41 |
+
*_full("full", "2026-01-01"),
|
| 42 |
+
("rheuma_only", "t005", 0.95, "2026-01-01"),
|
| 43 |
+
)
|
| 44 |
+
assert list(ranked_table(df, registry, named("bulk RNA"))["Model"]) == ["full"]
|
| 45 |
+
rheumatology = ranked_table(df, registry, named("Rheumatology"))
|
| 46 |
+
assert list(rheumatology["Model"]) == ["rheuma_only", "full"]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_ranks_are_ordered_by_the_mean_of_the_category_means(registry, named, results):
|
| 50 |
+
df = results(
|
| 51 |
+
*_full("better", "2026-01-01", scores=(0.9, 0.9, 0.9, 0.9, 0.9)),
|
| 52 |
+
*_full("worse", "2026-01-01", scores=(0.2, 0.2, 0.2, 0.2, 0.2)),
|
| 53 |
+
)
|
| 54 |
+
board = ranked_table(df, registry, named("bulk RNA"))
|
| 55 |
+
assert list(board["Model"]) == ["better", "worse"]
|
| 56 |
+
assert list(board["Rank"]) == [1, 2]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_one_column_per_category_in_its_native_metric(registry, named, results):
|
| 60 |
+
df = results(*_full("full", "2026-01-01"))
|
| 61 |
+
board = ranked_table(df, registry, named("bulk RNA"))
|
| 62 |
+
assert "Treatment outcome (AUROC)" in board.columns
|
| 63 |
+
assert "Clinical scores (Pearson)" in board.columns
|
| 64 |
+
assert "Endotype (AUROC)" in board.columns
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_comparison_columns_are_present_for_every_board(registry, named, results):
|
| 68 |
+
df = results(*_full("full", "2026-01-01"))
|
| 69 |
+
assert {"Elo", "Mean score", "Mean rank"} <= set(
|
| 70 |
+
ranked_table(df, registry, named("bulk RNA")).columns
|
| 71 |
+
)
|
| 72 |
+
endotype = ranked_table(df, registry, named("Endotype"))
|
| 73 |
+
assert {"Elo", "Mean score", "Mean rank"} <= set(endotype.columns)
|
| 74 |
+
assert "Endotype (AUROC)" in endotype.columns
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_latest_submission_wins_so_scores_cannot_be_shopped(registry, named, results):
|
| 78 |
+
df = results(
|
| 79 |
+
*_full("m", "2026-01-01", scores=(0.9,) * 5),
|
| 80 |
+
*_full("m", "2026-02-01", scores=(0.1,) * 5),
|
| 81 |
+
)
|
| 82 |
+
assert set(latest_only(df)["submitted_at"]) == {"2026-02-01"}
|
| 83 |
+
board = ranked_table(df, registry, named("bulk RNA"))
|
| 84 |
+
assert board.loc[0, "Treatment outcome (AUROC)"] == pytest.approx(0.1)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_equal_timestamp_submissions_do_not_mix_scores_or_credits(
|
| 88 |
+
registry, named, results
|
| 89 |
+
):
|
| 90 |
+
older = results(*_full("m", "2026-02-01", scores=(0.9,) * 5)).assign(
|
| 91 |
+
submission_id="2026-02-01T00:00:00.000001-a",
|
| 92 |
+
institution="Older University",
|
| 93 |
+
is_author_submission=False,
|
| 94 |
+
paper_link="https://example.org/older",
|
| 95 |
+
)
|
| 96 |
+
newer = results(*_full("m", "2026-02-01", scores=(0.1,) * 5)).assign(
|
| 97 |
+
submission_id="2026-02-01T00:00:00.000002-b",
|
| 98 |
+
institution="Newer University",
|
| 99 |
+
is_author_submission=True,
|
| 100 |
+
paper_link="https://example.org/newer",
|
| 101 |
+
)
|
| 102 |
+
board = ranked_table(pd.concat([older, newer]), registry, named("bulk RNA"))
|
| 103 |
+
assert board.loc[0, "Treatment outcome (AUROC)"] == pytest.approx(0.1)
|
| 104 |
+
assert "Author" not in board
|
| 105 |
+
assert board.loc[0, "Institution"] == "Newer University"
|
| 106 |
+
assert board.loc[0, "Model"].paper_link == "https://example.org/newer"
|
| 107 |
+
assert board.loc[0, "Model"].is_author_submission is True
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_nullable_credits_render_as_missing(registry, named, results):
|
| 111 |
+
df = results(*_full("m", "2026-01-01")).assign(
|
| 112 |
+
institution=float("nan"),
|
| 113 |
+
paper_link=pd.NA,
|
| 114 |
+
is_author_submission=pd.NA,
|
| 115 |
+
)
|
| 116 |
+
board = ranked_table(df, registry, named("bulk RNA"))
|
| 117 |
+
assert pd.isna(board.loc[0, "Institution"])
|
| 118 |
+
assert board.loc[0, "Model"].paper_link == ""
|
| 119 |
+
assert board.loc[0, "Model"].is_author_submission is False
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_a_model_scoring_nothing_finite_ranks_below_a_bad_one(registry, named, results):
|
| 123 |
+
"""NaN everywhere is "no score", which must not outrank a negative Pearson."""
|
| 124 |
+
df = results(
|
| 125 |
+
*_full("nan-everywhere", "2026-01-01", scores=(float("nan"),) * 5),
|
| 126 |
+
*_full("bad", "2026-01-01", scores=(-0.2,) * 5),
|
| 127 |
+
)
|
| 128 |
+
board = ranked_table(df, registry, named("bulk RNA"))
|
| 129 |
+
assert list(board["Model"]) == ["bad", "nan-everywhere"]
|
| 130 |
+
assert board.loc[1, "Elo"] == 1000
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_a_card_shows_a_dash_rather_than_minus_infinity(registry, named, results):
|
| 134 |
+
df = results(*_full("nan-everywhere", "2026-01-01", scores=(float("nan"),) * 5))
|
| 135 |
+
top = top_models(df, registry, named("bulk RNA"), 3)
|
| 136 |
+
assert top[0].elo is None
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_ranked_is_empty_when_nobody_covered_the_board(registry, named, results):
|
| 140 |
+
df = results(("partial", "t001", 0.9, "2026-01-01"))
|
| 141 |
+
assert ranked_table(df, registry, named("Rheumatology")).empty
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_per_task_keeps_partial_submissions_with_gaps(registry, named, results):
|
| 145 |
+
df = results(
|
| 146 |
+
*_full("full", "2026-01-01"),
|
| 147 |
+
("partial", "t002", 0.95, "2026-01-01"),
|
| 148 |
+
)
|
| 149 |
+
table = per_task_table(df, registry, named("Gastroenterology")).set_index("Task")
|
| 150 |
+
assert list(table.index) == [
|
| 151 |
+
"IBD adalimumab remission",
|
| 152 |
+
"Crohn SES-CD",
|
| 153 |
+
"Crohn HBI",
|
| 154 |
+
]
|
| 155 |
+
assert table.loc["Crohn SES-CD", "partial"] == pytest.approx(0.95)
|
| 156 |
+
assert table.loc["Crohn SES-CD", "Best"] == "partial"
|
| 157 |
+
assert pd.isna(table.loc["Crohn HBI", "partial"])
|
| 158 |
+
assert table.loc["Crohn HBI", "Best"] == "full"
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def test_reserved_names_are_exactly_the_per_task_metadata_columns(
|
| 162 |
+
registry, named, results
|
| 163 |
+
):
|
| 164 |
+
"""A model named after one of these would overwrite it -- Submit refuses them."""
|
| 165 |
+
df = results(("m", "t004", 0.5, "2026-01-01"))
|
| 166 |
+
table = per_task_table(df, registry, named("Dermatology"))
|
| 167 |
+
assert RESERVED_COLUMNS == set(table.columns) - {"m"}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_per_task_only_shows_the_board_it_was_asked_for(registry, named, results):
|
| 171 |
+
df = results(*_full("full", "2026-01-01"))
|
| 172 |
+
tasks = set(per_task_table(df, registry, named("Dermatology"))["Task"])
|
| 173 |
+
assert tasks == {"Psoriasis PASI"}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_per_task_is_empty_when_no_result_touches_the_board(registry, named, results):
|
| 177 |
+
df = results(("partial", "t001", 0.9, "2026-01-01"))
|
| 178 |
+
assert per_task_table(df, registry, named("Rheumatology")).empty
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_top_models_are_ordered_and_limited(registry, named, results):
|
| 182 |
+
df = results(
|
| 183 |
+
*_full("a", "2026-01-01", scores=(0.9,) * 5),
|
| 184 |
+
*_full("b", "2026-01-01", scores=(0.5,) * 5),
|
| 185 |
+
*_full("c", "2026-01-01", scores=(0.1,) * 5),
|
| 186 |
+
)
|
| 187 |
+
top = top_models(df, registry, named("bulk RNA"), 2)
|
| 188 |
+
assert [entry.name for entry in top] == ["a", "b"]
|
| 189 |
+
assert top_models(df, registry, named("bulk RNA"), 3)[0].elo > 1000
|
| 190 |
+
assert not any(entry.is_baseline for entry in top)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def test_top_models_ignores_models_that_did_not_cover_the_board(
|
| 194 |
+
registry, named, results
|
| 195 |
+
):
|
| 196 |
+
df = results(("partial", "t001", 0.99, "2026-01-01"))
|
| 197 |
+
assert top_models(df, registry, named("bulk RNA"), 3) == []
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def test_a_baseline_is_ranked_in_place_and_labelled(registry, named, flagged_results):
|
| 201 |
+
"""A PCA that beats a submitted model belongs above it, not in a footnote."""
|
| 202 |
+
df = flagged_results(
|
| 203 |
+
*_flagged("pca-50", "2026-01-01", True, scores=(0.9,) * 5),
|
| 204 |
+
*_flagged("eva", "2026-01-01", False, scores=(0.4,) * 5),
|
| 205 |
+
)
|
| 206 |
+
board = ranked_table(df, registry, named("bulk RNA"))
|
| 207 |
+
assert list(board["Model"]) == ["pca-50 (baseline)", "eva"]
|
| 208 |
+
assert list(board["Rank"]) == [1, 2]
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def test_rows_written_before_the_flag_existed_are_not_baselines(
|
| 212 |
+
registry, named, results
|
| 213 |
+
):
|
| 214 |
+
df = results(*_full("eva", "2026-01-01"))
|
| 215 |
+
assert list(ranked_table(df, registry, named("bulk RNA"))["Model"]) == ["eva"]
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def test_a_model_cannot_bury_a_baseline_by_reusing_its_name(
|
| 219 |
+
registry, named, flagged_results
|
| 220 |
+
):
|
| 221 |
+
"""Same name, different kind: two entries, and the later one hides nothing."""
|
| 222 |
+
df = flagged_results(
|
| 223 |
+
*_flagged("pca-50", "2026-01-01", True, scores=(0.9,) * 5),
|
| 224 |
+
*_flagged("pca-50", "2026-06-01", False, scores=(0.2,) * 5),
|
| 225 |
+
)
|
| 226 |
+
board = ranked_table(df, registry, named("bulk RNA"))
|
| 227 |
+
assert list(board["Model"]) == ["pca-50 (baseline)", "pca-50"]
|
| 228 |
+
assert "pca-50 (baseline)" in per_task_table(df, registry, named("Dermatology"))
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def test_a_resubmission_still_replaces_the_entry_of_the_same_kind(
|
| 232 |
+
registry, named, flagged_results
|
| 233 |
+
):
|
| 234 |
+
df = flagged_results(
|
| 235 |
+
*_flagged("eva", "2026-01-01", False, scores=(0.9,) * 5),
|
| 236 |
+
*_flagged("eva", "2026-06-01", False, scores=(0.2,) * 5),
|
| 237 |
+
)
|
| 238 |
+
board = ranked_table(df, registry, named("bulk RNA"))
|
| 239 |
+
assert list(board["Model"]) == ["eva"]
|
| 240 |
+
assert board.loc[0, "Endotype (AUROC)"] == pytest.approx(0.2)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def test_per_task_columns_name_the_baseline(registry, named, flagged_results):
|
| 244 |
+
df = flagged_results(*_flagged("random-32", "2026-01-01", True))
|
| 245 |
+
table = per_task_table(df, registry, named("Dermatology"))
|
| 246 |
+
assert "random-32 (baseline)" in table.columns
|
| 247 |
+
assert table.loc[0, "Best"] == "random-32 (baseline)"
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def test_top_models_carry_the_baseline_flag(registry, named, flagged_results):
|
| 251 |
+
df = flagged_results(*_flagged("pca-50", "2026-01-01", True))
|
| 252 |
+
assert top_models(df, registry, named("bulk RNA"), 3)[0].is_baseline
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def test_tasks_table_shows_biology_but_never_provenance(registry):
|
| 256 |
+
table = tasks_table(list(registry.values())).set_index("Task")
|
| 257 |
+
row = table.loc["IBD adalimumab remission"]
|
| 258 |
+
assert row["Disease"] == "Crohn Disease, Colitis, Ulcerative"
|
| 259 |
+
assert row["Tissue"] == "Digestive system"
|
| 260 |
+
assert row["Patients"] == 90
|
| 261 |
+
assert row["Metric"] == "AUROC"
|
| 262 |
+
assert row["Family"] == "Treatment outcome"
|
| 263 |
+
assert row["Predicts"] == "Will this patient respond?"
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def test_tasks_table_is_ordered_by_task_id(registry):
|
| 267 |
+
assert list(tasks_table(list(registry.values()))["Task"])[0] == (
|
| 268 |
+
"IBD adalimumab remission"
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def test_tasks_table_never_leaks_an_accession(registry):
|
| 273 |
+
rendered = tasks_table(list(registry.values())).to_string()
|
| 274 |
+
assert not re.search(r"GSE\d+|E-MTAB-\d+|benchmark_", rendered)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def test_source_repositories_name_the_archive_not_the_study(registry):
|
| 278 |
+
repositories = source_repositories(list(registry.values()))
|
| 279 |
+
assert repositories == ["EMBL-EBI ArrayExpress", "NCBI GEO"]
|
| 280 |
+
assert not re.search(r"GSE\d+|E-MTAB-\d+", " ".join(repositories))
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def test_the_unvalidated_licence_field_never_reaches_a_public_page(registry):
|
| 284 |
+
"""One hand-written line per dataset must not read as a checked rights position."""
|
| 285 |
+
assert "license" in next(iter(registry.values()))
|
| 286 |
+
assert not any(
|
| 287 |
+
"Terms of Use" in r for r in source_repositories(list(registry.values()))
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def test_empty_registry_yields_an_empty_table_not_a_crash():
|
| 292 |
+
assert list(tasks_table([]).columns) == TASK_COLUMNS
|
test_render.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the PRIMO page markup (no network, no Gradio).
|
| 2 |
+
|
| 3 |
+
These guard the guarantees a redesign must not lose: every board is reachable,
|
| 4 |
+
open slices route to Contribute, registry and submission text is escaped, the
|
| 5 |
+
baseline/first-submission states read correctly, the leaderboard bolds the best
|
| 6 |
+
value, provenance never leaks, and public submission credits are safely escaped.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
from boards import build_boards, open_in_group
|
| 12 |
+
from render import SECTIONS, rail_html, render_board, render_boards, render_tasks
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _slugs(html: str) -> list[str]:
|
| 16 |
+
return re.findall(r'href="\?board=([^"]+)"', html)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _boards_html(boards, registry, results):
|
| 20 |
+
df = results(*[("eva", tid, 0.8, "2026-01-01") for tid in registry])
|
| 21 |
+
return render_boards(boards, df, registry)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ---------------------------------------------------------------- boards page
|
| 25 |
+
def test_every_board_is_reachable_from_the_boards_page(boards, registry, results):
|
| 26 |
+
html = _boards_html(boards, registry, results)
|
| 27 |
+
assert set(_slugs(html)) == {b.slug for b in boards}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_each_facet_section_shows_with_its_note(boards, registry, results):
|
| 31 |
+
html = _boards_html(boards, registry, results)
|
| 32 |
+
assert "Per therapeutic areas" in html
|
| 33 |
+
assert "Task Category" in html
|
| 34 |
+
assert "Every task of one omics layer" in html
|
| 35 |
+
assert "PRIMO evaluates representations of omics samples" in html
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_modality_labels_are_displayed_as_rnaseq(boards, registry, results):
|
| 39 |
+
html = _boards_html(boards, registry, results)
|
| 40 |
+
assert "bulk RNAseq" in html
|
| 41 |
+
assert "single-cell RNAseq" in html
|
| 42 |
+
assert "Leaderboards" in html
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_open_cards_state_the_slices_nobody_covers(boards, registry, results):
|
| 46 |
+
html = _boards_html(boards, registry, results)
|
| 47 |
+
assert "proteomics" in html
|
| 48 |
+
assert "Oncology" in html
|
| 49 |
+
assert "OPEN" in html
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_both_open_axes_get_their_own_counter(boards, registry, results):
|
| 53 |
+
"""Modality and Therapeutic Areas each declare three gaps, so each says +3."""
|
| 54 |
+
assert _boards_html(boards, registry, results).count("+3 open") == 2
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_an_open_card_sends_the_visitor_to_contribute(boards, registry, results):
|
| 58 |
+
html = _boards_html(boards, registry, results)
|
| 59 |
+
assert 'href="?tab=contribute"' in html
|
| 60 |
+
assert "Propose a cohort" in html
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_an_open_card_disappears_once_the_registry_covers_it(registry, results):
|
| 64 |
+
registry["t006"] = {
|
| 65 |
+
**registry["t005"],
|
| 66 |
+
"task_id": "t006",
|
| 67 |
+
"modality": "single-cell RNA",
|
| 68 |
+
}
|
| 69 |
+
boards = build_boards(registry)
|
| 70 |
+
html = _boards_html(boards, registry, results)
|
| 71 |
+
assert 'href="?board=single-cell-rna"' in html
|
| 72 |
+
assert "+2 open" in html
|
| 73 |
+
assert "proteomics" in html
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_live_cards_teaser_the_leaders(boards, registry, results):
|
| 77 |
+
html = _boards_html(boards, registry, results)
|
| 78 |
+
assert "Leading" in html
|
| 79 |
+
assert "eva" in html
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_a_board_nobody_covered_invites_the_first_submission(boards, registry, results):
|
| 83 |
+
"""A board no model covered in full asks for the first entry, not a leader."""
|
| 84 |
+
html = render_boards(boards, results(("eva", "t001", 0.8, "2026-01-01")), registry)
|
| 85 |
+
assert "be the first" in html.lower()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_a_board_held_only_by_baselines_lists_its_models(
|
| 89 |
+
boards, registry, flagged_results
|
| 90 |
+
):
|
| 91 |
+
"""A board card lists its leading models without special baseline copy."""
|
| 92 |
+
df = flagged_results(
|
| 93 |
+
*[("pca-50", tid, 0.8, "2026-01-01", True) for tid in registry]
|
| 94 |
+
)
|
| 95 |
+
html = render_boards(boards, df, registry)
|
| 96 |
+
assert "be the first" not in html.lower()
|
| 97 |
+
assert "baseline</span>" in html
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_a_submitted_model_removes_the_challenger_line(
|
| 101 |
+
boards, registry, flagged_results
|
| 102 |
+
):
|
| 103 |
+
df = flagged_results(
|
| 104 |
+
*[("pca-50", tid, 0.8, "2026-01-01", True) for tid in registry],
|
| 105 |
+
*[("eva", tid, 0.9, "2026-01-01", False) for tid in registry],
|
| 106 |
+
)
|
| 107 |
+
html = render_boards(boards, df, registry)
|
| 108 |
+
assert "baseline</span>" in html
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_cards_carry_their_counts(boards, registry, results):
|
| 112 |
+
html = _boards_html(boards, registry, results)
|
| 113 |
+
assert "patients" in html and "cohorts" in html and "diseases" in html
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_registry_text_is_escaped_on_the_boards_page(registry, results):
|
| 117 |
+
registry["t004"]["diseases"] = ['<img src=x onerror="alert(1)">']
|
| 118 |
+
boards = build_boards(registry)
|
| 119 |
+
html = _boards_html(boards, registry, results)
|
| 120 |
+
assert "<img src=x" not in html
|
| 121 |
+
assert "<img src=x" in html
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ------------------------------------------------------------------- board page
|
| 125 |
+
def test_ranked_table_bolds_the_best_and_captions_full_coverage(
|
| 126 |
+
boards, registry, named, flagged_results
|
| 127 |
+
):
|
| 128 |
+
df = flagged_results(
|
| 129 |
+
*[("eva", tid, 0.9, "2026-01-01", False) for tid in registry],
|
| 130 |
+
*[("foo", tid, 0.5, "2026-01-01", False) for tid in registry],
|
| 131 |
+
)
|
| 132 |
+
html = render_board(named("bulk RNA"), df, registry)
|
| 133 |
+
assert "are ranked" in html
|
| 134 |
+
assert "eva" in html and "foo" in html
|
| 135 |
+
assert "font-weight:700" in html # the best value is bolded
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_data_tables_expose_sortable_column_headers(boards, registry, named, results):
|
| 139 |
+
df = results(*[("eva", task_id, 0.8, "2026-01-01") for task_id in registry])
|
| 140 |
+
html = render_board(named("bulk RNA"), df, registry)
|
| 141 |
+
assert 'class="pm-sort" data-sort-index="0"' in html
|
| 142 |
+
assert 'aria-sort="none"' in html
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_ranked_board_shows_only_the_institution_credit(
|
| 146 |
+
boards, registry, flagged_results
|
| 147 |
+
):
|
| 148 |
+
df = flagged_results(*[("eva", tid, 0.9, "2026-01-01", False) for tid in registry])
|
| 149 |
+
df["institution"] = "Scienta"
|
| 150 |
+
html = render_board(build_boards(registry)[0], df, registry)
|
| 151 |
+
assert "Institution" in html and "Scienta" in html
|
| 152 |
+
assert "Team" not in html
|
| 153 |
+
assert "Author" not in html
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_board_escapes_submission_credit(registry, named, flagged_results):
|
| 157 |
+
df = flagged_results(*[("eva", tid, 0.9, "2026-01-01", False) for tid in registry])
|
| 158 |
+
df["institution"] = "<script>alert(1)</script>"
|
| 159 |
+
html = render_board(named("bulk RNA"), df, registry)
|
| 160 |
+
assert "<script>" not in html and "<script>" in html
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def test_author_submission_gets_a_check_chip_and_paper_link(
|
| 164 |
+
registry, named, flagged_results
|
| 165 |
+
):
|
| 166 |
+
df = flagged_results(*[("eva", tid, 0.9, "2026-01-01", False) for tid in registry])
|
| 167 |
+
df["is_author_submission"] = True
|
| 168 |
+
df["paper_link"] = "https://example.org/paper"
|
| 169 |
+
html = render_board(named("bulk RNA"), df, registry)
|
| 170 |
+
assert 'href="https://example.org/paper"' in html
|
| 171 |
+
assert ">eva</a>" in html
|
| 172 |
+
assert 'class="pm-author-chip"' in html
|
| 173 |
+
assert "✓ Authors" in html
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_unsafe_paper_link_is_not_rendered_as_a_link(registry, named, flagged_results):
|
| 177 |
+
df = flagged_results(*[("eva", tid, 0.9, "2026-01-01", False) for tid in registry])
|
| 178 |
+
df["paper_link"] = 'javascript:alert("x")'
|
| 179 |
+
html = render_board(named("bulk RNA"), df, registry)
|
| 180 |
+
assert "javascript:" not in html
|
| 181 |
+
assert ">eva<" in html
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_board_keeps_legacy_private_owners_hidden(registry, named, flagged_results):
|
| 185 |
+
df = flagged_results(*[("eva", tid, 0.9, "2026-01-01", False) for tid in registry])
|
| 186 |
+
df["hf_username"] = "legacy-private-owner"
|
| 187 |
+
html = render_board(named("bulk RNA"), df, registry)
|
| 188 |
+
assert "legacy-private-owner" not in html
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def test_board_escapes_a_hostile_model_name(registry, named, flagged_results):
|
| 192 |
+
hostile = "<img src=x onerror=1>"
|
| 193 |
+
df = flagged_results(
|
| 194 |
+
*[(hostile, tid, 0.9, "2026-01-01", False) for tid in registry]
|
| 195 |
+
)
|
| 196 |
+
html = render_board(named("bulk RNA"), df, registry)
|
| 197 |
+
assert "<img src=x" not in html
|
| 198 |
+
assert "<img src=x" in html
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def test_an_empty_board_invites_the_first_submission(registry, results, named):
|
| 202 |
+
html = render_board(named("bulk RNA"), results(), registry)
|
| 203 |
+
assert "be the first" in html.lower()
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def test_a_missing_board_renders_a_message_not_a_crash(registry, results):
|
| 207 |
+
html = render_board(None, results(), registry)
|
| 208 |
+
assert "No board available" in html
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ------------------------------------------------------------------- tasks page
|
| 212 |
+
def test_tasks_page_shows_biology_but_never_an_accession(registry):
|
| 213 |
+
html = render_tasks(registry)
|
| 214 |
+
assert "Crohn Disease" in html
|
| 215 |
+
assert "Treatment outcome" in html
|
| 216 |
+
assert not re.search(r"GSE\d+|E-MTAB-\d+", html)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def test_tasks_page_escapes_registry_text(registry):
|
| 220 |
+
registry["t004"]["diseases"] = ['<img src=x onerror="alert(1)">']
|
| 221 |
+
html = render_tasks(registry)
|
| 222 |
+
assert "<img src=x" not in html
|
| 223 |
+
assert "<img src=x" in html
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ------------------------------------------------------------------------ rail
|
| 227 |
+
def test_rail_links_every_board_and_marks_the_active_one(boards):
|
| 228 |
+
active = boards[0].slug
|
| 229 |
+
html = rail_html(boards, active, None)
|
| 230 |
+
assert set(_slugs(html)) == {b.slug for b in boards}
|
| 231 |
+
assert f'class="pm-link pm-active" target="_self" href="?board={active}"' in html
|
| 232 |
+
assert "Submit a model" in html
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def test_rail_groups_contain_their_board_links(boards):
|
| 236 |
+
html = rail_html(boards, None, None)
|
| 237 |
+
groups = re.findall(r'<div class="pm-rail-group">(.*?)</div>', html)
|
| 238 |
+
grouped = "".join(groups)
|
| 239 |
+
n_open = sum(len(open_in_group(boards, group)) for group in SECTIONS)
|
| 240 |
+
|
| 241 |
+
assert set(_slugs(grouped)) == {board.slug for board in boards}
|
| 242 |
+
assert grouped.count('href="?tab=contribute"') == n_open
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def test_rail_open_slices_route_to_contribute(boards):
|
| 246 |
+
html = rail_html(boards, None, None)
|
| 247 |
+
assert 'href="?tab=contribute"' in html
|
| 248 |
+
assert "proteomics" in html
|
test_results.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the results schema (no network, no Gradio)."""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from results import (
|
| 6 |
+
INSTITUTION,
|
| 7 |
+
IS_AUTHOR_SUBMISSION,
|
| 8 |
+
IS_BASELINE,
|
| 9 |
+
OWNER,
|
| 10 |
+
PAPER_LINK,
|
| 11 |
+
RESULT_COLUMNS,
|
| 12 |
+
SUBMISSION_COLUMNS,
|
| 13 |
+
SUBMISSION_ID,
|
| 14 |
+
display_name,
|
| 15 |
+
owner_of,
|
| 16 |
+
with_author_submission_flag,
|
| 17 |
+
with_baseline_flag,
|
| 18 |
+
with_institution,
|
| 19 |
+
with_owner,
|
| 20 |
+
with_paper_link,
|
| 21 |
+
with_submission_id,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _frame(**columns) -> pd.DataFrame:
|
| 26 |
+
return pd.DataFrame({"model_name": ["m"], "score": [0.5], **columns})
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _rows(*rows) -> pd.DataFrame:
|
| 30 |
+
return pd.DataFrame(list(rows), columns=["model_name", IS_BASELINE, OWNER])
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_a_missing_column_means_nobody_is_a_baseline():
|
| 34 |
+
flagged = with_baseline_flag(_frame())
|
| 35 |
+
assert list(flagged[IS_BASELINE]) == [False]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_booleans_survive_the_round_trip():
|
| 39 |
+
flagged = with_baseline_flag(_frame(**{IS_BASELINE: [True]}))
|
| 40 |
+
assert list(flagged[IS_BASELINE]) == [True]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_the_strings_a_csv_gives_back_are_read_as_booleans():
|
| 44 |
+
"""``read_csv`` yields ``"True"``/``"False"`` as soon as one row is blank."""
|
| 45 |
+
flagged = with_baseline_flag(
|
| 46 |
+
pd.DataFrame({IS_BASELINE: ["True", "false", "TRUE ", np.nan, ""]})
|
| 47 |
+
)
|
| 48 |
+
assert list(flagged[IS_BASELINE]) == [True, False, True, False, False]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_submission_identity_is_part_of_both_schemas():
|
| 52 |
+
assert {
|
| 53 |
+
IS_BASELINE,
|
| 54 |
+
OWNER,
|
| 55 |
+
INSTITUTION,
|
| 56 |
+
IS_AUTHOR_SUBMISSION,
|
| 57 |
+
PAPER_LINK,
|
| 58 |
+
SUBMISSION_ID,
|
| 59 |
+
} <= set(RESULT_COLUMNS)
|
| 60 |
+
assert {OWNER, INSTITUTION, IS_AUTHOR_SUBMISSION, PAPER_LINK, SUBMISSION_ID} <= set(
|
| 61 |
+
SUBMISSION_COLUMNS
|
| 62 |
+
)
|
| 63 |
+
assert "diagnostics" in RESULT_COLUMNS
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_only_baselines_are_labelled():
|
| 67 |
+
assert display_name("pca-50", True) == "pca-50 (baseline)"
|
| 68 |
+
assert display_name("eva-rna-v1", False) == "eva-rna-v1"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_rows_written_before_the_owner_existed_are_unclaimed():
|
| 72 |
+
assert list(with_owner(_frame())[OWNER]) == [""]
|
| 73 |
+
assert list(with_owner(_frame(**{OWNER: [np.nan]}))[OWNER]) == [""]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_rows_written_before_the_institution_existed_have_no_institution():
|
| 77 |
+
assert list(with_institution(_frame())[INSTITUTION]) == [""]
|
| 78 |
+
assert list(with_institution(_frame(**{INSTITUTION: [np.nan]}))[INSTITUTION]) == [
|
| 79 |
+
""
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_legacy_team_values_are_read_as_institutions():
|
| 84 |
+
frame = _frame(team=["Legacy Lab"])
|
| 85 |
+
assert list(with_institution(frame)[INSTITUTION]) == ["Legacy Lab"]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_explicit_institution_wins_over_legacy_team():
|
| 89 |
+
frame = _frame(institution=["Current Lab"], team=["Old Lab"])
|
| 90 |
+
assert list(with_institution(frame)[INSTITUTION]) == ["Current Lab"]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_legacy_rows_are_not_marked_as_author_submissions():
|
| 94 |
+
assert list(with_author_submission_flag(_frame())[IS_AUTHOR_SUBMISSION]) == [False]
|
| 95 |
+
assert list(
|
| 96 |
+
with_author_submission_flag(_frame(**{IS_AUTHOR_SUBMISSION: [np.nan]}))[
|
| 97 |
+
IS_AUTHOR_SUBMISSION
|
| 98 |
+
]
|
| 99 |
+
) == [False]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_nullable_public_credits_and_submission_ids_normalize_to_blank():
|
| 103 |
+
frame = _frame(
|
| 104 |
+
**{PAPER_LINK: [pd.NA], INSTITUTION: [pd.NA], SUBMISSION_ID: [pd.NA]}
|
| 105 |
+
)
|
| 106 |
+
assert list(with_paper_link(frame)[PAPER_LINK]) == [""]
|
| 107 |
+
assert list(with_institution(frame)[INSTITUTION]) == [""]
|
| 108 |
+
assert list(with_submission_id(frame)[SUBMISSION_ID]) == [""]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_a_name_is_owned_by_whoever_submitted_it():
|
| 112 |
+
assert owner_of(_rows(("eva", False, "scienta")), "eva") == "scienta"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_an_unclaimed_or_unknown_name_is_free():
|
| 116 |
+
assert owner_of(_rows(("eva", False, "")), "eva") == ""
|
| 117 |
+
assert owner_of(_rows(("eva", False, "scienta")), "other") == ""
|
| 118 |
+
assert owner_of(pd.DataFrame(), "eva") == ""
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_publishing_a_baseline_never_locks_the_name_for_submitters():
|
| 122 |
+
"""``pca-50`` the baseline and ``pca-50`` the model are separate entries."""
|
| 123 |
+
assert owner_of(_rows(("pca-50", True, "scienta")), "pca-50") == ""
|
test_scoring.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the pure scoring policy (no network, no probe)."""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import pytest
|
| 5 |
+
from scoring import (
|
| 6 |
+
TaskScore,
|
| 7 |
+
category_means,
|
| 8 |
+
compute_auroc,
|
| 9 |
+
compute_pearson,
|
| 10 |
+
compute_residual_sample_spearman,
|
| 11 |
+
sort_key,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _score(task_id, dataset_id, category, metric, score):
|
| 16 |
+
return TaskScore(task_id, dataset_id, category, metric, score, 10)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_compute_auroc_binary():
|
| 20 |
+
y = np.array([0, 0, 1, 1])
|
| 21 |
+
proba = np.array([[0.9, 0.1], [0.6, 0.4], [0.3, 0.7], [0.2, 0.8]])
|
| 22 |
+
assert compute_auroc(y, proba) == pytest.approx(1.0)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_transfer_auroc_reads_the_column_of_the_class_it_scores():
|
| 26 |
+
"""Test cohort holds 2 of 3 classes: scoring class 1's column would be wrong."""
|
| 27 |
+
y_true = np.array([0, 0, 2, 2])
|
| 28 |
+
preds = np.array(
|
| 29 |
+
[[0.7, 0.2, 0.1], [0.6, 0.3, 0.1], [0.1, 0.3, 0.6], [0.2, 0.2, 0.6]]
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
assert compute_auroc(y_true, preds, np.array([0, 1, 2])) == 1.0
|
| 33 |
+
assert compute_auroc(y_true, preds[:, ::-1], np.array([0, 1, 2])) == 0.0
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_auroc_survives_a_class_the_training_fold_never_saw():
|
| 37 |
+
"""Zeroed column + renormalisation, instead of sklearn's sum-to-one refusal."""
|
| 38 |
+
y_true = np.array([0, 0, 1, 1, 2, 2])
|
| 39 |
+
preds = np.zeros((6, 3))
|
| 40 |
+
preds[:, 0] = [0.9, 0.8, 0.2, 0.1, 0.2, 0.1]
|
| 41 |
+
preds[:, 1] = [0.1, 0.2, 0.8, 0.9, 0.3, 0.2]
|
| 42 |
+
|
| 43 |
+
score = compute_auroc(y_true, preds, np.array([0, 1, 2]))
|
| 44 |
+
|
| 45 |
+
assert 0.0 <= score <= 1.0
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_auroc_refuses_predictions_with_no_mass_on_the_present_classes():
|
| 49 |
+
y_true = np.array([0, 0, 1, 1])
|
| 50 |
+
preds = np.zeros((4, 3))
|
| 51 |
+
preds[:, 2] = 1.0
|
| 52 |
+
|
| 53 |
+
with pytest.raises(ValueError, match="no probability"):
|
| 54 |
+
compute_auroc(y_true, preds, np.array([0, 1, 2]))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_compute_pearson_and_degenerate():
|
| 58 |
+
perfect = compute_pearson(np.array([1.0, 2, 3]), np.array([1.0, 2, 3]))
|
| 59 |
+
assert perfect == pytest.approx(1.0)
|
| 60 |
+
assert np.isnan(compute_pearson(np.array([1.0, 2, 3]), np.array([5.0, 5, 5])))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_residual_sample_spearman_anchors_mean_response_at_zero():
|
| 64 |
+
training_mean = np.array([10.0, 20.0, 30.0, 40.0])
|
| 65 |
+
truth = training_mean + np.array([[1.0, 3.0, -1.0, 2.0]])
|
| 66 |
+
baseline = training_mean[None, :]
|
| 67 |
+
|
| 68 |
+
assert compute_residual_sample_spearman(
|
| 69 |
+
truth, truth, training_mean
|
| 70 |
+
) == pytest.approx(1.0)
|
| 71 |
+
assert compute_residual_sample_spearman(
|
| 72 |
+
truth, baseline, training_mean
|
| 73 |
+
) == pytest.approx(0.0)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_category_means_groups_by_category():
|
| 77 |
+
scores = [
|
| 78 |
+
_score("t1", "d1", "treatment_outcome", "auroc", 0.8),
|
| 79 |
+
_score("t2", "d2", "treatment_outcome", "auroc", 0.6),
|
| 80 |
+
_score("t3", "d3", "clinical_scores", "pearson", 0.4),
|
| 81 |
+
]
|
| 82 |
+
cats = category_means(scores)
|
| 83 |
+
assert cats["treatment_outcome"] == {
|
| 84 |
+
"metric": "auroc",
|
| 85 |
+
"mean": pytest.approx(0.7),
|
| 86 |
+
"n_tasks": 2,
|
| 87 |
+
}
|
| 88 |
+
assert cats["clinical_scores"]["mean"] == pytest.approx(0.4)
|
| 89 |
+
assert cats["clinical_scores"]["n_tasks"] == 1
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_category_means_drops_non_finite_from_mean():
|
| 93 |
+
scores = [
|
| 94 |
+
_score("t1", "d1", "clinical_scores", "pearson", 0.4),
|
| 95 |
+
_score("t2", "d2", "clinical_scores", "pearson", float("nan")),
|
| 96 |
+
]
|
| 97 |
+
cats = category_means(scores)
|
| 98 |
+
assert cats["clinical_scores"]["mean"] == pytest.approx(0.4)
|
| 99 |
+
assert cats["clinical_scores"]["n_tasks"] == 2
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_category_means_all_non_finite_is_nan():
|
| 103 |
+
scores = [_score("t1", "d1", "endotype", "auroc", float("nan"))]
|
| 104 |
+
assert np.isnan(category_means(scores)["endotype"]["mean"])
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_category_means_empty():
|
| 108 |
+
assert category_means([]) == {}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_sort_key_is_mean_of_category_means():
|
| 112 |
+
cats = {
|
| 113 |
+
"treatment_outcome": {"metric": "auroc", "mean": 0.8, "n_tasks": 1},
|
| 114 |
+
"clinical_scores": {"metric": "pearson", "mean": 0.2, "n_tasks": 1},
|
| 115 |
+
}
|
| 116 |
+
assert sort_key(cats) == pytest.approx(0.5)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_sort_key_ignores_non_finite_and_empty():
|
| 120 |
+
cats = {
|
| 121 |
+
"treatment_outcome": {"metric": "auroc", "mean": 0.8, "n_tasks": 1},
|
| 122 |
+
"endotype": {"metric": "auroc", "mean": float("nan"), "n_tasks": 1},
|
| 123 |
+
}
|
| 124 |
+
assert sort_key(cats) == pytest.approx(0.8)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_nothing_finite_sorts_last_instead_of_at_zero():
|
| 128 |
+
"""A constant embedding scores NaN everywhere; that must not beat a bad score."""
|
| 129 |
+
nothing = {"clinical_scores": {"metric": "pearson", "mean": float("nan")}}
|
| 130 |
+
negative = {"clinical_scores": {"metric": "pearson", "mean": -0.2}}
|
| 131 |
+
assert sort_key(nothing) == float("-inf")
|
| 132 |
+
assert sort_key({}) == float("-inf")
|
| 133 |
+
assert sort_key(nothing) < sort_key(negative)
|