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,256 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Boltz-2 validator.
|
|
4
|
+
|
|
5
|
+
Wraps `Boltz-2 <https://github.com/jwohlwend/boltz>`_ (MIT for both code and
|
|
6
|
+
weights). Boltz-2 takes a target+binder spec and produces:
|
|
7
|
+
|
|
8
|
+
- ``ipTM``, ``pAE_interaction`` from the structure prediction
|
|
9
|
+
- ``affinity_pred_value`` (continuous, log(IC50)-like: **lower = stronger**
|
|
10
|
+
predicted binder; the ranker inverts it). Ligand-only, so it stays ``None``
|
|
11
|
+
for the protein binders bindsight designs.
|
|
12
|
+
- ``affinity_probability_binary`` (probability the design is a binder vs decoy)
|
|
13
|
+
|
|
14
|
+
This module's responsibility is the *parsing + composition* layer:
|
|
15
|
+
|
|
16
|
+
- :func:`build_boltz_yaml` produces the YAML config Boltz-2 ingests.
|
|
17
|
+
- :func:`parse_boltz_output` reads Boltz's output JSON into a
|
|
18
|
+
:class:`bindsight.validate.protocol.ValidationResult`.
|
|
19
|
+
- :class:`Boltz2Validator` is the plugin entry point used by the orchestrator.
|
|
20
|
+
|
|
21
|
+
The actual GPU inference happens in the runner (Colab / Modal / local Docker)
|
|
22
|
+
because Boltz-2 needs CUDA. Calling :meth:`Boltz2Validator.validate` from the
|
|
23
|
+
orchestrator either:
|
|
24
|
+
|
|
25
|
+
1. Reads pre-computed Boltz output from the run's ``validate/`` directory
|
|
26
|
+
(when the runner has already returned results), OR
|
|
27
|
+
2. Raises :class:`MissingValidationError` with a clear message pointing the
|
|
28
|
+
user at the runner step.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import statistics
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
import yaml
|
|
40
|
+
|
|
41
|
+
from bindsight.validate.protocol import (
|
|
42
|
+
UNRECORDED_VERSION,
|
|
43
|
+
ValidationResult,
|
|
44
|
+
installed_version,
|
|
45
|
+
note_unmeasured,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
LOG = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
#: The Boltz-2 release this project pins, audited and installed.
|
|
51
|
+
#:
|
|
52
|
+
#: Single source of truth: ``bindsight.runners.tools.BOLTZ_PIP`` is built from
|
|
53
|
+
#: it. The dependency runs that way round because ``tools`` already imports this
|
|
54
|
+
#: module.
|
|
55
|
+
PINNED_BOLTZ2_VERSION = "2.0.3"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def installed_boltz_version() -> str | None:
|
|
59
|
+
"""The Boltz-2 actually importable here, or ``None`` where there is none.
|
|
60
|
+
|
|
61
|
+
``validator_version`` used to be :data:`PINNED_BOLTZ2_VERSION` written
|
|
62
|
+
straight into every metrics row, which made it a label rather than a record:
|
|
63
|
+
the pin was a range, so any 2.x could have produced a number, and the row
|
|
64
|
+
said ``2.0.1`` regardless. A provenance field that reports a constant is
|
|
65
|
+
worse than an absent one, because it answers the question wrongly instead of
|
|
66
|
+
leaving it open.
|
|
67
|
+
|
|
68
|
+
Returns ``None`` rather than the pin when Boltz is not installed — which is
|
|
69
|
+
the case wherever the parser runs outside the GPU environment. Substituting
|
|
70
|
+
the pinned version there would recreate the same fiction one level down.
|
|
71
|
+
"""
|
|
72
|
+
return installed_version("boltz")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class MissingValidationError(FileNotFoundError):
|
|
76
|
+
"""Raised when validation output for a binder hasn't been produced yet."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_boltz_yaml(
|
|
80
|
+
*,
|
|
81
|
+
target_id: str,
|
|
82
|
+
target_sequence: str,
|
|
83
|
+
binder_id: str,
|
|
84
|
+
binder_sequence: str,
|
|
85
|
+
predict_affinity: bool = True,
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
"""Build the YAML config Boltz-2 expects on stdin.
|
|
88
|
+
|
|
89
|
+
See https://github.com/jwohlwend/boltz/blob/main/docs/prediction.md for
|
|
90
|
+
the full schema. We stay on the documented happy path (two protein chains
|
|
91
|
+
+ one affinity property) so the spec is portable across Boltz-2 minor
|
|
92
|
+
versions.
|
|
93
|
+
"""
|
|
94
|
+
# Boltz-2 chain IDs must be short identifiers (single letters), not our
|
|
95
|
+
# binder_id tracking string ("binder_0_seq0" makes Boltz-2 skip the input).
|
|
96
|
+
# Use target_id for the target chain and a distinct single letter for the
|
|
97
|
+
# binder; binder_id is kept only for our own bookkeeping/filenames.
|
|
98
|
+
binder_chain = "B" if target_id != "B" else "C"
|
|
99
|
+
spec: dict[str, Any] = {
|
|
100
|
+
"version": 1,
|
|
101
|
+
"sequences": [
|
|
102
|
+
{"protein": {"id": target_id, "sequence": target_sequence}},
|
|
103
|
+
{"protein": {"id": binder_chain, "sequence": binder_sequence}},
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
if predict_affinity:
|
|
107
|
+
spec["properties"] = [{"affinity": {"binder": binder_chain}}]
|
|
108
|
+
return spec
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def write_boltz_yaml(spec: dict[str, Any], path: Path) -> Path:
|
|
112
|
+
"""Write a Boltz-2 spec dict to disk as YAML."""
|
|
113
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
path.write_text(yaml.safe_dump(spec, sort_keys=False), encoding="utf-8", newline="\n")
|
|
115
|
+
return path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parse_boltz_output(
|
|
119
|
+
*,
|
|
120
|
+
output_dir: Path,
|
|
121
|
+
binder_id: str,
|
|
122
|
+
target_uniprot: str,
|
|
123
|
+
) -> ValidationResult:
|
|
124
|
+
"""Parse Boltz-2's per-design output directory into a ValidationResult.
|
|
125
|
+
|
|
126
|
+
Boltz-2 writes one ``predictions/<run>/`` directory per spec, containing:
|
|
127
|
+
|
|
128
|
+
- ``confidence_<run>_model_0.json`` — per-residue + interface confidence
|
|
129
|
+
(we extract ``iptm`` and ``ptm``).
|
|
130
|
+
- ``affinity_<run>.json`` (when affinity prediction enabled) —
|
|
131
|
+
``affinity_pred_value`` and ``affinity_probability_binary``.
|
|
132
|
+
|
|
133
|
+
Both files are read; missing fields become ``None`` (not an error — the
|
|
134
|
+
user may have disabled affinity prediction).
|
|
135
|
+
"""
|
|
136
|
+
# Boltz writes to <out_dir>/predictions/<name>/confidence_*.json, so search
|
|
137
|
+
# recursively (rglob) rather than only the top level.
|
|
138
|
+
#
|
|
139
|
+
# *Every* one of them, not the first. With --diffusion_samples > 1 Boltz
|
|
140
|
+
# writes one per draw and ranks them by confidence descending, so
|
|
141
|
+
# ``model_0`` is the best draw rather than a representative one. Reading
|
|
142
|
+
# whichever file turned up first therefore reported a maximum over k — an
|
|
143
|
+
# estimate that improves as more samples are bought, with no change to the
|
|
144
|
+
# design. Averaging is the estimate of the distribution's centre; the best
|
|
145
|
+
# draw is not an estimate of anything.
|
|
146
|
+
confidence_paths = sorted(output_dir.rglob("confidence_*.json"))
|
|
147
|
+
affinity_path = next(output_dir.rglob("affinity_*.json"), None)
|
|
148
|
+
|
|
149
|
+
iptm = pae_interaction = affinity_value = affinity_prob = None
|
|
150
|
+
iptm_samples: list[float] = []
|
|
151
|
+
pae_samples: list[float] = []
|
|
152
|
+
|
|
153
|
+
n_parsed = 0
|
|
154
|
+
for path in confidence_paths:
|
|
155
|
+
try:
|
|
156
|
+
cdata = json.loads(path.read_text(encoding="utf-8"))
|
|
157
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
158
|
+
LOG.warning("failed to parse %s: %s", path, e)
|
|
159
|
+
continue
|
|
160
|
+
n_parsed += 1
|
|
161
|
+
value = _safe_float(cdata.get("iptm"))
|
|
162
|
+
if value is not None:
|
|
163
|
+
iptm_samples.append(value)
|
|
164
|
+
pae = _safe_float(cdata.get("pae_interaction") or cdata.get("interface_pae"))
|
|
165
|
+
if pae is not None:
|
|
166
|
+
pae_samples.append(pae)
|
|
167
|
+
|
|
168
|
+
if iptm_samples:
|
|
169
|
+
iptm = statistics.fmean(iptm_samples)
|
|
170
|
+
if pae_samples:
|
|
171
|
+
pae_interaction = statistics.fmean(pae_samples)
|
|
172
|
+
|
|
173
|
+
if affinity_path is not None:
|
|
174
|
+
try:
|
|
175
|
+
adata = json.loads(affinity_path.read_text(encoding="utf-8"))
|
|
176
|
+
affinity_value = _safe_float(adata.get("affinity_pred_value"))
|
|
177
|
+
affinity_prob = _safe_float(adata.get("affinity_probability_binary"))
|
|
178
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
179
|
+
LOG.warning("failed to parse %s: %s", affinity_path, e)
|
|
180
|
+
|
|
181
|
+
result = ValidationResult(
|
|
182
|
+
binder_id=binder_id,
|
|
183
|
+
target_uniprot=target_uniprot,
|
|
184
|
+
iptm=iptm,
|
|
185
|
+
iptm_n_samples=len(iptm_samples) or None,
|
|
186
|
+
iptm_sd=statistics.stdev(iptm_samples) if len(iptm_samples) > 1 else None,
|
|
187
|
+
pae_interaction=pae_interaction,
|
|
188
|
+
affinity_pred_value=affinity_value,
|
|
189
|
+
affinity_probability_binary=affinity_prob,
|
|
190
|
+
validator_name="boltz2",
|
|
191
|
+
validator_version=installed_boltz_version() or UNRECORDED_VERSION,
|
|
192
|
+
notes=(
|
|
193
|
+
# Files parsed, of files found. This counted the files present, so a
|
|
194
|
+
# directory of unreadable JSON reported the same sample count as a
|
|
195
|
+
# directory of good ones and the row carried no trace of it.
|
|
196
|
+
f"parsed confidence={n_parsed} of {len(confidence_paths)} file(s), "
|
|
197
|
+
f"affinity={'yes' if affinity_path else 'no'}"
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
return note_unmeasured(
|
|
201
|
+
result,
|
|
202
|
+
reason=(
|
|
203
|
+
f"none of the {len(confidence_paths)} confidence file(s) under "
|
|
204
|
+
f"{output_dir} yielded an iptm or pae_interaction value"
|
|
205
|
+
),
|
|
206
|
+
log=LOG,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _safe_float(v: object) -> float | None:
|
|
211
|
+
if v is None:
|
|
212
|
+
return None
|
|
213
|
+
try:
|
|
214
|
+
return float(v) # type: ignore[arg-type]
|
|
215
|
+
except (TypeError, ValueError):
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class Boltz2Validator:
|
|
220
|
+
"""Plugin: Boltz-2 structure + affinity prediction.
|
|
221
|
+
|
|
222
|
+
The validator class itself is a thin parser; the GPU inference happens
|
|
223
|
+
in whichever runner the user picks (Colab / Modal / local Docker). The
|
|
224
|
+
orchestrator (``bindsight validate``) is responsible for shipping the
|
|
225
|
+
spec to the runner and pulling back the JSON outputs into
|
|
226
|
+
``<run>/validate/<binder_id>/``.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
name = "boltz2"
|
|
230
|
+
version = PINNED_BOLTZ2_VERSION
|
|
231
|
+
license_notice = "Boltz-2: MIT (code + weights). Commercial-OK."
|
|
232
|
+
|
|
233
|
+
def validate(
|
|
234
|
+
self,
|
|
235
|
+
target_uniprot: str,
|
|
236
|
+
binder_id: str,
|
|
237
|
+
binder_sequence: str,
|
|
238
|
+
target_structure_path: str,
|
|
239
|
+
) -> ValidationResult:
|
|
240
|
+
"""Look up Boltz-2 output for this binder + parse it.
|
|
241
|
+
|
|
242
|
+
Looks in ``<cwd>/validate/<binder_id>/`` for the JSONs Boltz-2 wrote.
|
|
243
|
+
Raises :class:`MissingValidationError` if not found — the orchestrator
|
|
244
|
+
catches this and prints a hint about running the GPU step first.
|
|
245
|
+
"""
|
|
246
|
+
cwd = Path("validate") / binder_id
|
|
247
|
+
if not cwd.exists():
|
|
248
|
+
raise MissingValidationError(
|
|
249
|
+
f"no Boltz-2 output found at {cwd}; "
|
|
250
|
+
"run the GPU validation step first (see docs/colab-design-howto.md)"
|
|
251
|
+
)
|
|
252
|
+
return parse_boltz_output(
|
|
253
|
+
output_dir=cwd,
|
|
254
|
+
binder_id=binder_id,
|
|
255
|
+
target_uniprot=target_uniprot,
|
|
256
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Chai-1 validator plugin.
|
|
4
|
+
|
|
5
|
+
`Chai-1 <https://github.com/chaidiscovery/chai-lab>`_ (Apache-2) is an
|
|
6
|
+
alternative to Boltz-2 for cross-model agreement. The GPU inference runs in
|
|
7
|
+
:mod:`bindsight.runners.job_exec`; this plugin's :meth:`validate` parses the
|
|
8
|
+
Chai output the runner produced into a :class:`ValidationResult`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from bindsight.validate.boltz2 import MissingValidationError
|
|
16
|
+
from bindsight.validate.protocol import ValidationResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Chai1rValidator:
|
|
20
|
+
"""Plugin: Chai-1 structure + confidence prediction (parser side)."""
|
|
21
|
+
|
|
22
|
+
name = "chai1r"
|
|
23
|
+
version = "0.6"
|
|
24
|
+
license_notice = "Chai-1: Apache-2.0. Commercial-OK."
|
|
25
|
+
|
|
26
|
+
def validate(
|
|
27
|
+
self,
|
|
28
|
+
target_uniprot: str,
|
|
29
|
+
binder_id: str,
|
|
30
|
+
binder_sequence: str,
|
|
31
|
+
target_structure_path: str,
|
|
32
|
+
) -> ValidationResult:
|
|
33
|
+
"""Parse Chai-1 output for this binder from ``validate/<binder_id>/``."""
|
|
34
|
+
from bindsight.runners.tools import parse_chai_output
|
|
35
|
+
|
|
36
|
+
cwd = Path("validate") / binder_id
|
|
37
|
+
if not cwd.exists():
|
|
38
|
+
raise MissingValidationError(
|
|
39
|
+
f"no Chai-1 output found at {cwd}; run the GPU validation step first "
|
|
40
|
+
"(bindsight validate --validator chai1r)"
|
|
41
|
+
)
|
|
42
|
+
return parse_chai_output(cwd, binder_id=binder_id, target_uniprot=target_uniprot)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Validator protocol.
|
|
4
|
+
|
|
5
|
+
Validators take a designed binder (sequence + maybe a structural model) plus
|
|
6
|
+
the target, and predict the complex structure + binding affinity. We treat
|
|
7
|
+
``Boltz-2`` as the default; users can opt into ``Chai-1r`` for cross-model
|
|
8
|
+
agreement or ``AF2-IG`` for the gold-standard Bennet/Baker filtering pipeline
|
|
9
|
+
(PyRosetta is free only for non-commercial use — banner shown at CLI time).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
18
|
+
|
|
19
|
+
#: What a validator reports when its tool's version cannot be established.
|
|
20
|
+
#:
|
|
21
|
+
#: Deliberately not a version-shaped string. Every parser here used to write a
|
|
22
|
+
#: literal into :attr:`ValidationResult.validator_version` — ``"2.0.1"``,
|
|
23
|
+
#: ``"0.6"``, ``"1.0"`` — regardless of what was installed, which made the one
|
|
24
|
+
#: field that looks like provenance answer from a constant. A reader checking
|
|
25
|
+
#: which model produced a result got a confident wrong answer instead of an
|
|
26
|
+
#: obviously missing one, and no substitute that *looks* like a version fixes
|
|
27
|
+
#: that.
|
|
28
|
+
UNRECORDED_VERSION = "unrecorded"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def installed_version(distribution: str) -> str | None:
|
|
32
|
+
"""The installed version of ``distribution``, or ``None`` if it is absent.
|
|
33
|
+
|
|
34
|
+
Validators parse output on whatever machine holds the files, which is not
|
|
35
|
+
always the machine that produced them; ``None`` is the honest answer there,
|
|
36
|
+
and callers turn it into :data:`UNRECORDED_VERSION`.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
distribution: the installed distribution name, e.g. ``"boltz"``.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The version string, or ``None`` when the distribution is not installed.
|
|
43
|
+
"""
|
|
44
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
return version(distribution)
|
|
48
|
+
except PackageNotFoundError:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ValidationResult(BaseModel):
|
|
53
|
+
"""Per-design validation metrics."""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
56
|
+
|
|
57
|
+
binder_id: str
|
|
58
|
+
target_uniprot: str
|
|
59
|
+
iptm: float | None = Field(None, ge=0.0, le=1.0)
|
|
60
|
+
ptm: float | None = Field(
|
|
61
|
+
None,
|
|
62
|
+
ge=0.0,
|
|
63
|
+
le=1.0,
|
|
64
|
+
description=(
|
|
65
|
+
"Predicted TM-score for the complex — a structure-confidence metric, NOT an "
|
|
66
|
+
"affinity. Kept in its own field so it can never be mistaken for one."
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
plddt_binder: float | None = Field(
|
|
70
|
+
None,
|
|
71
|
+
ge=0.0,
|
|
72
|
+
le=100.0,
|
|
73
|
+
description=(
|
|
74
|
+
"Mean pLDDT over the binder chain (0-100 scale) — a structure-confidence "
|
|
75
|
+
"metric, NOT an affinity."
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
pae_interaction: float | None = Field(None, ge=0.0)
|
|
79
|
+
rmsd_to_designed: float | None = Field(None, ge=0.0)
|
|
80
|
+
affinity_pred_value: float | None = Field(
|
|
81
|
+
None,
|
|
82
|
+
description=(
|
|
83
|
+
"Predicted binding affinity from a validator that actually predicts one. "
|
|
84
|
+
"LOWER IS STRONGER: Boltz-2 reports a log(IC50)-like value, so -8.0 is a "
|
|
85
|
+
"tighter binder than -6.0, and the ranker inverts it accordingly. Validators "
|
|
86
|
+
"that do not predict affinity MUST leave this None rather than substituting a "
|
|
87
|
+
"confidence metric — see ptm and plddt_binder."
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
affinity_probability_binary: float | None = Field(
|
|
91
|
+
None,
|
|
92
|
+
ge=0.0,
|
|
93
|
+
le=1.0,
|
|
94
|
+
description="Probability the design is a binder vs. a decoy (early-discovery filter).",
|
|
95
|
+
)
|
|
96
|
+
iptm_n_samples: int | None = Field(
|
|
97
|
+
None,
|
|
98
|
+
ge=1,
|
|
99
|
+
description=(
|
|
100
|
+
"How many independent draws ``iptm`` averages. Boltz-2 builds "
|
|
101
|
+
"structures by diffusion, so one draw is a sample, not a "
|
|
102
|
+
"measurement — a reader cannot otherwise tell a single draw from an "
|
|
103
|
+
"average of five. ``None`` means the run predates this field and "
|
|
104
|
+
"the count is unknown."
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
iptm_sd: float | None = Field(
|
|
108
|
+
None,
|
|
109
|
+
ge=0.0,
|
|
110
|
+
description=(
|
|
111
|
+
"Spread of ``iptm`` across those draws, or ``None`` when there was "
|
|
112
|
+
"only one. This is the metric's own noise, measured on the same "
|
|
113
|
+
"input in the same job, and it is what any difference between two "
|
|
114
|
+
"designs has to be larger than to mean anything."
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
validator_name: str
|
|
118
|
+
validator_version: str
|
|
119
|
+
notes: str | None = None
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def measured(self) -> bool:
|
|
123
|
+
"""True when the validator produced at least one metric.
|
|
124
|
+
|
|
125
|
+
A result whose every metric is ``None`` is still a valid row: it reaches
|
|
126
|
+
metrics.jsonl, the ranker and the report looking exactly like a design
|
|
127
|
+
that was scored and scored badly. It is not the same thing, and the
|
|
128
|
+
difference is invisible unless something says so.
|
|
129
|
+
"""
|
|
130
|
+
return any(
|
|
131
|
+
value is not None
|
|
132
|
+
for value in (
|
|
133
|
+
self.iptm,
|
|
134
|
+
self.ptm,
|
|
135
|
+
self.plddt_binder,
|
|
136
|
+
self.pae_interaction,
|
|
137
|
+
self.affinity_pred_value,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@runtime_checkable
|
|
143
|
+
class Validator(Protocol):
|
|
144
|
+
"""Protocol every validator plugin must implement."""
|
|
145
|
+
|
|
146
|
+
name: str
|
|
147
|
+
version: str
|
|
148
|
+
license_notice: str # Shown by the CLI before each run.
|
|
149
|
+
|
|
150
|
+
def validate(
|
|
151
|
+
self,
|
|
152
|
+
target_uniprot: str,
|
|
153
|
+
binder_id: str,
|
|
154
|
+
binder_sequence: str,
|
|
155
|
+
target_structure_path: str,
|
|
156
|
+
) -> ValidationResult:
|
|
157
|
+
"""Run the validator and return metrics."""
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
#: The phrase a validator's ``notes`` carries when it parsed no metric at all.
|
|
162
|
+
#: Checked across every parser by the test suite, so the guarantee is uniform
|
|
163
|
+
#: rather than whichever parser someone remembered to handle.
|
|
164
|
+
NO_METRICS_NOTE = "no metrics parsed"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def note_unmeasured(
|
|
168
|
+
result: ValidationResult, *, reason: str, log: logging.Logger
|
|
169
|
+
) -> ValidationResult:
|
|
170
|
+
"""Warn and annotate when ``result`` carries no metric; pass it through otherwise.
|
|
171
|
+
|
|
172
|
+
The AF2 initial-guess parser returned an all-``None`` result for a score file
|
|
173
|
+
that was missing, truncated to its header, or written with different column
|
|
174
|
+
names -- three real failure modes, none of which produced a warning or left
|
|
175
|
+
any trace in the row. This is the single place that decides what such a
|
|
176
|
+
result says about itself.
|
|
177
|
+
"""
|
|
178
|
+
if result.measured:
|
|
179
|
+
return result
|
|
180
|
+
log.warning(
|
|
181
|
+
"%s parsed no metrics for %s: %s",
|
|
182
|
+
result.validator_name,
|
|
183
|
+
result.binder_id,
|
|
184
|
+
reason,
|
|
185
|
+
)
|
|
186
|
+
existing = (result.notes or "").strip()
|
|
187
|
+
note = f"{NO_METRICS_NOTE}: {reason}"
|
|
188
|
+
return result.model_copy(update={"notes": f"{existing}; {note}" if existing else note})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# bindsight demo config — runs the discovery half on a REAL TCGA breast-cancer
|
|
2
|
+
# cohort (NIH/GDC). It discovers cell-surface, antibody-tractable antigens that
|
|
3
|
+
# are over-expressed in tumor vs. adjacent normal, ranked with full provenance.
|
|
4
|
+
# Well-known breast-cancer surface antigens (e.g. ERBB2/HER2) surface among the
|
|
5
|
+
# candidates when their signal is present in the sampled cohort; EGFR is often
|
|
6
|
+
# lower in bulk tumor than in normal breast epithelium, so it may rank as a
|
|
7
|
+
# down-regulated DEG rather than a top up-regulated target — that's the real
|
|
8
|
+
# biology, not a scripted result.
|
|
9
|
+
#
|
|
10
|
+
# On first run, `bindsight discover`/`demo` auto-downloads the cohort (STAR -
|
|
11
|
+
# Counts) from the GDC open-access API into the cache below, populates the full
|
|
12
|
+
# SURFY surfaceome list, and enriches the top up-regulated DEGs via Open
|
|
13
|
+
# Targets; subsequent runs are cached. Needs network the first time; takes a few
|
|
14
|
+
# minutes (real DESeq2 + enrichment on real data), not 30 seconds.
|
|
15
|
+
|
|
16
|
+
name: bindsight_demo_tcga_brca
|
|
17
|
+
out_dir: runs/demo
|
|
18
|
+
|
|
19
|
+
inputs:
|
|
20
|
+
# Auto-downloaded from GDC if absent (see bindsight/io/gdc.py). Cached here.
|
|
21
|
+
counts: data/gdc_cache/tcga_brca/counts.tsv.gz
|
|
22
|
+
design: data/gdc_cache/tcga_brca/design.tsv
|
|
23
|
+
download:
|
|
24
|
+
project: TCGA-BRCA
|
|
25
|
+
n_tumor: 20
|
|
26
|
+
n_normal: 20
|
|
27
|
+
gene_types: ["protein_coding"]
|
|
28
|
+
|
|
29
|
+
params:
|
|
30
|
+
deg:
|
|
31
|
+
design_formula: "~ condition"
|
|
32
|
+
contrast: ["condition", "tumor", "normal"]
|
|
33
|
+
fdr_threshold: 0.05
|
|
34
|
+
log2fc_threshold: 1.0
|
|
35
|
+
min_replicates: 3
|
|
36
|
+
min_count: 10
|
|
37
|
+
|
|
38
|
+
target_discovery:
|
|
39
|
+
require_surfy: true
|
|
40
|
+
surfy_allow_offline_fallback: true # attempt full SURFY; fall back if offline
|
|
41
|
+
use_open_targets: true # genuine discovery across the surfaceome
|
|
42
|
+
# The demo is expected to run offline, where Open Targets is unreachable
|
|
43
|
+
# and every safety-event count is unknown. A real run withholds those
|
|
44
|
+
# candidates; the demo accepts them so it still shows a full pipeline
|
|
45
|
+
# without a network. This is a deliberate loosening, stated here rather
|
|
46
|
+
# than hidden in a default.
|
|
47
|
+
open_targets_require_measured: false
|
|
48
|
+
require_tractable_modality: [] # don't pre-filter modality in the demo
|
|
49
|
+
max_safety_events: 100
|
|
50
|
+
require_surface_bind_site: false
|
|
51
|
+
top_n: 10
|
|
52
|
+
|
|
53
|
+
backend: colab
|
|
54
|
+
cheap_profile: false
|