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/naturalness.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Zero-shot naturalness providers (PLM likelihood) + a test/compose stub.
|
|
2
|
+
|
|
3
|
+
Naturalness is FolDE's actual lever (per the 6-paper tool-development synthesis): a
|
|
4
|
+
PLM zero-shot score, distilled into the supervised readout as a WARM START, is what
|
|
5
|
+
prevents the round-2 collapse of pure activity-only few-shot AL. Here a provider maps
|
|
6
|
+
full-length sequences to a per-sequence scalar; it is injected as a feature
|
|
7
|
+
(``features.NaturalnessBlock``) and/or used as a discovery scorer (``zero_shot``).
|
|
8
|
+
|
|
9
|
+
``FunctionNaturalness`` wraps any ``str -> float`` callable so the plumbing is
|
|
10
|
+
testable without a heavy PLM SDK. The ESM providers are import-guarded.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable, Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from shellde.embeddings.provider import EmbeddingCache, _key
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FunctionNaturalness:
|
|
23
|
+
"""A NaturalnessProvider backed by a plain ``str -> float`` function (cached)."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self, fn: Callable[[str], float], *, model_id: str = "fn", cache: EmbeddingCache | None = None
|
|
27
|
+
) -> None:
|
|
28
|
+
self._fn = fn
|
|
29
|
+
self.model_id = model_id
|
|
30
|
+
self._cache = cache if cache is not None else EmbeddingCache()
|
|
31
|
+
self.calls = 0
|
|
32
|
+
|
|
33
|
+
def score(self, sequences: Sequence[str]) -> np.ndarray:
|
|
34
|
+
out: list[float] = []
|
|
35
|
+
for s in sequences:
|
|
36
|
+
k = _key(self.model_id, s)
|
|
37
|
+
v = self._cache.get(k)
|
|
38
|
+
if v is None:
|
|
39
|
+
self.calls += 1
|
|
40
|
+
v = np.asarray([float(self._fn(s))], dtype=float)
|
|
41
|
+
self._cache.put(k, v)
|
|
42
|
+
out.append(float(v[0]))
|
|
43
|
+
return np.asarray(out, dtype=float)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ESM2Naturalness:
|
|
47
|
+
"""ESM-2 pseudo-log-likelihood naturalness (mean per-residue log-prob), import-guarded."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self, model: str = "facebook/esm2_t30_150M_UR50D", *, device: str | None = None,
|
|
51
|
+
cache: EmbeddingCache | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.model = model
|
|
54
|
+
self.device = device
|
|
55
|
+
self._cache = cache if cache is not None else EmbeddingCache()
|
|
56
|
+
self._tok: Any = None
|
|
57
|
+
self._mdl: Any = None
|
|
58
|
+
self._torch: Any = None
|
|
59
|
+
|
|
60
|
+
def _load(self) -> None: # pragma: no cover - exercised only with the SDK
|
|
61
|
+
try:
|
|
62
|
+
import torch
|
|
63
|
+
from transformers import AutoModelForMaskedLM, AutoTokenizer # type: ignore[import-not-found, import-untyped]
|
|
64
|
+
except ImportError as exc:
|
|
65
|
+
raise ImportError("ESM2Naturalness requires 'transformers' and 'torch'.") from exc
|
|
66
|
+
self._torch = torch
|
|
67
|
+
self._tok = AutoTokenizer.from_pretrained(self.model)
|
|
68
|
+
self._mdl = AutoModelForMaskedLM.from_pretrained(self.model).eval()
|
|
69
|
+
|
|
70
|
+
def score(self, sequences: Sequence[str]) -> np.ndarray:
|
|
71
|
+
keys = [_key(self.model, s) for s in sequences]
|
|
72
|
+
misses = [(i, s) for i, s in enumerate(sequences) if self._cache.get(keys[i]) is None]
|
|
73
|
+
if misses: # pragma: no cover - exercised only with the SDK
|
|
74
|
+
if self._mdl is None:
|
|
75
|
+
self._load()
|
|
76
|
+
assert self._torch is not None and self._tok is not None and self._mdl is not None
|
|
77
|
+
with self._torch.no_grad():
|
|
78
|
+
for i, s in misses:
|
|
79
|
+
enc = self._tok(s, return_tensors="pt")
|
|
80
|
+
logits = self._mdl(**enc).logits[0]
|
|
81
|
+
logp = self._torch.log_softmax(logits, dim=-1)
|
|
82
|
+
ids = enc["input_ids"][0]
|
|
83
|
+
tok_lp = logp[range(len(ids)), ids][1:-1] # drop BOS/EOS
|
|
84
|
+
self._cache.put(keys[i], np.asarray([float(tok_lp.mean())], dtype=float))
|
|
85
|
+
out = []
|
|
86
|
+
for k in keys:
|
|
87
|
+
v = self._cache.get(k)
|
|
88
|
+
assert v is not None
|
|
89
|
+
out.append(float(v[0]))
|
|
90
|
+
return np.asarray(out, dtype=float)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ESMCNaturalness:
|
|
94
|
+
"""ESM-C naturalness via the EvolutionaryScale SDK (mean per-residue log-prob), import-guarded."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self, model_name: str = "esmc_300m", *, device: str | None = None,
|
|
98
|
+
cache: EmbeddingCache | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
self.model_name = model_name
|
|
101
|
+
self.device = device
|
|
102
|
+
self._cache = cache if cache is not None else EmbeddingCache()
|
|
103
|
+
self._model: Any = None
|
|
104
|
+
self._torch: Any = None
|
|
105
|
+
|
|
106
|
+
def _load(self) -> None: # pragma: no cover - exercised only with the SDK
|
|
107
|
+
try:
|
|
108
|
+
import torch
|
|
109
|
+
from esm.models.esmc import ESMC # type: ignore[import-not-found, import-untyped]
|
|
110
|
+
except ImportError as exc:
|
|
111
|
+
raise ImportError("ESMCNaturalness requires the EvolutionaryScale 'esm' SDK.") from exc
|
|
112
|
+
self._torch = torch
|
|
113
|
+
dev = self.device or ("cuda" if torch.cuda.is_available() else "cpu")
|
|
114
|
+
self._model = ESMC.from_pretrained(self.model_name).to(dev).eval()
|
|
115
|
+
|
|
116
|
+
def score(self, sequences: Sequence[str]) -> np.ndarray:
|
|
117
|
+
keys = [_key(self.model_name, s) for s in sequences]
|
|
118
|
+
misses = [(i, s) for i, s in enumerate(sequences) if self._cache.get(keys[i]) is None]
|
|
119
|
+
if misses: # pragma: no cover - exercised only with the SDK
|
|
120
|
+
if self._model is None:
|
|
121
|
+
self._load()
|
|
122
|
+
from esm.sdk.api import ESMProtein, LogitsConfig # type: ignore[import-not-found, import-untyped]
|
|
123
|
+
|
|
124
|
+
assert self._torch is not None and self._model is not None
|
|
125
|
+
with self._torch.no_grad():
|
|
126
|
+
for i, s in misses:
|
|
127
|
+
tensor = self._model.encode(ESMProtein(sequence=s))
|
|
128
|
+
res = self._model.logits(tensor, LogitsConfig(sequence=True))
|
|
129
|
+
seq_logits = res.logits.sequence
|
|
130
|
+
if seq_logits.dim() == 3:
|
|
131
|
+
seq_logits = seq_logits[0]
|
|
132
|
+
logp = self._torch.log_softmax(seq_logits.float(), dim=-1) # (L+2, V)
|
|
133
|
+
ids = tensor.sequence.long() # token ids incl. BOS/EOS
|
|
134
|
+
pos = self._torch.arange(1, ids.shape[0] - 1) # residues only
|
|
135
|
+
tok_lp = logp[pos, ids[pos]]
|
|
136
|
+
self._cache.put(keys[i], np.asarray([float(tok_lp.mean())], dtype=float))
|
|
137
|
+
out = []
|
|
138
|
+
for k in keys:
|
|
139
|
+
v = self._cache.get(k)
|
|
140
|
+
assert v is not None
|
|
141
|
+
out.append(float(v[0]))
|
|
142
|
+
return np.asarray(out, dtype=float)
|
shellde/oracle.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Oracles: the function-aligned information source (one Protocol, two impls).
|
|
2
|
+
|
|
3
|
+
- ``DMSLookupOracle``: ground-truth lookup over a measured (variant -> fitness)
|
|
4
|
+
table; ``universe`` is the measurable pool (real-DMS benchmarks).
|
|
5
|
+
- ``SyntheticOracle``: additive backbone + ``alpha`` * pairwise epistasis over a
|
|
6
|
+
design space, with the global optimum knowable from the enumerated ``universe``
|
|
7
|
+
(tunable-epistasis benchmarks). alpha=0 is purely additive.
|
|
8
|
+
|
|
9
|
+
Both satisfy ``protocols.Oracle`` and expose an enumerable ``universe`` so the
|
|
10
|
+
active-learning loop is uniform across them.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import itertools
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from shellde.design_space import DesignSpace, to_assignment
|
|
20
|
+
|
|
21
|
+
_UNIVERSE_CAP = 200_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DMSLookupOracle:
|
|
25
|
+
"""Ground-truth fitness by lookup over a measured table."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, table: Mapping[str, float]) -> None:
|
|
28
|
+
self._table = {str(k): float(v) for k, v in table.items()}
|
|
29
|
+
self._universe = list(self._table)
|
|
30
|
+
|
|
31
|
+
def evaluate(self, variants: Sequence[str]) -> np.ndarray:
|
|
32
|
+
try:
|
|
33
|
+
return np.array([self._table[str(v)] for v in variants], dtype=float)
|
|
34
|
+
except KeyError as exc:
|
|
35
|
+
raise KeyError(f"variant {exc} not in the DMS lookup table") from exc
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def universe(self) -> list[str] | None:
|
|
39
|
+
return list(self._universe)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SyntheticOracle:
|
|
43
|
+
"""Additive + alpha*pairwise-epistatic landscape over a design space.
|
|
44
|
+
|
|
45
|
+
Fields h and couplings J are drawn once from ``seed``; ``alpha`` then scales the
|
|
46
|
+
pairwise term, so sweeping alpha walks from the additive null into progressively
|
|
47
|
+
stronger epistasis with the same underlying landscape.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, space: DesignSpace, *, alpha: float = 0.0, seed: int = 0) -> None:
|
|
51
|
+
self.space = space
|
|
52
|
+
self.alpha = alpha
|
|
53
|
+
n, q = space.n_positions, space.q
|
|
54
|
+
rng = np.random.default_rng(seed)
|
|
55
|
+
self._h = rng.standard_normal((n, q))
|
|
56
|
+
self._j = rng.standard_normal((n, n, q, q))
|
|
57
|
+
self._aa = {a: i for i, a in enumerate(space.alphabet)}
|
|
58
|
+
self._universe: list[str] | None = None
|
|
59
|
+
|
|
60
|
+
def evaluate(self, variants: Sequence[str]) -> np.ndarray:
|
|
61
|
+
space = self.space
|
|
62
|
+
n = space.n_positions
|
|
63
|
+
out = np.zeros(len(variants), dtype=float)
|
|
64
|
+
for k, v in enumerate(variants):
|
|
65
|
+
a = to_assignment(v, space)
|
|
66
|
+
idx = [self._aa[a[p]] for p in space.positions]
|
|
67
|
+
f = sum(self._h[i, idx[i]] for i in range(n))
|
|
68
|
+
if self.alpha != 0.0:
|
|
69
|
+
for i in range(n):
|
|
70
|
+
for j in range(i + 1, n):
|
|
71
|
+
f += self.alpha * self._j[i, j, idx[i], idx[j]]
|
|
72
|
+
out[k] = f
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def universe(self) -> list[str] | None:
|
|
77
|
+
if self._universe is None:
|
|
78
|
+
space = self.space
|
|
79
|
+
total = space.q**space.n_positions
|
|
80
|
+
if total > _UNIVERSE_CAP:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"SyntheticOracle universe {total} exceeds cap {_UNIVERSE_CAP}; "
|
|
83
|
+
"use a smaller design space for enumerated benchmarks"
|
|
84
|
+
)
|
|
85
|
+
self._universe = ["".join(c) for c in itertools.product(space.alphabet, repeat=space.n_positions)]
|
|
86
|
+
return list(self._universe)
|
|
87
|
+
|
|
88
|
+
def optimum(self) -> tuple[str, float]:
|
|
89
|
+
"""The true global optimum over the enumerated universe (for regret)."""
|
|
90
|
+
uni = self.universe
|
|
91
|
+
assert uni is not None
|
|
92
|
+
f = self.evaluate(uni)
|
|
93
|
+
i = int(np.argmax(f))
|
|
94
|
+
return uni[i], float(f[i])
|
shellde/plm.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""First-class PLM (ESM-C) front end for the real-data recommend path.
|
|
2
|
+
|
|
3
|
+
At the N=95 constraint, explicit pairwise epistasis is unestimable (one pairwise
|
|
4
|
+
term over q=20 is 19^2=361 params > 95 samples), so the lever that actually wins is
|
|
5
|
+
a protein language model: ESM-C embeddings carry IMPLICIT epistasis from pretraining
|
|
6
|
+
(attention makes them non-additive) and N=95 only fits a cheap readout on top.
|
|
7
|
+
|
|
8
|
+
ESM-C CPU inference is slow and unbatched, so scoring a 200k-candidate pool with it
|
|
9
|
+
is intractable AND wasteful (embeddings dilute on dense combinatorial libraries).
|
|
10
|
+
The honest, correct integration is a TWO-STAGE re-rank: a cheap one-hot surrogate
|
|
11
|
+
shortlists high-value candidates, then a PLM surrogate fit on the measured
|
|
12
|
+
embeddings re-scores ONLY the shortlist. The PLM block earns its place on the front
|
|
13
|
+
end, not on the bulk scoring. Embeds exactly ``len(measured) + top_k`` sequences.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable, Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from shellde.candidates import CandidatePool
|
|
23
|
+
from shellde.design_space import DesignSpace
|
|
24
|
+
from shellde.embeddings.provider import EmbeddingCache
|
|
25
|
+
from shellde.features import (
|
|
26
|
+
FeatureBlock,
|
|
27
|
+
FeatureMatrix,
|
|
28
|
+
NaturalnessBlock,
|
|
29
|
+
OneHotBlock,
|
|
30
|
+
PLMEmbeddingBlock,
|
|
31
|
+
)
|
|
32
|
+
from shellde.protocols import EmbeddingProvider, NaturalnessProvider, Surrogate
|
|
33
|
+
from shellde.surrogate import RidgeSurrogate
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_fasta(path: str | Path) -> str:
|
|
37
|
+
"""Return the first record's sequence from a FASTA file (whitespace stripped)."""
|
|
38
|
+
seq: list[str] = []
|
|
39
|
+
started = False
|
|
40
|
+
for line in Path(path).read_text().splitlines():
|
|
41
|
+
if line.startswith(">"):
|
|
42
|
+
if started:
|
|
43
|
+
break
|
|
44
|
+
started = True
|
|
45
|
+
continue
|
|
46
|
+
seq.append(line.strip())
|
|
47
|
+
out = "".join(seq).replace(" ", "")
|
|
48
|
+
if not out:
|
|
49
|
+
raise ValueError(f"{path}: no sequence found in FASTA")
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_provider(
|
|
54
|
+
kind: str, *, model: str = "", cache: EmbeddingCache | None = None
|
|
55
|
+
) -> EmbeddingProvider:
|
|
56
|
+
"""Construct an import-guarded PLM provider ('esmc' | 'esm2').
|
|
57
|
+
|
|
58
|
+
The heavy SDK import is lazy (inside the provider), so an unavailable SDK only
|
|
59
|
+
raises when ``embed`` is first called, with a clear install message.
|
|
60
|
+
"""
|
|
61
|
+
if kind == "esmc":
|
|
62
|
+
from shellde.embeddings.esmc import ESMCProvider
|
|
63
|
+
|
|
64
|
+
return ESMCProvider(model or "esmc_300m", cache=cache)
|
|
65
|
+
if kind == "esm2":
|
|
66
|
+
from shellde.embeddings.esm2 import ESM2Provider
|
|
67
|
+
|
|
68
|
+
return ESM2Provider(model or "facebook/esm2_t30_150M_UR50D", cache=cache)
|
|
69
|
+
raise ValueError(f"unknown PLM kind {kind!r} (expected 'esmc' or 'esm2')")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_naturalness(
|
|
73
|
+
kind: str, *, model: str = "", cache: EmbeddingCache | None = None
|
|
74
|
+
) -> NaturalnessProvider:
|
|
75
|
+
"""Construct an import-guarded zero-shot naturalness provider ('esmc' | 'esm2')."""
|
|
76
|
+
if kind == "esmc":
|
|
77
|
+
from shellde.naturalness import ESMCNaturalness
|
|
78
|
+
|
|
79
|
+
return ESMCNaturalness(model or "esmc_300m", cache=cache)
|
|
80
|
+
if kind == "esm2":
|
|
81
|
+
from shellde.naturalness import ESM2Naturalness
|
|
82
|
+
|
|
83
|
+
return ESM2Naturalness(model or "facebook/esm2_t30_150M_UR50D", cache=cache)
|
|
84
|
+
raise ValueError(f"unknown naturalness kind {kind!r} (expected 'esmc' or 'esm2')")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def plm_rerank(
|
|
88
|
+
pool: CandidatePool,
|
|
89
|
+
space: DesignSpace,
|
|
90
|
+
measured_variants: Sequence[str],
|
|
91
|
+
fitness: np.ndarray,
|
|
92
|
+
*,
|
|
93
|
+
provider: EmbeddingProvider | None = None,
|
|
94
|
+
naturalness: "NaturalnessProvider | None" = None,
|
|
95
|
+
wt_sequence: str,
|
|
96
|
+
top_k: int,
|
|
97
|
+
surrogate_factory: Callable[[], Surrogate] = lambda: RidgeSurrogate(random_state=0),
|
|
98
|
+
use_onehot: bool = True,
|
|
99
|
+
plm_pooling: str = "mean",
|
|
100
|
+
) -> CandidatePool:
|
|
101
|
+
"""Stage-2: refit a PLM/naturalness surrogate on measured data, re-score the shortlist.
|
|
102
|
+
|
|
103
|
+
Adds the (cost-bearing) PLM embedding and/or naturalness blocks only on the bounded
|
|
104
|
+
top-``top_k`` shortlist, not the 200k-candidate base scoring. At least one of
|
|
105
|
+
``provider`` (embedding) or ``naturalness`` must be given. ``stats['wt_mean']`` is
|
|
106
|
+
the refit surrogate's WT prediction (for risk flagging). Embeds/scores only
|
|
107
|
+
``len(measured) + top_k`` sequences, so CPU PLM inference stays tractable.
|
|
108
|
+
"""
|
|
109
|
+
if provider is None and naturalness is None:
|
|
110
|
+
raise ValueError("plm_rerank needs at least one of provider (embedding) or naturalness")
|
|
111
|
+
if not pool.variants:
|
|
112
|
+
return pool
|
|
113
|
+
k = max(1, min(top_k, len(pool.variants)))
|
|
114
|
+
shortlist = list(pool.variants[:k])
|
|
115
|
+
mut_count = list(pool.mut_count[:k])
|
|
116
|
+
|
|
117
|
+
blocks: list[FeatureBlock] = []
|
|
118
|
+
if use_onehot:
|
|
119
|
+
blocks.append(OneHotBlock(space))
|
|
120
|
+
dim = 0
|
|
121
|
+
if provider is not None:
|
|
122
|
+
blocks.append(PLMEmbeddingBlock(space, provider, wt_sequence, pooling=plm_pooling))
|
|
123
|
+
dim += int(provider.dim)
|
|
124
|
+
if naturalness is not None:
|
|
125
|
+
blocks.append(NaturalnessBlock(space, naturalness, wt_sequence))
|
|
126
|
+
dim += 1
|
|
127
|
+
matrix = FeatureMatrix(space, blocks)
|
|
128
|
+
|
|
129
|
+
measured = [str(v) for v in measured_variants]
|
|
130
|
+
sur = surrogate_factory().fit(matrix.encode(measured), np.asarray(fitness, dtype=float))
|
|
131
|
+
pred = sur.predict(matrix.encode(shortlist))
|
|
132
|
+
wt_mean = float(sur.predict(matrix.encode([space.wt()])).mean[0])
|
|
133
|
+
|
|
134
|
+
stats = dict(pool.stats)
|
|
135
|
+
stats.update({
|
|
136
|
+
"plm_reranked": k, "plm_dim": dim, "wt_mean": wt_mean,
|
|
137
|
+
"plm_embedding": provider is not None, "plm_naturalness": naturalness is not None,
|
|
138
|
+
})
|
|
139
|
+
return CandidatePool(
|
|
140
|
+
variants=shortlist,
|
|
141
|
+
pred=pred,
|
|
142
|
+
mut_count=mut_count,
|
|
143
|
+
pool_positions=pool.pool_positions,
|
|
144
|
+
stats=stats,
|
|
145
|
+
)
|
shellde/prereg.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Pre-registration: a locked protocol + deterministic hash stamped into outputs.
|
|
2
|
+
|
|
3
|
+
Locking the protocol once (metric, budget, seeds, statistics, decision rule) before
|
|
4
|
+
running is the falsifiability guard: results are interpreted against a fixed bar, not
|
|
5
|
+
a metric/seed/universe chosen after seeing the numbers.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
PROTOCOL: dict[str, Any] = {
|
|
14
|
+
"version": 1,
|
|
15
|
+
"primary_metric": "normalised_simple_regret", # (true_max - best_found)/(true_max - true_min)
|
|
16
|
+
"secondary_metrics": ["best_found", "heldout_spearman"],
|
|
17
|
+
"n_seeds": 20,
|
|
18
|
+
"budget": {"n_init": 95, "batch_size": 95, "n_rounds": 3},
|
|
19
|
+
"alpha_spectrum": [0.0, 0.5, 1.0, 1.5, 2.0],
|
|
20
|
+
"statistics": {"alpha": 0.05, "correction": "benjamini_hochberg", "paired": True},
|
|
21
|
+
"methods": ["ours", "additive_greedy", "rf_greedy", "ucb_bo"],
|
|
22
|
+
"decision_rule": (
|
|
23
|
+
"ours must never be FDR-significantly worse than a baseline on any landscape; "
|
|
24
|
+
"superiority is claimed only on an FDR-significant paired regret reduction with "
|
|
25
|
+
"CI lower bound > 0."
|
|
26
|
+
),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def prereg_hash(protocol: dict[str, Any] | None = None) -> str:
|
|
31
|
+
"""Deterministic SHA-256 over the canonical protocol JSON (sorted keys)."""
|
|
32
|
+
blob = json.dumps(protocol if protocol is not None else PROTOCOL, sort_keys=True, separators=(",", ":"))
|
|
33
|
+
return hashlib.sha256(blob.encode()).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def stamp(payload: dict[str, Any]) -> dict[str, Any]:
|
|
37
|
+
"""Return a copy of ``payload`` stamped with the protocol + its hash."""
|
|
38
|
+
out = dict(payload)
|
|
39
|
+
out["protocol"] = PROTOCOL
|
|
40
|
+
out["prereg_hash"] = prereg_hash()
|
|
41
|
+
return out
|
shellde/protocols.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Structural typing contracts (typing.Protocol) for the pluggable pieces.
|
|
2
|
+
|
|
3
|
+
These pin the seams that make ShellDE one coherent pipeline instead of two
|
|
4
|
+
lineages: a single Surrogate contract (calibrated std), a single Acquisition
|
|
5
|
+
contract, a single Oracle contract, and an EmbeddingProvider for PLM features.
|
|
6
|
+
Concrete implementations live in their own modules and need no registration.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from shellde.design_space import DesignSpace
|
|
16
|
+
from shellde.types import Prediction
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class Surrogate(Protocol):
|
|
21
|
+
"""A fitted fitness model over a feature matrix; returns calibrated uncertainty."""
|
|
22
|
+
|
|
23
|
+
def fit(
|
|
24
|
+
self, x: np.ndarray, y: np.ndarray, sample_weight: np.ndarray | None = None
|
|
25
|
+
) -> "Surrogate":
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def predict(self, x: np.ndarray) -> Prediction:
|
|
29
|
+
"""Return mean + CALIBRATED predictive std (1 sigma)."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def main_effects(self) -> np.ndarray | None:
|
|
33
|
+
"""Additive per-column coefficients for the position-selection prior (or None)."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class Acquisition(Protocol):
|
|
39
|
+
"""Selects which candidates to measure next, given surrogate predictions."""
|
|
40
|
+
|
|
41
|
+
def select(
|
|
42
|
+
self,
|
|
43
|
+
pred: Prediction,
|
|
44
|
+
k: int,
|
|
45
|
+
*,
|
|
46
|
+
candidates: Sequence[str],
|
|
47
|
+
space: DesignSpace,
|
|
48
|
+
) -> list[int]:
|
|
49
|
+
"""Return indices into ``candidates`` for the chosen batch of size <= k."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@runtime_checkable
|
|
54
|
+
class Oracle(Protocol):
|
|
55
|
+
"""Maps variants to (measured) fitness; the only function-aligned information source."""
|
|
56
|
+
|
|
57
|
+
def evaluate(self, variants: Sequence[str]) -> np.ndarray:
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def universe(self) -> list[str] | None:
|
|
62
|
+
"""The measurable variant pool for lookup oracles, else None (enumerable space)."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@runtime_checkable
|
|
67
|
+
class EmbeddingProvider(Protocol):
|
|
68
|
+
"""Produces per-sequence embeddings (PLM); implementations cache on disk."""
|
|
69
|
+
|
|
70
|
+
dim: int
|
|
71
|
+
|
|
72
|
+
def embed(self, sequences: Sequence[str]) -> np.ndarray:
|
|
73
|
+
"""Return an (len(sequences), dim) float array of mean-pooled embeddings."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@runtime_checkable
|
|
78
|
+
class NaturalnessProvider(Protocol):
|
|
79
|
+
"""Produces a per-sequence zero-shot naturalness score (PLM likelihood-style).
|
|
80
|
+
|
|
81
|
+
Higher means more 'natural' under the model's pretraining prior. Used as a
|
|
82
|
+
warm-start signal (a feature and/or discovery scorer); implementations cache.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def score(self, sequences: Sequence[str]) -> np.ndarray:
|
|
86
|
+
"""Return a (len(sequences),) float array of per-sequence naturalness."""
|
|
87
|
+
...
|
shellde/rank.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Rank a candidate pool into per-mutation-count tiers + a global ranking.
|
|
2
|
+
|
|
3
|
+
Each ranked entry carries evidence: predicted mean/std, acquisition value, the
|
|
4
|
+
mutation tokens, the baseline gain over WT, and a CALIBRATED risk flag. The risk
|
|
5
|
+
flag is the opportunity-cost framing made concrete: a recommendation is "confident"
|
|
6
|
+
when its predicted gain over WT exceeds one calibrated standard deviation, and
|
|
7
|
+
"speculative" (the predicted gain is within the calibrated sigma, so the ranking of
|
|
8
|
+
that candidate is not resolved by the current data) otherwise. It is a gain-vs-sigma
|
|
9
|
+
comparison only; it does not detect or classify epistasis.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from shellde.acquisition import _ScoreAcquisition
|
|
18
|
+
from shellde.candidates import CandidatePool
|
|
19
|
+
from shellde.design_space import DesignSpace, mutations_of
|
|
20
|
+
from shellde.types import Candidate
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RankingResult:
|
|
25
|
+
"""Per-mutation-count tiers + a global (count-mixing) exploratory ranking."""
|
|
26
|
+
|
|
27
|
+
per_count: dict[int, list[Candidate]] = field(default_factory=dict)
|
|
28
|
+
global_ranking: list[Candidate] = field(default_factory=list)
|
|
29
|
+
global_is_exploratory: bool = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _risk_flag(gain: float, sigma: float) -> str:
|
|
33
|
+
return "confident" if gain >= max(sigma, 0.0) else "speculative"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def rank_candidates(
|
|
37
|
+
pool: CandidatePool,
|
|
38
|
+
space: DesignSpace,
|
|
39
|
+
*,
|
|
40
|
+
acquisition: _ScoreAcquisition,
|
|
41
|
+
per_count_k: int = 10,
|
|
42
|
+
global_k: int = 20,
|
|
43
|
+
wt_mean: float = 0.0,
|
|
44
|
+
conformal_q: float | None = None,
|
|
45
|
+
max_measured_nmut: int | None = None,
|
|
46
|
+
) -> RankingResult:
|
|
47
|
+
"""Build per-count tiers + a global ranking from a scored candidate pool.
|
|
48
|
+
|
|
49
|
+
When ``conformal_q`` is given (the normalized cross-conformal quantile from the
|
|
50
|
+
MEASURED set), each candidate gets an ADDITIVE conformal abstain gate on its
|
|
51
|
+
WT-relative gain: ``conformal_lo = gain - conformal_q * sigma`` and ``abstain`` is
|
|
52
|
+
True when that lower end is <= 0 (cannot conclude the candidate beats WT at this
|
|
53
|
+
conformal level). ``conformal_scope`` is ``"conformal"`` when the candidate is
|
|
54
|
+
in-distribution (``n_mut <= max_measured_nmut``) and ``"conformal_oob"`` when it is a
|
|
55
|
+
higher-order combination out of the calibration distribution. The sigma-based
|
|
56
|
+
``risk`` flag is kept exactly as-is. When ``conformal_q`` is None the evidence dict is
|
|
57
|
+
byte-identical to the no-conformal behaviour (no new keys).
|
|
58
|
+
"""
|
|
59
|
+
n = len(pool.variants)
|
|
60
|
+
if n == 0:
|
|
61
|
+
return RankingResult({}, [], True)
|
|
62
|
+
scores = np.asarray(acquisition.score(pool.pred), dtype=float)
|
|
63
|
+
mean = np.asarray(pool.pred.mean, dtype=float)
|
|
64
|
+
std = np.asarray(pool.pred.std, dtype=float)
|
|
65
|
+
|
|
66
|
+
entries: list[Candidate] = []
|
|
67
|
+
for i, v in enumerate(pool.variants):
|
|
68
|
+
gain = float(mean[i] - wt_mean)
|
|
69
|
+
sigma = float(std[i])
|
|
70
|
+
n_mut = int(pool.mut_count[i])
|
|
71
|
+
evidence: dict = {"baseline_gain": gain, "risk": _risk_flag(gain, sigma)}
|
|
72
|
+
if conformal_q is not None:
|
|
73
|
+
conformal_lo = gain - conformal_q * sigma
|
|
74
|
+
evidence["conformal_lo"] = conformal_lo
|
|
75
|
+
evidence["abstain"] = bool(conformal_lo <= 0.0)
|
|
76
|
+
evidence["conformal_scope"] = (
|
|
77
|
+
"conformal_oob"
|
|
78
|
+
if (max_measured_nmut is not None and n_mut > max_measured_nmut)
|
|
79
|
+
else "conformal"
|
|
80
|
+
)
|
|
81
|
+
entries.append(
|
|
82
|
+
Candidate(
|
|
83
|
+
variant=v,
|
|
84
|
+
n_mut=n_mut,
|
|
85
|
+
mu=float(mean[i]),
|
|
86
|
+
sigma=sigma,
|
|
87
|
+
acq=float(scores[i]),
|
|
88
|
+
mutations=mutations_of(v, space),
|
|
89
|
+
evidence=evidence,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
by_acq = sorted(entries, key=lambda c: c.acq, reverse=True)
|
|
94
|
+
per_count: dict[int, list[Candidate]] = {}
|
|
95
|
+
for c in by_acq:
|
|
96
|
+
per_count.setdefault(c.n_mut, [])
|
|
97
|
+
if len(per_count[c.n_mut]) < per_count_k:
|
|
98
|
+
per_count[c.n_mut].append(c)
|
|
99
|
+
return RankingResult(
|
|
100
|
+
per_count=dict(sorted(per_count.items())),
|
|
101
|
+
global_ranking=by_acq[:global_k],
|
|
102
|
+
global_is_exploratory=True,
|
|
103
|
+
)
|