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.
Files changed (51) hide show
  1. shellde/__init__.py +36 -0
  2. shellde/acquisition.py +135 -0
  3. shellde/advisor.py +158 -0
  4. shellde/bench/__init__.py +17 -0
  5. shellde/bench/falsification.py +95 -0
  6. shellde/bench/harness.py +134 -0
  7. shellde/bench/stats.py +46 -0
  8. shellde/campaign.py +257 -0
  9. shellde/candidates.py +341 -0
  10. shellde/cli.py +1517 -0
  11. shellde/colab.py +257 -0
  12. shellde/conformal.py +160 -0
  13. shellde/consensus.py +52 -0
  14. shellde/design_space.py +141 -0
  15. shellde/embeddings/__init__.py +6 -0
  16. shellde/embeddings/esm2.py +76 -0
  17. shellde/embeddings/esmc.py +94 -0
  18. shellde/embeddings/provider.py +105 -0
  19. shellde/features/__init__.py +22 -0
  20. shellde/features/base.py +49 -0
  21. shellde/features/defaults.py +11 -0
  22. shellde/features/embedding.py +80 -0
  23. shellde/features/inverse_folding.py +66 -0
  24. shellde/features/matrix.py +50 -0
  25. shellde/features/naturalness.py +65 -0
  26. shellde/features/onehot.py +55 -0
  27. shellde/features/pairwise.py +64 -0
  28. shellde/funclib.py +331 -0
  29. shellde/gating.py +181 -0
  30. shellde/holo.py +175 -0
  31. shellde/hotspots.py +108 -0
  32. shellde/loop.py +161 -0
  33. shellde/msa.py +186 -0
  34. shellde/naturalness.py +142 -0
  35. shellde/oracle.py +94 -0
  36. shellde/plm.py +145 -0
  37. shellde/prereg.py +41 -0
  38. shellde/protocols.py +87 -0
  39. shellde/rank.py +103 -0
  40. shellde/report.py +132 -0
  41. shellde/selector.py +63 -0
  42. shellde/sitefinder.py +465 -0
  43. shellde/structure.py +356 -0
  44. shellde/surrogate.py +323 -0
  45. shellde/types.py +66 -0
  46. shellde/zero_shot.py +160 -0
  47. shellde-0.2.0.dist-info/METADATA +285 -0
  48. shellde-0.2.0.dist-info/RECORD +51 -0
  49. shellde-0.2.0.dist-info/WHEEL +5 -0
  50. shellde-0.2.0.dist-info/entry_points.txt +2 -0
  51. 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