bindsight 0.3.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bindsight/README.md +44 -0
- bindsight/__init__.py +15 -0
- bindsight/benchmark/__init__.py +71 -0
- bindsight/benchmark/core.py +497 -0
- bindsight/benchmark/defaults.py +28 -0
- bindsight/benchmark/designer_bench.py +899 -0
- bindsight/benchmark/outcomes.py +508 -0
- bindsight/benchmark/panel.py +621 -0
- bindsight/benchmark/rediscovery.py +1153 -0
- bindsight/benchmark/statistics.py +622 -0
- bindsight/benchmark/study.py +1153 -0
- bindsight/benchmark/study_figures.py +164 -0
- bindsight/benchmark/study_report.py +607 -0
- bindsight/cli.py +2429 -0
- bindsight/config.py +451 -0
- bindsight/cost.py +258 -0
- bindsight/deg/__init__.py +7 -0
- bindsight/deg/pydeseq2_runner.py +285 -0
- bindsight/design/__init__.py +23 -0
- bindsight/design/_common.py +501 -0
- bindsight/design/bindcraft.py +78 -0
- bindsight/design/boltzgen.py +79 -0
- bindsight/design/developability.py +140 -0
- bindsight/design/embeddings.py +165 -0
- bindsight/design/prescreen.py +169 -0
- bindsight/design/protocol.py +111 -0
- bindsight/design/rfdiff_mpnn.py +93 -0
- bindsight/epitopes/__init__.py +7 -0
- bindsight/epitopes/surface_bind.py +134 -0
- bindsight/export/__init__.py +7 -0
- bindsight/export/ro_crate.py +595 -0
- bindsight/io/__init__.py +19 -0
- bindsight/io/cbioportal.py +118 -0
- bindsight/io/gdc.py +343 -0
- bindsight/io/paths.py +124 -0
- bindsight/pipelines/__init__.py +9 -0
- bindsight/pipelines/caveats.py +60 -0
- bindsight/pipelines/discover.py +1443 -0
- bindsight/pipelines/full_run.py +410 -0
- bindsight/plugins.py +335 -0
- bindsight/provenance/__init__.py +34 -0
- bindsight/provenance/append.py +199 -0
- bindsight/provenance/fragments.py +216 -0
- bindsight/provenance/manifest.py +506 -0
- bindsight/rank/__init__.py +7 -0
- bindsight/rank/scoring.py +355 -0
- bindsight/report/__init__.py +39 -0
- bindsight/report/format.py +100 -0
- bindsight/report/html.py +645 -0
- bindsight/report/showcase.py +653 -0
- bindsight/report/templates/report.css +67 -0
- bindsight/report/templates/report.html.j2 +420 -0
- bindsight/report/theme.py +231 -0
- bindsight/report/web/__init__.py +35 -0
- bindsight/report/web/app.py +624 -0
- bindsight/report/web/static/binder_viewer.js +245 -0
- bindsight/report/web/static/bindsight.css +455 -0
- bindsight/report/web/static/charts.js +425 -0
- bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
- bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
- bindsight/report/web/static/your_data_check.js +370 -0
- bindsight/report/web/templates/base.html.j2 +48 -0
- bindsight/report/web/templates/evidence.html.j2 +341 -0
- bindsight/report/web/templates/overview.html.j2 +140 -0
- bindsight/report/web/templates/runs.html.j2 +125 -0
- bindsight/report/web/templates/try.html.j2 +155 -0
- bindsight/report/web/templates/your_data.html.j2 +46 -0
- bindsight/runners/__init__.py +7 -0
- bindsight/runners/colab.py +158 -0
- bindsight/runners/job_exec.py +1110 -0
- bindsight/runners/kaggle.py +425 -0
- bindsight/runners/kaggle_kernel.py +489 -0
- bindsight/runners/local_docker.py +154 -0
- bindsight/runners/mock.py +167 -0
- bindsight/runners/modal_runner.py +199 -0
- bindsight/runners/notebook.py +116 -0
- bindsight/runners/notebook_content.py +217 -0
- bindsight/runners/protocol.py +81 -0
- bindsight/runners/source_wheel.py +104 -0
- bindsight/runners/tools.py +997 -0
- bindsight/structures/__init__.py +7 -0
- bindsight/structures/alphafolddb.py +93 -0
- bindsight/structures/plddt.py +92 -0
- bindsight/structures/topology.py +227 -0
- bindsight/surfaceome/__init__.py +42 -0
- bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
- bindsight/surfaceome/data/surfy_offline.txt +17 -0
- bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
- bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
- bindsight/surfaceome/surfy.py +441 -0
- bindsight/targets/__init__.py +7 -0
- bindsight/targets/ensembl_uniprot.py +56 -0
- bindsight/targets/gtex.py +250 -0
- bindsight/targets/open_targets.py +239 -0
- bindsight/validate/__init__.py +7 -0
- bindsight/validate/af2_ig.py +49 -0
- bindsight/validate/boltz2.py +256 -0
- bindsight/validate/chai1r.py +42 -0
- bindsight/validate/protocol.py +188 -0
- bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
- bindsight-0.3.5.dist-info/METADATA +495 -0
- bindsight-0.3.5.dist-info/RECORD +106 -0
- bindsight-0.3.5.dist-info/WHEEL +4 -0
- bindsight-0.3.5.dist-info/entry_points.txt +20 -0
- bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
- bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""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)
|