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
bindsight/cost.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""GPU cost estimator.
|
|
4
|
+
|
|
5
|
+
Powers ``bindsight design --dry-run`` and ``bindsight run --dry-run``. Returns
|
|
6
|
+
a :class:`bindsight.runners.CostEstimate` for any (backend, designer/validator,
|
|
7
|
+
spec_size) tuple WITHOUT launching anything.
|
|
8
|
+
|
|
9
|
+
The pricing tables here are conservative point-in-time values (see
|
|
10
|
+
:data:`PRICE_TABLE_VERSION`). They are NOT a quote — actual cloud pricing
|
|
11
|
+
varies with region, spot vs on-demand, and provider promotions. The cost
|
|
12
|
+
shown by ``--dry-run`` is an order-of-magnitude estimate to help users
|
|
13
|
+
decide whether to proceed, not an invoice.
|
|
14
|
+
|
|
15
|
+
To update prices: bump :data:`PRICE_TABLE_VERSION` and edit
|
|
16
|
+
:data:`GPU_PRICE_USD_PER_HOUR`. Tests pin specific values and will catch
|
|
17
|
+
unintended drift.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
from bindsight.runners.protocol import CostEstimate
|
|
26
|
+
|
|
27
|
+
# Bumped any time the pricing or timing tables change.
|
|
28
|
+
PRICE_TABLE_VERSION = "2026.05"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Pricing — USD per GPU-hour, on-demand list price as of PRICE_TABLE_VERSION
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Free-tier providers map to 0.0; users still pay in queue time and quota.
|
|
35
|
+
GPU_PRICE_USD_PER_HOUR: dict[tuple[str, str], float] = {
|
|
36
|
+
# backend, gpu_type USD/hr
|
|
37
|
+
("modal", "T4"): 0.59,
|
|
38
|
+
("modal", "L4"): 0.75,
|
|
39
|
+
("modal", "A10G"): 1.10,
|
|
40
|
+
("modal", "A100-40GB"): 3.09,
|
|
41
|
+
("modal", "A100-80GB"): 3.50,
|
|
42
|
+
("modal", "H100"): 4.56,
|
|
43
|
+
("colab", "T4"): 0.00, # Colab free
|
|
44
|
+
("colab", "A100-40GB"): 1.40, # Colab Pro+ compute units, amortized
|
|
45
|
+
("kaggle", "T4"): 0.00, # Kaggle free
|
|
46
|
+
("kaggle", "P100"): 0.00,
|
|
47
|
+
("local_docker", "T4"): 0.00,
|
|
48
|
+
("local_docker", "A100-40GB"): 0.00,
|
|
49
|
+
("local_docker", "RTX4090"): 0.00,
|
|
50
|
+
("mock", "mock"): 0.00,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Per-designer / per-validator GPU-time estimates
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Time PER trajectory or PER design, in seconds, on a reference GPU.
|
|
58
|
+
# These are rough estimates from the upstream papers + community reports.
|
|
59
|
+
# Sources noted inline so we can revise when better data lands.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class _Timing:
|
|
64
|
+
seconds_per_unit: float
|
|
65
|
+
reference_gpu: str
|
|
66
|
+
notes: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_DESIGNER_TIMING: dict[str, _Timing] = {
|
|
70
|
+
# RFdiffusion + ProteinMPNN: backbone diffusion ~30s, sequence design ~2s/seq.
|
|
71
|
+
# We bundle them into one trajectory unit for simplicity.
|
|
72
|
+
"rfdiff_mpnn": _Timing(
|
|
73
|
+
seconds_per_unit=45.0,
|
|
74
|
+
reference_gpu="A100-40GB",
|
|
75
|
+
notes="RFdiffusion ~30s + ProteinMPNN ~2s/seq, on A100. Free Colab T4 takes ~3-5x longer.",
|
|
76
|
+
),
|
|
77
|
+
# BindCraft: published ~3-5 min/trajectory on A100, requires >=32GB VRAM.
|
|
78
|
+
"bindcraft": _Timing(
|
|
79
|
+
seconds_per_unit=240.0,
|
|
80
|
+
reference_gpu="A100-40GB",
|
|
81
|
+
notes="BindCraft Nature 2025 paper reports ~3-5 min/trajectory on A100. Needs >=32GB VRAM.",
|
|
82
|
+
),
|
|
83
|
+
# BoltzGen: similar order to RFdiff+MPNN, slightly faster per unit.
|
|
84
|
+
"boltzgen": _Timing(
|
|
85
|
+
seconds_per_unit=40.0,
|
|
86
|
+
reference_gpu="A100-40GB",
|
|
87
|
+
notes="BoltzGen 2025 release. Approximate; will refine after benchmark in v0.2 paper.",
|
|
88
|
+
),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_VALIDATOR_TIMING: dict[str, _Timing] = {
|
|
93
|
+
"boltz2": _Timing(
|
|
94
|
+
seconds_per_unit=20.0,
|
|
95
|
+
reference_gpu="A100-40GB",
|
|
96
|
+
notes="Boltz-2 README claims ~seconds-minutes per complex on A100. 20s/design conservative.",
|
|
97
|
+
),
|
|
98
|
+
"chai1r": _Timing(
|
|
99
|
+
seconds_per_unit=25.0,
|
|
100
|
+
reference_gpu="A100-40GB",
|
|
101
|
+
notes="Chai-1r similar to Boltz-2 in scale.",
|
|
102
|
+
),
|
|
103
|
+
"af2_ig": _Timing(
|
|
104
|
+
seconds_per_unit=120.0,
|
|
105
|
+
reference_gpu="A100-40GB",
|
|
106
|
+
notes="AF2 with initial guess is slower than single-step models.",
|
|
107
|
+
),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Approximate scaling factor when running on a slower GPU than the reference.
|
|
112
|
+
# Applied multiplicatively to the per-unit time.
|
|
113
|
+
_GPU_SLOWDOWN: dict[str, float] = {
|
|
114
|
+
"T4": 4.0,
|
|
115
|
+
"L4": 2.5,
|
|
116
|
+
"A10G": 2.0,
|
|
117
|
+
"P100": 3.0,
|
|
118
|
+
"A100-40GB": 1.0,
|
|
119
|
+
"A100-80GB": 1.0,
|
|
120
|
+
"H100": 0.6,
|
|
121
|
+
"RTX4090": 1.5,
|
|
122
|
+
"mock": 0.0,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Default GPU per backend if the user doesn't specify one.
|
|
127
|
+
_DEFAULT_GPU: dict[str, str] = {
|
|
128
|
+
"modal": "A100-40GB",
|
|
129
|
+
"colab": "T4", # free tier
|
|
130
|
+
"kaggle": "T4",
|
|
131
|
+
"local_docker": "A100-40GB", # arbitrary; user knows their hardware
|
|
132
|
+
"mock": "mock",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Approximate queue waits in minutes (free tiers are queue-prone).
|
|
137
|
+
_QUEUE_WAIT_MIN: dict[str, float] = {
|
|
138
|
+
"modal": 0.5, # cold start
|
|
139
|
+
"colab": 2.0, # free tier; Pro is faster
|
|
140
|
+
"kaggle": 3.0, # free tier
|
|
141
|
+
"local_docker": 0.0,
|
|
142
|
+
"mock": 0.0,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Public API
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
StageKind = Literal["design", "validate"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def estimate(
|
|
153
|
+
*,
|
|
154
|
+
backend: str,
|
|
155
|
+
stage: StageKind,
|
|
156
|
+
plugin: str,
|
|
157
|
+
n_units: int,
|
|
158
|
+
gpu_type: str | None = None,
|
|
159
|
+
) -> CostEstimate:
|
|
160
|
+
"""Estimate GPU cost for a stage.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
backend: ``"colab"`` | ``"modal"`` | ``"kaggle"`` | ``"local_docker"`` | ``"mock"``.
|
|
164
|
+
stage: ``"design"`` (cost per design trajectory) or ``"validate"``
|
|
165
|
+
(cost per design validated).
|
|
166
|
+
plugin: designer or validator name (e.g. ``"rfdiff_mpnn"``, ``"boltz2"``).
|
|
167
|
+
n_units: number of trajectories (design) or designs (validate).
|
|
168
|
+
gpu_type: override the default GPU for the backend.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
ValueError: if ``backend``/``plugin``/``gpu_type`` is unknown.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A :class:`CostEstimate` with the projected GPU-hours and USD.
|
|
175
|
+
"""
|
|
176
|
+
if backend not in _DEFAULT_GPU:
|
|
177
|
+
raise ValueError(f"unknown backend: {backend}")
|
|
178
|
+
|
|
179
|
+
chosen_gpu = gpu_type or _DEFAULT_GPU[backend]
|
|
180
|
+
if chosen_gpu not in _GPU_SLOWDOWN:
|
|
181
|
+
raise ValueError(f"unknown GPU type: {chosen_gpu}. Known: {sorted(_GPU_SLOWDOWN)}")
|
|
182
|
+
|
|
183
|
+
timing_table = _DESIGNER_TIMING if stage == "design" else _VALIDATOR_TIMING
|
|
184
|
+
if plugin not in timing_table:
|
|
185
|
+
raise ValueError(f"unknown {stage} plugin: {plugin}. Known: {sorted(timing_table)}")
|
|
186
|
+
timing = timing_table[plugin]
|
|
187
|
+
|
|
188
|
+
if chosen_gpu == "mock":
|
|
189
|
+
seconds = 0.0
|
|
190
|
+
else:
|
|
191
|
+
slowdown = _GPU_SLOWDOWN[chosen_gpu]
|
|
192
|
+
seconds = timing.seconds_per_unit * slowdown * n_units
|
|
193
|
+
|
|
194
|
+
gpu_hours = seconds / 3600.0
|
|
195
|
+
price_per_hour = GPU_PRICE_USD_PER_HOUR.get((backend, chosen_gpu))
|
|
196
|
+
if price_per_hour is None:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"no price entry for ({backend}, {chosen_gpu}) in PRICE_TABLE_VERSION={PRICE_TABLE_VERSION}"
|
|
199
|
+
)
|
|
200
|
+
usd = round(gpu_hours * price_per_hour, 2)
|
|
201
|
+
queue = _QUEUE_WAIT_MIN.get(backend, 0.0)
|
|
202
|
+
|
|
203
|
+
return CostEstimate(
|
|
204
|
+
backend=backend,
|
|
205
|
+
gpu_type=chosen_gpu,
|
|
206
|
+
gpu_hours=round(gpu_hours, 3),
|
|
207
|
+
usd_estimate=usd if price_per_hour > 0 else 0.0,
|
|
208
|
+
queue_minutes_estimate=queue,
|
|
209
|
+
notes=(
|
|
210
|
+
f"{plugin}: {timing.seconds_per_unit:.0f}s/unit on {timing.reference_gpu} "
|
|
211
|
+
f"× slowdown={_GPU_SLOWDOWN[chosen_gpu]:.1f} × n={n_units}. "
|
|
212
|
+
f"Pricing table {PRICE_TABLE_VERSION}. {timing.notes}"
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def estimate_full_run(
|
|
218
|
+
*,
|
|
219
|
+
backend: str,
|
|
220
|
+
designer: str,
|
|
221
|
+
validator: str,
|
|
222
|
+
n_targets: int,
|
|
223
|
+
n_trajectories: int,
|
|
224
|
+
gpu_type: str | None = None,
|
|
225
|
+
) -> tuple[CostEstimate, CostEstimate, CostEstimate]:
|
|
226
|
+
"""Estimate cost for a full design + validate pass.
|
|
227
|
+
|
|
228
|
+
Returns ``(design_cost, validate_cost, combined_cost)``.
|
|
229
|
+
"""
|
|
230
|
+
n_design_units = n_targets * n_trajectories
|
|
231
|
+
n_validate_units = n_design_units # validate one structure per design
|
|
232
|
+
|
|
233
|
+
d = estimate(
|
|
234
|
+
backend=backend,
|
|
235
|
+
stage="design",
|
|
236
|
+
plugin=designer,
|
|
237
|
+
n_units=n_design_units,
|
|
238
|
+
gpu_type=gpu_type,
|
|
239
|
+
)
|
|
240
|
+
v = estimate(
|
|
241
|
+
backend=backend,
|
|
242
|
+
stage="validate",
|
|
243
|
+
plugin=validator,
|
|
244
|
+
n_units=n_validate_units,
|
|
245
|
+
gpu_type=gpu_type,
|
|
246
|
+
)
|
|
247
|
+
combined = CostEstimate(
|
|
248
|
+
backend=backend,
|
|
249
|
+
gpu_type=d.gpu_type,
|
|
250
|
+
gpu_hours=round(d.gpu_hours + v.gpu_hours, 3),
|
|
251
|
+
usd_estimate=round((d.usd_estimate or 0.0) + (v.usd_estimate or 0.0), 2),
|
|
252
|
+
queue_minutes_estimate=d.queue_minutes_estimate,
|
|
253
|
+
notes=(
|
|
254
|
+
f"combined: design ({n_design_units} units of {designer}) + "
|
|
255
|
+
f"validate ({n_validate_units} units of {validator})"
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
return d, v, combined
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""pydeseq2 wrapper.
|
|
4
|
+
|
|
5
|
+
Wraps `pydeseq2 <https://github.com/scverse/PyDESeq2>`_ (MIT) so the rest of
|
|
6
|
+
the pipeline gets a clean, Pydantic-validated DEG result regardless of which
|
|
7
|
+
backend ran.
|
|
8
|
+
|
|
9
|
+
.. note::
|
|
10
|
+
pydeseq2 is **not bit-equivalent** to R's DESeq2. Numeric results agree to
|
|
11
|
+
within machine precision for default settings on the supported test
|
|
12
|
+
matrices, but any user expecting cross-paper-equivalent statistics should
|
|
13
|
+
use the optional R-bridge runner. See LICENSING.md for the GPL caveat.
|
|
14
|
+
|
|
15
|
+
Output schema (Parquet):
|
|
16
|
+
|
|
17
|
+
========== ========= ==========================================================
|
|
18
|
+
column dtype meaning
|
|
19
|
+
========== ========= ==========================================================
|
|
20
|
+
gene_id str feature ID as it appears in the counts matrix index
|
|
21
|
+
log2fc float64 log2 fold-change for the configured contrast
|
|
22
|
+
lfc_se float64 standard error of log2fc
|
|
23
|
+
stat float64 Wald statistic
|
|
24
|
+
pvalue float64 raw p-value
|
|
25
|
+
padj float64 FDR-adjusted p-value (Benjamini-Hochberg)
|
|
26
|
+
baseMean float64 mean of normalized counts across all samples
|
|
27
|
+
contrast str e.g. ``condition__tumor_vs_normal``
|
|
28
|
+
significant bool padj < params.fdr_threshold AND |log2fc| ≥ log2fc_threshold
|
|
29
|
+
========== ========= ==========================================================
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
import pandas as pd
|
|
40
|
+
|
|
41
|
+
from bindsight.config import DEGParams
|
|
42
|
+
|
|
43
|
+
LOG = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PyDESeq2Runner:
|
|
47
|
+
"""Run pydeseq2 against a counts matrix + sample design."""
|
|
48
|
+
|
|
49
|
+
name = "pydeseq2"
|
|
50
|
+
|
|
51
|
+
def __init__(self, params: DEGParams) -> None:
|
|
52
|
+
self.params = params
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------ #
|
|
55
|
+
# I/O #
|
|
56
|
+
# ------------------------------------------------------------------ #
|
|
57
|
+
@staticmethod
|
|
58
|
+
def load_counts(path: Path) -> pd.DataFrame:
|
|
59
|
+
"""Read a counts TSV (gene × sample, integer counts)."""
|
|
60
|
+
return pd.read_csv(
|
|
61
|
+
path,
|
|
62
|
+
sep="\t",
|
|
63
|
+
index_col=0,
|
|
64
|
+
compression="infer",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def load_design(path: Path) -> pd.DataFrame:
|
|
69
|
+
"""Read a sample design TSV. The first column must be the sample ID."""
|
|
70
|
+
return pd.read_csv(path, sep="\t", index_col=0)
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------ #
|
|
73
|
+
# Validation #
|
|
74
|
+
# ------------------------------------------------------------------ #
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _require_integer_counts(counts: pd.DataFrame) -> None:
|
|
77
|
+
"""Reject a counts matrix holding non-integer values.
|
|
78
|
+
|
|
79
|
+
DESeq2's negative-binomial model is defined on raw counts. A TPM/FPKM/
|
|
80
|
+
normalised matrix silently truncates toward zero on the integer cast
|
|
81
|
+
(0.7 → 0, 3.9 → 3), after which the test still returns confident
|
|
82
|
+
p-values that mean nothing. Integral floats (``5.0``) are accepted —
|
|
83
|
+
count matrices routinely round-trip through float dtypes. Non-finite
|
|
84
|
+
values are left to the cast, which rejects them on its own.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
counts: Gene × sample matrix as read from disk.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ValueError: If any value is finite but not integral, naming a few
|
|
91
|
+
offending gene/sample cells.
|
|
92
|
+
"""
|
|
93
|
+
values = np.asarray(counts.to_numpy(), dtype=np.float64)
|
|
94
|
+
non_integer = np.isfinite(values) & (values != np.trunc(values))
|
|
95
|
+
if not bool(non_integer.any()):
|
|
96
|
+
return
|
|
97
|
+
rows, cols = np.nonzero(non_integer)
|
|
98
|
+
examples = ", ".join(
|
|
99
|
+
f"{counts.index[i]}/{counts.columns[j]}={values[i, j]:g}"
|
|
100
|
+
for i, j in zip(rows[:3], cols[:3], strict=True)
|
|
101
|
+
)
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"counts matrix contains non-integer values — DESeq2 requires raw integer "
|
|
104
|
+
"counts, not TPM/FPKM/normalised expression; "
|
|
105
|
+
f"{int(non_integer.sum())} of {values.size} values are non-integer "
|
|
106
|
+
f"(e.g. {examples})"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _require_replicated_contrast_levels(self, design: pd.DataFrame) -> None:
|
|
110
|
+
"""Reject a design whose contrast levels are not both replicated.
|
|
111
|
+
|
|
112
|
+
The total-sample check upstream is blind to how the samples split: a
|
|
113
|
+
5-tumour / 1-normal cohort clears ``2 * min_replicates`` yet leaves
|
|
114
|
+
DESeq2 estimating a within-group dispersion from a single sample.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
design: Sample design restricted to the samples actually analysed.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ValueError: If the contrast factor is absent, or either contrast
|
|
121
|
+
level has fewer than ``min_replicates`` samples.
|
|
122
|
+
"""
|
|
123
|
+
factor, numerator, denominator = self.params.contrast
|
|
124
|
+
if factor not in design.columns:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"contrast factor {factor!r} is not a column of the design "
|
|
127
|
+
f"(columns: {sorted(map(str, design.columns))})"
|
|
128
|
+
)
|
|
129
|
+
per_level = design[factor].astype(str).value_counts()
|
|
130
|
+
for level in (numerator, denominator):
|
|
131
|
+
n_level = int(per_level.get(level, 0))
|
|
132
|
+
if n_level < self.params.min_replicates:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"contrast level {factor}={level!r} has {n_level} sample(s); "
|
|
135
|
+
f"need at least min_replicates={self.params.min_replicates} per level "
|
|
136
|
+
"for a valid dispersion estimate"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------ #
|
|
140
|
+
# Filtering #
|
|
141
|
+
# ------------------------------------------------------------------ #
|
|
142
|
+
def _low_count_filter(self, counts: pd.DataFrame) -> pd.DataFrame:
|
|
143
|
+
"""Drop genes with fewer than ``min_count`` counts in ``min_replicates`` samples."""
|
|
144
|
+
keep = (counts >= self.params.min_count).sum(axis=1) >= self.params.min_replicates
|
|
145
|
+
n_dropped = int((~keep).sum())
|
|
146
|
+
if n_dropped:
|
|
147
|
+
LOG.info(
|
|
148
|
+
"low-count filter: dropping %d / %d genes (min_count=%d, min_replicates=%d)",
|
|
149
|
+
n_dropped,
|
|
150
|
+
len(counts),
|
|
151
|
+
self.params.min_count,
|
|
152
|
+
self.params.min_replicates,
|
|
153
|
+
)
|
|
154
|
+
return counts.loc[keep]
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------ #
|
|
157
|
+
# Run #
|
|
158
|
+
# ------------------------------------------------------------------ #
|
|
159
|
+
def run(
|
|
160
|
+
self,
|
|
161
|
+
counts_path: Path,
|
|
162
|
+
design_path: Path,
|
|
163
|
+
out_path: Path,
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
"""Run pydeseq2 and write the DEG table as Parquet to ``out_path``.
|
|
166
|
+
|
|
167
|
+
Returns a metrics dict suitable for embedding in the manifest's
|
|
168
|
+
``params``/``notes`` field.
|
|
169
|
+
"""
|
|
170
|
+
counts = self.load_counts(counts_path)
|
|
171
|
+
design = self.load_design(design_path)
|
|
172
|
+
|
|
173
|
+
# Sanity-check sample alignment.
|
|
174
|
+
common = counts.columns.intersection(design.index)
|
|
175
|
+
missing_in_design = set(counts.columns) - set(common)
|
|
176
|
+
missing_in_counts = set(design.index) - set(common)
|
|
177
|
+
if missing_in_design:
|
|
178
|
+
LOG.warning("samples in counts but not design: %s", sorted(missing_in_design))
|
|
179
|
+
if missing_in_counts:
|
|
180
|
+
LOG.warning("samples in design but not counts: %s", sorted(missing_in_counts))
|
|
181
|
+
if len(common) < 2 * self.params.min_replicates:
|
|
182
|
+
raise ValueError(
|
|
183
|
+
f"only {len(common)} samples in common between counts and design; "
|
|
184
|
+
f"need at least {2 * self.params.min_replicates}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
counts = counts[common]
|
|
188
|
+
design = design.loc[common]
|
|
189
|
+
|
|
190
|
+
self._require_integer_counts(counts)
|
|
191
|
+
self._require_replicated_contrast_levels(design)
|
|
192
|
+
|
|
193
|
+
# Filter low-count genes BEFORE pydeseq2 (faster + more stable estimates).
|
|
194
|
+
counts = self._low_count_filter(counts)
|
|
195
|
+
|
|
196
|
+
results_df = self._run_pydeseq2(counts, design)
|
|
197
|
+
results_df = self._postprocess(results_df)
|
|
198
|
+
|
|
199
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
results_df.to_parquet(out_path, index=False)
|
|
201
|
+
|
|
202
|
+
n_sig = int(results_df["significant"].sum())
|
|
203
|
+
LOG.info(
|
|
204
|
+
"wrote %s: %d genes tested, %d significant (FDR<%g, |log2fc|≥%g)",
|
|
205
|
+
out_path,
|
|
206
|
+
len(results_df),
|
|
207
|
+
n_sig,
|
|
208
|
+
self.params.fdr_threshold,
|
|
209
|
+
self.params.log2fc_threshold,
|
|
210
|
+
)
|
|
211
|
+
return {
|
|
212
|
+
"n_samples": len(common),
|
|
213
|
+
"n_genes_tested": len(results_df),
|
|
214
|
+
"n_significant": n_sig,
|
|
215
|
+
"fdr_threshold": self.params.fdr_threshold,
|
|
216
|
+
"log2fc_threshold": self.params.log2fc_threshold,
|
|
217
|
+
"n_cpus": self.params.n_cpus,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# ------------------------------------------------------------------ #
|
|
221
|
+
# pydeseq2 invocation (split out so tests can mock it) #
|
|
222
|
+
# ------------------------------------------------------------------ #
|
|
223
|
+
def _run_pydeseq2(self, counts: pd.DataFrame, design: pd.DataFrame) -> pd.DataFrame:
|
|
224
|
+
"""Invoke pydeseq2; returns the raw results_df keyed by gene ID."""
|
|
225
|
+
# Lazy-import so the module loads cleanly even if pydeseq2 isn't installed.
|
|
226
|
+
from pydeseq2.dds import DeseqDataSet
|
|
227
|
+
from pydeseq2.default_inference import DefaultInference
|
|
228
|
+
from pydeseq2.ds import DeseqStats
|
|
229
|
+
|
|
230
|
+
# pydeseq2 wants samples × genes (rows = samples).
|
|
231
|
+
counts_t = counts.T.astype(int)
|
|
232
|
+
dds = DeseqDataSet(
|
|
233
|
+
counts=counts_t,
|
|
234
|
+
metadata=design,
|
|
235
|
+
design=self.params.design_formula,
|
|
236
|
+
refit_cooks=True,
|
|
237
|
+
# n_cpus=None lets pydeseq2 use every core, which is correct on a
|
|
238
|
+
# server and hostile on a laptop running a multi-hour cohort sweep.
|
|
239
|
+
inference=DefaultInference(n_cpus=self.params.n_cpus),
|
|
240
|
+
quiet=True,
|
|
241
|
+
)
|
|
242
|
+
dds.deseq2()
|
|
243
|
+
|
|
244
|
+
# alpha drives pydeseq2's independent filtering, so it must be the FDR the
|
|
245
|
+
# user configured — otherwise the filter is tuned for a threshold nobody asked for.
|
|
246
|
+
ds = DeseqStats(
|
|
247
|
+
dds,
|
|
248
|
+
contrast=self.params.contrast,
|
|
249
|
+
alpha=self.params.fdr_threshold,
|
|
250
|
+
quiet=True,
|
|
251
|
+
)
|
|
252
|
+
ds.summary()
|
|
253
|
+
return ds.results_df
|
|
254
|
+
|
|
255
|
+
# ------------------------------------------------------------------ #
|
|
256
|
+
# Postprocess: standardise column names + add ``significant`` #
|
|
257
|
+
# ------------------------------------------------------------------ #
|
|
258
|
+
def _postprocess(self, df: pd.DataFrame) -> pd.DataFrame:
|
|
259
|
+
# pydeseq2's columns: log2FoldChange, lfcSE, stat, pvalue, padj, baseMean
|
|
260
|
+
out = df.rename(
|
|
261
|
+
columns={
|
|
262
|
+
"log2FoldChange": "log2fc",
|
|
263
|
+
"lfcSE": "lfc_se",
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
out = out.reset_index().rename(columns={"index": "gene_id"})
|
|
267
|
+
contrast_label = (
|
|
268
|
+
f"{self.params.contrast[0]}__{self.params.contrast[1]}_vs_{self.params.contrast[2]}"
|
|
269
|
+
)
|
|
270
|
+
out["contrast"] = contrast_label
|
|
271
|
+
out["significant"] = (out["padj"].fillna(1.0) < self.params.fdr_threshold) & (
|
|
272
|
+
out["log2fc"].abs() >= self.params.log2fc_threshold
|
|
273
|
+
)
|
|
274
|
+
cols = [
|
|
275
|
+
"gene_id",
|
|
276
|
+
"log2fc",
|
|
277
|
+
"lfc_se",
|
|
278
|
+
"stat",
|
|
279
|
+
"pvalue",
|
|
280
|
+
"padj",
|
|
281
|
+
"baseMean",
|
|
282
|
+
"contrast",
|
|
283
|
+
"significant",
|
|
284
|
+
]
|
|
285
|
+
return out[cols]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Designer plugin interface.
|
|
4
|
+
|
|
5
|
+
Designers consume ``(structure, epitope)`` and produce designed binders.
|
|
6
|
+
Implementations are loaded via the ``bindsight.designers`` entry point group
|
|
7
|
+
(see pyproject.toml). Note that the CLI currently validates ``--designer``
|
|
8
|
+
against a fixed ``click.Choice`` of the built-in names, and
|
|
9
|
+
``runners.job_exec`` dispatches on the same fixed set, so a third-party
|
|
10
|
+
designer is not yet usable without also editing those two places. Lifting
|
|
11
|
+
that restriction is a roadmap item.
|
|
12
|
+
|
|
13
|
+
Built-in designers:
|
|
14
|
+
|
|
15
|
+
- ``rfdiff_mpnn`` (default) — RFdiffusion backbone + ProteinMPNN sequence,
|
|
16
|
+
T4-friendly, BSD-3 / MIT.
|
|
17
|
+
- ``bindcraft`` — BindCraft one-shot AF2-based, A100 (≥32 GB), MIT.
|
|
18
|
+
- ``boltzgen`` — BoltzGen universal binder design, MIT.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from bindsight.design.protocol import Designer, DesignResult, DesignSpec
|
|
22
|
+
|
|
23
|
+
__all__ = ["DesignResult", "DesignSpec", "Designer"]
|