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/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""ShellDE: low-N, acquisition-driven mutation recommendation for directed evolution.
|
|
2
|
+
|
|
3
|
+
The name states the scope. `shell` is the active-site contact shell that `resolve-site`
|
|
4
|
+
computes, and the second-shell ring that `--exclude-catalytic` designs over. `DE` is
|
|
5
|
+
directed evolution. Casing is fixed and does not vary: the distribution, the import name
|
|
6
|
+
and the CLI command are always lowercase `shellde`, while prose and citations use
|
|
7
|
+
`ShellDE`. Never `Shellde`, `shellDE` or `SHELLDE`.
|
|
8
|
+
|
|
9
|
+
The shipped default is calibrated-additive. On `recommend`, --auto-gate-pairwise and
|
|
10
|
+
--if-logprobs are CV-gated on your own held-out data (--no-auto-signals forces the latter
|
|
11
|
+
on), while --contacts-pdb opens the contact-restricted pairwise block unconditionally.
|
|
12
|
+
`campaign` and `round` offer neither pairwise flag and add --if-logprobs UNCONDITIONALLY,
|
|
13
|
+
with no gate. --plm and --naturalness are never gated on any command."""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from importlib.metadata import PackageNotFoundError
|
|
17
|
+
from importlib.metadata import version as _pkg_version
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
__version__ = _pkg_version("shellde")
|
|
21
|
+
except PackageNotFoundError: # not installed (e.g. run from a source tree without install)
|
|
22
|
+
__version__ = "0.0.0+unknown"
|
|
23
|
+
|
|
24
|
+
from shellde.funclib import (
|
|
25
|
+
FuncLibLibrary,
|
|
26
|
+
combine_tolerances,
|
|
27
|
+
design_library,
|
|
28
|
+
tolerance_from_logprob_table,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"FuncLibLibrary",
|
|
33
|
+
"combine_tolerances",
|
|
34
|
+
"design_library",
|
|
35
|
+
"tolerance_from_logprob_table",
|
|
36
|
+
]
|
shellde/acquisition.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Acquisition layer: the experimental-design lever (the tool's organizing axis).
|
|
2
|
+
|
|
3
|
+
Score-based acquisitions (Greedy / UCB / MaxVariance) rank candidates by a scalar;
|
|
4
|
+
``select`` returns the top-k. DiverseBatch wraps a score acquisition and applies
|
|
5
|
+
KURO greedy-maximin so a batch is both high-value and spread out (no near-duplicate
|
|
6
|
+
wells). All satisfy ``protocols.Acquisition`` and compose freely:
|
|
7
|
+
|
|
8
|
+
DiverseBatch(UCB(beta=1.0)) # diverse, uncertainty-aware exploitation
|
|
9
|
+
DiverseBatch(MaxVariance()) # diverse pure exploration (resolve uncertainty)
|
|
10
|
+
Greedy() # pure exploitation (the additive-greedy baseline)
|
|
11
|
+
|
|
12
|
+
UCB / MaxVariance are only meaningful because the surrogate std is CALIBRATED (P1).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from shellde.design_space import DesignSpace, to_assignment
|
|
22
|
+
from shellde.types import Prediction
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _ScoreAcquisition(ABC):
|
|
26
|
+
"""Acquisitions that rank candidates by a per-candidate scalar score."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def score(self, pred: Prediction) -> np.ndarray:
|
|
30
|
+
"""Per-candidate acquisition value (higher = more desirable)."""
|
|
31
|
+
|
|
32
|
+
def select(
|
|
33
|
+
self,
|
|
34
|
+
pred: Prediction,
|
|
35
|
+
k: int,
|
|
36
|
+
*,
|
|
37
|
+
candidates: Sequence[str] | None = None,
|
|
38
|
+
space: DesignSpace | None = None,
|
|
39
|
+
) -> list[int]:
|
|
40
|
+
s = self.score(pred)
|
|
41
|
+
if k <= 0 or s.size == 0:
|
|
42
|
+
return []
|
|
43
|
+
k = min(k, s.size)
|
|
44
|
+
return np.argsort(s)[::-1][:k].astype(int).tolist()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Greedy(_ScoreAcquisition):
|
|
48
|
+
"""Pure exploitation: score = predicted mean (the additive-greedy lever)."""
|
|
49
|
+
|
|
50
|
+
def score(self, pred: Prediction) -> np.ndarray:
|
|
51
|
+
return np.asarray(pred.mean, dtype=float)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UCB(_ScoreAcquisition):
|
|
55
|
+
"""Upper-confidence bound: score = mean + beta * calibrated std."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, beta: float = 1.0) -> None:
|
|
58
|
+
self.beta = beta
|
|
59
|
+
|
|
60
|
+
def score(self, pred: Prediction) -> np.ndarray:
|
|
61
|
+
return np.asarray(pred.mean, dtype=float) + self.beta * np.asarray(pred.std, dtype=float)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MaxVariance(_ScoreAcquisition):
|
|
65
|
+
"""Information-targeted (BALD-style) pure exploration: score = calibrated std.
|
|
66
|
+
|
|
67
|
+
Acquires where the model is most uncertain, i.e. where a measurement most reduces
|
|
68
|
+
predictive uncertainty. With calibrated std this is a principled exploration lever
|
|
69
|
+
(and the seed of resolving epistasis once interaction uncertainty is exposed).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def score(self, pred: Prediction) -> np.ndarray:
|
|
73
|
+
return np.asarray(pred.std, dtype=float)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _combo_vectors(candidates: Sequence[str], space: DesignSpace) -> list[tuple[str, ...]]:
|
|
77
|
+
"""Per-position residue tuples for fast Hamming distance over the design space."""
|
|
78
|
+
out: list[tuple[str, ...]] = []
|
|
79
|
+
for v in candidates:
|
|
80
|
+
a = to_assignment(v, space)
|
|
81
|
+
out.append(tuple(a[p] for p in space.positions))
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _hamming(a: tuple[str, ...], b: tuple[str, ...]) -> int:
|
|
86
|
+
return sum(1 for x, y in zip(a, b, strict=True) if x != y)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DiverseBatch:
|
|
90
|
+
"""KURO greedy-maximin diversity over a top-scored pool of a base acquisition.
|
|
91
|
+
|
|
92
|
+
Ranks all candidates by the base acquisition, keeps the top ``pool_factor * k``,
|
|
93
|
+
then greedily builds a batch that maximizes the minimum design-space (Hamming)
|
|
94
|
+
distance to the already-chosen set, seeded by the single best-scoring candidate.
|
|
95
|
+
The result is a high-value batch with no near-duplicate wells.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, base: _ScoreAcquisition, pool_factor: float = 4.0) -> None:
|
|
99
|
+
self.base = base
|
|
100
|
+
self.pool_factor = pool_factor
|
|
101
|
+
|
|
102
|
+
def select(
|
|
103
|
+
self,
|
|
104
|
+
pred: Prediction,
|
|
105
|
+
k: int,
|
|
106
|
+
*,
|
|
107
|
+
candidates: Sequence[str],
|
|
108
|
+
space: DesignSpace,
|
|
109
|
+
) -> list[int]:
|
|
110
|
+
n = len(candidates)
|
|
111
|
+
if n == 0 or k <= 0:
|
|
112
|
+
return []
|
|
113
|
+
k = min(k, n)
|
|
114
|
+
scores = self.base.score(pred)
|
|
115
|
+
order = np.argsort(scores)[::-1]
|
|
116
|
+
pool_size = min(n, max(k, int(self.pool_factor * k)))
|
|
117
|
+
pool: list[int] = [int(j) for j in order[:pool_size]]
|
|
118
|
+
vecs = _combo_vectors([candidates[i] for i in pool], space)
|
|
119
|
+
|
|
120
|
+
chosen = [0] # local index into pool; pool[0] is the highest base score
|
|
121
|
+
mind = [_hamming(vecs[j], vecs[0]) for j in range(len(pool))]
|
|
122
|
+
mind[0] = -1
|
|
123
|
+
while len(chosen) < k:
|
|
124
|
+
j = int(np.argmax(mind)) # max-min distance; ties -> lowest index = higher base score
|
|
125
|
+
if mind[j] < 0: # pool exhausted of distinct points
|
|
126
|
+
break
|
|
127
|
+
chosen.append(j)
|
|
128
|
+
mind[j] = -1
|
|
129
|
+
vj = vecs[j]
|
|
130
|
+
for t in range(len(pool)):
|
|
131
|
+
if mind[t] >= 0:
|
|
132
|
+
d = _hamming(vecs[t], vj)
|
|
133
|
+
if d < mind[t]:
|
|
134
|
+
mind[t] = d
|
|
135
|
+
return [pool[j] for j in chosen]
|
shellde/advisor.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Campaign readiness advisor: per-round signals turned into a next-step recommendation.
|
|
2
|
+
|
|
3
|
+
In the N=95 (one plate per round) regime a PI needs to know, after each round, whether
|
|
4
|
+
to keep gathering data (model not yet trustworthy), open the explicit-epistasis arm
|
|
5
|
+
(enough data has accumulated), exploit and finish (learning has plateaued), or simply
|
|
6
|
+
continue. This reads the metrics ``run_campaign`` already records (held-out Spearman
|
|
7
|
+
trend, best-fitness trajectory, per-round active blocks) plus an on-demand pairwise-gate
|
|
8
|
+
evidence check, and returns thresholded guidance. It forecasts nothing it cannot
|
|
9
|
+
measure: "how many more plates" is intentionally NOT claimed (low-N extrapolation is
|
|
10
|
+
unreliable); only the current state and a discrete recommendation are returned.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from sklearn.model_selection import KFold
|
|
18
|
+
|
|
19
|
+
from shellde.conformal import conformal_quantile
|
|
20
|
+
from shellde.design_space import DesignSpace
|
|
21
|
+
from shellde.features import FeatureMatrix, OneHotBlock
|
|
22
|
+
from shellde.gating import pairwise_gate_evidence
|
|
23
|
+
from shellde.protocols import Surrogate
|
|
24
|
+
from shellde.surrogate import RidgeSurrogate
|
|
25
|
+
from shellde.types import CampaignResult
|
|
26
|
+
|
|
27
|
+
SPEARMAN_TRUST = 0.4
|
|
28
|
+
PLATEAU_EPS = 0.03
|
|
29
|
+
GATE_MIN_IMPROVE = 0.02
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def campaign_readiness(
|
|
33
|
+
result: CampaignResult,
|
|
34
|
+
*,
|
|
35
|
+
space: DesignSpace | None = None,
|
|
36
|
+
spearman_trust: float = SPEARMAN_TRUST,
|
|
37
|
+
plateau_eps: float = PLATEAU_EPS,
|
|
38
|
+
gate_min_improve: float = GATE_MIN_IMPROVE,
|
|
39
|
+
) -> dict:
|
|
40
|
+
"""Return a next-step recommendation plus the signals it is based on.
|
|
41
|
+
|
|
42
|
+
``recommendation`` is one of: ``gather_more`` (predictions not yet reliable),
|
|
43
|
+
``open_epistasis`` (the pairwise block now earns its place), ``exploit_and_finish``
|
|
44
|
+
(learning and best-fitness have plateaued), or ``continue`` (trustworthy and still
|
|
45
|
+
improving). Pass ``space`` to enable the epistasis-readiness check.
|
|
46
|
+
"""
|
|
47
|
+
ho = list(result.heldout_spearman)
|
|
48
|
+
traj = list(result.trajectory)
|
|
49
|
+
latest = ho[-1] if ho else None
|
|
50
|
+
spear_delta = (ho[-1] - ho[-2]) if len(ho) >= 2 else None
|
|
51
|
+
best_gain = None
|
|
52
|
+
if len(traj) >= 2:
|
|
53
|
+
span = max(max(traj) - min(traj), 1e-9)
|
|
54
|
+
best_gain = (traj[-1] - traj[-2]) / span
|
|
55
|
+
|
|
56
|
+
trustworthy = latest is not None and latest >= spearman_trust
|
|
57
|
+
spear_plateau = spear_delta is not None and abs(spear_delta) <= plateau_eps
|
|
58
|
+
fitness_plateau = best_gain is not None and best_gain <= plateau_eps
|
|
59
|
+
plateaued = bool(spear_plateau and fitness_plateau)
|
|
60
|
+
|
|
61
|
+
last_blocks = result.rounds[-1].notes.get("active_blocks", []) if result.rounds else []
|
|
62
|
+
pairwise_open = "pairwise" in last_blocks
|
|
63
|
+
epistasis_ready = False
|
|
64
|
+
gate_improve: float | None = None
|
|
65
|
+
if space is not None and result.measured:
|
|
66
|
+
variants = list(result.measured)
|
|
67
|
+
fitness = np.asarray([result.measured[v] for v in variants], dtype=float)
|
|
68
|
+
gate_improve = float(pairwise_gate_evidence(space, variants, fitness)["improvement"])
|
|
69
|
+
epistasis_ready = gate_improve >= gate_min_improve
|
|
70
|
+
|
|
71
|
+
if not trustworthy:
|
|
72
|
+
rec, why = "gather_more", "held-out Spearman below trust threshold; predictions not yet reliable"
|
|
73
|
+
elif epistasis_ready and not pairwise_open:
|
|
74
|
+
rec, why = "open_epistasis", "enough data: the pairwise block now improves held-out prediction"
|
|
75
|
+
elif plateaued:
|
|
76
|
+
rec, why = "exploit_and_finish", "learning and best-fitness have plateaued; exploit the remaining budget"
|
|
77
|
+
else:
|
|
78
|
+
rec, why = "continue", "model is trustworthy and still improving; run another round"
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
"recommendation": rec,
|
|
82
|
+
"why": why,
|
|
83
|
+
"latest_heldout_spearman": latest,
|
|
84
|
+
"spearman_delta": spear_delta,
|
|
85
|
+
"best_fitness_gain_rel": best_gain,
|
|
86
|
+
"model_trustworthy": bool(trustworthy),
|
|
87
|
+
"learning_plateaued": plateaued,
|
|
88
|
+
"epistasis_ready": bool(epistasis_ready),
|
|
89
|
+
"pairwise_open": bool(pairwise_open),
|
|
90
|
+
"gate_improvement": gate_improve,
|
|
91
|
+
"n_rounds": result.n_rounds,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _spearman(a: np.ndarray, b: np.ndarray) -> float:
|
|
96
|
+
ra = np.argsort(np.argsort(a)).astype(float)
|
|
97
|
+
rb = np.argsort(np.argsort(b)).astype(float)
|
|
98
|
+
if np.std(ra) < 1e-12 or np.std(rb) < 1e-12:
|
|
99
|
+
return 0.0
|
|
100
|
+
return float(np.corrcoef(ra, rb)[0, 1])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def data_readiness(
|
|
104
|
+
space: DesignSpace,
|
|
105
|
+
variants: "list[str]",
|
|
106
|
+
fitness: np.ndarray,
|
|
107
|
+
*,
|
|
108
|
+
model_factory: "Callable[[], Surrogate]" = lambda: RidgeSurrogate(random_state=0),
|
|
109
|
+
spearman_trust: float = SPEARMAN_TRUST,
|
|
110
|
+
gate_min_improve: float = GATE_MIN_IMPROVE,
|
|
111
|
+
k: int = 4,
|
|
112
|
+
seed: int = 0,
|
|
113
|
+
) -> dict:
|
|
114
|
+
"""Readiness from a measured set alone (no simulation) for the real per-round workflow.
|
|
115
|
+
|
|
116
|
+
Computes k-fold held-out Spearman of an additive (one-hot) model on the accumulated
|
|
117
|
+
data plus the pairwise-gate evidence, and returns a discrete recommendation:
|
|
118
|
+
``gather_more`` (predictions not reliable yet), ``open_epistasis`` (pairwise now
|
|
119
|
+
earns its place), or ``model_reliable`` (additive/PLM model is trustworthy, proceed).
|
|
120
|
+
"""
|
|
121
|
+
y = np.asarray(fitness, dtype=float)
|
|
122
|
+
n = len(variants)
|
|
123
|
+
x = FeatureMatrix(space, [OneHotBlock(space)]).encode(variants)
|
|
124
|
+
heldout: float | None = None
|
|
125
|
+
conformal_halfwidth: float | None = None
|
|
126
|
+
if n >= 8 and float(np.std(y)) > 1e-12:
|
|
127
|
+
kf = KFold(n_splits=min(k, n), shuffle=True, random_state=seed)
|
|
128
|
+
pred = np.zeros(n)
|
|
129
|
+
for tr, te in kf.split(x):
|
|
130
|
+
if len(tr) < 2 or float(np.std(y[tr])) < 1e-12:
|
|
131
|
+
pred[te] = float(np.mean(y[tr])) if len(tr) else 0.0
|
|
132
|
+
continue
|
|
133
|
+
pred[te] = model_factory().fit(x[tr], y[tr]).predict(x[te]).mean
|
|
134
|
+
heldout = _spearman(pred, y)
|
|
135
|
+
# Pooled out-of-fold residuals = leave-one-fold-out cross-conformal scores;
|
|
136
|
+
# their conformal quantile is a distribution-free ~90% interval half-width.
|
|
137
|
+
conformal_halfwidth = conformal_quantile(y - pred)
|
|
138
|
+
gate_improve = float(pairwise_gate_evidence(space, variants, fitness, k=k, seed=seed)["improvement"])
|
|
139
|
+
epistasis_ready = gate_improve >= gate_min_improve
|
|
140
|
+
trustworthy = heldout is not None and heldout >= spearman_trust
|
|
141
|
+
|
|
142
|
+
if not trustworthy:
|
|
143
|
+
rec, why = "gather_more", "held-out Spearman below trust threshold; measure more before trusting recommendations"
|
|
144
|
+
elif epistasis_ready:
|
|
145
|
+
rec, why = "open_epistasis", "pairwise block now improves held-out prediction; enable --auto-gate-pairwise"
|
|
146
|
+
else:
|
|
147
|
+
rec, why = "model_reliable", "additive/PLM model is reliable on current data; proceed with recommend"
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
"recommendation": rec,
|
|
151
|
+
"why": why,
|
|
152
|
+
"n_measured": n,
|
|
153
|
+
"heldout_spearman": heldout,
|
|
154
|
+
"conformal_halfwidth_90": conformal_halfwidth,
|
|
155
|
+
"model_trustworthy": bool(trustworthy),
|
|
156
|
+
"epistasis_ready": bool(epistasis_ready),
|
|
157
|
+
"gate_improvement": gate_improve,
|
|
158
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Benchmark harness: tool-comparative campaigns + paired statistics."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from shellde.bench.falsification import additive_null_control, epistatic_residuals
|
|
5
|
+
from shellde.bench.harness import Method, default_methods, run_spectrum
|
|
6
|
+
from shellde.bench.stats import benjamini_hochberg, bootstrap_p_value, paired_bootstrap_ci
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Method",
|
|
10
|
+
"additive_null_control",
|
|
11
|
+
"benjamini_hochberg",
|
|
12
|
+
"bootstrap_p_value",
|
|
13
|
+
"default_methods",
|
|
14
|
+
"epistatic_residuals",
|
|
15
|
+
"paired_bootstrap_ci",
|
|
16
|
+
"run_spectrum",
|
|
17
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Visani additive-null control: the mandatory floor before claiming epistasis learning.
|
|
2
|
+
|
|
3
|
+
Per Visani, Verma & DeWitt 2026 (and rec 3 of the tool synthesis): a flexible model
|
|
4
|
+
only has EVIDENCE of learning epistasis if its predictions DECORRELATE from a fixed
|
|
5
|
+
additive null AND it beats that null out of sample. If a nonlinear model's held-out
|
|
6
|
+
predictions correlate with the additive null at r > ~0.999, its extra capacity is
|
|
7
|
+
unused and any "learned epistasis / synergy" claim is unsupported (the gain, if any,
|
|
8
|
+
is a statistical artifact). This module makes that control a first-class bench output.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from sklearn.model_selection import KFold
|
|
16
|
+
|
|
17
|
+
from shellde.design_space import DesignSpace
|
|
18
|
+
from shellde.features import FeatureMatrix, OneHotBlock
|
|
19
|
+
from shellde.protocols import Surrogate
|
|
20
|
+
from shellde.surrogate import RFSurrogate, RidgeSurrogate
|
|
21
|
+
|
|
22
|
+
R_THRESHOLD = 0.999
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _pearson(a: np.ndarray, b: np.ndarray) -> float:
|
|
26
|
+
if np.std(a) < 1e-12 or np.std(b) < 1e-12:
|
|
27
|
+
return 1.0
|
|
28
|
+
return float(np.corrcoef(a, b)[0, 1])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _rmse(pred: np.ndarray, y: np.ndarray) -> float:
|
|
32
|
+
return float(np.sqrt(np.mean((pred - y) ** 2)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def additive_null_control(
|
|
36
|
+
space: DesignSpace,
|
|
37
|
+
variants: Sequence[str],
|
|
38
|
+
fitness: np.ndarray,
|
|
39
|
+
*,
|
|
40
|
+
model_factory: Callable[[], Surrogate] = lambda: RFSurrogate(random_state=0),
|
|
41
|
+
k: int = 5,
|
|
42
|
+
seed: int = 0,
|
|
43
|
+
r_threshold: float = R_THRESHOLD,
|
|
44
|
+
) -> dict:
|
|
45
|
+
"""k-fold held-out predictions of a flexible model vs a fixed additive (ridge) null.
|
|
46
|
+
|
|
47
|
+
Both use the SAME one-hot encoding so only model capacity differs. Returns the
|
|
48
|
+
Pearson correlation of the two prediction vectors, each held-out RMSE, and a
|
|
49
|
+
``learned_epistasis`` verdict (decorrelated AND beats the null out of sample).
|
|
50
|
+
"""
|
|
51
|
+
y = np.asarray(fitness, dtype=float)
|
|
52
|
+
mat = FeatureMatrix(space, [OneHotBlock(space)])
|
|
53
|
+
x = mat.encode(variants)
|
|
54
|
+
n = len(variants)
|
|
55
|
+
if n < 8 or float(np.std(y)) < 1e-12:
|
|
56
|
+
return {
|
|
57
|
+
"pearson_r": 1.0, "additive_rmse": float("nan"), "model_rmse": float("nan"),
|
|
58
|
+
"decorrelated": False, "beats_additive": False, "learned_epistasis": False,
|
|
59
|
+
}
|
|
60
|
+
add_pred = np.zeros(n)
|
|
61
|
+
model_pred = np.zeros(n)
|
|
62
|
+
kf = KFold(n_splits=min(k, n), shuffle=True, random_state=seed)
|
|
63
|
+
for tr, te in kf.split(x):
|
|
64
|
+
if len(tr) < 2 or float(np.std(y[tr])) < 1e-12:
|
|
65
|
+
add_pred[te] = float(np.mean(y[tr])) if len(tr) else 0.0
|
|
66
|
+
model_pred[te] = add_pred[te]
|
|
67
|
+
continue
|
|
68
|
+
add_pred[te] = RidgeSurrogate(random_state=seed).fit(x[tr], y[tr]).predict(x[te]).mean
|
|
69
|
+
model_pred[te] = model_factory().fit(x[tr], y[tr]).predict(x[te]).mean
|
|
70
|
+
r = _pearson(model_pred, add_pred)
|
|
71
|
+
a_rmse, m_rmse = _rmse(add_pred, y), _rmse(model_pred, y)
|
|
72
|
+
decorrelated = r < r_threshold
|
|
73
|
+
beats = m_rmse < a_rmse
|
|
74
|
+
return {
|
|
75
|
+
"pearson_r": r, "additive_rmse": a_rmse, "model_rmse": m_rmse,
|
|
76
|
+
"decorrelated": bool(decorrelated), "beats_additive": bool(beats),
|
|
77
|
+
"learned_epistasis": bool(decorrelated and beats),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def epistatic_residuals(
|
|
82
|
+
space: DesignSpace, variants: Sequence[str], fitness: np.ndarray, *, seed: int = 0
|
|
83
|
+
) -> dict:
|
|
84
|
+
"""Residuals of a full-data additive (ridge) fit; their spread relative to y.
|
|
85
|
+
|
|
86
|
+
Under pure additivity the residuals carry no structure (small relative spread);
|
|
87
|
+
a large ``residual_fraction`` is necessary (not sufficient) for genuine epistasis.
|
|
88
|
+
"""
|
|
89
|
+
y = np.asarray(fitness, dtype=float)
|
|
90
|
+
mat = FeatureMatrix(space, [OneHotBlock(space)])
|
|
91
|
+
x = mat.encode(variants)
|
|
92
|
+
add = RidgeSurrogate(random_state=seed).fit(x, y)
|
|
93
|
+
resid = y - add.predict(x).mean
|
|
94
|
+
ystd = float(np.std(y)) or 1.0
|
|
95
|
+
return {"residual_std": float(np.std(resid)), "residual_fraction": float(np.std(resid) / ystd)}
|
shellde/bench/harness.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Tool-comparative benchmark over a synthetic epistasis spectrum.
|
|
2
|
+
|
|
3
|
+
Runs each method's active-learning campaign at IDENTICAL budget on a SyntheticOracle
|
|
4
|
+
across an alpha spectrum and many seeds, scores normalised simple regret against the
|
|
5
|
+
known optimum, and reports paired regret reductions of ``ours`` vs each baseline with
|
|
6
|
+
bootstrap CIs and BH-FDR. Methods are (surrogate, acquisition) configs:
|
|
7
|
+
|
|
8
|
+
ours BayesLinear + default_blocks (onehot) + UCB (THE shipped additive default)
|
|
9
|
+
ours_pairwise BayesLinear + [onehot+gated-pairwise] + UCB (explicit opt-in arm, not default)
|
|
10
|
+
additive_greedy RidgeSurrogate + Greedy (the additive-greedy baseline)
|
|
11
|
+
rf_greedy RFSurrogate + Greedy (EVOLVEpro-style RF greedy)
|
|
12
|
+
ucb_bo EnsembleSurrogate + UCB (UCB Bayesian-optimisation style)
|
|
13
|
+
|
|
14
|
+
`ours` is sourced from `features.default_blocks`, the SAME factory `cli.recommend` ships, so the
|
|
15
|
+
head-to-head always measures what ships; explicit gated-pairwise is the separate `ours_pairwise` arm.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Callable, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from shellde.acquisition import Greedy, UCB
|
|
25
|
+
from shellde.bench.stats import benjamini_hochberg, bootstrap_p_value, paired_bootstrap_ci
|
|
26
|
+
from shellde.design_space import DesignSpace
|
|
27
|
+
from shellde.features import FeatureBlock, FeatureMatrix, GatedPairwiseBlock, OneHotBlock, default_blocks
|
|
28
|
+
from shellde.loop import run_campaign
|
|
29
|
+
from shellde.oracle import SyntheticOracle
|
|
30
|
+
from shellde.protocols import Acquisition, Surrogate
|
|
31
|
+
from shellde.surrogate import BayesLinearSurrogate, EnsembleSurrogate, RFSurrogate, RidgeSurrogate
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _onehot_blocks(space: DesignSpace) -> list[FeatureBlock]:
|
|
35
|
+
return [OneHotBlock(space)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _ours_blocks(space: DesignSpace) -> list[FeatureBlock]:
|
|
39
|
+
# ours == THE shipped additive default (single source of truth), so the head-to-head
|
|
40
|
+
# measures what `cli.recommend` actually ships, not an always-on pairwise overfit.
|
|
41
|
+
return default_blocks(space)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ours_pairwise_blocks(space: DesignSpace) -> list[FeatureBlock]:
|
|
45
|
+
# explicit opt-in arm: additive one-hot + always-on gated explicit-pairwise epistasis
|
|
46
|
+
# (the OLD ours config, now a separate arm that has not earned default status).
|
|
47
|
+
return [OneHotBlock(space), GatedPairwiseBlock(space)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Method:
|
|
52
|
+
name: str
|
|
53
|
+
make_surrogate: Callable[[], Surrogate]
|
|
54
|
+
acquisition: Acquisition
|
|
55
|
+
make_blocks: Callable[[DesignSpace], list[FeatureBlock]] = _onehot_blocks
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def default_methods() -> list[Method]:
|
|
59
|
+
return [
|
|
60
|
+
Method("ours", lambda: BayesLinearSurrogate(random_state=0), UCB(1.0), _ours_blocks),
|
|
61
|
+
Method("ours_pairwise", lambda: BayesLinearSurrogate(random_state=0), UCB(1.0), _ours_pairwise_blocks),
|
|
62
|
+
Method("additive_greedy", lambda: RidgeSurrogate(random_state=0), Greedy()),
|
|
63
|
+
Method("rf_greedy", lambda: RFSurrogate(random_state=0), Greedy()),
|
|
64
|
+
Method("ucb_bo", lambda: EnsembleSurrogate(random_state=0), UCB(1.0)),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_spectrum(
|
|
69
|
+
space: DesignSpace,
|
|
70
|
+
alphas: Sequence[float],
|
|
71
|
+
seeds: Sequence[int],
|
|
72
|
+
methods: Sequence[Method] | None = None,
|
|
73
|
+
*,
|
|
74
|
+
n_init: int = 95,
|
|
75
|
+
batch_size: int = 95,
|
|
76
|
+
n_rounds: int = 3,
|
|
77
|
+
) -> dict:
|
|
78
|
+
"""Sweep alpha x seed x method; return per-alpha regret + ours-vs-baseline stats."""
|
|
79
|
+
methods = list(methods) if methods is not None else default_methods()
|
|
80
|
+
names = [m.name for m in methods]
|
|
81
|
+
if "ours" not in names:
|
|
82
|
+
raise ValueError("methods must include 'ours' for the head-to-head comparison")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
per_alpha: dict[str, dict] = {}
|
|
86
|
+
all_pvals: list[float] = []
|
|
87
|
+
pval_index: list[tuple[float, str]] = []
|
|
88
|
+
|
|
89
|
+
for alpha in alphas:
|
|
90
|
+
regret: dict[str, list[float]] = {m.name: [] for m in methods}
|
|
91
|
+
ho_spear: dict[str, list[float]] = {m.name: [] for m in methods}
|
|
92
|
+
for seed in seeds:
|
|
93
|
+
oracle = SyntheticOracle(space, alpha=alpha, seed=seed)
|
|
94
|
+
universe = oracle.universe
|
|
95
|
+
assert universe is not None
|
|
96
|
+
f = oracle.evaluate(universe)
|
|
97
|
+
fmax, fmin = float(f.max()), float(f.min())
|
|
98
|
+
span = max(fmax - fmin, 1e-9)
|
|
99
|
+
for m in methods:
|
|
100
|
+
res = run_campaign(
|
|
101
|
+
oracle, space,
|
|
102
|
+
make_matrix=lambda mm=m: FeatureMatrix(space, mm.make_blocks(space)),
|
|
103
|
+
make_surrogate=m.make_surrogate,
|
|
104
|
+
acquisition=m.acquisition, n_init=n_init, batch_size=batch_size,
|
|
105
|
+
n_rounds=n_rounds, seed=seed,
|
|
106
|
+
)
|
|
107
|
+
regret[m.name].append((fmax - res.best_fitness) / span)
|
|
108
|
+
if res.heldout_spearman:
|
|
109
|
+
ho_spear[m.name].append(float(np.mean(res.heldout_spearman)))
|
|
110
|
+
|
|
111
|
+
ours = np.asarray(regret["ours"], dtype=float)
|
|
112
|
+
comparisons: dict[str, dict] = {}
|
|
113
|
+
for name in names:
|
|
114
|
+
if name == "ours":
|
|
115
|
+
continue
|
|
116
|
+
base = np.asarray(regret[name], dtype=float)
|
|
117
|
+
delta = base - ours # positive => ours has lower regret (better)
|
|
118
|
+
mean, lo, hi = paired_bootstrap_ci(delta)
|
|
119
|
+
p = bootstrap_p_value(delta)
|
|
120
|
+
comparisons[name] = {"regret_reduction_mean": mean, "ci_lo": lo, "ci_hi": hi, "p": p}
|
|
121
|
+
all_pvals.append(p)
|
|
122
|
+
pval_index.append((alpha, name))
|
|
123
|
+
|
|
124
|
+
per_alpha[str(alpha)] = {
|
|
125
|
+
"mean_regret": {k: float(np.mean(v)) for k, v in regret.items()},
|
|
126
|
+
"heldout_spearman": {k: (float(np.mean(v)) if v else None) for k, v in ho_spear.items()},
|
|
127
|
+
"ours_vs": comparisons,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
sig = benjamini_hochberg(all_pvals)
|
|
131
|
+
for (alpha, name), s in zip(pval_index, sig, strict=True):
|
|
132
|
+
per_alpha[str(alpha)]["ours_vs"][name]["fdr_significant"] = bool(s)
|
|
133
|
+
|
|
134
|
+
return {"methods": names, "alphas": list(alphas), "n_seeds": len(seeds), "per_alpha": per_alpha}
|
shellde/bench/stats.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Paired statistics for benchmark comparisons: bootstrap CI, bootstrap p, BH-FDR."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def benjamini_hochberg(pvals: Sequence[float], alpha: float = 0.05) -> np.ndarray:
|
|
10
|
+
"""Return a boolean array: which hypotheses are rejected at FDR ``alpha``."""
|
|
11
|
+
p = np.asarray(pvals, dtype=float)
|
|
12
|
+
m = p.size
|
|
13
|
+
if m == 0:
|
|
14
|
+
return np.zeros(0, dtype=bool)
|
|
15
|
+
order = np.argsort(p)
|
|
16
|
+
thresh = 0.0
|
|
17
|
+
for rank, idx in enumerate(order, start=1):
|
|
18
|
+
if p[idx] <= rank / m * alpha:
|
|
19
|
+
thresh = rank / m * alpha
|
|
20
|
+
return p <= thresh
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def paired_bootstrap_ci(
|
|
24
|
+
deltas: Sequence[float] | np.ndarray, *, n_boot: int = 2000, seed: int = 0, ci: float = 0.95
|
|
25
|
+
) -> tuple[float, float, float]:
|
|
26
|
+
"""Mean paired delta + bootstrap CI (mean, lo, hi)."""
|
|
27
|
+
d = np.asarray(deltas, dtype=float)
|
|
28
|
+
n = d.size
|
|
29
|
+
if n == 0:
|
|
30
|
+
return 0.0, 0.0, 0.0
|
|
31
|
+
rng = np.random.default_rng(seed)
|
|
32
|
+
means = np.array([d[rng.integers(0, n, n)].mean() for _ in range(n_boot)])
|
|
33
|
+
lo, hi = np.percentile(means, [(1 - ci) / 2 * 100, (1 + ci) / 2 * 100])
|
|
34
|
+
return float(d.mean()), float(lo), float(hi)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def bootstrap_p_value(deltas: Sequence[float] | np.ndarray, *, n_boot: int = 2000, seed: int = 0) -> float:
|
|
38
|
+
"""Two-sided bootstrap p-value that the mean paired delta differs from zero."""
|
|
39
|
+
d = np.asarray(deltas, dtype=float)
|
|
40
|
+
n = d.size
|
|
41
|
+
if n == 0:
|
|
42
|
+
return 1.0
|
|
43
|
+
rng = np.random.default_rng(seed)
|
|
44
|
+
means = np.array([d[rng.integers(0, n, n)].mean() for _ in range(n_boot)])
|
|
45
|
+
p = 2.0 * min(float((means <= 0).mean()), float((means >= 0).mean()))
|
|
46
|
+
return float(min(1.0, p))
|