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/campaign.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""funclib -> active-learning campaign orchestrator (the validated D18->D19 handoff).
|
|
2
|
+
|
|
3
|
+
This wires the two halves the tool already ships into one flow:
|
|
4
|
+
|
|
5
|
+
R0 (cold start): ``funclib.design_library`` proposes the active-site measurement library
|
|
6
|
+
-- the seed plate -- using only zero-shot / structure signals (no fitness yet). This is
|
|
7
|
+
where prediction is irreplaceable: with zero measurements the AL surrogate is blind.
|
|
8
|
+
|
|
9
|
+
R1.. (engine): measurement-driven active learning takes over. Each round re-fits a fresh
|
|
10
|
+
surrogate on ALL accumulated measurements and proposes the next plate over the FULL
|
|
11
|
+
saturation universe of the design positions -- NOT hard-restricted to the funclib
|
|
12
|
+
library (D19: the non-natural winner is frequently outside the seed; restricting the
|
|
13
|
+
candidate space caps you below it). funclib only SEEDS; AL explores past it.
|
|
14
|
+
|
|
15
|
+
``simulate_campaign`` runs this against a lookup oracle (a measured landscape CSV) so the
|
|
16
|
+
handoff can be validated/reproduced (D19/D25). The live R0 plate generation lives in the CLI
|
|
17
|
+
(``shellde campaign`` without ``--simulate``).
|
|
18
|
+
|
|
19
|
+
Honest scope: this does not predict winners. It increases the probability/efficiency of
|
|
20
|
+
*finding* a better variant per experimental round, conditional on the winner staying in the
|
|
21
|
+
saturation universe and the active-site framing being right (see D19/D25/D27).
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import statistics
|
|
26
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from shellde.acquisition import Greedy, UCB
|
|
32
|
+
from shellde.design_space import DesignSpace
|
|
33
|
+
from shellde.features.defaults import default_blocks
|
|
34
|
+
from shellde.features.matrix import FeatureMatrix
|
|
35
|
+
from shellde.funclib import FuncLibLibrary, design_library
|
|
36
|
+
from shellde.loop import run_campaign
|
|
37
|
+
from shellde.oracle import DMSLookupOracle
|
|
38
|
+
from shellde.protocols import Surrogate
|
|
39
|
+
from shellde.surrogate import (
|
|
40
|
+
EnsembleSurrogate,
|
|
41
|
+
GlobalEpistasisSurrogate,
|
|
42
|
+
RankingSurrogate,
|
|
43
|
+
RFSurrogate,
|
|
44
|
+
RidgeSurrogate,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_SURROGATE_CLASSES = {
|
|
48
|
+
"ridge": RidgeSurrogate, "ensemble": EnsembleSurrogate, "rf": RFSurrogate,
|
|
49
|
+
"ranking": RankingSurrogate, "global_epistasis": GlobalEpistasisSurrogate,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class SurrogateSpec:
|
|
55
|
+
"""Picklable surrogate factory (a class+kwargs, unlike a lambda) so simulate_campaign can run
|
|
56
|
+
across processes. Callable like the lambdas the serial path also accepts."""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
random_state: int = 0
|
|
60
|
+
|
|
61
|
+
def __call__(self) -> Surrogate:
|
|
62
|
+
return _SURROGATE_CLASSES[self.name](random_state=self.random_state)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def funclib_seed_variants(
|
|
66
|
+
space: DesignSpace,
|
|
67
|
+
*,
|
|
68
|
+
plate: int,
|
|
69
|
+
ddg: Mapping[int, Mapping[str, float]] | None = None,
|
|
70
|
+
tolerance: Mapping[str, float] | None = None,
|
|
71
|
+
**design_kwargs: object,
|
|
72
|
+
) -> tuple[list[str], FuncLibLibrary]:
|
|
73
|
+
"""Build the funclib R0 seed plate: top-``plate`` combinable library variants.
|
|
74
|
+
|
|
75
|
+
Returns (combo strings sorted by funclib combo score, the FuncLibLibrary for provenance).
|
|
76
|
+
On abstain (no signal / everything filtered) the variant list is empty.
|
|
77
|
+
"""
|
|
78
|
+
lib = design_library(space, ddg=ddg, tolerance=tolerance, **design_kwargs) # type: ignore[arg-type]
|
|
79
|
+
variants = [c.variant for c in lib.candidates[:plate]]
|
|
80
|
+
return variants, lib
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def single_mutant_variants(space: DesignSpace) -> list[str]:
|
|
84
|
+
"""All single-substitution combo strings over the design positions (EVOLVEpro-style seed)."""
|
|
85
|
+
wt = space.wt()
|
|
86
|
+
out: list[str] = []
|
|
87
|
+
for i in range(space.n_positions):
|
|
88
|
+
for aa in space.alphabet:
|
|
89
|
+
if aa == wt[i]:
|
|
90
|
+
continue
|
|
91
|
+
out.append(wt[:i] + aa + wt[i + 1 :])
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def stable_variants(
|
|
96
|
+
variants: Sequence[str],
|
|
97
|
+
space: DesignSpace,
|
|
98
|
+
ddg: Mapping[int, Mapping[str, float]],
|
|
99
|
+
*,
|
|
100
|
+
ddg_cutoff: float = 2.5,
|
|
101
|
+
additive_budget: float | None = None,
|
|
102
|
+
allow_unscored: bool = False,
|
|
103
|
+
) -> list[str]:
|
|
104
|
+
"""Keep combo variants whose every mutation passes the folding-ddG stability gate.
|
|
105
|
+
|
|
106
|
+
A mutation at position p->aa passes iff ``ddg[p][aa] <= ddg_cutoff``; WT residues contribute 0.
|
|
107
|
+
If ``additive_budget`` is set, the summed single-mutation ddG must also be <= the budget
|
|
108
|
+
(the validated additive-destabilization proxy, D16). This makes predicted stability a PERSISTENT
|
|
109
|
+
constraint across the whole campaign -- the honest multi-objective decomposition for the
|
|
110
|
+
asymmetric-data regime (D14/D15: stability is predictable -> constraint; activity needs
|
|
111
|
+
measurement -> AL objective), not a two-measured-objective Pareto.
|
|
112
|
+
"""
|
|
113
|
+
wt = space.wt()
|
|
114
|
+
out: list[str] = []
|
|
115
|
+
for v in variants:
|
|
116
|
+
total = 0.0
|
|
117
|
+
ok = True
|
|
118
|
+
for i, pos in enumerate(space.positions):
|
|
119
|
+
aa = v[i]
|
|
120
|
+
if aa == wt[i]:
|
|
121
|
+
continue
|
|
122
|
+
d = ddg.get(pos, {}).get(aa)
|
|
123
|
+
if d is None:
|
|
124
|
+
if allow_unscored:
|
|
125
|
+
continue
|
|
126
|
+
ok = False
|
|
127
|
+
break
|
|
128
|
+
if d > ddg_cutoff:
|
|
129
|
+
ok = False
|
|
130
|
+
break
|
|
131
|
+
total += d
|
|
132
|
+
if ok and (additive_budget is None or total <= additive_budget):
|
|
133
|
+
out.append(v)
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class SimulationResult:
|
|
139
|
+
"""Aggregate of a multi-seed simulated campaign against a lookup oracle."""
|
|
140
|
+
|
|
141
|
+
strategy: str
|
|
142
|
+
n_seeds: int
|
|
143
|
+
winner_reach_rate: float
|
|
144
|
+
rounds_to_winner_median: float | None
|
|
145
|
+
best_found_mean: float
|
|
146
|
+
best_found_percentile_mean: float
|
|
147
|
+
per_seed: list[dict]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _percentile_of(value: float, sorted_fitness: np.ndarray) -> float:
|
|
151
|
+
"""Fraction of landscape variants with fitness <= ``value`` (empirical percentile)."""
|
|
152
|
+
return float(np.searchsorted(sorted_fitness, value, side="right")) / float(len(sorted_fitness))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_WORKER_STATE: dict = {}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _seed_result(
|
|
159
|
+
s: int, oracle: DMSLookupOracle, space: DesignSpace, seed_variants, make_surrogate,
|
|
160
|
+
blocks, beta: float, plate: int, rounds: int,
|
|
161
|
+
) -> tuple[list[float], str, float]:
|
|
162
|
+
"""One independent seeded campaign -> (trajectory, best_variant, best_fitness). Deterministic in s."""
|
|
163
|
+
acquisition = Greedy() if beta <= 0.0 else UCB(beta)
|
|
164
|
+
res = run_campaign(
|
|
165
|
+
oracle, space,
|
|
166
|
+
make_matrix=lambda: FeatureMatrix(space, blocks(space)),
|
|
167
|
+
make_surrogate=make_surrogate, acquisition=acquisition,
|
|
168
|
+
n_init=plate, batch_size=plate, n_rounds=rounds, seed=s,
|
|
169
|
+
init_variants=list(seed_variants) if seed_variants is not None else None,
|
|
170
|
+
)
|
|
171
|
+
return [float(x) for x in res.trajectory], res.best_variant, float(res.best_fitness)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _init_worker(table, space, seed_variants, make_surrogate, blocks, beta, plate, rounds) -> None:
|
|
175
|
+
_WORKER_STATE.clear()
|
|
176
|
+
_WORKER_STATE.update(
|
|
177
|
+
oracle=DMSLookupOracle(table), space=space, seed_variants=seed_variants,
|
|
178
|
+
make_surrogate=make_surrogate, blocks=blocks, beta=beta, plate=plate, rounds=rounds,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _seed_worker(s: int) -> tuple[list[float], str, float]:
|
|
183
|
+
w = _WORKER_STATE
|
|
184
|
+
return _seed_result(s, w["oracle"], w["space"], w["seed_variants"], w["make_surrogate"],
|
|
185
|
+
w["blocks"], w["beta"], w["plate"], w["rounds"])
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def simulate_campaign(
|
|
189
|
+
table: Mapping[str, float],
|
|
190
|
+
space: DesignSpace,
|
|
191
|
+
*,
|
|
192
|
+
seed_variants: Sequence[str] | None,
|
|
193
|
+
strategy: str,
|
|
194
|
+
plate: int = 95,
|
|
195
|
+
rounds: int = 3,
|
|
196
|
+
n_seeds: int = 20,
|
|
197
|
+
beta: float = 0.0,
|
|
198
|
+
make_surrogate: Callable[[], Surrogate],
|
|
199
|
+
make_blocks: Callable[[DesignSpace], list] | None = None,
|
|
200
|
+
winner_tol: float = 1e-9,
|
|
201
|
+
n_jobs: int = 1,
|
|
202
|
+
) -> SimulationResult:
|
|
203
|
+
"""Run the funclib->AL handoff against a measured landscape (oracle = lookup table).
|
|
204
|
+
|
|
205
|
+
``seed_variants`` is the R0 seed (funclib library or singles); ``None`` = random R0.
|
|
206
|
+
The AL pool is the full landscape universe each round, so the loop explores past the seed.
|
|
207
|
+
``beta``=0 is greedy exploitation (D25: greedy was not beaten by UCB on a good seed); >0 is UCB.
|
|
208
|
+
"""
|
|
209
|
+
blocks = make_blocks or default_blocks
|
|
210
|
+
# Each seed is an independent, deterministic campaign (run_campaign(seed=s)). Serial by default;
|
|
211
|
+
# n_jobs>1 runs them across processes (NOT threads: the loop body is Python/GIL-bound). The
|
|
212
|
+
# aggregate is collected in seed order and percentiles/winner are computed centrally, so a
|
|
213
|
+
# parallel run is byte-identical to serial. Parallel needs picklable make_surrogate/make_blocks
|
|
214
|
+
# (use SurrogateSpec / the default blocks), not lambdas.
|
|
215
|
+
if n_jobs and n_jobs > 1 and n_seeds > 1:
|
|
216
|
+
import multiprocessing as _mp
|
|
217
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
218
|
+
# spawn (not fork): the parent may hold BLAS threads, and fork()-with-threads can deadlock.
|
|
219
|
+
with ProcessPoolExecutor(
|
|
220
|
+
max_workers=min(n_jobs, n_seeds),
|
|
221
|
+
mp_context=_mp.get_context("spawn"),
|
|
222
|
+
initializer=_init_worker,
|
|
223
|
+
initargs=(dict(table), space, seed_variants, make_surrogate, blocks, beta, plate, rounds),
|
|
224
|
+
) as ex:
|
|
225
|
+
raw = list(ex.map(_seed_worker, range(n_seeds)))
|
|
226
|
+
else:
|
|
227
|
+
oracle = DMSLookupOracle(table)
|
|
228
|
+
raw = [_seed_result(s, oracle, space, seed_variants, make_surrogate, blocks, beta, plate, rounds)
|
|
229
|
+
for s in range(n_seeds)]
|
|
230
|
+
|
|
231
|
+
fitness = np.asarray(list(table.values()), dtype=float)
|
|
232
|
+
sorted_fitness = np.sort(fitness)
|
|
233
|
+
global_max = float(fitness.max())
|
|
234
|
+
per_seed: list[dict] = []
|
|
235
|
+
for s, (traj, best_variant, best_fitness) in enumerate(raw):
|
|
236
|
+
# trajectory[0] is best-after-seed (R0); trajectory[k] is best after AL round k.
|
|
237
|
+
reached = next((i for i, b in enumerate(traj) if b >= global_max - winner_tol), None)
|
|
238
|
+
per_seed.append({
|
|
239
|
+
"rng_seed": s,
|
|
240
|
+
"best_found": best_fitness,
|
|
241
|
+
"best_variant": best_variant,
|
|
242
|
+
"best_percentile": _percentile_of(best_fitness, sorted_fitness),
|
|
243
|
+
"winner_reached": reached is not None,
|
|
244
|
+
"rounds_to_winner": reached,
|
|
245
|
+
"trajectory": traj,
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
reaches = [p["rounds_to_winner"] for p in per_seed if p["winner_reached"]]
|
|
249
|
+
return SimulationResult(
|
|
250
|
+
strategy=strategy,
|
|
251
|
+
n_seeds=n_seeds,
|
|
252
|
+
winner_reach_rate=sum(1 for p in per_seed if p["winner_reached"]) / n_seeds,
|
|
253
|
+
rounds_to_winner_median=(statistics.median(reaches) if reaches else None),
|
|
254
|
+
best_found_mean=float(np.mean([p["best_found"] for p in per_seed])),
|
|
255
|
+
best_found_percentile_mean=float(np.mean([p["best_percentile"] for p in per_seed])),
|
|
256
|
+
per_seed=per_seed,
|
|
257
|
+
)
|
shellde/candidates.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Candidate generation: pick positions, then lazily enumerate up-to-N combos.
|
|
2
|
+
|
|
3
|
+
Two public entry points:
|
|
4
|
+
- ``select_positions``: narrow a large design space to the top-m positions, by
|
|
5
|
+
additive main-effect spread (cheap) or an interaction-aware Sobol total-effect
|
|
6
|
+
estimate (for epistatic targets; additive importance misses interaction-only
|
|
7
|
+
positions).
|
|
8
|
+
- ``generate_candidates``: lazily enumerate high-value up-to-``max_mut`` mutation
|
|
9
|
+
combinations under hard caps (never iterates the Cartesian product), add a random
|
|
10
|
+
exploration quota so non-additive optima are reachable, then prefilter to a
|
|
11
|
+
scorable pool with the calibrated surrogate. Returns a ``CandidatePool`` carrying
|
|
12
|
+
the surrogate Prediction so downstream ranking does not re-predict.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import heapq
|
|
17
|
+
import itertools
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from shellde.design_space import DesignSpace
|
|
25
|
+
from shellde.features.matrix import FeatureMatrix
|
|
26
|
+
from shellde.protocols import Surrogate
|
|
27
|
+
from shellde.types import Prediction
|
|
28
|
+
|
|
29
|
+
GENERATED_CAP = 200_000
|
|
30
|
+
SCORED_CAP = 20_000
|
|
31
|
+
DEFAULT_M = 15
|
|
32
|
+
DEFAULT_MAX_MUT = 5
|
|
33
|
+
DEFAULT_EXPLORE_FRAC = 0.2
|
|
34
|
+
DEFAULT_BETA = 1.0
|
|
35
|
+
DEFAULT_TIME_BUDGET_S = 60.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class CandidatePool:
|
|
40
|
+
"""Prefiltered candidate variants + the surrogate prediction on them."""
|
|
41
|
+
|
|
42
|
+
variants: list[str]
|
|
43
|
+
pred: Prediction
|
|
44
|
+
mut_count: list[int]
|
|
45
|
+
pool_positions: list[int]
|
|
46
|
+
stats: dict = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --------------------------------------------------------------------------- #
|
|
50
|
+
# Position selection
|
|
51
|
+
# --------------------------------------------------------------------------- #
|
|
52
|
+
def _additive_scores(surrogate: Surrogate, matrix: FeatureMatrix) -> np.ndarray:
|
|
53
|
+
"""Per-(position, AA) additive main-effect, shape (n_positions, q); zeros if no onehot."""
|
|
54
|
+
space = matrix.space
|
|
55
|
+
npos, q = space.n_positions, space.q
|
|
56
|
+
coef = surrogate.main_effects()
|
|
57
|
+
oh = next((se for name, *se in matrix.column_layout() if name == "onehot"), None)
|
|
58
|
+
if coef is None or oh is None:
|
|
59
|
+
return np.zeros((npos, q), dtype=float)
|
|
60
|
+
start, end = oh
|
|
61
|
+
block = np.asarray(coef, dtype=float)[start:end]
|
|
62
|
+
if block.size != npos * q:
|
|
63
|
+
return np.zeros((npos, q), dtype=float)
|
|
64
|
+
return block.reshape(npos, q)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _interaction_importance(
|
|
68
|
+
surrogate: Surrogate, matrix: FeatureMatrix, *, n_backgrounds: int = 64, seed: int = 0
|
|
69
|
+
) -> np.ndarray:
|
|
70
|
+
"""Sobol-style total-effect importance per position via the full surrogate.
|
|
71
|
+
|
|
72
|
+
Vary each position over the alphabet on RANDOM backgrounds and average the
|
|
73
|
+
prediction variance across residues; random backgrounds make the estimate
|
|
74
|
+
include interaction effects (a position important only through epistasis still
|
|
75
|
+
scores high), unlike the additive main-effect spread.
|
|
76
|
+
"""
|
|
77
|
+
space = matrix.space
|
|
78
|
+
npos, q = space.n_positions, space.q
|
|
79
|
+
alphabet = space.alphabet
|
|
80
|
+
rng = np.random.default_rng(seed)
|
|
81
|
+
nb = min(n_backgrounds, max(1, q**npos))
|
|
82
|
+
bg = rng.integers(0, q, size=(nb, npos))
|
|
83
|
+
imp = np.zeros(npos, dtype=float)
|
|
84
|
+
for j in range(npos):
|
|
85
|
+
preds = np.zeros((nb, q), dtype=float)
|
|
86
|
+
for ai in range(q):
|
|
87
|
+
variants = []
|
|
88
|
+
for b in range(nb):
|
|
89
|
+
chars = [alphabet[int(bg[b, k])] for k in range(npos)]
|
|
90
|
+
chars[j] = alphabet[ai]
|
|
91
|
+
variants.append("".join(chars))
|
|
92
|
+
preds[:, ai] = np.asarray(surrogate.predict(matrix.encode(variants)).mean, dtype=float)
|
|
93
|
+
imp[j] = float(np.mean(preds.var(axis=1)))
|
|
94
|
+
return imp
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def select_positions(
|
|
98
|
+
surrogate: Surrogate,
|
|
99
|
+
matrix: FeatureMatrix,
|
|
100
|
+
*,
|
|
101
|
+
m: int,
|
|
102
|
+
interaction_aware: bool = False,
|
|
103
|
+
seed: int = 0,
|
|
104
|
+
) -> list[int]:
|
|
105
|
+
"""Top-m designed positions by importance.
|
|
106
|
+
|
|
107
|
+
Default importance is the additive main-effect spread (cheap, but MISSES a
|
|
108
|
+
position that matters only through epistasis). ``interaction_aware=True`` ranks by
|
|
109
|
+
a Sobol total-effect estimate over the full surrogate. ``m >= n_positions`` keeps
|
|
110
|
+
all positions (the safe choice for small combinatorial design spaces).
|
|
111
|
+
"""
|
|
112
|
+
space = matrix.space
|
|
113
|
+
if interaction_aware:
|
|
114
|
+
importance = _interaction_importance(surrogate, matrix, seed=seed)
|
|
115
|
+
else:
|
|
116
|
+
importance = _additive_scores(surrogate, matrix).var(axis=1)
|
|
117
|
+
order = np.argsort(importance)[::-1][: max(1, min(m, space.n_positions))]
|
|
118
|
+
return sorted(space.positions[int(j)] for j in order)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --------------------------------------------------------------------------- #
|
|
122
|
+
# Combinatorial generation
|
|
123
|
+
# --------------------------------------------------------------------------- #
|
|
124
|
+
def _assignment_to_combo(mut: tuple[tuple[int, str], ...], space: DesignSpace) -> str:
|
|
125
|
+
chosen = dict(space.reference)
|
|
126
|
+
for pos, aa in mut:
|
|
127
|
+
chosen[pos] = aa
|
|
128
|
+
return "".join(chosen[p] for p in space.positions)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _mut_count(variant: str, space: DesignSpace) -> int:
|
|
132
|
+
ref = space.wt()
|
|
133
|
+
return sum(1 for a, b in zip(variant, ref, strict=True) if a != b)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _generate_additive(
|
|
137
|
+
add: np.ndarray, space: DesignSpace, pool: list[int], *, max_mut: int, gen_cap: int, deadline: float
|
|
138
|
+
) -> set[str]:
|
|
139
|
+
"""Lazy best-first enumeration of high-gain <= max_mut combos (no Cartesian sweep)."""
|
|
140
|
+
aa_to_int = {a: i for i, a in enumerate(space.alphabet)}
|
|
141
|
+
subs: dict[int, list[tuple[str, float]]] = {}
|
|
142
|
+
for p in pool:
|
|
143
|
+
j = space.position_index(p)
|
|
144
|
+
wt_score = add[j, aa_to_int[space.reference[p]]]
|
|
145
|
+
subs[p] = sorted(
|
|
146
|
+
((a, float(add[j, aa_to_int[a]] - wt_score)) for a in space.alphabet if a != space.reference[p]),
|
|
147
|
+
key=lambda kv: kv[1],
|
|
148
|
+
reverse=True,
|
|
149
|
+
)
|
|
150
|
+
gain_of = {p: dict(subs[p]) for p in pool}
|
|
151
|
+
out: set[str] = set()
|
|
152
|
+
visited: set[tuple[tuple[int, str], ...]] = {()}
|
|
153
|
+
visit_cap = max(4 * gen_cap, 1000)
|
|
154
|
+
heap: list[tuple[float, tuple[tuple[int, str], ...]]] = [(0.0, ())]
|
|
155
|
+
while heap and len(out) < gen_cap:
|
|
156
|
+
if time.perf_counter() > deadline:
|
|
157
|
+
break
|
|
158
|
+
neg, mut = heapq.heappop(heap)
|
|
159
|
+
out.add(_assignment_to_combo(mut, space))
|
|
160
|
+
used = {p for p, _ in mut}
|
|
161
|
+
if len(mut) < max_mut and len(visited) < visit_cap:
|
|
162
|
+
for p in pool:
|
|
163
|
+
if p in used or not subs[p]:
|
|
164
|
+
continue
|
|
165
|
+
a, g = subs[p][0]
|
|
166
|
+
nm = tuple(sorted((*mut, (p, a))))
|
|
167
|
+
if nm not in visited:
|
|
168
|
+
visited.add(nm)
|
|
169
|
+
heapq.heappush(heap, (neg - g, nm))
|
|
170
|
+
if len(visited) < visit_cap:
|
|
171
|
+
for i, (p, a) in enumerate(mut):
|
|
172
|
+
ranked = subs[p]
|
|
173
|
+
k = next((idx for idx, (aa, _) in enumerate(ranked) if aa == a), -1)
|
|
174
|
+
if 0 <= k and k + 1 < len(ranked):
|
|
175
|
+
na, gna = ranked[k + 1]
|
|
176
|
+
nm = tuple(sorted((*mut[:i], (p, na), *mut[i + 1 :])))
|
|
177
|
+
if nm not in visited:
|
|
178
|
+
visited.add(nm)
|
|
179
|
+
heapq.heappush(heap, (neg + gain_of[p][a] - gna, nm))
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _explore_sample(
|
|
184
|
+
space: DesignSpace, pool: list[int], *, max_mut: int, n: int, rng: np.random.Generator, deadline: float
|
|
185
|
+
) -> set[str]:
|
|
186
|
+
"""Random non-additive <= max_mut combos over the pool (exploration quota)."""
|
|
187
|
+
out: set[str] = set()
|
|
188
|
+
subs = {p: [a for a in space.alphabet if a != space.reference[p]] for p in pool}
|
|
189
|
+
attempts = 0
|
|
190
|
+
while len(out) < n and attempts < 20 * n + 50:
|
|
191
|
+
if time.perf_counter() > deadline:
|
|
192
|
+
break
|
|
193
|
+
attempts += 1
|
|
194
|
+
k = min(int(rng.integers(1, max_mut + 1)), len(pool))
|
|
195
|
+
chosen_pos = rng.choice(len(pool), size=k, replace=False)
|
|
196
|
+
mut = tuple(
|
|
197
|
+
sorted((pool[int(i)], subs[pool[int(i)]][int(rng.integers(0, len(subs[pool[int(i)]])))]) for i in chosen_pos)
|
|
198
|
+
)
|
|
199
|
+
out.add(_assignment_to_combo(mut, space))
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def generate_candidates(
|
|
204
|
+
surrogate: Surrogate,
|
|
205
|
+
matrix: FeatureMatrix,
|
|
206
|
+
*,
|
|
207
|
+
m: int = DEFAULT_M,
|
|
208
|
+
max_mut: int = DEFAULT_MAX_MUT,
|
|
209
|
+
pinned_positions: Sequence[int] | None = None,
|
|
210
|
+
interaction_aware: bool = False,
|
|
211
|
+
generated_cap: int = GENERATED_CAP,
|
|
212
|
+
scored_cap: int = SCORED_CAP,
|
|
213
|
+
explore_frac: float = DEFAULT_EXPLORE_FRAC,
|
|
214
|
+
beta: float = DEFAULT_BETA,
|
|
215
|
+
time_budget_s: float = DEFAULT_TIME_BUDGET_S,
|
|
216
|
+
seed: int = 0,
|
|
217
|
+
) -> CandidatePool:
|
|
218
|
+
"""Generate + prefilter up-to-``max_mut`` mutation candidates under hard caps."""
|
|
219
|
+
if max_mut < 1:
|
|
220
|
+
raise ValueError("max_mut must be >= 1")
|
|
221
|
+
if not 0.0 <= explore_frac < 1.0:
|
|
222
|
+
raise ValueError("explore_frac must be in [0, 1)")
|
|
223
|
+
space = matrix.space
|
|
224
|
+
rng = np.random.default_rng(seed)
|
|
225
|
+
t0 = time.perf_counter()
|
|
226
|
+
deadline = t0 + time_budget_s
|
|
227
|
+
|
|
228
|
+
if pinned_positions is not None:
|
|
229
|
+
pool = sorted(int(p) for p in pinned_positions)
|
|
230
|
+
bad = [p for p in pool if p not in space.positions]
|
|
231
|
+
if bad:
|
|
232
|
+
raise ValueError(f"pinned positions {bad} not in design space")
|
|
233
|
+
else:
|
|
234
|
+
pool = select_positions(surrogate, matrix, m=m, interaction_aware=interaction_aware, seed=seed)
|
|
235
|
+
|
|
236
|
+
add = _additive_scores(surrogate, matrix)
|
|
237
|
+
n_explore = int(round(generated_cap * explore_frac))
|
|
238
|
+
generated = _generate_additive(
|
|
239
|
+
add, space, pool, max_mut=max_mut, gen_cap=max(0, generated_cap - n_explore), deadline=deadline
|
|
240
|
+
)
|
|
241
|
+
generated |= _explore_sample(space, pool, max_mut=max_mut, n=n_explore, rng=rng, deadline=deadline)
|
|
242
|
+
generated.discard(space.wt()) # never let WT dominate the tier ranking
|
|
243
|
+
if len(generated) > generated_cap:
|
|
244
|
+
generated = set(sorted(generated)[:generated_cap])
|
|
245
|
+
|
|
246
|
+
variants = sorted(generated)
|
|
247
|
+
if not variants:
|
|
248
|
+
empty = Prediction(np.zeros(0), np.zeros(0))
|
|
249
|
+
return CandidatePool([], empty, [], pool, {"n_generated": 0, "n_scored": 0, "n_pool_positions": len(pool)})
|
|
250
|
+
|
|
251
|
+
# full-surrogate prefilter (mean + beta*calibrated std), batched, deadline-bounded
|
|
252
|
+
n_total = len(variants)
|
|
253
|
+
mu_parts: list[np.ndarray] = []
|
|
254
|
+
sd_parts: list[np.ndarray] = []
|
|
255
|
+
for i in range(0, n_total, 4096):
|
|
256
|
+
if time.perf_counter() > deadline:
|
|
257
|
+
break
|
|
258
|
+
chunk = variants[i : i + 4096]
|
|
259
|
+
pr = surrogate.predict(matrix.encode(chunk))
|
|
260
|
+
mu_parts.append(np.asarray(pr.mean, dtype=float))
|
|
261
|
+
sd_parts.append(np.asarray(pr.std, dtype=float))
|
|
262
|
+
mu = np.concatenate(mu_parts) if mu_parts else np.zeros(0)
|
|
263
|
+
sigma = np.concatenate(sd_parts) if sd_parts else np.zeros(0)
|
|
264
|
+
variants = variants[: mu.shape[0]]
|
|
265
|
+
acq = mu + beta * sigma
|
|
266
|
+
keep = np.argsort(acq)[::-1][:scored_cap]
|
|
267
|
+
order = keep[np.argsort(acq[keep])[::-1]]
|
|
268
|
+
sel = [variants[int(i)] for i in order]
|
|
269
|
+
return CandidatePool(
|
|
270
|
+
variants=sel,
|
|
271
|
+
pred=Prediction(mu[order], sigma[order]),
|
|
272
|
+
mut_count=[_mut_count(v, space) for v in sel],
|
|
273
|
+
pool_positions=pool,
|
|
274
|
+
stats={
|
|
275
|
+
"n_generated": n_total,
|
|
276
|
+
"n_scored": len(sel),
|
|
277
|
+
"n_pool_positions": len(pool),
|
|
278
|
+
"wall_clock_s": time.perf_counter() - t0,
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def recombine_beneficials(
|
|
284
|
+
space: DesignSpace,
|
|
285
|
+
variants: Sequence[str],
|
|
286
|
+
fitness: Sequence[float],
|
|
287
|
+
*,
|
|
288
|
+
wt_fitness: float | None = None,
|
|
289
|
+
min_gain: float = 0.0,
|
|
290
|
+
max_mut: int = 4,
|
|
291
|
+
cap: int = GENERATED_CAP,
|
|
292
|
+
) -> list[str]:
|
|
293
|
+
"""Recombine the mutations of MEASURED variants that beat WT (Arnold-style recombination).
|
|
294
|
+
|
|
295
|
+
The evidence-backed strategy (broad measurement then recombine confirmed beneficials, not
|
|
296
|
+
predictor ranking): pool every substitution that appears in a measured variant with
|
|
297
|
+
fitness > ``wt_fitness`` (+ ``min_gain``), then enumerate combinations of those beneficial
|
|
298
|
+
substitutions (one AA per position) up to ``max_mut``, ordered by observed gain, capped.
|
|
299
|
+
|
|
300
|
+
``wt_fitness`` defaults to the measured WT combo's fitness if present, else the 75th percentile
|
|
301
|
+
of the measured fitness (a "clearly-above-typical" proxy, since true WT is unmeasured). Returns
|
|
302
|
+
combo strings over ``space.positions`` (WT elsewhere), excluding WT itself.
|
|
303
|
+
"""
|
|
304
|
+
wt = space.wt()
|
|
305
|
+
fit = {str(v): float(f) for v, f in zip(variants, fitness, strict=True)}
|
|
306
|
+
if wt_fitness is None:
|
|
307
|
+
wt_fitness = fit.get(wt, float(np.quantile(np.asarray(list(fit.values()), dtype=float), 0.75)))
|
|
308
|
+
thr = wt_fitness + min_gain
|
|
309
|
+
# best observed gain per (position-index, aa) among beneficial variants
|
|
310
|
+
mut_gain: dict[tuple[int, str], float] = {}
|
|
311
|
+
for v, f in fit.items():
|
|
312
|
+
if f <= thr or len(v) != len(wt):
|
|
313
|
+
continue
|
|
314
|
+
gain = f - wt_fitness
|
|
315
|
+
for i, (a, w) in enumerate(zip(v, wt, strict=True)):
|
|
316
|
+
if a != w:
|
|
317
|
+
key = (i, a)
|
|
318
|
+
if gain > mut_gain.get(key, float("-inf")):
|
|
319
|
+
mut_gain[key] = gain
|
|
320
|
+
if not mut_gain:
|
|
321
|
+
return []
|
|
322
|
+
per_pos: dict[int, list[str]] = {}
|
|
323
|
+
for (i, a), _g in sorted(mut_gain.items(), key=lambda kv: -kv[1]):
|
|
324
|
+
per_pos.setdefault(i, []).append(a)
|
|
325
|
+
pos_order = sorted(per_pos, key=lambda i: -max(mut_gain[(i, a)] for a in per_pos[i]))
|
|
326
|
+
out: list[str] = []
|
|
327
|
+
seen: set[str] = set()
|
|
328
|
+
for k in range(1, min(max_mut, len(pos_order)) + 1):
|
|
329
|
+
for pos_combo in itertools.combinations(pos_order, k):
|
|
330
|
+
for aa_combo in itertools.product(*(per_pos[i] for i in pos_combo)):
|
|
331
|
+
chosen = list(wt)
|
|
332
|
+
for i, a in zip(pos_combo, aa_combo, strict=True):
|
|
333
|
+
chosen[i] = a
|
|
334
|
+
cs = "".join(chosen)
|
|
335
|
+
if cs == wt or cs in seen:
|
|
336
|
+
continue
|
|
337
|
+
seen.add(cs)
|
|
338
|
+
out.append(cs)
|
|
339
|
+
if len(out) >= cap:
|
|
340
|
+
return out
|
|
341
|
+
return out
|