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,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
|