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,508 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Why an antigen did not appear: four classes that must never be merged.
|
|
4
|
+
|
|
5
|
+
The earlier study reported one number, recall at k, and it conflated four
|
|
6
|
+
entirely different things. An antigen bindsight's surfaceome reference has never
|
|
7
|
+
heard of, an antigen a documented filter deliberately excluded, an antigen the
|
|
8
|
+
ranker buried, and an antigen whose lookup hit a network error are four separate
|
|
9
|
+
statements about four different parts of the system. Reporting them as one rate
|
|
10
|
+
tells a reader nothing about which part to fix.
|
|
11
|
+
|
|
12
|
+
The four classes:
|
|
13
|
+
|
|
14
|
+
- :data:`NOT_REACHABLE` — decidable **before the run**, from static references
|
|
15
|
+
alone: the gene is absent from the annotation, or its accession is absent from
|
|
16
|
+
the surfaceome list. This is an instrument-coverage failure. It names its own
|
|
17
|
+
fix, which is to extend the reference, and it must never be reported as a
|
|
18
|
+
ranking miss.
|
|
19
|
+
- :data:`GATED_OUT` — measured, then excluded by a stated filter. Every gate
|
|
20
|
+
gets named, including the enrichment cut, which decides which genes become
|
|
21
|
+
candidates at all and was previously invisible.
|
|
22
|
+
- :data:`RANKED` — entered the candidate table. Report the rank *and the size of
|
|
23
|
+
the shortlist it sits in*, because one without the other is not interpretable.
|
|
24
|
+
- :data:`INFRASTRUCTURE` — a lookup errored or never ran. A network outage is
|
|
25
|
+
not a scientific negative. Any pair landing here invalidates that pair, and the
|
|
26
|
+
count must be zero in anything published.
|
|
27
|
+
|
|
28
|
+
**Why the class is not read off the disposition.** ``failure_taxonomy.parquet``
|
|
29
|
+
records an ordered cascade, and in that cascade ``not_surfaceome`` is only
|
|
30
|
+
reachable for genes that already passed significance, up-regulation and the
|
|
31
|
+
enrichment cut. A surfaceome-absent antigen that also missed the enrichment cut
|
|
32
|
+
is recorded as ``below_enrichment_cutoff``, so classifying from the disposition
|
|
33
|
+
alone would file an instrument-coverage failure as a gate failure. Reachability
|
|
34
|
+
is therefore computed independently and wins; the disposition is corroboration.
|
|
35
|
+
|
|
36
|
+
**The counterfactual rank.** For every pair that did not reach the shortlist,
|
|
37
|
+
:func:`counterfactual_rank` reports where it *would* have ranked with no gates at
|
|
38
|
+
all. A counterfactual rank of 3 means a gate killed it; a rank of 900 means the
|
|
39
|
+
ranker buried it. That single number is what separates the two failure modes,
|
|
40
|
+
and it costs nothing beyond the differential-expression table that already
|
|
41
|
+
exists.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from typing import Any, Literal
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"GATED_OUT",
|
|
51
|
+
"INFRASTRUCTURE",
|
|
52
|
+
"NOT_REACHABLE",
|
|
53
|
+
"RANKED",
|
|
54
|
+
"CounterfactualRank",
|
|
55
|
+
"Outcome",
|
|
56
|
+
"OutcomeClass",
|
|
57
|
+
"classify",
|
|
58
|
+
"counterfactual_rank",
|
|
59
|
+
"eligible_ranking",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
OutcomeClass = Literal["not_reachable", "gated_out", "ranked", "infrastructure"]
|
|
63
|
+
|
|
64
|
+
NOT_REACHABLE: OutcomeClass = "not_reachable"
|
|
65
|
+
GATED_OUT: OutcomeClass = "gated_out"
|
|
66
|
+
RANKED: OutcomeClass = "ranked"
|
|
67
|
+
INFRASTRUCTURE: OutcomeClass = "infrastructure"
|
|
68
|
+
|
|
69
|
+
#: Dispositions meaning a lookup errored, was never attempted, or returned
|
|
70
|
+
#: nothing to judge by. These are not findings about biology and must never be
|
|
71
|
+
#: published as negative results.
|
|
72
|
+
#:
|
|
73
|
+
#: The three ``*_unassessed`` members are here because the pipeline that sets
|
|
74
|
+
#: them says so itself. ``discover.py`` records ``safety_unassessed`` rather
|
|
75
|
+
#: than ``fails_safety`` with the comment "Withheld for want of an answer, not
|
|
76
|
+
#: for failing one. Reporting this as fails_safety would publish a network
|
|
77
|
+
#: outage as a negative result about the gene." Leaving them out of this set
|
|
78
|
+
#: sent them to the gated-out fallthrough, which counts in the denominator --
|
|
79
|
+
#: so the outage was published as a miss against the gene anyway, one module
|
|
80
|
+
#: later. Their own explanations already say the gate *could not* assess them,
|
|
81
|
+
#: which is not the gate excluding them.
|
|
82
|
+
_INFRASTRUCTURE_DISPOSITIONS = frozenset(
|
|
83
|
+
{
|
|
84
|
+
"uniprot_lookup_failed",
|
|
85
|
+
"structure_not_queried",
|
|
86
|
+
"safety_unassessed",
|
|
87
|
+
"normal_tissue_unassessed",
|
|
88
|
+
"structure_confidence_unassessed",
|
|
89
|
+
# pydeseq2's independent filtering removed the gene before testing it,
|
|
90
|
+
# so there is no adjusted p-value to compare against anything. This is
|
|
91
|
+
# the same distinction :func:`eligible_ranking` already draws a few
|
|
92
|
+
# hundred lines down: *not tested* is not *tested and found null*, and
|
|
93
|
+
# merging them put an unmeasured antigen into the recall denominator as
|
|
94
|
+
# a miss, under a reason naming a comparison that never happened.
|
|
95
|
+
"significance_unassessed",
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
#: Dispositions meaning the gene was measured and then excluded by a named gate,
|
|
100
|
+
#: before ever entering the candidate table.
|
|
101
|
+
_GATE_DISPOSITIONS = frozenset(
|
|
102
|
+
{
|
|
103
|
+
"not_significant",
|
|
104
|
+
"down_regulated",
|
|
105
|
+
"below_enrichment_cutoff",
|
|
106
|
+
"fails_tractability",
|
|
107
|
+
"fails_safety",
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
#: Human-readable explanation of each gate, so a results table can say what
|
|
112
|
+
#: actually happened rather than emitting a bare enum.
|
|
113
|
+
GATE_EXPLANATIONS: dict[str, str] = {
|
|
114
|
+
"not_significant": (
|
|
115
|
+
"did not clear the significance rule, which requires BOTH an adjusted "
|
|
116
|
+
"p-value below the FDR threshold AND an absolute log2 fold change at or "
|
|
117
|
+
"above the floor — naming only the FDR would misattribute an antigen that "
|
|
118
|
+
"is statistically solid but modestly changed, such as ERBB2 in the "
|
|
119
|
+
"unstratified breast cohort at log2fc 0.92"
|
|
120
|
+
),
|
|
121
|
+
"down_regulated": "measured as down-regulated in tumour",
|
|
122
|
+
"below_enrichment_cutoff": (
|
|
123
|
+
"outside the top-K enrichment cut, so it never became a candidate at all "
|
|
124
|
+
"(this is a gate, not a ranking outcome)"
|
|
125
|
+
),
|
|
126
|
+
"fails_tractability": "no required antibody-tractability modality in Open Targets",
|
|
127
|
+
"fails_safety": "too many vital-tissue safety events",
|
|
128
|
+
"not_surfaceome": "absent from the surfaceome reference",
|
|
129
|
+
"no_uniprot": "no UniProt accession resolved for the gene",
|
|
130
|
+
"uniprot_lookup_failed": "the Open Targets lookup errored; nothing was measured",
|
|
131
|
+
"structure_not_queried": "past the structure-fetch cap; no lookup was attempted",
|
|
132
|
+
"high_normal_tissue_expression": "over the vital-tissue expression ceiling",
|
|
133
|
+
"normal_tissue_unassessed": "no normal-tissue measurement, so the safety gate could not clear it",
|
|
134
|
+
"safety_unassessed": (
|
|
135
|
+
"no Open Targets record, so its safety-event count is unknown rather "
|
|
136
|
+
"than zero and the gate could not clear it"
|
|
137
|
+
),
|
|
138
|
+
"no_extracellular_domain": "no annotated extracellular domain to bind",
|
|
139
|
+
"low_confidence_structure": "predicted structure too disordered",
|
|
140
|
+
"no_alphafold_model": "no AlphaFold model available",
|
|
141
|
+
"not_top_n": "ranked below the design carry-forward cutoff",
|
|
142
|
+
"structure_confidence_unassessed": (
|
|
143
|
+
"carries a structure whose mean pLDDT could not be read, so the "
|
|
144
|
+
"confidence gate never assessed it — not the same as clearing it"
|
|
145
|
+
),
|
|
146
|
+
"significance_unassessed": (
|
|
147
|
+
"pydeseq2's independent filtering removed it before testing, so it has "
|
|
148
|
+
"no adjusted p-value — the significance rule never ran on it, which is "
|
|
149
|
+
"not the same as failing it"
|
|
150
|
+
),
|
|
151
|
+
"no_surface_bind_site": "no targetable site in the SURFACE-Bind data",
|
|
152
|
+
"surface_bind_lookup_failed": (
|
|
153
|
+
"the SURFACE-Bind lookup errored, so nothing was learned about this "
|
|
154
|
+
"protein — not the same as learning it has no targetable site"
|
|
155
|
+
),
|
|
156
|
+
"surfaced": "carried through to design",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class Outcome:
|
|
162
|
+
"""What happened to one antigen in one cohort, and why.
|
|
163
|
+
|
|
164
|
+
Attributes:
|
|
165
|
+
outcome_class: Which of the four classes this pair belongs to.
|
|
166
|
+
disposition: The pipeline's own per-gene disposition, kept as
|
|
167
|
+
corroboration. It may disagree with ``outcome_class`` for a
|
|
168
|
+
reachability failure, which is expected and is why both are reported.
|
|
169
|
+
rank: Position in the candidate shortlist, when the pair reached it.
|
|
170
|
+
shortlist_size: Size of that shortlist. A rank without it is not
|
|
171
|
+
interpretable, so the two always travel together.
|
|
172
|
+
reason: Plain-language explanation suitable for a results table.
|
|
173
|
+
counts_in_denominator: Whether this pair may enter a recall rate. False
|
|
174
|
+
for reachability and infrastructure failures, which say nothing about
|
|
175
|
+
the ranker.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
outcome_class: OutcomeClass
|
|
179
|
+
disposition: str | None
|
|
180
|
+
rank: int | None
|
|
181
|
+
shortlist_size: int | None
|
|
182
|
+
reason: str
|
|
183
|
+
counts_in_denominator: bool
|
|
184
|
+
|
|
185
|
+
def as_dict(self) -> dict[str, Any]:
|
|
186
|
+
"""Serialisable form for the results table."""
|
|
187
|
+
return {
|
|
188
|
+
"outcome_class": self.outcome_class,
|
|
189
|
+
"disposition": self.disposition,
|
|
190
|
+
"rank": self.rank,
|
|
191
|
+
"shortlist_size": self.shortlist_size,
|
|
192
|
+
"normalised_rank": (
|
|
193
|
+
self.rank / self.shortlist_size
|
|
194
|
+
if self.rank is not None and self.shortlist_size
|
|
195
|
+
else None
|
|
196
|
+
),
|
|
197
|
+
"reason": self.reason,
|
|
198
|
+
"counts_in_denominator": self.counts_in_denominator,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def classify(
|
|
203
|
+
*,
|
|
204
|
+
reachability: dict[str, bool],
|
|
205
|
+
disposition: str | None,
|
|
206
|
+
rank: int | None,
|
|
207
|
+
shortlist_size: int | None,
|
|
208
|
+
) -> Outcome:
|
|
209
|
+
"""Assign an antigen-cohort pair to exactly one outcome class.
|
|
210
|
+
|
|
211
|
+
Reachability is evaluated first and wins, because it is decidable from static
|
|
212
|
+
references before the run and the disposition cascade cannot express it
|
|
213
|
+
reliably. See the module docstring.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
reachability: from :func:`bindsight.benchmark.panel.reachability`.
|
|
217
|
+
disposition: the pipeline's per-gene disposition, if the gene was tested.
|
|
218
|
+
rank: position in the candidate shortlist, if it reached one.
|
|
219
|
+
shortlist_size: size of that shortlist.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The classified outcome.
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
ValueError: If a rank is given without the shortlist size it sits in.
|
|
226
|
+
A rank alone is not interpretable and must not be recorded.
|
|
227
|
+
"""
|
|
228
|
+
if rank is not None and not shortlist_size:
|
|
229
|
+
raise ValueError(
|
|
230
|
+
"a rank requires the shortlist size it was taken within; "
|
|
231
|
+
"reporting one without the other is not interpretable"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if not reachability.get("uniprot_resolved", False):
|
|
235
|
+
return Outcome(
|
|
236
|
+
outcome_class=NOT_REACHABLE,
|
|
237
|
+
disposition=disposition,
|
|
238
|
+
rank=None,
|
|
239
|
+
shortlist_size=shortlist_size,
|
|
240
|
+
reason="no UniProt accession, so the antigen can never enter the candidate table",
|
|
241
|
+
counts_in_denominator=False,
|
|
242
|
+
)
|
|
243
|
+
if not reachability.get("in_surfaceome", False):
|
|
244
|
+
return Outcome(
|
|
245
|
+
outcome_class=NOT_REACHABLE,
|
|
246
|
+
disposition=disposition,
|
|
247
|
+
rank=None,
|
|
248
|
+
shortlist_size=shortlist_size,
|
|
249
|
+
reason=(
|
|
250
|
+
"absent from the surfaceome reference, so unreachable at any expression "
|
|
251
|
+
"level. This is an instrument-coverage failure, not a ranking miss; the "
|
|
252
|
+
"fix is to extend the reference"
|
|
253
|
+
),
|
|
254
|
+
counts_in_denominator=False,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# A pair that reached the shortlist was assessed by the ranking, which is what
|
|
258
|
+
# this study measures, so its rank stands whatever a later stage reports.
|
|
259
|
+
# `structure_not_queried` in particular is the normal outcome for anything
|
|
260
|
+
# past the structure-fetch cap: treating it as an infrastructure failure
|
|
261
|
+
# would discard a genuine rank and shrink the denominator for no reason.
|
|
262
|
+
if rank is None and disposition in _INFRASTRUCTURE_DISPOSITIONS:
|
|
263
|
+
return Outcome(
|
|
264
|
+
outcome_class=INFRASTRUCTURE,
|
|
265
|
+
disposition=disposition,
|
|
266
|
+
rank=None,
|
|
267
|
+
shortlist_size=shortlist_size,
|
|
268
|
+
reason=(
|
|
269
|
+
f"{GATE_EXPLANATIONS.get(disposition, disposition)} — this pair is "
|
|
270
|
+
"invalid and must be re-run, not reported"
|
|
271
|
+
),
|
|
272
|
+
counts_in_denominator=False,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if rank is not None:
|
|
276
|
+
return Outcome(
|
|
277
|
+
outcome_class=RANKED,
|
|
278
|
+
disposition=disposition,
|
|
279
|
+
rank=rank,
|
|
280
|
+
shortlist_size=shortlist_size,
|
|
281
|
+
reason=(
|
|
282
|
+
f"reached the candidate shortlist at rank {rank} of {shortlist_size}"
|
|
283
|
+
+ (
|
|
284
|
+
f" ({GATE_EXPLANATIONS[disposition]})"
|
|
285
|
+
if disposition and disposition in GATE_EXPLANATIONS
|
|
286
|
+
else ""
|
|
287
|
+
)
|
|
288
|
+
),
|
|
289
|
+
counts_in_denominator=True,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if disposition in _GATE_DISPOSITIONS:
|
|
293
|
+
return Outcome(
|
|
294
|
+
outcome_class=GATED_OUT,
|
|
295
|
+
disposition=disposition,
|
|
296
|
+
rank=None,
|
|
297
|
+
shortlist_size=shortlist_size,
|
|
298
|
+
reason=GATE_EXPLANATIONS.get(disposition, str(disposition)),
|
|
299
|
+
counts_in_denominator=True,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if disposition is None or not str(disposition).strip():
|
|
303
|
+
# No disposition at all means nothing ever classified this antigen, which
|
|
304
|
+
# is not the same as a gate excluding it. Counting it as a miss publishes
|
|
305
|
+
# "we did not measure this" as "this failed". The reachable way in is a
|
|
306
|
+
# cohort whose discover stage never ran: every antigen in it arrives here,
|
|
307
|
+
# and the study reports a clean sweep of misses with nothing saying why.
|
|
308
|
+
return Outcome(
|
|
309
|
+
outcome_class=INFRASTRUCTURE,
|
|
310
|
+
disposition=disposition,
|
|
311
|
+
rank=None,
|
|
312
|
+
shortlist_size=shortlist_size,
|
|
313
|
+
reason=(
|
|
314
|
+
"no disposition was recorded, so nothing is known about this "
|
|
315
|
+
"antigen in this cohort — it was not measured and then excluded"
|
|
316
|
+
),
|
|
317
|
+
counts_in_denominator=False,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return Outcome(
|
|
321
|
+
outcome_class=GATED_OUT,
|
|
322
|
+
disposition=disposition,
|
|
323
|
+
rank=None,
|
|
324
|
+
shortlist_size=shortlist_size,
|
|
325
|
+
reason=(
|
|
326
|
+
GATE_EXPLANATIONS.get(disposition or "")
|
|
327
|
+
or f"excluded before the shortlist (disposition: {disposition or 'unrecorded'})"
|
|
328
|
+
),
|
|
329
|
+
counts_in_denominator=True,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True)
|
|
334
|
+
class CounterfactualRank:
|
|
335
|
+
"""Where an antigen would have ranked with every gate removed.
|
|
336
|
+
|
|
337
|
+
Attributes:
|
|
338
|
+
rank: 1-based position among the eligible surfaceome by combined score.
|
|
339
|
+
n_eligible: size of that eligible set, without which the rank means nothing.
|
|
340
|
+
n_up_regulated: how many of the eligible set were up-regulated. This is
|
|
341
|
+
the reference set for the uniform-rank null; using ``n_eligible``
|
|
342
|
+
there would roughly halve the p-value.
|
|
343
|
+
log2fc: the antigen's fold change, reported separately from the rank so a
|
|
344
|
+
down-regulated antigen is legible rather than silently excluded.
|
|
345
|
+
score: the combined score itself.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
rank: int
|
|
349
|
+
n_eligible: int
|
|
350
|
+
n_up_regulated: int
|
|
351
|
+
log2fc: float | None
|
|
352
|
+
score: float | None
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def direction(self) -> str:
|
|
356
|
+
"""Whether the antigen was up- or down-regulated, or neither."""
|
|
357
|
+
if self.log2fc is None:
|
|
358
|
+
return "unmeasured"
|
|
359
|
+
if self.log2fc > 0:
|
|
360
|
+
return "up"
|
|
361
|
+
return "down" if self.log2fc < 0 else "flat"
|
|
362
|
+
|
|
363
|
+
def as_dict(self) -> dict[str, Any]:
|
|
364
|
+
"""Serialisable form for the results table."""
|
|
365
|
+
return {
|
|
366
|
+
"counterfactual_rank": self.rank,
|
|
367
|
+
"n_eligible": self.n_eligible,
|
|
368
|
+
"n_up_regulated": self.n_up_regulated,
|
|
369
|
+
"normalised_counterfactual_rank": self.rank / self.n_eligible
|
|
370
|
+
if self.n_eligible
|
|
371
|
+
else None,
|
|
372
|
+
"log2fc": self.log2fc,
|
|
373
|
+
"direction": self.direction,
|
|
374
|
+
"combined_score": self.score,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def eligible_ranking(
|
|
379
|
+
deg: Any,
|
|
380
|
+
*,
|
|
381
|
+
eligible_gene_ids: set[str],
|
|
382
|
+
score_column: str = "pi_score",
|
|
383
|
+
) -> Any:
|
|
384
|
+
"""Order the eligible surfaceome by combined score, best first.
|
|
385
|
+
|
|
386
|
+
Factored out of :func:`counterfactual_rank` because the decoy null needs
|
|
387
|
+
every eligible gene's rank, not one. Calling that function per gene re-sorted
|
|
388
|
+
the whole frame each time — on a real cohort that is roughly 4,800 sorts of a
|
|
389
|
+
4,800-row table, to produce an ordering that never changes.
|
|
390
|
+
|
|
391
|
+
Ranking is over **all** eligible genes with no sign restriction, for the
|
|
392
|
+
reason :func:`counterfactual_rank` gives: restricting to up-regulated genes
|
|
393
|
+
leaves the value undefined for exactly the lineage-antigen cases it exists to
|
|
394
|
+
adjudicate.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
deg: the differential-expression table, carrying ``gene_id``, ``log2fc``
|
|
398
|
+
and the score column. The score is computed from ``padj`` if absent.
|
|
399
|
+
eligible_gene_ids: gene ids whose accession is in the surfaceome. These,
|
|
400
|
+
and only these, could ever have been candidates.
|
|
401
|
+
score_column: the combined ranking score.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
A frame ordered best-first with a 1-based ``counterfactual_rank``
|
|
405
|
+
column, or an empty frame when nothing is eligible.
|
|
406
|
+
|
|
407
|
+
Raises:
|
|
408
|
+
ValueError: If the table lacks the columns needed to rank at all.
|
|
409
|
+
"""
|
|
410
|
+
import numpy as np
|
|
411
|
+
import pandas as pd
|
|
412
|
+
|
|
413
|
+
if not isinstance(deg, pd.DataFrame): # pragma: no cover - defensive
|
|
414
|
+
raise ValueError("deg must be a pandas DataFrame")
|
|
415
|
+
for required in ("gene_id", "log2fc"):
|
|
416
|
+
if required not in deg.columns:
|
|
417
|
+
raise ValueError(f"differential-expression table is missing {required!r}")
|
|
418
|
+
|
|
419
|
+
eligible = deg[deg["gene_id"].astype(str).isin(eligible_gene_ids)].copy()
|
|
420
|
+
if eligible.empty:
|
|
421
|
+
return eligible
|
|
422
|
+
|
|
423
|
+
if score_column not in eligible.columns:
|
|
424
|
+
if "padj" not in eligible.columns:
|
|
425
|
+
raise ValueError(f"cannot compute {score_column!r}: the table has no 'padj' column")
|
|
426
|
+
|
|
427
|
+
# A NaN padj means pydeseq2's independent filtering removed the gene
|
|
428
|
+
# before testing it. That is *not tested*, which this module's opening
|
|
429
|
+
# paragraph names as a different class from *tested and found null* --
|
|
430
|
+
# and this line used to merge them: ``fillna(1.0)`` gives
|
|
431
|
+
# ``-log10(1.0) == 0``, so an untested gene scored exactly 0 and landed
|
|
432
|
+
# in the middle of the counterfactual ordering, displacing the antigen
|
|
433
|
+
# it is supposed to be a reference for.
|
|
434
|
+
#
|
|
435
|
+
# An untested gene cannot be ranked against tested ones, so it leaves
|
|
436
|
+
# the ordering. It is counted rather than dropped silently: the count
|
|
437
|
+
# rides on the frame as an attribute so a caller can report the pool it
|
|
438
|
+
# actually ranked against.
|
|
439
|
+
padj_raw = eligible["padj"].astype(float)
|
|
440
|
+
untested = padj_raw.isna()
|
|
441
|
+
n_untested = int(untested.sum())
|
|
442
|
+
eligible = eligible[~untested].copy()
|
|
443
|
+
if eligible.empty:
|
|
444
|
+
eligible.attrs["n_untested"] = n_untested
|
|
445
|
+
return eligible
|
|
446
|
+
padj = eligible["padj"].astype(float).clip(lower=1e-300)
|
|
447
|
+
eligible[score_column] = eligible["log2fc"].astype(float) * -np.log10(padj)
|
|
448
|
+
eligible.attrs["n_untested"] = n_untested
|
|
449
|
+
|
|
450
|
+
ordered = eligible.sort_values(
|
|
451
|
+
by=[score_column, "gene_id"], ascending=[False, True]
|
|
452
|
+
).reset_index(drop=True)
|
|
453
|
+
ordered["counterfactual_rank"] = ordered.index + 1
|
|
454
|
+
# ``sort_values``/``reset_index`` do not carry ``attrs`` reliably across
|
|
455
|
+
# pandas versions, so it is set again on the frame that is returned.
|
|
456
|
+
ordered.attrs["n_untested"] = int(eligible.attrs.get("n_untested", 0))
|
|
457
|
+
return ordered
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def counterfactual_rank(
|
|
461
|
+
deg: Any,
|
|
462
|
+
*,
|
|
463
|
+
target_gene_id: str,
|
|
464
|
+
eligible_gene_ids: set[str],
|
|
465
|
+
score_column: str = "pi_score",
|
|
466
|
+
) -> CounterfactualRank | None:
|
|
467
|
+
"""Rank an antigen among the eligible surfaceome with every gate removed.
|
|
468
|
+
|
|
469
|
+
Ranking is over **all** eligible genes with no sign restriction. Restricting
|
|
470
|
+
to up-regulated genes would leave the value undefined for exactly the
|
|
471
|
+
lineage-antigen cases it exists to adjudicate — CEACAM5 measured a negative
|
|
472
|
+
fold change, and CLDN18 and EGFR are expected near zero — so the direction is
|
|
473
|
+
reported alongside the rank instead of silently filtering.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
deg: the differential-expression table, carrying ``gene_id``, ``log2fc``
|
|
477
|
+
and the score column.
|
|
478
|
+
target_gene_id: the antigen's Ensembl gene id.
|
|
479
|
+
eligible_gene_ids: gene ids whose accession is in the surfaceome. These,
|
|
480
|
+
and only these, could ever have been candidates.
|
|
481
|
+
score_column: the combined ranking score. Computed if absent.
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
The counterfactual rank, or ``None`` if the antigen was never tested,
|
|
485
|
+
which is a different statement from ranking last.
|
|
486
|
+
|
|
487
|
+
Raises:
|
|
488
|
+
ValueError: If the table lacks the columns needed to rank at all.
|
|
489
|
+
"""
|
|
490
|
+
import pandas as pd
|
|
491
|
+
|
|
492
|
+
ordered = eligible_ranking(deg, eligible_gene_ids=eligible_gene_ids, score_column=score_column)
|
|
493
|
+
if ordered.empty:
|
|
494
|
+
return None
|
|
495
|
+
match = ordered.index[ordered["gene_id"].astype(str) == str(target_gene_id)]
|
|
496
|
+
if len(match) == 0:
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
position = int(match[0]) + 1
|
|
500
|
+
row = ordered.iloc[position - 1]
|
|
501
|
+
log2fc = float(row["log2fc"]) if pd.notna(row["log2fc"]) else None
|
|
502
|
+
return CounterfactualRank(
|
|
503
|
+
rank=position,
|
|
504
|
+
n_eligible=len(ordered),
|
|
505
|
+
n_up_regulated=int((ordered["log2fc"].astype(float) > 0).sum()),
|
|
506
|
+
log2fc=log2fc,
|
|
507
|
+
score=float(row[score_column]) if pd.notna(row[score_column]) else None,
|
|
508
|
+
)
|