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
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)}
@@ -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))