bindsight 0.3.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bindsight/README.md +44 -0
- bindsight/__init__.py +15 -0
- bindsight/benchmark/__init__.py +71 -0
- bindsight/benchmark/core.py +497 -0
- bindsight/benchmark/defaults.py +28 -0
- bindsight/benchmark/designer_bench.py +899 -0
- bindsight/benchmark/outcomes.py +508 -0
- bindsight/benchmark/panel.py +621 -0
- bindsight/benchmark/rediscovery.py +1153 -0
- bindsight/benchmark/statistics.py +622 -0
- bindsight/benchmark/study.py +1153 -0
- bindsight/benchmark/study_figures.py +164 -0
- bindsight/benchmark/study_report.py +607 -0
- bindsight/cli.py +2429 -0
- bindsight/config.py +451 -0
- bindsight/cost.py +258 -0
- bindsight/deg/__init__.py +7 -0
- bindsight/deg/pydeseq2_runner.py +285 -0
- bindsight/design/__init__.py +23 -0
- bindsight/design/_common.py +501 -0
- bindsight/design/bindcraft.py +78 -0
- bindsight/design/boltzgen.py +79 -0
- bindsight/design/developability.py +140 -0
- bindsight/design/embeddings.py +165 -0
- bindsight/design/prescreen.py +169 -0
- bindsight/design/protocol.py +111 -0
- bindsight/design/rfdiff_mpnn.py +93 -0
- bindsight/epitopes/__init__.py +7 -0
- bindsight/epitopes/surface_bind.py +134 -0
- bindsight/export/__init__.py +7 -0
- bindsight/export/ro_crate.py +595 -0
- bindsight/io/__init__.py +19 -0
- bindsight/io/cbioportal.py +118 -0
- bindsight/io/gdc.py +343 -0
- bindsight/io/paths.py +124 -0
- bindsight/pipelines/__init__.py +9 -0
- bindsight/pipelines/caveats.py +60 -0
- bindsight/pipelines/discover.py +1443 -0
- bindsight/pipelines/full_run.py +410 -0
- bindsight/plugins.py +335 -0
- bindsight/provenance/__init__.py +34 -0
- bindsight/provenance/append.py +199 -0
- bindsight/provenance/fragments.py +216 -0
- bindsight/provenance/manifest.py +506 -0
- bindsight/rank/__init__.py +7 -0
- bindsight/rank/scoring.py +355 -0
- bindsight/report/__init__.py +39 -0
- bindsight/report/format.py +100 -0
- bindsight/report/html.py +645 -0
- bindsight/report/showcase.py +653 -0
- bindsight/report/templates/report.css +67 -0
- bindsight/report/templates/report.html.j2 +420 -0
- bindsight/report/theme.py +231 -0
- bindsight/report/web/__init__.py +35 -0
- bindsight/report/web/app.py +624 -0
- bindsight/report/web/static/binder_viewer.js +245 -0
- bindsight/report/web/static/bindsight.css +455 -0
- bindsight/report/web/static/charts.js +425 -0
- bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
- bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
- bindsight/report/web/static/your_data_check.js +370 -0
- bindsight/report/web/templates/base.html.j2 +48 -0
- bindsight/report/web/templates/evidence.html.j2 +341 -0
- bindsight/report/web/templates/overview.html.j2 +140 -0
- bindsight/report/web/templates/runs.html.j2 +125 -0
- bindsight/report/web/templates/try.html.j2 +155 -0
- bindsight/report/web/templates/your_data.html.j2 +46 -0
- bindsight/runners/__init__.py +7 -0
- bindsight/runners/colab.py +158 -0
- bindsight/runners/job_exec.py +1110 -0
- bindsight/runners/kaggle.py +425 -0
- bindsight/runners/kaggle_kernel.py +489 -0
- bindsight/runners/local_docker.py +154 -0
- bindsight/runners/mock.py +167 -0
- bindsight/runners/modal_runner.py +199 -0
- bindsight/runners/notebook.py +116 -0
- bindsight/runners/notebook_content.py +217 -0
- bindsight/runners/protocol.py +81 -0
- bindsight/runners/source_wheel.py +104 -0
- bindsight/runners/tools.py +997 -0
- bindsight/structures/__init__.py +7 -0
- bindsight/structures/alphafolddb.py +93 -0
- bindsight/structures/plddt.py +92 -0
- bindsight/structures/topology.py +227 -0
- bindsight/surfaceome/__init__.py +42 -0
- bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
- bindsight/surfaceome/data/surfy_offline.txt +17 -0
- bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
- bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
- bindsight/surfaceome/surfy.py +441 -0
- bindsight/targets/__init__.py +7 -0
- bindsight/targets/ensembl_uniprot.py +56 -0
- bindsight/targets/gtex.py +250 -0
- bindsight/targets/open_targets.py +239 -0
- bindsight/validate/__init__.py +7 -0
- bindsight/validate/af2_ig.py +49 -0
- bindsight/validate/boltz2.py +256 -0
- bindsight/validate/chai1r.py +42 -0
- bindsight/validate/protocol.py +188 -0
- bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
- bindsight-0.3.5.dist-info/METADATA +495 -0
- bindsight-0.3.5.dist-info/RECORD +106 -0
- bindsight-0.3.5.dist-info/WHEEL +4 -0
- bindsight-0.3.5.dist-info/entry_points.txt +20 -0
- bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
- bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""The rediscovery study: run one cohort at a time, then score the panel.
|
|
4
|
+
|
|
5
|
+
Deliberately split into two halves that do not depend on each other.
|
|
6
|
+
|
|
7
|
+
:func:`prepare_cohort` and :func:`run_cohort` touch the network and the CPU.
|
|
8
|
+
They are staged on purpose — one TCGA project per call — so a sweep across
|
|
9
|
+
thirteen projects never becomes a single unattended multi-hour job that makes
|
|
10
|
+
the machine unusable, and so a failure costs one cohort rather than the study.
|
|
11
|
+
The worker count is capped rather than left at "every core".
|
|
12
|
+
|
|
13
|
+
:func:`score_cohort` and :func:`summarise` are pure functions over tables that
|
|
14
|
+
already exist on disk. They can be re-run, tested and argued with at no cost,
|
|
15
|
+
which matters because the scoring rules are where the previous study went wrong.
|
|
16
|
+
|
|
17
|
+
**The paired design.** Cohorts are built from patients who contributed both a
|
|
18
|
+
tumour and a solid-tissue normal, and the contrast blocks on the patient. In
|
|
19
|
+
most TCGA projects nearly every normal has a matching tumour, so pairing costs
|
|
20
|
+
almost no data and removes between-patient variance — the largest free power
|
|
21
|
+
gain available here. The blocking factor is ``case_barcode``, which is the
|
|
22
|
+
column the design table actually carries.
|
|
23
|
+
|
|
24
|
+
**What is reported.** Never a bare rate. Every cohort publishes the sizes of the
|
|
25
|
+
sets its numbers were computed over, every pair publishes its outcome class and
|
|
26
|
+
its counterfactual rank, and every rate publishes a numerator, a denominator and
|
|
27
|
+
an interval. A reader who disagrees with the denominator can recompute from what
|
|
28
|
+
is printed.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
from collections.abc import Sequence
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, cast
|
|
39
|
+
|
|
40
|
+
from bindsight.benchmark import outcomes as O
|
|
41
|
+
from bindsight.benchmark import panel as P
|
|
42
|
+
from bindsight.benchmark import statistics as S
|
|
43
|
+
|
|
44
|
+
LOG = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"CohortResult",
|
|
48
|
+
"StudyConfig",
|
|
49
|
+
"prepare_cohort",
|
|
50
|
+
"run_cohort",
|
|
51
|
+
"score_cohort",
|
|
52
|
+
"summarise",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class StudyConfig:
|
|
58
|
+
"""How the study runs, and what it is scored against.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
out_dir: root for cohort run directories and artifacts.
|
|
62
|
+
n_cpus: worker cap for differential expression. Two leaves headroom on a
|
|
63
|
+
four-core laptop, which is what this is expected to run on.
|
|
64
|
+
max_pairs: cap on matched pairs per cohort. ``None`` uses every pair,
|
|
65
|
+
which is the point of the paired design.
|
|
66
|
+
tiers: regulatory tiers entering the primary denominator.
|
|
67
|
+
fdr_threshold: FDR for calling a gene differentially expressed.
|
|
68
|
+
log2fc_threshold: fold-change floor applied after FDR.
|
|
69
|
+
seed: fixed so every interval and null in the report is reproducible.
|
|
70
|
+
n_decoys: decoys per antigen for the primary null.
|
|
71
|
+
n_permutations: permutations for the panel-level specificity null.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
out_dir: Path
|
|
75
|
+
n_cpus: int | None = 2
|
|
76
|
+
max_pairs: int | None = None
|
|
77
|
+
tiers: tuple[P.Tier, ...] = ("approved",)
|
|
78
|
+
fdr_threshold: float = 0.05
|
|
79
|
+
log2fc_threshold: float = 1.0
|
|
80
|
+
seed: int = 0
|
|
81
|
+
n_decoys: int = 1000
|
|
82
|
+
n_permutations: int = 10_000
|
|
83
|
+
# Cutoffs at which recall is reported. A single cutoff is not interpretable
|
|
84
|
+
# across cohorts whose shortlists differ in size, and the shortlist here is
|
|
85
|
+
# roughly 285 rather than the ~40 it was before the surfaceome pre-filter —
|
|
86
|
+
# so "within the top 20" silently became a far harder question. Reporting a
|
|
87
|
+
# range, alongside each antigen's normalised rank, is what lets a reader see
|
|
88
|
+
# that rather than infer it.
|
|
89
|
+
recall_at_k: tuple[int, ...] = (5, 10, 20, 50, 100)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class CohortResult:
|
|
94
|
+
"""Everything one cohort contributes to the study.
|
|
95
|
+
|
|
96
|
+
``set_sizes`` is not optional decoration. A rank without the size of the
|
|
97
|
+
shortlist it sits in is uninterpretable, and the previous study's results
|
|
98
|
+
table omitted it.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
project: str
|
|
102
|
+
set_sizes: dict[str, int]
|
|
103
|
+
pairs: list[dict[str, Any]] = field(default_factory=list)
|
|
104
|
+
provenance: dict[str, Any] = field(default_factory=dict)
|
|
105
|
+
stage_status: dict[str, str] = field(default_factory=dict)
|
|
106
|
+
# Every panel antigen's standing in THIS cohort, whether or not the cohort is
|
|
107
|
+
# its indication. Symbol -> 1.0 at counterfactual rank 1, falling to 0 at the
|
|
108
|
+
# bottom of the eligible set. The panel-level specificity null needs each
|
|
109
|
+
# antigen scored in every cohort, and this is where that matrix comes from.
|
|
110
|
+
antigen_scores: dict[str, float] = field(default_factory=dict)
|
|
111
|
+
|
|
112
|
+
def as_dict(self) -> dict[str, Any]:
|
|
113
|
+
"""Serialisable form."""
|
|
114
|
+
return {
|
|
115
|
+
"project": self.project,
|
|
116
|
+
"set_sizes": self.set_sizes,
|
|
117
|
+
"pairs": self.pairs,
|
|
118
|
+
"provenance": self.provenance,
|
|
119
|
+
"stage_status": self.stage_status,
|
|
120
|
+
"antigen_scores": self.antigen_scores,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def prepare_cohort(
|
|
125
|
+
project: str, config: StudyConfig, *, max_pairs: int | None = None
|
|
126
|
+
) -> dict[str, Any]:
|
|
127
|
+
"""Download one project's matched tumour-normal cohort.
|
|
128
|
+
|
|
129
|
+
Only patients contributing both arms are used, so the contrast can block on
|
|
130
|
+
the patient. Nothing is downloaded twice: an existing counts file with its
|
|
131
|
+
provenance is reused, which is what makes a staged sweep resumable -- but
|
|
132
|
+
only when it was built at the size being asked for. A cohort on disk that
|
|
133
|
+
holds a different number of pairs is rebuilt and the mismatch logged, because
|
|
134
|
+
returning it would report the cached size as the requested one.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
project: GDC project id.
|
|
138
|
+
config: study configuration.
|
|
139
|
+
max_pairs: override the configured cap for this cohort.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The GDC provenance dict.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If the project has no matched pairs, so no paired contrast
|
|
146
|
+
exists. That is a property of the data and must not be worked around
|
|
147
|
+
by silently falling back to an unpaired design.
|
|
148
|
+
"""
|
|
149
|
+
from bindsight.io.gdc import fetch_cohort, matched_pair_cases
|
|
150
|
+
|
|
151
|
+
run_dir = Path(config.out_dir) / project.removeprefix("TCGA-").lower()
|
|
152
|
+
counts = run_dir / "counts.tsv.gz"
|
|
153
|
+
design = run_dir / "design.tsv"
|
|
154
|
+
provenance_path = run_dir / "provenance.json"
|
|
155
|
+
|
|
156
|
+
cap = max_pairs if max_pairs is not None else config.max_pairs
|
|
157
|
+
|
|
158
|
+
if counts.exists() and design.exists() and provenance_path.exists():
|
|
159
|
+
cached = dict(json.loads(provenance_path.read_text(encoding="utf-8")))
|
|
160
|
+
built_with = cached.get("n_tumor")
|
|
161
|
+
# Reuse only what matches the request. The three files existing says a
|
|
162
|
+
# cohort is there, not that it is *this* cohort: a sweep run at 50 pairs
|
|
163
|
+
# and re-run at 5 returned the 50-pair cohort and reported n_tumor 50,
|
|
164
|
+
# which benchmarks/run_study.py copies straight into the published
|
|
165
|
+
# record. The study would then state one size and its provenance
|
|
166
|
+
# another, from the same call.
|
|
167
|
+
if cap is None or built_with is None or int(built_with) == int(cap):
|
|
168
|
+
LOG.info("%s: reusing the cohort already on disk (%s)", project, counts)
|
|
169
|
+
return cached
|
|
170
|
+
LOG.warning(
|
|
171
|
+
"%s: the cohort on disk holds %s pair(s) and %s were requested; "
|
|
172
|
+
"rebuilding rather than reporting the cached size as the one asked for",
|
|
173
|
+
project,
|
|
174
|
+
built_with,
|
|
175
|
+
cap,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
cases = matched_pair_cases(project)
|
|
179
|
+
if not cases:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"{project} has no patients with both a primary tumour and a solid-tissue "
|
|
182
|
+
"normal, so no paired contrast is possible. Exclude the cohort rather than "
|
|
183
|
+
"substituting an unpaired or cross-study normal."
|
|
184
|
+
)
|
|
185
|
+
if cap is not None:
|
|
186
|
+
cases = cases[:cap]
|
|
187
|
+
|
|
188
|
+
LOG.info("%s: fetching %d matched pair(s)", project, len(cases))
|
|
189
|
+
provenance = fetch_cohort(
|
|
190
|
+
project=project,
|
|
191
|
+
n_tumor=len(cases),
|
|
192
|
+
n_normal=len(cases),
|
|
193
|
+
counts_out=counts,
|
|
194
|
+
design_out=design,
|
|
195
|
+
tumor_cases=cases,
|
|
196
|
+
normal_cases=cases,
|
|
197
|
+
)
|
|
198
|
+
return dict(provenance)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def build_run_config(project: str, config: StudyConfig) -> Any:
|
|
202
|
+
"""Discovery configuration for one cohort, with the paired design.
|
|
203
|
+
|
|
204
|
+
The blocking factor is ``case_barcode`` because that is the column the
|
|
205
|
+
design table carries; ``patient`` does not exist and would fail inside
|
|
206
|
+
pydeseq2 with an unhelpful message about an unknown factor.
|
|
207
|
+
"""
|
|
208
|
+
from bindsight.config import (
|
|
209
|
+
DEGParams,
|
|
210
|
+
DesignParams,
|
|
211
|
+
InputsConfig,
|
|
212
|
+
RankParams,
|
|
213
|
+
RunConfig,
|
|
214
|
+
StageParams,
|
|
215
|
+
TargetDiscoveryParams,
|
|
216
|
+
ValidateParams,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
run_dir = Path(config.out_dir) / project.removeprefix("TCGA-").lower()
|
|
220
|
+
return RunConfig(
|
|
221
|
+
name=f"study_{project.removeprefix('TCGA-').lower()}",
|
|
222
|
+
out_dir=run_dir,
|
|
223
|
+
inputs=InputsConfig(
|
|
224
|
+
counts=run_dir / "counts.tsv.gz", design=run_dir / "design.tsv", download=None
|
|
225
|
+
),
|
|
226
|
+
params=StageParams(
|
|
227
|
+
deg=DEGParams(
|
|
228
|
+
# Blocking on the patient is what the matched-pair cohort exists
|
|
229
|
+
# for. case_barcode is the column the design table carries;
|
|
230
|
+
# "patient" does not exist and would fail inside pydeseq2 with an
|
|
231
|
+
# unhelpful message about an unknown factor.
|
|
232
|
+
design_formula="~ case_barcode + condition",
|
|
233
|
+
contrast=["condition", "tumor", "normal"],
|
|
234
|
+
fdr_threshold=config.fdr_threshold,
|
|
235
|
+
log2fc_threshold=config.log2fc_threshold,
|
|
236
|
+
min_replicates=3,
|
|
237
|
+
n_cpus=config.n_cpus,
|
|
238
|
+
),
|
|
239
|
+
target_discovery=TargetDiscoveryParams(
|
|
240
|
+
require_surfy=True,
|
|
241
|
+
surfy_allow_offline_fallback=False,
|
|
242
|
+
use_open_targets=True,
|
|
243
|
+
# The surfaceome filter already enforces cell-surface localisation,
|
|
244
|
+
# which is the biological prerequisite. Gating additionally on Open
|
|
245
|
+
# Targets' antibody-tractability bucket would drop bona-fide surface
|
|
246
|
+
# antigens on an incomplete curated call and confound a test that is
|
|
247
|
+
# meant to be about expression.
|
|
248
|
+
require_tractable_modality=[],
|
|
249
|
+
max_safety_events=5,
|
|
250
|
+
require_surface_bind_site=False,
|
|
251
|
+
top_n=20,
|
|
252
|
+
),
|
|
253
|
+
design=DesignParams(),
|
|
254
|
+
validate=ValidateParams(),
|
|
255
|
+
rank=RankParams(),
|
|
256
|
+
),
|
|
257
|
+
backend="mock",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def run_cohort(project: str, config: StudyConfig) -> Path:
|
|
262
|
+
"""Run discovery for one prepared cohort. Returns its run directory.
|
|
263
|
+
|
|
264
|
+
This is the expensive call: differential expression across every gene with a
|
|
265
|
+
blocking factor per patient. It is one project per invocation by design.
|
|
266
|
+
"""
|
|
267
|
+
from bindsight.pipelines.discover import run as run_discover
|
|
268
|
+
|
|
269
|
+
run_dir = Path(config.out_dir) / project.removeprefix("TCGA-").lower()
|
|
270
|
+
run_config = build_run_config(project, config)
|
|
271
|
+
LOG.info("%s: running discovery (n_cpus=%s)", project, config.n_cpus)
|
|
272
|
+
run_discover(run_config, out_dir=run_dir)
|
|
273
|
+
return run_dir
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
#: Number of strata per axis for decoy matching, on each of abundance and
|
|
277
|
+
#: dispersion.
|
|
278
|
+
#:
|
|
279
|
+
#: Quintiles, not deciles, and the reason is resolution. An exact within-stratum
|
|
280
|
+
#: tail can be no smaller than ``1 / (1 + pool)``, so the stratum size *is* the
|
|
281
|
+
#: smallest p-value the null can ever report. Measured on the real KIRC cohort,
|
|
282
|
+
#: whose eligible surfaceome is 2,209 genes:
|
|
283
|
+
#:
|
|
284
|
+
#: deciles 97 cells median 22 per cell floor p = 0.046
|
|
285
|
+
#: quintiles 25 cells median 76 per cell floor p = 0.013
|
|
286
|
+
#:
|
|
287
|
+
#: Deciles match more tightly and cannot resolve significance at all — CA9's own
|
|
288
|
+
#: cell held nine other genes, so 0.1 was the best p obtainable for the
|
|
289
|
+
#: strongest signal in the panel. Quintiles keep each cell a fifth of each axis,
|
|
290
|
+
#: which is still a meaningful match, and buy an order of magnitude of
|
|
291
|
+
#: resolution. Every pair reports its own floor so the trade is visible per
|
|
292
|
+
#: antigen rather than assumed.
|
|
293
|
+
#: Strata the decoy matching bins into. Five, so the published fields are
|
|
294
|
+
#: named "_stratum": they were called "_decile" while carrying quintiles 0-4,
|
|
295
|
+
#: which told a reader the matching was ten times finer than it is.
|
|
296
|
+
_DECOY_STRATA_BINS = 5
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _decoy_strata(ordered: Any) -> Any:
|
|
300
|
+
"""Label each eligible gene with its abundance and dispersion stratum.
|
|
301
|
+
|
|
302
|
+
A decoy is only a decoy if it could plausibly have been the antigen. The
|
|
303
|
+
confound the decoy null exists to remove is that abundant, high-dispersion
|
|
304
|
+
genes reach significance more easily, so a matched decoy must share both.
|
|
305
|
+
|
|
306
|
+
``baseMean`` is DESeq2's abundance. ``lfc_se`` stands in for dispersion: the
|
|
307
|
+
standard error of the log fold change is a function of dispersion, count
|
|
308
|
+
depth and sample size, and since abundance is already conditioned on, what
|
|
309
|
+
remains tracks dispersion. The true fitted dispersion is not written to
|
|
310
|
+
``deg/results.parquet``, and re-running DESeq2 across fifteen cohorts to
|
|
311
|
+
recover it would cost far more than the refinement is worth. This is a proxy
|
|
312
|
+
and is reported as one.
|
|
313
|
+
|
|
314
|
+
Ranks are binned rather than raw values, so a heavily skewed distribution
|
|
315
|
+
still yields even strata.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
ordered: the eligible ranking, carrying ``baseMean`` and ``lfc_se``.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
The frame with ``base_mean_stratum`` and ``dispersion_stratum`` columns.
|
|
322
|
+
Either is ``-1`` where the underlying column is missing or unusable, which
|
|
323
|
+
collapses those genes into one stratum rather than dropping them.
|
|
324
|
+
"""
|
|
325
|
+
import pandas as pd
|
|
326
|
+
|
|
327
|
+
out = ordered.copy()
|
|
328
|
+
for column, label in (("baseMean", "base_mean_stratum"), ("lfc_se", "dispersion_stratum")):
|
|
329
|
+
if column not in out.columns:
|
|
330
|
+
out[label] = -1
|
|
331
|
+
continue
|
|
332
|
+
values = pd.to_numeric(out[column], errors="coerce")
|
|
333
|
+
if values.notna().sum() < _DECOY_STRATA_BINS:
|
|
334
|
+
out[label] = -1
|
|
335
|
+
continue
|
|
336
|
+
binned = pd.qcut(
|
|
337
|
+
values.rank(method="first"), _DECOY_STRATA_BINS, labels=False, duplicates="drop"
|
|
338
|
+
)
|
|
339
|
+
out[label] = binned.fillna(-1).astype(int)
|
|
340
|
+
return out
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _decoy_null_by_gene(
|
|
344
|
+
ordered: Any,
|
|
345
|
+
*,
|
|
346
|
+
gene_ids: set[str],
|
|
347
|
+
n_decoys: int,
|
|
348
|
+
seed: int,
|
|
349
|
+
) -> dict[str, dict[str, Any]]:
|
|
350
|
+
"""Decoy-null p-value for each named gene, against its own stratum.
|
|
351
|
+
|
|
352
|
+
**This is the null the module calls primary, and until now it had never
|
|
353
|
+
run.** ``decoy_null_p`` and ``match_decoys`` had no non-test call site, and
|
|
354
|
+
``StudyConfig.n_decoys`` was documented as configuring them and read by
|
|
355
|
+
nothing.
|
|
356
|
+
|
|
357
|
+
Scoped to the **counterfactual rank**, not the shortlist rank. That matters:
|
|
358
|
+
a decoy null over the shortlist would require every decoy to face the same
|
|
359
|
+
gates as the antigen, and Open Targets is only queried for the top few
|
|
360
|
+
hundred genes per cohort, so gate parity would need a bulk warm-up over the
|
|
361
|
+
whole surfaceome. The counterfactual rank is a pure function of the
|
|
362
|
+
differential-expression table and the surfaceome reference — there are no
|
|
363
|
+
gates to match — so the comparison is exact and costs nothing.
|
|
364
|
+
|
|
365
|
+
Exact, too, in the literal sense. The counterfactual rank is deterministic,
|
|
366
|
+
so the honest p-value is the true within-stratum tail: of the genes matched
|
|
367
|
+
to this one on abundance and dispersion, what fraction ranked at least as
|
|
368
|
+
well. Drawing a thousand samples from a stratum of fifty would only add Monte
|
|
369
|
+
Carlo noise to a quantity that can be computed. ``n_decoys`` is therefore a
|
|
370
|
+
cap: a stratum at or below it is used whole and the p-value is exact, and
|
|
371
|
+
only a larger one is subsampled.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
ordered: the eligible ranking from :func:`outcomes.eligible_ranking`.
|
|
375
|
+
gene_ids: the genes to compute a p-value for.
|
|
376
|
+
n_decoys: cap on decoys drawn from one stratum.
|
|
377
|
+
seed: fixed so a published p-value is reproducible.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
gene id -> the p-value, the stratum it was taken in, how many decoys
|
|
381
|
+
were available and used, and whether the tail was exact.
|
|
382
|
+
"""
|
|
383
|
+
out: dict[str, dict[str, Any]] = {}
|
|
384
|
+
if ordered.empty or not gene_ids:
|
|
385
|
+
return out
|
|
386
|
+
|
|
387
|
+
labelled = _decoy_strata(ordered)
|
|
388
|
+
wanted = labelled[labelled["gene_id"].astype(str).isin(gene_ids)]
|
|
389
|
+
if wanted.empty:
|
|
390
|
+
return out
|
|
391
|
+
|
|
392
|
+
grouped = {
|
|
393
|
+
key: frame for key, frame in labelled.groupby(["base_mean_stratum", "dispersion_stratum"])
|
|
394
|
+
}
|
|
395
|
+
for row in wanted.itertuples(index=False):
|
|
396
|
+
gene = str(row.gene_id)
|
|
397
|
+
stratum = grouped.get((row.base_mean_stratum, row.dispersion_stratum))
|
|
398
|
+
if stratum is None:
|
|
399
|
+
continue
|
|
400
|
+
pool = stratum[stratum["gene_id"].astype(str) != gene]
|
|
401
|
+
available = len(pool)
|
|
402
|
+
if available == 0:
|
|
403
|
+
# A stratum of one says nothing: there was nothing to compare against.
|
|
404
|
+
out[gene] = {
|
|
405
|
+
"p_decoy": None,
|
|
406
|
+
"p_decoy_floor": None,
|
|
407
|
+
"decoy_pool_size": 0,
|
|
408
|
+
"decoy_n_used": 0,
|
|
409
|
+
"decoy_exact": False,
|
|
410
|
+
"base_mean_stratum": int(row.base_mean_stratum),
|
|
411
|
+
"dispersion_stratum": int(row.dispersion_stratum),
|
|
412
|
+
}
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
exact = available <= n_decoys
|
|
416
|
+
if exact:
|
|
417
|
+
ranks = [int(r) for r in pool["counterfactual_rank"]]
|
|
418
|
+
else:
|
|
419
|
+
# Too many to enumerate: fall back to the matched sampler, which
|
|
420
|
+
# draws without replacement while the stratum can supply it.
|
|
421
|
+
records = [
|
|
422
|
+
{
|
|
423
|
+
"uniprot": str(g),
|
|
424
|
+
"base_mean_stratum": int(row.base_mean_stratum),
|
|
425
|
+
"dispersion_stratum": int(row.dispersion_stratum),
|
|
426
|
+
"counterfactual_rank": int(r),
|
|
427
|
+
}
|
|
428
|
+
for g, r in zip(pool["gene_id"], pool["counterfactual_rank"], strict=True)
|
|
429
|
+
]
|
|
430
|
+
target = {
|
|
431
|
+
"uniprot": gene,
|
|
432
|
+
"base_mean_stratum": int(row.base_mean_stratum),
|
|
433
|
+
"dispersion_stratum": int(row.dispersion_stratum),
|
|
434
|
+
}
|
|
435
|
+
drawn = S.match_decoys(records, target, n_decoys=n_decoys, seed=seed)
|
|
436
|
+
ranks = [int(d["counterfactual_rank"]) for d in drawn]
|
|
437
|
+
|
|
438
|
+
out[gene] = {
|
|
439
|
+
"p_decoy": S.decoy_null_p(int(row.counterfactual_rank), ranks),
|
|
440
|
+
# The smallest p this stratum could ever have produced. Without it a
|
|
441
|
+
# reader cannot tell a genuine null result from a stratum too thin to
|
|
442
|
+
# resolve one.
|
|
443
|
+
"p_decoy_floor": 1 / (1 + len(ranks)),
|
|
444
|
+
"decoy_pool_size": available,
|
|
445
|
+
"decoy_n_used": len(ranks),
|
|
446
|
+
"decoy_exact": exact,
|
|
447
|
+
"base_mean_stratum": int(row.base_mean_stratum),
|
|
448
|
+
"dispersion_stratum": int(row.dispersion_stratum),
|
|
449
|
+
}
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
#: What a stage status says when the run cannot be asked.
|
|
454
|
+
#:
|
|
455
|
+
#: Deliberately not "completed". A cohort scored from a run whose manifest is
|
|
456
|
+
#: missing has an unknown provenance, and the one thing that must not be written
|
|
457
|
+
#: is the reassuring answer.
|
|
458
|
+
UNRECORDED_STAGE = "unrecorded"
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def stage_status_from_manifest(run_dir: Path) -> dict[str, str]:
|
|
462
|
+
"""The stages a run actually recorded, read rather than assumed.
|
|
463
|
+
|
|
464
|
+
``CohortResult.stage_status`` was the literal
|
|
465
|
+
``{"deg": "completed", "discover": "completed"}``. A cohort whose discover
|
|
466
|
+
stage crashed still has its DEG table, so it was scored — with an empty
|
|
467
|
+
shortlist, every panel antigen counted as gated out, and both stages
|
|
468
|
+
asserted complete. That turns an infrastructure failure into a biological
|
|
469
|
+
negative and folds it into the study's headline recall.
|
|
470
|
+
|
|
471
|
+
The principle is already stated one function down, for the DEG table: *a
|
|
472
|
+
cohort that did not run is not a cohort that found nothing*. It simply was
|
|
473
|
+
not applied to the stage that builds the shortlist.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
run_dir: the cohort's run directory.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
``{stage name: status}`` from ``run_manifest.jsonld``. A stage the
|
|
480
|
+
manifest does not mention, and every stage when the manifest is absent
|
|
481
|
+
or unreadable, maps to :data:`UNRECORDED_STAGE`.
|
|
482
|
+
"""
|
|
483
|
+
import json
|
|
484
|
+
|
|
485
|
+
wanted = ("deg", "discover")
|
|
486
|
+
manifest = Path(run_dir) / "run_manifest.jsonld"
|
|
487
|
+
status = dict.fromkeys(wanted, UNRECORDED_STAGE)
|
|
488
|
+
try:
|
|
489
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
490
|
+
except (OSError, json.JSONDecodeError):
|
|
491
|
+
return status
|
|
492
|
+
for record in data.get("stages") or []:
|
|
493
|
+
name = record.get("name")
|
|
494
|
+
if name in status and record.get("status"):
|
|
495
|
+
# Last write wins: a stage re-run supersedes its earlier attempt.
|
|
496
|
+
status[name] = str(record["status"])
|
|
497
|
+
return status
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def score_cohort(
|
|
501
|
+
project: str,
|
|
502
|
+
run_dir: Path,
|
|
503
|
+
*,
|
|
504
|
+
surfaceome: frozenset[str],
|
|
505
|
+
entries: list[P.AntigenCohort] | None = None,
|
|
506
|
+
n_decoys: int = StudyConfig.n_decoys,
|
|
507
|
+
seed: int = StudyConfig.seed,
|
|
508
|
+
) -> CohortResult:
|
|
509
|
+
"""Score every panel antigen for one cohort. Pure: reads tables, writes nothing.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
project: the project scored.
|
|
513
|
+
run_dir: a completed discovery run directory.
|
|
514
|
+
surfaceome: the accessions the pipeline filters on, used to decide
|
|
515
|
+
reachability *before* consulting any pipeline output.
|
|
516
|
+
entries: panel entries for this project; defaults to all of them.
|
|
517
|
+
n_decoys: cap on decoys drawn from one abundance/dispersion stratum.
|
|
518
|
+
A stratum at or below it is used whole, making the tail exact.
|
|
519
|
+
seed: fixed so a published decoy p-value is reproducible.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
The cohort's contribution to the study.
|
|
523
|
+
|
|
524
|
+
Raises:
|
|
525
|
+
FileNotFoundError: If the run has no differential-expression table, which
|
|
526
|
+
means the cohort never ran and must not be scored as a miss.
|
|
527
|
+
"""
|
|
528
|
+
import pandas as pd
|
|
529
|
+
|
|
530
|
+
deg_path = run_dir / "deg" / "results.parquet"
|
|
531
|
+
if not deg_path.exists():
|
|
532
|
+
raise FileNotFoundError(
|
|
533
|
+
f"{project}: no differential-expression table at {deg_path}. A cohort that "
|
|
534
|
+
"did not run is not a cohort that found nothing."
|
|
535
|
+
)
|
|
536
|
+
deg = pd.read_parquet(deg_path)
|
|
537
|
+
|
|
538
|
+
# Read, not assumed. A discover stage that crashed leaves the DEG table
|
|
539
|
+
# intact, so the guard above passes and the cohort is scored with an empty
|
|
540
|
+
# shortlist — every panel antigen gated out, counted in the denominator,
|
|
541
|
+
# and folded into the study's recall as though discovery had legitimately
|
|
542
|
+
# found nothing.
|
|
543
|
+
stage_status = stage_status_from_manifest(run_dir)
|
|
544
|
+
if stage_status.get("discover") == "failed":
|
|
545
|
+
raise RuntimeError(
|
|
546
|
+
f"{project}: the discover stage failed, so this cohort has no shortlist "
|
|
547
|
+
"to score. A cohort whose discovery crashed is not a cohort that found "
|
|
548
|
+
"nothing; scoring it would count every panel antigen as a miss."
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
candidates_path = run_dir / "targets" / "candidates.parquet"
|
|
552
|
+
candidates = (
|
|
553
|
+
pd.read_parquet(candidates_path)
|
|
554
|
+
if candidates_path.exists() and candidates_path.stat().st_size > 0
|
|
555
|
+
else pd.DataFrame()
|
|
556
|
+
)
|
|
557
|
+
taxonomy_path = run_dir / "taxonomy" / "failure_taxonomy.parquet"
|
|
558
|
+
taxonomy = (
|
|
559
|
+
pd.read_parquet(taxonomy_path)
|
|
560
|
+
if taxonomy_path.exists() and taxonomy_path.stat().st_size > 0
|
|
561
|
+
else pd.DataFrame()
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# The eligible surfaceome: every TESTED gene whose accession is in the
|
|
565
|
+
# reference. This is the set a counterfactual rank is taken within, and its
|
|
566
|
+
# size is what makes that rank mean anything.
|
|
567
|
+
#
|
|
568
|
+
# It must come from the vendored gene map, not from the run's own candidates
|
|
569
|
+
# table. The candidates table holds only the genes that survived the
|
|
570
|
+
# enrichment cut, so ranking within it would be ranking within the pipeline's
|
|
571
|
+
# own output — and the counterfactual rank exists precisely to say where an
|
|
572
|
+
# antigen would have landed had that cut not been applied.
|
|
573
|
+
# And it must come from the *same* reference the pipeline filtered on.
|
|
574
|
+
#
|
|
575
|
+
# This read ``load_surfy_gene_map()`` — the SURFY core, 3,366 gene ids —
|
|
576
|
+
# while ``run_study`` passes the extended accession set (4,801) that
|
|
577
|
+
# discovery actually used. Every SURFY accession is already in the extended
|
|
578
|
+
# set, so the filter below removed nothing and 2,108 genes the pipeline can
|
|
579
|
+
# surface were simply absent from the denominator. Counterfactual ranks were
|
|
580
|
+
# then taken among fewer competitors than the run really had, which flatters
|
|
581
|
+
# them, and the decoy null drew its support from the same short set.
|
|
582
|
+
#
|
|
583
|
+
# ``run_study._surfaceome`` states this exact requirement in its docstring —
|
|
584
|
+
# "this must match what discovery actually used" — and it was applied to the
|
|
585
|
+
# accession set and not to the gene map keyed against it. Deriving the map
|
|
586
|
+
# from the accessions that were passed in makes the two agree by
|
|
587
|
+
# construction rather than by remembering to pass the same flag twice.
|
|
588
|
+
from bindsight.surfaceome.surfy import load_surfaceome_gene_map
|
|
589
|
+
|
|
590
|
+
gene_to_uniprot = {
|
|
591
|
+
gene: accession
|
|
592
|
+
for gene, accession in load_surfaceome_gene_map(extended=True).items()
|
|
593
|
+
if accession in surfaceome
|
|
594
|
+
}
|
|
595
|
+
entries = entries if entries is not None else [c for c in P.PANEL if c.project == project]
|
|
596
|
+
# Panel antigens must be resolvable even if the vendored map missed them.
|
|
597
|
+
for entry in entries:
|
|
598
|
+
if entry.uniprot in surfaceome:
|
|
599
|
+
gene_to_uniprot.setdefault(entry.ensembl, entry.uniprot)
|
|
600
|
+
tested_genes = set(deg["gene_id"].astype(str))
|
|
601
|
+
eligible_gene_ids = tested_genes & set(gene_to_uniprot)
|
|
602
|
+
|
|
603
|
+
shortlist_size = len(candidates) if not candidates.empty else 0
|
|
604
|
+
rank_by_gene: dict[str, int] = {}
|
|
605
|
+
if not candidates.empty and {"gene_id", "rank"} <= set(candidates.columns):
|
|
606
|
+
rank_by_gene = {
|
|
607
|
+
str(g): int(r) for g, r in zip(candidates["gene_id"], candidates["rank"], strict=False)
|
|
608
|
+
}
|
|
609
|
+
disposition_by_gene: dict[str, str] = {}
|
|
610
|
+
if not taxonomy.empty and {"gene_id", "disposition"} <= set(taxonomy.columns):
|
|
611
|
+
disposition_by_gene = {
|
|
612
|
+
str(g): str(d)
|
|
613
|
+
for g, d in zip(taxonomy["gene_id"], taxonomy["disposition"], strict=False)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
# One ranking for the whole cohort, shared by every antigen and by the decoy
|
|
617
|
+
# null. Building it per gene would re-sort a 4,800-row frame 4,800 times to
|
|
618
|
+
# produce an ordering that never changes.
|
|
619
|
+
ordered = O.eligible_ranking(deg, eligible_gene_ids=eligible_gene_ids)
|
|
620
|
+
decoy_by_gene = _decoy_null_by_gene(
|
|
621
|
+
ordered,
|
|
622
|
+
gene_ids={str(entry.ensembl) for entry in entries},
|
|
623
|
+
n_decoys=n_decoys,
|
|
624
|
+
seed=seed,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Every panel antigen's standing here, not only this cohort's own. The
|
|
628
|
+
# ranking already exists, so this is a lookup, and it is what lets the
|
|
629
|
+
# panel-level null ask whether antigens land in the *right* cancers.
|
|
630
|
+
rank_of_gene = (
|
|
631
|
+
{
|
|
632
|
+
str(g): int(r)
|
|
633
|
+
for g, r in zip(ordered["gene_id"], ordered["counterfactual_rank"], strict=True)
|
|
634
|
+
}
|
|
635
|
+
if not ordered.empty
|
|
636
|
+
else {}
|
|
637
|
+
)
|
|
638
|
+
n_ranked = len(rank_of_gene)
|
|
639
|
+
antigen_scores = {
|
|
640
|
+
antigen.symbol: 1.0 - (rank_of_gene[str(antigen.ensembl)] - 1) / n_ranked
|
|
641
|
+
for antigen in P.PANEL
|
|
642
|
+
if n_ranked and str(antigen.ensembl) in rank_of_gene
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
pairs: list[dict[str, Any]] = []
|
|
646
|
+
for entry in entries:
|
|
647
|
+
reach = P.reachability(entry.uniprot, surfaceome)
|
|
648
|
+
rank = rank_by_gene.get(entry.ensembl)
|
|
649
|
+
outcome = O.classify(
|
|
650
|
+
reachability=reach,
|
|
651
|
+
disposition=disposition_by_gene.get(entry.ensembl),
|
|
652
|
+
rank=rank,
|
|
653
|
+
shortlist_size=shortlist_size or None,
|
|
654
|
+
)
|
|
655
|
+
counterfactual = O.counterfactual_rank(
|
|
656
|
+
deg, target_gene_id=entry.ensembl, eligible_gene_ids=eligible_gene_ids
|
|
657
|
+
)
|
|
658
|
+
row: dict[str, Any] = {
|
|
659
|
+
"project": entry.project,
|
|
660
|
+
"symbol": entry.symbol,
|
|
661
|
+
"uniprot": entry.uniprot,
|
|
662
|
+
"ensembl": entry.ensembl,
|
|
663
|
+
"agent": entry.agent,
|
|
664
|
+
"tier": entry.tier,
|
|
665
|
+
"usable": entry.usable,
|
|
666
|
+
"note": entry.note,
|
|
667
|
+
"reachability": reach,
|
|
668
|
+
**outcome.as_dict(),
|
|
669
|
+
}
|
|
670
|
+
row.update(counterfactual.as_dict() if counterfactual else {"counterfactual_rank": None})
|
|
671
|
+
row.update(decoy_by_gene.get(str(entry.ensembl), {"p_decoy": None}))
|
|
672
|
+
row.update(_deg_row(deg, entry.ensembl))
|
|
673
|
+
pairs.append(row)
|
|
674
|
+
|
|
675
|
+
return CohortResult(
|
|
676
|
+
project=project,
|
|
677
|
+
set_sizes={
|
|
678
|
+
"n_genes_tested": len(deg),
|
|
679
|
+
"n_eligible_surfaceome": len(eligible_gene_ids),
|
|
680
|
+
"n_candidates": shortlist_size,
|
|
681
|
+
"n_significant": int(deg["significant"].sum()) if "significant" in deg.columns else 0,
|
|
682
|
+
},
|
|
683
|
+
pairs=pairs,
|
|
684
|
+
stage_status=stage_status,
|
|
685
|
+
antigen_scores=antigen_scores,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _deg_row(deg: Any, ensembl: str) -> dict[str, Any]:
|
|
690
|
+
"""The antigen's own differential-expression statistics, or explicit nulls."""
|
|
691
|
+
import pandas as pd
|
|
692
|
+
|
|
693
|
+
match = deg[deg["gene_id"].astype(str) == ensembl]
|
|
694
|
+
if match.empty:
|
|
695
|
+
# Not tested is a different statement from not significant, and the
|
|
696
|
+
# results table has to be able to say which.
|
|
697
|
+
return {"tested": False, "log2fc": None, "padj": None, "significant": None}
|
|
698
|
+
row = match.iloc[0]
|
|
699
|
+
|
|
700
|
+
def _num(column: str) -> float | None:
|
|
701
|
+
if column not in match.columns or pd.isna(row[column]):
|
|
702
|
+
return None
|
|
703
|
+
return float(row[column])
|
|
704
|
+
|
|
705
|
+
return {
|
|
706
|
+
"tested": True,
|
|
707
|
+
"log2fc": _num("log2fc"),
|
|
708
|
+
"padj": _num("padj"),
|
|
709
|
+
"base_mean": _num("baseMean"),
|
|
710
|
+
"lfc_se": _num("lfc_se"),
|
|
711
|
+
"significant": bool(row["significant"]) if "significant" in match.columns else None,
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def summarise(results: list[CohortResult], config: StudyConfig) -> dict[str, Any]:
|
|
716
|
+
"""Aggregate scored cohorts into the reportable study summary.
|
|
717
|
+
|
|
718
|
+
Three denominators are published as a nested cascade, each with its own
|
|
719
|
+
numerator and count, so a reader can reconstruct the conservative headline
|
|
720
|
+
from the permissive one:
|
|
721
|
+
|
|
722
|
+
- **all** — every scored pair *in the primary regulatory tier*
|
|
723
|
+
(``config.tiers``), not every scored pair in the panel. The headline, and
|
|
724
|
+
the most conservative of the three. The unrestricted figure is published
|
|
725
|
+
separately as the tier sensitivity analysis, and is a different number:
|
|
726
|
+
1/17 here against 3/22 across every tier.
|
|
727
|
+
- **reachable** — pairs the instrument could have surfaced at all.
|
|
728
|
+
- **gate_passed** — pairs that entered the shortlist. This, and only this, is
|
|
729
|
+
a measure of the ranker.
|
|
730
|
+
|
|
731
|
+
The previous study chose its denominator after seeing the data, restricting
|
|
732
|
+
to antigens measured as over-expressed, which mechanically inflated the rate.
|
|
733
|
+
These are fixed in advance and never merged.
|
|
734
|
+
"""
|
|
735
|
+
pairs = [p for r in results for p in r.pairs if p.get("usable") == "scored"]
|
|
736
|
+
in_tiers = [p for p in pairs if p.get("tier") in config.tiers]
|
|
737
|
+
infrastructure = [p for p in in_tiers if p["outcome_class"] == O.INFRASTRUCTURE]
|
|
738
|
+
|
|
739
|
+
cascade: dict[str, Any] = {}
|
|
740
|
+
for name, subset in (
|
|
741
|
+
("all", in_tiers),
|
|
742
|
+
("reachable", [p for p in in_tiers if p["outcome_class"] != O.NOT_REACHABLE]),
|
|
743
|
+
("gate_passed", [p for p in in_tiers if p["outcome_class"] == O.RANKED]),
|
|
744
|
+
):
|
|
745
|
+
eligible = [p for p in subset if p["outcome_class"] != O.INFRASTRUCTURE]
|
|
746
|
+
if not eligible:
|
|
747
|
+
cascade[name] = {"numerator": 0, "denominator": 0, "note": "no eligible pairs"}
|
|
748
|
+
continue
|
|
749
|
+
at_k: dict[str, Any] = {}
|
|
750
|
+
for k in config.recall_at_k:
|
|
751
|
+
hits = sum(1 for p in eligible if _is_hit(p, k=k))
|
|
752
|
+
at_k[f"recall@{k}"] = {
|
|
753
|
+
"wilson": S.wilson_interval(hits, len(eligible)).as_dict(),
|
|
754
|
+
"clopper_pearson": S.clopper_pearson_interval(hits, len(eligible)).as_dict(),
|
|
755
|
+
}
|
|
756
|
+
# The headline cutoff, kept so a single number can still be quoted — but
|
|
757
|
+
# only ever beside the shortlist sizes it was computed against.
|
|
758
|
+
headline_k = 20 if 20 in config.recall_at_k else config.recall_at_k[-1]
|
|
759
|
+
headline = at_k[f"recall@{headline_k}"]
|
|
760
|
+
cascade[name] = {
|
|
761
|
+
"at_k": at_k,
|
|
762
|
+
"headline_k": headline_k,
|
|
763
|
+
"wilson": headline["wilson"],
|
|
764
|
+
"clopper_pearson": headline["clopper_pearson"],
|
|
765
|
+
"removed_from_previous_step": len(subset) - len(eligible),
|
|
766
|
+
"median_shortlist_size": _median(
|
|
767
|
+
[p["shortlist_size"] for p in eligible if p.get("shortlist_size")]
|
|
768
|
+
),
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
# The de-duplicated panel is what the interval is honestly computed over,
|
|
772
|
+
# because one antigen in four cohorts is one piece of evidence.
|
|
773
|
+
dedup_keys = {c.key for c in P.deduplicated_panel(tiers=config.tiers)}
|
|
774
|
+
dedup = [p for p in in_tiers if _pair_key(p) in dedup_keys]
|
|
775
|
+
dedup_eligible = [p for p in dedup if p["outcome_class"] != O.INFRASTRUCTURE]
|
|
776
|
+
|
|
777
|
+
clusters: dict[str, Sequence[bool]] = {}
|
|
778
|
+
headline_k = 20 if 20 in config.recall_at_k else config.recall_at_k[-1]
|
|
779
|
+
for p in in_tiers:
|
|
780
|
+
if p["outcome_class"] != O.INFRASTRUCTURE:
|
|
781
|
+
cast("list[bool]", clusters.setdefault(p["uniprot"], [])).append(
|
|
782
|
+
_is_hit(p, k=headline_k)
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
summary: dict[str, Any] = {
|
|
786
|
+
"schema": "bindsight-rediscovery/3",
|
|
787
|
+
"design": {
|
|
788
|
+
# Not "all primary tumour": prepare_cohort calls matched_pair_cases,
|
|
789
|
+
# so a tumour with no matched normal never enters the contrast.
|
|
790
|
+
"cohorts": (
|
|
791
|
+
"every patient in the TCGA project who contributed both a primary "
|
|
792
|
+
"tumour and a solid-tissue normal; no biomarker stratification"
|
|
793
|
+
),
|
|
794
|
+
"contrast": "~ case_barcode + condition (paired on patient)",
|
|
795
|
+
"admissible_stratifier_rule": P.ADMISSIBLE_STRATIFIER_RULE,
|
|
796
|
+
"unreachable_note": P.UNREACHABLE_NOTE,
|
|
797
|
+
"tiers_in_primary_denominator": list(config.tiers),
|
|
798
|
+
"seed": config.seed,
|
|
799
|
+
},
|
|
800
|
+
"set_sizes_by_cohort": {r.project: r.set_sizes for r in results},
|
|
801
|
+
"recall_cascade": cascade,
|
|
802
|
+
"infrastructure_failures": {
|
|
803
|
+
"n": len(infrastructure),
|
|
804
|
+
"pairs": [f"{p['project']}:{p['symbol']}" for p in infrastructure],
|
|
805
|
+
"note": (
|
|
806
|
+
"A lookup that errored or never ran is not a negative result. This "
|
|
807
|
+
"count must be zero in anything published; any pair here invalidates "
|
|
808
|
+
"itself and must be re-run."
|
|
809
|
+
),
|
|
810
|
+
},
|
|
811
|
+
"outcome_class_counts": _class_counts(in_tiers),
|
|
812
|
+
"pairs": pairs,
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if dedup_eligible:
|
|
816
|
+
headline_k = 20 if 20 in config.recall_at_k else config.recall_at_k[-1]
|
|
817
|
+
hits = sum(1 for p in dedup_eligible if _is_hit(p, k=headline_k))
|
|
818
|
+
summary["primary_interval"] = {
|
|
819
|
+
"description": (
|
|
820
|
+
"One cohort per antigen, so the trials are independent. This is the "
|
|
821
|
+
"interval to quote; the cascade above is computed over correlated pairs."
|
|
822
|
+
),
|
|
823
|
+
"wilson": S.wilson_interval(hits, len(dedup_eligible)).as_dict(),
|
|
824
|
+
"clopper_pearson": S.clopper_pearson_interval(hits, len(dedup_eligible)).as_dict(),
|
|
825
|
+
"n_distinct_antigens": len(dedup_eligible),
|
|
826
|
+
}
|
|
827
|
+
if len(clusters) >= 2:
|
|
828
|
+
summary["cluster_bootstrap"] = S.cluster_bootstrap_interval(
|
|
829
|
+
clusters, seed=config.seed
|
|
830
|
+
).as_dict()
|
|
831
|
+
|
|
832
|
+
# Sensitivity to the regulatory tier. The primary denominator is
|
|
833
|
+
# pre-registered on approved agents alone, but restricting to them excludes
|
|
834
|
+
# antigens whose evidence is strong and whose agent is merely not yet
|
|
835
|
+
# licensed — CA9 among them, which ranks first in its cohort. Reporting the
|
|
836
|
+
# wider panel separately shows what the pre-registration costs, without
|
|
837
|
+
# letting a post-hoc denominator inflate the headline.
|
|
838
|
+
all_tiers: tuple[P.Tier, ...] = ("approved", "late_clinical", "clinical_stage")
|
|
839
|
+
wider = [p for p in pairs if p.get("tier") in all_tiers]
|
|
840
|
+
wider_eligible = [p for p in wider if p["outcome_class"] != O.INFRASTRUCTURE]
|
|
841
|
+
if wider_eligible:
|
|
842
|
+
headline_k = 20 if 20 in config.recall_at_k else config.recall_at_k[-1]
|
|
843
|
+
summary["tier_sensitivity"] = {
|
|
844
|
+
"description": (
|
|
845
|
+
"Every scored pair regardless of regulatory tier, reported as a "
|
|
846
|
+
"sensitivity analysis. The primary denominator remains "
|
|
847
|
+
f"{list(config.tiers)}."
|
|
848
|
+
),
|
|
849
|
+
"at_k": {
|
|
850
|
+
f"recall@{k}": S.wilson_interval(
|
|
851
|
+
sum(1 for p in wider_eligible if _is_hit(p, k=k)), len(wider_eligible)
|
|
852
|
+
).as_dict()
|
|
853
|
+
for k in config.recall_at_k
|
|
854
|
+
},
|
|
855
|
+
"headline": S.wilson_interval(
|
|
856
|
+
sum(1 for p in wider_eligible if _is_hit(p, k=headline_k)),
|
|
857
|
+
len(wider_eligible),
|
|
858
|
+
).as_dict(),
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
for p in summary["pairs"]:
|
|
862
|
+
cf, n_up = p.get("counterfactual_rank"), p.get("n_up_regulated")
|
|
863
|
+
if isinstance(cf, int) and isinstance(n_up, int) and n_up > 0 and cf <= n_up:
|
|
864
|
+
p["p_uniform_rank"] = S.uniform_rank_p(cf, n_up)
|
|
865
|
+
else:
|
|
866
|
+
p["p_uniform_rank"] = None
|
|
867
|
+
|
|
868
|
+
_adjust_decoy_p(summary["pairs"])
|
|
869
|
+
specificity = _specificity_null(results, config)
|
|
870
|
+
if specificity is not None:
|
|
871
|
+
summary["specificity_null"] = specificity
|
|
872
|
+
calibration = _null_calibration(results, config)
|
|
873
|
+
if calibration is not None:
|
|
874
|
+
summary["null_calibration"] = calibration
|
|
875
|
+
return summary
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _cognate_projects() -> dict[str, set[str]]:
|
|
879
|
+
"""Antigen symbol -> the projects the panel names as its indication."""
|
|
880
|
+
cognate: dict[str, set[str]] = {}
|
|
881
|
+
for entry in P.PANEL:
|
|
882
|
+
cognate.setdefault(entry.symbol, set()).add(entry.project)
|
|
883
|
+
return cognate
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _null_calibration(results: list[CohortResult], config: StudyConfig) -> dict[str, Any] | None:
|
|
887
|
+
"""What the pipeline surfaces where no panel antigen is expected.
|
|
888
|
+
|
|
889
|
+
``NULL_CALIBRATION_PROJECTS`` has been declared since the panel was written —
|
|
890
|
+
"run with no antigen attached, purely to calibrate the null models and audit
|
|
891
|
+
shortlist composition" — and both cohorts were downloaded, run and scored
|
|
892
|
+
without contributing to a single reported number.
|
|
893
|
+
|
|
894
|
+
They contribute two things now. As cohorts they enter the permutation null
|
|
895
|
+
as assignments an antigen could receive but should not fit, which is what
|
|
896
|
+
makes that test a test. And they answer directly the question the recall
|
|
897
|
+
number cannot: where does a panel antigen land in a cancer it has nothing to
|
|
898
|
+
do with? If the standing in an antigen's own indication is not clearly
|
|
899
|
+
better than its standing here, the pipeline is ranking generic biology.
|
|
900
|
+
|
|
901
|
+
Args:
|
|
902
|
+
results: every scored cohort, calibration cohorts included.
|
|
903
|
+
config: the study configuration; its ``seed`` fixes the bootstraps so a
|
|
904
|
+
published interval is reproducible.
|
|
905
|
+
|
|
906
|
+
Args:
|
|
907
|
+
results: every scored cohort.
|
|
908
|
+
|
|
909
|
+
Returns:
|
|
910
|
+
The comparison and the cohorts it was computed over, or None when
|
|
911
|
+
neither calibration cohort has been run.
|
|
912
|
+
"""
|
|
913
|
+
calibration = [r for r in results if r.project in P.NULL_CALIBRATION_PROJECTS]
|
|
914
|
+
if not calibration:
|
|
915
|
+
return None
|
|
916
|
+
|
|
917
|
+
cognate = _cognate_projects()
|
|
918
|
+
# Kept by antigen, not flattened. One antigen contributes a standing in each
|
|
919
|
+
# calibration cohort, so the standings are not independent draws and an
|
|
920
|
+
# interval over the flat list would be too narrow. The antigen is the unit.
|
|
921
|
+
off_by_antigen: dict[str, list[float]] = {}
|
|
922
|
+
for r in calibration:
|
|
923
|
+
for symbol, score in r.antigen_scores.items():
|
|
924
|
+
off_by_antigen.setdefault(symbol, []).append(score)
|
|
925
|
+
own_by_antigen: dict[str, list[float]] = {}
|
|
926
|
+
for r in results:
|
|
927
|
+
for symbol, score in r.antigen_scores.items():
|
|
928
|
+
if r.project in cognate.get(symbol, set()):
|
|
929
|
+
own_by_antigen.setdefault(symbol, []).append(score)
|
|
930
|
+
|
|
931
|
+
off_indication = [score for scores in off_by_antigen.values() for score in scores]
|
|
932
|
+
own_indication = [score for scores in own_by_antigen.values() for score in scores]
|
|
933
|
+
if not off_indication:
|
|
934
|
+
return None
|
|
935
|
+
|
|
936
|
+
block: dict[str, Any] = {
|
|
937
|
+
"description": (
|
|
938
|
+
"Cohorts carrying no panel antigen, run to calibrate the nulls and to "
|
|
939
|
+
"show where panel antigens land in a cancer that is not theirs. A "
|
|
940
|
+
"standing near the off-indication mean is what 'no signal' looks "
|
|
941
|
+
"like on this scale."
|
|
942
|
+
),
|
|
943
|
+
"projects": [r.project for r in calibration],
|
|
944
|
+
"shortlist_sizes": {r.project: r.set_sizes.get("n_candidates") for r in calibration},
|
|
945
|
+
# Must be zero. A scored pair here would mean an expectation was invented
|
|
946
|
+
# for a cohort chosen precisely because it carries none.
|
|
947
|
+
"n_scored_pairs": sum(len(r.pairs) for r in calibration),
|
|
948
|
+
"n_antigen_standings": len(off_indication),
|
|
949
|
+
# The flat mean over every standing, kept because it is what a reader
|
|
950
|
+
# recomputing by hand from the pair table will get. It is NOT the
|
|
951
|
+
# estimator the interval below describes: this one counts an antigen
|
|
952
|
+
# once per cohort, the interval counts it once. Report the interval's
|
|
953
|
+
# own point beside the interval; these two must never be mixed.
|
|
954
|
+
"mean_standing_off_indication": sum(off_indication) / len(off_indication),
|
|
955
|
+
"mean_standing_off_indication_is_unclustered": True,
|
|
956
|
+
# The same mean with the spread and interval behind it. Two bare means
|
|
957
|
+
# were being contrasted in the report with no n and no uncertainty, which
|
|
958
|
+
# is exactly the shape of claim this study exists to avoid making.
|
|
959
|
+
"off_indication_interval": S.cluster_bootstrap_mean(
|
|
960
|
+
off_by_antigen, seed=config.seed
|
|
961
|
+
).as_dict(),
|
|
962
|
+
}
|
|
963
|
+
if own_indication:
|
|
964
|
+
# Unclustered, as above.
|
|
965
|
+
block["mean_standing_in_own_indication"] = sum(own_indication) / len(own_indication)
|
|
966
|
+
block["mean_standing_in_own_indication_is_unclustered"] = True
|
|
967
|
+
block["n_own_indication"] = len(own_indication)
|
|
968
|
+
block["own_indication_interval"] = S.cluster_bootstrap_mean(
|
|
969
|
+
own_by_antigen, seed=config.seed
|
|
970
|
+
).as_dict()
|
|
971
|
+
|
|
972
|
+
# The paired contrast, which is the comparison the sentence is making.
|
|
973
|
+
# Pairing within an antigen removes the between-antigen variation that
|
|
974
|
+
# both means share, and it is the only form of this comparison in which
|
|
975
|
+
# the two groups are not partly the same antigens measured twice.
|
|
976
|
+
paired = {
|
|
977
|
+
symbol: [
|
|
978
|
+
sum(own_by_antigen[symbol]) / len(own_by_antigen[symbol])
|
|
979
|
+
- sum(off_by_antigen[symbol]) / len(off_by_antigen[symbol])
|
|
980
|
+
]
|
|
981
|
+
for symbol in sorted(set(own_by_antigen) & set(off_by_antigen))
|
|
982
|
+
}
|
|
983
|
+
if paired:
|
|
984
|
+
block["paired_difference"] = {
|
|
985
|
+
"description": (
|
|
986
|
+
"Per antigen, its mean standing in its own indication minus its "
|
|
987
|
+
"mean standing in the calibration cohorts. Bootstrapped over "
|
|
988
|
+
"antigens, which are the independent unit."
|
|
989
|
+
),
|
|
990
|
+
**S.cluster_bootstrap_mean(paired, seed=config.seed).as_dict(),
|
|
991
|
+
}
|
|
992
|
+
return block
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _adjust_decoy_p(pairs: list[dict[str, Any]]) -> None:
|
|
996
|
+
"""Benjamini-Hochberg across the panel's decoy p-values, in place.
|
|
997
|
+
|
|
998
|
+
The panel puts roughly twenty hypotheses forward at once, and one nominally
|
|
999
|
+
significant result among twenty is not news. Every pair carries both its raw
|
|
1000
|
+
p and the panel-adjusted one, so a reader can see the cost of the correction
|
|
1001
|
+
rather than being handed only its output.
|
|
1002
|
+
|
|
1003
|
+
Pairs with no decoy p — a stratum of one, or an antigen never tested — get
|
|
1004
|
+
``None`` rather than being dropped, so the column is present on every row.
|
|
1005
|
+
"""
|
|
1006
|
+
for pair in pairs:
|
|
1007
|
+
pair.setdefault("p_decoy_bh", None)
|
|
1008
|
+
scored = [
|
|
1009
|
+
(index, pair["p_decoy"])
|
|
1010
|
+
for index, pair in enumerate(pairs)
|
|
1011
|
+
if isinstance(pair.get("p_decoy"), float)
|
|
1012
|
+
]
|
|
1013
|
+
if not scored:
|
|
1014
|
+
return
|
|
1015
|
+
adjusted = S.benjamini_hochberg([value for _, value in scored])
|
|
1016
|
+
for (index, _), q in zip(scored, adjusted, strict=True):
|
|
1017
|
+
pairs[index]["p_decoy_bh"] = q
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _specificity_null(results: list[CohortResult], config: StudyConfig) -> dict[str, Any] | None:
|
|
1021
|
+
"""Does bindsight put antigens in the *right* cancers?
|
|
1022
|
+
|
|
1023
|
+
The decoy null asks whether an antigen beats matched background inside its
|
|
1024
|
+
own cohort. This asks the panel-level question instead: are antigens ranked
|
|
1025
|
+
better in their own indication than in someone else's, or is the pipeline
|
|
1026
|
+
surfacing generic epithelial biology that happens to contain them? The
|
|
1027
|
+
antigen-to-cohort assignment is permuted and the panel statistic recomputed.
|
|
1028
|
+
No differential expression is re-run, so it is nearly free.
|
|
1029
|
+
|
|
1030
|
+
**Restricted to antigens with exactly one indication in the panel.** The test
|
|
1031
|
+
assigns one cohort per antigen, and ERBB2, EGFR, TACSTD2, MET and CLDN18 each
|
|
1032
|
+
appear in several; giving such an antigen a single "own" cohort would mean
|
|
1033
|
+
choosing one arbitrarily, and averaging its cognate cohorts would compare an
|
|
1034
|
+
average against a single draw. The count of usable antigens is reported so
|
|
1035
|
+
the restriction is visible rather than implied.
|
|
1036
|
+
|
|
1037
|
+
Args:
|
|
1038
|
+
results: every scored cohort, each carrying its antigen scores.
|
|
1039
|
+
config: supplies the permutation count and the seed.
|
|
1040
|
+
|
|
1041
|
+
Returns:
|
|
1042
|
+
The panel statistic, its p-value and what it was computed over, or None
|
|
1043
|
+
when fewer than two antigens qualify.
|
|
1044
|
+
"""
|
|
1045
|
+
by_project = {r.project: r.antigen_scores for r in results if r.antigen_scores}
|
|
1046
|
+
if len(by_project) < 2:
|
|
1047
|
+
return None
|
|
1048
|
+
|
|
1049
|
+
# An antigen must be scored in every cohort, or the permutation would compare
|
|
1050
|
+
# a statistic built from one set of cohorts against one built from another.
|
|
1051
|
+
scored_everywhere = set.intersection(*(set(v) for v in by_project.values()))
|
|
1052
|
+
|
|
1053
|
+
cognate = _cognate_projects()
|
|
1054
|
+
|
|
1055
|
+
usable = sorted(
|
|
1056
|
+
symbol
|
|
1057
|
+
for symbol in scored_everywhere
|
|
1058
|
+
if len(cognate.get(symbol, ())) == 1 and next(iter(cognate[symbol])) in by_project
|
|
1059
|
+
)
|
|
1060
|
+
if len(usable) < 2:
|
|
1061
|
+
return None
|
|
1062
|
+
|
|
1063
|
+
scores = {
|
|
1064
|
+
symbol: {project: by_project[project][symbol] for project in by_project}
|
|
1065
|
+
for symbol in usable
|
|
1066
|
+
}
|
|
1067
|
+
# The assignment goes in; the observed statistic comes back out. It used to
|
|
1068
|
+
# be computed here and the null built separately, which is how the two came
|
|
1069
|
+
# to disagree: this sum gives TCGA-PRAD to both FOLH1 and STEAP1, and the
|
|
1070
|
+
# null drew distinct cohorts, so the observation was outside the
|
|
1071
|
+
# distribution it was being compared against.
|
|
1072
|
+
assignment = {s: next(iter(cognate[s])) for s in usable}
|
|
1073
|
+
null = S.permutation_null_p(
|
|
1074
|
+
scores,
|
|
1075
|
+
assignment,
|
|
1076
|
+
n_perm=config.n_permutations,
|
|
1077
|
+
seed=config.seed,
|
|
1078
|
+
higher_is_better=True,
|
|
1079
|
+
)
|
|
1080
|
+
observed = null["observed"]
|
|
1081
|
+
p = null["p_value"]
|
|
1082
|
+
return {
|
|
1083
|
+
"description": (
|
|
1084
|
+
"Antigens ranked in their own indication versus a permuted "
|
|
1085
|
+
"assignment. The statistic is the mean standing of each antigen in "
|
|
1086
|
+
"its cohort, where 1.0 is the top of the eligible surfaceome and 0.0 "
|
|
1087
|
+
"the bottom. Restricted to antigens with a single indication in the "
|
|
1088
|
+
"panel, because the test assigns one cohort per antigen."
|
|
1089
|
+
),
|
|
1090
|
+
"observed": observed,
|
|
1091
|
+
"p_value": p,
|
|
1092
|
+
# The smallest p this many permutations can return. A p sitting on its
|
|
1093
|
+
# floor means "as extreme as 10,000 draws can show", not "vanishingly
|
|
1094
|
+
# small", and the decoy null next to it already reports its own floor
|
|
1095
|
+
# for exactly that reason. Correcting the eligible surfaceome put this
|
|
1096
|
+
# one on the floor, which is precisely when the distinction matters.
|
|
1097
|
+
"p_value_floor": null["p_value_floor"],
|
|
1098
|
+
# Whether the p-value is enumerated or sampled. With seven antigens the
|
|
1099
|
+
# 5,040 permutations are enumerable, so there is no Monte Carlo error
|
|
1100
|
+
# and no sampling floor to reason about.
|
|
1101
|
+
"exact": null["exact"],
|
|
1102
|
+
"n_antigens": len(usable),
|
|
1103
|
+
"n_cohorts": len(by_project),
|
|
1104
|
+
"n_permutations": null["n_permutations"],
|
|
1105
|
+
"antigens": usable,
|
|
1106
|
+
"excluded_multi_indication": sorted(
|
|
1107
|
+
s for s in scored_everywhere if len(cognate.get(s, ())) > 1
|
|
1108
|
+
),
|
|
1109
|
+
# An antigen missing from any cohort's eligible surfaceome cannot enter a
|
|
1110
|
+
# complete matrix. Named rather than silently dropped, because the list
|
|
1111
|
+
# includes CA9 — the strongest signal in the panel — and a reader should
|
|
1112
|
+
# know the specificity result was reached without it.
|
|
1113
|
+
"excluded_not_scored_everywhere": sorted(
|
|
1114
|
+
{symbol for scores_ in by_project.values() for symbol in scores_} - scored_everywhere
|
|
1115
|
+
),
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _pair_key(pair: dict[str, Any]) -> str:
|
|
1120
|
+
project = str(pair["project"]).removeprefix("TCGA-").lower()
|
|
1121
|
+
return f"{project}_{str(pair['symbol']).lower()}"
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _is_hit(pair: dict[str, Any], *, k: int = 20) -> bool:
|
|
1125
|
+
"""Whether the antigen reached the shortlist inside the given cutoff.
|
|
1126
|
+
|
|
1127
|
+
``k`` is an absolute rank, so it is only comparable across cohorts whose
|
|
1128
|
+
shortlists are of similar size. The shortlist sizes are published alongside
|
|
1129
|
+
every rate for exactly that reason, and each pair also carries its
|
|
1130
|
+
normalised rank.
|
|
1131
|
+
"""
|
|
1132
|
+
rank = pair.get("rank")
|
|
1133
|
+
return isinstance(rank, int) and rank <= k
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _median(values: list[int]) -> int | None:
|
|
1137
|
+
"""Median as an integer, or None for an empty list."""
|
|
1138
|
+
if not values:
|
|
1139
|
+
return None
|
|
1140
|
+
ordered = sorted(values)
|
|
1141
|
+
mid = len(ordered) // 2
|
|
1142
|
+
if len(ordered) % 2:
|
|
1143
|
+
return ordered[mid]
|
|
1144
|
+
return (ordered[mid - 1] + ordered[mid]) // 2
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _class_counts(pairs: list[dict[str, Any]]) -> dict[str, int]:
|
|
1148
|
+
counts: dict[str, int] = dict.fromkeys(
|
|
1149
|
+
(O.NOT_REACHABLE, O.GATED_OUT, O.RANKED, O.INFRASTRUCTURE), 0
|
|
1150
|
+
)
|
|
1151
|
+
for p in pairs:
|
|
1152
|
+
counts[p["outcome_class"]] = counts.get(p["outcome_class"], 0) + 1
|
|
1153
|
+
return counts
|