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.
Files changed (106) hide show
  1. bindsight/README.md +44 -0
  2. bindsight/__init__.py +15 -0
  3. bindsight/benchmark/__init__.py +71 -0
  4. bindsight/benchmark/core.py +497 -0
  5. bindsight/benchmark/defaults.py +28 -0
  6. bindsight/benchmark/designer_bench.py +899 -0
  7. bindsight/benchmark/outcomes.py +508 -0
  8. bindsight/benchmark/panel.py +621 -0
  9. bindsight/benchmark/rediscovery.py +1153 -0
  10. bindsight/benchmark/statistics.py +622 -0
  11. bindsight/benchmark/study.py +1153 -0
  12. bindsight/benchmark/study_figures.py +164 -0
  13. bindsight/benchmark/study_report.py +607 -0
  14. bindsight/cli.py +2429 -0
  15. bindsight/config.py +451 -0
  16. bindsight/cost.py +258 -0
  17. bindsight/deg/__init__.py +7 -0
  18. bindsight/deg/pydeseq2_runner.py +285 -0
  19. bindsight/design/__init__.py +23 -0
  20. bindsight/design/_common.py +501 -0
  21. bindsight/design/bindcraft.py +78 -0
  22. bindsight/design/boltzgen.py +79 -0
  23. bindsight/design/developability.py +140 -0
  24. bindsight/design/embeddings.py +165 -0
  25. bindsight/design/prescreen.py +169 -0
  26. bindsight/design/protocol.py +111 -0
  27. bindsight/design/rfdiff_mpnn.py +93 -0
  28. bindsight/epitopes/__init__.py +7 -0
  29. bindsight/epitopes/surface_bind.py +134 -0
  30. bindsight/export/__init__.py +7 -0
  31. bindsight/export/ro_crate.py +595 -0
  32. bindsight/io/__init__.py +19 -0
  33. bindsight/io/cbioportal.py +118 -0
  34. bindsight/io/gdc.py +343 -0
  35. bindsight/io/paths.py +124 -0
  36. bindsight/pipelines/__init__.py +9 -0
  37. bindsight/pipelines/caveats.py +60 -0
  38. bindsight/pipelines/discover.py +1443 -0
  39. bindsight/pipelines/full_run.py +410 -0
  40. bindsight/plugins.py +335 -0
  41. bindsight/provenance/__init__.py +34 -0
  42. bindsight/provenance/append.py +199 -0
  43. bindsight/provenance/fragments.py +216 -0
  44. bindsight/provenance/manifest.py +506 -0
  45. bindsight/rank/__init__.py +7 -0
  46. bindsight/rank/scoring.py +355 -0
  47. bindsight/report/__init__.py +39 -0
  48. bindsight/report/format.py +100 -0
  49. bindsight/report/html.py +645 -0
  50. bindsight/report/showcase.py +653 -0
  51. bindsight/report/templates/report.css +67 -0
  52. bindsight/report/templates/report.html.j2 +420 -0
  53. bindsight/report/theme.py +231 -0
  54. bindsight/report/web/__init__.py +35 -0
  55. bindsight/report/web/app.py +624 -0
  56. bindsight/report/web/static/binder_viewer.js +245 -0
  57. bindsight/report/web/static/bindsight.css +455 -0
  58. bindsight/report/web/static/charts.js +425 -0
  59. bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
  60. bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
  61. bindsight/report/web/static/your_data_check.js +370 -0
  62. bindsight/report/web/templates/base.html.j2 +48 -0
  63. bindsight/report/web/templates/evidence.html.j2 +341 -0
  64. bindsight/report/web/templates/overview.html.j2 +140 -0
  65. bindsight/report/web/templates/runs.html.j2 +125 -0
  66. bindsight/report/web/templates/try.html.j2 +155 -0
  67. bindsight/report/web/templates/your_data.html.j2 +46 -0
  68. bindsight/runners/__init__.py +7 -0
  69. bindsight/runners/colab.py +158 -0
  70. bindsight/runners/job_exec.py +1110 -0
  71. bindsight/runners/kaggle.py +425 -0
  72. bindsight/runners/kaggle_kernel.py +489 -0
  73. bindsight/runners/local_docker.py +154 -0
  74. bindsight/runners/mock.py +167 -0
  75. bindsight/runners/modal_runner.py +199 -0
  76. bindsight/runners/notebook.py +116 -0
  77. bindsight/runners/notebook_content.py +217 -0
  78. bindsight/runners/protocol.py +81 -0
  79. bindsight/runners/source_wheel.py +104 -0
  80. bindsight/runners/tools.py +997 -0
  81. bindsight/structures/__init__.py +7 -0
  82. bindsight/structures/alphafolddb.py +93 -0
  83. bindsight/structures/plddt.py +92 -0
  84. bindsight/structures/topology.py +227 -0
  85. bindsight/surfaceome/__init__.py +42 -0
  86. bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
  87. bindsight/surfaceome/data/surfy_offline.txt +17 -0
  88. bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
  89. bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
  90. bindsight/surfaceome/surfy.py +441 -0
  91. bindsight/targets/__init__.py +7 -0
  92. bindsight/targets/ensembl_uniprot.py +56 -0
  93. bindsight/targets/gtex.py +250 -0
  94. bindsight/targets/open_targets.py +239 -0
  95. bindsight/validate/__init__.py +7 -0
  96. bindsight/validate/af2_ig.py +49 -0
  97. bindsight/validate/boltz2.py +256 -0
  98. bindsight/validate/chai1r.py +42 -0
  99. bindsight/validate/protocol.py +188 -0
  100. bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
  101. bindsight-0.3.5.dist-info/METADATA +495 -0
  102. bindsight-0.3.5.dist-info/RECORD +106 -0
  103. bindsight-0.3.5.dist-info/WHEEL +4 -0
  104. bindsight-0.3.5.dist-info/entry_points.txt +20 -0
  105. bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
  106. 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