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,899 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Three-way designer benchmark: RFdiffusion+ProteinMPNN vs BindCraft vs BoltzGen.
|
|
4
|
+
|
|
5
|
+
Runs each designer over the *same* target set with the *same* validator (Boltz-2
|
|
6
|
+
by default) on a chosen GPU backend, then tabulates per-designer binder quality
|
|
7
|
+
(ipTM, PAE-interaction, predicted affinity, success rate). The comparison the
|
|
8
|
+
v0.2 validation paper promises.
|
|
9
|
+
|
|
10
|
+
The real three-way comparison needs a GPU — RFdiffusion, BindCraft and BoltzGen
|
|
11
|
+
do not run on CPU — so this module is **CPU-tested with the mock backend** and
|
|
12
|
+
**runs for real on ``--backend modal|local_docker|kaggle``**. With the mock
|
|
13
|
+
backend the numbers are clearly labelled synthetic (CI/orchestration only) and
|
|
14
|
+
are never written as if they were real results.
|
|
15
|
+
|
|
16
|
+
Reuses the production plugin stack verbatim: :mod:`bindsight.plugins` to resolve
|
|
17
|
+
designers/validators/runners, each designer's ``make_spec``/``submit``, and
|
|
18
|
+
:mod:`bindsight.cost` for the GPU-cost estimate — so a green mock run is a
|
|
19
|
+
faithful dry-run of the real GPU job.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import statistics
|
|
27
|
+
import tempfile
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from bindsight import __version__
|
|
34
|
+
from bindsight import cost as cost_mod
|
|
35
|
+
from bindsight.benchmark.statistics import cluster_bootstrap_interval, wilson_interval
|
|
36
|
+
from bindsight.plugins import get_designer, get_runner, runner_accepts
|
|
37
|
+
|
|
38
|
+
LOG = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
DEFAULT_DESIGNERS = ("rfdiff_mpnn", "bindcraft", "boltzgen")
|
|
41
|
+
DEFAULT_IPTM_SUCCESS = 0.65
|
|
42
|
+
|
|
43
|
+
#: Withdrawal notice attached to every rendering of success@0.65.
|
|
44
|
+
#:
|
|
45
|
+
#: The threshold had no citation, so the project measured what it is worth
|
|
46
|
+
#: (``benchmarks/calibration``). The answer is that it does not currently
|
|
47
|
+
#: separate a design from a shuffle of itself, which makes the rate above a
|
|
48
|
+
#: measurement of the pipeline rather than of the designs. The number is not
|
|
49
|
+
#: deleted — it is what Boltz-2 returned, and deleting it would hide the
|
|
50
|
+
#: finding rather than state it — but it must not travel without this.
|
|
51
|
+
#:
|
|
52
|
+
#: One string, referenced by every surface that renders the rate, so the caveat
|
|
53
|
+
#: cannot survive in one place and go stale in another. A test fails if a
|
|
54
|
+
#: document states the rate without it.
|
|
55
|
+
#: The designer-benchmark summary schema. Named because two writers emit it —
|
|
56
|
+
#: this module and benchmarks/designer_benchmark/score_run.py — and a bumped
|
|
57
|
+
#: version in one would silently disagree with the other.
|
|
58
|
+
DESIGNER_BENCHMARK_SCHEMA = "bindsight-designer-benchmark/1"
|
|
59
|
+
|
|
60
|
+
IPTM_CALIBRATION_CAVEAT = (
|
|
61
|
+
"> **This success rate is withdrawn as a measure of design quality.** "
|
|
62
|
+
"A paired control folded each of these twenty designs in one job alongside "
|
|
63
|
+
"a shuffle of its own sequence — same length, same amino-acid composition, "
|
|
64
|
+
"same target, same validator, same card, same session. Under a seeded "
|
|
65
|
+
"validator averaging five diffusion draws per binder, the **shuffles pass "
|
|
66
|
+
"more often than the designs**: 50% of shuffles clear 0.65 against 30% of "
|
|
67
|
+
"designs. The paired difference is −0.043 (95% CI −0.142 to +0.054, exact "
|
|
68
|
+
"sign-flip p = 0.40), and 9 of 20 designs beat their own shuffle where 10 "
|
|
69
|
+
"is chance.\n"
|
|
70
|
+
">\n"
|
|
71
|
+
"> Two causes were found in the pipeline and fixed — the validator and the "
|
|
72
|
+
"designer were both invoked without a seed, so every number in this table "
|
|
73
|
+
"is a single unseeded draw of a sequence that was itself an unseeded draw. "
|
|
74
|
+
"Refolding these twenty sequences moves ipTM by a median of 0.172, against "
|
|
75
|
+
"a measured per-draw spread of 0.139. But the fixes did not rescue the "
|
|
76
|
+
"result: five times the sampling effort moved it slightly further against "
|
|
77
|
+
"the designs, and 85% of the remaining spread is real pair-to-pair "
|
|
78
|
+
"variation that no amount of resampling reduces.\n"
|
|
79
|
+
">\n"
|
|
80
|
+
"> The numbers in this table are real Boltz-2 outputs. What is withdrawn is "
|
|
81
|
+
"the claim that the rate measures the designs. See "
|
|
82
|
+
"`benchmarks/calibration/README.md`.\n"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Minimal valid PDB so the mock/dry-run path has a structure to ship without a
|
|
86
|
+
# network fetch. Real GPU runs pass a true AlphaFold/PDB target structure.
|
|
87
|
+
_PLACEHOLDER_PDB = (
|
|
88
|
+
"ATOM 1 CA GLY A 1 0.000 0.000 0.000 1.00 0.00 C\n"
|
|
89
|
+
"ATOM 2 CA SER A 2 3.800 0.000 0.000 1.00 0.00 C\n"
|
|
90
|
+
"ATOM 3 CA HIS A 3 7.600 0.000 0.000 1.00 0.00 C\n"
|
|
91
|
+
"END\n"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class Target:
|
|
97
|
+
"""One antigen to design binders against."""
|
|
98
|
+
|
|
99
|
+
uniprot: str
|
|
100
|
+
symbol: str
|
|
101
|
+
epitope_residues: list[int] = field(default_factory=list)
|
|
102
|
+
epitope_chain: str = "A"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Default target set: the held-out known antigens (benchmarks/known.tsv). Epitope
|
|
106
|
+
# residues are left empty (whole-target design) until SURFACE-Bind epitope
|
|
107
|
+
# prediction lands in v0.2 — which is honest and valid per DesignSpec.
|
|
108
|
+
DEFAULT_TARGETS = [
|
|
109
|
+
Target("P04626", "ERBB2"),
|
|
110
|
+
Target("P00533", "EGFR"),
|
|
111
|
+
Target("Q13421", "MSLN"),
|
|
112
|
+
Target("P20138", "CD33"),
|
|
113
|
+
Target("P26951", "IL3RA"),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class DesignerScore:
|
|
119
|
+
"""Aggregate binder-quality metrics for one designer over the target set."""
|
|
120
|
+
|
|
121
|
+
designer: str
|
|
122
|
+
n_targets: int = 0
|
|
123
|
+
n_designs: int = 0
|
|
124
|
+
mean_iptm: float | None = None
|
|
125
|
+
median_iptm: float | None = None
|
|
126
|
+
mean_pae_interaction: float | None = None
|
|
127
|
+
mean_affinity: float | None = None
|
|
128
|
+
success_rate: float | None = None # fraction of designs with ipTM >= threshold
|
|
129
|
+
# The numerator and a Wilson interval for it. Two runs of the same target
|
|
130
|
+
# differing only in seed returned 2/20 and 6/20 - 10% and 30% - which a
|
|
131
|
+
# Fisher exact test cannot separate (p = 0.24). A bare percentage from
|
|
132
|
+
# twenty designs claims a precision the sample does not carry, so the
|
|
133
|
+
# interval travels with it.
|
|
134
|
+
n_success: int | None = None
|
|
135
|
+
# The denominator the rate was actually computed over: designs the validator
|
|
136
|
+
# returned a confidence for. It can be smaller than n_designs, because a
|
|
137
|
+
# design Boltz-2 skipped has no ipTM to compare against the threshold. The
|
|
138
|
+
# cell used to print n_success/n_designs beside a percentage computed over
|
|
139
|
+
# this, so with any unfolded design the fraction and the percentage
|
|
140
|
+
# disagreed — 8/20 rendered beside a rate of 8/16.
|
|
141
|
+
n_scored: int | None = None
|
|
142
|
+
# The reported interval, clustered over backbones. See _success_intervals.
|
|
143
|
+
success_ci_low: float | None = None
|
|
144
|
+
success_ci_high: float | None = None
|
|
145
|
+
success_ci_method: str | None = None
|
|
146
|
+
# The same interval computed as if every design were independent, kept so a
|
|
147
|
+
# reader can see how much the clustering costs rather than taking it on
|
|
148
|
+
# trust. Never the reported figure.
|
|
149
|
+
success_ci_independent_low: float | None = None
|
|
150
|
+
success_ci_independent_high: float | None = None
|
|
151
|
+
# Backbone-level view: how many RFdiffusion trajectories yielded any design
|
|
152
|
+
# over the bar. This is the number of independent attempts that worked.
|
|
153
|
+
n_backbones: int | None = None
|
|
154
|
+
n_backbones_with_success: int | None = None
|
|
155
|
+
per_target: list[dict[str, Any]] = field(default_factory=list)
|
|
156
|
+
# Results tarballs this designer produced, so the binder artifacts can be
|
|
157
|
+
# staged after the run. Excluded from the serialised summary: a local scratch
|
|
158
|
+
# path is not a published result.
|
|
159
|
+
archives: list[Path] = field(default_factory=list)
|
|
160
|
+
#: Estimated by :mod:`bindsight.cost` before the run, not measured.
|
|
161
|
+
#: :attr:`wall_seconds` is the measurement; the table says which is which.
|
|
162
|
+
cost_usd: float | None = None
|
|
163
|
+
gpu_hours: float | None = None
|
|
164
|
+
#: Measured: this designer's own wall-clock for its whole pass.
|
|
165
|
+
wall_seconds: float | None = None
|
|
166
|
+
error: str | None = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Structure resolution
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
#: Where prepared target structures live by default. Leaving this unset used to
|
|
173
|
+
#: mean "write a placeholder", which is catastrophic on a real backend.
|
|
174
|
+
DEFAULT_STRUCTURES_DIR = Path("data/target_structures")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _resolve_structure(
|
|
178
|
+
target: Target,
|
|
179
|
+
structures_dir: Path | None,
|
|
180
|
+
scratch: Path,
|
|
181
|
+
*,
|
|
182
|
+
allow_placeholder: bool = False,
|
|
183
|
+
) -> Path:
|
|
184
|
+
"""Return a real target structure, or fail.
|
|
185
|
+
|
|
186
|
+
The placeholder is a three-residue stub. It exists so the harness can run
|
|
187
|
+
offline on ``--backend mock``, and it is worthless anywhere else: RFdiffusion
|
|
188
|
+
will happily design binders against three residues, Boltz-2 will score them,
|
|
189
|
+
and the run will report ipTM figures for a target that was never present.
|
|
190
|
+
That is the most expensive kind of silent failure this project can produce —
|
|
191
|
+
it costs GPU hours and yields numbers that look real.
|
|
192
|
+
|
|
193
|
+
So ``allow_placeholder`` is False by default and only the mock backend passes
|
|
194
|
+
True. A real backend with no resolvable structure raises.
|
|
195
|
+
|
|
196
|
+
Resolution order: the structures directory, then AlphaFoldDB. The directory
|
|
197
|
+
is consulted even when the caller passed none, because the default location
|
|
198
|
+
is where ``prepare_erbb2_target.py`` writes.
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
FileNotFoundError: On a real backend when no structure could be resolved.
|
|
202
|
+
"""
|
|
203
|
+
search_dir = structures_dir if structures_dir is not None else DEFAULT_STRUCTURES_DIR
|
|
204
|
+
for ext in (".cif", ".pdb", ".mmcif"):
|
|
205
|
+
cand = Path(search_dir) / f"{target.uniprot}{ext}"
|
|
206
|
+
if cand.exists():
|
|
207
|
+
LOG.info("%s: using %s", target.symbol, cand)
|
|
208
|
+
return cand
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
from bindsight.structures.alphafolddb import AlphaFoldDBClient
|
|
212
|
+
|
|
213
|
+
fetched = AlphaFoldDBClient().fetch(target.uniprot)
|
|
214
|
+
if fetched is not None:
|
|
215
|
+
LOG.info("%s: using AlphaFold model %s", target.symbol, fetched)
|
|
216
|
+
return Path(fetched)
|
|
217
|
+
except Exception as e: # pragma: no cover - network edge cases
|
|
218
|
+
LOG.warning("AlphaFold fetch failed for %s: %s", target.uniprot, e)
|
|
219
|
+
|
|
220
|
+
if not allow_placeholder:
|
|
221
|
+
raise FileNotFoundError(
|
|
222
|
+
f"no structure for {target.symbol} ({target.uniprot}). Looked in "
|
|
223
|
+
f"{search_dir}/ and AlphaFoldDB. A real backend will not fall back to "
|
|
224
|
+
"the three-residue placeholder: designing against it burns GPU hours "
|
|
225
|
+
"and produces ipTM figures for a target that was never there. Prepare "
|
|
226
|
+
"the structure first, e.g. "
|
|
227
|
+
"`python benchmarks/designer_benchmark/prepare_erbb2_target.py`."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
LOG.warning(
|
|
231
|
+
"%s: no structure found; writing the three-residue placeholder. This is "
|
|
232
|
+
"only meaningful on the mock backend.",
|
|
233
|
+
target.symbol,
|
|
234
|
+
)
|
|
235
|
+
placeholder = scratch / f"{target.uniprot}.pdb"
|
|
236
|
+
placeholder.write_text(_PLACEHOLDER_PDB, encoding="utf-8", newline="\n")
|
|
237
|
+
return placeholder
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# Metrics parsing
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
def _read_metrics(metrics_jsonl: Path) -> list[dict[str, Any]]:
|
|
244
|
+
if not metrics_jsonl.exists():
|
|
245
|
+
# Said out loud. An absent metrics file scores the run as zero designs,
|
|
246
|
+
# which is the same published shape as a run that produced none — and
|
|
247
|
+
# this function already goes to some length for the lesser case, naming
|
|
248
|
+
# every unparseable *line* below. A missing file loses every line.
|
|
249
|
+
LOG.warning(
|
|
250
|
+
"%s: no metrics file, so this run scores as zero designs. That is not "
|
|
251
|
+
"the same as a run that designed nothing: nothing was read.",
|
|
252
|
+
metrics_jsonl,
|
|
253
|
+
)
|
|
254
|
+
return []
|
|
255
|
+
rows = []
|
|
256
|
+
skipped: list[int] = []
|
|
257
|
+
for lineno, line in enumerate(metrics_jsonl.read_text(encoding="utf-8").splitlines(), start=1):
|
|
258
|
+
line = line.strip()
|
|
259
|
+
if not line:
|
|
260
|
+
continue
|
|
261
|
+
try:
|
|
262
|
+
rows.append(json.loads(line))
|
|
263
|
+
except json.JSONDecodeError:
|
|
264
|
+
# Named, not swallowed. Every dropped line changes n_designs, the
|
|
265
|
+
# mean ipTM and the success rate, and the old ``continue`` left no
|
|
266
|
+
# trace of it anywhere: a truncated file scored as a smaller run.
|
|
267
|
+
skipped.append(lineno)
|
|
268
|
+
if skipped:
|
|
269
|
+
LOG.warning(
|
|
270
|
+
"%s: %d unparseable line(s) skipped (line %s); n_designs and every "
|
|
271
|
+
"rate below are computed over the %d that parsed",
|
|
272
|
+
metrics_jsonl,
|
|
273
|
+
len(skipped),
|
|
274
|
+
", ".join(str(n) for n in skipped[:10]),
|
|
275
|
+
len(rows),
|
|
276
|
+
)
|
|
277
|
+
return rows
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _floats(rows: list[dict[str, Any]], key: str) -> list[float]:
|
|
281
|
+
out = []
|
|
282
|
+
for r in rows:
|
|
283
|
+
v = r.get(key)
|
|
284
|
+
if isinstance(v, (int, float)):
|
|
285
|
+
out.append(float(v))
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Run one designer over the target set
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
def run_one_designer(
|
|
293
|
+
designer_name: str,
|
|
294
|
+
targets: list[Target],
|
|
295
|
+
*,
|
|
296
|
+
backend: str,
|
|
297
|
+
validator: str,
|
|
298
|
+
n_trajectories: int,
|
|
299
|
+
seed: int,
|
|
300
|
+
structures_dir: Path | None,
|
|
301
|
+
scratch: Path,
|
|
302
|
+
bindsight_wheel: Path | None = None,
|
|
303
|
+
) -> DesignerScore:
|
|
304
|
+
"""Design + validate binders for every target with one designer; aggregate."""
|
|
305
|
+
score = DesignerScore(designer=designer_name, n_targets=len(targets))
|
|
306
|
+
# Wall-clock for this designer's whole pass. The table's GPU-hours column was
|
|
307
|
+
# the cost model's forecast, printed beside measured ipTMs with no marking,
|
|
308
|
+
# and the run it described recorded nothing to check it against.
|
|
309
|
+
started = time.monotonic()
|
|
310
|
+
try:
|
|
311
|
+
designer = get_designer(designer_name)
|
|
312
|
+
# get_runner forwards only the kwargs a given runner accepts, so this is
|
|
313
|
+
# inert for backends that install nothing.
|
|
314
|
+
runner = get_runner(backend, bindsight_wheel=bindsight_wheel)
|
|
315
|
+
except Exception as e:
|
|
316
|
+
score.error = f"plugin load failed: {e!r}"
|
|
317
|
+
return score
|
|
318
|
+
|
|
319
|
+
all_iptm: list[float] = []
|
|
320
|
+
all_pae: list[float] = []
|
|
321
|
+
all_aff: list[float] = []
|
|
322
|
+
outcomes_by_backbone: dict[str, list[bool]] = {}
|
|
323
|
+
archives: list[Path] = []
|
|
324
|
+
n_designs = 0
|
|
325
|
+
|
|
326
|
+
for target in targets:
|
|
327
|
+
try:
|
|
328
|
+
struct = _resolve_structure(
|
|
329
|
+
target, structures_dir, scratch, allow_placeholder=(backend == "mock")
|
|
330
|
+
)
|
|
331
|
+
except FileNotFoundError as e:
|
|
332
|
+
# Abort the whole designer: submitting the remaining targets would
|
|
333
|
+
# spend GPU time on a run whose summary is already incomplete.
|
|
334
|
+
LOG.error("%s", e)
|
|
335
|
+
score.error = str(e)
|
|
336
|
+
return score
|
|
337
|
+
spec = designer.make_spec(
|
|
338
|
+
target_uniprot=target.uniprot,
|
|
339
|
+
target_structure_path=struct,
|
|
340
|
+
epitope_residues=target.epitope_residues,
|
|
341
|
+
epitope_chain=target.epitope_chain,
|
|
342
|
+
n_trajectories=n_trajectories,
|
|
343
|
+
seed=seed,
|
|
344
|
+
)
|
|
345
|
+
# Pin the validator so the remote executor validates identically across
|
|
346
|
+
# designers (job_exec reads extra_params['validator']).
|
|
347
|
+
spec = spec.model_copy(
|
|
348
|
+
update={"extra_params": {**spec.extra_params, "validator": validator}}
|
|
349
|
+
)
|
|
350
|
+
try:
|
|
351
|
+
result = designer.submit(spec, runner)
|
|
352
|
+
except Exception as e:
|
|
353
|
+
LOG.warning("%s/%s submit failed: %s", designer_name, target.symbol, e)
|
|
354
|
+
score.per_target.append({"symbol": target.symbol, "error": repr(e)})
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
archives.append(Path(result.results_archive_path))
|
|
358
|
+
rows = _read_metrics(Path(result.metrics_jsonl_path))
|
|
359
|
+
iptm = _floats(rows, "iptm")
|
|
360
|
+
pae = _floats(rows, "pae_interaction")
|
|
361
|
+
aff = _floats(rows, "affinity_pred_value")
|
|
362
|
+
all_iptm += iptm
|
|
363
|
+
all_pae += pae
|
|
364
|
+
all_aff += aff
|
|
365
|
+
for row in rows:
|
|
366
|
+
value = row.get("iptm")
|
|
367
|
+
if value is None:
|
|
368
|
+
continue
|
|
369
|
+
key = _backbone_of(str(row.get("binder_id", "")))
|
|
370
|
+
outcomes_by_backbone.setdefault(key, []).append(float(value) >= DEFAULT_IPTM_SUCCESS)
|
|
371
|
+
n_designs += len(rows)
|
|
372
|
+
score.per_target.append(
|
|
373
|
+
{
|
|
374
|
+
"symbol": target.symbol,
|
|
375
|
+
"uniprot": target.uniprot,
|
|
376
|
+
"n_designs": len(rows),
|
|
377
|
+
"mean_iptm": round(statistics.fmean(iptm), 4) if iptm else None,
|
|
378
|
+
"mean_affinity": round(statistics.fmean(aff), 4) if aff else None,
|
|
379
|
+
}
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
score.n_designs = n_designs
|
|
383
|
+
if all_iptm:
|
|
384
|
+
score.mean_iptm = round(statistics.fmean(all_iptm), 4)
|
|
385
|
+
score.median_iptm = round(statistics.median(all_iptm), 4)
|
|
386
|
+
n_ok = sum(1 for v in all_iptm if v >= DEFAULT_IPTM_SUCCESS)
|
|
387
|
+
score.success_rate = round(n_ok / len(all_iptm), 4)
|
|
388
|
+
score.n_success = n_ok
|
|
389
|
+
score.n_scored = len(all_iptm)
|
|
390
|
+
for field, value in _success_intervals(outcomes_by_backbone).items():
|
|
391
|
+
setattr(score, field, value)
|
|
392
|
+
if all_pae:
|
|
393
|
+
score.mean_pae_interaction = round(statistics.fmean(all_pae), 4)
|
|
394
|
+
if all_aff:
|
|
395
|
+
score.mean_affinity = round(statistics.fmean(all_aff), 4)
|
|
396
|
+
|
|
397
|
+
# Cost estimate for the real GPU job (zero on mock).
|
|
398
|
+
try:
|
|
399
|
+
_d, _v, combined = cost_mod.estimate_full_run(
|
|
400
|
+
backend=backend,
|
|
401
|
+
designer=designer_name,
|
|
402
|
+
validator=validator,
|
|
403
|
+
n_targets=len(targets),
|
|
404
|
+
n_trajectories=n_trajectories,
|
|
405
|
+
)
|
|
406
|
+
score.cost_usd = combined.usd_estimate
|
|
407
|
+
score.gpu_hours = combined.gpu_hours
|
|
408
|
+
except Exception as e: # pragma: no cover - unknown plugin/backend
|
|
409
|
+
LOG.warning("cost estimate failed for %s: %s", designer_name, e)
|
|
410
|
+
score.wall_seconds = round(time.monotonic() - started, 1)
|
|
411
|
+
|
|
412
|
+
score.archives = archives
|
|
413
|
+
return score
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
# Orchestration + reporting
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
def stage_binder_artifacts(archives: list[Path], out_dir: Path) -> dict[str, int]:
|
|
420
|
+
"""Replace ``out_dir/binders/`` with the artifacts these archives contain.
|
|
421
|
+
|
|
422
|
+
A benchmark that rewrites ``results.json`` but leaves the previous run's
|
|
423
|
+
structures in place produces a directory whose metrics and whose molecules
|
|
424
|
+
describe different runs. The Real-results page renders those structures, so
|
|
425
|
+
the mismatch would be published as one coherent result. Staging is therefore
|
|
426
|
+
destructive by design: superseded ``.fasta`` and ``_complex.cif`` files are
|
|
427
|
+
removed before the new ones are written.
|
|
428
|
+
|
|
429
|
+
Files a run does not produce — developability descriptors, embedding
|
|
430
|
+
coordinates — are left alone, because they are derived by separate scripts
|
|
431
|
+
from whatever binders are present and are regenerated by running those.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
archives: results tarballs from the run, in target order.
|
|
435
|
+
out_dir: the benchmark directory holding ``binders/``.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
Counts of what was removed and written.
|
|
439
|
+
"""
|
|
440
|
+
import tarfile
|
|
441
|
+
|
|
442
|
+
binders = Path(out_dir) / "binders"
|
|
443
|
+
binders.mkdir(parents=True, exist_ok=True)
|
|
444
|
+
|
|
445
|
+
removed = 0
|
|
446
|
+
for stale in list(binders.glob("*.fasta")) + list(binders.glob("*_complex.cif")):
|
|
447
|
+
stale.unlink()
|
|
448
|
+
removed += 1
|
|
449
|
+
|
|
450
|
+
written = 0
|
|
451
|
+
metrics_lines: list[str] = []
|
|
452
|
+
for archive in archives:
|
|
453
|
+
if not Path(archive).is_file():
|
|
454
|
+
LOG.warning("results archive missing, cannot stage: %s", archive)
|
|
455
|
+
continue
|
|
456
|
+
with tarfile.open(archive, "r:gz", encoding="utf-8") as tf:
|
|
457
|
+
for member in tf.getmembers():
|
|
458
|
+
if not member.isfile():
|
|
459
|
+
continue
|
|
460
|
+
name = Path(member.name).name
|
|
461
|
+
src = tf.extractfile(member)
|
|
462
|
+
if src is None:
|
|
463
|
+
continue
|
|
464
|
+
if member.name.startswith("design/") and name.endswith(".fasta"):
|
|
465
|
+
(binders / name).write_bytes(src.read())
|
|
466
|
+
written += 1
|
|
467
|
+
elif member.name.startswith("validate/") and name.endswith(".cif"):
|
|
468
|
+
# Boltz-2 writes the predicted complex under validate/<binder_id>/.
|
|
469
|
+
binder_id = Path(member.name).parts[1]
|
|
470
|
+
(binders / f"{binder_id}_complex.cif").write_bytes(src.read())
|
|
471
|
+
written += 1
|
|
472
|
+
elif name == "metrics.jsonl":
|
|
473
|
+
metrics_lines += [ln for ln in src.read().decode().splitlines() if ln.strip()]
|
|
474
|
+
|
|
475
|
+
if metrics_lines:
|
|
476
|
+
(binders / "metrics.jsonl").write_text(
|
|
477
|
+
"\n".join(metrics_lines) + "\n", encoding="utf-8", newline="\n"
|
|
478
|
+
)
|
|
479
|
+
LOG.info("staged %d binder artifact(s); removed %d superseded", written, removed)
|
|
480
|
+
return {"written": written, "removed": removed}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def run_designer_benchmark(
|
|
484
|
+
*,
|
|
485
|
+
out_dir: Path,
|
|
486
|
+
backend: str,
|
|
487
|
+
designers: tuple[str, ...] = DEFAULT_DESIGNERS,
|
|
488
|
+
validator: str = "boltz2",
|
|
489
|
+
targets: list[Target] | None = None,
|
|
490
|
+
n_trajectories: int = 50,
|
|
491
|
+
seed: int = 42,
|
|
492
|
+
structures_dir: Path | None = None,
|
|
493
|
+
bindsight_wheel: Path | None = None,
|
|
494
|
+
) -> dict[str, Any]:
|
|
495
|
+
"""Run every designer over the target set and write results + a summary table.
|
|
496
|
+
|
|
497
|
+
Writes ``results.json`` and ``RESULTS.md`` under ``out_dir``. On ``mock`` the
|
|
498
|
+
summary is clearly marked synthetic.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
out_dir: directory to write ``results.json``, ``RESULTS.md`` and staged
|
|
502
|
+
binder artifacts into.
|
|
503
|
+
backend: runner backend to submit design jobs to (``kaggle``, ``modal``,
|
|
504
|
+
``local_docker``, ``mock``).
|
|
505
|
+
designers: designer plugins to score, one arm each.
|
|
506
|
+
validator: validator plugin used to score every design.
|
|
507
|
+
targets: targets to design against; ``DEFAULT_TARGETS`` when omitted.
|
|
508
|
+
n_trajectories: backbones per target, per designer.
|
|
509
|
+
seed: seed forwarded to each designer.
|
|
510
|
+
structures_dir: directory of prepared target structures. A real backend
|
|
511
|
+
errors rather than falling back to the placeholder.
|
|
512
|
+
bindsight_wheel: a wheel built from the working tree, embedded in the
|
|
513
|
+
remote job so the GPU runs this code. Without it a remote backend
|
|
514
|
+
pip-installs bindsight from git and silently exercises the default
|
|
515
|
+
branch, which is how a benchmark launched to validate a fix came
|
|
516
|
+
back having run the unfixed code.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
The summary dict that was written to ``results.json``.
|
|
520
|
+
"""
|
|
521
|
+
import datetime as _dt
|
|
522
|
+
|
|
523
|
+
out_dir = Path(out_dir)
|
|
524
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
525
|
+
targets = targets or DEFAULT_TARGETS
|
|
526
|
+
|
|
527
|
+
scores: list[DesignerScore] = []
|
|
528
|
+
with tempfile.TemporaryDirectory(prefix="bindsight_designerbench_") as tmp:
|
|
529
|
+
scratch = Path(tmp)
|
|
530
|
+
for name in designers:
|
|
531
|
+
LOG.info("=== designer benchmark: %s (%d targets) ===", name, len(targets))
|
|
532
|
+
scores.append(
|
|
533
|
+
run_one_designer(
|
|
534
|
+
name,
|
|
535
|
+
targets,
|
|
536
|
+
backend=backend,
|
|
537
|
+
validator=validator,
|
|
538
|
+
n_trajectories=n_trajectories,
|
|
539
|
+
seed=seed,
|
|
540
|
+
bindsight_wheel=bindsight_wheel,
|
|
541
|
+
structures_dir=structures_dir,
|
|
542
|
+
scratch=scratch,
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
is_mock = backend == "mock"
|
|
547
|
+
# Never stage mock output into the committed binder directory: the whole
|
|
548
|
+
# point of that directory is that it holds real predicted complexes.
|
|
549
|
+
if not is_mock:
|
|
550
|
+
archives = [a for sc in scores for a in getattr(sc, "archives", [])]
|
|
551
|
+
if archives:
|
|
552
|
+
stage_binder_artifacts(archives, out_dir)
|
|
553
|
+
summary = {
|
|
554
|
+
"schema": DESIGNER_BENCHMARK_SCHEMA,
|
|
555
|
+
"generated_utc": _dt.datetime.now(_dt.UTC).isoformat(timespec="seconds"),
|
|
556
|
+
"bindsight_version": __version__,
|
|
557
|
+
"backend": backend,
|
|
558
|
+
"validator": validator,
|
|
559
|
+
"n_trajectories": n_trajectories,
|
|
560
|
+
# The parameter that decides which draw this is. It was the only input
|
|
561
|
+
# the summary did not record — backend, validator, trajectory count,
|
|
562
|
+
# version and wheel were all here — so a run could name everything about
|
|
563
|
+
# itself except the thing that would let someone reproduce it.
|
|
564
|
+
"seed": seed,
|
|
565
|
+
"is_mock": is_mock,
|
|
566
|
+
# Which bindsight actually ran. A result that cannot name its own code
|
|
567
|
+
# is not reproducible, and "the default branch at some past moment" is
|
|
568
|
+
# not a name.
|
|
569
|
+
"bindsight_source": _bindsight_source(backend, bindsight_wheel, is_mock=is_mock),
|
|
570
|
+
"targets": [t.symbol for t in targets],
|
|
571
|
+
"designers": [_score_dict(s) for s in scores],
|
|
572
|
+
}
|
|
573
|
+
if _would_erase_a_real_result(out_dir, summary):
|
|
574
|
+
salvage = out_dir / "results.failed.json"
|
|
575
|
+
salvage.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8", newline="\n")
|
|
576
|
+
LOG.error(
|
|
577
|
+
"this run produced no designs, and %s records a run that did. Refusing "
|
|
578
|
+
"to overwrite it; the failed run is saved to %s. Fix the cause and "
|
|
579
|
+
"re-run, or delete the committed result deliberately if it is genuinely "
|
|
580
|
+
"superseded.",
|
|
581
|
+
out_dir / "results.json",
|
|
582
|
+
salvage,
|
|
583
|
+
)
|
|
584
|
+
return summary
|
|
585
|
+
|
|
586
|
+
(out_dir / "results.json").write_text(
|
|
587
|
+
json.dumps(summary, indent=2) + "\n", encoding="utf-8", newline="\n"
|
|
588
|
+
)
|
|
589
|
+
(out_dir / "RESULTS.md").write_text(_render_md(summary), encoding="utf-8", newline="\n")
|
|
590
|
+
LOG.info("designer benchmark complete; wrote %s", out_dir)
|
|
591
|
+
return summary
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _design_count(summary: dict[str, Any]) -> int:
|
|
595
|
+
"""Total designs across every arm of a benchmark summary."""
|
|
596
|
+
return sum(int(d.get("n_designs") or 0) for d in summary.get("designers", []))
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _would_erase_a_real_result(out_dir: Path, summary: dict[str, Any]) -> bool:
|
|
600
|
+
"""True when writing ``summary`` would replace a real result with an empty one.
|
|
601
|
+
|
|
602
|
+
A benchmark that fails after the GPU work is done still reaches the write,
|
|
603
|
+
and an empty summary is a perfectly well-formed one. That is not
|
|
604
|
+
hypothetical: a corrected ERBB2 re-run completed on the T4, produced its
|
|
605
|
+
tarball, and then the harness hit an encoding error writing the kernel log
|
|
606
|
+
and recorded zero designs — which promptly overwrote twenty committed
|
|
607
|
+
binders' worth of measurements with dashes, while ``binders/`` still held
|
|
608
|
+
the structures those measurements described.
|
|
609
|
+
|
|
610
|
+
Refusing the write is the conservative direction. The two ways to lose data
|
|
611
|
+
here are not symmetric: a stale-but-real result is visibly stale and can be
|
|
612
|
+
replaced deliberately, whereas an overwritten one is gone.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
out_dir: the benchmark directory.
|
|
616
|
+
summary: the summary this run would write.
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
True if the existing ``results.json`` records designs and this one does not.
|
|
620
|
+
"""
|
|
621
|
+
if _design_count(summary) > 0:
|
|
622
|
+
return False
|
|
623
|
+
existing = out_dir / "results.json"
|
|
624
|
+
if not existing.is_file():
|
|
625
|
+
return False
|
|
626
|
+
try:
|
|
627
|
+
prior = json.loads(existing.read_text(encoding="utf-8"))
|
|
628
|
+
except (OSError, json.JSONDecodeError):
|
|
629
|
+
# An unreadable prior result is not evidence worth protecting.
|
|
630
|
+
return False
|
|
631
|
+
return _design_count(prior) > 0
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _score_dict(s: DesignerScore) -> dict[str, Any]:
|
|
635
|
+
return {
|
|
636
|
+
"designer": s.designer,
|
|
637
|
+
"n_targets": s.n_targets,
|
|
638
|
+
"n_designs": s.n_designs,
|
|
639
|
+
"n_scored": s.n_scored,
|
|
640
|
+
# The denominator the success rate was actually computed over.
|
|
641
|
+
# `_success_cell` reads it and discloses any gap from n_designs --
|
|
642
|
+
# but it was never written here, so the fallback always fired and the
|
|
643
|
+
# cell printed "8/20 = 50%", a rate over 16 beside a denominator of
|
|
644
|
+
# 20. The two numbers in one cell did not describe the same thing.
|
|
645
|
+
"mean_iptm": s.mean_iptm,
|
|
646
|
+
"median_iptm": s.median_iptm,
|
|
647
|
+
"mean_pae_interaction": s.mean_pae_interaction,
|
|
648
|
+
"mean_affinity": s.mean_affinity,
|
|
649
|
+
"success_rate": s.success_rate,
|
|
650
|
+
"n_success": s.n_success,
|
|
651
|
+
"success_ci_low": s.success_ci_low,
|
|
652
|
+
"success_ci_high": s.success_ci_high,
|
|
653
|
+
"success_ci_method": s.success_ci_method,
|
|
654
|
+
"success_ci_independent_low": s.success_ci_independent_low,
|
|
655
|
+
"success_ci_independent_high": s.success_ci_independent_high,
|
|
656
|
+
"n_backbones": s.n_backbones,
|
|
657
|
+
"n_backbones_with_success": s.n_backbones_with_success,
|
|
658
|
+
"cost_usd": s.cost_usd,
|
|
659
|
+
"gpu_hours": s.gpu_hours,
|
|
660
|
+
"wall_seconds": s.wall_seconds,
|
|
661
|
+
"per_target": s.per_target,
|
|
662
|
+
"error": s.error,
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _backbone_of(binder_id: str) -> str:
|
|
667
|
+
"""The trajectory a design came from.
|
|
668
|
+
|
|
669
|
+
Ids are ``<target>_<backbone>_seq<i>``, so several designs share a backbone:
|
|
670
|
+
ProteinMPNN produces multiple sequences per RFdiffusion trajectory. A
|
|
671
|
+
designer whose ids carry no ``_seq`` suffix gets one cluster per design,
|
|
672
|
+
which is the right degradation — nothing is assumed to be correlated that
|
|
673
|
+
is not known to be.
|
|
674
|
+
"""
|
|
675
|
+
return binder_id.rsplit("_seq", 1)[0]
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _success_intervals(outcomes: dict[str, list[bool]]) -> dict[str, Any]:
|
|
679
|
+
"""Interval for the success rate, clustered over backbones.
|
|
680
|
+
|
|
681
|
+
Designs are not independent trials. Ten RFdiffusion trajectories times two
|
|
682
|
+
ProteinMPNN sequences is twenty designs but ten attempts, and success
|
|
683
|
+
clusters hard: on the committed ERBB2 run five backbones yielded nothing,
|
|
684
|
+
three yielded two, and within-trajectory ipTM correlates at about 0.55.
|
|
685
|
+
A binomial interval over the designs therefore claims more precision than
|
|
686
|
+
the run contains — it gave (22%, 61%) where clustering gives (15%, 70%).
|
|
687
|
+
|
|
688
|
+
This is the same error the study half was already fixed for, where one
|
|
689
|
+
antigen appearing in several cohorts is one piece of evidence rather than
|
|
690
|
+
several; :func:`cluster_bootstrap_interval` exists for it and was simply
|
|
691
|
+
never called from here.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
outcomes: backbone id -> whether each of its designs cleared the bar.
|
|
695
|
+
|
|
696
|
+
Returns:
|
|
697
|
+
Fields to merge into the score: the clustered interval, the
|
|
698
|
+
independence-assuming one for contrast, and the backbone-level counts.
|
|
699
|
+
"""
|
|
700
|
+
flat = [ok for v in outcomes.values() for ok in v]
|
|
701
|
+
n_ok = sum(flat)
|
|
702
|
+
independent = wilson_interval(n_ok, len(flat))
|
|
703
|
+
out: dict[str, Any] = {
|
|
704
|
+
"n_backbones": len(outcomes),
|
|
705
|
+
"n_backbones_with_success": sum(1 for v in outcomes.values() if any(v)),
|
|
706
|
+
"success_ci_independent_low": round(independent.low, 4),
|
|
707
|
+
"success_ci_independent_high": round(independent.high, 4),
|
|
708
|
+
}
|
|
709
|
+
try:
|
|
710
|
+
clustered = cluster_bootstrap_interval(outcomes, n_boot=10_000, seed=0)
|
|
711
|
+
except ValueError: # no clusters with outcomes; nothing to report
|
|
712
|
+
return out
|
|
713
|
+
out["success_ci_low"] = round(clustered.low, 4)
|
|
714
|
+
out["success_ci_high"] = round(clustered.high, 4)
|
|
715
|
+
out["success_ci_method"] = clustered.method
|
|
716
|
+
return out
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _bindsight_source(backend: str, wheel: Path | None, *, is_mock: bool) -> str:
|
|
720
|
+
"""Which bindsight actually ran, as opposed to which one was offered.
|
|
721
|
+
|
|
722
|
+
This said "working-tree wheel <name>" whenever a wheel had been *built*,
|
|
723
|
+
and building one is not the same as the backend taking it.
|
|
724
|
+
``get_runner`` forwards only the kwargs a runner's constructor declares, and
|
|
725
|
+
``ModalRunner`` declares no ``bindsight_wheel`` — so a Modal benchmark built
|
|
726
|
+
the wheel, dropped it, installed bindsight from the repository's default
|
|
727
|
+
branch, and published the wheel's name as its provenance. That is the exact
|
|
728
|
+
failure the wheel was introduced to prevent, reported as though prevented.
|
|
729
|
+
|
|
730
|
+
Args:
|
|
731
|
+
backend: the runner name the results came from.
|
|
732
|
+
wheel: the wheel that was built, if any.
|
|
733
|
+
is_mock: whether the mock backend produced these numbers.
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
A sentence naming the code that ran.
|
|
737
|
+
"""
|
|
738
|
+
if is_mock:
|
|
739
|
+
return "mock backend: nothing is installed and no GPU runs"
|
|
740
|
+
if wheel is None:
|
|
741
|
+
return "pip install from the repository default branch"
|
|
742
|
+
if not runner_accepts(backend, "bindsight_wheel"):
|
|
743
|
+
return (
|
|
744
|
+
f"pip install from the repository default branch — a working-tree wheel "
|
|
745
|
+
f"({Path(wheel).name}) was built but backend {backend!r} cannot ship one, "
|
|
746
|
+
"so these results are not attributable to the tree that launched them"
|
|
747
|
+
)
|
|
748
|
+
return f"working-tree wheel {Path(wheel).name}"
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _gpu_hours_cell(designer: dict[str, Any]) -> str:
|
|
752
|
+
"""GPU-hours, marked as measured or forecast.
|
|
753
|
+
|
|
754
|
+
The column used to print ``bindsight.cost``'s forecast unmarked, in a row
|
|
755
|
+
whose other numbers are all measurements. A reader had no way to tell that
|
|
756
|
+
0.722 was predicted before the run rather than observed during it.
|
|
757
|
+
"""
|
|
758
|
+
wall = designer.get("wall_seconds")
|
|
759
|
+
if wall:
|
|
760
|
+
return f"{float(wall) / 3600:.3g} (measured)"
|
|
761
|
+
estimate = designer.get("gpu_hours")
|
|
762
|
+
if estimate is None:
|
|
763
|
+
return "—"
|
|
764
|
+
return f"{float(estimate):.3g} (est.)"
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _success_cell(d: dict[str, Any]) -> str:
|
|
768
|
+
"""Render success@0.65 with its interval, never as a bare percentage.
|
|
769
|
+
|
|
770
|
+
The interval is clustered over backbones, because designs sharing a
|
|
771
|
+
trajectory are not independent trials. A binomial interval over the designs
|
|
772
|
+
reads narrower than the run earns.
|
|
773
|
+
"""
|
|
774
|
+
rate = d.get("success_rate")
|
|
775
|
+
if rate is None:
|
|
776
|
+
return "—"
|
|
777
|
+
low, high = d.get("success_ci_low"), d.get("success_ci_high")
|
|
778
|
+
n_ok = d.get("n_success")
|
|
779
|
+
# The denominator the rate was computed over, not the number of designs
|
|
780
|
+
# submitted. Older summaries carry no n_scored; for those the two were
|
|
781
|
+
# equal or the discrepancy is unrecoverable, and n_designs is the honest
|
|
782
|
+
# fallback.
|
|
783
|
+
n_designs = d.get("n_designs")
|
|
784
|
+
n = d.get("n_scored") or n_designs
|
|
785
|
+
if low is None or high is None or n_ok is None or not n:
|
|
786
|
+
return f"{rate:.0%}"
|
|
787
|
+
cell = f"{n_ok}/{n} = {rate:.0%} ({low:.0%}–{high:.0%})"
|
|
788
|
+
if n_designs and n != n_designs:
|
|
789
|
+
# Silently narrowing a denominator is itself a claim. Say how many
|
|
790
|
+
# designs the validator never scored, so the rate is read as the
|
|
791
|
+
# conditional quantity it is.
|
|
792
|
+
cell += f" of {n_designs} designed; {n_designs - n} unscored"
|
|
793
|
+
return cell
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _backbone_cell(d: dict[str, Any]) -> str:
|
|
797
|
+
"""Backbones that yielded any design over the bar — the independent attempts.
|
|
798
|
+
|
|
799
|
+
Deliberately a bare fraction. Rendering it as a percentage put "50%" in a
|
|
800
|
+
table whose withdrawn predecessor reported 50% success@0.65, which is the
|
|
801
|
+
figure this benchmark exists to have replaced; a reader skimming the row
|
|
802
|
+
would have had every reason to think the old number had survived.
|
|
803
|
+
"""
|
|
804
|
+
hit, total = d.get("n_backbones_with_success"), d.get("n_backbones")
|
|
805
|
+
if hit is None or not total:
|
|
806
|
+
return "—"
|
|
807
|
+
return f"{hit}/{total}"
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _render_md(summary: dict[str, Any]) -> str:
|
|
811
|
+
lines: list[str] = []
|
|
812
|
+
a = lines.append
|
|
813
|
+
a("# bindsight designer benchmark — results\n")
|
|
814
|
+
if summary["is_mock"]:
|
|
815
|
+
a(
|
|
816
|
+
"> **⚠ MOCK BACKEND — synthetic numbers for CI/orchestration only.** "
|
|
817
|
+
"These are NOT real GPU results. Re-run with `--backend modal` (or "
|
|
818
|
+
"`local_docker`/`kaggle`) on a GPU to produce real metrics.\n"
|
|
819
|
+
)
|
|
820
|
+
a(f"- Generated: `{summary['generated_utc']}` · bindsight `{summary['bindsight_version']}`")
|
|
821
|
+
a(
|
|
822
|
+
f"- Backend: `{summary['backend']}` · validator: `{summary['validator']}` · "
|
|
823
|
+
f"trajectories/target: {summary['n_trajectories']}"
|
|
824
|
+
)
|
|
825
|
+
# Absent, not zero. A run predating the seed record must not render as
|
|
826
|
+
# though it had been seeded with 0, which is a real and different run.
|
|
827
|
+
a(
|
|
828
|
+
"- Seed: "
|
|
829
|
+
+ (
|
|
830
|
+
f"`{summary['seed']}`"
|
|
831
|
+
if summary.get("seed") is not None
|
|
832
|
+
else "**unrecorded** — this run predates the benchmark recording "
|
|
833
|
+
"its seed, and its designer and validator both ran unseeded"
|
|
834
|
+
)
|
|
835
|
+
)
|
|
836
|
+
a(f"- Targets: {', '.join(summary['targets'])}")
|
|
837
|
+
if summary.get("gpu"):
|
|
838
|
+
a(f"- GPU: `{summary['gpu']}`")
|
|
839
|
+
if summary.get("bindsight_source"):
|
|
840
|
+
# Which bindsight actually ran. Remote backends pip-install it, so the
|
|
841
|
+
# code that produced a result is not necessarily the code that submitted
|
|
842
|
+
# the job — a benchmark launched to validate a fix once ran the unfixed
|
|
843
|
+
# code because the kernel installed the repository's default branch.
|
|
844
|
+
a(f"- Code: {summary['bindsight_source']}")
|
|
845
|
+
if not summary["is_mock"]:
|
|
846
|
+
a(
|
|
847
|
+
"\n> **The target chain was held fixed.** ProteinMPNN is invoked with\n"
|
|
848
|
+
"> `--pdb_path_chains` so it redesigns the binder only. A superseded run\n"
|
|
849
|
+
"> omitted that flag, rewrote the target as well, and therefore optimised\n"
|
|
850
|
+
"> its designs against a partly-invented surface before scoring them\n"
|
|
851
|
+
"> against the native one; its figures are withdrawn. Every design here\n"
|
|
852
|
+
"> carries a target chain byte-identical to the prepared structure.\n"
|
|
853
|
+
">\n"
|
|
854
|
+
"> That is checked against the artifacts, not asserted: "
|
|
855
|
+
"`tests/test_target_chain_artifact.py` reads every committed "
|
|
856
|
+
"`*_complex.cif` and requires exactly one chain to equal the native "
|
|
857
|
+
"target in `target/P04626_domain_IV.pdb`."
|
|
858
|
+
)
|
|
859
|
+
a("")
|
|
860
|
+
|
|
861
|
+
a(
|
|
862
|
+
"| designer | designs | mean ipTM | median ipTM | mean PAE-int | mean affinity | "
|
|
863
|
+
"success@0.65 | backbones hit | est. cost (USD) | GPU-h |"
|
|
864
|
+
)
|
|
865
|
+
a("|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|")
|
|
866
|
+
for d in summary["designers"]:
|
|
867
|
+
|
|
868
|
+
def fmt(v: Any, pct: bool = False) -> str:
|
|
869
|
+
if v is None:
|
|
870
|
+
return "—"
|
|
871
|
+
return f"{v:.0%}" if pct else f"{v:.3g}"
|
|
872
|
+
|
|
873
|
+
a(
|
|
874
|
+
f"| {d['designer']} | {d['n_designs']} | {fmt(d['mean_iptm'])} | "
|
|
875
|
+
f"{fmt(d['median_iptm'])} | {fmt(d['mean_pae_interaction'])} | "
|
|
876
|
+
f"{fmt(d['mean_affinity'])} | {_success_cell(d)} | {_backbone_cell(d)} | "
|
|
877
|
+
f"{fmt(d['cost_usd'])} | {_gpu_hours_cell(d)} |"
|
|
878
|
+
)
|
|
879
|
+
a("")
|
|
880
|
+
a(
|
|
881
|
+
"**ipTM** / **PAE-interaction** / **affinity** are the validator's "
|
|
882
|
+
"(Boltz-2) interface-confidence and predicted-affinity outputs; "
|
|
883
|
+
"**success@0.65** is the fraction of designs with ipTM ≥ 0.65, with a 95% "
|
|
884
|
+
"interval **bootstrapped over backbones**, not over designs: ProteinMPNN "
|
|
885
|
+
"produces several sequences per RFdiffusion trajectory, so the designs are "
|
|
886
|
+
"not independent trials and a binomial interval over them reads narrower "
|
|
887
|
+
"than the run earns. **backbones hit** is the fraction of trajectories that "
|
|
888
|
+
"yielded any design over the bar, which is the count of independent attempts "
|
|
889
|
+
"that worked. Read the interval, not the point: two runs of the same target "
|
|
890
|
+
"differing only in seed returned 2/20 and 6/20, which a Fisher exact test "
|
|
891
|
+
"cannot separate. Cost is the "
|
|
892
|
+
"`bindsight.cost` estimate for the run on the chosen backend, and so is "
|
|
893
|
+
"any GPU-hours figure marked `(est.)` — both are forecasts made before "
|
|
894
|
+
"the run rather than measurements of it. A figure marked `(measured)` is "
|
|
895
|
+
"this designer's own wall-clock. The committed run predates that "
|
|
896
|
+
"measurement, which is why its GPU-hours are an estimate.\n"
|
|
897
|
+
)
|
|
898
|
+
a(IPTM_CALIBRATION_CAVEAT)
|
|
899
|
+
return "\n".join(lines)
|