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/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)
|