shellde 0.2.0__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.
- shellde/__init__.py +36 -0
- shellde/acquisition.py +135 -0
- shellde/advisor.py +158 -0
- shellde/bench/__init__.py +17 -0
- shellde/bench/falsification.py +95 -0
- shellde/bench/harness.py +134 -0
- shellde/bench/stats.py +46 -0
- shellde/campaign.py +257 -0
- shellde/candidates.py +341 -0
- shellde/cli.py +1517 -0
- shellde/colab.py +257 -0
- shellde/conformal.py +160 -0
- shellde/consensus.py +52 -0
- shellde/design_space.py +141 -0
- shellde/embeddings/__init__.py +6 -0
- shellde/embeddings/esm2.py +76 -0
- shellde/embeddings/esmc.py +94 -0
- shellde/embeddings/provider.py +105 -0
- shellde/features/__init__.py +22 -0
- shellde/features/base.py +49 -0
- shellde/features/defaults.py +11 -0
- shellde/features/embedding.py +80 -0
- shellde/features/inverse_folding.py +66 -0
- shellde/features/matrix.py +50 -0
- shellde/features/naturalness.py +65 -0
- shellde/features/onehot.py +55 -0
- shellde/features/pairwise.py +64 -0
- shellde/funclib.py +331 -0
- shellde/gating.py +181 -0
- shellde/holo.py +175 -0
- shellde/hotspots.py +108 -0
- shellde/loop.py +161 -0
- shellde/msa.py +186 -0
- shellde/naturalness.py +142 -0
- shellde/oracle.py +94 -0
- shellde/plm.py +145 -0
- shellde/prereg.py +41 -0
- shellde/protocols.py +87 -0
- shellde/rank.py +103 -0
- shellde/report.py +132 -0
- shellde/selector.py +63 -0
- shellde/sitefinder.py +465 -0
- shellde/structure.py +356 -0
- shellde/surrogate.py +323 -0
- shellde/types.py +66 -0
- shellde/zero_shot.py +160 -0
- shellde-0.2.0.dist-info/METADATA +285 -0
- shellde-0.2.0.dist-info/RECORD +51 -0
- shellde-0.2.0.dist-info/WHEEL +5 -0
- shellde-0.2.0.dist-info/entry_points.txt +2 -0
- shellde-0.2.0.dist-info/top_level.txt +1 -0
shellde/cli.py
ADDED
|
@@ -0,0 +1,1517 @@
|
|
|
1
|
+
"""Command-line entry point: a single ``recommend`` plus ``bench`` and ``report``.
|
|
2
|
+
|
|
3
|
+
recommend MEASURED.csv variant,fitness -> ranked candidates + evidence report
|
|
4
|
+
bench synthetic alpha-spectrum tool-comparative benchmark
|
|
5
|
+
report SPECTRUM.json summarise a benchmark (or recommend) JSON
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from shellde.acquisition import UCB
|
|
18
|
+
from shellde.bench import run_spectrum
|
|
19
|
+
from shellde.candidates import CandidatePool, generate_candidates, recombine_beneficials, select_positions
|
|
20
|
+
from shellde.conformal import cross_conformal_normalized_q
|
|
21
|
+
from shellde.design_space import AA_ALPHABET, DesignSpace, mutations_of
|
|
22
|
+
from shellde.types import Prediction
|
|
23
|
+
from shellde.features import (
|
|
24
|
+
FeatureBlock,
|
|
25
|
+
FeatureMatrix,
|
|
26
|
+
GatedPairwiseBlock,
|
|
27
|
+
InverseFoldingBlock,
|
|
28
|
+
default_blocks,
|
|
29
|
+
)
|
|
30
|
+
from shellde.gating import should_include_block, should_open_pairwise
|
|
31
|
+
from shellde.structure import (
|
|
32
|
+
contacts_from_pdb,
|
|
33
|
+
load_ddg_table,
|
|
34
|
+
load_inverse_folding_logprobs,
|
|
35
|
+
shells_from_pdb,
|
|
36
|
+
)
|
|
37
|
+
from shellde.advisor import data_readiness
|
|
38
|
+
from shellde.prereg import prereg_hash, stamp
|
|
39
|
+
from shellde.rank import rank_candidates
|
|
40
|
+
from shellde.report import write_report
|
|
41
|
+
from shellde.surrogate import EnsembleSurrogate, GlobalEpistasisSurrogate, RankingSurrogate, RFSurrogate, RidgeSurrogate
|
|
42
|
+
from shellde.embeddings.provider import EmbeddingCache
|
|
43
|
+
from shellde.plm import build_naturalness, build_provider, plm_rerank, read_fasta
|
|
44
|
+
from shellde.funclib import design_library, tolerance_from_logprob_table
|
|
45
|
+
from shellde.msa import build_msa_mmseqs2, load_msa_logprobs
|
|
46
|
+
from shellde.hotspots import rank_hotspots
|
|
47
|
+
from shellde.holo import find_holo_homolog
|
|
48
|
+
from shellde.consensus import build_consensus, consensus_summary
|
|
49
|
+
from shellde.campaign import (
|
|
50
|
+
SurrogateSpec,
|
|
51
|
+
funclib_seed_variants,
|
|
52
|
+
simulate_campaign,
|
|
53
|
+
single_mutant_variants,
|
|
54
|
+
stable_variants,
|
|
55
|
+
)
|
|
56
|
+
from shellde.sitefinder import (
|
|
57
|
+
ResolveResult,
|
|
58
|
+
fetch_alphafold,
|
|
59
|
+
fetch_pdb,
|
|
60
|
+
fetch_uniprot,
|
|
61
|
+
pdb_ids_from_uniprot,
|
|
62
|
+
resolve_from_apo,
|
|
63
|
+
resolve_from_entry,
|
|
64
|
+
resolve_from_pdb,
|
|
65
|
+
uniprot_from_sequence,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
_SURROGATE = {
|
|
69
|
+
"ridge": RidgeSurrogate, "ensemble": EnsembleSurrogate, "rf": RFSurrogate,
|
|
70
|
+
"ranking": RankingSurrogate, "global_epistasis": GlobalEpistasisSurrogate,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_plm_pooling(pooling: str, plm_model: str) -> str:
|
|
75
|
+
"""Resolve --plm-pooling 'auto' to a concrete mode based on the PLM model.
|
|
76
|
+
|
|
77
|
+
Optimal pooling depends on model size (held-out: esmc_600m favours site-specific,
|
|
78
|
+
esmc_300m and smaller favour whole-sequence mean). 'auto' -> 'site' iff the model is
|
|
79
|
+
esmc_600m, else 'mean' (the safe default that every ESM-C size can run). A non-'auto'
|
|
80
|
+
value is returned unchanged (explicit override).
|
|
81
|
+
"""
|
|
82
|
+
if pooling != "auto":
|
|
83
|
+
return pooling
|
|
84
|
+
return "site" if plm_model == "esmc_600m" else "mean"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _read_design(measured_path: str, design_positions: str | None):
|
|
88
|
+
"""Load a variant,fitness CSV and build the DesignSpace (shared by recommend/advise)."""
|
|
89
|
+
import pandas as pd
|
|
90
|
+
|
|
91
|
+
df = pd.read_csv(measured_path)
|
|
92
|
+
for col in ("variant", "fitness"):
|
|
93
|
+
if col not in df.columns:
|
|
94
|
+
raise ValueError(f"{measured_path}: missing required column {col!r} (need variant,fitness)")
|
|
95
|
+
variants = [str(v) for v in df["variant"]]
|
|
96
|
+
fitness = df["fitness"].to_numpy(dtype=float)
|
|
97
|
+
if not variants:
|
|
98
|
+
raise ValueError(f"{measured_path}: no measured variants")
|
|
99
|
+
length = len(variants[0])
|
|
100
|
+
if any(len(v) != length for v in variants):
|
|
101
|
+
raise ValueError("all variants must share one length for a fixed-site design space")
|
|
102
|
+
if design_positions:
|
|
103
|
+
positions = tuple(sorted(int(x) for x in design_positions.split(",")))
|
|
104
|
+
if len(positions) != length:
|
|
105
|
+
raise ValueError(f"--design-positions has {len(positions)} positions but variants have length {length}")
|
|
106
|
+
else:
|
|
107
|
+
positions = tuple(range(1, length + 1))
|
|
108
|
+
ref = {p: Counter(v[j] for v in variants).most_common(1)[0][0] for j, p in enumerate(positions)}
|
|
109
|
+
return variants, fitness, DesignSpace(positions, ref), ref, length
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _print_epistasis_next_step(
|
|
113
|
+
recommendation: str, measured: object = None, positions: object = None
|
|
114
|
+
) -> None:
|
|
115
|
+
"""The ``open_epistasis`` verdict is actionable on ``recommend`` ONLY: neither ``campaign``/
|
|
116
|
+
``round`` nor ``advise`` exposes the pairwise data-gate flag. ``measured`` is the measured
|
|
117
|
+
path(s) in scope at the call site (a single path on ``advise``, a per-round list on
|
|
118
|
+
``campaign``/``round``). With exactly one path the printed command names a real file; with
|
|
119
|
+
several (or none) the measured CSV is a ``<placeholder>``, because ``recommend`` takes ONE CSV
|
|
120
|
+
and the caller must concatenate the rounds first. That join is spelled out header-aware on
|
|
121
|
+
purpose: a plain ``cat r0.csv r1.csv`` leaves the SECOND file's header line as a data row, and
|
|
122
|
+
``_read_design`` then dies inside ``float("fitness")``. The printed command uses ``head``/``tail``
|
|
123
|
+
so exactly one header line survives.
|
|
124
|
+
|
|
125
|
+
The two commands do NOT read the same formats: ``campaign``/``round`` load measured tables via
|
|
126
|
+
``_read_landscape`` (CSV/TSV *and* Excel, lenient column names), while ``recommend``/``advise``
|
|
127
|
+
load them via ``_read_design``, which is ``pandas.read_csv`` plus exact ``variant``/``fitness``
|
|
128
|
+
columns. Variant NOTATION differs too: ``campaign``/``round`` resolve each variant through
|
|
129
|
+
``design_space.to_assignment``, which accepts mutation tokens (``V39A``, ``L42M:K88R``) as well as
|
|
130
|
+
fixed-site combo strings, whereas ``_read_design`` requires every variant to be a combo string of
|
|
131
|
+
one shared length and raises otherwise. So a file that ``campaign`` accepted may still be
|
|
132
|
+
rejected by ``recommend``. There is ONE shipped file that always crosses cleanly: the
|
|
133
|
+
``roundN_measured_template.csv`` that ``campaign``/``round`` writes into ``--outdir`` is already
|
|
134
|
+
``variant``,``fitness`` with combo-string variants, so it is readable by ``recommend`` as soon as
|
|
135
|
+
its ``fitness`` column is filled. For anything else there is NO shipped converter (the
|
|
136
|
+
subcommands are recommend / advise / funclib / campaign / resolve-site / round / hotspots /
|
|
137
|
+
bench / report; none of them reshapes a measured table), so the user has to reshape it by hand.
|
|
138
|
+
When the caller is that path (``measured`` given as a list, i.e. not a ``str``), the printed
|
|
139
|
+
guidance carries both facts, so the user does not discover them at the shell.
|
|
140
|
+
|
|
141
|
+
``positions`` is the resolved design region in scope (the campaign/round variant strings are
|
|
142
|
+
the per-position combo encoding over it). It is printed as ``--design-positions`` so the
|
|
143
|
+
suggested command runs over the SAME mutation space: without it ``recommend`` falls back to
|
|
144
|
+
``1..len(variant)``, which is the wrong space for a campaign round.
|
|
145
|
+
"""
|
|
146
|
+
if recommendation != "open_epistasis":
|
|
147
|
+
return
|
|
148
|
+
paths = [str(measured)] if isinstance(measured, str) else [str(m) for m in (measured or [])]
|
|
149
|
+
# advise passes a str already validated by _read_design; campaign/round pass a list read by
|
|
150
|
+
# the lenient _read_landscape, so those files may not be readable by `recommend` as-is.
|
|
151
|
+
fmt = ("" if isinstance(measured, str) else
|
|
152
|
+
"; `recommend` needs a CSV with exact `variant`,`fitness` columns and fixed-site combo "
|
|
153
|
+
"variants (one residue per design position, every row the same length). The "
|
|
154
|
+
"`roundN_measured_template.csv` written into --outdir is already in that format once its "
|
|
155
|
+
"`fitness` column is filled. Any other round file (Excel, other column names, or "
|
|
156
|
+
"mutation-token variants like `V39A`) has to be reshaped BY HAND: ShellDE ships no "
|
|
157
|
+
"converter subcommand")
|
|
158
|
+
if isinstance(positions, str):
|
|
159
|
+
pos = f" --design-positions {positions}"
|
|
160
|
+
elif positions:
|
|
161
|
+
pos = " --design-positions " + ",".join(str(p) for p in positions)
|
|
162
|
+
else:
|
|
163
|
+
pos = ""
|
|
164
|
+
if len(paths) == 1:
|
|
165
|
+
print(f" -> act on it with: shellde recommend {paths[0]}{pos} --auto-gate-pairwise "
|
|
166
|
+
f"(the pairwise block is a data-gated opt-in on `recommend`{fmt})")
|
|
167
|
+
elif len(paths) > 1:
|
|
168
|
+
# A plain `cat` of the rounds leaves the later header lines as data rows and _read_design
|
|
169
|
+
# fails inside float("fitness"), so name a join that keeps exactly ONE header line.
|
|
170
|
+
join = ("head -1 " + paths[0] + " > all_measured.csv; tail -q -n +2 "
|
|
171
|
+
+ " ".join(paths) + " >> all_measured.csv")
|
|
172
|
+
print(f" -> act on it with: shellde recommend <all_measured.csv>{pos} --auto-gate-pairwise "
|
|
173
|
+
"(the pairwise block is a data-gated opt-in on `recommend`, which takes ONE measured "
|
|
174
|
+
f"CSV: concatenate {', '.join(paths)} first, keeping exactly ONE header row -- "
|
|
175
|
+
f"`{join}`. A plain `cat` of them leaves the second header as a data row and the read "
|
|
176
|
+
f"then fails with `could not convert string to float: \'fitness\'`{fmt})")
|
|
177
|
+
else:
|
|
178
|
+
print(f" -> act on it with: shellde recommend <measured.csv>{pos} --auto-gate-pairwise "
|
|
179
|
+
f"(the pairwise block is a data-gated opt-in on `recommend`{fmt})")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def cmd_advise(args: argparse.Namespace) -> int:
|
|
183
|
+
variants, fitness, space, _ref, _length = _read_design(args.measured, args.design_positions)
|
|
184
|
+
r = data_readiness(space, variants, fitness)
|
|
185
|
+
spear = "n/a" if r["heldout_spearman"] is None else f"{r['heldout_spearman']:+.3f}"
|
|
186
|
+
print(f"[advise] n={r['n_measured']} sites={space.n_positions} | held-out Spearman={spear} "
|
|
187
|
+
f"| trustworthy={r['model_trustworthy']} | gate_improvement={r['gate_improvement']:+.3f} "
|
|
188
|
+
f"(epistasis_ready={r['epistasis_ready']})")
|
|
189
|
+
print(f"[advise] recommendation: {r['recommendation'].upper()} -- {r['why']}")
|
|
190
|
+
_print_epistasis_next_step(r["recommendation"], args.measured, args.design_positions)
|
|
191
|
+
hw = r["conformal_halfwidth_90"]
|
|
192
|
+
hw_disp = "n/a" if hw is None else f"{hw:+.3f}"
|
|
193
|
+
print(f"[advise] conformal 90% interval half-width: {hw_disp} (distribution-free, valid at low N)")
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_recommend(args: argparse.Namespace) -> int:
|
|
198
|
+
variants, fitness, space, ref, length = _read_design(args.measured, getattr(args, "design_positions", None))
|
|
199
|
+
blocks: list[FeatureBlock] = default_blocks(space)
|
|
200
|
+
pairs = contacts_from_pdb(args.contacts_pdb, space.positions, cutoff=args.contact_cutoff) if args.contacts_pdb else None
|
|
201
|
+
use_pairwise = bool(args.contacts_pdb) # explicit contacts -> include the restricted block
|
|
202
|
+
if getattr(args, "auto_gate_pairwise", False):
|
|
203
|
+
use_pairwise = should_open_pairwise(space, variants, fitness, pairs=pairs)
|
|
204
|
+
if use_pairwise:
|
|
205
|
+
blocks.append(GatedPairwiseBlock(space, pairs))
|
|
206
|
+
signal_gate: dict | None = None
|
|
207
|
+
if args.if_logprobs:
|
|
208
|
+
if_block = InverseFoldingBlock(space, load_inverse_folding_logprobs(args.if_logprobs))
|
|
209
|
+
if getattr(args, "auto_signals", True):
|
|
210
|
+
# Tool decides whether the supplied signal helps on THIS protein's held-out data,
|
|
211
|
+
# instead of using it unconditionally (opt-in audit 2026-06-29).
|
|
212
|
+
include, signal_gate = should_include_block(
|
|
213
|
+
space, variants, fitness, blocks, if_block, label="inverse_folding",
|
|
214
|
+
)
|
|
215
|
+
if include:
|
|
216
|
+
blocks.append(if_block)
|
|
217
|
+
else:
|
|
218
|
+
blocks.append(if_block) # --no-auto-signals: expert override, use unconditionally
|
|
219
|
+
matrix = FeatureMatrix(space, blocks)
|
|
220
|
+
x_enc = matrix.encode(variants)
|
|
221
|
+
chosen = args.model_class
|
|
222
|
+
sur = _SURROGATE[chosen](random_state=0).fit(x_enc, fitness)
|
|
223
|
+
|
|
224
|
+
# Normalized cross-conformal quantile on the MEASURED set (heteroscedastic-preserving).
|
|
225
|
+
# Guard: needs enough points + non-degenerate y, else the gate is disabled (None).
|
|
226
|
+
conf_q = (
|
|
227
|
+
cross_conformal_normalized_q(x_enc, fitness, lambda: _SURROGATE[chosen](random_state=0))
|
|
228
|
+
if len(variants) >= 8 and float(np.std(fitness)) > 0.0
|
|
229
|
+
else None
|
|
230
|
+
)
|
|
231
|
+
# Max mutation count among the measured variants (vs WT/consensus reference). Candidates
|
|
232
|
+
# with n_mut above this are out of the calibration distribution (label conformal_oob).
|
|
233
|
+
max_meas_nmut = max(len(mutations_of(v, space)) for v in variants)
|
|
234
|
+
|
|
235
|
+
if args.max_positions is not None and args.max_positions < length:
|
|
236
|
+
pool = select_positions(sur, matrix, m=args.max_positions, interaction_aware=args.interaction_aware)
|
|
237
|
+
else:
|
|
238
|
+
pool = list(space.positions)
|
|
239
|
+
cset = generate_candidates(
|
|
240
|
+
sur, matrix, pinned_positions=pool, max_mut=min(args.max_mut, len(pool)),
|
|
241
|
+
generated_cap=args.generated_cap, scored_cap=args.scored_cap, beta=args.beta,
|
|
242
|
+
interaction_aware=args.interaction_aware, seed=0,
|
|
243
|
+
)
|
|
244
|
+
n_recombined = 0
|
|
245
|
+
if getattr(args, "recombine", False):
|
|
246
|
+
# Evidence-backed strategy: recombine measured beneficials (not just surrogate-guided combos).
|
|
247
|
+
recomb = recombine_beneficials(
|
|
248
|
+
space, variants, fitness, min_gain=args.recombine_min_gain,
|
|
249
|
+
max_mut=min(args.max_mut, len(pool)), cap=args.generated_cap,
|
|
250
|
+
)
|
|
251
|
+
have = set(cset.variants) | set(variants)
|
|
252
|
+
extra = [v for v in recomb if v not in have]
|
|
253
|
+
if extra:
|
|
254
|
+
pr = sur.predict(matrix.encode(extra))
|
|
255
|
+
n_recombined = len(extra)
|
|
256
|
+
cset = CandidatePool(
|
|
257
|
+
variants=list(cset.variants) + extra,
|
|
258
|
+
pred=Prediction(
|
|
259
|
+
np.concatenate([np.asarray(cset.pred.mean, float), np.asarray(pr.mean, float)]),
|
|
260
|
+
np.concatenate([np.asarray(cset.pred.std, float), np.asarray(pr.std, float)]),
|
|
261
|
+
),
|
|
262
|
+
mut_count=list(cset.mut_count) + [len(mutations_of(v, space)) for v in extra],
|
|
263
|
+
pool_positions=cset.pool_positions,
|
|
264
|
+
stats={**cset.stats, "n_recombined": n_recombined},
|
|
265
|
+
)
|
|
266
|
+
wt_mean = float(sur.predict(matrix.encode([space.wt()])).mean[0])
|
|
267
|
+
if args.plm != "none" or args.naturalness != "none":
|
|
268
|
+
if not args.wt_fasta:
|
|
269
|
+
raise ValueError("--plm/--naturalness requires --wt-fasta (full WT background sequence)")
|
|
270
|
+
emb_cache = EmbeddingCache(args.plm_cache) if args.plm_cache else None
|
|
271
|
+
nat_cache = EmbeddingCache(args.naturalness_cache) if args.naturalness_cache else None
|
|
272
|
+
provider = build_provider(args.plm, model=args.plm_model, cache=emb_cache) if args.plm != "none" else None
|
|
273
|
+
nat = build_naturalness(args.naturalness, model=args.plm_model, cache=nat_cache) if args.naturalness != "none" else None
|
|
274
|
+
resolved_pooling = resolve_plm_pooling(args.plm_pooling, args.plm_model)
|
|
275
|
+
cset = plm_rerank(
|
|
276
|
+
cset, space, variants, fitness, provider=provider, naturalness=nat,
|
|
277
|
+
wt_sequence=read_fasta(args.wt_fasta), top_k=args.plm_rerank,
|
|
278
|
+
plm_pooling=resolved_pooling,
|
|
279
|
+
)
|
|
280
|
+
wt_mean = float(cset.stats.get("wt_mean", wt_mean))
|
|
281
|
+
if emb_cache is not None:
|
|
282
|
+
emb_cache.save()
|
|
283
|
+
if nat_cache is not None:
|
|
284
|
+
nat_cache.save()
|
|
285
|
+
ranking = rank_candidates(
|
|
286
|
+
cset, space, acquisition=UCB(args.beta), per_count_k=args.per_count_k,
|
|
287
|
+
global_k=args.top, wt_mean=wt_mean,
|
|
288
|
+
conformal_q=conf_q, max_measured_nmut=max_meas_nmut,
|
|
289
|
+
)
|
|
290
|
+
paths = write_report(ranking, args.outdir, prereg_hash=prereg_hash())
|
|
291
|
+
|
|
292
|
+
print(f"[recommend] {length} sites, WT={space.wt()}; generated={cset.stats.get('n_generated', 0)} "
|
|
293
|
+
f"scored={cset.stats.get('n_scored', 0)}")
|
|
294
|
+
if n_recombined:
|
|
295
|
+
print(f"[recommend] recombine: added {n_recombined} recombinations of measured beneficials "
|
|
296
|
+
f"(>WT) to the candidate pool")
|
|
297
|
+
if getattr(args, "auto_gate_pairwise", False) or args.contacts_pdb:
|
|
298
|
+
opened = "pairwise" in matrix.active_blocks()
|
|
299
|
+
npairs = "all" if pairs is None else str(len(pairs))
|
|
300
|
+
print(f"[recommend] epistasis block {'OPEN' if opened else 'closed'} "
|
|
301
|
+
f"(pairs={npairs}{', contact-restricted' if args.contacts_pdb else ''}; "
|
|
302
|
+
f"{'data justifies' if opened else 'data does not justify' if args.auto_gate_pairwise else 'structure-provided'})")
|
|
303
|
+
if signal_gate is not None:
|
|
304
|
+
print(f"[recommend] signal gate [{signal_gate['label']}]: "
|
|
305
|
+
f"{'KEPT' if signal_gate['included'] else 'DROPPED'} -- {signal_gate['reason']}")
|
|
306
|
+
if args.plm != "none" or args.naturalness != "none":
|
|
307
|
+
pooling_note = (
|
|
308
|
+
f" pooling={resolve_plm_pooling(args.plm_pooling, args.plm_model)}"
|
|
309
|
+
if args.plm != "none" else ""
|
|
310
|
+
)
|
|
311
|
+
print(f"[recommend] rerank PLM={args.plm} naturalness={args.naturalness}{pooling_note} "
|
|
312
|
+
f"top {cset.stats.get('plm_reranked', 0)} (dim={cset.stats.get('plm_dim', 0)})")
|
|
313
|
+
if args.max_positions is not None and args.max_positions < length:
|
|
314
|
+
print(f"[recommend] auto-selected {len(pool)}/{length} positions: "
|
|
315
|
+
f"{','.join(f'{ref[p]}{p}' for p in pool)}")
|
|
316
|
+
print(f"[recommend] top {min(args.top, len(ranking.global_ranking))} (global):")
|
|
317
|
+
for c in ranking.global_ranking[: args.top]:
|
|
318
|
+
muts = ",".join(c.mutations) or "WT"
|
|
319
|
+
print(f" {c.variant} mu={c.mu:.3f} sigma={c.sigma:.3f} acq={c.acq:.3f} [{muts}] ({c.evidence['risk']})")
|
|
320
|
+
if conf_q is not None:
|
|
321
|
+
top = ranking.global_ranking[: args.top]
|
|
322
|
+
n_abstain = sum(1 for c in top if c.evidence.get("abstain"))
|
|
323
|
+
n_oob = sum(1 for c in top if c.evidence.get("conformal_scope") == "conformal_oob")
|
|
324
|
+
scope_note = (
|
|
325
|
+
"; conformal-derived conservative threshold (calibrated on measured set; "
|
|
326
|
+
"this candidate is a higher-order combination, out of the calibration distribution)"
|
|
327
|
+
if n_oob else ""
|
|
328
|
+
)
|
|
329
|
+
print(f"[recommend] conformal abstain gate (q_norm={conf_q:.3f}, max measured n_mut="
|
|
330
|
+
f"{max_meas_nmut}): {n_abstain}/{len(top)} top candidates flagged abstain "
|
|
331
|
+
f"(cannot conclude they beat WT){f', {n_oob} out-of-distribution' if n_oob else ''}"
|
|
332
|
+
f"{scope_note}")
|
|
333
|
+
for kind, p in paths.items():
|
|
334
|
+
print(f"[recommend] wrote {kind}: {p}")
|
|
335
|
+
return 0
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _funclib_signals(args: argparse.Namespace, space: DesignSpace):
|
|
339
|
+
"""Resolve (ddG table, per-substitution tolerance) from --ddg/--tolerance/--if-logprobs/--msa.
|
|
340
|
+
|
|
341
|
+
Shared by `funclib` and `campaign` so both gate the seed library with identical signal
|
|
342
|
+
precedence: explicit --tolerance > --if-logprobs > --msa (first present wins).
|
|
343
|
+
"""
|
|
344
|
+
ddg = load_ddg_table(args.ddg) if args.ddg else None
|
|
345
|
+
tolerance: dict[str, float] | None = None
|
|
346
|
+
if getattr(args, "tolerance", None):
|
|
347
|
+
raw = json.loads(Path(args.tolerance).read_text())
|
|
348
|
+
if raw and all(isinstance(v, dict) for v in raw.values()):
|
|
349
|
+
tolerance = tolerance_from_logprob_table({int(k): v for k, v in raw.items()}, space)
|
|
350
|
+
else:
|
|
351
|
+
tolerance = {str(k): float(v) for k, v in raw.items()}
|
|
352
|
+
elif args.if_logprobs:
|
|
353
|
+
tolerance = tolerance_from_logprob_table(load_inverse_folding_logprobs(args.if_logprobs), space)
|
|
354
|
+
elif args.msa:
|
|
355
|
+
tolerance = tolerance_from_logprob_table(load_msa_logprobs(args.msa), space)
|
|
356
|
+
return ddg, tolerance
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _funclib_positions(args: argparse.Namespace, seq_len: int) -> list[int]:
|
|
360
|
+
"""Resolve the design region: explicit --positions, else an active-site shell from --holo."""
|
|
361
|
+
if args.positions:
|
|
362
|
+
positions = sorted({int(x) for x in args.positions.split(",")})
|
|
363
|
+
elif args.holo:
|
|
364
|
+
resn = args.ligand_resnames.split(",") if args.ligand_resnames else None
|
|
365
|
+
shells = shells_from_pdb(args.holo, ligand_resnames=resn, contact_cutoff=args.contact_cutoff)
|
|
366
|
+
if args.shell == "both":
|
|
367
|
+
positions = sorted(set(shells[1]) | set(shells[2]))
|
|
368
|
+
else:
|
|
369
|
+
positions = sorted(shells[int(args.shell)])
|
|
370
|
+
else:
|
|
371
|
+
raise ValueError("funclib needs --positions or --holo (a ligand pose) to define the design region")
|
|
372
|
+
bad = [p for p in positions if p < 1 or p > seq_len]
|
|
373
|
+
if bad:
|
|
374
|
+
raise ValueError(f"positions {bad} fall outside the WT sequence (length {seq_len})")
|
|
375
|
+
return positions
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def cmd_funclib(args: argparse.Namespace) -> int:
|
|
379
|
+
seq = read_fasta(args.wt_fasta)
|
|
380
|
+
outdir = Path(args.outdir)
|
|
381
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
382
|
+
positions = _funclib_positions(args, len(seq))
|
|
383
|
+
if not positions:
|
|
384
|
+
(outdir / "funclib.json").write_text(json.dumps(
|
|
385
|
+
{"abstained": True, "reason": "no positions (apo structure or empty shell)", "library": []}, indent=2))
|
|
386
|
+
print("[funclib] ABSTAIN: no positions (apo structure or empty shell)")
|
|
387
|
+
return 0
|
|
388
|
+
|
|
389
|
+
ref = {p: seq[p - 1] for p in positions}
|
|
390
|
+
space = DesignSpace(tuple(positions), ref)
|
|
391
|
+
|
|
392
|
+
ddg, tolerance = _funclib_signals(args, space)
|
|
393
|
+
|
|
394
|
+
lib = design_library(
|
|
395
|
+
space, ddg=ddg, tolerance=tolerance,
|
|
396
|
+
ddg_cutoff=args.ddg_cutoff, tolerance_cutoff=args.tolerance_cutoff,
|
|
397
|
+
per_position_cap=args.per_position_cap, min_mut=args.min_mut, max_mut=args.max_mut,
|
|
398
|
+
additive_ddg_budget=args.additive_ddg_budget, max_library=args.max_library,
|
|
399
|
+
allow_unscored=args.allow_unscored,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
proposal = {
|
|
403
|
+
"positions": list(lib.positions),
|
|
404
|
+
"abstained": lib.abstained,
|
|
405
|
+
"reason": lib.reason,
|
|
406
|
+
"stats": lib.stats,
|
|
407
|
+
"tolerated": {str(p): [[aa, s] for aa, s in lib.tolerated.get(p, [])] for p in lib.positions},
|
|
408
|
+
"library": [
|
|
409
|
+
{"variant": c.variant, "mutations": list(c.mutations), "n_mut": c.n_mut, **c.evidence}
|
|
410
|
+
for c in lib.candidates
|
|
411
|
+
],
|
|
412
|
+
}
|
|
413
|
+
(outdir / "funclib.json").write_text(json.dumps(proposal, indent=2))
|
|
414
|
+
import csv as _csv
|
|
415
|
+
with (outdir / "funclib_library.csv").open("w", newline="") as fh:
|
|
416
|
+
w = _csv.writer(fh)
|
|
417
|
+
w.writerow(["mutations", "n_mut", "combo_score", "additive_ddg", "variant"])
|
|
418
|
+
for c in lib.candidates:
|
|
419
|
+
w.writerow([":".join(c.mutations), c.n_mut, f"{c.acq:.4f}",
|
|
420
|
+
f"{c.evidence['additive_ddg']:.4f}" if "additive_ddg" in c.evidence else "", c.variant])
|
|
421
|
+
|
|
422
|
+
if lib.abstained:
|
|
423
|
+
print(f"[funclib] ABSTAIN: {lib.reason}")
|
|
424
|
+
else:
|
|
425
|
+
st = lib.stats
|
|
426
|
+
gates = "+".join(g for g in ("ddG", "tolerance") if st["gates"][g.lower() if g == "tolerance" else "ddg"])
|
|
427
|
+
print(f"[funclib] region={st['n_positions']} positions, gates={gates or 'none'}; "
|
|
428
|
+
f"{st['n_kept_positions']} mutable after filters")
|
|
429
|
+
print(f"[funclib] naive saturation {st['naive_saturation_space']:.3g} -> library {st['library_size']} "
|
|
430
|
+
f"(reduction {st['reduction_factor']:.3g}x){' [truncated]' if st['truncated'] else ''}")
|
|
431
|
+
for p in lib.positions:
|
|
432
|
+
opts = lib.tolerated.get(p, [])
|
|
433
|
+
tag = ",".join(aa for aa, _ in opts) if opts else "(excluded: conserved/destabilizing core)"
|
|
434
|
+
print(f" {ref[p]}{p}: {tag}")
|
|
435
|
+
print(f"[funclib] top {min(10, len(lib.candidates))} combinable multipoint targets (to MEASURE, not predictions):")
|
|
436
|
+
for c in lib.candidates[:10]:
|
|
437
|
+
extra = f" ddG={c.evidence['additive_ddg']:+.2f}" if "additive_ddg" in c.evidence else ""
|
|
438
|
+
print(f" {':'.join(c.mutations)} score={c.acq:.3f} n_mut={c.n_mut}{extra}")
|
|
439
|
+
print(f"[funclib] wrote json: {outdir / 'funclib.json'}")
|
|
440
|
+
print(f"[funclib] wrote csv: {outdir / 'funclib_library.csv'}")
|
|
441
|
+
return 0
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _read_table(path: str) -> tuple[list[dict], list[str]]:
|
|
445
|
+
"""Read a tabular file (CSV/TSV or Excel .xlsx/.xls/.xlsm) -> (rows as dicts, column names).
|
|
446
|
+
|
|
447
|
+
Excel needs pandas (clear error otherwise). CSV/TSV is stdlib with BOM stripping and a simple
|
|
448
|
+
tab-vs-comma delimiter sniff, so files exported straight from a spreadsheet load without a crash.
|
|
449
|
+
"""
|
|
450
|
+
import csv as _csv
|
|
451
|
+
|
|
452
|
+
suffix = Path(path).suffix.lower()
|
|
453
|
+
if suffix in (".xlsx", ".xls", ".xlsm"):
|
|
454
|
+
try:
|
|
455
|
+
import pandas as pd
|
|
456
|
+
except Exception as exc: # noqa: BLE001 (no pandas -> tell the user to export CSV)
|
|
457
|
+
raise ValueError(
|
|
458
|
+
f"{path} is an Excel file but pandas is unavailable to read it; export it to CSV "
|
|
459
|
+
"and pass the CSV instead"
|
|
460
|
+
) from exc
|
|
461
|
+
frame = pd.read_excel(path) # first sheet
|
|
462
|
+
return (
|
|
463
|
+
[{str(k): v for k, v in rec.items()} for rec in frame.to_dict("records")],
|
|
464
|
+
[str(c) for c in frame.columns],
|
|
465
|
+
)
|
|
466
|
+
with open(path, newline="", encoding="utf-8-sig") as fh: # utf-8-sig strips Excel's BOM
|
|
467
|
+
sample = fh.read(8192)
|
|
468
|
+
fh.seek(0)
|
|
469
|
+
delim = "\t" if ("\t" in sample and sample.count("\t") >= sample.count(",")) else ","
|
|
470
|
+
rdr = _csv.DictReader(fh, delimiter=delim)
|
|
471
|
+
return list(rdr), list(rdr.fieldnames or [])
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _read_landscape(path: str) -> dict[str, float]:
|
|
475
|
+
"""Read a variant,fitness table into {variant: fitness}. Last value wins on dup.
|
|
476
|
+
|
|
477
|
+
Accepts CSV/TSV *and* Excel (.xlsx/.xls) -- users routinely upload spreadsheets. Column
|
|
478
|
+
detection is lenient: a variant column (variant/mutant/mutation/sequence) and a fitness column
|
|
479
|
+
(fitness/activity/score/value/y), else the first two columns. Rows with a blank or non-numeric
|
|
480
|
+
fitness are skipped (an unfilled template column, or a predictions-only export).
|
|
481
|
+
"""
|
|
482
|
+
rows, fieldnames = _read_table(path)
|
|
483
|
+
if not fieldnames:
|
|
484
|
+
raise ValueError(f"{path}: no columns found (need a variant column and a fitness column)")
|
|
485
|
+
cols = {str(c).strip().lower(): c for c in fieldnames}
|
|
486
|
+
vcol = next((cols[k] for k in ("variant", "variants", "mutant", "mutation", "mutations",
|
|
487
|
+
"sequence", "seq") if k in cols), fieldnames[0])
|
|
488
|
+
fcol = next((cols[k] for k in ("fitness", "activity", "score", "value", "y", "measured",
|
|
489
|
+
"y_actual", "fit") if k in cols),
|
|
490
|
+
fieldnames[1] if len(fieldnames) > 1 else None)
|
|
491
|
+
if fcol is None:
|
|
492
|
+
raise ValueError(f"{path}: need a fitness column (e.g. 'fitness'/'activity'); found {fieldnames}")
|
|
493
|
+
table: dict[str, float] = {}
|
|
494
|
+
for row in rows:
|
|
495
|
+
v = str(row.get(vcol, "")).strip()
|
|
496
|
+
raw = row.get(fcol, None)
|
|
497
|
+
s = "" if raw is None else str(raw).strip()
|
|
498
|
+
if not v or s == "":
|
|
499
|
+
continue
|
|
500
|
+
try:
|
|
501
|
+
val = float(s)
|
|
502
|
+
except ValueError:
|
|
503
|
+
continue
|
|
504
|
+
if val != val: # NaN (e.g. a blank Excel cell)
|
|
505
|
+
continue
|
|
506
|
+
table[v] = val
|
|
507
|
+
if not table:
|
|
508
|
+
raise ValueError(
|
|
509
|
+
f"{path}: found columns {fieldnames} but no rows with a numeric '{fcol}' value "
|
|
510
|
+
"(predictions-only or unfilled template? need measured variant,fitness)"
|
|
511
|
+
)
|
|
512
|
+
return table
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _funclib_space(args: argparse.Namespace, seq: str) -> DesignSpace:
|
|
516
|
+
positions = _funclib_positions(args, len(seq))
|
|
517
|
+
ref = {p: seq[p - 1] for p in positions}
|
|
518
|
+
return DesignSpace(tuple(positions), ref)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def cmd_campaign(args: argparse.Namespace) -> int:
|
|
522
|
+
"""funclib -> active-learning handoff: R0 seed (funclib) then measurement-driven AL rounds.
|
|
523
|
+
|
|
524
|
+
--simulate LANDSCAPE.csv reproduces/validates the handoff against a measured landscape
|
|
525
|
+
(D19/D25). Without --simulate it is the live loop: no --measured -> emit the funclib R0 seed
|
|
526
|
+
plate; --measured given -> fit on accumulated data and propose the next plate over the FULL
|
|
527
|
+
saturation universe of the design positions (never hard-restricted to the funclib library).
|
|
528
|
+
"""
|
|
529
|
+
seq = read_fasta(args.wt_fasta)
|
|
530
|
+
space = _funclib_space(args, seq)
|
|
531
|
+
outdir = Path(args.outdir)
|
|
532
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
533
|
+
# auto-MSA seeds the funclib R0 library only; Rk (measured given) is activity-driven and never
|
|
534
|
+
# uses the tolerance signal, so skip the network build there.
|
|
535
|
+
if getattr(args, "auto_msa", False) and not args.msa and not args.measured:
|
|
536
|
+
print("[campaign] no --msa given; building the seed MSA from the WT sequence via ColabFold MMseqs2 ...")
|
|
537
|
+
a3m = build_msa_mmseqs2(seq)
|
|
538
|
+
if a3m:
|
|
539
|
+
msa_path = outdir / "auto_msa.a3m"
|
|
540
|
+
msa_path.write_text(a3m)
|
|
541
|
+
args.msa = str(msa_path)
|
|
542
|
+
print(f"[campaign] auto-MSA: {a3m.count('>')} sequences -> {msa_path}")
|
|
543
|
+
else:
|
|
544
|
+
print("[campaign] auto-MSA failed (MMseqs2 API unreachable / no hits); "
|
|
545
|
+
"R0 will abstain unless you supply --msa / --ddg / --if-logprobs")
|
|
546
|
+
ddg, tolerance = _funclib_signals(args, space)
|
|
547
|
+
make_surrogate = SurrogateSpec(args.model_class) # picklable factory (enables --jobs)
|
|
548
|
+
|
|
549
|
+
if args.simulate:
|
|
550
|
+
table = _read_landscape(args.simulate)
|
|
551
|
+
# design positions must match the landscape's combo encoding (one residue per position)
|
|
552
|
+
wt = space.wt()
|
|
553
|
+
if wt not in table:
|
|
554
|
+
print(f"[campaign] note: WT combo {wt!r} not in landscape (ok if landscape lacks WT row)")
|
|
555
|
+
constraint_info = None
|
|
556
|
+
if args.stability_constraint:
|
|
557
|
+
if ddg is None:
|
|
558
|
+
raise ValueError("--stability-constraint requires --ddg (predicted folding ddG table)")
|
|
559
|
+
global_win = max(table, key=table.get)
|
|
560
|
+
stable = set(stable_variants(
|
|
561
|
+
list(table), space, ddg, ddg_cutoff=args.ddg_cutoff,
|
|
562
|
+
additive_budget=args.additive_ddg_budget,
|
|
563
|
+
))
|
|
564
|
+
stable.add(wt) # always allow WT (the campaign baseline)
|
|
565
|
+
kept = {v: f for v, f in table.items() if v in stable}
|
|
566
|
+
constr_win = max(kept, key=kept.get) if kept else None
|
|
567
|
+
constraint_info = {
|
|
568
|
+
"global_activity_winner": global_win,
|
|
569
|
+
"global_winner_fitness": table[global_win],
|
|
570
|
+
"global_winner_passes_stability": global_win in stable,
|
|
571
|
+
"n_stable": len(kept), "n_total": len(table),
|
|
572
|
+
"constrained_optimum": constr_win,
|
|
573
|
+
"constrained_optimum_fitness": (kept[constr_win] if constr_win else None),
|
|
574
|
+
}
|
|
575
|
+
table = kept
|
|
576
|
+
print(f"[campaign] stability constraint (ddG<= {args.ddg_cutoff}"
|
|
577
|
+
f"{f', budget {args.additive_ddg_budget}' if args.additive_ddg_budget is not None else ''}): "
|
|
578
|
+
f"{len(kept)}/{constraint_info['n_total']} variants stable; "
|
|
579
|
+
f"global activity winner {global_win} {'PASSES' if constraint_info['global_winner_passes_stability'] else 'FAILS'} the gate")
|
|
580
|
+
seed_lib = None
|
|
581
|
+
results = {}
|
|
582
|
+
for strat in args.strategies:
|
|
583
|
+
if strat == "funclib":
|
|
584
|
+
sv, seed_lib = funclib_seed_variants(
|
|
585
|
+
space, plate=args.plate, ddg=ddg, tolerance=tolerance,
|
|
586
|
+
ddg_cutoff=args.ddg_cutoff, tolerance_cutoff=args.tolerance_cutoff,
|
|
587
|
+
per_position_cap=args.per_position_cap, min_mut=args.min_mut,
|
|
588
|
+
max_mut=args.max_mut, additive_ddg_budget=args.additive_ddg_budget,
|
|
589
|
+
allow_unscored=args.allow_unscored,
|
|
590
|
+
)
|
|
591
|
+
if not sv:
|
|
592
|
+
print(f"[campaign] funclib ABSTAINED ({seed_lib.reason}); skipping funclib arm")
|
|
593
|
+
continue
|
|
594
|
+
elif strat == "singles":
|
|
595
|
+
sv = single_mutant_variants(space)
|
|
596
|
+
elif strat == "random":
|
|
597
|
+
sv = None
|
|
598
|
+
else:
|
|
599
|
+
raise ValueError(f"unknown strategy {strat!r}")
|
|
600
|
+
res = simulate_campaign(
|
|
601
|
+
table, space, seed_variants=sv, strategy=strat, plate=args.plate,
|
|
602
|
+
rounds=args.rounds, n_seeds=args.seeds, beta=args.beta,
|
|
603
|
+
make_surrogate=make_surrogate, n_jobs=args.jobs,
|
|
604
|
+
)
|
|
605
|
+
results[strat] = res
|
|
606
|
+
|
|
607
|
+
payload = {
|
|
608
|
+
"mode": "simulate", "landscape": args.simulate,
|
|
609
|
+
"positions": list(space.positions), "wt": space.wt(),
|
|
610
|
+
"plate": args.plate, "rounds": args.rounds, "n_seeds": args.seeds,
|
|
611
|
+
"acquisition": "greedy" if args.beta <= 0 else f"ucb(beta={args.beta})",
|
|
612
|
+
"surrogate": args.model_class,
|
|
613
|
+
"stability_constraint": constraint_info,
|
|
614
|
+
"funclib_seed_size": (len(seed_lib.candidates) if seed_lib else None),
|
|
615
|
+
"strategies": {
|
|
616
|
+
s: {
|
|
617
|
+
"winner_reach_rate": r.winner_reach_rate,
|
|
618
|
+
"rounds_to_winner_median": r.rounds_to_winner_median,
|
|
619
|
+
"best_found_mean": r.best_found_mean,
|
|
620
|
+
"best_found_percentile_mean": r.best_found_percentile_mean,
|
|
621
|
+
"per_seed": r.per_seed,
|
|
622
|
+
} for s, r in results.items()
|
|
623
|
+
},
|
|
624
|
+
}
|
|
625
|
+
(outdir / "campaign_simulate.json").write_text(json.dumps(payload, indent=2))
|
|
626
|
+
print(f"[campaign] simulate over {len(table)} measured variants; positions={list(space.positions)} "
|
|
627
|
+
f"plate={args.plate} rounds={args.rounds} seeds={args.seeds} "
|
|
628
|
+
f"acq={'greedy' if args.beta<=0 else f'ucb{args.beta}'}")
|
|
629
|
+
for s, r in results.items():
|
|
630
|
+
rtw = f"{r.rounds_to_winner_median:.1f}" if r.rounds_to_winner_median is not None else "n/a"
|
|
631
|
+
print(f" {s:8s}: winner-reach {r.winner_reach_rate*100:5.1f}% median rounds-to-winner {rtw} "
|
|
632
|
+
f"best-found percentile {r.best_found_percentile_mean:.3f}")
|
|
633
|
+
print(f"[campaign] wrote {outdir / 'campaign_simulate.json'}")
|
|
634
|
+
return 0
|
|
635
|
+
|
|
636
|
+
# ---- live mode ----
|
|
637
|
+
measured_paths = args.measured or []
|
|
638
|
+
if not measured_paths:
|
|
639
|
+
# R0: cold start -> funclib seed plate to MEASURE (AL is blind with zero data).
|
|
640
|
+
sv, lib = funclib_seed_variants(
|
|
641
|
+
space, plate=args.plate, ddg=ddg, tolerance=tolerance,
|
|
642
|
+
ddg_cutoff=args.ddg_cutoff, tolerance_cutoff=args.tolerance_cutoff,
|
|
643
|
+
per_position_cap=args.per_position_cap, min_mut=args.min_mut,
|
|
644
|
+
max_mut=args.max_mut, additive_ddg_budget=args.additive_ddg_budget,
|
|
645
|
+
allow_unscored=args.allow_unscored,
|
|
646
|
+
)
|
|
647
|
+
if lib.abstained or not sv:
|
|
648
|
+
print(f"[campaign] R0 ABSTAIN: {lib.reason or 'no seed variants'} (supply --msa/--ddg/--if-logprobs or relax cutoffs)")
|
|
649
|
+
return 0
|
|
650
|
+
import csv as _csv
|
|
651
|
+
|
|
652
|
+
def _r0_rows(cands):
|
|
653
|
+
for c in cands:
|
|
654
|
+
yield [":".join(c.mutations) or "WT", c.n_mut, f"{c.acq:.4f}", c.variant]
|
|
655
|
+
|
|
656
|
+
header = ["mutations", "n_mut", "seed_score", "variant"]
|
|
657
|
+
top = lib.candidates[: args.plate]
|
|
658
|
+
for name, rows in (("round0_plate.csv", top), ("round0_all.csv", lib.candidates)):
|
|
659
|
+
with (outdir / name).open("w", newline="") as fh:
|
|
660
|
+
w = _csv.writer(fh)
|
|
661
|
+
w.writerow(header)
|
|
662
|
+
w.writerows(_r0_rows(rows))
|
|
663
|
+
with (outdir / "round0_measured_template.csv").open("w", newline="") as fh:
|
|
664
|
+
w = _csv.writer(fh)
|
|
665
|
+
w.writerow(["variant", "fitness"])
|
|
666
|
+
for c in top:
|
|
667
|
+
w.writerow([c.variant, ""])
|
|
668
|
+
print(f"[campaign] R0 funclib seed over positions {list(space.positions)}: "
|
|
669
|
+
f"{len(top)} to MEASURE (round0_plate.csv), {len(lib.candidates)} total candidates "
|
|
670
|
+
f"(round0_all.csv)")
|
|
671
|
+
print("[campaign] fill the 'fitness' column of round0_measured_template.csv, then re-run with "
|
|
672
|
+
"--measured round0_measured_template.csv for R1 (AL over the full saturation space).")
|
|
673
|
+
return 0
|
|
674
|
+
|
|
675
|
+
# Rk: fit on accumulated measurements, propose the next plate over the FULL saturation universe.
|
|
676
|
+
table: dict[str, float] = {}
|
|
677
|
+
for p in measured_paths:
|
|
678
|
+
table.update(_read_landscape(p))
|
|
679
|
+
variants = list(table)
|
|
680
|
+
if getattr(args, "wt_fitness", None) is not None:
|
|
681
|
+
# WT is not on the plate, but under WT-normalisation its (relative) activity is known
|
|
682
|
+
# (e.g. 1.0). Anchor it so "beats WT" and the surrogate baseline are exact, not predicted.
|
|
683
|
+
table.setdefault(space.wt(), float(args.wt_fitness))
|
|
684
|
+
variants = list(table)
|
|
685
|
+
# Guard: measured variants must fit the resolved design region. A whole-protein single-mutant
|
|
686
|
+
# scan (e.g. "29I", or positions outside the active-site shell) is a different regime -- abstain
|
|
687
|
+
# with guidance instead of crashing deep in the variant parser / encoder.
|
|
688
|
+
_measured_only = [v for v in variants if v != space.wt()]
|
|
689
|
+
_unfit: dict[str, str] = {}
|
|
690
|
+
for _v in _measured_only:
|
|
691
|
+
try:
|
|
692
|
+
mutations_of(_v, space)
|
|
693
|
+
except ValueError as _exc:
|
|
694
|
+
_unfit[_v] = str(_exc)
|
|
695
|
+
if _measured_only and len(_unfit) == len(_measured_only):
|
|
696
|
+
_lo, _hi = space.positions[0], space.positions[-1]
|
|
697
|
+
print(f"[campaign] ABSTAIN: none of the {len(_measured_only)} measured variants fit the design "
|
|
698
|
+
f"region (positions {_lo}-{_hi}, {space.n_positions} sites).")
|
|
699
|
+
for _v, _why in list(_unfit.items())[:4]:
|
|
700
|
+
print(f" {_v}: {_why}")
|
|
701
|
+
print("[campaign] this looks like a whole-protein single-mutant scan, not active-site "
|
|
702
|
+
"combinatorial data for these positions.")
|
|
703
|
+
print("[campaign] -> rank positions from a scan with `shellde hotspots <scan.csv>`, then "
|
|
704
|
+
"`round --positions <top>`; or set --positions to the measured positions.")
|
|
705
|
+
print("[campaign] -> variant notation must be [WT][pos][MT] (e.g. V29I), not 29I.")
|
|
706
|
+
return 1
|
|
707
|
+
if _unfit:
|
|
708
|
+
print(f"[campaign] WARNING: dropping {len(_unfit)}/{len(_measured_only)} measured variants "
|
|
709
|
+
f"outside the design region (e.g. {next(iter(_unfit))}); fitting on the rest.")
|
|
710
|
+
variants = [v for v in variants if v == space.wt() or v not in _unfit]
|
|
711
|
+
fitness = np.asarray([table[v] for v in variants], dtype=float)
|
|
712
|
+
matrix = FeatureMatrix(space, default_blocks(space))
|
|
713
|
+
if args.if_logprobs:
|
|
714
|
+
matrix = FeatureMatrix(space, [*default_blocks(space), InverseFoldingBlock(space, load_inverse_folding_logprobs(args.if_logprobs))])
|
|
715
|
+
x_enc = matrix.encode(variants)
|
|
716
|
+
sur = make_surrogate().fit(x_enc, fitness)
|
|
717
|
+
conf_q = (
|
|
718
|
+
cross_conformal_normalized_q(x_enc, fitness, make_surrogate)
|
|
719
|
+
if len(variants) >= 8 and float(np.std(fitness)) > 0.0 else None
|
|
720
|
+
)
|
|
721
|
+
max_meas_nmut = max(len(mutations_of(v, space)) for v in variants)
|
|
722
|
+
cset = generate_candidates(
|
|
723
|
+
sur, matrix, pinned_positions=list(space.positions), max_mut=min(args.max_mut, space.n_positions),
|
|
724
|
+
generated_cap=args.generated_cap, scored_cap=args.scored_cap, beta=args.beta, seed=0,
|
|
725
|
+
)
|
|
726
|
+
wt_mean = float(sur.predict(matrix.encode([space.wt()])).mean[0])
|
|
727
|
+
if getattr(args, "plm", "none") != "none" or getattr(args, "naturalness", "none") != "none":
|
|
728
|
+
# EVOLVEpro-style Rk: refit a surrogate on the measured data over ESM embeddings and rerank the
|
|
729
|
+
# top-K one-hot candidates. Embeds only measured + shortlist (tractable). GPU-recommended.
|
|
730
|
+
emb_cache = EmbeddingCache(args.plm_cache) if getattr(args, "plm_cache", None) else None
|
|
731
|
+
nat_cache = EmbeddingCache(args.naturalness_cache) if getattr(args, "naturalness_cache", None) else None
|
|
732
|
+
provider = build_provider(args.plm, model=args.plm_model, cache=emb_cache) if args.plm != "none" else None
|
|
733
|
+
nat = (build_naturalness(args.naturalness, model=args.plm_model, cache=nat_cache)
|
|
734
|
+
if args.naturalness != "none" else None)
|
|
735
|
+
resolved_pooling = resolve_plm_pooling(args.plm_pooling, args.plm_model)
|
|
736
|
+
print(f"[campaign] Rk rerank: refit PLM/naturalness surrogate on {len(variants)} measured -> "
|
|
737
|
+
f"rerank top {args.plm_rerank} candidates ({args.plm}/{args.naturalness})")
|
|
738
|
+
cset = plm_rerank(cset, space, variants, fitness, provider=provider, naturalness=nat,
|
|
739
|
+
wt_sequence=read_fasta(args.wt_fasta), top_k=args.plm_rerank,
|
|
740
|
+
plm_pooling=resolved_pooling)
|
|
741
|
+
wt_mean = float(cset.stats.get("wt_mean", wt_mean))
|
|
742
|
+
if emb_cache is not None:
|
|
743
|
+
emb_cache.save()
|
|
744
|
+
if nat_cache is not None:
|
|
745
|
+
nat_cache.save()
|
|
746
|
+
ranking = rank_candidates(
|
|
747
|
+
cset, space, acquisition=UCB(max(args.beta, 0.0)), per_count_k=args.plate,
|
|
748
|
+
global_k=args.plate, wt_mean=wt_mean, conformal_q=conf_q, max_measured_nmut=max_meas_nmut,
|
|
749
|
+
)
|
|
750
|
+
rk = len(measured_paths)
|
|
751
|
+
import csv as _csv
|
|
752
|
+
|
|
753
|
+
def _rk_rows(cands):
|
|
754
|
+
for c in cands:
|
|
755
|
+
yield [":".join(c.mutations) or "WT", len(c.mutations), f"{c.mu:.4f}", f"{c.sigma:.4f}",
|
|
756
|
+
f"{c.acq:.4f}", c.evidence.get("risk", ""), c.variant]
|
|
757
|
+
|
|
758
|
+
header = ["mutations", "n_mut", "mu", "sigma", "acq", "risk", "variant"]
|
|
759
|
+
top = ranking.global_ranking[: args.plate]
|
|
760
|
+
for name, rows in ((f"round{rk}_plate.csv", top), (f"round{rk}_all.csv", ranking.global_ranking)):
|
|
761
|
+
with (outdir / name).open("w", newline="") as fh:
|
|
762
|
+
w = _csv.writer(fh)
|
|
763
|
+
w.writerow(header)
|
|
764
|
+
w.writerows(_rk_rows(rows))
|
|
765
|
+
with (outdir / f"round{rk}_measured_template.csv").open("w", newline="") as fh:
|
|
766
|
+
w = _csv.writer(fh)
|
|
767
|
+
w.writerow(["variant", "fitness"])
|
|
768
|
+
for c in top:
|
|
769
|
+
w.writerow([c.variant, ""])
|
|
770
|
+
print(f"[campaign] R{rk}: fit on {len(variants)} measured; {len(top)} to MEASURE "
|
|
771
|
+
f"(round{rk}_plate.csv), {len(ranking.global_ranking)} scored candidates (round{rk}_all.csv) "
|
|
772
|
+
f"over full saturation of {space.n_positions} positions")
|
|
773
|
+
for c in top[: min(10, len(top))]:
|
|
774
|
+
print(f" [{','.join(c.mutations) or 'WT'}] mu={c.mu:.3f} sigma={c.sigma:.3f} "
|
|
775
|
+
f"acq={c.acq:.3f} ({c.evidence.get('risk', '')})")
|
|
776
|
+
print(f"[campaign] fill the 'fitness' column of round{rk}_measured_template.csv for the next round")
|
|
777
|
+
try: # auto-advise: is the surrogate trustworthy yet, or keep measuring? (advisory, never fatal)
|
|
778
|
+
adv = data_readiness(space, variants, fitness)
|
|
779
|
+
sp = "n/a" if adv["heldout_spearman"] is None else f"{adv['heldout_spearman']:+.3f}"
|
|
780
|
+
print(f"[campaign] advise: held-out Spearman={sp}, trustworthy={adv['model_trustworthy']} "
|
|
781
|
+
f"-> {adv['recommendation'].upper()} ({adv['why']})")
|
|
782
|
+
_print_epistasis_next_step(
|
|
783
|
+
adv["recommendation"], getattr(args, "measured", None), space.positions,
|
|
784
|
+
)
|
|
785
|
+
except Exception: # noqa: BLE001 (advisory only)
|
|
786
|
+
pass
|
|
787
|
+
return 0
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _resolve_site_ladder(
|
|
791
|
+
args: argparse.Namespace, seq: str
|
|
792
|
+
) -> tuple[ResolveResult | None, Path | None]:
|
|
793
|
+
"""Evidence ladder (UniProt features -> UniProt-linked PDB cocrystal -> AlphaFold fetch),
|
|
794
|
+
every position sequence-aligned onto the WT numbering. Returns (result, alphafold_model_path).
|
|
795
|
+
Returns (None, None) when no lookup source (--uniprot/--query/--pdb) was given. Shared by
|
|
796
|
+
``resolve-site`` and the ``round`` front door so both use one ladder with one behaviour.
|
|
797
|
+
"""
|
|
798
|
+
if getattr(args, "uniprot", None) == "auto":
|
|
799
|
+
accs = uniprot_from_sequence(seq, timeout=args.timeout)
|
|
800
|
+
if accs:
|
|
801
|
+
print(f"[resolve-site] exact UniProt match for this sequence: {accs[0]}"
|
|
802
|
+
f"{f' (+{len(accs) - 1} identical entrie(s))' if len(accs) > 1 else ''}")
|
|
803
|
+
args.uniprot = accs[0]
|
|
804
|
+
else:
|
|
805
|
+
print("[resolve-site] no EXACT UniProt match for this sequence (engineered construct?) "
|
|
806
|
+
"-> pass --uniprot ACC / --query / --pdb / --positions")
|
|
807
|
+
args.uniprot = None
|
|
808
|
+
lig = args.ligand_resnames.split(",") if args.ligand_resnames else None
|
|
809
|
+
|
|
810
|
+
def _try_pdb(pdb_ref: str) -> ResolveResult:
|
|
811
|
+
# accept a LOCAL structure file (your own AlphaFold model or cocrystal) OR an RCSB ID
|
|
812
|
+
if Path(pdb_ref).is_file():
|
|
813
|
+
text = Path(pdb_ref).read_text()
|
|
814
|
+
pid = Path(pdb_ref).stem
|
|
815
|
+
else:
|
|
816
|
+
try:
|
|
817
|
+
text = fetch_pdb(pdb_ref, timeout=args.timeout)
|
|
818
|
+
except Exception as exc: # noqa: BLE001 (network/HTTP failures -> skip this PDB)
|
|
819
|
+
return ResolveResult(source=f"PDB:{pdb_ref}", abstained=True,
|
|
820
|
+
reason=f"download failed ({exc})")
|
|
821
|
+
pid = pdb_ref
|
|
822
|
+
return resolve_from_pdb(seq, text, pdb_id=pid, ligand_resnames=lig,
|
|
823
|
+
contact_cutoff=args.contact_cutoff, min_coverage=args.min_coverage)
|
|
824
|
+
|
|
825
|
+
acc = args.uniprot
|
|
826
|
+
if args.pdb and (args.uniprot or args.query):
|
|
827
|
+
# Both a structure AND a UniProt source: the PDB cocrystal shell is the (broad) design set,
|
|
828
|
+
# and UniProt curated sites annotate its catalytic core (+ QC that they fall in the shell).
|
|
829
|
+
# --exclude-catalytic then designs only the tunable 2nd-shell ring (avoids saturating the
|
|
830
|
+
# usually-lethal catalytic core).
|
|
831
|
+
res = _try_pdb(args.pdb)
|
|
832
|
+
if res is not None and not res.abstained:
|
|
833
|
+
catalytic: set[int] = set()
|
|
834
|
+
try:
|
|
835
|
+
udata = fetch_uniprot(accession=args.uniprot, query=args.query, timeout=args.timeout)
|
|
836
|
+
except Exception: # noqa: BLE001 (UniProt lookup failure -> just skip annotation)
|
|
837
|
+
udata = None
|
|
838
|
+
if udata:
|
|
839
|
+
ures = resolve_from_entry(seq, udata, min_coverage=args.min_coverage)
|
|
840
|
+
if not ures.abstained:
|
|
841
|
+
catalytic = set(ures.positions)
|
|
842
|
+
in_shell = sorted(catalytic & set(res.positions))
|
|
843
|
+
print(f"[resolve-site] combine: PDB shell {len(res.positions)} positions + UniProt catalytic "
|
|
844
|
+
f"{len(catalytic)}; {len(in_shell)}/{len(catalytic)} catalytic residues fall in the shell (QC)")
|
|
845
|
+
res.consensus = build_consensus({
|
|
846
|
+
"pdb_cocrystal_shell": list(res.positions),
|
|
847
|
+
"uniprot_catalytic": sorted(catalytic),
|
|
848
|
+
})
|
|
849
|
+
print(f"[resolve-site] evidence consensus: {consensus_summary(res.consensus)} "
|
|
850
|
+
"(high = >=2 evidence types agree; measure high-confidence positions first)")
|
|
851
|
+
if getattr(args, "exclude_catalytic", False):
|
|
852
|
+
keep = [p for p in res.positions if p not in catalytic]
|
|
853
|
+
res.positions = keep
|
|
854
|
+
res.sites = [s for s in res.sites if s.wt_position in keep]
|
|
855
|
+
res.source = f"{res.source}+UniProt(-catalytic)"
|
|
856
|
+
print(f"[resolve-site] --exclude-catalytic: designing over {len(keep)} tunable-ring "
|
|
857
|
+
f"positions (dropped {len(in_shell)} catalytic-core positions {in_shell})")
|
|
858
|
+
else:
|
|
859
|
+
res.source = f"{res.source}+UniProt(catalytic-flagged)"
|
|
860
|
+
if in_shell:
|
|
861
|
+
print(f"[resolve-site] catalytic core flagged: {in_shell} "
|
|
862
|
+
"(pass --exclude-catalytic to design only the tunable ring)")
|
|
863
|
+
elif args.pdb:
|
|
864
|
+
res = _try_pdb(args.pdb)
|
|
865
|
+
elif not (args.uniprot or args.query):
|
|
866
|
+
return None, None
|
|
867
|
+
else:
|
|
868
|
+
try:
|
|
869
|
+
data = fetch_uniprot(accession=args.uniprot, query=args.query, timeout=args.timeout)
|
|
870
|
+
except Exception as exc: # noqa: BLE001 (network/HTTP/timeout -> abstain, never crash)
|
|
871
|
+
return ResolveResult(source="UniProt", abstained=True,
|
|
872
|
+
reason=f"UniProt lookup failed ({exc})"), None
|
|
873
|
+
if data is None:
|
|
874
|
+
return ResolveResult(source="UniProt", abstained=True,
|
|
875
|
+
reason="no UniProt entry matched"), None
|
|
876
|
+
acc = acc or data.get("primaryAccession")
|
|
877
|
+
res = resolve_from_entry(seq, data, min_coverage=args.min_coverage)
|
|
878
|
+
if res.abstained and not args.no_pdb_fallback:
|
|
879
|
+
pdb_ids = pdb_ids_from_uniprot(data)
|
|
880
|
+
if pdb_ids:
|
|
881
|
+
print(f"[resolve-site] UniProt features did not map ({res.reason}); "
|
|
882
|
+
f"trying {min(len(pdb_ids), args.max_pdb)} linked PDB cocrystal(s): {pdb_ids[:args.max_pdb]}")
|
|
883
|
+
for pid in pdb_ids[: args.max_pdb]:
|
|
884
|
+
cand = _try_pdb(pid)
|
|
885
|
+
print(f"[resolve-site] {pid}: {'ok' if not cand.abstained else 'skip -- ' + cand.reason}")
|
|
886
|
+
if not cand.abstained:
|
|
887
|
+
res = cand
|
|
888
|
+
break
|
|
889
|
+
|
|
890
|
+
outdir = Path(args.outdir)
|
|
891
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
892
|
+
af_saved = None
|
|
893
|
+
if res.abstained and acc and not args.no_alphafold:
|
|
894
|
+
af = fetch_alphafold(acc, timeout=args.timeout)
|
|
895
|
+
if af:
|
|
896
|
+
af_saved = outdir / f"AF-{acc}.pdb"
|
|
897
|
+
af_saved.write_text(af)
|
|
898
|
+
if args.apo_pocket:
|
|
899
|
+
apo = resolve_from_apo(seq, af, fpocket_bin=args.fpocket)
|
|
900
|
+
if not apo.abstained:
|
|
901
|
+
res = apo # predicted-pocket -- LOW confidence, must verify
|
|
902
|
+
return res, af_saved
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _find_holo_pdb(args: argparse.Namespace, seq: str) -> str | None:
|
|
906
|
+
"""--find-holo: get a WT query structure (--pdb, or the AlphaFold model via accession) -> Foldseek
|
|
907
|
+
-> top ligand-bound (holo) PDB id, to use as the design region. None if nothing usable."""
|
|
908
|
+
query = None
|
|
909
|
+
if args.pdb:
|
|
910
|
+
query = Path(args.pdb).read_text() if Path(args.pdb).exists() else fetch_pdb(args.pdb, timeout=args.timeout)
|
|
911
|
+
else:
|
|
912
|
+
acc = args.uniprot if args.uniprot and args.uniprot != "auto" else None
|
|
913
|
+
if acc is None and args.uniprot == "auto":
|
|
914
|
+
hits = uniprot_from_sequence(seq, timeout=args.timeout)
|
|
915
|
+
acc = hits[0] if hits else None
|
|
916
|
+
if acc:
|
|
917
|
+
try:
|
|
918
|
+
query = fetch_alphafold(acc, timeout=args.timeout)
|
|
919
|
+
except Exception: # noqa: BLE001
|
|
920
|
+
query = None
|
|
921
|
+
if not query:
|
|
922
|
+
print("[round] --find-holo: no query structure (need --pdb, or --uniprot ACC/auto for the "
|
|
923
|
+
"AlphaFold model); skipping")
|
|
924
|
+
return None
|
|
925
|
+
print("[round] --find-holo: Foldseek-searching for ligand-bound homologs (network; ~1 min)...")
|
|
926
|
+
cands = find_holo_homolog(query, timeout=args.timeout)
|
|
927
|
+
if not cands:
|
|
928
|
+
print("[round] --find-holo: no ligand-bound homolog found -> continuing with the normal ladder")
|
|
929
|
+
return None
|
|
930
|
+
print("[round] holo homolog candidates: " + ", ".join(
|
|
931
|
+
f"{c['pdb_id']}(lig {';'.join(c['ligands'])}; E={c['evalue']:.0e}, cov {c['coverage']:.0%})"
|
|
932
|
+
for c in cands))
|
|
933
|
+
top = cands[0]["pdb_id"]
|
|
934
|
+
print(f"[round] --find-holo: using {top} as --pdb (override by passing a different pdb=)")
|
|
935
|
+
return top
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def cmd_round(args: argparse.Namespace) -> int:
|
|
939
|
+
"""One command per wet-lab round: auto-resolve the active-site design region (evidence ladder),
|
|
940
|
+
then run the campaign round. No --measured -> emit the R0 funclib seed plate to MEASURE; add each
|
|
941
|
+
round's results via --measured to get the next AL plate over the full saturation universe. The
|
|
942
|
+
front door: WT FASTA + an accession/PDB in, a plate CSV out, no position bookkeeping by hand.
|
|
943
|
+
"""
|
|
944
|
+
seq = read_fasta(args.wt_fasta)
|
|
945
|
+
if getattr(args, "find_holo", False) and not args.pdb and not args.positions and not args.holo:
|
|
946
|
+
holo_id = _find_holo_pdb(args, seq)
|
|
947
|
+
if holo_id:
|
|
948
|
+
args.pdb = holo_id
|
|
949
|
+
if not args.positions and not args.holo:
|
|
950
|
+
if not (args.uniprot or args.query or args.pdb):
|
|
951
|
+
print("[round] need a design region: --positions, --holo <PDB>, or a lookup source "
|
|
952
|
+
"(--uniprot ACC / --query / --pdb ID) for the auto evidence ladder")
|
|
953
|
+
return 1
|
|
954
|
+
res, af_saved = _resolve_site_ladder(args, seq)
|
|
955
|
+
if res is None or res.abstained or not res.positions:
|
|
956
|
+
reason = "no lookup source" if res is None else (res.reason or "no positions mapped")
|
|
957
|
+
print(f"[round] active-site auto-resolve ABSTAINED ({reason}); supply --positions or --holo <PDB>")
|
|
958
|
+
if af_saved:
|
|
959
|
+
print(f"[round] fetched AlphaFold model -> {af_saved} (apo; add a ligand pose and pass via --pdb)")
|
|
960
|
+
return 1
|
|
961
|
+
args.positions = ",".join(map(str, res.positions))
|
|
962
|
+
print(f"[round] resolved {len(res.positions)} active-site positions from {res.source} "
|
|
963
|
+
f"(WT-coverage {res.coverage:.0%}): {args.positions}")
|
|
964
|
+
return cmd_campaign(args)
|
|
965
|
+
|
|
966
|
+
def cmd_resolve_site(args: argparse.Namespace) -> int:
|
|
967
|
+
"""Resolve active-site/binding positions via an evidence ladder, mapped to the WT numbering.
|
|
968
|
+
|
|
969
|
+
Ladder: (1) UniProt curated site features; (2) if those do not map and fallback is allowed, a
|
|
970
|
+
UniProt-linked PDB cocrystal -> active-site shells. ``--pdb ID`` forces the structure path.
|
|
971
|
+
Abstains (defer to --holo / --positions) when no evidence maps. Every position is sequence-aligned
|
|
972
|
+
onto WT so numbering offsets (transit peptide / species / PDB gaps) are handled, never guessed.
|
|
973
|
+
"""
|
|
974
|
+
seq = read_fasta(args.wt_fasta)
|
|
975
|
+
res, af_saved = _resolve_site_ladder(args, seq)
|
|
976
|
+
if res is None:
|
|
977
|
+
print("[resolve-site] no --uniprot/--query/--pdb given; cannot look up evidence")
|
|
978
|
+
print("[resolve-site] -> provide --uniprot ACC, --query, or --pdb ID (or use --holo/--positions)")
|
|
979
|
+
return 0
|
|
980
|
+
if res.source == "UniProt" and res.reason == "no UniProt entry matched" and not res.sites:
|
|
981
|
+
print("[resolve-site] no UniProt entry matched; abstaining -> use --holo/--positions")
|
|
982
|
+
return 0
|
|
983
|
+
outdir = Path(args.outdir)
|
|
984
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
985
|
+
|
|
986
|
+
payload = {
|
|
987
|
+
"source": res.source, "wt_coverage": res.coverage, "abstained": res.abstained,
|
|
988
|
+
"reason": res.reason, "positions": res.positions, "consensus": res.consensus,
|
|
989
|
+
"alphafold_model": (str(af_saved) if af_saved else None),
|
|
990
|
+
"sites": [
|
|
991
|
+
{"wt_position": s.wt_position, "residue": s.residue, "source_position": s.source_position,
|
|
992
|
+
"types": list(s.types), "ligands": list(s.ligands), "confidence": s.confidence}
|
|
993
|
+
for s in res.sites
|
|
994
|
+
],
|
|
995
|
+
"unmapped_features": [
|
|
996
|
+
{"type": f.type, "uniprot_position": f.start, "ligand": f.ligand, "evidence": f.evidence}
|
|
997
|
+
for f in res.unmapped
|
|
998
|
+
],
|
|
999
|
+
}
|
|
1000
|
+
(outdir / "resolve_site.json").write_text(json.dumps(payload, indent=2))
|
|
1001
|
+
if getattr(args, "find_holo", False):
|
|
1002
|
+
qtext = None
|
|
1003
|
+
if af_saved:
|
|
1004
|
+
qtext = Path(af_saved).read_text()
|
|
1005
|
+
elif getattr(args, "holo", None) and Path(args.holo).exists():
|
|
1006
|
+
qtext = Path(args.holo).read_text()
|
|
1007
|
+
elif args.pdb:
|
|
1008
|
+
qtext = Path(args.pdb).read_text() if Path(args.pdb).exists() else fetch_pdb(args.pdb, timeout=args.timeout)
|
|
1009
|
+
if not qtext:
|
|
1010
|
+
print("[resolve-site] --find-holo needs a query structure (AlphaFold model / --holo / --pdb); none available")
|
|
1011
|
+
else:
|
|
1012
|
+
print("[resolve-site] --find-holo: Foldseek-searching for ligand-bound homologs (network; ~1 min)...")
|
|
1013
|
+
cands = find_holo_homolog(qtext, timeout=args.timeout)
|
|
1014
|
+
if cands:
|
|
1015
|
+
print(f"[resolve-site] holo homolog candidates ({len(cands)}):")
|
|
1016
|
+
for c in cands:
|
|
1017
|
+
print(f" {c['pdb_id']} ligands={';'.join(c['ligands'])} "
|
|
1018
|
+
f"(E={c['evalue']:.0e}, coverage {c['coverage']:.0%}, Foldseek rank {c['rank']})")
|
|
1019
|
+
print(f"[resolve-site] -> re-run with --pdb {cands[0]['pdb_id']} to use its ligand pose (aligned onto WT)")
|
|
1020
|
+
else:
|
|
1021
|
+
print("[resolve-site] --find-holo: no ligand-bound homolog found (abstained)")
|
|
1022
|
+
if res.abstained:
|
|
1023
|
+
print(f"[resolve-site] ABSTAIN ({res.source}, WT-coverage {res.coverage:.0%}): {res.reason}")
|
|
1024
|
+
if af_saved:
|
|
1025
|
+
print(f"[resolve-site] fetched AlphaFold model -> {af_saved} (predicted, APO / no ligand)")
|
|
1026
|
+
if args.apo_pocket:
|
|
1027
|
+
print("[resolve-site] apo-pocket (fpocket) found no pocket that maps to WT")
|
|
1028
|
+
else:
|
|
1029
|
+
print("[resolve-site] -> add a ligand pose and pass via --pdb, or try --apo-pocket "
|
|
1030
|
+
"(fpocket; LOW confidence -- can miss the catalytic site), or --positions.")
|
|
1031
|
+
print("[resolve-site] -> options: --positions (manual); --pdb <RCSB id or local holo PDB>; --holo in campaign.")
|
|
1032
|
+
print(f"[resolve-site] wrote {outdir / 'resolve_site.json'}")
|
|
1033
|
+
return 0
|
|
1034
|
+
if any(s.confidence == "predicted-pocket" for s in res.sites):
|
|
1035
|
+
print("[resolve-site] WARNING: predicted-pocket (apo fpocket) is LOW-confidence and can MISS the "
|
|
1036
|
+
"catalytic site (it did on ispS in our test). Verify before committing a campaign.")
|
|
1037
|
+
print(f"[resolve-site] {res.source} WT-coverage {res.coverage:.0%} {len(res.sites)} mapped site positions")
|
|
1038
|
+
for s in res.sites:
|
|
1039
|
+
ligs = f" lig={';'.join(s.ligands)}" if s.ligands else ""
|
|
1040
|
+
print(f" {s.residue}{s.wt_position} [{','.join(s.types)}]{ligs} ({s.confidence}; src {s.source_position})")
|
|
1041
|
+
if res.unmapped:
|
|
1042
|
+
print(f"[resolve-site] {len(res.unmapped)} UniProt features did NOT align onto WT "
|
|
1043
|
+
f"(numbering/region mismatch) -- not used (see json)")
|
|
1044
|
+
pstr = ",".join(map(str, res.positions))
|
|
1045
|
+
print(f"[resolve-site] positions: {pstr}")
|
|
1046
|
+
print(f"[resolve-site] -> next: shellde campaign {args.wt_fasta} --positions {pstr} --msa ... --plate 95")
|
|
1047
|
+
print(f"[resolve-site] wrote {outdir / 'resolve_site.json'}")
|
|
1048
|
+
return 0
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def cmd_bench(args: argparse.Namespace) -> int:
|
|
1052
|
+
alphabet = AA_ALPHABET[: args.q]
|
|
1053
|
+
positions = tuple(range(1, args.n_sites + 1))
|
|
1054
|
+
space = DesignSpace(positions, {p: alphabet[0] for p in positions}, alphabet=alphabet)
|
|
1055
|
+
out = run_spectrum(
|
|
1056
|
+
space, alphas=args.alphas, seeds=list(range(args.seeds)),
|
|
1057
|
+
n_init=args.n_init, batch_size=args.batch_size, n_rounds=args.n_rounds,
|
|
1058
|
+
)
|
|
1059
|
+
payload = stamp(out)
|
|
1060
|
+
outdir = Path(args.outdir)
|
|
1061
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
1062
|
+
(outdir / "spectrum.json").write_text(json.dumps(payload, indent=2))
|
|
1063
|
+
print(f"[bench] prereg {payload['prereg_hash'][:12]} | sites={args.n_sites} q={args.q} seeds={args.seeds}")
|
|
1064
|
+
_print_spectrum(out)
|
|
1065
|
+
print(f"[bench] wrote {outdir / 'spectrum.json'}")
|
|
1066
|
+
return 0
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
1070
|
+
path = Path(args.path)
|
|
1071
|
+
if not path.exists():
|
|
1072
|
+
print(f"[report] not found: {path}", file=sys.stderr)
|
|
1073
|
+
return 1
|
|
1074
|
+
data = json.loads(path.read_text())
|
|
1075
|
+
if "per_alpha" in data:
|
|
1076
|
+
_print_spectrum(data)
|
|
1077
|
+
elif "global_ranking" in data:
|
|
1078
|
+
print(f"[report] {len(data['global_ranking'])} global candidates (prereg {str(data.get('prereg_hash'))[:12]})")
|
|
1079
|
+
for i, c in enumerate(data["global_ranking"][:20], start=1):
|
|
1080
|
+
print(f" {i}. {c['variant']} acq={c['acq']:.3f} risk={c['risk']}")
|
|
1081
|
+
else:
|
|
1082
|
+
print("[report] unrecognised JSON (expected a bench spectrum or a recommend report)")
|
|
1083
|
+
return 1
|
|
1084
|
+
return 0
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _print_spectrum(out: dict) -> None:
|
|
1088
|
+
for alpha, row in out["per_alpha"].items():
|
|
1089
|
+
mr = row["mean_regret"]
|
|
1090
|
+
print(f" alpha={alpha}: " + " ".join(f"{k}={v:.4f}" for k, v in mr.items()))
|
|
1091
|
+
for name, comp in row["ours_vs"].items():
|
|
1092
|
+
sig = "sig" if comp.get("fdr_significant") else "-"
|
|
1093
|
+
print(f" ours vs {name}: dReg={comp['regret_reduction_mean']:+.4f} "
|
|
1094
|
+
f"p={comp['p']:.3f} [{sig}]")
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _read_singles(path: str, mutation_col: str | None, score_col: str | None):
|
|
1098
|
+
"""Read a single-mutant scan CSV -> [(mutation_token, score)], auto-detecting the columns.
|
|
1099
|
+
|
|
1100
|
+
Mutation column: named variant/mutation/mutant, else the first column whose values look like
|
|
1101
|
+
``V100F``. Score column: prefer a measured column (y_actual/fitness/activity/measured) with
|
|
1102
|
+
numeric values, else a predicted one (score/y_pred/prediction/pred). Returns (rows, mut_col, score_col).
|
|
1103
|
+
"""
|
|
1104
|
+
from shellde.hotspots import parse_mutation
|
|
1105
|
+
|
|
1106
|
+
rows, fields = _read_table(path) # CSV/TSV or Excel
|
|
1107
|
+
low = {f.lower(): f for f in fields}
|
|
1108
|
+
|
|
1109
|
+
if mutation_col is None:
|
|
1110
|
+
for cand in ("variant", "mutation", "mutant", "mut"):
|
|
1111
|
+
if cand in low:
|
|
1112
|
+
mutation_col = low[cand]
|
|
1113
|
+
break
|
|
1114
|
+
else:
|
|
1115
|
+
for f in fields:
|
|
1116
|
+
v = next((r[f] for r in rows if r.get(f)), "")
|
|
1117
|
+
if parse_mutation(str(v)):
|
|
1118
|
+
mutation_col = f
|
|
1119
|
+
break
|
|
1120
|
+
if mutation_col is None:
|
|
1121
|
+
raise ValueError("could not find a mutation column (e.g. 'variant' with tokens like V100F)")
|
|
1122
|
+
|
|
1123
|
+
def _numeric_count(f: str) -> int:
|
|
1124
|
+
c = 0
|
|
1125
|
+
for r in rows:
|
|
1126
|
+
v = r.get(f, "")
|
|
1127
|
+
if v in ("", None):
|
|
1128
|
+
continue
|
|
1129
|
+
try:
|
|
1130
|
+
float(v)
|
|
1131
|
+
c += 1
|
|
1132
|
+
except (TypeError, ValueError):
|
|
1133
|
+
return -1
|
|
1134
|
+
return c
|
|
1135
|
+
|
|
1136
|
+
if score_col is None:
|
|
1137
|
+
for cand in ("y_actual", "fitness", "activity", "measured", "score", "y_pred", "prediction", "pred"):
|
|
1138
|
+
if cand in low and _numeric_count(low[cand]) > 0:
|
|
1139
|
+
score_col = low[cand]
|
|
1140
|
+
break
|
|
1141
|
+
if score_col is None:
|
|
1142
|
+
raise ValueError("could not find a numeric score column (e.g. fitness/activity/y_pred)")
|
|
1143
|
+
|
|
1144
|
+
out = [(r[mutation_col], r[score_col]) for r in rows
|
|
1145
|
+
if r.get(mutation_col) and r.get(score_col) not in ("", None)]
|
|
1146
|
+
return out, mutation_col, score_col
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def cmd_hotspots(args: argparse.Namespace) -> int:
|
|
1150
|
+
"""Scan->focus bridge: rank hotspot positions from a whole-protein single-mutant scan."""
|
|
1151
|
+
singles, mcol, scol = _read_singles(args.scan, args.mutation_col, args.score_col)
|
|
1152
|
+
hs = rank_hotspots(singles, top_k=args.top_k, metric=args.metric,
|
|
1153
|
+
baseline=args.baseline, min_effect=args.min_effect)
|
|
1154
|
+
if not hs:
|
|
1155
|
+
print("[hotspots] no positions ranked -- check the CSV (a mutation column like V100F "
|
|
1156
|
+
"+ a numeric score column)")
|
|
1157
|
+
return 1
|
|
1158
|
+
print(f"[hotspots] scan {args.scan}: mutation='{mcol}', score='{scol}', metric={args.metric}, "
|
|
1159
|
+
f"top {len(hs)} of the scanned positions")
|
|
1160
|
+
for h in hs:
|
|
1161
|
+
print(f" {h.wt}{h.position} best {h.best_sub} ({h.best_effect:.4g}) n_beneficial={h.n_beneficial}")
|
|
1162
|
+
positions = ",".join(str(h.position) for h in sorted(hs, key=lambda x: x.position))
|
|
1163
|
+
print(f"[hotspots] positions: {positions}")
|
|
1164
|
+
print(f"[hotspots] -> next: shellde round WT.fasta --positions {positions} --auto-msa "
|
|
1165
|
+
"(combinatorial AL over the hotspots: the candidate pool is multi-mutant, "
|
|
1166
|
+
"the fitted model is additive by default)")
|
|
1167
|
+
outdir = Path(args.outdir)
|
|
1168
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
1169
|
+
(outdir / "hotspots.json").write_text(json.dumps({
|
|
1170
|
+
"scan": args.scan, "metric": args.metric, "positions": [h.position for h in hs],
|
|
1171
|
+
"hotspots": [{"position": h.position, "wt": h.wt, "best_sub": h.best_sub,
|
|
1172
|
+
"best_effect": h.best_effect, "n_beneficial": h.n_beneficial} for h in hs],
|
|
1173
|
+
}, indent=2))
|
|
1174
|
+
print(f"[hotspots] wrote {outdir / 'hotspots.json'}")
|
|
1175
|
+
return 0
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _add_plm_args(p: argparse.ArgumentParser) -> None:
|
|
1179
|
+
"""PLM/naturalness options for the Rk surrogate rerank (EVOLVEpro-style). Shared by campaign/round."""
|
|
1180
|
+
p.add_argument("--plm", default="none", choices=["none", "esmc", "esm2"],
|
|
1181
|
+
help="PLM embedding surrogate for the Rk plate (EVOLVEpro-style: refit on YOUR measured "
|
|
1182
|
+
"data over ESM embeddings, rerank the plate). GPU-recommended; opt-in")
|
|
1183
|
+
p.add_argument("--plm-model", dest="plm_model", default="", help="specific PLM model, e.g. esmc_600m")
|
|
1184
|
+
p.add_argument("--plm-pooling", dest="plm_pooling", default="auto", choices=["auto", "mean", "site"],
|
|
1185
|
+
help="embedding pooling; auto -> site for esmc_600m, else mean")
|
|
1186
|
+
p.add_argument("--plm-cache", dest="plm_cache", default=None,
|
|
1187
|
+
help="embedding cache path (reuse across rounds so ESM runs once per sequence)")
|
|
1188
|
+
p.add_argument("--plm-rerank", dest="plm_rerank", type=int, default=256,
|
|
1189
|
+
help="rerank the top-K one-hot candidates with the PLM surrogate (default 256)")
|
|
1190
|
+
p.add_argument("--naturalness", default="none", choices=["none", "esmc", "esm2"],
|
|
1191
|
+
help="zero-shot naturalness block for the Rk rerank")
|
|
1192
|
+
p.add_argument("--naturalness-cache", dest="naturalness_cache", default=None, help="naturalness cache path")
|
|
1193
|
+
|
|
1194
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1195
|
+
parser = argparse.ArgumentParser(
|
|
1196
|
+
prog="shellde",
|
|
1197
|
+
description="Low-N, acquisition-driven mutation recommendation (calibrated-additive default; "
|
|
1198
|
+
"pairwise epistasis is an opt-in on `recommend` only, CV-gated under "
|
|
1199
|
+
"--auto-gate-pairwise, while --contacts-pdb opens it with no gate)",
|
|
1200
|
+
)
|
|
1201
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1202
|
+
|
|
1203
|
+
rc = sub.add_parser("recommend", help="rank mutation candidates from a measured CSV")
|
|
1204
|
+
rc.add_argument("measured", help="CSV with variant,fitness columns")
|
|
1205
|
+
rc.add_argument("--outdir", default="outputs/recommend")
|
|
1206
|
+
rc.add_argument("--max-mut", dest="max_mut", type=int, default=3)
|
|
1207
|
+
rc.add_argument("--max-positions", dest="max_positions", type=int, default=None)
|
|
1208
|
+
rc.add_argument("--interaction-aware-positions", dest="interaction_aware", action="store_true",
|
|
1209
|
+
help="rank positions by interaction-aware importance (for epistatic targets)")
|
|
1210
|
+
rc.add_argument("--top", type=int, default=20)
|
|
1211
|
+
rc.add_argument("--per-count-k", dest="per_count_k", type=int, default=10)
|
|
1212
|
+
rc.add_argument("--model-class", dest="model_class", default="ensemble",
|
|
1213
|
+
choices=[*_SURROGATE],
|
|
1214
|
+
help="surrogate class (default ensemble = nested-CV best at low N); ridge/rf/ranking/"
|
|
1215
|
+
"global_epistasis are expert overrides for known regimes (the default needs no "
|
|
1216
|
+
"manual choice). CV auto-selection was pruned (not shown to beat the fixed default).")
|
|
1217
|
+
rc.add_argument("--beta", type=float, default=1.0, help="UCB exploration weight")
|
|
1218
|
+
rc.add_argument("--generated-cap", dest="generated_cap", type=int, default=200000)
|
|
1219
|
+
rc.add_argument("--scored-cap", dest="scored_cap", type=int, default=20000)
|
|
1220
|
+
rc.add_argument("--plm", default="none", choices=["none", "esmc", "esm2"],
|
|
1221
|
+
help="PLM front end for stage-2 re-rank (ESM-C is the N=95 lever; needs the SDK)")
|
|
1222
|
+
rc.add_argument("--plm-model", dest="plm_model", default="",
|
|
1223
|
+
help="PLM model id (e.g. esmc_300m, facebook/esm2_t30_150M_UR50D)")
|
|
1224
|
+
rc.add_argument("--plm-pooling", dest="plm_pooling", default="auto",
|
|
1225
|
+
choices=["auto", "mean", "site"],
|
|
1226
|
+
help="PLM embedding pooling: 'mean' (whole-seq), 'site' (design-position "
|
|
1227
|
+
"residues), or 'auto' (site for esmc_600m, else mean)")
|
|
1228
|
+
rc.add_argument("--wt-fasta", dest="wt_fasta", default=None,
|
|
1229
|
+
help="full WT background FASTA (required with --plm; design positions splice into it)")
|
|
1230
|
+
rc.add_argument("--plm-cache", dest="plm_cache", default=None,
|
|
1231
|
+
help="path to a .npz embedding cache (persisted across runs/rounds)")
|
|
1232
|
+
rc.add_argument("--plm-rerank", dest="plm_rerank", type=int, default=256,
|
|
1233
|
+
help="shortlist size the PLM surrogate re-scores (keep small: CPU PLM is slow)")
|
|
1234
|
+
rc.add_argument("--design-positions", dest="design_positions", default=None,
|
|
1235
|
+
help="comma-separated absolute 1-based residue positions (default 1..len); needed when variants are a non-contiguous subset")
|
|
1236
|
+
rc.add_argument("--naturalness", default="none", choices=["none", "esmc", "esm2"],
|
|
1237
|
+
help="zero-shot naturalness warm-start feature in stage-2 rerank (needs --wt-fasta + SDK)")
|
|
1238
|
+
rc.add_argument("--naturalness-cache", dest="naturalness_cache", default=None,
|
|
1239
|
+
help="path to a .npz naturalness-score cache")
|
|
1240
|
+
rc.add_argument("--auto-gate-pairwise", dest="auto_gate_pairwise", action="store_true",
|
|
1241
|
+
help="open the explicit-pairwise epistasis block only if k-fold CV shows it helps (data-gate)")
|
|
1242
|
+
rc.add_argument("--contacts-pdb", dest="contacts_pdb", default=None,
|
|
1243
|
+
help="PDB structure; restrict the pairwise epistasis block to Cbeta contacts "
|
|
1244
|
+
"among design positions (absolute residue numbers). NOTE: this OPENS the "
|
|
1245
|
+
"pairwise block unconditionally (no CV gate) unless --auto-gate-pairwise "
|
|
1246
|
+
"is also given")
|
|
1247
|
+
rc.add_argument("--contact-cutoff", dest="contact_cutoff", type=float, default=8.0,
|
|
1248
|
+
help="Cbeta-Cbeta contact distance cutoff in Angstrom (default 8.0)")
|
|
1249
|
+
rc.add_argument("--if-logprobs", dest="if_logprobs", default=None,
|
|
1250
|
+
help="JSON {position: {aa: logprob}} inverse-folding table (e.g. from ProteinMPNN); adds an IF score feature")
|
|
1251
|
+
rc.add_argument("--no-auto-signals", dest="auto_signals", action="store_false",
|
|
1252
|
+
help="expert override: include provided optional signals (--if-logprobs) UNCONDITIONALLY "
|
|
1253
|
+
"instead of CV-gating them. Default: the tool held-out-CV-tests each supplied signal "
|
|
1254
|
+
"and opens it only if it beats the additive baseline on this protein's data (abstains at low N)")
|
|
1255
|
+
rc.set_defaults(auto_signals=True)
|
|
1256
|
+
rc.add_argument("--recombine", action="store_true",
|
|
1257
|
+
help="add recombinations of MEASURED beneficials (variants that beat WT) to the "
|
|
1258
|
+
"candidate pool (Arnold-style; the strategy the real-data evidence supports)")
|
|
1259
|
+
rc.add_argument("--recombine-min-gain", dest="recombine_min_gain", type=float, default=0.0,
|
|
1260
|
+
help="only recombine mutations from variants whose fitness exceeds WT by at least this")
|
|
1261
|
+
rc.set_defaults(func=cmd_recommend)
|
|
1262
|
+
|
|
1263
|
+
ad = sub.add_parser("advise", help="readiness check on measured data: learned enough? open epistasis? proceed?")
|
|
1264
|
+
ad.add_argument("measured", help="CSV with variant,fitness columns (current accumulated data)")
|
|
1265
|
+
ad.add_argument("--design-positions", dest="design_positions", default=None,
|
|
1266
|
+
help="comma-separated absolute 1-based residue positions (default 1..len)")
|
|
1267
|
+
ad.set_defaults(func=cmd_advise)
|
|
1268
|
+
|
|
1269
|
+
fl = sub.add_parser(
|
|
1270
|
+
"funclib",
|
|
1271
|
+
help="FuncLib-style active-site library: tolerated+stable combinable multipoint mutants "
|
|
1272
|
+
"to MEASURE directly (design-space construction, NOT activity predictions)",
|
|
1273
|
+
)
|
|
1274
|
+
fl.add_argument("wt_fasta", help="full WT background FASTA (defines sequence + reference residues)")
|
|
1275
|
+
fl.add_argument("--outdir", default="outputs/funclib")
|
|
1276
|
+
fl.add_argument("--holo", default=None,
|
|
1277
|
+
help="HOLO PDB with a ligand pose; design region = active-site shell(s) by contact topology")
|
|
1278
|
+
fl.add_argument("--ligand-resnames", dest="ligand_resnames", default=None,
|
|
1279
|
+
help="comma-separated HETATM resnames to treat as the ligand (else auto; additives blocklisted)")
|
|
1280
|
+
fl.add_argument("--shell", default="2", choices=["1", "2", "both"],
|
|
1281
|
+
help="active-site shell to design over (default 2 = tunable ring; 1 = catalytic core, usually lethal to saturate)")
|
|
1282
|
+
fl.add_argument("--contact-cutoff", dest="contact_cutoff", type=float, default=4.5,
|
|
1283
|
+
help="ligand/residue heavy-atom contact cutoff in Angstrom for shells (default 4.5)")
|
|
1284
|
+
fl.add_argument("--positions", default=None,
|
|
1285
|
+
help="explicit comma-separated absolute 1-based positions (overrides --holo)")
|
|
1286
|
+
fl.add_argument("--ddg", default=None,
|
|
1287
|
+
help="ddG table (JSON {position:{aa:ddG}} or CSV/TSV with position+aa+ddG or mutation token); stability gate")
|
|
1288
|
+
fl.add_argument("--tolerance", default=None,
|
|
1289
|
+
help="JSON tolerance: {token: score} (MSA PSSM / PLM zero-shot) or {position: {aa: logp}} (auto-detected)")
|
|
1290
|
+
fl.add_argument("--if-logprobs", dest="if_logprobs", default=None,
|
|
1291
|
+
help="JSON inverse-folding {position: {aa: logp}} used as the tolerance signal (log-odds vs WT)")
|
|
1292
|
+
fl.add_argument("--msa", default=None,
|
|
1293
|
+
help="a3m MSA file; Henikoff-weighted PSSM log-odds as the tolerance signal "
|
|
1294
|
+
"(evolutionary conservation; GPU-free alternative to PLM, comparable to "
|
|
1295
|
+
"ESM across 217 ProteinGym DMS, not a proven improvement)")
|
|
1296
|
+
fl.add_argument("--ddg-cutoff", dest="ddg_cutoff", type=float, default=2.5,
|
|
1297
|
+
help="keep mutations with folding ddG <= this (kcal/mol; default 2.5)")
|
|
1298
|
+
fl.add_argument("--tolerance-cutoff", dest="tolerance_cutoff", type=float, default=0.0,
|
|
1299
|
+
help="keep mutations with tolerance score >= this (default 0.0)")
|
|
1300
|
+
fl.add_argument("--per-position-cap", dest="per_position_cap", type=int, default=4,
|
|
1301
|
+
help="keep at most this many tolerated+stable AAs per position (default 4)")
|
|
1302
|
+
fl.add_argument("--min-mut", dest="min_mut", type=int, default=1)
|
|
1303
|
+
fl.add_argument("--max-mut", dest="max_mut", type=int, default=4,
|
|
1304
|
+
help="max mutated positions per combo (FuncLib designs carry ~3-6; default 4)")
|
|
1305
|
+
fl.add_argument("--additive-ddg-budget", dest="additive_ddg_budget", type=float, default=None,
|
|
1306
|
+
help="drop combos whose summed single-mutation ddG exceeds this (additive destabilization proxy)")
|
|
1307
|
+
fl.add_argument("--max-library", dest="max_library", type=int, default=2000,
|
|
1308
|
+
help="keep at most this many combos in the final library (default 2000)")
|
|
1309
|
+
fl.add_argument("--allow-unscored", dest="allow_unscored", action="store_true",
|
|
1310
|
+
help="treat mutations missing from the ddG table as passing the stability gate (ddG=0)")
|
|
1311
|
+
fl.set_defaults(func=cmd_funclib)
|
|
1312
|
+
|
|
1313
|
+
cp = sub.add_parser(
|
|
1314
|
+
"campaign",
|
|
1315
|
+
help="funclib R0 seed -> active-learning rounds (the validated handoff); "
|
|
1316
|
+
"--simulate to reproduce/validate against a measured landscape",
|
|
1317
|
+
)
|
|
1318
|
+
cp.add_argument("wt_fasta", help="full WT background FASTA (defines sequence + reference residues)")
|
|
1319
|
+
cp.add_argument("--outdir", default="outputs/campaign")
|
|
1320
|
+
cp.add_argument("--simulate", default=None,
|
|
1321
|
+
help="variant,fitness landscape CSV: reproduce the funclib->AL handoff against it "
|
|
1322
|
+
"(design --positions must match the landscape's per-position combo encoding)")
|
|
1323
|
+
cp.add_argument("--measured", nargs="*", default=None,
|
|
1324
|
+
help="live mode: accumulated variant,fitness CSV(s). None -> emit the funclib R0 seed plate")
|
|
1325
|
+
cp.add_argument("--strategies", nargs="+", default=["funclib", "random"],
|
|
1326
|
+
choices=["funclib", "singles", "random"],
|
|
1327
|
+
help="simulate arms to compare (default funclib vs random)")
|
|
1328
|
+
cp.add_argument("--plate", type=int, default=95, help="variants measured per round (default 95)")
|
|
1329
|
+
cp.add_argument("--rounds", type=int, default=3, help="active-learning rounds after the seed (default 3)")
|
|
1330
|
+
cp.add_argument("--seeds", type=int, default=20, help="RNG seeds per strategy in --simulate (default 20)")
|
|
1331
|
+
cp.add_argument("--jobs", type=int, default=1,
|
|
1332
|
+
help="parallel processes for --simulate seeds (default 1 = serial; >1 = byte-identical, "
|
|
1333
|
+
"for batch evidence/bench runs). Threads do not help (GIL-bound loop body)")
|
|
1334
|
+
cp.add_argument("--beta", type=float, default=0.0,
|
|
1335
|
+
help="acquisition: 0 = greedy exploitation (D25 default), >0 = UCB exploration weight")
|
|
1336
|
+
cp.add_argument("--model-class", dest="model_class", default="ensemble", choices=[*_SURROGATE],
|
|
1337
|
+
help="surrogate class for the AL rounds (default ensemble)")
|
|
1338
|
+
# design region + seed signals (shared with funclib)
|
|
1339
|
+
cp.add_argument("--positions", default=None,
|
|
1340
|
+
help="explicit comma-separated 1-based positions (required for --simulate to match the landscape)")
|
|
1341
|
+
cp.add_argument("--holo", default=None, help="HOLO PDB with a ligand pose; design region = active-site shell(s)")
|
|
1342
|
+
cp.add_argument("--ligand-resnames", dest="ligand_resnames", default=None,
|
|
1343
|
+
help="comma-separated HETATM resnames for the ligand (else auto)")
|
|
1344
|
+
cp.add_argument("--shell", default="2", choices=["1", "2", "both"], help="active-site shell to design over")
|
|
1345
|
+
cp.add_argument("--contact-cutoff", dest="contact_cutoff", type=float, default=4.5,
|
|
1346
|
+
help="ligand/residue heavy-atom contact cutoff for shells (Angstrom)")
|
|
1347
|
+
cp.add_argument("--ddg", default=None, help="ddG table (stability gate for the funclib seed)")
|
|
1348
|
+
cp.add_argument("--tolerance", default=None, help="JSON tolerance ({token:score} or {pos:{aa:logp}})")
|
|
1349
|
+
cp.add_argument("--if-logprobs", dest="if_logprobs", default=None,
|
|
1350
|
+
help="JSON inverse-folding {position:{aa:logp}} (tolerance signal + AL IF feature). "
|
|
1351
|
+
"NOTE: on `campaign` the AL IF feature is added UNCONDITIONALLY, with no CV "
|
|
1352
|
+
"gate -- unlike `recommend`, which held-out-CV-tests it first")
|
|
1353
|
+
cp.add_argument("--msa", default=None, help="a3m MSA file; PSSM log-odds tolerance signal for the seed")
|
|
1354
|
+
cp.add_argument("--auto-msa", dest="auto_msa", action="store_true",
|
|
1355
|
+
help="if --msa is not given, build the seed MSA from the WT sequence via the "
|
|
1356
|
+
"ColabFold MMseqs2 API (GPU-free; network). Abstains gracefully if unreachable")
|
|
1357
|
+
cp.add_argument("--ddg-cutoff", dest="ddg_cutoff", type=float, default=2.5)
|
|
1358
|
+
cp.add_argument("--tolerance-cutoff", dest="tolerance_cutoff", type=float, default=0.0)
|
|
1359
|
+
cp.add_argument("--per-position-cap", dest="per_position_cap", type=int, default=4)
|
|
1360
|
+
cp.add_argument("--min-mut", dest="min_mut", type=int, default=1)
|
|
1361
|
+
cp.add_argument("--max-mut", dest="max_mut", type=int, default=4)
|
|
1362
|
+
cp.add_argument("--additive-ddg-budget", dest="additive_ddg_budget", type=float, default=None)
|
|
1363
|
+
cp.add_argument("--allow-unscored", dest="allow_unscored", action="store_true")
|
|
1364
|
+
cp.add_argument("--stability-constraint", dest="stability_constraint", action="store_true",
|
|
1365
|
+
help="simulate: restrict the AL universe to variants passing the --ddg stability gate "
|
|
1366
|
+
"throughout (predicted-stability constraint + measured-activity objective = the "
|
|
1367
|
+
"honest multi-objective decomposition); reports if the activity winner survives")
|
|
1368
|
+
cp.add_argument("--generated-cap", dest="generated_cap", type=int, default=200000)
|
|
1369
|
+
cp.add_argument("--scored-cap", dest="scored_cap", type=int, default=20000)
|
|
1370
|
+
cp.add_argument("--wt-fitness", dest="wt_fitness", type=float, default=None,
|
|
1371
|
+
help="known (normalised) WT activity, e.g. 1.0 under WT-normalisation. WT is not "
|
|
1372
|
+
"on the plate; this anchors the 'beats WT' baseline exactly instead of "
|
|
1373
|
+
"predicting it. Injected as the WT combo in the measured set")
|
|
1374
|
+
_add_plm_args(cp)
|
|
1375
|
+
cp.set_defaults(func=cmd_campaign)
|
|
1376
|
+
|
|
1377
|
+
rs = sub.add_parser(
|
|
1378
|
+
"resolve-site",
|
|
1379
|
+
help="resolve active-site/binding positions from UniProt experimental annotation "
|
|
1380
|
+
"(alignment-mapped to your WT numbering); abstains when no curated evidence maps",
|
|
1381
|
+
)
|
|
1382
|
+
rs.add_argument("wt_fasta", help="WT FASTA whose numbering the resolved positions are mapped to")
|
|
1383
|
+
rs.add_argument("--uniprot", default=None,
|
|
1384
|
+
help="UniProt accession, e.g. Q50L36 (most reliable); or 'auto' to find the "
|
|
1385
|
+
"accession by EXACT sequence match against UniProt (abstains if no exact match)")
|
|
1386
|
+
rs.add_argument("--query", default=None,
|
|
1387
|
+
help="UniProt text search, e.g. 'isoprene synthase Populus' (top hit used)")
|
|
1388
|
+
rs.add_argument("--min-coverage", dest="min_coverage", type=float, default=0.6,
|
|
1389
|
+
help="abstain if WT aligns to less than this fraction of the UniProt sequence (wrong entry guard)")
|
|
1390
|
+
rs.add_argument("--timeout", type=float, default=20.0, help="network timeout seconds")
|
|
1391
|
+
rs.add_argument("--pdb", default=None,
|
|
1392
|
+
help="force the structure path: an RCSB PDB ID or a LOCAL structure file "
|
|
1393
|
+
"(cocrystal, or an AlphaFold model with a docked ligand)")
|
|
1394
|
+
rs.add_argument("--find-holo", dest="find_holo", action="store_true",
|
|
1395
|
+
help="Foldseek-search for a ligand-bound (holo) structural homolog and list candidate "
|
|
1396
|
+
"PDB ids (with their ligands) to feed back via --pdb; fills the apo/no-ligand gap")
|
|
1397
|
+
rs.add_argument("--exclude-catalytic", dest="exclude_catalytic", action="store_true",
|
|
1398
|
+
help="when BOTH --pdb and --uniprot are given: design over the PDB shell MINUS the "
|
|
1399
|
+
"UniProt catalytic core (the tunable 2nd-shell ring), instead of flagging it")
|
|
1400
|
+
rs.add_argument("--no-pdb-fallback", dest="no_pdb_fallback", action="store_true",
|
|
1401
|
+
help="do not fall back to UniProt-linked PDB cocrystals when features do not map")
|
|
1402
|
+
rs.add_argument("--max-pdb", dest="max_pdb", type=int, default=5,
|
|
1403
|
+
help="max UniProt-linked PDB cocrystals to try in the fallback")
|
|
1404
|
+
rs.add_argument("--no-alphafold", dest="no_alphafold", action="store_true",
|
|
1405
|
+
help="do not fetch an AlphaFold model when no experimental evidence is available")
|
|
1406
|
+
rs.add_argument("--apo-pocket", dest="apo_pocket", action="store_true",
|
|
1407
|
+
help="LOW-confidence: run fpocket on the fetched AlphaFold (apo) model to guess pocket "
|
|
1408
|
+
"positions. Can miss the catalytic site (it did on ispS); verify. Needs fpocket.")
|
|
1409
|
+
rs.add_argument("--fpocket", default="fpocket", help="fpocket binary path (for --apo-pocket)")
|
|
1410
|
+
rs.add_argument("--ligand-resnames", dest="ligand_resnames", default=None,
|
|
1411
|
+
help="comma-separated HETATM ligand resnames for shell detection (else auto)")
|
|
1412
|
+
rs.add_argument("--contact-cutoff", dest="contact_cutoff", type=float, default=4.5,
|
|
1413
|
+
help="ligand/residue heavy-atom contact cutoff (Angstrom) for PDB shells")
|
|
1414
|
+
rs.add_argument("--outdir", default="outputs/resolve_site")
|
|
1415
|
+
rs.set_defaults(func=cmd_resolve_site)
|
|
1416
|
+
|
|
1417
|
+
rd = sub.add_parser(
|
|
1418
|
+
"round",
|
|
1419
|
+
help="one command per round: auto-resolve active-site positions then emit the next plate "
|
|
1420
|
+
"(no --measured -> R0 funclib seed; --measured -> Rk AL plate). The wet-lab front door.",
|
|
1421
|
+
)
|
|
1422
|
+
rd.add_argument("wt_fasta", help="full WT background FASTA")
|
|
1423
|
+
rd.add_argument("--measured", nargs="*", default=None,
|
|
1424
|
+
help="accumulated variant,fitness CSV(s); omit for the R0 seed plate, add each "
|
|
1425
|
+
"round's measured results to advance (R1, R2, ...)")
|
|
1426
|
+
rd.add_argument("--outdir", default="outputs/round")
|
|
1427
|
+
# design region: pick one (the auto ladder is the easy path)
|
|
1428
|
+
rd.add_argument("--uniprot", default=None,
|
|
1429
|
+
help="UniProt accession (e.g. Q50L36) -> auto active-site positions; or 'auto' "
|
|
1430
|
+
"to match the accession from the WT sequence exactly (abstains if none)")
|
|
1431
|
+
rd.add_argument("--query", default=None, help="UniProt text search (top hit); auto active-site positions")
|
|
1432
|
+
rd.add_argument("--pdb", default=None, help="RCSB PDB ID or local holo structure; active-site shell (aligned to WT)")
|
|
1433
|
+
rd.add_argument("--find-holo", dest="find_holo", action="store_true",
|
|
1434
|
+
help="no cocrystal? Foldseek-find a ligand-bound (holo) homolog of the WT (via its "
|
|
1435
|
+
"AlphaFold model or --pdb) and use it as the design region automatically")
|
|
1436
|
+
rd.add_argument("--exclude-catalytic", dest="exclude_catalytic", action="store_true",
|
|
1437
|
+
help="with both --pdb and --uniprot: design the tunable ring (PDB shell minus the "
|
|
1438
|
+
"UniProt catalytic core) instead of the full shell")
|
|
1439
|
+
rd.add_argument("--holo", default=None, help="local HOLO PDB with a ligand pose; active-site shell by contact")
|
|
1440
|
+
rd.add_argument("--positions", default=None, help="explicit comma-separated 1-based positions (skips auto-resolve)")
|
|
1441
|
+
# seed signals (optional but recommended for a strong R0)
|
|
1442
|
+
rd.add_argument("--msa", default=None, help="a3m MSA; PSSM tolerance signal for the funclib seed (GPU-free)")
|
|
1443
|
+
rd.add_argument("--auto-msa", dest="auto_msa", action="store_true",
|
|
1444
|
+
help="build the R0 seed MSA from the WT sequence via ColabFold MMseqs2 when no "
|
|
1445
|
+
"--msa is given (GPU-free; network) -- lets R0 run from just WT + region")
|
|
1446
|
+
rd.add_argument("--ddg", default=None, help="folding ddG table; stability gate for the seed")
|
|
1447
|
+
rd.add_argument("--if-logprobs", dest="if_logprobs", default=None,
|
|
1448
|
+
help="inverse-folding {pos:{aa:logp}}; tolerance signal for the seed + AL IF feature. "
|
|
1449
|
+
"NOTE: `round` runs the campaign round, which adds the AL IF feature "
|
|
1450
|
+
"UNCONDITIONALLY, with no CV gate -- unlike `recommend`")
|
|
1451
|
+
rd.add_argument("--tolerance", default=None, help="explicit tolerance JSON (alternative to --msa/--if-logprobs)")
|
|
1452
|
+
rd.add_argument("--ligand-resnames", dest="ligand_resnames", default=None,
|
|
1453
|
+
help="HETATM ligand resnames for shell detection (else auto)")
|
|
1454
|
+
# plate + model knobs (validated defaults; usually leave alone)
|
|
1455
|
+
rd.add_argument("--plate", type=int, default=95, help="variants per plate (default 95)")
|
|
1456
|
+
rd.add_argument("--max-mut", dest="max_mut", type=int, default=4, help="max mutated positions per combo")
|
|
1457
|
+
rd.add_argument("--beta", type=float, default=0.0, help="acquisition: 0 greedy (default), >0 UCB weight")
|
|
1458
|
+
rd.add_argument("--model-class", dest="model_class", default="ensemble", choices=[*_SURROGATE],
|
|
1459
|
+
help="AL surrogate class (default ensemble)")
|
|
1460
|
+
rd.add_argument("--min-coverage", dest="min_coverage", type=float, default=0.6,
|
|
1461
|
+
help="abstain if WT aligns to less than this fraction of the reference (wrong-entry guard)")
|
|
1462
|
+
rd.add_argument("--timeout", type=float, default=20.0, help="network timeout seconds (auto-resolve)")
|
|
1463
|
+
rd.add_argument("--wt-fitness", dest="wt_fitness", type=float, default=None,
|
|
1464
|
+
help="known (normalised) WT activity (e.g. 1.0) to anchor the 'beats WT' baseline "
|
|
1465
|
+
"exactly; WT stays off the plate")
|
|
1466
|
+
_add_plm_args(rd)
|
|
1467
|
+
rd.set_defaults(
|
|
1468
|
+
func=cmd_round,
|
|
1469
|
+
simulate=None, strategies=["funclib", "random"], stability_constraint=False,
|
|
1470
|
+
seeds=20, jobs=1, rounds=3, generated_cap=200000, scored_cap=20000,
|
|
1471
|
+
ddg_cutoff=2.5, tolerance_cutoff=0.0, per_position_cap=4, min_mut=1,
|
|
1472
|
+
additive_ddg_budget=None, allow_unscored=False, shell="2", contact_cutoff=4.5,
|
|
1473
|
+
max_pdb=5, no_pdb_fallback=False, no_alphafold=False, apo_pocket=False, fpocket="fpocket",
|
|
1474
|
+
exclude_catalytic=False,
|
|
1475
|
+
)
|
|
1476
|
+
b = sub.add_parser("bench", help="synthetic alpha-spectrum tool-comparative benchmark")
|
|
1477
|
+
b.add_argument("--outdir", default="outputs/bench")
|
|
1478
|
+
b.add_argument("--n-sites", dest="n_sites", type=int, default=5)
|
|
1479
|
+
b.add_argument("--q", type=int, default=4, help="alphabet size (first q amino acids)")
|
|
1480
|
+
b.add_argument("--alphas", type=float, nargs="+", default=[0.0, 0.5, 1.0, 2.0])
|
|
1481
|
+
b.add_argument("--seeds", type=int, default=20)
|
|
1482
|
+
b.add_argument("--n-init", dest="n_init", type=int, default=95)
|
|
1483
|
+
b.add_argument("--batch-size", dest="batch_size", type=int, default=95)
|
|
1484
|
+
b.add_argument("--n-rounds", dest="n_rounds", type=int, default=3)
|
|
1485
|
+
b.set_defaults(func=cmd_bench)
|
|
1486
|
+
|
|
1487
|
+
r = sub.add_parser("report", help="summarise a bench spectrum or recommend report JSON")
|
|
1488
|
+
r.add_argument("path", help="path to spectrum.json or report.json")
|
|
1489
|
+
r.set_defaults(func=cmd_report)
|
|
1490
|
+
hp = sub.add_parser(
|
|
1491
|
+
"hotspots",
|
|
1492
|
+
help="whole-protein single-mutant SCAN -> top hotspot positions for combinatorial AL "
|
|
1493
|
+
"(the scan->focus bridge; feed the positions to `round`/`campaign`)",
|
|
1494
|
+
)
|
|
1495
|
+
hp.add_argument("scan", help="single-mutant CSV: a mutation column (e.g. V100F) + a score column "
|
|
1496
|
+
"(measured fitness/activity, or a predicted y_pred/score)")
|
|
1497
|
+
hp.add_argument("--top-k", dest="top_k", type=int, default=6, help="number of hotspot positions to return")
|
|
1498
|
+
hp.add_argument("--metric", default="max", choices=["max", "mean", "count"],
|
|
1499
|
+
help="rank positions by best single ('max', default), average ('mean'), or count of "
|
|
1500
|
+
"beneficials above --baseline ('count')")
|
|
1501
|
+
hp.add_argument("--baseline", type=float, default=0.0, help="score above which a single counts as beneficial")
|
|
1502
|
+
hp.add_argument("--min-effect", dest="min_effect", type=float, default=None,
|
|
1503
|
+
help="drop positions whose best single scores below this")
|
|
1504
|
+
hp.add_argument("--mutation-col", dest="mutation_col", default=None, help="mutation column name (else auto)")
|
|
1505
|
+
hp.add_argument("--score-col", dest="score_col", default=None, help="score column name (else auto)")
|
|
1506
|
+
hp.add_argument("--outdir", default="outputs/hotspots")
|
|
1507
|
+
hp.set_defaults(func=cmd_hotspots)
|
|
1508
|
+
return parser
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1512
|
+
args = build_parser().parse_args(argv)
|
|
1513
|
+
return int(args.func(args))
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
if __name__ == "__main__":
|
|
1517
|
+
sys.exit(main())
|