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,140 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Binder developability — sequence-level manufacturability / liability flags.
|
|
4
|
+
|
|
5
|
+
A high-ipTM design is useless if it won't express, aggregates, or is hard to
|
|
6
|
+
manufacture. This module computes sequence-level biophysical descriptors (no
|
|
7
|
+
network, no GPU) so poor-developability designs can be down-ranked before any
|
|
8
|
+
wet-lab or GPU spend.
|
|
9
|
+
|
|
10
|
+
**Three of them are combined into ``developability_score``**, and only these
|
|
11
|
+
three affect any ranking:
|
|
12
|
+
|
|
13
|
+
- **Instability index** (Guruprasad 1990) — > 40 predicts an unstable protein.
|
|
14
|
+
- **GRAVY** (Kyte-Doolittle grand average hydropathy) — higher ⇒ more hydrophobic
|
|
15
|
+
⇒ harder to express / more aggregation-prone.
|
|
16
|
+
- **Aggregation-prone fraction** — fraction of the chain inside a hydrophobic
|
|
17
|
+
(Kyte-Doolittle window) aggregation-prone region (APR), a standard first-order
|
|
18
|
+
aggregation signal.
|
|
19
|
+
|
|
20
|
+
**The rest are reported and not scored.** They are real liabilities and a reader
|
|
21
|
+
should see them, but nothing down-ranks a design for them, and saying otherwise
|
|
22
|
+
would claim a filter that does not exist:
|
|
23
|
+
|
|
24
|
+
- **Isoelectric point** and **aromaticity** — formulation / handling liabilities.
|
|
25
|
+
- **Cysteine count** — ``n_cys`` is the *total* number of cysteines in the
|
|
26
|
+
sequence, not the free ones. An odd count flags a probable unpaired cysteine
|
|
27
|
+
and so a disulfide / oxidation liability; pairing cannot be determined from
|
|
28
|
+
sequence alone, and this module does not read a structure.
|
|
29
|
+
- **Length** and **molecular weight** — context for everything above.
|
|
30
|
+
|
|
31
|
+
Five descriptors come from Biopython's ``ProtParam`` (the reference
|
|
32
|
+
implementation): molecular weight, GRAVY, instability index, isoelectric point
|
|
33
|
+
and aromaticity. The other three — length, cysteine count and aggregation-prone
|
|
34
|
+
fraction — are computed here; the APR window is this module's own
|
|
35
|
+
implementation, not ProtParam's. All are deterministic, offline and exact.
|
|
36
|
+
|
|
37
|
+
(T-cell–epitope / immunogenicity scoring, which needs a licensed or heavy MHC
|
|
38
|
+
predictor, is intentionally left for a follow-up so this module ships only
|
|
39
|
+
signals it can compute exactly here.)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import logging
|
|
45
|
+
import math
|
|
46
|
+
from dataclasses import asdict, dataclass
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
LOG = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
_STANDARD_AA = set("ACDEFGHIKLMNPQRSTVWY")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Developability:
|
|
56
|
+
"""Deterministic sequence-level developability descriptors for one binder."""
|
|
57
|
+
|
|
58
|
+
length: int
|
|
59
|
+
molecular_weight: float
|
|
60
|
+
gravy: float
|
|
61
|
+
instability_index: float
|
|
62
|
+
isoelectric_point: float
|
|
63
|
+
aromaticity: float
|
|
64
|
+
n_cys: int
|
|
65
|
+
aggregation_prone_fraction: float
|
|
66
|
+
developability_score: float # composite in [0, 1], higher = more developable
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_stable(self) -> bool:
|
|
70
|
+
"""ProtParam instability index < 40 ⇒ predicted stable."""
|
|
71
|
+
return self.instability_index < 40.0
|
|
72
|
+
|
|
73
|
+
def as_dict(self) -> dict[str, Any]:
|
|
74
|
+
"""Return the descriptors as a plain dict (for TSV/JSON export)."""
|
|
75
|
+
return asdict(self)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _aggregation_prone_fraction(seq: str, window: int = 7, threshold: float = 1.0) -> float:
|
|
79
|
+
"""Fraction of residues inside a hydrophobic (Kyte-Doolittle) APR window.
|
|
80
|
+
|
|
81
|
+
Slides a ``window``-residue window; a window whose mean Kyte-Doolittle
|
|
82
|
+
hydropathy exceeds ``threshold`` is aggregation-prone, and all its residues
|
|
83
|
+
are marked. Returns marked / length (0.0 for sequences shorter than a window).
|
|
84
|
+
"""
|
|
85
|
+
from Bio.SeqUtils.ProtParamData import kd # Kyte-Doolittle scale
|
|
86
|
+
|
|
87
|
+
n = len(seq)
|
|
88
|
+
if n < window:
|
|
89
|
+
return 0.0
|
|
90
|
+
prone = [False] * n
|
|
91
|
+
for i in range(n - window + 1):
|
|
92
|
+
w = seq[i : i + window]
|
|
93
|
+
if sum(kd.get(a, 0.0) for a in w) / window > threshold:
|
|
94
|
+
for j in range(i, i + window):
|
|
95
|
+
prone[j] = True
|
|
96
|
+
return sum(prone) / n
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _composite(instability: float, gravy: float, aggregation_prone_fraction: float) -> float:
|
|
100
|
+
"""Combine stability, solubility and aggregation into a [0, 1] developability score."""
|
|
101
|
+
# Stable below the 40 instability threshold, decaying above it.
|
|
102
|
+
stability = 1.0 if instability < 40.0 else max(0.0, 1.0 - (instability - 40.0) / 40.0)
|
|
103
|
+
# Solubility: sigmoid of -GRAVY (GRAVY 0 → 0.5, negative/hydrophilic → higher).
|
|
104
|
+
solubility = 1.0 / (1.0 + math.exp(gravy))
|
|
105
|
+
non_aggregating = 1.0 - aggregation_prone_fraction
|
|
106
|
+
return (stability + solubility + non_aggregating) / 3.0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def developability(sequence: str) -> Developability | None:
|
|
110
|
+
"""Compute developability descriptors for a binder amino-acid sequence.
|
|
111
|
+
|
|
112
|
+
Returns ``None`` for an empty sequence or one containing non-standard
|
|
113
|
+
residues (ProtParam is only defined on the 20 standard amino acids).
|
|
114
|
+
"""
|
|
115
|
+
if not sequence:
|
|
116
|
+
return None
|
|
117
|
+
seq = sequence.strip().upper()
|
|
118
|
+
if not seq or any(a not in _STANDARD_AA for a in seq):
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
from Bio.SeqUtils.ProtParam import ProteinAnalysis
|
|
122
|
+
|
|
123
|
+
pa = ProteinAnalysis(seq)
|
|
124
|
+
instability = float(pa.instability_index())
|
|
125
|
+
gravy = float(pa.gravy())
|
|
126
|
+
apr = _aggregation_prone_fraction(seq)
|
|
127
|
+
return Developability(
|
|
128
|
+
length=len(seq),
|
|
129
|
+
molecular_weight=round(float(pa.molecular_weight()), 2),
|
|
130
|
+
gravy=round(gravy, 4),
|
|
131
|
+
instability_index=round(instability, 2),
|
|
132
|
+
isoelectric_point=round(float(pa.isoelectric_point()), 2),
|
|
133
|
+
aromaticity=round(float(pa.aromaticity()), 4),
|
|
134
|
+
n_cys=seq.count("C"),
|
|
135
|
+
aggregation_prone_fraction=round(apr, 4),
|
|
136
|
+
developability_score=round(_composite(instability, gravy, apr), 4),
|
|
137
|
+
)
|
|
138
|
+
except Exception as e: # malformed sequence ProtParam can't handle
|
|
139
|
+
LOG.warning("developability failed for a %d-aa sequence: %s", len(seq), e)
|
|
140
|
+
return None
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Protein-language-model embeddings + a pre-GPU sequence-space visualizer.
|
|
4
|
+
|
|
5
|
+
Before spending GPU on structure prediction / validation, it's useful to *see*
|
|
6
|
+
the sequence space of your designed binders (or candidate targets): which designs
|
|
7
|
+
cluster, which are outliers, how diverse the set is. This is the ProtSpace idea —
|
|
8
|
+
project protein-language-model (pLM) embeddings to 2-D.
|
|
9
|
+
|
|
10
|
+
**pLM choice — ESM-2 (`facebook/esm2_t6_8M_UR50D`, 8 M params).** ESM-2 is the
|
|
11
|
+
de-facto open protein LM; the 8 M (t6) checkpoint runs on a CPU in seconds, needs
|
|
12
|
+
no GPU, and its mean-pooled residue embedding is a strong, standard per-protein
|
|
13
|
+
representation. Larger ESM-2 checkpoints (35 M…3 B) or ProtT5 can be dropped in via
|
|
14
|
+
``model_name`` when accuracy matters more than speed.
|
|
15
|
+
|
|
16
|
+
Embedding (ESM-2) needs the optional ``embed`` extra (torch + transformers):
|
|
17
|
+
``pip install 'bindsight[embed]'``. The 2-D projection (PCA) is pure NumPy and
|
|
18
|
+
always available, so the visualizer works on any precomputed embedding matrix.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
LOG = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
DEFAULT_PLM = "facebook/esm2_t6_8M_UR50D"
|
|
31
|
+
|
|
32
|
+
_INSTALL_HINT = (
|
|
33
|
+
"ESM-2 embeddings need the optional 'embed' extra: pip install 'bindsight[embed]' "
|
|
34
|
+
"(installs torch + transformers)."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def esm2_embed(
|
|
39
|
+
sequences: list[str],
|
|
40
|
+
*,
|
|
41
|
+
model_name: str = DEFAULT_PLM,
|
|
42
|
+
batch_size: int = 8,
|
|
43
|
+
) -> np.ndarray:
|
|
44
|
+
"""Mean-pooled ESM-2 embeddings for ``sequences`` → array of shape (N, D).
|
|
45
|
+
|
|
46
|
+
Real protein-LM inference (no placeholder). Pools over residue token states,
|
|
47
|
+
excluding the special BOS/EOS and padding tokens. Raises ``ImportError`` (with
|
|
48
|
+
an install hint) if the ``embed`` extra isn't installed.
|
|
49
|
+
"""
|
|
50
|
+
if not sequences:
|
|
51
|
+
return np.zeros((0, 0), dtype=np.float32)
|
|
52
|
+
try:
|
|
53
|
+
import torch
|
|
54
|
+
from transformers import AutoModel, AutoTokenizer
|
|
55
|
+
except ImportError as e: # optional extra not installed
|
|
56
|
+
raise ImportError(_INSTALL_HINT) from e
|
|
57
|
+
|
|
58
|
+
tok = AutoTokenizer.from_pretrained(model_name)
|
|
59
|
+
model = AutoModel.from_pretrained(model_name)
|
|
60
|
+
model.eval()
|
|
61
|
+
|
|
62
|
+
vecs: list[np.ndarray] = []
|
|
63
|
+
with torch.no_grad():
|
|
64
|
+
for i in range(0, len(sequences), batch_size):
|
|
65
|
+
batch = sequences[i : i + batch_size]
|
|
66
|
+
enc = tok(
|
|
67
|
+
batch,
|
|
68
|
+
return_tensors="pt",
|
|
69
|
+
padding=True,
|
|
70
|
+
return_special_tokens_mask=True,
|
|
71
|
+
)
|
|
72
|
+
special = enc.pop("special_tokens_mask")
|
|
73
|
+
hidden = model(**enc).last_hidden_state # (B, L, D)
|
|
74
|
+
# Real residues only: attended to, and not a special token.
|
|
75
|
+
keep = (enc["attention_mask"] * (1 - special)).cpu().numpy()
|
|
76
|
+
vecs.append(mean_pool_residues(hidden.cpu().numpy(), keep))
|
|
77
|
+
return np.vstack(vecs)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def mean_pool_residues(hidden: np.ndarray, keep: np.ndarray) -> np.ndarray:
|
|
81
|
+
"""Mean of ``hidden`` over the positions ``keep`` marks, per sequence.
|
|
82
|
+
|
|
83
|
+
Split out of :func:`esm2_embed` so it can be tested. It is arithmetic --
|
|
84
|
+
no model, no tokenizer, no ``torch`` -- and it is the step that decides the
|
|
85
|
+
numbers: a padding token or a BOS/EOS marker left in the average shifts
|
|
86
|
+
every embedding, and the prescreen built on those embeddings decides which
|
|
87
|
+
designs reach validation. Inside ``esm2_embed`` it was reachable only with
|
|
88
|
+
the ``embed`` extra installed, which no CI job does, so the one test that
|
|
89
|
+
exercised it skipped on every platform.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
hidden: ``(B, L, D)`` final hidden states.
|
|
93
|
+
keep: ``(B, L)`` of 1 for a real residue and 0 for padding or a special
|
|
94
|
+
token.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
``(B, D)`` float32. A sequence with no kept position averages over a
|
|
98
|
+
count of 1 rather than dividing by zero, giving a zero vector.
|
|
99
|
+
"""
|
|
100
|
+
weights = keep[..., None].astype(hidden.dtype)
|
|
101
|
+
summed = (hidden * weights).sum(axis=1)
|
|
102
|
+
counts = weights.sum(axis=1).clip(min=1.0)
|
|
103
|
+
pooled: np.ndarray = (summed / counts).astype(np.float32)
|
|
104
|
+
return pooled
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def pca_2d(embeddings: np.ndarray) -> np.ndarray:
|
|
108
|
+
"""Project an (N, D) embedding matrix to (N, 2) principal coordinates.
|
|
109
|
+
|
|
110
|
+
Pure NumPy (centred SVD) so it needs no scikit-learn/UMAP and runs anywhere.
|
|
111
|
+
Returns zeros for fewer than 2 rows. An embedding with a single feature has
|
|
112
|
+
only one principal axis; the second coordinate is then zero, and the shape
|
|
113
|
+
is still (N, 2) because every caller relies on it.
|
|
114
|
+
"""
|
|
115
|
+
x = np.asarray(embeddings, dtype=float)
|
|
116
|
+
if x.ndim != 2 or x.shape[0] < 2:
|
|
117
|
+
return np.zeros((x.shape[0] if x.ndim == 2 else 0, 2), dtype=float)
|
|
118
|
+
xc = x - x.mean(axis=0, keepdims=True)
|
|
119
|
+
_u, _s, vt = np.linalg.svd(xc, full_matrices=False)
|
|
120
|
+
coords = np.asarray(xc @ vt[:2].T, dtype=float) # principal coordinates
|
|
121
|
+
if coords.shape[1] < 2:
|
|
122
|
+
# A one-dimensional embedding has one principal axis, so `vt[:2]` is a
|
|
123
|
+
# single row and this returned (N, 1) while promising (N, 2) -- which
|
|
124
|
+
# `render_embedding_png` then unpacks as x and y. The second axis is not
|
|
125
|
+
# missing, it is zero: there is no variance left to put on it. Say that
|
|
126
|
+
# rather than hand back a shape the signature rules out.
|
|
127
|
+
coords = np.concatenate(
|
|
128
|
+
[coords, np.zeros((coords.shape[0], 2 - coords.shape[1]), dtype=float)], axis=1
|
|
129
|
+
)
|
|
130
|
+
return coords
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def render_embedding_png(
|
|
134
|
+
coords: np.ndarray,
|
|
135
|
+
labels: list[str],
|
|
136
|
+
out_path: str | Path,
|
|
137
|
+
*,
|
|
138
|
+
title: str = "Binder sequence space (ESM-2 → PCA)",
|
|
139
|
+
) -> bool:
|
|
140
|
+
"""Render a 2-D scatter of ``coords`` labelled by ``labels`` to a PNG.
|
|
141
|
+
|
|
142
|
+
Returns False (no-op) if matplotlib isn't installed. Used to produce the
|
|
143
|
+
committed ProtSpace-style visualization of the designed binders.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
import matplotlib
|
|
147
|
+
|
|
148
|
+
matplotlib.use("Agg")
|
|
149
|
+
import matplotlib.pyplot as plt
|
|
150
|
+
except ImportError:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
fig, ax = plt.subplots(figsize=(7, 6), dpi=120)
|
|
154
|
+
ax.scatter(coords[:, 0], coords[:, 1], s=40, alpha=0.8, c="#1f77b4")
|
|
155
|
+
for (x, y), lab in zip(coords, labels, strict=False):
|
|
156
|
+
ax.annotate(
|
|
157
|
+
str(lab), (x, y), fontsize=7, alpha=0.75, xytext=(3, 3), textcoords="offset points"
|
|
158
|
+
)
|
|
159
|
+
ax.set_xlabel("PC1")
|
|
160
|
+
ax.set_ylabel("PC2")
|
|
161
|
+
ax.set_title(title)
|
|
162
|
+
fig.tight_layout()
|
|
163
|
+
fig.savefig(out_path)
|
|
164
|
+
plt.close(fig)
|
|
165
|
+
return True
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""ESM-2 pre-screen: cut the validation set before it reaches the GPU.
|
|
4
|
+
|
|
5
|
+
Structure-and-affinity validation is the expensive half of a design run —
|
|
6
|
+
Boltz-2 costs roughly 20 GPU-seconds per design against RFdiffusion+ProteinMPNN's
|
|
7
|
+
~45 for producing one. Validating fifty near-identical backbones is mostly
|
|
8
|
+
wasted compute.
|
|
9
|
+
|
|
10
|
+
This screen embeds each designed sequence with ESM-2 and keeps a
|
|
11
|
+
*representative* subset: designs closest to the embedding centroid, which are
|
|
12
|
+
the ones the model considers most typical of what the designer produced.
|
|
13
|
+
Outliers are dropped first, on the reasoning that a design far from the rest of
|
|
14
|
+
the batch in sequence space is more often a degenerate backbone than a
|
|
15
|
+
breakthrough.
|
|
16
|
+
|
|
17
|
+
Deliberate properties:
|
|
18
|
+
|
|
19
|
+
- **Opt-in.** ``top_k=None`` returns everything untouched, so default runs are
|
|
20
|
+
bit-identical to before this module existed.
|
|
21
|
+
- **Degrading, never fatal.** ``torch``/``transformers`` live in the optional
|
|
22
|
+
``embed`` extra. Without them the screen logs and returns everything: a
|
|
23
|
+
missing optional dependency must not lose a GPU run that already succeeded.
|
|
24
|
+
- **Deterministic.** Ties break on the input order, so the same batch always
|
|
25
|
+
yields the same selection.
|
|
26
|
+
- **Auditable.** :func:`prescreen_report` describes exactly what was dropped,
|
|
27
|
+
and that goes into provenance — silently discarding candidates would violate
|
|
28
|
+
the project's own contract that failures are recorded, not swallowed.
|
|
29
|
+
|
|
30
|
+
Reuses :func:`bindsight.design.embeddings.esm2_embed`; this module adds only
|
|
31
|
+
the selection, not a second embedding implementation.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
|
|
39
|
+
LOG = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class PrescreenResult:
|
|
44
|
+
"""Outcome of a pre-screen pass."""
|
|
45
|
+
|
|
46
|
+
kept: list[int]
|
|
47
|
+
dropped: list[int]
|
|
48
|
+
applied: bool
|
|
49
|
+
reason: str
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def n_kept(self) -> int:
|
|
53
|
+
"""Number of designs carried forward to validation."""
|
|
54
|
+
return len(self.kept)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def n_dropped(self) -> int:
|
|
58
|
+
"""Number of designs screened out before validation."""
|
|
59
|
+
return len(self.dropped)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fallback(n: int, reason: str, *, benign: bool = False) -> PrescreenResult:
|
|
63
|
+
"""Keep everything, recording why the screen did not run.
|
|
64
|
+
|
|
65
|
+
``benign`` separates "nothing needed screening" from "the screen could not
|
|
66
|
+
run". Both keep every design and both must be recorded in provenance, but
|
|
67
|
+
only the second is worth a warning: a run with fewer designs than its cap is
|
|
68
|
+
behaving exactly as configured, and logging that as a problem trains readers
|
|
69
|
+
to ignore the line that does signal one.
|
|
70
|
+
"""
|
|
71
|
+
if reason:
|
|
72
|
+
LOG.log(
|
|
73
|
+
logging.INFO if benign else logging.WARNING,
|
|
74
|
+
"ESM-2 pre-screen not applied: %s; validating all %d designs",
|
|
75
|
+
reason,
|
|
76
|
+
n,
|
|
77
|
+
)
|
|
78
|
+
return PrescreenResult(kept=list(range(n)), dropped=[], applied=False, reason=reason)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def select_representative(sequences: list[str], top_k: int | None) -> PrescreenResult:
|
|
82
|
+
"""Choose which designs to carry into validation.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
sequences: Designed binder sequences, in design order.
|
|
86
|
+
top_k: How many to keep. ``None`` or a value at least as large as the
|
|
87
|
+
input keeps everything and skips the embedding entirely.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
A :class:`PrescreenResult` whose ``kept`` indices are in ascending
|
|
91
|
+
design order, so downstream output ordering is unchanged.
|
|
92
|
+
"""
|
|
93
|
+
n = len(sequences)
|
|
94
|
+
if top_k is None:
|
|
95
|
+
return _fallback(n, "")
|
|
96
|
+
if top_k <= 0:
|
|
97
|
+
return _fallback(n, f"top_k={top_k} is not positive")
|
|
98
|
+
if n <= top_k:
|
|
99
|
+
# Distinct from `top_k is None`. The screen *was* configured; there were
|
|
100
|
+
# simply not enough designs to cut. Reporting both as an empty reason
|
|
101
|
+
# made the provenance line say "no top_k set" about a run that set one,
|
|
102
|
+
# so an auditor reading it would conclude the screen had never been
|
|
103
|
+
# asked for.
|
|
104
|
+
return _fallback(
|
|
105
|
+
n,
|
|
106
|
+
f"only {n} design{'s' if n != 1 else ''} to screen, top_k={top_k}",
|
|
107
|
+
benign=True,
|
|
108
|
+
)
|
|
109
|
+
if any(not s for s in sequences):
|
|
110
|
+
return _fallback(n, "one or more designs have an empty sequence")
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
import numpy as np
|
|
114
|
+
|
|
115
|
+
from bindsight.design.embeddings import esm2_embed
|
|
116
|
+
except ImportError as e: # pragma: no cover - exercised via the extra
|
|
117
|
+
return _fallback(n, f"the 'embed' extra is not installed ({e})")
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
emb = esm2_embed(sequences)
|
|
121
|
+
except Exception as e: # a screen must never lose an already-successful GPU run
|
|
122
|
+
return _fallback(n, f"embedding failed ({e})")
|
|
123
|
+
|
|
124
|
+
matrix = np.asarray(emb, dtype=float)
|
|
125
|
+
if matrix.ndim != 2 or matrix.shape[0] != n:
|
|
126
|
+
return _fallback(n, f"unexpected embedding shape {matrix.shape} for {n} designs")
|
|
127
|
+
|
|
128
|
+
centroid = matrix.mean(axis=0)
|
|
129
|
+
distances = np.linalg.norm(matrix - centroid, axis=1)
|
|
130
|
+
# argsort is stable, so equal distances keep their original design order.
|
|
131
|
+
order = np.argsort(distances, kind="stable")
|
|
132
|
+
kept = sorted(int(i) for i in order[:top_k])
|
|
133
|
+
dropped = sorted(int(i) for i in order[top_k:])
|
|
134
|
+
|
|
135
|
+
LOG.info(
|
|
136
|
+
"ESM-2 pre-screen: keeping %d of %d designs (dropped %d least-representative)",
|
|
137
|
+
len(kept),
|
|
138
|
+
n,
|
|
139
|
+
len(dropped),
|
|
140
|
+
)
|
|
141
|
+
return PrescreenResult(
|
|
142
|
+
kept=kept,
|
|
143
|
+
dropped=dropped,
|
|
144
|
+
applied=True,
|
|
145
|
+
reason=f"kept the {top_k} designs closest to the ESM-2 embedding centroid",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def prescreen_report(result: PrescreenResult, binder_ids: list[str]) -> str:
|
|
150
|
+
"""Render a one-line, provenance-ready summary of the screen.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
result: The screen outcome.
|
|
154
|
+
binder_ids: Binder identifiers in design order.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
A human-readable summary naming the dropped designs.
|
|
158
|
+
"""
|
|
159
|
+
if not result.applied:
|
|
160
|
+
return (
|
|
161
|
+
f"ESM-2 pre-screen not applied ({result.reason or 'no top_k set'}); "
|
|
162
|
+
f"all {len(binder_ids)} designs validated"
|
|
163
|
+
)
|
|
164
|
+
dropped = [binder_ids[i] for i in result.dropped if i < len(binder_ids)]
|
|
165
|
+
shown = ", ".join(dropped[:10]) + (" …" if len(dropped) > 10 else "")
|
|
166
|
+
return (
|
|
167
|
+
f"ESM-2 pre-screen: validated {result.n_kept} of {len(binder_ids)} designs; "
|
|
168
|
+
f"{result.reason}. Screened out: {shown or 'none'}"
|
|
169
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Designer plugin protocol.
|
|
4
|
+
|
|
5
|
+
A Designer turns a ``DesignSpec`` (target structure + epitope + params) into
|
|
6
|
+
``DesignResult`` (a tarball of binder PDBs + per-design metrics). All real
|
|
7
|
+
work is offloaded to a :class:`bindsight.runners.GPURunner`; the Designer
|
|
8
|
+
itself only owns the spec shape and result schema.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
|
|
18
|
+
# A real import, not a TYPE_CHECKING one. ``submit`` annotates this type, and
|
|
19
|
+
# the annotation was previously silenced with ``# type: ignore[name-defined]``
|
|
20
|
+
# plus a ``noqa: F821`` directive -- which quieted two checkers rather than
|
|
21
|
+
# telling either
|
|
22
|
+
# what the name is, so ``typing.get_type_hints`` on this Protocol raised and
|
|
23
|
+
# nothing could introspect the contract. ``bindsight.runners.protocol`` imports
|
|
24
|
+
# nothing from this package, so there is no cycle to avoid.
|
|
25
|
+
from bindsight.runners.protocol import GPURunner
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DesignSpec(BaseModel):
|
|
29
|
+
"""Inputs to a single design job (one target × N trajectories)."""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
32
|
+
|
|
33
|
+
target_uniprot: str
|
|
34
|
+
target_structure_path: str = Field(..., description="Local path to mmCIF/PDB.")
|
|
35
|
+
epitope_chain: str = "A"
|
|
36
|
+
# Hotspot residues to focus the binder on. Empty = whole-target design
|
|
37
|
+
# (valid before SURFACE-Bind epitope prediction is wired); RFdiffusion then
|
|
38
|
+
# runs without ``ppi.hotspot_res``.
|
|
39
|
+
epitope_residues: list[int] = Field(default_factory=list)
|
|
40
|
+
# Inclusive residue ranges of the target the binder may be designed against —
|
|
41
|
+
# the extracellular domain(s) annotated by discovery. Empty means no topology
|
|
42
|
+
# was available, so the whole chain (transmembrane helix and cytoplasmic tail
|
|
43
|
+
# included) is the design surface; the executor says so rather than implying
|
|
44
|
+
# the full-length receptor was intended.
|
|
45
|
+
design_ranges: list[tuple[int, int]] = Field(default_factory=list)
|
|
46
|
+
binder_length_min: int = 50
|
|
47
|
+
binder_length_max: int = 100
|
|
48
|
+
n_trajectories: int = 50
|
|
49
|
+
seed: int = 0
|
|
50
|
+
extra_params: dict[str, str | int | float | bool] = Field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DesignResult(BaseModel):
|
|
54
|
+
"""Result of a design job."""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
57
|
+
|
|
58
|
+
spec: DesignSpec
|
|
59
|
+
results_archive_path: str = Field(..., description="Local path to the .tar.gz of PDBs.")
|
|
60
|
+
metrics_jsonl_path: str = Field(
|
|
61
|
+
..., description="Local path to per-design metrics JSONL (one row per design)."
|
|
62
|
+
)
|
|
63
|
+
designer_name: str
|
|
64
|
+
designer_version: str = Field(
|
|
65
|
+
...,
|
|
66
|
+
description=(
|
|
67
|
+
"Version of the bindsight adapter, NOT of the upstream tool it drives. "
|
|
68
|
+
"The upstream revision is pinned in designer_commit_sha and in the "
|
|
69
|
+
"spec's extra_params (e.g. rfdiff_commit, proteinmpnn_commit), which "
|
|
70
|
+
"are what the cache key and reproducibility depend on."
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
designer_commit_sha: str | None = None
|
|
74
|
+
weights_sha256: str | None = None
|
|
75
|
+
cache_key: str = Field(..., description="Used to deduplicate identical jobs across runs.")
|
|
76
|
+
cache_status: Literal["hit", "miss"] = Field(
|
|
77
|
+
"miss",
|
|
78
|
+
description=(
|
|
79
|
+
"Whether this result was reused from a previous run with the same cache_key "
|
|
80
|
+
"('hit') or newly computed ('miss'). Recorded so a manifest shows which units "
|
|
81
|
+
"actually consumed GPU time."
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@runtime_checkable
|
|
87
|
+
class Designer(Protocol):
|
|
88
|
+
"""Protocol every designer plugin must implement."""
|
|
89
|
+
|
|
90
|
+
name: str
|
|
91
|
+
version: str
|
|
92
|
+
|
|
93
|
+
def make_spec(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
target_uniprot: str,
|
|
97
|
+
target_structure_path: Path,
|
|
98
|
+
epitope_residues: list[int],
|
|
99
|
+
epitope_chain: str = "A",
|
|
100
|
+
design_ranges: list[tuple[int, int]] | None = None,
|
|
101
|
+
n_trajectories: int = 50,
|
|
102
|
+
seed: int = 0,
|
|
103
|
+
binder_length_min: int = 50,
|
|
104
|
+
binder_length_max: int = 100,
|
|
105
|
+
) -> DesignSpec:
|
|
106
|
+
"""Build a designer-specific DesignSpec from generic inputs."""
|
|
107
|
+
...
|
|
108
|
+
|
|
109
|
+
def submit(self, spec: DesignSpec, runner: GPURunner) -> DesignResult:
|
|
110
|
+
"""Execute the design job (synchronous from the caller's POV)."""
|
|
111
|
+
...
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""RFdiffusion + ProteinMPNN designer plugin.
|
|
4
|
+
|
|
5
|
+
The default designer. Generates backbones with `RFdiffusion
|
|
6
|
+
<https://github.com/RosettaCommons/RFdiffusion>`_ (BSD-3, weights open) and
|
|
7
|
+
fills in sequences with `ProteinMPNN <https://github.com/dauparas/ProteinMPNN>`_
|
|
8
|
+
(MIT). Both fit in 16 GB VRAM, so this designer works on free Colab T4 — that
|
|
9
|
+
is why it's the default over BindCraft (which needs ≥32 GB).
|
|
10
|
+
|
|
11
|
+
The actual GPU work runs in :mod:`bindsight.runners.job_exec` (via whichever
|
|
12
|
+
runner backend the user picks). This module owns the plugin entry point and the
|
|
13
|
+
spec it builds; the submit body is shared via :mod:`bindsight.design._common`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from bindsight.design._common import make_cache_key, submit_via_runner
|
|
22
|
+
from bindsight.design.protocol import DesignResult, DesignSpec
|
|
23
|
+
from bindsight.runners.protocol import GPURunner
|
|
24
|
+
from bindsight.runners.tools import PROTEINMPNN_COMMIT, RFDIFF_COMMIT
|
|
25
|
+
|
|
26
|
+
LOG = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Pinned real upstream revisions (resolved via git ls-remote; see runners.tools).
|
|
29
|
+
DEFAULT_RFDIFF_COMMIT = RFDIFF_COMMIT
|
|
30
|
+
DEFAULT_PROTEINMPNN_COMMIT = PROTEINMPNN_COMMIT
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RFdiffMPNNDesigner:
|
|
34
|
+
"""Plugin: RFdiffusion backbone + ProteinMPNN sequence."""
|
|
35
|
+
|
|
36
|
+
name = "rfdiff_mpnn"
|
|
37
|
+
# The adapter's own version, not the upstream tool's. Pinned upstream
|
|
38
|
+
# revisions live in the spec's extra_params and in the cache key; this
|
|
39
|
+
# number moves only when this adapter's behaviour does.
|
|
40
|
+
version = "0.1.0"
|
|
41
|
+
license_notice = "RFdiffusion: BSD-3. ProteinMPNN: MIT. Both commercial-OK."
|
|
42
|
+
|
|
43
|
+
def make_spec(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
target_uniprot: str,
|
|
47
|
+
target_structure_path: Path,
|
|
48
|
+
epitope_residues: list[int],
|
|
49
|
+
epitope_chain: str = "A",
|
|
50
|
+
design_ranges: list[tuple[int, int]] | None = None,
|
|
51
|
+
n_trajectories: int = 50,
|
|
52
|
+
seed: int = 0,
|
|
53
|
+
binder_length_min: int = 50,
|
|
54
|
+
binder_length_max: int = 100,
|
|
55
|
+
) -> DesignSpec:
|
|
56
|
+
"""Build a DesignSpec carrying the RFdiff+MPNN parameters."""
|
|
57
|
+
return DesignSpec(
|
|
58
|
+
target_uniprot=target_uniprot,
|
|
59
|
+
target_structure_path=str(target_structure_path),
|
|
60
|
+
epitope_chain=epitope_chain,
|
|
61
|
+
epitope_residues=epitope_residues,
|
|
62
|
+
design_ranges=list(design_ranges or []),
|
|
63
|
+
n_trajectories=n_trajectories,
|
|
64
|
+
seed=seed,
|
|
65
|
+
binder_length_min=binder_length_min,
|
|
66
|
+
binder_length_max=binder_length_max,
|
|
67
|
+
extra_params={
|
|
68
|
+
"designer": self.name,
|
|
69
|
+
"designer_version": self.version,
|
|
70
|
+
"rfdiff_commit": DEFAULT_RFDIFF_COMMIT,
|
|
71
|
+
"proteinmpnn_commit": DEFAULT_PROTEINMPNN_COMMIT,
|
|
72
|
+
"binder_length_min": binder_length_min,
|
|
73
|
+
"binder_length_max": binder_length_max,
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def submit(self, spec: DesignSpec, runner: GPURunner) -> DesignResult:
|
|
78
|
+
"""Ship the spec+structure to the runner and stage the results."""
|
|
79
|
+
cache_key = make_cache_key(
|
|
80
|
+
spec,
|
|
81
|
+
extra=(
|
|
82
|
+
str(spec.extra_params.get("rfdiff_commit", "")),
|
|
83
|
+
str(spec.extra_params.get("proteinmpnn_commit", "")),
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
return submit_via_runner(
|
|
87
|
+
spec,
|
|
88
|
+
runner,
|
|
89
|
+
designer_name=self.name,
|
|
90
|
+
designer_version=self.version,
|
|
91
|
+
designer_commit_sha=DEFAULT_RFDIFF_COMMIT,
|
|
92
|
+
cache_key=cache_key,
|
|
93
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Epitope identification — SURFACE-Bind targetable-site lookup.
|
|
4
|
+
|
|
5
|
+
An fpocket fallback for proteins SURFACE-Bind does not cover is planned and
|
|
6
|
+
not implemented; this docstring previously named it as though it shipped.
|
|
7
|
+
"""
|