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
+ """SUPERSEDED by :mod:`bindsight.benchmark.study`.
4
+
5
+ This is the original six-cohort rediscovery harness. Its published result
6
+ -- ERBB2 at rank 4, recall@5 of 33% -- is withdrawn: the breast cohort was
7
+ stratified by a PAM50 subtype call, and ERBB2 is one of the fifty genes that
8
+ classifier is built on, so the tumour arm was selected partly by expression of
9
+ the gene then reported as discovered. The denominator was also chosen after
10
+ seeing which antigens proved over-expressed.
11
+
12
+ Retained so the earlier analysis stays reproducible and the correction is
13
+ auditable. New work belongs in ``bindsight.benchmark.study``, which runs whole
14
+ unstratified cohorts against a pre-registered panel.
15
+
16
+ Rediscovery validation: do real TCGA cohorts resurface known antigens?
17
+
18
+ This module runs the discovery half of bindsight on real TCGA cohorts (one per
19
+ known antigen, in its indication) as tumor-vs-adjacent-normal contrasts, and
20
+ scores the rank of each antigen in the candidate shortlist
21
+ (:func:`benchmark.score_run`), crediting only the cohort's own indication. The
22
+ report groups antigens by their *measured* differential expression so the result
23
+ is transparent and not gamed; the requested cohort sizes are the only hand-set
24
+ values and are reported as inputs next to the achieved per-arm counts.
25
+
26
+ What these runs produced, and why it does not stand: bulk-DE discovery appeared
27
+ to surface ERBB2 at rank 4 in breast, but only because the cohort was
28
+ subtype-stratified via :mod:`bindsight.io.cbioportal` on a classifier built on
29
+ ERBB2 itself. The surviving observation is the uncontroversial half — that the
30
+ method withholds antigens whose tumour-selectivity arises from other mechanisms —
31
+ mutation/amplification (EGFR) or lineage co-expression in the normal
32
+ tissue-of-origin (CEA, PSMA). Sensitivity therefore tracks effect size, the
33
+ expected behaviour of a differential-expression method. That withholding
34
+ follows from the DE gate by construction (a gene failing the over-expression
35
+ rule never becomes a candidate), so it is reported as an internal-consistency
36
+ check rather than as measured specificity.
37
+
38
+ CLDN6 (ovarian) and CD33/CD123 (AML) are deliberately *not* run: TCGA-OV and
39
+ TCGA-LAML ship zero matched solid-tissue normals, and substituting GTEx normals
40
+ would confound the result with a cross-study batch effect — documented as data
41
+ limitations rather than reported with a manufactured number.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import datetime as _dt
47
+ import gzip
48
+ import hashlib
49
+ import json
50
+ import logging
51
+ from dataclasses import asdict, dataclass
52
+ from pathlib import Path
53
+ from typing import Any
54
+
55
+ import pandas as pd
56
+
57
+ from bindsight import __version__
58
+ from bindsight.benchmark.core import (
59
+ KnownAntigen,
60
+ portable_path,
61
+ render_benchmark_html,
62
+ score_run,
63
+ )
64
+ from bindsight.config import (
65
+ DEGParams,
66
+ DesignParams,
67
+ InputsConfig,
68
+ RankParams,
69
+ RunConfig,
70
+ StageParams,
71
+ TargetDiscoveryParams,
72
+ ValidateParams,
73
+ )
74
+
75
+ LOG = logging.getLogger(__name__)
76
+
77
+ CBIOPORTAL_STUDY = "brca_tcga_pan_can_atlas_2018"
78
+ KS: tuple[int, ...] = (5, 10, 20)
79
+
80
+ # Uniform, pre-stated rules for classifying each antigen by the *measured* data
81
+ # (not by a hoped-for label), so the validation is transparent and not gamed.
82
+ OVEREXPRESSION_LOG2FC = 1.0 # the pipeline's own DE effect-size threshold
83
+ #: The FDR half of the same rule, read from the config's default rather
84
+ #: than typed again. It was a literal 0.05 inside the published rule
85
+ #: string while the fold-change half came from the constant above, so a
86
+ #: run configured with another FDR published a rule it had not applied.
87
+ OVEREXPRESSION_FDR = DEGParams.model_fields["fdr_threshold"].default
88
+ MIN_NORMALS_FOR_POWER = 8 # below this a tumor-vs-normal contrast is underpowered
89
+
90
+
91
+ def _categorise(deg_expected: dict[str, Any], n_normal: int) -> str:
92
+ """Classify a cohort by the antigen's *measured* differential expression.
93
+
94
+ - ``over_expressed`` — significant and log2fc ≥ threshold (the pipeline
95
+ can and should surface it; counts toward recall);
96
+ - ``not_over_expressed`` — not significantly up at the bulk level (the DE
97
+ gate excludes it from candidacy; counts toward the consistency check);
98
+ - ``underpowered`` — too few matched normals to call DE reliably;
99
+ - ``not_tested`` — antigen absent from the DEG table.
100
+ """
101
+ de = deg_expected or {}
102
+ if not de.get("tested"):
103
+ return "not_tested"
104
+ if n_normal < MIN_NORMALS_FOR_POWER:
105
+ return "underpowered"
106
+ if de.get("significant") and (de.get("log2fc") or 0.0) >= OVEREXPRESSION_LOG2FC:
107
+ return "over_expressed"
108
+ return "not_over_expressed"
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class Cohort:
113
+ """One rediscovery cohort and the antigen it should resurface.
114
+
115
+ ``expectation`` is one of:
116
+
117
+ - ``"positive"`` — the antigen is transcriptionally over-expressed in the
118
+ tumor, so a correct pipeline should resurface it (counts toward recall);
119
+ - ``"negative_control"`` — clinically relevant but driven by
120
+ mutation/amplification rather than over-expression, so a *specific*
121
+ pipeline should correctly leave it out;
122
+ - ``"limited"`` — a cohort whose result is reported transparently but is
123
+ compromised by a data limitation (e.g. too few normals, or an antigen
124
+ also highly expressed in the matched normal tissue); excluded from recall.
125
+
126
+ ``requested_n_tumor`` / ``requested_n_normal`` are *inputs* to the GDC query,
127
+ not measurements: GDC may hold fewer samples, and the one-sample-per-patient
128
+ rule drops technical replicates. The achieved per-arm counts are derived from
129
+ the run's own provenance (:func:`_achieved_sampling`) and reported separately.
130
+ """
131
+
132
+ key: str
133
+ label: str
134
+ project: str
135
+ tumor_type: str # indication code, matching the known table's ``tumor_type``
136
+ expected_symbol: str
137
+ expected_uniprot: str
138
+ expected_ensembl: str
139
+ subtype: str | None # cBioPortal PAM50 label, or None for whole-project tumor
140
+ requested_n_tumor: int
141
+ requested_n_normal: int
142
+ expectation: str
143
+ note: str
144
+
145
+
146
+ VALIDATION_COHORTS: list[Cohort] = [
147
+ Cohort(
148
+ key="brca_her2",
149
+ label="BRCA HER2-enriched",
150
+ project="TCGA-BRCA",
151
+ tumor_type="BRCA",
152
+ expected_symbol="ERBB2",
153
+ expected_uniprot="P04626",
154
+ expected_ensembl="ENSG00000141736",
155
+ subtype="BRCA_Her2",
156
+ requested_n_tumor=50,
157
+ requested_n_normal=40,
158
+ expectation="positive",
159
+ note="PAM50 HER2-enriched tumors are ERBB2-amplified, so ERBB2 mRNA is high.",
160
+ ),
161
+ Cohort(
162
+ key="coad",
163
+ label="COAD",
164
+ project="TCGA-COAD",
165
+ tumor_type="COAD",
166
+ expected_symbol="CEACAM5",
167
+ expected_uniprot="P06731",
168
+ expected_ensembl="ENSG00000105388",
169
+ subtype=None,
170
+ requested_n_tumor=50,
171
+ requested_n_normal=40,
172
+ expectation="positive",
173
+ note="CEA (target of tusamitamab ravtansine / labetuzumab govitecan) is a classic "
174
+ "colorectal marker, but it is also abundantly expressed in normal colon "
175
+ "epithelium, so the bulk tumor-vs-adjacent-normal fold-change is ~0.",
176
+ ),
177
+ Cohort(
178
+ key="blca",
179
+ label="BLCA",
180
+ project="TCGA-BLCA",
181
+ tumor_type="BLCA",
182
+ expected_symbol="NECTIN4",
183
+ expected_uniprot="Q96NY8",
184
+ expected_ensembl="ENSG00000143217",
185
+ subtype=None,
186
+ requested_n_tumor=50,
187
+ requested_n_normal=19,
188
+ expectation="positive",
189
+ note="Nectin-4 (target of enfortumab vedotin, Padcev) is elevated in urothelial "
190
+ "carcinoma, but only modestly at the bulk-mRNA level (log2fc ~1.6), below the "
191
+ "discovery shortlist.",
192
+ ),
193
+ Cohort(
194
+ key="luad",
195
+ label="LUAD (EGFR negative control)",
196
+ project="TCGA-LUAD",
197
+ tumor_type="LUAD",
198
+ expected_symbol="EGFR",
199
+ expected_uniprot="P00533",
200
+ expected_ensembl="ENSG00000146648",
201
+ subtype=None,
202
+ requested_n_tumor=50,
203
+ requested_n_normal=40,
204
+ expectation="negative_control",
205
+ note="EGFR drives LUAD via mutation/amplification, not bulk mRNA over-expression, "
206
+ "so a specificity-respecting pipeline should NOT surface it on expression alone.",
207
+ ),
208
+ Cohort(
209
+ key="paad",
210
+ label="PAAD (MSLN, limited)",
211
+ project="TCGA-PAAD",
212
+ tumor_type="PAAD",
213
+ expected_symbol="MSLN",
214
+ expected_uniprot="Q13421",
215
+ expected_ensembl="ENSG00000102854",
216
+ subtype=None,
217
+ requested_n_tumor=50,
218
+ requested_n_normal=4,
219
+ expectation="limited",
220
+ note="Mesothelin is over-expressed in PDAC, but TCGA-PAAD ships only 4 matched "
221
+ "normals, so the contrast is underpowered (reported for transparency).",
222
+ ),
223
+ Cohort(
224
+ key="prad",
225
+ label="PRAD (FOLH1, limited)",
226
+ project="TCGA-PRAD",
227
+ tumor_type="PRAD",
228
+ expected_symbol="FOLH1",
229
+ expected_uniprot="Q04609",
230
+ expected_ensembl="ENSG00000086205",
231
+ subtype=None,
232
+ requested_n_tumor=50,
233
+ requested_n_normal=40,
234
+ expectation="limited",
235
+ note="PSMA (FOLH1) is highly expressed but also abundant in normal prostate, so "
236
+ "the tumor-vs-normal fold-change is modest (reported for transparency).",
237
+ ),
238
+ ]
239
+
240
+ # Targets named in the planned validation but not runnable from TCGA alone
241
+ # (no matched solid-tissue normals); recorded honestly, never fabricated.
242
+ DATA_LIMITED = [
243
+ {
244
+ "symbol": "CLDN6",
245
+ "uniprot": "P56747",
246
+ "project": "TCGA-OV",
247
+ "reason": "TCGA-OV ships 0 solid-tissue-normal RNA-seq samples; a clean "
248
+ "tumor-vs-normal contrast is impossible without an external (GTEx) normal, "
249
+ "which would introduce a cross-study batch confound.",
250
+ },
251
+ {
252
+ "symbol": "CD33 / IL3RA (CD123)",
253
+ "uniprot": "P20138 / P26951",
254
+ "project": "TCGA-LAML",
255
+ "reason": "TCGA-LAML ships 0 solid-tissue-normal samples; an AML-vs-normal "
256
+ "contrast needs a normal haematopoietic reference (e.g. GTEx whole blood / "
257
+ "normal bone marrow), again a cross-study batch confound.",
258
+ },
259
+ ]
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Config + cohort preparation
264
+ # ---------------------------------------------------------------------------
265
+ def _build_config(cohort: Cohort, counts: Path, design: Path, out_dir: Path) -> RunConfig:
266
+ """Build a discovery RunConfig for a cohort (production surfaceome, top_n=20)."""
267
+ return RunConfig(
268
+ name=f"validation_{cohort.key}",
269
+ out_dir=out_dir,
270
+ inputs=InputsConfig(counts=counts, design=design, download=None),
271
+ params=StageParams(
272
+ deg=DEGParams(
273
+ design_formula="~ condition",
274
+ contrast=["condition", "tumor", "normal"],
275
+ fdr_threshold=0.05,
276
+ log2fc_threshold=1.0,
277
+ min_replicates=3,
278
+ ),
279
+ target_discovery=TargetDiscoveryParams(
280
+ require_surfy=True,
281
+ surfy_allow_offline_fallback=False,
282
+ use_open_targets=True,
283
+ # The SURFY surfaceome filter already enforces cell-surface
284
+ # localization (the biological prerequisite for an antibody
285
+ # target). We deliberately do NOT additionally gate on Open
286
+ # Targets' "Antibody tractability" bucket: it is an incomplete
287
+ # curated druggability call that would drop bona-fide surface
288
+ # antigens and confound a pure expression-based rediscovery test.
289
+ require_tractable_modality=[],
290
+ max_safety_events=5,
291
+ require_surface_bind_site=False,
292
+ top_n=20,
293
+ ),
294
+ design=DesignParams(),
295
+ validate=ValidateParams(),
296
+ rank=RankParams(),
297
+ ),
298
+ backend="mock",
299
+ )
300
+
301
+
302
+ _DESIGN_COLUMNS = ("sample", "condition", "case_barcode", "sample_barcode")
303
+
304
+
305
+ def _one_sample_per_patient(samples: list[dict[str, Any]]) -> list[str]:
306
+ """Choose at most one sample per (patient, arm) from a cohort's sample list.
307
+
308
+ A GDC listing can return several aliquots of the same patient in one arm
309
+ (e.g. ``-01A`` and ``-01B``); an unpaired ``~ condition`` design would treat
310
+ those technical replicates as independent patients. The kept aliquot is the
311
+ lexicographically first ``sample_barcode``, so re-running reproduces exactly
312
+ the same cohort.
313
+
314
+ Returns the sample ids to keep, in the input order.
315
+ """
316
+ best: dict[tuple[str, str], tuple[str, str]] = {}
317
+ for s in samples:
318
+ arm = (str(s["condition"]), str(s["case_barcode"]))
319
+ aliquot = (str(s["sample_barcode"]), str(s["sample"]))
320
+ if arm not in best or aliquot < best[arm]:
321
+ best[arm] = aliquot
322
+ kept = {sample_id for _, sample_id in best.values()}
323
+ return [str(s["sample"]) for s in samples if str(s["sample"]) in kept]
324
+
325
+
326
+ def _sampling_summary(samples: list[dict[str, Any]], dropped: list[str]) -> dict[str, Any]:
327
+ """Achieved per-arm sample/patient counts, measured from the sample list."""
328
+ by_arm = {
329
+ arm: [s for s in samples if str(s["condition"]) == arm] for arm in ("tumor", "normal")
330
+ }
331
+ patients = {arm: {str(s["case_barcode"]) for s in rows} for arm, rows in by_arm.items()}
332
+ both = sorted(patients["tumor"] & patients["normal"])
333
+ # Measured, not assumed: an older cohort fetched before the rule existed can
334
+ # still carry two aliquots of one patient in one arm.
335
+ arms_patients = {(str(s["condition"]), str(s["case_barcode"])) for s in samples}
336
+ return {
337
+ "n_tumor": len(by_arm["tumor"]),
338
+ "n_normal": len(by_arm["normal"]),
339
+ "n_patients_tumor": len(patients["tumor"]),
340
+ "n_patients_normal": len(patients["normal"]),
341
+ "n_patients_in_both_arms": len(both),
342
+ "patients_in_both_arms": both,
343
+ "dropped_replicate_samples": dropped,
344
+ "one_sample_per_patient_per_arm": len(arms_patients) == len(samples),
345
+ "unpaired_design_disclosure": (
346
+ f"{len(both)} patient(s) contribute a sample to both arms; the DE design is "
347
+ "unpaired (~ condition), so that pairing is not modelled."
348
+ ),
349
+ }
350
+
351
+
352
+ def _achieved_sampling(cohort_key: str, prov: dict[str, Any]) -> dict[str, Any]:
353
+ """Read the achieved sampling out of a cohort's GDC provenance.
354
+
355
+ Raises:
356
+ ValueError: if the provenance carries no per-sample record, in which
357
+ case the achieved cohort size is unknown and must not be replaced
358
+ by the requested constants.
359
+ """
360
+ samples = prov.get("samples")
361
+ if not isinstance(samples, list) or not samples:
362
+ raise ValueError(
363
+ f"{cohort_key}: GDC provenance has no per-sample record, so the achieved "
364
+ "cohort size cannot be measured; re-fetch the cohort instead of reporting "
365
+ "the requested sizes as if they were achieved"
366
+ )
367
+ summary = prov.get("patient_sampling")
368
+ if isinstance(summary, dict):
369
+ return summary
370
+ return _sampling_summary(samples, [])
371
+
372
+
373
+ def _requested(cohort: Cohort) -> dict[str, Any]:
374
+ """The cohort-size *inputs*, kept apart from the achieved counts."""
375
+ return {"n_tumor": cohort.requested_n_tumor, "n_normal": cohort.requested_n_normal}
376
+
377
+
378
+ def _sha256(path: Path) -> str:
379
+ return hashlib.sha256(path.read_bytes()).hexdigest()
380
+
381
+
382
+ def _enforce_one_sample_per_patient(
383
+ cohort_key: str, counts: Path, design_path: Path, prov: dict[str, Any], prov_path: Path
384
+ ) -> dict[str, Any]:
385
+ """Reduce a fetched cohort to one sample per patient per arm, in place.
386
+
387
+ Rewrites ``counts``/``design`` (and the cohort's ``provenance.json``) only
388
+ when a replicate is actually dropped, so repeated calls are a no-op.
389
+ Returns the achieved sampling summary.
390
+ """
391
+ design = pd.read_csv(design_path, sep="\t", dtype=str)
392
+ missing = set(_DESIGN_COLUMNS) - set(design.columns)
393
+ if missing:
394
+ raise ValueError(
395
+ f"{design_path}: design table missing columns {sorted(missing)}; cannot verify "
396
+ "that each patient contributes one sample per arm"
397
+ )
398
+ samples: list[dict[str, Any]] = design.to_dict("records")
399
+ keep = _one_sample_per_patient(samples)
400
+ kept = set(keep)
401
+ dropped = [str(s["sample"]) for s in samples if str(s["sample"]) not in kept]
402
+
403
+ if dropped:
404
+ LOG.warning(
405
+ "%s: %d same-patient replicate sample(s) dropped before DE: %s",
406
+ cohort_key,
407
+ len(dropped),
408
+ ", ".join(dropped),
409
+ )
410
+ design[design["sample"].isin(kept)].to_csv(
411
+ design_path, sep="\t", index=False, lineterminator="\n"
412
+ )
413
+ counts_df = pd.read_csv(counts, sep="\t", index_col=0)
414
+ with gzip.open(counts, "wt", newline="", encoding="utf-8") as fh:
415
+ counts_df[keep].to_csv(fh, sep="\t")
416
+ samples = [s for s in samples if str(s["sample"]) in kept]
417
+
418
+ summary = _sampling_summary(samples, dropped)
419
+ if summary["n_patients_in_both_arms"]:
420
+ LOG.warning("%s: %s", cohort_key, summary["unpaired_design_disclosure"])
421
+ if prov:
422
+ prov["samples"] = samples
423
+ prov["n_tumor"] = summary["n_tumor"]
424
+ prov["n_normal"] = summary["n_normal"]
425
+ prov["patient_sampling"] = summary
426
+ outputs = prov.get("outputs")
427
+ if isinstance(outputs, dict) and dropped:
428
+ for path in (counts, design_path):
429
+ if path.name in outputs:
430
+ outputs[path.name] = {
431
+ "sha256": _sha256(path),
432
+ "bytes": path.stat().st_size,
433
+ }
434
+ prov_path.write_text(json.dumps(prov, indent=2) + "\n", encoding="utf-8", newline="\n")
435
+ return summary
436
+
437
+
438
+ def prepare_cohort(
439
+ cohort: Cohort, data_root: Path, subtype_labels: dict[str, str] | None
440
+ ) -> tuple[Path, Path, dict[str, Any]]:
441
+ """Fetch a cohort's counts + design from GDC (idempotent); return their paths.
442
+
443
+ For a subtype cohort, the tumor cases are the PAM50-labelled patients from
444
+ ``subtype_labels``; normals are the project's adjacent-normal samples. The
445
+ fetched cohort is then reduced to one sample per patient per arm, so no
446
+ patient enters the unpaired contrast twice through the same arm.
447
+ """
448
+ from bindsight.io.cbioportal import patients_with_subtype
449
+ from bindsight.io.gdc import fetch_cohort
450
+
451
+ cohort_dir = data_root / cohort.key
452
+ counts = cohort_dir / "counts.tsv.gz"
453
+ design = cohort_dir / "design.tsv"
454
+ prov_path = cohort_dir / "provenance.json"
455
+
456
+ tumor_cases: list[str] | None = None
457
+ if cohort.subtype is not None:
458
+ if not subtype_labels:
459
+ raise ValueError(f"{cohort.key} needs subtype labels but none were provided")
460
+ tumor_cases = patients_with_subtype(subtype_labels, cohort.subtype)
461
+ LOG.info("%s: %d %s patients from cBioPortal", cohort.key, len(tumor_cases), cohort.subtype)
462
+ if not tumor_cases:
463
+ # An empty match used to flow through as `[]`, which every guard in
464
+ # `_list_cohort_files` read as "no restriction" -- so the cohort was
465
+ # built from the first n tumours of any subtype and published under
466
+ # the stratified cohort's name. This module's own docstring calls
467
+ # that stratification the scientific prerequisite for recovering a
468
+ # subtype-specific antigen, because a bulk contrast over the whole
469
+ # project averages the signal away.
470
+ #
471
+ # Raised here rather than left to the query builder so the message
472
+ # names the label that failed to match, which is where the fix is.
473
+ raise ValueError(
474
+ f"{cohort.key}: no patient carries subtype {cohort.subtype!r} in the "
475
+ f"supplied labels ({len(subtype_labels)} label(s) read). The cohort "
476
+ "cannot be built as specified, and building it unstratified would "
477
+ "publish a different cohort under this one's name. Check the subtype "
478
+ "label against the cBioPortal study's own vocabulary."
479
+ )
480
+
481
+ prov: dict[str, Any]
482
+ if counts.exists() and design.exists():
483
+ LOG.info("%s: cohort already downloaded at %s", cohort.key, cohort_dir)
484
+ prov = json.loads(prov_path.read_text(encoding="utf-8")) if prov_path.exists() else {}
485
+ else:
486
+ prov = fetch_cohort(
487
+ project=cohort.project,
488
+ n_tumor=cohort.requested_n_tumor,
489
+ n_normal=cohort.requested_n_normal,
490
+ counts_out=counts,
491
+ design_out=design,
492
+ tumor_cases=tumor_cases,
493
+ )
494
+ _enforce_one_sample_per_patient(cohort.key, counts, design, prov, prov_path)
495
+ return counts, design, prov
496
+
497
+
498
+ # ---------------------------------------------------------------------------
499
+ # Run + score one cohort
500
+ # ---------------------------------------------------------------------------
501
+ def run_and_score_cohort(
502
+ cohort: Cohort,
503
+ *,
504
+ data_root: Path,
505
+ runs_root: Path,
506
+ known: list[KnownAntigen],
507
+ subtype_labels: dict[str, str] | None,
508
+ ) -> dict[str, Any]:
509
+ """Fetch + discover + score one cohort. Returns a JSON-able result dict.
510
+
511
+ The dict separates ``requested`` (the cohort-size inputs to the GDC query)
512
+ from ``achieved`` (the per-arm sample and patient counts of the samples the
513
+ run actually used); ``n_tumor``/``n_normal`` mirror the achieved counts.
514
+ """
515
+ from bindsight.pipelines import discover
516
+
517
+ counts, design, gdc_prov = prepare_cohort(cohort, data_root, subtype_labels)
518
+ run_out = runs_root / cohort.key
519
+ config = _build_config(cohort, counts, design, run_out)
520
+
521
+ LOG.info("=== discover: %s (%s) ===", cohort.label, cohort.project)
522
+ manifest = discover.run(config)
523
+ statuses = {s.name: s.status for s in manifest.stages}
524
+ if any(v != "completed" for v in statuses.values()):
525
+ LOG.warning("%s: stages not all completed: %s", cohort.key, statuses)
526
+
527
+ # Persist the GDC provenance into the run directory so a later
528
+ # ``rescore_from_runs`` (which only sees ``runs_root``) can recover the real
529
+ # file UUIDs / barcodes / checksums instead of emitting an empty object.
530
+ run_out.mkdir(parents=True, exist_ok=True)
531
+ (run_out / "gdc_provenance.json").write_text(
532
+ json.dumps(gdc_prov, indent=2) + "\n", encoding="utf-8", newline="\n"
533
+ )
534
+
535
+ # Score the cohort's own indication (for the side-by-side report) and pull
536
+ # out the expected antigen for the headline.
537
+ full_score = score_run(
538
+ run_out, known, ks=KS, run_name=cohort.label, tumor_type=cohort.tumor_type
539
+ )
540
+ expected = next(
541
+ (a for a in full_score.per_antigen if a["uniprot"] == cohort.expected_uniprot), None
542
+ )
543
+
544
+ deg_stats = _deg_stats(run_out)
545
+ deg_expected = _expected_deg(run_out, cohort.expected_ensembl)
546
+ achieved = _achieved_sampling(cohort.key, gdc_prov)
547
+ return {
548
+ "cohort": asdict(cohort),
549
+ "requested": _requested(cohort),
550
+ "achieved": achieved,
551
+ "n_tumor": achieved["n_tumor"],
552
+ "n_normal": achieved["n_normal"],
553
+ "n_candidates": full_score.n_candidates,
554
+ "cross_indication": full_score.cross_indication,
555
+ "deg": deg_stats,
556
+ "deg_expected": deg_expected,
557
+ "expected": expected,
558
+ "category": _categorise(deg_expected, achieved["n_normal"]),
559
+ "stage_status": statuses,
560
+ "gdc_provenance": gdc_prov,
561
+ "run_dir": str(run_out),
562
+ }
563
+
564
+
565
+ def _deg_stats(run_dir: Path) -> dict[str, Any]:
566
+ """Summarise the DEG table (n tested / significant) for the report."""
567
+ deg_path = run_dir / "deg" / "results.parquet"
568
+ if not deg_path.exists():
569
+ return {}
570
+ deg = pd.read_parquet(deg_path)
571
+ sig = deg["significant"] if "significant" in deg.columns else pd.Series(dtype=bool)
572
+ return {
573
+ "n_genes_tested": len(deg),
574
+ "n_significant": int(sig.sum()) if len(sig) else 0,
575
+ }
576
+
577
+
578
+ def _expected_deg(run_dir: Path, ensembl: str) -> dict[str, Any]:
579
+ """Look up the expected antigen's own DEG row (log2fc/padj/significant).
580
+
581
+ This is reported regardless of whether the antigen became a candidate, so
582
+ the negative control can *show* its near-zero fold-change as evidence.
583
+ """
584
+ deg_path = run_dir / "deg" / "results.parquet"
585
+ if not deg_path.exists():
586
+ return {}
587
+ deg = pd.read_parquet(deg_path)
588
+ row = deg[deg["gene_id"] == ensembl]
589
+ if row.empty:
590
+ return {"tested": False}
591
+ r = row.iloc[0]
592
+ return {
593
+ "tested": True,
594
+ "log2fc": float(r["log2fc"]),
595
+ "padj": float(r["padj"]) if pd.notna(r["padj"]) else None,
596
+ "significant": bool(r["significant"]),
597
+ }
598
+
599
+
600
+ # ---------------------------------------------------------------------------
601
+ # Orchestration + reporting
602
+ # ---------------------------------------------------------------------------
603
+ def run_validation(
604
+ *,
605
+ out_dir: Path,
606
+ data_root: Path,
607
+ runs_root: Path,
608
+ known_path: Path,
609
+ cohorts: list[Cohort] | None = None,
610
+ study_id: str = CBIOPORTAL_STUDY,
611
+ ) -> dict[str, Any]:
612
+ """Run every cohort, score it, and write the legacy validation artifacts.
613
+
614
+ **Superseded and unreachable.** Nothing in the repository calls this: its
615
+ driver, ``benchmarks/run_validation.py``, was removed when the fifteen-project
616
+ rediscovery study (``benchmarks/run_study.py``) replaced the six-cohort design
617
+ this implements — a design the validation manuscript withdraws, because its
618
+ headline cohort was stratified by a classifier keyed on the antigen it then
619
+ reported discovering. It is kept, rather than deleted, because the helpers it
620
+ composes are shared with the live re-scoring path and are covered by tests;
621
+ what is dead is this entry point, and it is marked so that nobody mistakes it
622
+ for a supported way to produce a result.
623
+
624
+ Writes ``RESULTS.md``, ``results.json``, ``report.html``, ``provenance.json``
625
+ and ``figures/*.png`` under ``out_dir``. Returns the summary dict.
626
+ """
627
+ from bindsight.io.cbioportal import fetch_pam50_subtypes
628
+
629
+ out_dir = Path(out_dir)
630
+ out_dir.mkdir(parents=True, exist_ok=True)
631
+ cohorts = cohorts or VALIDATION_COHORTS
632
+ known = _load_known(known_path)
633
+
634
+ subtype_labels: dict[str, str] | None = None
635
+ if any(c.subtype for c in cohorts):
636
+ subtype_labels = fetch_pam50_subtypes(study_id, cache_dir=data_root / "cbioportal")
637
+
638
+ results: list[dict[str, Any]] = []
639
+ for cohort in cohorts:
640
+ res = run_and_score_cohort(
641
+ cohort,
642
+ data_root=data_root,
643
+ runs_root=runs_root,
644
+ known=known,
645
+ subtype_labels=subtype_labels,
646
+ )
647
+ results.append(res)
648
+
649
+ return _write_artifacts(out_dir, results, known, study_id, known_path)
650
+
651
+
652
+ def rescore_from_runs(
653
+ *,
654
+ out_dir: Path,
655
+ runs_root: Path,
656
+ known_path: Path,
657
+ cohorts: list[Cohort] | None = None,
658
+ study_id: str = CBIOPORTAL_STUDY,
659
+ ) -> dict[str, Any]:
660
+ """Regenerate all validation artifacts from *already-finished* run dirs.
661
+
662
+ Re-scores the cached ``runs_root/<cohort>`` discovery outputs and rewrites
663
+ RESULTS.md / results.json / report.html / figures — without re-running DEG
664
+ or enrichment. Used to refresh the reporting after a scoring change.
665
+
666
+ Raises:
667
+ FileNotFoundError: if a cached run has no ``gdc_provenance.json``, i.e.
668
+ its achieved per-arm sample counts are unknown.
669
+ """
670
+ out_dir = Path(out_dir)
671
+ out_dir.mkdir(parents=True, exist_ok=True)
672
+ cohorts = cohorts or VALIDATION_COHORTS
673
+ known = _load_known(known_path)
674
+
675
+ results: list[dict[str, Any]] = []
676
+ for cohort in cohorts:
677
+ run_out = runs_root / cohort.key
678
+ if not (run_out / "targets" / "candidates.parquet").exists():
679
+ LOG.warning("%s: no cached run at %s; skipping", cohort.key, run_out)
680
+ continue
681
+ full_score = score_run(
682
+ run_out, known, ks=KS, run_name=cohort.label, tumor_type=cohort.tumor_type
683
+ )
684
+ expected = next(
685
+ (a for a in full_score.per_antigen if a["uniprot"] == cohort.expected_uniprot), None
686
+ )
687
+ deg_expected = _expected_deg(run_out, cohort.expected_ensembl)
688
+ # Recover the GDC provenance persisted by the fresh run (see
689
+ # ``run_and_score_cohort``). A run dir without it cannot say how many
690
+ # samples per arm it actually used, and the requested constants are not
691
+ # a stand-in for that, so re-scoring it is an error.
692
+ gdc_prov_path = run_out / "gdc_provenance.json"
693
+ if not gdc_prov_path.exists():
694
+ raise FileNotFoundError(
695
+ f"{cohort.key}: {gdc_prov_path} is missing, so the achieved cohort size "
696
+ "cannot be recovered; re-run the cohort with run_validation()"
697
+ )
698
+ gdc_prov: dict[str, Any] = json.loads(gdc_prov_path.read_text(encoding="utf-8"))
699
+ achieved = _achieved_sampling(cohort.key, gdc_prov)
700
+ results.append(
701
+ {
702
+ "cohort": asdict(cohort),
703
+ "requested": _requested(cohort),
704
+ "achieved": achieved,
705
+ "n_tumor": achieved["n_tumor"],
706
+ "n_normal": achieved["n_normal"],
707
+ "n_candidates": full_score.n_candidates,
708
+ "cross_indication": full_score.cross_indication,
709
+ "deg": _deg_stats(run_out),
710
+ "deg_expected": deg_expected,
711
+ "expected": expected,
712
+ "category": _categorise(deg_expected, achieved["n_normal"]),
713
+ "run_dir": str(run_out),
714
+ "gdc_provenance": gdc_prov,
715
+ }
716
+ )
717
+ return _write_artifacts(out_dir, results, known, study_id, known_path)
718
+
719
+
720
+ def _write_artifacts(
721
+ out_dir: Path,
722
+ results: list[dict[str, Any]],
723
+ known: list[KnownAntigen],
724
+ study_id: str,
725
+ known_path: Path,
726
+ ) -> dict[str, Any]:
727
+ """Build the summary, write all artifacts, and return the summary dict."""
728
+ scores = [
729
+ score_run(
730
+ Path(r["run_dir"]),
731
+ known,
732
+ ks=KS,
733
+ run_name=r["cohort"]["label"],
734
+ tumor_type=r["cohort"]["tumor_type"],
735
+ )
736
+ for r in results
737
+ ]
738
+ recall = _aggregate_recall(results)
739
+ # ``results`` keeps absolute run directories because ``_render_figures``
740
+ # below still reads each run's parquet off disk. The published summary must
741
+ # not carry them: they are machine-specific and leak the environment that
742
+ # produced the run, so serialise a copy with portable paths instead.
743
+ published_results = [{**r, "run_dir": portable_path(r["run_dir"])} for r in results]
744
+ summary = {
745
+ "schema": "bindsight-validation/2",
746
+ "generated_utc": _dt.datetime.now(_dt.UTC).isoformat(timespec="seconds"),
747
+ "bindsight_version": __version__,
748
+ "cbioportal_study": study_id,
749
+ "known_set": portable_path(known_path),
750
+ "ks": list(KS),
751
+ # Both halves of the rule from the same place. The FDR was typed as a
752
+ # literal beside a configured fold-change floor, so a run with a
753
+ # different FDR published a rule it had not applied.
754
+ "overexpression_rule": (f"FDR<{OVEREXPRESSION_FDR} and log2fc>={OVEREXPRESSION_LOG2FC}"),
755
+ "recall_at_k": recall,
756
+ "exclusion_consistency_check": _exclusion_consistency_check(results),
757
+ "cohorts": published_results,
758
+ "data_limited": DATA_LIMITED,
759
+ }
760
+
761
+ (out_dir / "results.json").write_text(
762
+ json.dumps(summary, indent=2) + "\n", encoding="utf-8", newline="\n"
763
+ )
764
+ (out_dir / "report.html").write_text(
765
+ render_benchmark_html(scores, ks=KS, known_source=portable_path(known_path)),
766
+ encoding="utf-8",
767
+ newline="\n",
768
+ )
769
+ (out_dir / "RESULTS.md").write_text(_render_results_md(summary), encoding="utf-8", newline="\n")
770
+ _write_provenance(out_dir, summary)
771
+ try:
772
+ _render_figures(out_dir / "figures", results, recall)
773
+ except Exception as e: # matplotlib optional / headless edge cases
774
+ LOG.warning("figure rendering skipped: %s", e)
775
+
776
+ LOG.info("validation complete; wrote %s", out_dir)
777
+ return summary
778
+
779
+
780
+ def _load_known(known_path: Path) -> list[KnownAntigen]:
781
+ from bindsight.benchmark.core import load_known_antigens
782
+
783
+ return load_known_antigens(known_path)
784
+
785
+
786
+ def _antigen_rank(r: dict[str, Any]) -> int | None:
787
+ ex = r.get("expected") or {}
788
+ rank = ex.get("rank")
789
+ return int(rank) if rank is not None else None
790
+
791
+
792
+ def _aggregate_recall(results: list[dict[str, Any]]) -> dict[str, float]:
793
+ """recall@k over antigens that are *measurably* over-expressed in their cohort.
794
+
795
+ Only antigens that pass the over-expression precondition (significant and
796
+ log2fc ≥ threshold) are in the denominator — an expression-based discovery
797
+ method can only be expected to surface antigens that are actually
798
+ over-expressed. Non-over-expressed antigens go to the exclusion
799
+ consistency check instead.
800
+ """
801
+ oe = [r for r in results if r.get("category") == "over_expressed"]
802
+ if not oe:
803
+ return {f"recall@{k}": 0.0 for k in KS}
804
+ out: dict[str, float] = {}
805
+ for k in KS:
806
+ hits = sum(1 for r in oe if (_antigen_rank(r) or 10**9) <= k)
807
+ out[f"recall@{k}"] = round(hits / len(oe), 4)
808
+ return out
809
+
810
+
811
+ def _exclusion_consistency_check(results: list[dict[str, Any]], k: int = 20) -> dict[str, Any]:
812
+ """Internal-consistency check: non-over-expressed antigens stay out of the top-k.
813
+
814
+ This is **not** a measure of ranking discrimination. ``not_over_expressed``
815
+ is the exact complement of the rule a gene must pass (significant and
816
+ log2fc ≥ threshold) to enter the candidate list at all, so such an antigen
817
+ cannot appear in the shortlist by construction and the check cannot fail. It
818
+ is kept because a failure would mean the DE filter and the shortlist had
819
+ fallen out of step — a real bug — not because it demonstrates specificity.
820
+ """
821
+ noe = [r for r in results if r.get("category") == "not_over_expressed"]
822
+ tautological = (
823
+ "Antigens failing the over-expression rule are excluded from candidacy by "
824
+ "construction, so this check confirms internal consistency between the DE "
825
+ "filter and the shortlist; it does not measure ranking discrimination."
826
+ )
827
+ if not noe:
828
+ return {
829
+ "check": "non_over_expressed_absent_from_top_k",
830
+ "n": 0,
831
+ "consistent": 0,
832
+ "fraction": None,
833
+ "k": k,
834
+ "tautological_by_construction": True,
835
+ "interpretation": tautological,
836
+ }
837
+ consistent = sum(1 for r in noe if (_antigen_rank(r) or 10**9) > k)
838
+ return {
839
+ "check": "non_over_expressed_absent_from_top_k",
840
+ "n": len(noe),
841
+ "consistent": consistent,
842
+ "fraction": round(consistent / len(noe), 4),
843
+ "k": k,
844
+ "tautological_by_construction": True,
845
+ "interpretation": tautological,
846
+ }
847
+
848
+
849
+ _CATEGORY_ORDER = ["over_expressed", "not_over_expressed", "underpowered", "not_tested"]
850
+ _CATEGORY_TITLE = {
851
+ "over_expressed": "Transcriptionally over-expressed (the pipeline should — and is scored to — surface these)",
852
+ "not_over_expressed": "Not over-expressed at the bulk level (excluded from candidacy by the DE rule)",
853
+ "underpowered": "Underpowered (too few matched normals to call differential expression)",
854
+ "not_tested": "Antigen absent from the DEG table",
855
+ }
856
+
857
+
858
+ def _render_results_md(summary: dict[str, Any]) -> str:
859
+ ks = summary["ks"]
860
+ lines: list[str] = []
861
+ a = lines.append
862
+ cohorts = summary["cohorts"]
863
+
864
+ def _row(r: dict[str, Any]) -> str:
865
+ c = r["cohort"]
866
+ ex = r.get("expected") or {}
867
+ rank = ex.get("rank")
868
+ dexp = r.get("deg_expected") or {}
869
+ log2fc = ex.get("log2fc") if ex.get("log2fc") is not None else dexp.get("log2fc")
870
+ padj = ex.get("padj") if ex.get("padj") is not None else dexp.get("padj")
871
+ ach = r["achieved"]
872
+ req = r["requested"]
873
+ tops = " | ".join("✓" if (rank is not None and rank <= k) else "·" for k in ks)
874
+ return (
875
+ f"| {c['expected_symbol']} ({c['expected_uniprot']}) | {c['project']} | "
876
+ f"{ach['n_tumor']} ({req['n_tumor']}) | {ach['n_normal']} ({req['n_normal']}) | "
877
+ f"{ach['n_patients_in_both_arms']} | "
878
+ f"{f'{log2fc:.2f}' if isinstance(log2fc, (int, float)) else '—'} | "
879
+ f"{f'{padj:.1e}' if isinstance(padj, (int, float)) else '—'} | "
880
+ f"{rank if rank is not None else '—'} | {tops} |"
881
+ )
882
+
883
+ header = (
884
+ "| antigen | project | tumor: got (asked) | normal: got (asked) | patients in both arms "
885
+ "| log2fc | padj | rank | " + " | ".join(f"≤{k}" for k in ks) + " |"
886
+ )
887
+ sep = "|---|---|--:|--:|--:|--:|--:|--:|" + "|".join("--:" for _ in ks) + "|"
888
+
889
+ by_cat: dict[str, list[dict[str, Any]]] = {}
890
+ for r in cohorts:
891
+ by_cat.setdefault(r.get("category", "not_tested"), []).append(r)
892
+
893
+ rec = summary["recall_at_k"]
894
+ check = summary["exclusion_consistency_check"]
895
+ oe = by_cat.get("over_expressed", [])
896
+ found = [r for r in oe if _antigen_rank(r) is not None]
897
+
898
+ a("# bindsight rediscovery validation — results\n")
899
+ a(
900
+ "Does bindsight's expression-based discovery resurface clinically-validated "
901
+ "cell-surface antigens from real TCGA RNA-seq? Each antigen is evaluated in "
902
+ "its indication cohort as a tumor-vs-adjacent-normal contrast run through the "
903
+ "discovery half (`bindsight discover`), then scored by the rank of the antigen "
904
+ "in the candidate shortlist (`bindsight.benchmark.score_run`).\n"
905
+ )
906
+ a(
907
+ "**Every measured number below is produced by the runs. The only hand-set values "
908
+ "are the _requested_ cohort sizes — inputs to the GDC query, shown in parentheses "
909
+ "next to the achieved per-arm sample counts, which are derived from each run's own "
910
+ "provenance. Antigens are grouped by their _measured_ differential expression "
911
+ f"(rule: {summary['overexpression_rule']}), not by any prior label — an "
912
+ "expression-based method can only surface antigens that are actually "
913
+ "over-expressed, and we report that precondition transparently.**\n"
914
+ )
915
+ replicated = [
916
+ r["cohort"]["label"] for r in cohorts if not r["achieved"]["one_sample_per_patient_per_arm"]
917
+ ]
918
+ if replicated:
919
+ a(
920
+ "**Pseudo-replication warning:** these cohorts still contain more than one "
921
+ f"sample from the same patient in one arm: {', '.join(replicated)}. Re-fetch "
922
+ "them so the one-sample-per-patient rule applies.\n"
923
+ )
924
+ else:
925
+ a(
926
+ "Each cohort takes at most one sample per patient per arm (the lexicographically "
927
+ "first aliquot), so no patient enters an arm twice.\n"
928
+ )
929
+ a(
930
+ "Patients contributing to both arms are counted per cohort below; the DE design is "
931
+ "unpaired (`~ condition`), so that pairing is not modelled.\n"
932
+ )
933
+ a(f"- Generated: `{summary['generated_utc']}` · bindsight `{summary['bindsight_version']}`")
934
+ a(f"- PAM50 subtypes: cBioPortal study `{summary['cbioportal_study']}`")
935
+ a(f"- Known-antigen set: `{summary['known_set']}`\n")
936
+
937
+ a("## Headline\n")
938
+ if found:
939
+ best = min(found, key=lambda r: _antigen_rank(r) or 10**9)
940
+ bc = best["cohort"]
941
+ br = _antigen_rank(best)
942
+ a(
943
+ f"- **Sensitivity:** of {len(oe)} antigen(s) genuinely over-expressed in their "
944
+ f"cohort, **{best['cohort']['expected_symbol']}** is rediscovered at "
945
+ f"**rank {br}** in {bc['project']}"
946
+ + (f" ({bc['subtype']} subtype)" if bc["subtype"] else "")
947
+ + f" — log2fc {best['deg_expected']['log2fc']:.2f}, "
948
+ f"padj {best['deg_expected']['padj']:.1e}."
949
+ )
950
+ a(
951
+ "- **recall@k over over-expressed antigens:** "
952
+ + ", ".join(f"recall@{k}={rec[f'recall@{k}']:.0%}" for k in ks)
953
+ + "."
954
+ )
955
+ if check.get("fraction") is not None:
956
+ a(
957
+ f"- **Internal-consistency check (not a specificity measurement):** "
958
+ f"{check['consistent']}/{check['n']} antigens that fail the over-expression rule "
959
+ f"are absent from the top-{check['k']}. {check['interpretation']} This check "
960
+ "cannot fail unless the DE filter and the shortlist disagree, and it says "
961
+ "nothing about how the pipeline ranks antigens that *are* over-expressed."
962
+ )
963
+ a("")
964
+
965
+ a("## Reproduce\n")
966
+ a(
967
+ "> This is the **superseded** six-cohort validation. Its driver script "
968
+ "(`benchmarks/run_validation.py`) was removed when the fifteen-project "
969
+ "rediscovery study replaced it; the command below regenerates the study "
970
+ "that supersedes this report, not this report."
971
+ )
972
+ a("")
973
+ a("```bash")
974
+ a('pip install -e ".[discover,report]"')
975
+ a("python benchmarks/run_study.py --score-only")
976
+ a("```\n")
977
+
978
+ a("## Per-antigen results (grouped by measured over-expression)\n")
979
+ a(
980
+ "`rank` is the antigen's 1-based position in the cohort's surface-filtered "
981
+ "candidate shortlist; `—` = not surfaced. Only the cohort's own indication "
982
+ "antigen counts as a rediscovery.\n"
983
+ )
984
+ for cat in _CATEGORY_ORDER:
985
+ rows = by_cat.get(cat, [])
986
+ if not rows:
987
+ continue
988
+ a(f"### {_CATEGORY_TITLE[cat]}\n")
989
+ a(header)
990
+ a(sep)
991
+ for r in sorted(rows, key=lambda r: -(r.get("deg_expected") or {}).get("log2fc", 0.0)):
992
+ a(_row(r))
993
+ a("")
994
+ for r in rows:
995
+ a(
996
+ f"- **{r['cohort']['expected_symbol']}** ({r['cohort']['project']}): "
997
+ f"{r['cohort']['note']}"
998
+ )
999
+ a("")
1000
+
1001
+ a("## Cross-indication cross-reactivity (NOT rediscovery)\n")
1002
+ a(
1003
+ "Known antigens of *other* cancer types that a cohort's shortlist happens to "
1004
+ "contain. They are excluded from recall@k: surfacing a colorectal antigen in a "
1005
+ "breast cohort is a cross-reactivity observation, not a rediscovery.\n"
1006
+ )
1007
+ cross_any = False
1008
+ for r in cohorts:
1009
+ for x in r.get("cross_indication") or []:
1010
+ cross_any = True
1011
+ a(
1012
+ f"- **{r['cohort']['label']}** ({r['cohort']['tumor_type']}) surfaced "
1013
+ f"**{x['symbol']}** ({x['tumor_type']}) at rank {x['rank']}."
1014
+ )
1015
+ if not cross_any:
1016
+ a("- None: no cohort surfaced a known antigen from another indication.")
1017
+ a("")
1018
+
1019
+ a("## Interpretation\n")
1020
+ a(
1021
+ "- The discovery pipeline (subtype-stratified DESeq2 → SURFY surfaceome filter → "
1022
+ "combined-significance ranking) surfaces the antigen that is strongly "
1023
+ "transcriptionally over-expressed. Antigens that are not over-expressed — "
1024
+ "including clinically famous ones whose tumor-selectivity arises from mutation/"
1025
+ "amplification (EGFR) or lineage co-expression in the normal tissue-of-origin "
1026
+ "(CEA, PSMA) — are withheld by the DE gate itself rather than by the ranking, so "
1027
+ "their absence is a property of the filter, not evidence about the ranker. "
1028
+ "Sensitivity therefore tracks effect size, as expected for a "
1029
+ "differential-expression method."
1030
+ )
1031
+ a(
1032
+ "- This delineates the scope of bulk tumor-vs-normal discovery and motivates the "
1033
+ "multi-modal specificity scoring (single-cell, co-expression, immunopeptidomics) "
1034
+ "planned for v1.0.\n"
1035
+ )
1036
+
1037
+ a("## Antigens with no matched TCGA normal (not runnable here)\n")
1038
+ for d in summary["data_limited"]:
1039
+ a(f"- **{d['symbol']}** ({d['project']}): {d['reason']}")
1040
+ a("")
1041
+ a("## Provenance\n")
1042
+ a(
1043
+ "Per-cohort GDC file UUIDs, case barcodes, SHA-256 checksums and the "
1044
+ "requested-vs-achieved sample counts are in `provenance.json` (and each cohort's "
1045
+ "own `provenance.json` under the GDC cache). The side-by-side per-antigen "
1046
+ "scoring — on-indication antigens plus any cross-indication cross-reactivity — "
1047
+ "is in `report.html`.\n"
1048
+ )
1049
+ return "\n".join(lines)
1050
+
1051
+
1052
+ def _write_provenance(out_dir: Path, summary: dict[str, Any]) -> None:
1053
+ prov = {
1054
+ "schema": "bindsight-validation-provenance/2",
1055
+ "generated_utc": summary["generated_utc"],
1056
+ "bindsight_version": summary["bindsight_version"],
1057
+ "cbioportal_study": summary["cbioportal_study"],
1058
+ "sources": {
1059
+ "rna_seq": "NIH/GDC TCGA STAR-Counts (GENCODE v36), open access",
1060
+ "subtypes": "cBioPortal PAM50 (Parker et al. 2009)",
1061
+ "known_antigens": summary["known_set"],
1062
+ },
1063
+ "cohorts": [
1064
+ {
1065
+ "key": r["cohort"]["key"],
1066
+ "project": r["cohort"]["project"],
1067
+ "tumor_type": r["cohort"]["tumor_type"],
1068
+ "subtype": r["cohort"]["subtype"],
1069
+ "requested": r["requested"],
1070
+ "achieved": r["achieved"],
1071
+ "gdc": r.get("gdc_provenance", {}),
1072
+ }
1073
+ for r in summary["cohorts"]
1074
+ ],
1075
+ }
1076
+ (out_dir / "provenance.json").write_text(
1077
+ json.dumps(prov, indent=2) + "\n", encoding="utf-8", newline="\n"
1078
+ )
1079
+
1080
+
1081
+ # ---------------------------------------------------------------------------
1082
+ # Figures (matplotlib; imported lazily)
1083
+ # ---------------------------------------------------------------------------
1084
+ def _render_figures(fig_dir: Path, results: list[dict[str, Any]], recall: dict[str, float]) -> None:
1085
+ import matplotlib
1086
+
1087
+ matplotlib.use("Agg")
1088
+ import matplotlib.pyplot as plt
1089
+
1090
+ fig_dir.mkdir(parents=True, exist_ok=True)
1091
+
1092
+ # 1. recall@k bar chart.
1093
+ ks = [int(k.split("@")[1]) for k in recall]
1094
+ vals = [recall[f"recall@{k}"] for k in ks]
1095
+ fig, ax = plt.subplots(figsize=(4.5, 3.2))
1096
+ ax.bar([f"recall@{k}" for k in ks], vals, color="#2a9d8f")
1097
+ ax.set_ylim(0, 1.0)
1098
+ ax.set_ylabel("recall")
1099
+ ax.set_title("Rediscovery recall@k\n(over runnable cohort-antigen pairs)")
1100
+ for i, v in enumerate(vals):
1101
+ ax.text(i, v + 0.02, f"{v:.0%}", ha="center", fontsize=9)
1102
+ fig.tight_layout()
1103
+ fig.savefig(fig_dir / "recall_at_k.png", dpi=150)
1104
+ plt.close(fig)
1105
+
1106
+ # 2. expected-antigen rank per cohort (lower is better).
1107
+ labels = [r["cohort"]["label"] + f"\n{r['cohort']['expected_symbol']}" for r in results]
1108
+ ranks = [(r.get("expected") or {}).get("rank") for r in results]
1109
+ plotted = [(lab, rk) for lab, rk in zip(labels, ranks, strict=True) if rk is not None]
1110
+ if plotted:
1111
+ labs, rks = zip(*plotted, strict=True)
1112
+ fig, ax = plt.subplots(figsize=(5.5, 3.2))
1113
+ ax.bar(labs, rks, color="#e76f51")
1114
+ ax.axhline(10, ls="--", color="#555", lw=1, label="top-10")
1115
+ ax.set_ylabel("rank in shortlist (lower = better)")
1116
+ ax.set_title("Expected-antigen rank per cohort")
1117
+ ax.legend()
1118
+ fig.tight_layout()
1119
+ fig.savefig(fig_dir / "antigen_rank.png", dpi=150)
1120
+ plt.close(fig)
1121
+
1122
+ # 3. volcano per cohort, expected antigen highlighted.
1123
+ import numpy as np
1124
+
1125
+ for r in results:
1126
+ deg_path = Path(r["run_dir"]) / "deg" / "results.parquet"
1127
+ if not deg_path.exists():
1128
+ continue
1129
+ deg = pd.read_parquet(deg_path)
1130
+ if not {"log2fc", "padj", "gene_id"}.issubset(deg.columns):
1131
+ continue
1132
+ padj = deg["padj"].clip(lower=1e-300)
1133
+ nlog = -np.log10(padj)
1134
+ fig, ax = plt.subplots(figsize=(4.8, 3.6))
1135
+ ax.scatter(deg["log2fc"], nlog, s=4, alpha=0.25, color="#888", linewidths=0)
1136
+ ens = r["cohort"]["expected_ensembl"]
1137
+ hit = deg[deg["gene_id"] == ens]
1138
+ if not hit.empty:
1139
+ ax.scatter(
1140
+ hit["log2fc"],
1141
+ -np.log10(hit["padj"].clip(lower=1e-300)),
1142
+ s=60,
1143
+ color="#d62728",
1144
+ zorder=5,
1145
+ label=r["cohort"]["expected_symbol"],
1146
+ )
1147
+ ax.legend()
1148
+ ax.set_xlabel("log2 fold-change (tumor / normal)")
1149
+ ax.set_ylabel("-log10 adj. p")
1150
+ ax.set_title(f"{r['cohort']['label']} volcano")
1151
+ fig.tight_layout()
1152
+ fig.savefig(fig_dir / f"volcano_{r['cohort']['key']}.png", dpi=150)
1153
+ plt.close(fig)