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
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,7 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Differential expression analysis (pydeseq2)."""
4
+
5
+ from bindsight.deg.pydeseq2_runner import PyDESeq2Runner
6
+
7
+ __all__ = ["PyDESeq2Runner"]
@@ -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"]