bindsight 0.3.5__py3-none-any.whl

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