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/structure.py ADDED
@@ -0,0 +1,356 @@
1
+ """Structure-derived contacts to RESTRICT the explicit-pairwise epistasis block.
2
+
3
+ The honest way to use structure at N=95 is not an inverse-folding feature (protein
4
+ dependent, falsified as a universal win), but to cut the unestimable pairwise term
5
+ count down to biophysically plausible interactions: only model epistasis between
6
+ residues whose side chains are in contact. A few contact pairs x (q-1)^2 is estimable
7
+ where the full C(L,2) expansion is not. Feeds ``GatedPairwiseBlock(space, pairs=...)``
8
+ and the data-gate, so structure proposes the pairs and the data still decides whether
9
+ to open the block.
10
+
11
+ Minimal dependency-free PDB parser (CB, or CA for glycine / missing CB).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import csv
16
+ import json
17
+ import re
18
+ from collections.abc import Sequence
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+
23
+
24
+ def _residue_atoms(pdb_path: str | Path, chain: str | None) -> dict[int, dict[str, np.ndarray]]:
25
+ """Map residue number -> {atom_name: xyz} for the chosen chain (first if None)."""
26
+ out: dict[int, dict[str, np.ndarray]] = {}
27
+ picked_chain: str | None = chain
28
+ for line in Path(pdb_path).read_text().splitlines():
29
+ if not line.startswith("ATOM"):
30
+ continue
31
+ altloc = line[16]
32
+ if altloc not in (" ", "A"):
33
+ continue
34
+ ch = line[21]
35
+ if picked_chain is None:
36
+ picked_chain = ch
37
+ if ch != picked_chain:
38
+ continue
39
+ atom = line[12:16].strip()
40
+ try:
41
+ resnum = int(line[22:26])
42
+ xyz = np.array([float(line[30:38]), float(line[38:46]), float(line[46:54])], dtype=float)
43
+ except ValueError:
44
+ continue
45
+ out.setdefault(resnum, {})[atom] = xyz
46
+ return out
47
+
48
+
49
+ def _cbeta(atoms: dict[str, np.ndarray]) -> np.ndarray | None:
50
+ """Cbeta coordinate, falling back to Calpha (glycine / missing CB)."""
51
+ if "CB" in atoms:
52
+ return atoms["CB"]
53
+ return atoms.get("CA")
54
+
55
+
56
+ def contacts_from_pdb(
57
+ pdb_path: str | Path,
58
+ positions: Sequence[int],
59
+ *,
60
+ cutoff: float = 8.0,
61
+ chain: str | None = None,
62
+ ) -> list[tuple[int, int]]:
63
+ """Cbeta-Cbeta contact pairs (distance <= cutoff Angstrom) among ``positions``.
64
+
65
+ Positions are absolute 1-based residue numbers (the DesignSpace positions). Residues
66
+ absent from the structure are skipped. Returns sorted unique (i, j) pairs with i < j.
67
+ """
68
+ res = _residue_atoms(pdb_path, chain)
69
+ coords: dict[int, np.ndarray] = {}
70
+ for p in positions:
71
+ atoms = res.get(int(p))
72
+ cb = _cbeta(atoms) if atoms else None
73
+ if cb is not None:
74
+ coords[int(p)] = cb
75
+ pos = sorted(coords)
76
+ pairs: list[tuple[int, int]] = []
77
+ for a in range(len(pos)):
78
+ for b in range(a + 1, len(pos)):
79
+ if float(np.linalg.norm(coords[pos[a]] - coords[pos[b]])) <= cutoff:
80
+ pairs.append((pos[a], pos[b]))
81
+ return pairs
82
+
83
+
84
+ # Crystallization additives / solvents / non-catalytic ions to ignore when picking the
85
+ # ligand from HETATM. Catalytic metals (MG, MN, ZN, FE, CA, CO, NI, CU) are intentionally
86
+ # NOT blocklisted (they are first-shell for metalloenzymes). Override via ligand_resnames
87
+ # for precision.
88
+ _NON_LIGAND = frozenset({
89
+ "HOH", "WAT", "DOD",
90
+ "GOL", "EDO", "PEG", "PG4", "PG0", "1PE", "2PE", "P6G", "PGE", "MPD", "BU3",
91
+ "SO4", "PO4", "ACT", "FMT", "CIT", "FLC", "TLA", "MLA", "OXL", "TAR",
92
+ "DMS", "BME", "MES", "EPE", "TRS", "IMD", "NH4", "NO3", "CO3", "BCT", "PEO",
93
+ "NA", "CL", "K", "BR", "IOD", "CS", "RB", "LI",
94
+ })
95
+
96
+
97
+ def _is_hydrogen(atom_name: str, element: str = "") -> bool:
98
+ if element:
99
+ return element.upper() in ("H", "D")
100
+ return atom_name.lstrip("0123456789")[:1] == "H"
101
+
102
+
103
+ def _heavy_coords(atoms: dict[str, np.ndarray]) -> np.ndarray:
104
+ """Stack a residue's heavy-atom (non-hydrogen) coordinates as an (N, 3) array."""
105
+ rows = [xyz for name, xyz in atoms.items() if not _is_hydrogen(name)]
106
+ return np.asarray(rows, dtype=float) if rows else np.empty((0, 3), dtype=float)
107
+
108
+
109
+ def _min_pair_dist(a: np.ndarray, b: np.ndarray) -> float:
110
+ """Minimum pairwise Euclidean distance between two point sets (N,3) and (M,3)."""
111
+ d = np.sqrt(((a[:, None, :] - b[None, :, :]) ** 2).sum(axis=-1))
112
+ return float(d.min())
113
+
114
+
115
+ def _ligand_heavy_atoms(
116
+ pdb_path: str | Path,
117
+ ligand_resnames: Sequence[str] | None,
118
+ blocklist: frozenset[str],
119
+ ) -> np.ndarray:
120
+ """Heavy-atom coordinates (M, 3) of the ligand(s) from HETATM records.
121
+
122
+ Waters and crystallization additives are dropped via ``blocklist`` unless
123
+ ``ligand_resnames`` restricts to explicit residue names (which then bypasses the
124
+ blocklist). Hydrogens are excluded. Ligand chain is not constrained.
125
+ """
126
+ keep = frozenset(r.strip().upper() for r in ligand_resnames) if ligand_resnames else None
127
+ rows: list[list[float]] = []
128
+ for line in Path(pdb_path).read_text().splitlines():
129
+ if not line.startswith("HETATM"):
130
+ continue
131
+ if line[16] not in (" ", "A"):
132
+ continue
133
+ resname = line[17:20].strip().upper()
134
+ if keep is not None:
135
+ if resname not in keep:
136
+ continue
137
+ elif resname in blocklist:
138
+ continue
139
+ element = line[76:78].strip() if len(line) >= 78 else ""
140
+ atom = line[12:16].strip()
141
+ if _is_hydrogen(atom, element):
142
+ continue
143
+ try:
144
+ rows.append([float(line[30:38]), float(line[38:46]), float(line[46:54])])
145
+ except ValueError:
146
+ continue
147
+ return np.asarray(rows, dtype=float) if rows else np.empty((0, 3), dtype=float)
148
+
149
+
150
+ def shells_from_pdb(
151
+ pdb_path: str | Path,
152
+ *,
153
+ ligand_resnames: Sequence[str] | None = None,
154
+ chain: str | None = None,
155
+ contact_cutoff: float = 4.5,
156
+ blocklist: frozenset[str] | None = None,
157
+ ) -> dict[int, list[int]]:
158
+ """Contact-topology active-site shells from a HOLO structure.
159
+
160
+ 1st shell: residues with any heavy atom within ``contact_cutoff`` Angstrom of any
161
+ ligand heavy atom. 2nd shell: residues NOT in the 1st shell with any heavy atom within
162
+ ``contact_cutoff`` of any 1st-shell residue heavy atom. This is the principled
163
+ (geometry-adaptive) definition; it replaces an arbitrary single radius and needs a
164
+ ligand pose (HETATM). It does NOT predict beneficial mutations; it proposes WHERE to
165
+ saturate. This function carries NO gate, and neither does what consumes it: the shell is
166
+ handed to the design space as-is (`resolve-site` -> `--positions`), so position selection is
167
+ evidence-driven, not data-gated. Only optional FEATURE blocks are gated, and there the CALLER
168
+ decides per flag and per command (`features/base.py`).
169
+
170
+ Requires a ligand in the structure. For apo structures, obtain a ligand pose first
171
+ (experimental cocrystal, co-fold, or homolog superposition) then pass the holo PDB.
172
+ Returns {1: [...], 2: [...]} of absolute residue numbers (sorted, 1-based).
173
+ """
174
+ block = _NON_LIGAND if blocklist is None else blocklist
175
+ lig = _ligand_heavy_atoms(pdb_path, ligand_resnames, block)
176
+ if lig.shape[0] == 0:
177
+ raise ValueError(
178
+ "no ligand heavy atoms found (apo structure or all HETATM blocklisted); "
179
+ "pass ligand_resnames or a holo structure with a ligand pose"
180
+ )
181
+ res = _residue_atoms(pdb_path, chain)
182
+ heavy = {r: _heavy_coords(a) for r, a in res.items()}
183
+ heavy = {r: h for r, h in heavy.items() if h.shape[0]}
184
+ first = sorted(r for r, h in heavy.items() if _min_pair_dist(h, lig) <= contact_cutoff)
185
+ fset = set(first)
186
+ fcoords = [heavy[r] for r in first]
187
+ second = sorted(
188
+ r for r, h in heavy.items()
189
+ if r not in fset and any(_min_pair_dist(h, fc) <= contact_cutoff for fc in fcoords)
190
+ )
191
+ return {1: first, 2: second}
192
+
193
+
194
+ def load_inverse_folding_logprobs(path: str | Path) -> dict[int, dict[str, float]]:
195
+ """Load a per-position inverse-folding log-prob table {position: {aa: logp}} from JSON.
196
+
197
+ Produce it once with an inverse-folding model (e.g. ProteinMPNN: a single backbone
198
+ pass yields per-position amino-acid log-probabilities over the 20 AAs). Keys are
199
+ absolute 1-based residue numbers; feed the result to ``InverseFoldingBlock``.
200
+ """
201
+ raw = json.loads(Path(path).read_text())
202
+ return {int(p): {str(a): float(v) for a, v in d.items()} for p, d in raw.items()}
203
+
204
+
205
+ _CANONICAL_AA = frozenset("ACDEFGHIKLMNPQRSTVWY")
206
+ _POS_COLS = frozenset(("position", "pos", "site", "residue", "resnum", "resid"))
207
+ _AA_COLS = frozenset(("aa", "mut", "mutant", "to", "alt", "mutaa", "toaa", "alternate"))
208
+ _TOKEN_COLS = frozenset(("mutation", "mutant", "mut", "substitution", "variant", "token"))
209
+ _DDG_COLS = frozenset((
210
+ "ddg", "deltadeltag", "delta_delta_g", "delta_delta_G",
211
+ "ddgkcalpermol", "ddgkcal", "ddgkcalmol", "delta_deltag",
212
+ ))
213
+ _MUT_TOKEN_RE = re.compile(r"^[A-Z](?P<pos>[1-9][0-9]*)(?P<aa>[A-Z])$|^(?P<pos2>[1-9][0-9]*)(?P<aa2>[A-Z])$")
214
+
215
+
216
+ def _norm_col(name: str) -> str:
217
+ return re.sub(r"[^a-z0-9]", "", name.lower())
218
+
219
+
220
+ def _find_col(fieldnames: Sequence[str], aliases: frozenset[str]) -> str | None:
221
+ norm_aliases = {_norm_col(a) for a in aliases}
222
+ for name in fieldnames:
223
+ if _norm_col(name) in norm_aliases:
224
+ return name
225
+ return None
226
+
227
+
228
+ def _parse_mut_token(value: object) -> tuple[int, str] | None:
229
+ text = str(value).strip().upper()
230
+ m = _MUT_TOKEN_RE.match(text)
231
+ if not m:
232
+ return None
233
+ pos = m.group("pos") or m.group("pos2")
234
+ aa = m.group("aa") or m.group("aa2")
235
+ return int(pos), aa
236
+
237
+
238
+ def _validate_aa(aa: object, *, context: str) -> str:
239
+ text = str(aa).strip().upper()
240
+ if len(text) != 1 or text not in _CANONICAL_AA:
241
+ raise ValueError(f"invalid amino-acid code {aa!r} in {context}; expected one of {''.join(sorted(_CANONICAL_AA))}")
242
+ return text
243
+
244
+
245
+ def _insert_ddg(out: dict[int, dict[str, float]], pos: int, aa: str, value: float, *, context: str) -> None:
246
+ if pos < 1:
247
+ raise ValueError(f"invalid residue position {pos!r} in {context}; positions are 1-based positive integers")
248
+ aa = _validate_aa(aa, context=context)
249
+ row = out.setdefault(int(pos), {})
250
+ if aa in row:
251
+ if row[aa] != value:
252
+ raise ValueError(f"conflicting duplicate ddG for position {pos}, aa {aa}: {row[aa]} vs {value} in {context}")
253
+ return
254
+ row[aa] = float(value)
255
+
256
+
257
+ def _load_ddg_json(path: Path) -> dict[int, dict[str, float]]:
258
+ raw = json.loads(path.read_text())
259
+ if not isinstance(raw, dict):
260
+ raise ValueError("ddG JSON must be an object {position: {aa: ddG}}")
261
+ out: dict[int, dict[str, float]] = {}
262
+ for p, table in raw.items():
263
+ try:
264
+ pos = int(p)
265
+ except (TypeError, ValueError) as exc:
266
+ raise ValueError(f"invalid ddG JSON position {p!r}") from exc
267
+ if not isinstance(table, dict):
268
+ raise ValueError(f"ddG JSON position {p!r} must map to an object of aa->ddG")
269
+ for aa, value in table.items():
270
+ try:
271
+ ddg = float(value)
272
+ except (TypeError, ValueError) as exc:
273
+ raise ValueError(f"invalid ddG value {value!r} for position {p}, aa {aa}") from exc
274
+ _insert_ddg(out, pos, str(aa), ddg, context=f"JSON position {p}")
275
+ return out
276
+
277
+
278
+ def _sniff_delimiter(path: Path, text: str) -> str:
279
+ if path.suffix.lower() in (".tsv", ".tab"):
280
+ return "\t"
281
+ if path.suffix.lower() == ".csv":
282
+ return ","
283
+ sample = text[:4096]
284
+ try:
285
+ return csv.Sniffer().sniff(sample, delimiters=",\t").delimiter
286
+ except csv.Error:
287
+ return "\t" if "\t" in sample and "," not in sample else ","
288
+
289
+
290
+ def _load_ddg_delimited(path: Path) -> dict[int, dict[str, float]]:
291
+ text = path.read_text()
292
+ delimiter = _sniff_delimiter(path, text)
293
+ reader = csv.DictReader(text.splitlines(), delimiter=delimiter)
294
+ if not reader.fieldnames:
295
+ raise ValueError("ddG table must have a header row")
296
+ fieldnames = [f for f in reader.fieldnames if f is not None]
297
+ ddg_col = _find_col(fieldnames, _DDG_COLS)
298
+ if ddg_col is None:
299
+ raise ValueError("ddG table needs a ddG column (aliases: ddg, delta_delta_G, ddg_kcal_per_mol)")
300
+ pos_col = _find_col(fieldnames, _POS_COLS)
301
+ aa_col = _find_col(fieldnames, _AA_COLS)
302
+ token_col = _find_col(fieldnames, _TOKEN_COLS)
303
+
304
+ out: dict[int, dict[str, float]] = {}
305
+ for line_no, row in enumerate(reader, start=2):
306
+ context = f"{path.name}:{line_no}"
307
+ try:
308
+ ddg = float(row[ddg_col])
309
+ except (TypeError, ValueError) as exc:
310
+ raise ValueError(f"invalid ddG value {row.get(ddg_col)!r} in {context}") from exc
311
+
312
+ parsed: tuple[int, str] | None = None
313
+ if token_col and row.get(token_col) not in (None, ""):
314
+ parsed = _parse_mut_token(row[token_col])
315
+ if parsed is not None:
316
+ pos, aa = parsed
317
+ else:
318
+ if pos_col is None or aa_col is None:
319
+ raise ValueError(
320
+ "ddG table needs either a mutation token column like A123V, or both "
321
+ "position (position/pos/site/residue) and mutant-aa (aa/mut/mutant/to/alt) columns"
322
+ )
323
+ try:
324
+ pos = int(str(row[pos_col]).strip())
325
+ except (TypeError, ValueError) as exc:
326
+ raise ValueError(f"invalid residue position {row.get(pos_col)!r} in {context}") from exc
327
+ aa = _validate_aa(row[aa_col], context=context)
328
+ _insert_ddg(out, pos, aa, ddg, context=context)
329
+
330
+ if not out:
331
+ raise ValueError("ddG table contained no rows")
332
+ return out
333
+
334
+
335
+ def load_ddg_table(path: str | Path) -> dict[int, dict[str, float]]:
336
+ """Load a per-position folding ddG table {position: {aa: ddG_kcal_per_mol}}.
337
+
338
+ Accepted formats:
339
+ - JSON object ``{position: {aa: ddG}}`` (backward compatible).
340
+ - CSV/TSV long table with a ddG column plus either:
341
+ (a) position + mutant-AA columns (aliases: position/pos/site/residue and aa/mut/mutant/to/alt), or
342
+ (b) a mutation token column containing values like ``A123V`` or ``123V``.
343
+
344
+ Convention: ddG is the predicted change in folding free energy on mutating the WT
345
+ residue at ``position`` to ``aa`` (positive = destabilizing, like ThermoMPNN / FoldX;
346
+ the WT residue is 0 by definition and need not be listed). Produce it once with a
347
+ free ddG predictor (ThermoMPNN, FoldX, RaSP) over the active-site region; feed the
348
+ result to ``funclib.design_library`` as its stability filter. Keys are absolute
349
+ 1-based residue numbers. Shares the table shape of an inverse-folding log-prob
350
+ table but carries ddG semantics (lower is more stable, not a log-prob).
351
+ """
352
+ p = Path(path)
353
+ stripped = p.read_text()[:2048].lstrip()
354
+ if p.suffix.lower() == ".json" or stripped.startswith("{"):
355
+ return _load_ddg_json(p)
356
+ return _load_ddg_delimited(p)
shellde/surrogate.py ADDED
@@ -0,0 +1,323 @@
1
+ """Surrogates with CALIBRATED predictive uncertainty (the day-one contract).
2
+
3
+ All surrogates expose the same contract: ``fit`` -> ``predict`` returning a
4
+ ``Prediction(mean, std)`` whose ``std`` is a calibrated 1-sigma predictive standard
5
+ deviation, plus ``main_effects`` (an always-fit ridge arm) for the additive
6
+ position-selection prior.
7
+
8
+ Calibration is variance scaling: a model produces a RAW per-point uncertainty
9
+ (ensemble/tree spread combined with a residual floor), and a single scale ``s`` is
10
+ fit by k-fold so that held-out standardised residuals (y - mean) / (s * raw) have
11
+ unit variance. This is simple, low-N robust, and turns an ad-hoc
12
+ "spread + floor" into an actually-calibrated number that acquisition and the risk
13
+ flags can trust.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from abc import ABC, abstractmethod
18
+
19
+ import numpy as np
20
+ from sklearn.ensemble import RandomForestRegressor
21
+ from sklearn.isotonic import IsotonicRegression
22
+ from sklearn.linear_model import LogisticRegression, Ridge
23
+
24
+ from shellde.types import Prediction
25
+
26
+ DEFAULT_ALPHA = 1.0
27
+ N_MEMBERS = 15
28
+ N_TREES = 100
29
+ CAL_FOLDS = 5
30
+ _FLOOR = 1e-9
31
+
32
+
33
+ class _BaseSurrogate(ABC):
34
+ """Shared fit/calibrate/predict machinery; subclasses define the deployed model."""
35
+
36
+ def __init__(self, alpha: float = DEFAULT_ALPHA, random_state: int = 0) -> None:
37
+ self.alpha = alpha
38
+ self.random_state = random_state
39
+ self._arm: Ridge | None = None
40
+ self._scale: float = 1.0
41
+ self._resid_std: float = _FLOOR
42
+ self._fitted: bool = False
43
+
44
+ # --- subclass hooks ----------------------------------------------------
45
+ @abstractmethod
46
+ def _new(self) -> "_BaseSurrogate":
47
+ """A fresh, unfitted clone with identical hyperparameters (for calibration)."""
48
+
49
+ @abstractmethod
50
+ def _fit_raw(self, x: np.ndarray, y: np.ndarray, sw: np.ndarray | None) -> None:
51
+ """Fit the deployed model + set ``self._resid_std`` from in-sample residuals."""
52
+
53
+ @abstractmethod
54
+ def _mean(self, x: np.ndarray) -> np.ndarray: ...
55
+
56
+ @abstractmethod
57
+ def _raw_std(self, x: np.ndarray) -> np.ndarray:
58
+ """Uncalibrated per-point std (spread combined with the residual floor)."""
59
+
60
+ # --- public contract ---------------------------------------------------
61
+ def fit(
62
+ self, x: np.ndarray, y: np.ndarray, sample_weight: np.ndarray | None = None
63
+ ) -> "_BaseSurrogate":
64
+ x = np.asarray(x, dtype=float)
65
+ y = np.asarray(y, dtype=float).ravel()
66
+ if x.ndim != 2 or x.shape[0] != y.shape[0]:
67
+ raise ValueError(f"x {x.shape} incompatible with y {y.shape}")
68
+ if x.shape[0] < 2:
69
+ raise ValueError("need at least 2 training samples")
70
+ sw = None if sample_weight is None else np.asarray(sample_weight, dtype=float)
71
+ self._arm = Ridge(alpha=self.alpha).fit(x, y, sample_weight=sw) # additive prior
72
+ self._scale = self._calibrate(x, y, sw)
73
+ self._fit_raw(x, y, sw)
74
+ self._fitted = True
75
+ return self
76
+
77
+ def _calibrate(self, x: np.ndarray, y: np.ndarray, sw: np.ndarray | None) -> float:
78
+ n = len(y)
79
+ if n < 4:
80
+ return 1.0
81
+ k = min(CAL_FOLDS, n // 2)
82
+ idx = np.random.default_rng(self.random_state).permutation(n)
83
+ folds = np.array_split(idx, k)
84
+ ratios: list[np.ndarray] = []
85
+ for i in range(k):
86
+ te = folds[i]
87
+ tr = np.concatenate([folds[j] for j in range(k) if j != i])
88
+ if len(tr) < 2 or len(te) == 0:
89
+ continue
90
+ m = self._new()
91
+ m._fit_raw(x[tr], y[tr], None if sw is None else sw[tr])
92
+ resid = y[te] - m._mean(x[te])
93
+ rs = np.maximum(m._raw_std(x[te]), _FLOOR)
94
+ ratios.append((resid / rs) ** 2)
95
+ if not ratios:
96
+ return 1.0
97
+ return float(np.sqrt(max(float(np.mean(np.concatenate(ratios))), _FLOOR)))
98
+
99
+ def _check(self) -> None:
100
+ if not self._fitted:
101
+ raise RuntimeError("surrogate not fitted; call fit() first")
102
+
103
+ def predict(self, x: np.ndarray) -> Prediction:
104
+ self._check()
105
+ x = np.asarray(x, dtype=float)
106
+ std = self._scale * np.maximum(self._raw_std(x), _FLOOR)
107
+ return Prediction(mean=self._mean(x), std=std)
108
+
109
+ def main_effects(self) -> np.ndarray | None:
110
+ self._check()
111
+ assert self._arm is not None
112
+ return np.asarray(self._arm.coef_, dtype=float)
113
+
114
+
115
+ class RidgeSurrogate(_BaseSurrogate):
116
+ """Homoscedastic ridge; calibrated std equals the held-out RMSE (constant)."""
117
+
118
+ def _new(self) -> "RidgeSurrogate":
119
+ return RidgeSurrogate(self.alpha, self.random_state)
120
+
121
+ def _fit_raw(self, x: np.ndarray, y: np.ndarray, sw: np.ndarray | None) -> None:
122
+ self._model = Ridge(alpha=self.alpha).fit(x, y, sample_weight=sw)
123
+ self._resid_std = float(np.std(y - self._model.predict(x))) or _FLOOR
124
+
125
+ def _mean(self, x: np.ndarray) -> np.ndarray:
126
+ return np.asarray(self._model.predict(x), dtype=float)
127
+
128
+ def _raw_std(self, x: np.ndarray) -> np.ndarray:
129
+ return np.full(x.shape[0], max(self._resid_std, _FLOOR), dtype=float)
130
+
131
+
132
+ class EnsembleSurrogate(_BaseSurrogate):
133
+ """Bootstrap-ridge ensemble; heteroscedastic std from member spread + floor."""
134
+
135
+ def __init__(
136
+ self, alpha: float = DEFAULT_ALPHA, n_members: int = N_MEMBERS, random_state: int = 0
137
+ ) -> None:
138
+ super().__init__(alpha, random_state)
139
+ self.n_members = n_members
140
+
141
+ def _new(self) -> "EnsembleSurrogate":
142
+ return EnsembleSurrogate(self.alpha, self.n_members, self.random_state)
143
+
144
+ def _fit_raw(self, x: np.ndarray, y: np.ndarray, sw: np.ndarray | None) -> None:
145
+ rng = np.random.default_rng(self.random_state)
146
+ n = x.shape[0]
147
+ self._members: list[Ridge] = []
148
+ for _ in range(self.n_members):
149
+ b = rng.integers(0, n, n)
150
+ swb = None if sw is None else sw[b]
151
+ self._members.append(Ridge(alpha=self.alpha).fit(x[b], y[b], sample_weight=swb))
152
+ self._resid_std = float(np.std(y - self._mean(x))) or _FLOOR
153
+
154
+ def _mean(self, x: np.ndarray) -> np.ndarray:
155
+ return np.mean([m.predict(x) for m in self._members], axis=0)
156
+
157
+ def _raw_std(self, x: np.ndarray) -> np.ndarray:
158
+ preds = np.stack([m.predict(x) for m in self._members], axis=0)
159
+ return np.sqrt(preds.std(axis=0) ** 2 + self._resid_std**2)
160
+
161
+
162
+ class RFSurrogate(_BaseSurrogate):
163
+ """Random forest; heteroscedastic std from tree spread + floor (captures interactions)."""
164
+
165
+ def __init__(
166
+ self, alpha: float = DEFAULT_ALPHA, n_estimators: int = N_TREES, random_state: int = 0
167
+ ) -> None:
168
+ super().__init__(alpha, random_state)
169
+ self.n_estimators = n_estimators
170
+
171
+ def _new(self) -> "RFSurrogate":
172
+ return RFSurrogate(self.alpha, self.n_estimators, self.random_state)
173
+
174
+ def _fit_raw(self, x: np.ndarray, y: np.ndarray, sw: np.ndarray | None) -> None:
175
+ self._rf = RandomForestRegressor(
176
+ n_estimators=self.n_estimators, random_state=self.random_state
177
+ ).fit(x, y, sample_weight=sw)
178
+ self._resid_std = float(np.std(y - self._mean(x))) or _FLOOR
179
+
180
+ def _mean(self, x: np.ndarray) -> np.ndarray:
181
+ return np.asarray(self._rf.predict(x), dtype=float)
182
+
183
+ def _raw_std(self, x: np.ndarray) -> np.ndarray:
184
+ preds = np.stack([t.predict(x) for t in self._rf.estimators_], axis=0)
185
+ return np.sqrt(preds.std(axis=0) ** 2 + self._resid_std**2)
186
+
187
+
188
+ class BayesLinearSurrogate(_BaseSurrogate):
189
+ """Closed-form Bayesian linear regression.
190
+
191
+ The posterior predictive variance (noise + x^T S x) IS the uncertainty, which made
192
+ explicit-pairwise epistasis estimation stronger than a bootstrap-ridge
193
+ ensemble. Feed it [one-hot + GatedPairwise] to get the explicit-epistasis path.
194
+ Note: at N=95, q=20 the pairwise block is still unestimable (361 params/pair), so
195
+ this is a completeness/strength port, not an N=95 winner.
196
+ """
197
+
198
+ def _new(self) -> "BayesLinearSurrogate":
199
+ return BayesLinearSurrogate(self.alpha, self.random_state)
200
+
201
+ def _fit_raw(self, x: np.ndarray, y: np.ndarray, sw: np.ndarray | None) -> None:
202
+ d = x.shape[1]
203
+ a_inv = np.linalg.inv(x.T @ x + self.alpha * np.eye(d)) # (XtX + lam I)^-1
204
+ self._coef = a_inv @ x.T @ y
205
+ resid = y - x @ self._coef
206
+ self._beta = 1.0 / max(float(np.var(resid)), _FLOOR) # noise precision from residual
207
+ self._cov = a_inv / self._beta # posterior covariance for precision beta
208
+ self._resid_std = float(np.std(resid)) or _FLOOR
209
+
210
+ def _mean(self, x: np.ndarray) -> np.ndarray:
211
+ return x @ self._coef
212
+
213
+ def _raw_std(self, x: np.ndarray) -> np.ndarray:
214
+ var = 1.0 / self._beta + np.sum((x @ self._cov) * x, axis=1)
215
+ return np.sqrt(np.maximum(var, 0.0))
216
+
217
+
218
+ class RankingSurrogate(_BaseSurrogate):
219
+ """Scale-invariant pairwise-logistic ranking readout (FolDE's ranking-loss lesson).
220
+
221
+ Trains a linear model with a Bradley-Terry / BCE-over-pairs objective (label = which
222
+ of a pair is fitter), so naturalness units and activity units can share one loss and
223
+ the fit is invariant to monotone rescaling of y. The raw ranking score is then mapped
224
+ to activity units by a 1-D least-squares fit so the surrogate still returns a
225
+ calibrated mean+std. Pair this with NaturalnessBlock to get warm-start anchoring.
226
+ """
227
+
228
+ def __init__(self, alpha: float = DEFAULT_ALPHA, random_state: int = 0, max_pairs: int = 4000) -> None:
229
+ super().__init__(alpha, random_state)
230
+ self.max_pairs = max_pairs
231
+
232
+ def _new(self) -> "RankingSurrogate":
233
+ return RankingSurrogate(self.alpha, self.random_state, self.max_pairs)
234
+
235
+ def _fit_raw(self, x: np.ndarray, y: np.ndarray, sw: np.ndarray | None) -> None:
236
+ n = x.shape[0]
237
+ rng = np.random.default_rng(self.random_state)
238
+ # ordered index pairs with distinct y; sample if the full set is too large
239
+ if n * (n - 1) <= 2 * self.max_pairs:
240
+ ii, jj = np.meshgrid(np.arange(n), np.arange(n), indexing="ij")
241
+ mask = ii != jj
242
+ pi, pj = ii[mask], jj[mask]
243
+ else:
244
+ pi = rng.integers(0, n, self.max_pairs)
245
+ pj = rng.integers(0, n, self.max_pairs)
246
+ keep = pi != pj
247
+ pi, pj = pi[keep], pj[keep]
248
+ diff_y = y[pi] - y[pj]
249
+ good = np.abs(diff_y) > _FLOOR
250
+ pi, pj, diff_y = pi[good], pj[good], diff_y[good]
251
+ if pi.size < 2: # degenerate (all y equal): rank-neutral fallback
252
+ self._w = np.zeros(x.shape[1], dtype=float)
253
+ self._a, self._b = float(np.mean(y)), 0.0
254
+ self._resid_std = float(np.std(y)) or _FLOOR
255
+ return
256
+ d = x[pi] - x[pj]
257
+ lab = (diff_y > 0).astype(int)
258
+ clf = LogisticRegression(fit_intercept=False, C=1.0 / max(self.alpha, _FLOOR), max_iter=1000)
259
+ clf.fit(d, lab)
260
+ self._w = np.asarray(clf.coef_[0], dtype=float)
261
+ s = x @ self._w
262
+ if float(np.std(s)) < _FLOOR:
263
+ self._a, self._b = float(np.mean(y)), 0.0
264
+ else:
265
+ self._b, self._a = (float(v) for v in np.polyfit(s, y, 1))
266
+ self._resid_std = float(np.std(y - (self._a + self._b * s))) or _FLOOR
267
+
268
+ def _mean(self, x: np.ndarray) -> np.ndarray:
269
+ return self._a + self._b * (x @ self._w)
270
+
271
+ def _raw_std(self, x: np.ndarray) -> np.ndarray:
272
+ return np.full(x.shape[0], max(self._resid_std, _FLOOR), dtype=float)
273
+
274
+
275
+ class GlobalEpistasisSurrogate(_BaseSurrogate):
276
+ """Additive latent (ridge) passed through a learned MONOTONIC link (global epistasis).
277
+
278
+ Models nonspecific / global epistasis: an additive latent trait fed through a learned monotonic
279
+ nonlinearity (Otwinowski 2018; Sailer & Harms; MoCHI). Recovers calibrated magnitude where naive
280
+ additive prediction collapses at high mutation order (ShellDE D24 on avGFP: the monotonic link
281
+ lifts R2 from negative back to 0.78 / 0.63 at orders 5 / 6). The measured value is sharper
282
+ calibrated uncertainty (D30: 2x tighter intervals with both coverages at or above nominal, not
283
+ equal to each other, on synthetic saturating-link data), not a different ranking objective.
284
+
285
+ NOT rank-preserving in the sense of "same plate". The link never INVERTS a pair (measured: zero
286
+ strict inversions of the mean against the ridge order over 30 seeds x 124750 pool pairs), but
287
+ `IsotonicRegression(out_of_bounds="clip")` in `_fit_raw` is only WEAKLY monotonic, so its flat
288
+ segments collapse distinct candidates onto identical values (about 180 distinct predictions for
289
+ a 500-candidate pool). `_ScoreAcquisition.select` in `acquisition.py` breaks those ties by
290
+ `np.argsort` index order, so a tie block straddling the top-k cut (mean size 25.0, range 1 to
291
+ 74) emits a DIFFERENT plate: over 30 seeds at n=95, d=10, k=20 the plate matched the ridge
292
+ plate in only 7 of 30 seeds, mean well overlap 0.785 (range 0.25 to 1.00), and the plate ORDER
293
+ matched in 0 of 30.
294
+
295
+ That number is acquisition-independent FOR THIS SURROGATE AND `RidgeSurrogate` ONLY: both
296
+ return a constant per-point std (`_raw_std` is `np.full(...)` in each, measured constant in 30
297
+ of 30 seeds), so `UCB` reduces to `Greedy` and the result is identical at the `recommend`
298
+ default beta=1.0 and the `campaign` / `round` default beta=0.0. Do NOT generalize it:
299
+ `EnsembleSurrogate`, which is the default `--model-class` on all three commands, is
300
+ heteroscedastic (relative std spread 0.2702 mean, range 0.1767 to 0.4759), and its `Greedy` and
301
+ `UCB(beta=1.0)` plates agree as a SET in only 20 of 30 seeds and never in order.
302
+
303
+ Synthetic saturating-link data on DENSE standard-normal features (d=10), not the one-hot
304
+ combinatorial encoding `FeatureMatrix.encode` builds for the CLI from `default_blocks`, so the
305
+ tie mechanism transfers but these magnitudes are not a measurement on the shipped design
306
+ matrix. `scripts/run_global_epistasis_rank_check.py`, raw in
307
+ `artifacts/global_epistasis_rank_check.json`. Opt-in; not the default.
308
+ """
309
+
310
+ def _new(self) -> "GlobalEpistasisSurrogate":
311
+ return GlobalEpistasisSurrogate(self.alpha, self.random_state)
312
+
313
+ def _fit_raw(self, x: np.ndarray, y: np.ndarray, sw: np.ndarray | None) -> None:
314
+ self._latent = Ridge(alpha=self.alpha).fit(x, y, sample_weight=sw)
315
+ z = self._latent.predict(x)
316
+ self._link = IsotonicRegression(out_of_bounds="clip").fit(z, y)
317
+ self._resid_std = float(np.std(y - self._link.predict(z))) or _FLOOR
318
+
319
+ def _mean(self, x: np.ndarray) -> np.ndarray:
320
+ return np.asarray(self._link.predict(self._latent.predict(x)), dtype=float)
321
+
322
+ def _raw_std(self, x: np.ndarray) -> np.ndarray:
323
+ return np.full(x.shape[0], max(self._resid_std, _FLOOR), dtype=float)