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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""GatedPairwiseBlock: explicit, restricted, evidence-gated pairwise epistasis.
|
|
2
|
+
|
|
3
|
+
Reference-relative pairwise indicator features over a RESTRICTED set of position
|
|
4
|
+
pairs (provide contacting / coevolving pairs, or pairs among the active positions
|
|
5
|
+
for strong heredity), so the interaction parameter count stays estimable at low N.
|
|
6
|
+
The block is GATED: when ``gate_open`` is False it presents zero columns (absent), so
|
|
7
|
+
the surrogate is purely additive until the data justifies epistasis. This is the
|
|
8
|
+
single explicit-epistasis path (no separate surrogate class).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from shellde.design_space import DesignSpace
|
|
17
|
+
from shellde.features.base import FeatureBlock
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GatedPairwiseBlock(FeatureBlock):
|
|
21
|
+
name = "pairwise"
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
space: DesignSpace,
|
|
26
|
+
pairs: Sequence[tuple[int, int]] | None = None,
|
|
27
|
+
*,
|
|
28
|
+
gate_open: bool = True,
|
|
29
|
+
) -> None:
|
|
30
|
+
if pairs is None:
|
|
31
|
+
pos = space.positions
|
|
32
|
+
pairs = [(pos[i], pos[j]) for i in range(len(pos)) for j in range(i + 1, len(pos))]
|
|
33
|
+
bad = [pp for pp in pairs if pp[0] not in space.reference or pp[1] not in space.reference]
|
|
34
|
+
if bad:
|
|
35
|
+
raise ValueError(f"pairwise pairs not in design space: {bad}")
|
|
36
|
+
self._pairs = [tuple(sorted(pp)) for pp in pairs]
|
|
37
|
+
self._gate_open = gate_open
|
|
38
|
+
self._q1 = space.q - 1 # reference-relative: drop the WT category per position
|
|
39
|
+
self._nonref_idx = {
|
|
40
|
+
p: {aa: k for k, aa in enumerate(a for a in space.alphabet if a != space.reference[p])}
|
|
41
|
+
for p in space.positions
|
|
42
|
+
}
|
|
43
|
+
self._dim = len(self._pairs) * self._q1 * self._q1
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def dim(self) -> int:
|
|
47
|
+
return self._dim
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def present(self) -> bool:
|
|
51
|
+
return self._gate_open and self._dim > 0
|
|
52
|
+
|
|
53
|
+
def _encode_present(
|
|
54
|
+
self, assignments: Sequence[Mapping[int, str]], space: DesignSpace
|
|
55
|
+
) -> np.ndarray:
|
|
56
|
+
q1 = self._q1
|
|
57
|
+
x = np.zeros((len(assignments), self._dim), dtype=float)
|
|
58
|
+
for i, a in enumerate(assignments):
|
|
59
|
+
for pidx, (pi, pj) in enumerate(self._pairs):
|
|
60
|
+
ai = self._nonref_idx[pi].get(a[pi])
|
|
61
|
+
bj = self._nonref_idx[pj].get(a[pj])
|
|
62
|
+
if ai is not None and bj is not None: # both positions are mutated
|
|
63
|
+
x[i, pidx * q1 * q1 + ai * q1 + bj] = 1.0
|
|
64
|
+
return x
|
shellde/funclib.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""FuncLib-style active-site library designer (design-space construction, NOT a predictor).
|
|
2
|
+
|
|
3
|
+
Implements the core principle of FuncLib (Khersonsky & Fleishman, Mol Cell 2018; htFuncLib,
|
|
4
|
+
J Mol Biol 2025): substantial active-site improvements demand MULTIPLE proximal mutations whose
|
|
5
|
+
epistasis on activity is unpredictable, so do NOT try to predict the winning mutation. Instead
|
|
6
|
+
constrain the combinatorial space to point mutations that are
|
|
7
|
+
|
|
8
|
+
(1) tolerated by natural diversity -- a per-substitution tolerance score >= cutoff
|
|
9
|
+
(an MSA PSSM log-odds, or a PLM zero-shot ensemble; ``zero_shot.zero_shot_ensemble``
|
|
10
|
+
returns exactly the {token: score} map this consumes), and
|
|
11
|
+
(2) not destabilizing -- a per-mutation folding ddG <= cutoff
|
|
12
|
+
(ThermoMPNN / FoldX; ``structure.load_ddg_table`` loads it),
|
|
13
|
+
|
|
14
|
+
then enumerate COMBINABLE multipoint variants for DIRECT measurement. This collapses the naive
|
|
15
|
+
q**L active-site saturation space to a small, screenable library and auto-excludes the conserved
|
|
16
|
+
catalytic core (no natural diversity -> fails the tolerance gate; saturating it is lethal).
|
|
17
|
+
|
|
18
|
+
This module predicts NOTHING about activity. Its outputs are saturation / measurement targets
|
|
19
|
+
(``role="saturation_target"``, ``predict=False``). Whether saturating them yields activity-
|
|
20
|
+
improving variants is wet-lab-gated -- the same honesty bar as ``StructureSiteProposer`` and the
|
|
21
|
+
inverse-folding block. The verifiable, by-construction value is the combinatorial reduction and
|
|
22
|
+
the lethal-core exclusion; the activity benefit is a claim only the wet lab settles.
|
|
23
|
+
|
|
24
|
+
Negative epistasis between the chosen mutations is approximated additively here (an optional cap
|
|
25
|
+
on the summed single-mutation ddG of a combo). When measured data on these positions arrives, the
|
|
26
|
+
contact-restricted ``GatedPairwiseBlock`` is the epistasis-aware upgrade; this module is the
|
|
27
|
+
data-free seeding step for the regime where the surrogate has no signal.
|
|
28
|
+
|
|
29
|
+
Dependency-light by design (numpy + stdlib): it consumes precomputed tables. Producing the ddG /
|
|
30
|
+
tolerance tables is an external one-time step (free tools; see the table loaders' docstrings).
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import itertools
|
|
35
|
+
from collections.abc import Mapping, Sequence
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
|
|
38
|
+
from shellde.design_space import DesignSpace
|
|
39
|
+
from shellde.types import Candidate
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tolerance_from_logprob_table(
|
|
43
|
+
table: Mapping[int, Mapping[str, float]],
|
|
44
|
+
space: DesignSpace,
|
|
45
|
+
*,
|
|
46
|
+
positions: Sequence[int] | None = None,
|
|
47
|
+
) -> dict[str, float]:
|
|
48
|
+
"""Convert a per-position log-prob table to per-substitution tolerance, keyed by token.
|
|
49
|
+
|
|
50
|
+
Maps {position: {aa: logp}} (an inverse-folding / ProteinMPNN table, or a PLM per-position
|
|
51
|
+
marginal) to {"<WT><pos><AA>": logp(AA) - logp(WT)}: a log-odds-vs-WT tolerance where a
|
|
52
|
+
higher value means the substitution is more natural/foldable than WT at that site. WT tokens
|
|
53
|
+
are omitted. Positions absent from ``table`` are skipped (they simply contribute no tolerated
|
|
54
|
+
mutations downstream). Use this to feed an IF/PLM table into :func:`design_library`.
|
|
55
|
+
"""
|
|
56
|
+
pos = list(space.positions if positions is None else positions)
|
|
57
|
+
out: dict[str, float] = {}
|
|
58
|
+
for p in pos:
|
|
59
|
+
col = table.get(int(p))
|
|
60
|
+
if col is None:
|
|
61
|
+
continue
|
|
62
|
+
wt = space.reference[p]
|
|
63
|
+
wt_lp = float(col.get(wt, 0.0))
|
|
64
|
+
for aa in space.alphabet:
|
|
65
|
+
if aa == wt or aa not in col:
|
|
66
|
+
continue
|
|
67
|
+
out[f"{wt}{p}{aa}"] = float(col[aa]) - wt_lp
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def combine_tolerances(
|
|
72
|
+
maps: Sequence[Mapping[str, float]], *, standardize: bool = True
|
|
73
|
+
) -> dict[str, float]:
|
|
74
|
+
"""Ensemble several per-substitution tolerance maps into one (MULTI-evolve style).
|
|
75
|
+
|
|
76
|
+
Each input is a ``{token: score}`` map (e.g. ESM-C, ESM-2 and inverse-folding zero-shot
|
|
77
|
+
signals on different scales). When ``standardize`` each map is z-scored (mean 0, unit std)
|
|
78
|
+
so signals on different scales combine fairly, then averaged across the maps that score each
|
|
79
|
+
token. A token present in any map survives, valued by the mean of its available standardised
|
|
80
|
+
scores. Empty input -> ``{}``. Per MULTI-evolve, a multi-method zero-shot ensemble surfaces
|
|
81
|
+
more enhancing mutations than any single scorer; whether it helps a given protein is an
|
|
82
|
+
empirical question (backtest before trusting it).
|
|
83
|
+
"""
|
|
84
|
+
std_maps: list[dict[str, float]] = []
|
|
85
|
+
for m in maps:
|
|
86
|
+
vals = list(m.values())
|
|
87
|
+
if standardize and len(vals) > 1:
|
|
88
|
+
mu = sum(vals) / len(vals)
|
|
89
|
+
var = sum((v - mu) ** 2 for v in vals) / len(vals)
|
|
90
|
+
sd = var ** 0.5
|
|
91
|
+
std_maps.append({k: (v - mu) / sd for k, v in m.items()} if sd > 1e-12 else {k: 0.0 for k in m})
|
|
92
|
+
else:
|
|
93
|
+
std_maps.append(dict(m))
|
|
94
|
+
acc: dict[str, list[float]] = {}
|
|
95
|
+
for sm in std_maps:
|
|
96
|
+
for k, v in sm.items():
|
|
97
|
+
acc.setdefault(k, []).append(v)
|
|
98
|
+
return {k: sum(vs) / len(vs) for k, vs in acc.items()}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class FuncLibLibrary:
|
|
103
|
+
"""A FuncLib-style design-space proposal: filtered per-position options + a combinable library.
|
|
104
|
+
|
|
105
|
+
``candidates`` are saturation/measurement targets (``evidence["role"] == "saturation_target"``,
|
|
106
|
+
``evidence["predict"] is False``), NOT ranked activity predictions. ``tolerated`` is the
|
|
107
|
+
per-position kept-mutation map (position -> list of (aa, score), score descending). ``stats``
|
|
108
|
+
carries the by-construction provenance (naive vs filtered space size, reduction factor,
|
|
109
|
+
truncation). ``abstained`` is True when no stability/tolerance signal was supplied or every
|
|
110
|
+
mutation was filtered out, with ``reason`` explaining why.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
positions: tuple[int, ...]
|
|
114
|
+
tolerated: dict[int, list[tuple[str, float]]]
|
|
115
|
+
candidates: list[Candidate]
|
|
116
|
+
abstained: bool = False
|
|
117
|
+
reason: str = ""
|
|
118
|
+
stats: dict = field(default_factory=dict)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _combo_string(assignment: Mapping[int, str], space: DesignSpace) -> str:
|
|
122
|
+
return "".join(assignment.get(p, space.reference[p]) for p in space.positions)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def design_library(
|
|
126
|
+
space: DesignSpace,
|
|
127
|
+
*,
|
|
128
|
+
positions: Sequence[int] | None = None,
|
|
129
|
+
ddg: Mapping[int, Mapping[str, float]] | None = None,
|
|
130
|
+
tolerance: Mapping[str, float] | None = None,
|
|
131
|
+
ddg_cutoff: float = 2.5,
|
|
132
|
+
tolerance_cutoff: float = 0.0,
|
|
133
|
+
per_position_cap: int = 4,
|
|
134
|
+
min_mut: int = 1,
|
|
135
|
+
max_mut: int = 4,
|
|
136
|
+
additive_ddg_budget: float | None = None,
|
|
137
|
+
generated_cap: int = 200_000,
|
|
138
|
+
max_library: int = 2000,
|
|
139
|
+
allow_unscored: bool = False,
|
|
140
|
+
) -> FuncLibLibrary:
|
|
141
|
+
"""Construct a FuncLib-style combinable multipoint library over ``positions``.
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
space:
|
|
146
|
+
The :class:`DesignSpace`. Emitted combo strings span ``space.positions``; positions not
|
|
147
|
+
in ``positions`` stay at WT.
|
|
148
|
+
positions:
|
|
149
|
+
Subset of ``space.positions`` to design over (e.g. an active-site 2nd shell from
|
|
150
|
+
:func:`structure.shells_from_pdb`). Defaults to all of ``space.positions``.
|
|
151
|
+
ddg:
|
|
152
|
+
Per-position per-AA folding ddG (kcal/mol; positive = destabilizing). The stability gate
|
|
153
|
+
keeps a mutation iff ``ddg[p][aa] <= ddg_cutoff``. ``None`` disables the gate.
|
|
154
|
+
tolerance:
|
|
155
|
+
Per-substitution tolerance keyed by token ``"<WT><pos><AA>"`` (higher = more tolerated;
|
|
156
|
+
from an MSA PSSM or :func:`zero_shot.zero_shot_ensemble`). The tolerance gate keeps a
|
|
157
|
+
mutation iff ``tolerance[token] >= tolerance_cutoff``. ``None`` disables the gate.
|
|
158
|
+
ddg_cutoff, tolerance_cutoff:
|
|
159
|
+
Gate thresholds (see above).
|
|
160
|
+
per_position_cap:
|
|
161
|
+
Keep at most this many tolerated+stable mutations per position (top by combined score).
|
|
162
|
+
min_mut, max_mut:
|
|
163
|
+
Inclusive bounds on the number of mutated positions per emitted variant (FuncLib designs
|
|
164
|
+
carry ~3-6 active-site mutations).
|
|
165
|
+
additive_ddg_budget:
|
|
166
|
+
If set and ``ddg`` is given, drop any combo whose summed single-mutation ddG exceeds this
|
|
167
|
+
(an additive negative-epistasis / total-destabilization proxy).
|
|
168
|
+
generated_cap:
|
|
169
|
+
Hard cap on enumerated combos before ranking (guards against combinatorial blow-up).
|
|
170
|
+
max_library:
|
|
171
|
+
Keep at most this many combos in the final library (top by combined score).
|
|
172
|
+
allow_unscored:
|
|
173
|
+
When ``ddg`` is given but a position/AA is missing from it, treat the mutation as passing
|
|
174
|
+
the stability gate (ddG contribution 0) instead of dropping it. Off by default (a missing
|
|
175
|
+
ddG cannot be certified stable).
|
|
176
|
+
|
|
177
|
+
Returns
|
|
178
|
+
-------
|
|
179
|
+
FuncLibLibrary
|
|
180
|
+
Abstains (empty ``candidates``, ``abstained=True``) when neither ``ddg`` nor ``tolerance``
|
|
181
|
+
is supplied (that would be unfiltered saturation, not FuncLib), or when every mutation is
|
|
182
|
+
filtered out.
|
|
183
|
+
"""
|
|
184
|
+
if min_mut < 1 or max_mut < min_mut:
|
|
185
|
+
raise ValueError("require 1 <= min_mut <= max_mut")
|
|
186
|
+
if per_position_cap < 1:
|
|
187
|
+
raise ValueError("per_position_cap must be >= 1")
|
|
188
|
+
|
|
189
|
+
pos = list(space.positions if positions is None else sorted(set(int(p) for p in positions)))
|
|
190
|
+
bad = [p for p in pos if p not in space.positions]
|
|
191
|
+
if bad:
|
|
192
|
+
raise ValueError(f"positions {bad} not in design space")
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
naive_space = float(len(space.alphabet) ** len(pos)) # exact int -> float (may overflow)
|
|
196
|
+
except OverflowError:
|
|
197
|
+
naive_space = float("inf")
|
|
198
|
+
base_stats = {
|
|
199
|
+
"positions": pos,
|
|
200
|
+
"n_positions": len(pos),
|
|
201
|
+
"naive_saturation_space": naive_space,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if ddg is None and tolerance is None:
|
|
205
|
+
return FuncLibLibrary(
|
|
206
|
+
positions=tuple(pos), tolerated={}, candidates=[], abstained=True,
|
|
207
|
+
reason="no stability (ddG) or tolerance signal supplied: this would be unfiltered "
|
|
208
|
+
"saturation, not a FuncLib-filtered library",
|
|
209
|
+
stats=base_stats,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# ---- per-position gates -------------------------------------------------------------- #
|
|
213
|
+
ddg_single: dict[tuple[int, str], float] = {}
|
|
214
|
+
tolerated: dict[int, list[tuple[str, float]]] = {}
|
|
215
|
+
for p in pos:
|
|
216
|
+
wt = space.reference[p]
|
|
217
|
+
scored: list[tuple[str, float]] = []
|
|
218
|
+
ddg_col = None if ddg is None else ddg.get(int(p))
|
|
219
|
+
for aa in space.alphabet:
|
|
220
|
+
if aa == wt:
|
|
221
|
+
continue
|
|
222
|
+
tok = f"{wt}{p}{aa}"
|
|
223
|
+
# tolerance gate
|
|
224
|
+
tol_val = None if tolerance is None else tolerance.get(tok)
|
|
225
|
+
if tolerance is not None:
|
|
226
|
+
if tol_val is None or tol_val < tolerance_cutoff:
|
|
227
|
+
continue
|
|
228
|
+
# stability gate
|
|
229
|
+
d_val: float | None = None
|
|
230
|
+
if ddg is not None:
|
|
231
|
+
d_val = None if ddg_col is None else ddg_col.get(aa)
|
|
232
|
+
if d_val is None:
|
|
233
|
+
if not allow_unscored:
|
|
234
|
+
continue
|
|
235
|
+
d_val = 0.0
|
|
236
|
+
elif d_val > ddg_cutoff:
|
|
237
|
+
continue
|
|
238
|
+
ddg_single[(p, aa)] = 0.0 if d_val is None else float(d_val)
|
|
239
|
+
score = (0.0 if tol_val is None else float(tol_val)) - (0.0 if d_val is None else float(d_val))
|
|
240
|
+
scored.append((aa, score))
|
|
241
|
+
scored.sort(key=lambda kv: kv[1], reverse=True)
|
|
242
|
+
if scored:
|
|
243
|
+
tolerated[p] = scored[:per_position_cap]
|
|
244
|
+
|
|
245
|
+
kept_positions = sorted(tolerated)
|
|
246
|
+
base_stats["kept_positions"] = kept_positions
|
|
247
|
+
base_stats["n_kept_positions"] = len(kept_positions)
|
|
248
|
+
base_stats["tolerated_per_position"] = {p: len(tolerated[p]) for p in kept_positions}
|
|
249
|
+
|
|
250
|
+
if not kept_positions:
|
|
251
|
+
return FuncLibLibrary(
|
|
252
|
+
positions=tuple(pos), tolerated={}, candidates=[], abstained=True,
|
|
253
|
+
reason="every candidate mutation failed the tolerance/stability gates "
|
|
254
|
+
"(active-site core may be too conserved or too destabilizing to mutate)",
|
|
255
|
+
stats=base_stats,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# ---- combinable enumeration (capped) ------------------------------------------------- #
|
|
259
|
+
eff_max = min(max_mut, len(kept_positions))
|
|
260
|
+
rows: list[tuple[float, dict[int, str], float]] = [] # (score, assignment, summed_ddg)
|
|
261
|
+
truncated = False
|
|
262
|
+
for k in range(min_mut, eff_max + 1):
|
|
263
|
+
if truncated:
|
|
264
|
+
break
|
|
265
|
+
for poscombo in itertools.combinations(kept_positions, k):
|
|
266
|
+
option_lists = [tolerated[p] for p in poscombo]
|
|
267
|
+
for picks in itertools.product(*option_lists):
|
|
268
|
+
summed_ddg = sum(ddg_single[(poscombo[i], aa)] for i, (aa, _) in enumerate(picks))
|
|
269
|
+
if additive_ddg_budget is not None and ddg is not None and summed_ddg > additive_ddg_budget:
|
|
270
|
+
continue
|
|
271
|
+
assignment = {poscombo[i]: aa for i, (aa, _) in enumerate(picks)}
|
|
272
|
+
combo_score = sum(s for _, s in picks)
|
|
273
|
+
rows.append((combo_score, assignment, summed_ddg))
|
|
274
|
+
if len(rows) >= generated_cap:
|
|
275
|
+
truncated = True
|
|
276
|
+
break
|
|
277
|
+
if truncated:
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
rows.sort(key=lambda r: r[0], reverse=True)
|
|
281
|
+
rows = rows[:max_library]
|
|
282
|
+
|
|
283
|
+
candidates: list[Candidate] = []
|
|
284
|
+
for combo_score, assignment, summed_ddg in rows:
|
|
285
|
+
muts = tuple(
|
|
286
|
+
f"{space.reference[p]}{p}{assignment[p]}" for p in space.positions if p in assignment
|
|
287
|
+
)
|
|
288
|
+
evidence = {
|
|
289
|
+
"role": "saturation_target",
|
|
290
|
+
"predict": False,
|
|
291
|
+
"source": "funclib",
|
|
292
|
+
"combo_score": float(combo_score),
|
|
293
|
+
}
|
|
294
|
+
if ddg is not None:
|
|
295
|
+
evidence["additive_ddg"] = float(summed_ddg)
|
|
296
|
+
candidates.append(
|
|
297
|
+
Candidate(
|
|
298
|
+
variant=_combo_string(assignment, space),
|
|
299
|
+
n_mut=len(assignment),
|
|
300
|
+
mu=0.0,
|
|
301
|
+
sigma=0.0,
|
|
302
|
+
acq=float(combo_score),
|
|
303
|
+
mutations=muts,
|
|
304
|
+
evidence=evidence,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
base_stats["generated"] = len(rows) if not truncated else generated_cap
|
|
309
|
+
base_stats["library_size"] = len(candidates)
|
|
310
|
+
base_stats["truncated"] = truncated
|
|
311
|
+
base_stats["reduction_factor"] = (
|
|
312
|
+
naive_space / len(candidates) if candidates else float("inf")
|
|
313
|
+
)
|
|
314
|
+
base_stats["gates"] = {
|
|
315
|
+
"ddg": ddg is not None,
|
|
316
|
+
"tolerance": tolerance is not None,
|
|
317
|
+
"ddg_cutoff": ddg_cutoff,
|
|
318
|
+
"tolerance_cutoff": tolerance_cutoff,
|
|
319
|
+
"per_position_cap": per_position_cap,
|
|
320
|
+
"min_mut": min_mut,
|
|
321
|
+
"max_mut": max_mut,
|
|
322
|
+
"additive_ddg_budget": additive_ddg_budget,
|
|
323
|
+
}
|
|
324
|
+
return FuncLibLibrary(
|
|
325
|
+
positions=tuple(pos),
|
|
326
|
+
tolerated=tolerated,
|
|
327
|
+
candidates=candidates,
|
|
328
|
+
abstained=not candidates,
|
|
329
|
+
reason="" if candidates else "no combos survived the additive ddG budget",
|
|
330
|
+
stats=base_stats,
|
|
331
|
+
)
|
shellde/gating.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Evidence-based data gate for the explicit-pairwise epistasis arm.
|
|
2
|
+
|
|
3
|
+
Per the tool-development synthesis (recs 2 and 6): never hardcode "this protein gets
|
|
4
|
+
the epistasis arm". Instead decide it from the data: open the gated-pairwise block only
|
|
5
|
+
when adding it measurably improves held-out prediction over a pure-additive baseline by
|
|
6
|
+
k-fold cross-validation. At N=95, q=20 the pairwise block is unestimable so this
|
|
7
|
+
correctly stays shut; on a low-q / higher-N / strongly-epistatic landscape it opens.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from sklearn.model_selection import KFold
|
|
15
|
+
|
|
16
|
+
from shellde.design_space import DesignSpace
|
|
17
|
+
from shellde.features import FeatureBlock, FeatureMatrix, GatedPairwiseBlock, OneHotBlock
|
|
18
|
+
from shellde.surrogate import RidgeSurrogate
|
|
19
|
+
|
|
20
|
+
DEFAULT_MIN_IMPROVE = 0.02
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cv_rmse(
|
|
24
|
+
space: DesignSpace, variants: Sequence[str], y: np.ndarray,
|
|
25
|
+
blocks: Sequence[FeatureBlock], *, k: int, seed: int,
|
|
26
|
+
) -> float:
|
|
27
|
+
"""k-fold held-out RMSE of a Ridge surrogate on the given feature blocks (nan if degenerate)."""
|
|
28
|
+
n = len(variants)
|
|
29
|
+
if n < 8 or float(np.std(y)) < 1e-12:
|
|
30
|
+
return float("nan")
|
|
31
|
+
x = FeatureMatrix(space, list(blocks)).encode(variants)
|
|
32
|
+
kf = KFold(n_splits=min(k, n), shuffle=True, random_state=seed)
|
|
33
|
+
sse, cnt = 0.0, 0
|
|
34
|
+
for tr, te in kf.split(x):
|
|
35
|
+
if len(tr) < 2 or float(np.std(y[tr])) < 1e-12:
|
|
36
|
+
continue
|
|
37
|
+
cnt += len(te)
|
|
38
|
+
m = RidgeSurrogate(random_state=seed).fit(x[tr], y[tr])
|
|
39
|
+
sse += float(np.sum((m.predict(x[te]).mean - y[te]) ** 2))
|
|
40
|
+
return float(np.sqrt(sse / cnt)) if cnt else float("nan")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def block_gate_evidence(
|
|
44
|
+
space: DesignSpace,
|
|
45
|
+
variants: Sequence[str],
|
|
46
|
+
fitness: np.ndarray,
|
|
47
|
+
base_blocks: Sequence[FeatureBlock],
|
|
48
|
+
candidate_block: FeatureBlock,
|
|
49
|
+
*,
|
|
50
|
+
k: int = 4,
|
|
51
|
+
seed: int = 0,
|
|
52
|
+
) -> dict:
|
|
53
|
+
"""k-fold held-out RMSE of ``base_blocks`` vs ``base_blocks + candidate_block``.
|
|
54
|
+
|
|
55
|
+
``improvement`` = (base_rmse - candidate_rmse) / base_rmse (>0 means the candidate block
|
|
56
|
+
helps out of sample). The general data-gate behind any optional feature signal.
|
|
57
|
+
"""
|
|
58
|
+
y = np.asarray(fitness, dtype=float)
|
|
59
|
+
if len(variants) != y.shape[0]:
|
|
60
|
+
raise ValueError(f"{len(variants)} variants but {y.shape[0]} fitness values")
|
|
61
|
+
base = _cv_rmse(space, variants, y, base_blocks, k=k, seed=seed)
|
|
62
|
+
aug = _cv_rmse(space, variants, y, [*base_blocks, candidate_block], k=k, seed=seed)
|
|
63
|
+
if not (np.isfinite(base) and np.isfinite(aug) and base > 0):
|
|
64
|
+
improvement = 0.0
|
|
65
|
+
else:
|
|
66
|
+
improvement = (base - aug) / base
|
|
67
|
+
return {"base_rmse": base, "candidate_rmse": aug, "improvement": improvement, "n": len(variants)}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def should_include_block(
|
|
71
|
+
space: DesignSpace,
|
|
72
|
+
variants: Sequence[str],
|
|
73
|
+
fitness: np.ndarray,
|
|
74
|
+
base_blocks: Sequence[FeatureBlock],
|
|
75
|
+
candidate_block: FeatureBlock,
|
|
76
|
+
*,
|
|
77
|
+
label: str = "signal",
|
|
78
|
+
min_improve: float = DEFAULT_MIN_IMPROVE,
|
|
79
|
+
min_n: int = 12,
|
|
80
|
+
k: int = 4,
|
|
81
|
+
seed: int = 0,
|
|
82
|
+
n_gate_seeds: int = 5,
|
|
83
|
+
min_positive_fraction: float = 0.6,
|
|
84
|
+
) -> tuple[bool, dict]:
|
|
85
|
+
"""Decide whether to open an optional feature block FROM THE PROTEIN'S OWN held-out data.
|
|
86
|
+
|
|
87
|
+
Returns (include, report). At N < ``min_n`` the gate is unreliable, so it abstains to the
|
|
88
|
+
conservative additive baseline (include=False) rather than guess. Otherwise it repeats the
|
|
89
|
+
same block-vs-baseline CV gate across several deterministic fold seeds and opens the block only
|
|
90
|
+
when the MEDIAN improvement clears ``min_improve`` AND enough split seeds show positive
|
|
91
|
+
improvement. This reduces the single-fold-split variance/overfitting that motivated pruning
|
|
92
|
+
the old ``--model-class auto`` selector.
|
|
93
|
+
"""
|
|
94
|
+
n = len(variants)
|
|
95
|
+
if n < min_n:
|
|
96
|
+
return False, {"label": label, "included": False, "n": n, "min_n": min_n, "improvement": 0.0,
|
|
97
|
+
"reason": f"N={n} < min_n={min_n}: gate unreliable, using conservative additive baseline"}
|
|
98
|
+
n_gate_seeds = max(1, int(n_gate_seeds))
|
|
99
|
+
reports = [
|
|
100
|
+
block_gate_evidence(space, variants, fitness, base_blocks, candidate_block, k=k, seed=seed + i)
|
|
101
|
+
for i in range(n_gate_seeds)
|
|
102
|
+
]
|
|
103
|
+
improvements = np.asarray([float(r["improvement"]) for r in reports], dtype=float)
|
|
104
|
+
improvement = float(np.median(improvements))
|
|
105
|
+
positive_fraction = float(np.mean(improvements > 0.0))
|
|
106
|
+
ev = {
|
|
107
|
+
"label": label,
|
|
108
|
+
"n": n,
|
|
109
|
+
"base_rmse": float(np.median([r["base_rmse"] for r in reports])),
|
|
110
|
+
"candidate_rmse": float(np.median([r["candidate_rmse"] for r in reports])),
|
|
111
|
+
"improvement": improvement,
|
|
112
|
+
"improvements": [float(x) for x in improvements],
|
|
113
|
+
"positive_fraction": positive_fraction,
|
|
114
|
+
"n_gate_seeds": n_gate_seeds,
|
|
115
|
+
"min_positive_fraction": min_positive_fraction,
|
|
116
|
+
"min_improve": min_improve,
|
|
117
|
+
}
|
|
118
|
+
ev["included"] = improvement >= min_improve and positive_fraction >= min_positive_fraction
|
|
119
|
+
ev["reason"] = (
|
|
120
|
+
f"median held-out RMSE improvement {improvement:+.3f} >= {min_improve} and "
|
|
121
|
+
f"positive split fraction {positive_fraction:.2f} >= {min_positive_fraction:.2f} "
|
|
122
|
+
f"(robust across {n_gate_seeds} CV seeds)"
|
|
123
|
+
if ev["included"] else
|
|
124
|
+
f"median held-out RMSE improvement {improvement:+.3f} / positive split fraction "
|
|
125
|
+
f"{positive_fraction:.2f} did not pass {min_improve}/{min_positive_fraction:.2f} "
|
|
126
|
+
f"(dropped; robust gate across {n_gate_seeds} CV seeds)"
|
|
127
|
+
)
|
|
128
|
+
return ev["included"], ev
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def pairwise_gate_evidence(
|
|
132
|
+
space: DesignSpace,
|
|
133
|
+
variants: Sequence[str],
|
|
134
|
+
fitness: np.ndarray,
|
|
135
|
+
*,
|
|
136
|
+
pairs: Sequence[tuple[int, int]] | None = None,
|
|
137
|
+
k: int = 4,
|
|
138
|
+
seed: int = 0,
|
|
139
|
+
) -> dict:
|
|
140
|
+
"""k-fold held-out RMSE of additive vs additive+pairwise; relative improvement.
|
|
141
|
+
|
|
142
|
+
``improvement`` = (additive_rmse - pairwise_rmse) / additive_rmse (>0 means the
|
|
143
|
+
pairwise block helps out of sample). Returns RMSEs and the improvement fraction.
|
|
144
|
+
"""
|
|
145
|
+
ev = block_gate_evidence(
|
|
146
|
+
space, variants, fitness, [OneHotBlock(space)], GatedPairwiseBlock(space, pairs), k=k, seed=seed
|
|
147
|
+
)
|
|
148
|
+
return {"additive_rmse": ev["base_rmse"], "pairwise_rmse": ev["candidate_rmse"], "improvement": ev["improvement"]}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def should_open_pairwise(
|
|
152
|
+
space: DesignSpace,
|
|
153
|
+
variants: Sequence[str],
|
|
154
|
+
fitness: np.ndarray,
|
|
155
|
+
*,
|
|
156
|
+
pairs: Sequence[tuple[int, int]] | None = None,
|
|
157
|
+
min_improve: float = DEFAULT_MIN_IMPROVE,
|
|
158
|
+
k: int = 4,
|
|
159
|
+
seed: int = 0,
|
|
160
|
+
) -> bool:
|
|
161
|
+
"""True iff the pairwise block improves held-out RMSE by at least ``min_improve``."""
|
|
162
|
+
return pairwise_gate_evidence(space, variants, fitness, pairs=pairs, k=k, seed=seed)["improvement"] >= min_improve
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def pairwise_regate(
|
|
166
|
+
space: DesignSpace, *, pairs: Sequence[tuple[int, int]] | None = None,
|
|
167
|
+
min_improve: float = DEFAULT_MIN_IMPROVE, k: int = 4, seed: int = 0
|
|
168
|
+
):
|
|
169
|
+
"""A ``run_campaign(regate=...)`` callback: open the pairwise block per round by CV.
|
|
170
|
+
|
|
171
|
+
Each round it rebuilds [OneHot (+ GatedPairwise if the accumulated data justify it)],
|
|
172
|
+
so explicit epistasis turns ON only once enough rounds have accumulated to estimate
|
|
173
|
+
it, and stays additive otherwise. Returns ``(variants, fitness) -> FeatureMatrix``.
|
|
174
|
+
"""
|
|
175
|
+
def _regate(variants: Sequence[str], fitness: np.ndarray) -> FeatureMatrix:
|
|
176
|
+
blocks: list = [OneHotBlock(space)]
|
|
177
|
+
if should_open_pairwise(space, variants, fitness, pairs=pairs, min_improve=min_improve, k=k, seed=seed):
|
|
178
|
+
blocks.append(GatedPairwiseBlock(space, pairs))
|
|
179
|
+
return FeatureMatrix(space, blocks)
|
|
180
|
+
|
|
181
|
+
return _regate
|