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,622 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Null models and interval estimates for the rediscovery study.
4
+
5
+ A rank is not evidence on its own. "ERBB2 at rank 4" means something entirely
6
+ different in a shortlist of 27 than in a shortlist of 2,000, and a recall rate
7
+ quoted without a denominator, an interval and a null is decoration rather than a
8
+ measurement. This module supplies the three things that turn a rank into a claim
9
+ a reviewer can check.
10
+
11
+ **Nulls — would a random surface protein have done this well?**
12
+
13
+ - :func:`uniform_rank_p` is the weakest and cheapest. Under the null that the
14
+ pipeline orders the eligible surfaceome arbitrarily, an antigen's rank is
15
+ uniform, so the one-sided p-value is its rank over the size of the set it was
16
+ ranked within. The reference set is the *up-regulated* eligible surfaceome,
17
+ not the whole of it: using the whole set roughly halves the p-value and is
18
+ anticonservative. This null ignores that abundant, high-dispersion genes are
19
+ likelier to reach significance at all, and says so.
20
+ - :func:`decoy_null_p` is the primary null and corrects exactly that. Each true
21
+ antigen is compared against decoys matched on expression abundance and
22
+ dispersion, pushed through the same ranking, so the question becomes "did this
23
+ antigen beat comparable genes" rather than "did it beat all genes".
24
+ - :func:`permutation_null_p` asks the panel-level question the per-antigen nulls
25
+ cannot: is bindsight matching antigens to the *right* indications, or merely
26
+ surfacing generic epithelial biology that happens to include them? Permuting
27
+ which antigen is expected in which cohort tests exactly that, and needs no
28
+ differential expression re-run.
29
+
30
+ **Intervals.** :func:`wilson_interval` is the reported interval and
31
+ :func:`clopper_pearson_interval` the conservative cross-check; both behave
32
+ correctly at zero and at one, which a Wald interval does not, and at the panel
33
+ sizes here that matters constantly. :func:`cluster_bootstrap_interval` handles
34
+ the fact that one antigen appears in several cohorts, so pairs are not
35
+ independent trials.
36
+
37
+ Nothing here draws on the pipeline's own output to decide its reference set, and
38
+ every function takes an explicit seed so a published number is reproducible.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import itertools
44
+ import math
45
+ import random
46
+ from collections import Counter
47
+ from collections.abc import Mapping, Sequence
48
+ from dataclasses import dataclass
49
+ from typing import Any
50
+
51
+ __all__ = [
52
+ "Interval",
53
+ "MeanEstimate",
54
+ "benjamini_hochberg",
55
+ "clopper_pearson_interval",
56
+ "cluster_bootstrap_interval",
57
+ "cluster_bootstrap_mean",
58
+ "decoy_null_p",
59
+ "permutation_null_p",
60
+ "uniform_rank_p",
61
+ "wilson_interval",
62
+ ]
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Interval:
67
+ """A point estimate with a confidence interval and the counts behind it."""
68
+
69
+ point: float
70
+ low: float
71
+ high: float
72
+ numerator: int
73
+ denominator: int
74
+ method: str
75
+ confidence: float = 0.95
76
+
77
+ def as_dict(self) -> dict[str, Any]:
78
+ """Serialisable form, including the counts so a reader can recompute it."""
79
+ return {
80
+ "point": self.point,
81
+ "low": self.low,
82
+ "high": self.high,
83
+ "numerator": self.numerator,
84
+ "denominator": self.denominator,
85
+ "method": self.method,
86
+ "confidence": self.confidence,
87
+ }
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class MeanEstimate:
92
+ """A mean with a confidence interval, its spread, and the counts behind it.
93
+
94
+ :class:`Interval` carries a numerator and a denominator because it describes
95
+ a proportion. A mean standing has neither, and forcing one into those fields
96
+ would put a number in front of a reader that does not mean what it says.
97
+ """
98
+
99
+ point: float
100
+ low: float
101
+ high: float
102
+ sd: float
103
+ n_clusters: int
104
+ n_observations: int
105
+ method: str
106
+ confidence: float = 0.95
107
+
108
+ def as_dict(self) -> dict[str, Any]:
109
+ """Serialisable form, including the counts so a reader can recompute it."""
110
+ return {
111
+ "point": self.point,
112
+ "low": self.low,
113
+ "high": self.high,
114
+ "sd": self.sd,
115
+ "n_clusters": self.n_clusters,
116
+ "n_observations": self.n_observations,
117
+ "method": self.method,
118
+ "confidence": self.confidence,
119
+ }
120
+
121
+
122
+ def _z_for(confidence: float) -> float:
123
+ """Two-sided normal quantile, without pulling in scipy for one number."""
124
+ # Inverse of the standard normal CDF via the Beasley-Springer-Moro style
125
+ # rational approximation used by Acklam; accurate to ~1e-9 over (0, 1),
126
+ # which is far beyond what a confidence interval needs.
127
+ p = 1.0 - (1.0 - confidence) / 2.0
128
+ a = [
129
+ -3.969683028665376e01,
130
+ 2.209460984245205e02,
131
+ -2.759285104469687e02,
132
+ 1.383577518672690e02,
133
+ -3.066479806614716e01,
134
+ 2.506628277459239e00,
135
+ ]
136
+ b = [
137
+ -5.447609879822406e01,
138
+ 1.615858368580409e02,
139
+ -1.556989798598866e02,
140
+ 6.680131188771972e01,
141
+ -1.328068155288572e01,
142
+ ]
143
+ c = [
144
+ -7.784894002430293e-03,
145
+ -3.223964580411365e-01,
146
+ -2.400758277161838e00,
147
+ -2.549732539343734e00,
148
+ 4.374664141464968e00,
149
+ 2.938163982698783e00,
150
+ ]
151
+ d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e00, 3.754408661907416e00]
152
+ plow, phigh = 0.02425, 1 - 0.02425
153
+ if p < plow:
154
+ q = math.sqrt(-2 * math.log(p))
155
+ return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / (
156
+ (((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1
157
+ )
158
+ if p > phigh:
159
+ q = math.sqrt(-2 * math.log(1 - p))
160
+ return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / (
161
+ (((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1
162
+ )
163
+ q = p - 0.5
164
+ r = q * q
165
+ return (
166
+ (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5])
167
+ * q
168
+ / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1)
169
+ )
170
+
171
+
172
+ def wilson_interval(successes: int, trials: int, *, confidence: float = 0.95) -> Interval:
173
+ """Wilson score interval for a proportion.
174
+
175
+ The reported interval. Unlike a Wald interval it does not collapse to zero
176
+ width at 0 successes or at 100%, which is the situation this panel is most
177
+ likely to be in.
178
+
179
+ Raises:
180
+ ValueError: If ``trials`` is not positive or ``successes`` is out of range.
181
+ """
182
+ if trials <= 0:
183
+ raise ValueError("trials must be positive")
184
+ if not 0 <= successes <= trials:
185
+ raise ValueError(f"successes {successes} outside 0..{trials}")
186
+ z = _z_for(confidence)
187
+ n = float(trials)
188
+ p = successes / n
189
+ denom = 1.0 + z * z / n
190
+ centre = (p + z * z / (2 * n)) / denom
191
+ half = (z / denom) * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
192
+ return Interval(
193
+ point=p,
194
+ low=max(0.0, centre - half),
195
+ high=min(1.0, centre + half),
196
+ numerator=successes,
197
+ denominator=trials,
198
+ method="wilson",
199
+ confidence=confidence,
200
+ )
201
+
202
+
203
+ def clopper_pearson_interval(successes: int, trials: int, *, confidence: float = 0.95) -> Interval:
204
+ """Clopper-Pearson exact interval, the conservative cross-check.
205
+
206
+ Reported alongside Wilson so a reader can see the interval is not an artefact
207
+ of one approximation. Wider than Wilson by construction.
208
+
209
+ Raises:
210
+ ValueError: If ``trials`` is not positive or ``successes`` is out of range.
211
+ """
212
+ if trials <= 0:
213
+ raise ValueError("trials must be positive")
214
+ if not 0 <= successes <= trials:
215
+ raise ValueError(f"successes {successes} outside 0..{trials}")
216
+ from scipy import stats # lazy: only the reporting path needs scipy
217
+
218
+ alpha = 1.0 - confidence
219
+ low = (
220
+ 0.0
221
+ if successes == 0
222
+ else float(stats.beta.ppf(alpha / 2, successes, trials - successes + 1))
223
+ )
224
+ high = (
225
+ 1.0
226
+ if successes == trials
227
+ else float(stats.beta.ppf(1 - alpha / 2, successes + 1, trials - successes))
228
+ )
229
+ return Interval(
230
+ point=successes / trials,
231
+ low=low,
232
+ high=high,
233
+ numerator=successes,
234
+ denominator=trials,
235
+ method="clopper-pearson",
236
+ confidence=confidence,
237
+ )
238
+
239
+
240
+ def cluster_bootstrap_interval(
241
+ clusters: Mapping[str, Sequence[bool]],
242
+ *,
243
+ n_boot: int = 10_000,
244
+ confidence: float = 0.95,
245
+ seed: int = 0,
246
+ ) -> Interval:
247
+ """Percentile bootstrap over antigens, which are the independent unit.
248
+
249
+ One antigen can appear in several cohorts — ERBB2 in four, EGFR in four — so
250
+ the pairs are correlated and a binomial interval over all of them would
251
+ understate uncertainty. Resampling *antigens* with replacement, and taking
252
+ all of a resampled antigen's cohorts with it, respects that structure.
253
+
254
+ **Known limitation, and why the Wilson interval is still reported.** A
255
+ percentile bootstrap cannot express uncertainty it cannot resample. If every
256
+ outcome is a hit, every replicate is also all hits and the interval collapses
257
+ to exactly (1.0, 1.0) — which would be a false claim of certainty. The same
258
+ happens at zero hits. Those are precisely the cases a small panel lands in,
259
+ so the bootstrap is a *supplement* to :func:`wilson_interval`, never a
260
+ replacement, and ``degenerate`` marks any interval a reader must not quote
261
+ alone.
262
+
263
+ Args:
264
+ clusters: antigen accession -> its per-cohort outcomes (hit or miss).
265
+ n_boot: bootstrap replicates.
266
+ confidence: interval width.
267
+ seed: fixed so a published interval is reproducible.
268
+
269
+ Raises:
270
+ ValueError: If no clusters or no outcomes were supplied.
271
+ """
272
+ keys = [k for k, v in clusters.items() if len(v) > 0]
273
+ if not keys:
274
+ raise ValueError("no clusters with outcomes")
275
+ flat = [bool(o) for k in keys for o in clusters[k]]
276
+ if not flat:
277
+ raise ValueError("no outcomes")
278
+
279
+ rng = random.Random(seed)
280
+ estimates: list[float] = []
281
+ n_clusters = len(keys)
282
+ for _ in range(n_boot):
283
+ hits = total = 0
284
+ for _ in range(n_clusters):
285
+ drawn = clusters[keys[rng.randrange(n_clusters)]]
286
+ hits += sum(1 for o in drawn if o)
287
+ total += len(drawn)
288
+ if total:
289
+ estimates.append(hits / total)
290
+ estimates.sort()
291
+ alpha = 1.0 - confidence
292
+ lo_i = max(0, math.floor(alpha / 2 * len(estimates)))
293
+ hi_i = min(len(estimates) - 1, math.ceil((1 - alpha / 2) * len(estimates)) - 1)
294
+ low, high = estimates[lo_i], estimates[hi_i]
295
+ method = f"cluster-bootstrap(n_clusters={n_clusters}, B={n_boot})"
296
+ if low == high:
297
+ # No variation to resample: the data are all hits or all misses. Say so
298
+ # in the method string rather than letting a zero-width interval be read
299
+ # as certainty.
300
+ method += " [degenerate: no variation to resample; quote the Wilson interval]"
301
+ return Interval(
302
+ point=sum(flat) / len(flat),
303
+ low=low,
304
+ high=high,
305
+ numerator=sum(flat),
306
+ denominator=len(flat),
307
+ method=method,
308
+ confidence=confidence,
309
+ )
310
+
311
+
312
+ def cluster_bootstrap_mean(
313
+ clusters: Mapping[str, Sequence[float]],
314
+ *,
315
+ n_boot: int = 10_000,
316
+ confidence: float = 0.95,
317
+ seed: int = 0,
318
+ ) -> MeanEstimate:
319
+ """Percentile bootstrap of a mean, resampling clusters rather than values.
320
+
321
+ The continuous counterpart of :func:`cluster_bootstrap_interval`, and it
322
+ exists for the same reason: one antigen contributes a standing in several
323
+ cohorts, so the values are not independent draws and an interval built from
324
+ the values alone would be too narrow.
325
+
326
+ Each cluster contributes the mean of its own values, so a cluster measured
327
+ in ten cohorts does not outweigh one measured in two -- the antigen is the
328
+ unit, not the measurement.
329
+
330
+ Args:
331
+ clusters: cluster key -> its values (e.g. antigen -> per-cohort standings).
332
+ n_boot: bootstrap replicates.
333
+ confidence: interval width.
334
+ seed: fixed so a published interval is reproducible.
335
+
336
+ Raises:
337
+ ValueError: If no cluster carries a value.
338
+ """
339
+ keys = [k for k, v in clusters.items() if len(v) > 0]
340
+ if not keys:
341
+ raise ValueError("no clusters with values")
342
+
343
+ per_cluster = [sum(clusters[k]) / len(clusters[k]) for k in keys]
344
+ n_observations = sum(len(clusters[k]) for k in keys)
345
+ point = sum(per_cluster) / len(per_cluster)
346
+ sd = (
347
+ math.sqrt(sum((v - point) ** 2 for v in per_cluster) / (len(per_cluster) - 1))
348
+ if len(per_cluster) > 1
349
+ else 0.0
350
+ )
351
+
352
+ rng = random.Random(seed)
353
+ n_clusters = len(keys)
354
+ estimates = []
355
+ for _ in range(n_boot):
356
+ drawn = [per_cluster[rng.randrange(n_clusters)] for _ in range(n_clusters)]
357
+ estimates.append(sum(drawn) / n_clusters)
358
+ estimates.sort()
359
+ alpha = 1.0 - confidence
360
+ lo_i = max(0, math.floor(alpha / 2 * len(estimates)))
361
+ hi_i = min(len(estimates) - 1, math.ceil((1 - alpha / 2) * len(estimates)) - 1)
362
+ method = f"cluster-bootstrap-mean(n_clusters={n_clusters}, B={n_boot})"
363
+ if estimates[lo_i] == estimates[hi_i]:
364
+ # One cluster, or every cluster identical: nothing to resample. Said in
365
+ # the method string rather than left to read as certainty.
366
+ method += " [degenerate: no variation to resample]"
367
+ return MeanEstimate(
368
+ point=point,
369
+ low=estimates[lo_i],
370
+ high=estimates[hi_i],
371
+ sd=sd,
372
+ n_clusters=n_clusters,
373
+ n_observations=n_observations,
374
+ method=method,
375
+ confidence=confidence,
376
+ )
377
+
378
+
379
+ def uniform_rank_p(rank: int, n_eligible: int) -> float:
380
+ """One-sided p-value under a uniform-rank null.
381
+
382
+ ``n_eligible`` must be the size of the set the rank was taken *within* — for
383
+ bindsight that is the up-regulated eligible surfaceome, not the whole
384
+ surfaceome. Passing the whole set roughly halves the p-value and is
385
+ anticonservative, because roughly half of the significant genes are
386
+ down-regulated and were never in contention.
387
+
388
+ Raises:
389
+ ValueError: On a non-positive rank or eligible count, or a rank outside
390
+ the eligible set.
391
+ """
392
+ if rank < 1:
393
+ raise ValueError(f"rank must be >= 1, got {rank}")
394
+ if n_eligible < 1:
395
+ raise ValueError(f"n_eligible must be >= 1, got {n_eligible}")
396
+ if rank > n_eligible:
397
+ raise ValueError(f"rank {rank} exceeds the eligible set size {n_eligible}")
398
+ return rank / n_eligible
399
+
400
+
401
+ def decoy_null_p(
402
+ antigen_rank: int,
403
+ decoy_ranks: Sequence[int],
404
+ ) -> float:
405
+ """One-sided p-value against abundance-matched decoys — the primary null.
406
+
407
+ Each decoy is a surface protein matched to the antigen on expression
408
+ abundance and dispersion, pushed through the identical filter cascade and
409
+ ranking. The p-value is the fraction of decoys that ranked at least as well,
410
+ with the standard add-one correction so it can never be reported as exactly
411
+ zero on a finite number of draws.
412
+
413
+ Raises:
414
+ ValueError: If no decoys were supplied.
415
+ """
416
+ if not decoy_ranks:
417
+ raise ValueError("decoy null needs at least one decoy")
418
+ at_least_as_good = sum(1 for r in decoy_ranks if r <= antigen_rank)
419
+ return (1 + at_least_as_good) / (1 + len(decoy_ranks))
420
+
421
+
422
+ def match_decoys(
423
+ candidates: Sequence[dict[str, Any]],
424
+ target: dict[str, Any],
425
+ *,
426
+ n_decoys: int = 1000,
427
+ strata: Sequence[str] = ("base_mean_stratum", "dispersion_stratum"),
428
+ seed: int = 0,
429
+ ) -> list[dict[str, Any]]:
430
+ """Draw decoys from the same abundance and dispersion strata as ``target``.
431
+
432
+ Sampling is without replacement while the stratum can supply it, then with
433
+ replacement, so a thin stratum degrades gracefully instead of silently
434
+ returning too few decoys. The caller is expected to report the achieved match
435
+ quality rather than assume it.
436
+
437
+ Raises:
438
+ ValueError: If the strata columns are missing from ``target``.
439
+ """
440
+ missing = [s for s in strata if s not in target]
441
+ if missing:
442
+ raise ValueError(f"target is missing strata columns: {missing}")
443
+ key = tuple(target[s] for s in strata)
444
+ pool = [
445
+ c
446
+ for c in candidates
447
+ if all(s in c for s in strata)
448
+ and tuple(c[s] for s in strata) == key
449
+ and c.get("uniprot") != target.get("uniprot")
450
+ ]
451
+ if not pool:
452
+ return []
453
+ rng = random.Random(seed)
454
+ if len(pool) >= n_decoys:
455
+ return rng.sample(pool, n_decoys)
456
+ return [pool[rng.randrange(len(pool))] for _ in range(n_decoys)]
457
+
458
+
459
+ def permutation_null_p(
460
+ scores: dict[str, dict[str, float]],
461
+ assignment: dict[str, str],
462
+ *,
463
+ n_perm: int = 10_000,
464
+ seed: int = 0,
465
+ higher_is_better: bool = True,
466
+ exact_limit: int = 200_000,
467
+ ) -> dict[str, Any]:
468
+ """Panel-level p-value for indication specificity.
469
+
470
+ Asks whether bindsight matches antigens to the *right* cancers, rather than
471
+ surfacing generic epithelial biology that happens to contain them. The
472
+ antigen-to-cohort assignment is permuted and the panel statistic recomputed.
473
+ No differential expression is re-run, so this is cheap.
474
+
475
+ **The null permutes the assignment; it does not resample cohorts.** This
476
+ drew each antigen a *distinct* cohort from the whole panel, which put the
477
+ observation outside the null's support the moment two antigens shared an
478
+ indication — and two do. FOLH1 and STEAP1 are both single-indication and
479
+ both TCGA-PRAD, so the observed statistic counts prostate twice while no
480
+ draw from ``sample`` ever could. Both score near the top there, so the
481
+ observed statistic was systematically larger than anything the null could
482
+ produce, and the p-value was not a tail probability of it under any
483
+ distribution the code sampled. It reached the reporting floor, which is
484
+ exactly what that failure looks like.
485
+
486
+ Shuffling which antigen receives which of the *observed* cohorts fixes the
487
+ support — the observation is the identity permutation, so it is always in
488
+ the null — and controls for cohort difficulty as a side effect: every
489
+ permutation uses the same cohorts the observation did, so a cohort whose
490
+ standings run high cannot inflate one arm and not the other.
491
+
492
+ Enumerated exactly when the antigen count allows, because a p-value that
493
+ could be exact should not carry Monte Carlo error or a sampling floor.
494
+
495
+ Args:
496
+ scores: antigen -> {cohort -> score}. Must be complete: a missing score
497
+ is an antigen that was never evaluated in that cohort, which would
498
+ bias the permutation.
499
+ assignment: antigen -> its own indication. The observed statistic is
500
+ computed from this rather than passed in, so the observation and
501
+ the null cannot be built from different rules.
502
+ n_perm: permutations to sample when exact enumeration is too large.
503
+ seed: fixed for reproducibility.
504
+ higher_is_better: whether a larger statistic is a better result.
505
+ exact_limit: enumerate when ``n!`` is at most this.
506
+
507
+ Returns:
508
+ ``observed``, ``p_value``, ``p_value_floor``, ``n_permutations`` and
509
+ ``exact``.
510
+
511
+ Raises:
512
+ ValueError: If fewer than two antigens are supplied, the score matrix is
513
+ ragged, or an assignment names a cohort or antigen not in ``scores``.
514
+ """
515
+ antigens = sorted(scores)
516
+ if len(antigens) < 2:
517
+ raise ValueError("permutation null needs at least two antigens")
518
+ cohorts = sorted({c for row in scores.values() for c in row})
519
+ ragged = [a for a in antigens if set(scores[a]) != set(cohorts)]
520
+ if ragged:
521
+ raise ValueError(
522
+ "score matrix is ragged; every antigen must be scored in every cohort. "
523
+ f"incomplete: {ragged[:5]}"
524
+ )
525
+ if set(assignment) != set(antigens):
526
+ raise ValueError(
527
+ "every antigen must have exactly one assigned cohort; "
528
+ f"mismatch: {sorted(set(antigens) ^ set(assignment))[:5]}"
529
+ )
530
+ unknown = sorted({c for c in assignment.values() if c not in set(cohorts)})
531
+ if unknown:
532
+ raise ValueError(f"assigned cohorts absent from the score matrix: {unknown}")
533
+
534
+ # The multiset the observation actually used. Repeats are kept: two antigens
535
+ # sharing an indication is a property of the panel, not an error, and
536
+ # removing it is what put the observation outside the null before.
537
+ assigned = [assignment[a] for a in antigens]
538
+ n = len(antigens)
539
+ observed = sum(scores[a][c] for a, c in zip(antigens, assigned, strict=True)) / n
540
+
541
+ def _stat(order: Sequence[int]) -> float:
542
+ return sum(scores[antigens[i]][assigned[j]] for j, i in enumerate(order)) / n
543
+
544
+ def _as_extreme(stat: float) -> bool:
545
+ # Tolerance, not equality: permutations that only swap two antigens
546
+ # sharing a cohort reproduce the observed statistic through a different
547
+ # summation order, and floating point does not guarantee the same bits.
548
+ return (stat >= observed - 1e-12) if higher_is_better else (stat <= observed + 1e-12)
549
+
550
+ total = math.factorial(n)
551
+ if total <= exact_limit:
552
+ extreme = sum(1 for order in itertools.permutations(range(n)) if _as_extreme(_stat(order)))
553
+ # Exact: the identity permutation is always counted, so this can never
554
+ # be zero and needs no add-one correction.
555
+ #
556
+ # But the identity is not counted *once*. Antigens sharing a cohort are
557
+ # interchangeable, so each distinct assignment is reproduced by one
558
+ # permutation per ordering within each shared cohort -- the product of
559
+ # the factorials of the cohort multiplicities. With seven antigens of
560
+ # which two are prostate, every assignment appears twice, and the
561
+ # smallest p this design can express is 2/5040, not 1/5040.
562
+ #
563
+ # Reporting 1/total understated the floor by exactly that factor, and
564
+ # because the panel's observed p sat on the true floor, the report's
565
+ # own "p equals its floor" warning -- built for precisely this case --
566
+ # never fired on the one result that needed it.
567
+ repeats = math.prod(math.factorial(k) for k in Counter(assigned).values())
568
+ return {
569
+ "observed": observed,
570
+ "p_value": extreme / total,
571
+ "p_value_floor": repeats / total,
572
+ "n_permutations": total,
573
+ "exact": True,
574
+ }
575
+
576
+ rng = random.Random(seed)
577
+ order = list(range(n))
578
+ extreme = 0
579
+ for _ in range(n_perm):
580
+ rng.shuffle(order)
581
+ if _as_extreme(_stat(order)):
582
+ extreme += 1
583
+ return {
584
+ "observed": observed,
585
+ "p_value": (1 + extreme) / (1 + n_perm),
586
+ "p_value_floor": 1.0 / (1 + n_perm),
587
+ "n_permutations": n_perm,
588
+ "exact": False,
589
+ }
590
+
591
+
592
+ def benjamini_hochberg(p_values: Sequence[float]) -> list[float]:
593
+ """Benjamini-Hochberg adjusted p-values, order preserved.
594
+
595
+ Reported alongside raw p-values because the panel tests roughly twenty
596
+ hypotheses at once, and a single nominally significant result among twenty is
597
+ not news.
598
+
599
+ Raises:
600
+ ValueError: If any p-value is outside [0, 1].
601
+ """
602
+ if any(not 0.0 <= p <= 1.0 for p in p_values):
603
+ raise ValueError("p-values must lie in [0, 1]")
604
+ n = len(p_values)
605
+ if n == 0:
606
+ return []
607
+ order = sorted(range(n), key=lambda i: p_values[i])
608
+ adjusted = [0.0] * n
609
+ running = 1.0
610
+ for rank, idx in enumerate(reversed(order), start=1):
611
+ i = n - rank + 1
612
+ running = min(running, p_values[idx] * n / i)
613
+ # Never below the raw value. Every term in the running minimum is
614
+ # p_(j) * n / j with j >= i, and n / j >= 1, so the exact adjusted
615
+ # p-value is always at least p_(i); this is arithmetic, not a fudge
616
+ # factor. In floating point the largest p-value's own term, p * n / n,
617
+ # can land one unit in the last place below p — observed here as an
618
+ # adjusted 0.8170212765957445 against a raw 0.8170212765957446 — and a
619
+ # corrected p-value printed below the one it corrects is wrong however
620
+ # small the margin.
621
+ adjusted[idx] = max(running, p_values[idx])
622
+ return adjusted