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,607 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Render the rediscovery study as Markdown a reviewer can check.
|
|
4
|
+
|
|
5
|
+
Two rules govern what this writes, and both exist because the previous results
|
|
6
|
+
page broke them.
|
|
7
|
+
|
|
8
|
+
**Every number carries what it was computed over.** A rank without its shortlist
|
|
9
|
+
size, a rate without its numerator and denominator, a p-value without its
|
|
10
|
+
reference set: each of those is a number a reader cannot check and cannot
|
|
11
|
+
disagree with. The old page reported "ERBB2 at rank 4" and "recall@5 = 33%"
|
|
12
|
+
without ever printing the size of the shortlist or the fact that the denominator
|
|
13
|
+
was three.
|
|
14
|
+
|
|
15
|
+
**Every absence says which kind of absence it is.** An antigen the surfaceome
|
|
16
|
+
reference does not contain, one a stated filter excluded, and one the ranker
|
|
17
|
+
placed low are three different findings. Reporting them as a single miss tells a
|
|
18
|
+
reader nothing about what to fix, so each row states its class and its
|
|
19
|
+
counterfactual rank.
|
|
20
|
+
|
|
21
|
+
The page is generated, never hand-edited, so it cannot drift from the artifacts.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from bindsight.benchmark import outcomes as O
|
|
30
|
+
from bindsight.benchmark import panel as P
|
|
31
|
+
from bindsight.report.format import fmt_p
|
|
32
|
+
|
|
33
|
+
__all__ = ["render_markdown"]
|
|
34
|
+
|
|
35
|
+
_CLASS_TITLE = {
|
|
36
|
+
O.RANKED: "Reached the shortlist",
|
|
37
|
+
O.GATED_OUT: "Measured, then excluded by a stated filter",
|
|
38
|
+
O.NOT_REACHABLE: "Outside the instrument's reach",
|
|
39
|
+
O.INFRASTRUCTURE: "Invalid — a lookup errored or never ran",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_CLASS_NOTE = {
|
|
43
|
+
O.RANKED: (
|
|
44
|
+
"These entered the candidate shortlist. The rank is only interpretable "
|
|
45
|
+
"beside the shortlist size, so both are printed."
|
|
46
|
+
),
|
|
47
|
+
O.GATED_OUT: (
|
|
48
|
+
"These were measured and then excluded by a named filter. The "
|
|
49
|
+
"counterfactual rank says whether the filter or the ranking was "
|
|
50
|
+
"responsible: a low counterfactual rank means a gate excluded an antigen "
|
|
51
|
+
"the ranking would have placed well."
|
|
52
|
+
),
|
|
53
|
+
O.NOT_REACHABLE: (
|
|
54
|
+
"These are absent from the surfaceome reference, so no expression level "
|
|
55
|
+
"could have surfaced them. They are instrument-coverage failures, not "
|
|
56
|
+
"ranking failures, and are excluded from every rate. The fix is to extend "
|
|
57
|
+
"the reference."
|
|
58
|
+
),
|
|
59
|
+
O.INFRASTRUCTURE: (
|
|
60
|
+
"A lookup that errored or never ran is not a scientific negative. Any pair "
|
|
61
|
+
"here invalidates itself and must be re-run before publication."
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _interval_phrase(interval: Mapping[str, Any] | None) -> str:
|
|
67
|
+
"""Render an interval as ``(95% CI a–b, n antigens)``, or empty when absent.
|
|
68
|
+
|
|
69
|
+
Written to degrade rather than fail: an artifact produced before the study
|
|
70
|
+
recorded these fields still renders, it simply renders the bare mean it has.
|
|
71
|
+
A missing interval is a gap in the artifact, not a reason for the report to
|
|
72
|
+
stop building.
|
|
73
|
+
"""
|
|
74
|
+
if not interval:
|
|
75
|
+
return ""
|
|
76
|
+
low, high = interval.get("low"), interval.get("high")
|
|
77
|
+
n = interval.get("n_clusters")
|
|
78
|
+
if low is None or high is None:
|
|
79
|
+
return ""
|
|
80
|
+
inside = f"{_confidence_label(interval)}CI {_fmt(low, 3)}–{_fmt(high, 3)}"
|
|
81
|
+
if n:
|
|
82
|
+
inside += f", {n} antigen{'s' if int(n) != 1 else ''}"
|
|
83
|
+
return f" ({inside})"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _confidence_label(interval: Mapping[str, Any]) -> str:
|
|
87
|
+
"""``"95% "`` when the artifact recorded a level, ``""`` when it did not.
|
|
88
|
+
|
|
89
|
+
Both callers used ``interval.get("confidence") or 0.95``, which prints
|
|
90
|
+
"95% CI" over an interval whose level nothing had established -- the second
|
|
91
|
+
of them directly under a comment saying that doing so "would misstate it".
|
|
92
|
+
An interval is not publishable without its level, so the level is either
|
|
93
|
+
read from the artifact or not claimed. It is never assumed.
|
|
94
|
+
|
|
95
|
+
``or`` was also wrong for a recorded ``0``: falsy, so it silently became
|
|
96
|
+
0.95. Presence is tested, not truthiness.
|
|
97
|
+
"""
|
|
98
|
+
raw = interval.get("confidence")
|
|
99
|
+
if raw is None:
|
|
100
|
+
return ""
|
|
101
|
+
return f"{float(raw) * 100:g}% "
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _paired_phrase(paired: Mapping[str, Any] | None) -> str:
|
|
105
|
+
"""The within-antigen contrast, which is the comparison the sentence makes.
|
|
106
|
+
|
|
107
|
+
The two means share antigens, so their difference is not the difference of
|
|
108
|
+
two independent samples. Pairing inside each antigen is; this is the number
|
|
109
|
+
a reader should weigh.
|
|
110
|
+
"""
|
|
111
|
+
if not paired:
|
|
112
|
+
return ""
|
|
113
|
+
point, low, high = paired.get("point"), paired.get("low"), paired.get("high")
|
|
114
|
+
if point is None or low is None or high is None:
|
|
115
|
+
return ""
|
|
116
|
+
n = paired.get("n_clusters")
|
|
117
|
+
spans_zero = float(low) <= 0.0 <= float(high)
|
|
118
|
+
tail = ", an interval that includes zero" if spans_zero else ", an interval that excludes zero"
|
|
119
|
+
# Read, not assumed: the artifact records the confidence level, and printing
|
|
120
|
+
# "95%" over a block computed at another level would misstate it.
|
|
121
|
+
return (
|
|
122
|
+
f" — a within-antigen difference of **{_fmt(point, 3)}** "
|
|
123
|
+
f"({_confidence_label(paired)}CI {_fmt(low, 3)}–{_fmt(high, 3)} over {n} antigen"
|
|
124
|
+
f"{'s' if n and int(n) != 1 else ''}{tail})"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _fmt_p(value: Any) -> str:
|
|
129
|
+
"""A p-value, under the rule the other two surfaces already apply.
|
|
130
|
+
|
|
131
|
+
``_fmt`` guards its scientific-notation branch with ``value != 0``, so a
|
|
132
|
+
padj of literally 0.0 -- below floating-point resolution, which is what the
|
|
133
|
+
strongest result in the panel carries -- fell through to ``0.000``. In a
|
|
134
|
+
column whose other entries read ``8.39e-58``, that makes the most
|
|
135
|
+
significant antigen look like the least. ``fmt_p`` is the same function the
|
|
136
|
+
served interface and the standalone HTML report use.
|
|
137
|
+
"""
|
|
138
|
+
return fmt_p(value)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _fmt(value: Any, places: int = 3) -> str:
|
|
142
|
+
"""Numbers to fixed precision, absences as an explicit dash."""
|
|
143
|
+
if value is None:
|
|
144
|
+
return "—"
|
|
145
|
+
if isinstance(value, bool):
|
|
146
|
+
return "yes" if value else "no"
|
|
147
|
+
if isinstance(value, float):
|
|
148
|
+
if value != value: # NaN
|
|
149
|
+
return "—"
|
|
150
|
+
if value != 0 and (abs(value) < 1e-3 or abs(value) >= 1e5):
|
|
151
|
+
return f"{value:.2e}"
|
|
152
|
+
return f"{value:.{places}f}"
|
|
153
|
+
return str(value)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _interval(block: dict[str, Any] | None) -> str:
|
|
157
|
+
"""A rate as point estimate, interval and the counts behind it."""
|
|
158
|
+
if not block:
|
|
159
|
+
return "—"
|
|
160
|
+
# `_confidence_label` exists for this and says why: an interval is not
|
|
161
|
+
# publishable without its level, so the level is read from the artifact or
|
|
162
|
+
# not claimed. `_interval_phrase` already used it; this renderer -- the
|
|
163
|
+
# headline cascade and the primary interval -- printed "95% CI" over
|
|
164
|
+
# whatever level the artifact happened to record.
|
|
165
|
+
return (
|
|
166
|
+
f"{block['numerator']}/{block['denominator']} = {block['point']:.3f} "
|
|
167
|
+
f"({_confidence_label(block)}CI {block['low']:.3f}–{block['high']:.3f})"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def render_markdown(summary: dict[str, Any]) -> str:
|
|
172
|
+
"""Render the study summary produced by :func:`bindsight.benchmark.study.summarise`."""
|
|
173
|
+
design = summary.get("design", {})
|
|
174
|
+
lines: list[str] = [
|
|
175
|
+
"# bindsight rediscovery study — results",
|
|
176
|
+
"",
|
|
177
|
+
"Does bindsight's expression-based discovery resurface clinically validated "
|
|
178
|
+
"cell-surface antigens from real TCGA RNA-seq?",
|
|
179
|
+
"",
|
|
180
|
+
"## How this study is built",
|
|
181
|
+
"",
|
|
182
|
+
f"- **Cohorts:** {design.get('cohorts', '—')}.",
|
|
183
|
+
f"- **Contrast:** `{design.get('contrast', '—')}`.",
|
|
184
|
+
"- **Denominators are pre-registered**, not chosen after seeing the data.",
|
|
185
|
+
"- **Every rate carries an interval.** At these panel sizes the interval, "
|
|
186
|
+
"not the point estimate, is the finding.",
|
|
187
|
+
"",
|
|
188
|
+
"### Why cohorts are not stratified",
|
|
189
|
+
"",
|
|
190
|
+
str(design.get("admissible_stratifier_rule", "")),
|
|
191
|
+
"",
|
|
192
|
+
"### Antigens the instrument cannot reach",
|
|
193
|
+
"",
|
|
194
|
+
str(design.get("unreachable_note", "")),
|
|
195
|
+
"",
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
cascade = summary.get("recall_cascade", {})
|
|
199
|
+
if cascade:
|
|
200
|
+
# The cutoff the artifact actually reported. `study.py:758` sets it to 20
|
|
201
|
+
# only when 20 is among the configured cutoffs and otherwise to the last
|
|
202
|
+
# one, so a literal "Recall@20" header can name a column it is not.
|
|
203
|
+
headline_k = summary.get("headline_k", 20)
|
|
204
|
+
lines += [
|
|
205
|
+
"## Headline",
|
|
206
|
+
"",
|
|
207
|
+
"Three nested denominators. Each is a different question, and merging "
|
|
208
|
+
"them would answer none of them.",
|
|
209
|
+
"",
|
|
210
|
+
f"| Denominator | What it asks | Recall@{headline_k} |",
|
|
211
|
+
"|---|---|---|",
|
|
212
|
+
]
|
|
213
|
+
# `all` means every pair in the primary tier, NOT every scored pair: the
|
|
214
|
+
# sensitivity table below counts 22 where this counts 17. Printing "every
|
|
215
|
+
# scored pair" beside 1/17 invited the reader to conclude five pairs had
|
|
216
|
+
# been dropped without explanation. The tier is now named in the label.
|
|
217
|
+
tiers = summary.get("design", {}).get("tiers_in_primary_denominator") or []
|
|
218
|
+
tier_label = "+".join(str(t) for t in tiers) if tiers else "primary tier"
|
|
219
|
+
questions = {
|
|
220
|
+
"all": f"Of every scored **{tier_label}**-tier pair, how many were surfaced?",
|
|
221
|
+
"reachable": "Of those the instrument could see at all?",
|
|
222
|
+
"gate_passed": "Of those that reached the shortlist? This alone measures the ranking.",
|
|
223
|
+
}
|
|
224
|
+
labels = {
|
|
225
|
+
"all": f"all ({tier_label})",
|
|
226
|
+
"reachable": "reachable",
|
|
227
|
+
"gate_passed": "gate_passed",
|
|
228
|
+
}
|
|
229
|
+
for name in ("all", "reachable", "gate_passed"):
|
|
230
|
+
block = cascade.get(name, {})
|
|
231
|
+
lines.append(
|
|
232
|
+
f"| `{labels[name]}` | {questions[name]} | {_interval(block.get('wilson'))} |"
|
|
233
|
+
)
|
|
234
|
+
lines.append("")
|
|
235
|
+
|
|
236
|
+
# One cutoff cannot be compared across studies whose shortlists differ in
|
|
237
|
+
# size. The shortlist here is roughly 285 candidates; before the surfaceome
|
|
238
|
+
# pre-filter it was roughly 40, so "within the top 20" quietly became a far
|
|
239
|
+
# harder question. Publishing the range and the shortlist size is what lets
|
|
240
|
+
# a reader see that instead of having to infer it.
|
|
241
|
+
cutoffs = sorted(
|
|
242
|
+
{int(k.removeprefix("recall@")) for b in cascade.values() for k in b.get("at_k", {})}
|
|
243
|
+
)
|
|
244
|
+
if cutoffs:
|
|
245
|
+
lines += [
|
|
246
|
+
"### Recall across cutoffs",
|
|
247
|
+
"",
|
|
248
|
+
"An absolute cutoff is only comparable between cohorts whose "
|
|
249
|
+
"shortlists are of similar size, so the median shortlist is printed "
|
|
250
|
+
"beside each row.",
|
|
251
|
+
"",
|
|
252
|
+
"| Denominator | Median shortlist | " + " | ".join(f"@{k}" for k in cutoffs) + " |",
|
|
253
|
+
"|---|--:|" + "--:|" * len(cutoffs),
|
|
254
|
+
]
|
|
255
|
+
for name in ("all", "reachable", "gate_passed"):
|
|
256
|
+
block = cascade.get(name, {})
|
|
257
|
+
at_k = block.get("at_k", {})
|
|
258
|
+
if not at_k:
|
|
259
|
+
continue
|
|
260
|
+
name = labels[name]
|
|
261
|
+
cells = []
|
|
262
|
+
for k in cutoffs:
|
|
263
|
+
w = at_k.get(f"recall@{k}", {}).get("wilson")
|
|
264
|
+
cells.append(f"{w['numerator']}/{w['denominator']}" if w else "—")
|
|
265
|
+
lines.append(
|
|
266
|
+
f"| `{name}` | {block.get('median_shortlist_size', '—')} | "
|
|
267
|
+
+ " | ".join(cells)
|
|
268
|
+
+ " |"
|
|
269
|
+
)
|
|
270
|
+
lines.append("")
|
|
271
|
+
|
|
272
|
+
sensitivity = summary.get("tier_sensitivity")
|
|
273
|
+
if sensitivity:
|
|
274
|
+
lines += [
|
|
275
|
+
"### Sensitivity to the regulatory tier",
|
|
276
|
+
"",
|
|
277
|
+
str(sensitivity.get("description", "")),
|
|
278
|
+
"",
|
|
279
|
+
"| Cutoff | Every scored pair |",
|
|
280
|
+
"|---|--:|",
|
|
281
|
+
]
|
|
282
|
+
for k, block in sensitivity.get("at_k", {}).items():
|
|
283
|
+
lines.append(f"| {k} | {block['numerator']}/{block['denominator']} |")
|
|
284
|
+
lines.append("")
|
|
285
|
+
|
|
286
|
+
primary = summary.get("primary_interval")
|
|
287
|
+
if primary:
|
|
288
|
+
lines += [
|
|
289
|
+
"### Interval over independent antigens",
|
|
290
|
+
"",
|
|
291
|
+
str(primary.get("description", "")),
|
|
292
|
+
"",
|
|
293
|
+
f"- Wilson: {_interval(primary.get('wilson'))}",
|
|
294
|
+
f"- Clopper-Pearson: {_interval(primary.get('clopper_pearson'))}",
|
|
295
|
+
f"- Distinct antigens: {primary.get('n_distinct_antigens')}",
|
|
296
|
+
"",
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
bootstrap = summary.get("cluster_bootstrap")
|
|
300
|
+
if bootstrap:
|
|
301
|
+
lines += [
|
|
302
|
+
f"- Cluster bootstrap over antigens: {_interval(bootstrap)}",
|
|
303
|
+
f" `{bootstrap.get('method', '')}`",
|
|
304
|
+
"",
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
infra = summary.get("infrastructure_failures", {})
|
|
308
|
+
if infra:
|
|
309
|
+
lines += [
|
|
310
|
+
"### Invalid pairs",
|
|
311
|
+
"",
|
|
312
|
+
f"**{infra.get('n', 0)}**. {infra.get('note', '')}",
|
|
313
|
+
"",
|
|
314
|
+
]
|
|
315
|
+
if infra.get("pairs"):
|
|
316
|
+
lines += [f"Affected: {', '.join(infra['pairs'])}", ""]
|
|
317
|
+
|
|
318
|
+
lines += _render_nulls(summary)
|
|
319
|
+
# The headline antigen's own caveat, beside the table that ranks it first.
|
|
320
|
+
# The artifact has carried this text since the study ran and no surface
|
|
321
|
+
# rendered it: that the girentuximab phase 3 failed its primary endpoint, so
|
|
322
|
+
# the live asset is an imaging agent rather than a therapeutic, and that CA9
|
|
323
|
+
# is absent from SURFY. A reader meeting "rank 1 of 291" without it is
|
|
324
|
+
# reading the strongest number in the study with its qualification removed.
|
|
325
|
+
# Derived the same way bindsight.report.showcase derives it -- the
|
|
326
|
+
# best-ranked surfaced pair -- because the artifact records no top-level
|
|
327
|
+
# "headline" key and reading one would silently render nothing.
|
|
328
|
+
_ranked = [p for p in (summary.get("pairs") or []) if isinstance(p.get("rank"), int)]
|
|
329
|
+
headline: dict[str, Any] = min(_ranked, key=lambda p: p["rank"], default={}) or {}
|
|
330
|
+
if headline.get("note"):
|
|
331
|
+
lines += [
|
|
332
|
+
f"> **What qualifies {headline.get('symbol', 'the top-ranked antigen')}.** "
|
|
333
|
+
f"{headline['note']}",
|
|
334
|
+
"",
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
lines += _render_pairs(summary.get("pairs", []))
|
|
338
|
+
lines += _render_set_sizes(summary.get("set_sizes_by_cohort", {}))
|
|
339
|
+
lines += _render_excluded()
|
|
340
|
+
|
|
341
|
+
not_run = summary.get("cohorts_not_yet_run") or []
|
|
342
|
+
if not_run:
|
|
343
|
+
lines += [
|
|
344
|
+
"## Cohorts not yet run",
|
|
345
|
+
"",
|
|
346
|
+
"A cohort that has not run is not a cohort that found nothing, so these "
|
|
347
|
+
"are named rather than counted as misses.",
|
|
348
|
+
"",
|
|
349
|
+
", ".join(sorted(not_run)),
|
|
350
|
+
"",
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
lines += [
|
|
354
|
+
"## Reproduce",
|
|
355
|
+
"",
|
|
356
|
+
"```bash",
|
|
357
|
+
'pip install -e ".[discover,report]"',
|
|
358
|
+
"python benchmarks/run_study.py --list",
|
|
359
|
+
"python benchmarks/run_study.py --all --cpus 2",
|
|
360
|
+
"```",
|
|
361
|
+
"",
|
|
362
|
+
"`--score-only` re-derives every number above from the run directories "
|
|
363
|
+
"already on disk, without re-running any differential expression.",
|
|
364
|
+
"",
|
|
365
|
+
]
|
|
366
|
+
return "\n".join(lines) + "\n"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _render_nulls(summary: dict[str, Any]) -> list[str]:
|
|
370
|
+
"""The null models, which the human-readable report carried none of.
|
|
371
|
+
|
|
372
|
+
Until now this page published three interval estimators and no null-model
|
|
373
|
+
p-value of any kind. The module's own docstring calls the decoy null "the
|
|
374
|
+
primary null" and it had never run; the only null that did run, the uniform
|
|
375
|
+
rank, is the one the module calls the weakest, and its output never left
|
|
376
|
+
results.json.
|
|
377
|
+
"""
|
|
378
|
+
pairs = [p for p in summary.get("pairs", []) if p.get("p_decoy") is not None]
|
|
379
|
+
spec = summary.get("specificity_null")
|
|
380
|
+
if not pairs and not spec:
|
|
381
|
+
return []
|
|
382
|
+
|
|
383
|
+
lines = ["## Null models", ""]
|
|
384
|
+
|
|
385
|
+
if pairs:
|
|
386
|
+
exact = sum(1 for p in pairs if p.get("decoy_exact"))
|
|
387
|
+
lines += [
|
|
388
|
+
"### Decoy null — the primary one",
|
|
389
|
+
"",
|
|
390
|
+
"Each antigen is compared against the genes matched to it on abundance "
|
|
391
|
+
"and dispersion quintile, drawn from the same eligible surfaceome and "
|
|
392
|
+
"ranked by the same counterfactual score. The question it answers: "
|
|
393
|
+
"would a gene that merely *looks* like this antigen have ranked as "
|
|
394
|
+
"well?",
|
|
395
|
+
"",
|
|
396
|
+
"Taken over the **counterfactual** rank rather than the shortlist "
|
|
397
|
+
"rank, so no gate has to be matched and the tail is computed exactly "
|
|
398
|
+
"rather than sampled — "
|
|
399
|
+
f"{exact} of {len(pairs)} pairs used their whole stratum.",
|
|
400
|
+
"",
|
|
401
|
+
"**`floor` is the smallest p the stratum could have produced.** A p "
|
|
402
|
+
"equal to its floor means no decoy beat the antigen and the stratum "
|
|
403
|
+
"had no finer resolution to offer; it is not the same statement as a "
|
|
404
|
+
"p of that size drawn from a large pool.",
|
|
405
|
+
"",
|
|
406
|
+
"| antigen | cohort | counterfactual rank | p | BH | floor | decoys |",
|
|
407
|
+
"|---|---|--:|--:|--:|--:|--:|",
|
|
408
|
+
]
|
|
409
|
+
for pair in sorted(pairs, key=lambda p: float(p["p_decoy"])):
|
|
410
|
+
lines.append(
|
|
411
|
+
f"| **{pair['symbol']}** | {str(pair['project']).removeprefix('TCGA-')} "
|
|
412
|
+
f"| {pair.get('counterfactual_rank')} "
|
|
413
|
+
f"| {_fmt_p(pair['p_decoy'])} "
|
|
414
|
+
f"| {_fmt_p(pair.get('p_decoy_bh'))} "
|
|
415
|
+
f"| {_fmt_p(pair.get('p_decoy_floor'))} "
|
|
416
|
+
f"| {pair.get('decoy_pool_size')} |"
|
|
417
|
+
)
|
|
418
|
+
nominal = [p for p in pairs if float(p["p_decoy"]) < 0.05]
|
|
419
|
+
survivors = [
|
|
420
|
+
p for p in pairs if isinstance(p.get("p_decoy_bh"), float) and p["p_decoy_bh"] < 0.05
|
|
421
|
+
]
|
|
422
|
+
lines += [
|
|
423
|
+
"",
|
|
424
|
+
f"**{len(nominal)} of {len(pairs)} pairs are nominally significant at 0.05"
|
|
425
|
+
+ (f" ({', '.join(sorted(p['symbol'] for p in nominal))})" if nominal else "")
|
|
426
|
+
+ f", and {len(survivors)} survive Benjamini-Hochberg across the panel.**",
|
|
427
|
+
"",
|
|
428
|
+
]
|
|
429
|
+
if nominal and not survivors:
|
|
430
|
+
lines += [
|
|
431
|
+
"That is the finding, and it is a negative one: against background "
|
|
432
|
+
"matched on abundance and dispersion, no antigen in this panel is "
|
|
433
|
+
"distinguishable once the panel is corrected for its own size. "
|
|
434
|
+
f"Reporting the {len(nominal)} nominal "
|
|
435
|
+
f"hit{'s' if len(nominal) != 1 else ''} without the correction would "
|
|
436
|
+
"be the error this column exists to prevent.",
|
|
437
|
+
"",
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
if spec:
|
|
441
|
+
lines += [
|
|
442
|
+
"### Indication-specificity null",
|
|
443
|
+
"",
|
|
444
|
+
str(spec.get("description", "")),
|
|
445
|
+
"",
|
|
446
|
+
f"- Observed mean standing: **{_fmt(spec.get('observed'), 3)}** "
|
|
447
|
+
"(1.0 is the top of the eligible surfaceome, 0.0 the bottom)",
|
|
448
|
+
f"- p = **{_fmt_p(spec.get('p_value'))}** over "
|
|
449
|
+
f"{spec.get('n_permutations')} "
|
|
450
|
+
+ ("enumerated" if spec.get("exact") else "sampled")
|
|
451
|
+
+ " permutations"
|
|
452
|
+
+ (
|
|
453
|
+
" — the floor for this many, so it is as extreme as this "
|
|
454
|
+
"permutation count can show rather than vanishingly small"
|
|
455
|
+
if spec.get("p_value_floor") is not None
|
|
456
|
+
and spec.get("p_value") is not None
|
|
457
|
+
and spec["p_value"] <= spec["p_value_floor"] * 1.000001
|
|
458
|
+
else ""
|
|
459
|
+
),
|
|
460
|
+
f"- Computed over {spec.get('n_antigens')} antigens "
|
|
461
|
+
f"and {spec.get('n_cohorts')} cohorts: "
|
|
462
|
+
f"{', '.join(spec.get('antigens', []))}",
|
|
463
|
+
"",
|
|
464
|
+
]
|
|
465
|
+
excluded_multi = spec.get("excluded_multi_indication") or []
|
|
466
|
+
excluded_missing = spec.get("excluded_not_scored_everywhere") or []
|
|
467
|
+
if excluded_multi:
|
|
468
|
+
lines += [
|
|
469
|
+
f"Excluded, several indications each: {', '.join(excluded_multi)}. "
|
|
470
|
+
"The test assigns one cohort per antigen, so an antigen with four "
|
|
471
|
+
"indications has no single 'own' cohort to hold fixed.",
|
|
472
|
+
"",
|
|
473
|
+
]
|
|
474
|
+
calib = summary.get("null_calibration")
|
|
475
|
+
if calib:
|
|
476
|
+
own = calib.get("mean_standing_in_own_indication")
|
|
477
|
+
off = calib.get("mean_standing_off_indication")
|
|
478
|
+
off_ci = calib.get("off_indication_interval") or {}
|
|
479
|
+
own_ci = calib.get("own_indication_interval") or {}
|
|
480
|
+
paired = calib.get("paired_difference") or {}
|
|
481
|
+
# The interval's own point, not the flat mean beside it. The flat
|
|
482
|
+
# mean counts an antigen once per cohort and the interval counts it
|
|
483
|
+
# once, so pairing them printed 0.738 with bounds around 0.765 --
|
|
484
|
+
# a point estimate and an interval for two different estimators.
|
|
485
|
+
off = off_ci.get("point", off)
|
|
486
|
+
own = own_ci.get("point", own)
|
|
487
|
+
lines += [
|
|
488
|
+
"**Calibration.** "
|
|
489
|
+
+ ", ".join(str(x).removeprefix("TCGA-") for x in calib.get("projects", []))
|
|
490
|
+
+ " carry no panel antigen and were run to show what no signal "
|
|
491
|
+
"looks like on this scale. Panel antigens land at a mean standing "
|
|
492
|
+
f"of **{_fmt(off, 3)}**{_interval_phrase(off_ci)} there — the middle "
|
|
493
|
+
"of the eligible surfaceome — against "
|
|
494
|
+
f"**{_fmt(own, 3)}**{_interval_phrase(own_ci)} in their own "
|
|
495
|
+
f"indication{_paired_phrase(paired)}. "
|
|
496
|
+
f"Neither cohort contributes a scored pair "
|
|
497
|
+
f"({calib.get('n_scored_pairs')}), because inventing an "
|
|
498
|
+
"expectation for a cohort chosen for having none is the error "
|
|
499
|
+
"they exist to avoid.",
|
|
500
|
+
"",
|
|
501
|
+
]
|
|
502
|
+
|
|
503
|
+
if excluded_missing:
|
|
504
|
+
# Derived, not asserted. This sentence used to end "This list
|
|
505
|
+
# includes CA9, the strongest single signal in the panel, so the
|
|
506
|
+
# specificity result is reached without it" — a claim about the
|
|
507
|
+
# list's contents written as a literal. When the eligible surfaceome
|
|
508
|
+
# was corrected, CA9 stopped being excluded and the sentence went on
|
|
509
|
+
# saying it was. A report that describes its own table has to read
|
|
510
|
+
# the table.
|
|
511
|
+
note = (
|
|
512
|
+
f"Excluded, not scored in every cohort: {', '.join(excluded_missing)}. "
|
|
513
|
+
"A complete matrix is required, or the observed statistic and the "
|
|
514
|
+
"permuted one would be built from different sets of cohorts."
|
|
515
|
+
)
|
|
516
|
+
if "CA9" in excluded_missing:
|
|
517
|
+
note += (
|
|
518
|
+
" That list includes CA9, the strongest single signal in the "
|
|
519
|
+
"panel, so the specificity result is reached without it."
|
|
520
|
+
)
|
|
521
|
+
lines += [note, ""]
|
|
522
|
+
|
|
523
|
+
return lines
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _render_pairs(pairs: list[dict[str, Any]]) -> list[str]:
|
|
527
|
+
"""One section per outcome class, so absences are never merged."""
|
|
528
|
+
if not pairs:
|
|
529
|
+
return []
|
|
530
|
+
lines = ["## Results by outcome class", ""]
|
|
531
|
+
by_class: dict[str, list[dict[str, Any]]] = {}
|
|
532
|
+
for pair in pairs:
|
|
533
|
+
by_class.setdefault(str(pair.get("outcome_class")), []).append(pair)
|
|
534
|
+
|
|
535
|
+
for cls in (O.RANKED, O.GATED_OUT, O.NOT_REACHABLE, O.INFRASTRUCTURE):
|
|
536
|
+
rows = by_class.get(cls, [])
|
|
537
|
+
if not rows:
|
|
538
|
+
continue
|
|
539
|
+
lines += [
|
|
540
|
+
f"### {_CLASS_TITLE[cls]} ({len(rows)})",
|
|
541
|
+
"",
|
|
542
|
+
_CLASS_NOTE[cls],
|
|
543
|
+
"",
|
|
544
|
+
"| Cohort | Antigen | Agent | Tier | log2FC | padj | Rank / shortlist | "
|
|
545
|
+
"Counterfactual rank / eligible | Direction | Why |",
|
|
546
|
+
"|---|---|---|---|--:|--:|--:|--:|---|---|",
|
|
547
|
+
]
|
|
548
|
+
for r in sorted(rows, key=lambda x: (str(x.get("project")), str(x.get("symbol")))):
|
|
549
|
+
rank = f"{r['rank']} / {r['shortlist_size']}" if r.get("rank") is not None else "—"
|
|
550
|
+
cf = (
|
|
551
|
+
f"{r['counterfactual_rank']} / {r['n_eligible']}"
|
|
552
|
+
if r.get("counterfactual_rank") is not None
|
|
553
|
+
else "—"
|
|
554
|
+
)
|
|
555
|
+
lines.append(
|
|
556
|
+
f"| {r.get('project')} | **{r.get('symbol')}** ({r.get('uniprot')}) "
|
|
557
|
+
f"| {r.get('agent')} | {r.get('tier')} | {_fmt(r.get('log2fc'), 2)} "
|
|
558
|
+
f"| {_fmt_p(r.get('padj'))} | {rank} | {cf} | {r.get('direction', '—')} "
|
|
559
|
+
f"| {r.get('reason', '')} |"
|
|
560
|
+
)
|
|
561
|
+
lines.append("")
|
|
562
|
+
return lines
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _render_set_sizes(sizes: dict[str, dict[str, int]]) -> list[str]:
|
|
566
|
+
"""The sets every rank and rate above was computed over."""
|
|
567
|
+
if not sizes:
|
|
568
|
+
return []
|
|
569
|
+
lines = [
|
|
570
|
+
"## Set sizes",
|
|
571
|
+
"",
|
|
572
|
+
"A rank means nothing without the size of the set it was taken within, so "
|
|
573
|
+
"those sizes are published rather than left to be inferred.",
|
|
574
|
+
"",
|
|
575
|
+
"| Cohort | Genes tested | Significant | Eligible surfaceome | Candidate shortlist |",
|
|
576
|
+
"|---|--:|--:|--:|--:|",
|
|
577
|
+
]
|
|
578
|
+
for project in sorted(sizes):
|
|
579
|
+
s = sizes[project]
|
|
580
|
+
lines.append(
|
|
581
|
+
f"| {project} | {s.get('n_genes_tested', '—')} | {s.get('n_significant', '—')} "
|
|
582
|
+
f"| {s.get('n_eligible_surfaceome', '—')} | {s.get('n_candidates', '—')} |"
|
|
583
|
+
)
|
|
584
|
+
lines.append("")
|
|
585
|
+
return lines
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _render_excluded() -> list[str]:
|
|
589
|
+
"""Pairs published for transparency but excluded from every denominator."""
|
|
590
|
+
lines = [
|
|
591
|
+
"## Published but excluded from every denominator",
|
|
592
|
+
"",
|
|
593
|
+
"These are reported so a reader can see what was left out and why. A rate "
|
|
594
|
+
"computed over them would be meaningless.",
|
|
595
|
+
"",
|
|
596
|
+
"| Cohort | Antigen | Reason |",
|
|
597
|
+
"|---|---|---|",
|
|
598
|
+
]
|
|
599
|
+
for c in P.EXCLUDED:
|
|
600
|
+
reason = (
|
|
601
|
+
f"only {c.n_normals} solid-tissue normals, below the floor of {P.MIN_NORMALS_FOR_POWER}"
|
|
602
|
+
if c.usable == "underpowered"
|
|
603
|
+
else "no solid-tissue normals, so no tumour-vs-normal contrast is possible"
|
|
604
|
+
)
|
|
605
|
+
lines.append(f"| {c.project} | **{c.symbol}** ({c.uniprot}) | {reason} |")
|
|
606
|
+
lines.append("")
|
|
607
|
+
return lines
|