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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""ESM-2 (Meta) embedding provider via transformers (import-guarded, cached).
|
|
2
|
+
|
|
3
|
+
Provided as an alternative/baseline PLM front end. Mean-pools the last hidden state
|
|
4
|
+
over residues (excluding BOS/EOS). transformers + torch are optional; the import is
|
|
5
|
+
lazy and raises a clear error when absent.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from shellde.embeddings.provider import EmbeddingCache, _key
|
|
15
|
+
|
|
16
|
+
_WIDTH = {
|
|
17
|
+
"facebook/esm2_t6_8M_UR50D": 320,
|
|
18
|
+
"facebook/esm2_t12_35M_UR50D": 480,
|
|
19
|
+
"facebook/esm2_t30_150M_UR50D": 640,
|
|
20
|
+
"facebook/esm2_t33_650M_UR50D": 1280,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ESM2Provider:
|
|
25
|
+
"""Mean-pooled ESM-2 embeddings; satisfies protocols.EmbeddingProvider."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self, model: str = "facebook/esm2_t30_150M_UR50D", *, dim: int | None = None,
|
|
29
|
+
device: str | None = None, cache: EmbeddingCache | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.model = model
|
|
32
|
+
self.dim = dim if dim is not None else _WIDTH.get(model, 640)
|
|
33
|
+
self.device = device
|
|
34
|
+
self._cache = cache if cache is not None else EmbeddingCache()
|
|
35
|
+
self._tok: Any = None
|
|
36
|
+
self._mdl: Any = None
|
|
37
|
+
self._torch: Any = None
|
|
38
|
+
|
|
39
|
+
def _load(self) -> None:
|
|
40
|
+
try:
|
|
41
|
+
import torch
|
|
42
|
+
from transformers import AutoModel, AutoTokenizer # type: ignore[import-not-found, import-untyped]
|
|
43
|
+
except ImportError as exc: # pragma: no cover - exercised only without the SDK
|
|
44
|
+
raise ImportError(
|
|
45
|
+
"ESM2Provider requires 'transformers' and 'torch'. Install them to use ESM-2."
|
|
46
|
+
) from exc
|
|
47
|
+
self._torch = torch
|
|
48
|
+
self._tok = AutoTokenizer.from_pretrained(self.model)
|
|
49
|
+
self._mdl = AutoModel.from_pretrained(self.model).eval()
|
|
50
|
+
|
|
51
|
+
def embed(self, sequences: Sequence[str]) -> np.ndarray:
|
|
52
|
+
keys = [_key(self.model, s) for s in sequences]
|
|
53
|
+
misses = [(i, s) for i, s in enumerate(sequences) if self._cache.get(keys[i]) is None]
|
|
54
|
+
if misses:
|
|
55
|
+
if self._mdl is None:
|
|
56
|
+
self._load()
|
|
57
|
+
assert self._torch is not None and self._tok is not None and self._mdl is not None
|
|
58
|
+
with self._torch.no_grad():
|
|
59
|
+
for j in range(0, len(misses), 16):
|
|
60
|
+
batch = misses[j : j + 16]
|
|
61
|
+
enc = self._tok([s for _, s in batch], return_tensors="pt", padding=True)
|
|
62
|
+
rep = self._mdl(**enc).last_hidden_state
|
|
63
|
+
mask = enc["attention_mask"].unsqueeze(-1).float()
|
|
64
|
+
mask[:, 0, :] = 0.0
|
|
65
|
+
for b in range(len(batch)):
|
|
66
|
+
n = int(enc["attention_mask"][b].sum())
|
|
67
|
+
mask[b, n - 1, :] = 0.0
|
|
68
|
+
pooled = (rep * mask).sum(1) / mask.sum(1).clamp(min=1.0)
|
|
69
|
+
for b, (i, _) in enumerate(batch):
|
|
70
|
+
self._cache.put(keys[i], pooled[b].cpu().numpy())
|
|
71
|
+
out: list[np.ndarray] = []
|
|
72
|
+
for k in keys:
|
|
73
|
+
v = self._cache.get(k)
|
|
74
|
+
assert v is not None
|
|
75
|
+
out.append(v)
|
|
76
|
+
return np.stack(out, axis=0)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""ESM-C (EvolutionaryScale) embedding provider (import-guarded, cached).
|
|
2
|
+
|
|
3
|
+
ESM-C was measured substantially more activity-predictive than ESM-2 / one-hot in
|
|
4
|
+
the single-mutant regime, so it is the default PLM front end. The EvolutionaryScale
|
|
5
|
+
``esm`` SDK is optional and distinct from fair-esm; the import is lazy and raises a
|
|
6
|
+
clear error when absent, so the rest of the package works without it.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from shellde.embeddings.provider import EmbeddingCache, _key
|
|
16
|
+
|
|
17
|
+
_WIDTH = {"esmc_300m": 960, "esmc_600m": 1152}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ESMCProvider:
|
|
21
|
+
"""Mean-pooled per-residue ESM-C embeddings; satisfies protocols.EmbeddingProvider."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self, model_name: str = "esmc_300m", *, device: str | None = None,
|
|
25
|
+
cache: EmbeddingCache | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.model_name = model_name
|
|
28
|
+
self.dim = _WIDTH.get(model_name, 960)
|
|
29
|
+
self.device = device
|
|
30
|
+
self._cache = cache if cache is not None else EmbeddingCache()
|
|
31
|
+
self._model: Any = None
|
|
32
|
+
self._torch: Any = None
|
|
33
|
+
|
|
34
|
+
def _load(self) -> None:
|
|
35
|
+
try:
|
|
36
|
+
import torch
|
|
37
|
+
from esm.models.esmc import ESMC # type: ignore[import-not-found, import-untyped]
|
|
38
|
+
except ImportError as exc: # pragma: no cover - exercised only without the SDK
|
|
39
|
+
raise ImportError(
|
|
40
|
+
"ESMCProvider requires the EvolutionaryScale 'esm' SDK "
|
|
41
|
+
"(esm.models.esmc.ESMC), which is separate from fair-esm. Install 'esm'."
|
|
42
|
+
) from exc
|
|
43
|
+
self._torch = torch
|
|
44
|
+
dev = self.device or ("cuda" if torch.cuda.is_available() else "cpu")
|
|
45
|
+
self._model = ESMC.from_pretrained(self.model_name).to(dev).eval()
|
|
46
|
+
|
|
47
|
+
def embed(self, sequences: Sequence[str]) -> np.ndarray:
|
|
48
|
+
keys = [_key(self.model_name, s) for s in sequences]
|
|
49
|
+
misses = [(i, s) for i, s in enumerate(sequences) if self._cache.get(keys[i]) is None]
|
|
50
|
+
if misses:
|
|
51
|
+
if self._model is None:
|
|
52
|
+
self._load()
|
|
53
|
+
from esm.sdk.api import ESMProtein, LogitsConfig # type: ignore[import-not-found, import-untyped]
|
|
54
|
+
|
|
55
|
+
assert self._torch is not None and self._model is not None
|
|
56
|
+
with self._torch.no_grad():
|
|
57
|
+
for i, s in misses:
|
|
58
|
+
tensor = self._model.encode(ESMProtein(sequence=s))
|
|
59
|
+
res = self._model.logits(tensor, LogitsConfig(sequence=True, return_embeddings=True))
|
|
60
|
+
emb = res.embeddings[0].float().cpu().numpy() # (L+2, D): BOS..residues..EOS
|
|
61
|
+
self._cache.put(keys[i], emb[1:-1].mean(axis=0))
|
|
62
|
+
out: list[np.ndarray] = []
|
|
63
|
+
for k in keys:
|
|
64
|
+
v = self._cache.get(k)
|
|
65
|
+
assert v is not None
|
|
66
|
+
out.append(v)
|
|
67
|
+
return np.stack(out, axis=0)
|
|
68
|
+
|
|
69
|
+
def embed_residues(self, sequences: Sequence[str]) -> list[np.ndarray]:
|
|
70
|
+
"""Per-residue embeddings (one (L, D) float array per sequence; BOS/EOS stripped).
|
|
71
|
+
|
|
72
|
+
Cached under a ``:res`` namespace key so they never collide with the mean-pool
|
|
73
|
+
cache. Returns a list (not a stacked array) because residue arrays differ in L.
|
|
74
|
+
"""
|
|
75
|
+
keys = [_key(self.model_name + ":res", s) for s in sequences]
|
|
76
|
+
misses = [(i, s) for i, s in enumerate(sequences) if self._cache.get(keys[i]) is None]
|
|
77
|
+
if misses:
|
|
78
|
+
if self._model is None:
|
|
79
|
+
self._load()
|
|
80
|
+
from esm.sdk.api import ESMProtein, LogitsConfig # type: ignore[import-not-found, import-untyped]
|
|
81
|
+
|
|
82
|
+
assert self._torch is not None and self._model is not None
|
|
83
|
+
with self._torch.no_grad():
|
|
84
|
+
for i, s in misses:
|
|
85
|
+
tensor = self._model.encode(ESMProtein(sequence=s))
|
|
86
|
+
res = self._model.logits(tensor, LogitsConfig(sequence=True, return_embeddings=True))
|
|
87
|
+
emb = res.embeddings[0].float().cpu().numpy() # (L+2, D): BOS..residues..EOS
|
|
88
|
+
self._cache.put(keys[i], emb[1:-1])
|
|
89
|
+
out: list[np.ndarray] = []
|
|
90
|
+
for k in keys:
|
|
91
|
+
v = self._cache.get(k)
|
|
92
|
+
assert v is not None
|
|
93
|
+
out.append(v)
|
|
94
|
+
return out
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Embedding cache + a test/compose-friendly FunctionProvider.
|
|
2
|
+
|
|
3
|
+
Providers turn sequences into mean-pooled embeddings on demand and cache them so
|
|
4
|
+
repeated variants (across active-learning rounds) are embedded once. The cache key
|
|
5
|
+
hashes (model_id, sequence) so keys stay short and persistable. FunctionProvider
|
|
6
|
+
wraps any ``str -> vector`` callable, which makes the embedding plumbing testable
|
|
7
|
+
without a heavy PLM SDK and lets callers compose custom features.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _key(model_id: str, seq: str) -> str:
|
|
19
|
+
return f"{model_id}:{hashlib.sha1(seq.encode()).hexdigest()}" # noqa: S324 (cache key, not security)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EmbeddingCache:
|
|
23
|
+
"""In-memory embedding cache with optional ``.npz`` persistence."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, path: str | Path | None = None) -> None:
|
|
26
|
+
self.path = Path(path) if path else None
|
|
27
|
+
self._store: dict[str, np.ndarray] = {}
|
|
28
|
+
if self.path is not None and self.path.exists():
|
|
29
|
+
self.load()
|
|
30
|
+
|
|
31
|
+
def get(self, key: str) -> np.ndarray | None:
|
|
32
|
+
return self._store.get(key)
|
|
33
|
+
|
|
34
|
+
def put(self, key: str, vec: np.ndarray) -> None:
|
|
35
|
+
self._store[key] = np.asarray(vec, dtype=float)
|
|
36
|
+
|
|
37
|
+
def __len__(self) -> int:
|
|
38
|
+
return len(self._store)
|
|
39
|
+
|
|
40
|
+
def save(self) -> None:
|
|
41
|
+
if self.path is not None:
|
|
42
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
np.savez(self.path, **self._store) # type: ignore[arg-type]
|
|
44
|
+
|
|
45
|
+
def load(self) -> None:
|
|
46
|
+
if self.path is not None and self.path.exists():
|
|
47
|
+
data = np.load(self.path)
|
|
48
|
+
self._store = {k: data[k] for k in data.files}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FunctionProvider:
|
|
52
|
+
"""An EmbeddingProvider backed by a plain ``str -> 1D array`` function (cached).
|
|
53
|
+
|
|
54
|
+
Satisfies ``protocols.EmbeddingProvider``. ``calls`` counts cache misses so tests
|
|
55
|
+
can assert that repeated sequences are embedded only once.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
dim: int,
|
|
61
|
+
fn: Callable[[str], np.ndarray],
|
|
62
|
+
*,
|
|
63
|
+
model_id: str = "fn",
|
|
64
|
+
cache: EmbeddingCache | None = None,
|
|
65
|
+
residue_fn: Callable[[str], np.ndarray] | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
self.dim = dim
|
|
68
|
+
self._fn = fn
|
|
69
|
+
self.model_id = model_id
|
|
70
|
+
self._cache = cache if cache is not None else EmbeddingCache()
|
|
71
|
+
self.calls = 0
|
|
72
|
+
self._residue_fn = residue_fn
|
|
73
|
+
|
|
74
|
+
def embed(self, sequences: Sequence[str]) -> np.ndarray:
|
|
75
|
+
out: list[np.ndarray] = []
|
|
76
|
+
for s in sequences:
|
|
77
|
+
key = _key(self.model_id, s)
|
|
78
|
+
vec = self._cache.get(key)
|
|
79
|
+
if vec is None:
|
|
80
|
+
self.calls += 1
|
|
81
|
+
vec = np.asarray(self._fn(s), dtype=float)
|
|
82
|
+
if vec.shape != (self.dim,):
|
|
83
|
+
raise ValueError(f"fn returned {vec.shape}, expected ({self.dim},)")
|
|
84
|
+
self._cache.put(key, vec)
|
|
85
|
+
out.append(vec)
|
|
86
|
+
return np.stack(out, axis=0)
|
|
87
|
+
|
|
88
|
+
def embed_residues(self, sequences: Sequence[str]) -> list[np.ndarray]:
|
|
89
|
+
"""Per-residue embeddings via ``residue_fn`` (one (L, D) array per sequence).
|
|
90
|
+
|
|
91
|
+
Raises if no ``residue_fn`` was supplied (no silent fallback to mean-pool).
|
|
92
|
+
"""
|
|
93
|
+
if self._residue_fn is None:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
"FunctionProvider has no residue_fn; embed_residues (site pooling) unsupported"
|
|
96
|
+
)
|
|
97
|
+
out: list[np.ndarray] = []
|
|
98
|
+
for s in sequences:
|
|
99
|
+
arr = np.asarray(self._residue_fn(s), dtype=float)
|
|
100
|
+
if arr.ndim != 2 or arr.shape != (len(s), self.dim):
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"residue_fn returned {arr.shape}, expected ({len(s)}, {self.dim})"
|
|
103
|
+
)
|
|
104
|
+
out.append(arr)
|
|
105
|
+
return out
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Feature blocks + matrix composition."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from shellde.features.base import FeatureBlock
|
|
5
|
+
from shellde.features.defaults import default_blocks
|
|
6
|
+
from shellde.features.embedding import PLMEmbeddingBlock
|
|
7
|
+
from shellde.features.matrix import FeatureMatrix
|
|
8
|
+
from shellde.features.inverse_folding import InverseFoldingBlock
|
|
9
|
+
from shellde.features.naturalness import NaturalnessBlock
|
|
10
|
+
from shellde.features.onehot import OneHotBlock
|
|
11
|
+
from shellde.features.pairwise import GatedPairwiseBlock
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"FeatureBlock",
|
|
15
|
+
"FeatureMatrix",
|
|
16
|
+
"GatedPairwiseBlock",
|
|
17
|
+
"InverseFoldingBlock",
|
|
18
|
+
"NaturalnessBlock",
|
|
19
|
+
"OneHotBlock",
|
|
20
|
+
"PLMEmbeddingBlock",
|
|
21
|
+
"default_blocks",
|
|
22
|
+
]
|
shellde/features/base.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""FeatureBlock: one fixed-width, column-stable feature group.
|
|
2
|
+
|
|
3
|
+
A block carries NO gate of its own. Whether an optional block is included is decided by the
|
|
4
|
+
caller, per flag and per command (see `shellde.gating` and the CLI command functions).
|
|
5
|
+
|
|
6
|
+
A block declares a fixed ``dim`` and whether it is ``present``. When absent (no
|
|
7
|
+
structure, no embeddings, ...) it contributes a zero-weight column group of its
|
|
8
|
+
declared width, so the matrix width is identical regardless of data availability
|
|
9
|
+
and the surrogate auto-weights the unused columns down. ``encode`` is shape-checked
|
|
10
|
+
so a buggy block fails loud instead of silently corrupting the matrix.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from shellde.design_space import DesignSpace
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FeatureBlock(ABC):
|
|
23
|
+
"""One feature block contributing a fixed-width column group."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def dim(self) -> int: ...
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def present(self) -> bool:
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def _encode_present(
|
|
37
|
+
self, assignments: Sequence[Mapping[int, str]], space: DesignSpace
|
|
38
|
+
) -> np.ndarray: ...
|
|
39
|
+
|
|
40
|
+
def encode(
|
|
41
|
+
self, assignments: Sequence[Mapping[int, str]], space: DesignSpace
|
|
42
|
+
) -> np.ndarray:
|
|
43
|
+
n = len(assignments)
|
|
44
|
+
if not self.present:
|
|
45
|
+
return np.zeros((n, self.dim), dtype=float)
|
|
46
|
+
out = self._encode_present(assignments, space)
|
|
47
|
+
if out.shape != (n, self.dim):
|
|
48
|
+
raise ValueError(f"block {self.name} produced {out.shape}, expected {(n, self.dim)}")
|
|
49
|
+
return out
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""THE single source of truth for the shipped default feature blocks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from shellde.design_space import DesignSpace
|
|
5
|
+
from shellde.features.base import FeatureBlock
|
|
6
|
+
from shellde.features.onehot import OneHotBlock
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def default_blocks(space: DesignSpace) -> list[FeatureBlock]:
|
|
10
|
+
"""THE shipped additive default block set, consumed by both `cli.recommend` and the bench `ours` method so the head-to-head always measures what ships. `GatedPairwiseBlock` and `InverseFoldingBlock` are OPT-INs the CALLER appends on top, never part of the default. Do not read "opt-in" as "data-gated": pairwise is CV-gated only under `--auto-gate-pairwise` (`--contacts-pdb` alone opens it unconditionally), and IF is CV-gated on `recommend` only (`cmd_campaign`, and `cmd_round` through it, appends it with no gate). `--plm` / `--naturalness` features do not pass through here at all; `plm_rerank` adds them, ungated, on any command that offers the flags."""
|
|
11
|
+
return [OneHotBlock(space)]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""PLMEmbeddingBlock: on-demand PLM embedding of design variants (single-mutant front end).
|
|
2
|
+
|
|
3
|
+
Splices each variant's design-position residues into a full WT background sequence,
|
|
4
|
+
then asks an EmbeddingProvider for the mean-pooled embedding. This is the PLM-native
|
|
5
|
+
path for the regime where PLM implicit epistasis wins (single-mutant / long context);
|
|
6
|
+
on dense combinatorial libraries embeddings dilute, so the surrogate auto-weights it
|
|
7
|
+
down (it earns its place on the front end, not 200k-candidate scoring).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from shellde.design_space import DesignSpace
|
|
16
|
+
from shellde.features.base import FeatureBlock
|
|
17
|
+
from shellde.protocols import EmbeddingProvider
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PLMEmbeddingBlock(FeatureBlock):
|
|
21
|
+
"""Per-variant PLM embedding spliced into a full WT background (optional block)."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
space: DesignSpace,
|
|
26
|
+
provider: EmbeddingProvider | None,
|
|
27
|
+
wt_sequence: str | None,
|
|
28
|
+
*,
|
|
29
|
+
name: str = "plm",
|
|
30
|
+
pooling: str = "mean",
|
|
31
|
+
) -> None:
|
|
32
|
+
if pooling not in {"mean", "site"}:
|
|
33
|
+
raise ValueError(f"pooling must be 'mean' or 'site', got {pooling!r}")
|
|
34
|
+
self.name = name
|
|
35
|
+
self._provider = provider
|
|
36
|
+
self._wt = wt_sequence
|
|
37
|
+
self._pooling = pooling
|
|
38
|
+
if provider is not None and wt_sequence is not None:
|
|
39
|
+
for p in space.positions:
|
|
40
|
+
if p < 1 or p > len(wt_sequence):
|
|
41
|
+
raise ValueError(f"position {p} outside wt_sequence length {len(wt_sequence)}")
|
|
42
|
+
if wt_sequence[p - 1] != space.reference[p]:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"wt_sequence[{p - 1}]={wt_sequence[p - 1]!r} != reference "
|
|
45
|
+
f"{space.reference[p]!r} at position {p}"
|
|
46
|
+
)
|
|
47
|
+
self._dim = provider.dim if provider is not None else 0
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def dim(self) -> int:
|
|
51
|
+
return self._dim
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def present(self) -> bool:
|
|
55
|
+
return self._provider is not None and self._wt is not None
|
|
56
|
+
|
|
57
|
+
def _encode_present(
|
|
58
|
+
self, assignments: Sequence[Mapping[int, str]], space: DesignSpace
|
|
59
|
+
) -> np.ndarray:
|
|
60
|
+
assert self._provider is not None and self._wt is not None
|
|
61
|
+
seqs: list[str] = []
|
|
62
|
+
for a in assignments:
|
|
63
|
+
chars = list(self._wt)
|
|
64
|
+
for p in space.positions:
|
|
65
|
+
chars[p - 1] = a[p]
|
|
66
|
+
seqs.append("".join(chars))
|
|
67
|
+
if self._pooling == "mean":
|
|
68
|
+
return np.asarray(self._provider.embed(seqs), dtype=float)
|
|
69
|
+
embed_residues = getattr(self._provider, "embed_residues", None)
|
|
70
|
+
if embed_residues is None:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"pooling='site' requires the provider to implement embed_residues, "
|
|
73
|
+
f"but {type(self._provider).__name__} does not"
|
|
74
|
+
)
|
|
75
|
+
per_residue = embed_residues(seqs) # list of (L, D) arrays, one per variant
|
|
76
|
+
rows: list[np.ndarray] = []
|
|
77
|
+
for res in per_residue:
|
|
78
|
+
res = np.asarray(res, dtype=float)
|
|
79
|
+
rows.append(np.mean([res[p - 1] for p in space.positions], axis=0))
|
|
80
|
+
return np.stack(rows, axis=0)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""InverseFoldingBlock: a structure inverse-folding score as one optional feature.
|
|
2
|
+
|
|
3
|
+
An inverse-folding model (ProteinMPNN) gives per-position amino-acid log-probabilities
|
|
4
|
+
GIVEN the backbone in a single pass, so a variant's score is a cheap lookup (sum over
|
|
5
|
+
mutated positions of log P(mut) - log P(wt)), not a per-variant model call. This IF
|
|
6
|
+
feature is kept HONEST: the evidence (V2) showed IF is
|
|
7
|
+
protein dependent (helped TrpB, inert on GB1, slight negative on ParPgb), so it is an
|
|
8
|
+
optional block. The block itself carries NO gate; the CALLER decides. `cmd_recommend`
|
|
9
|
+
(`src/shellde/cli.py`) runs it through the additive-null CV gate (`gating.should_include_block`)
|
|
10
|
+
by default, so on `recommend` it is opened only when this protein's held-out data supports it.
|
|
11
|
+
`cmd_campaign` -- and `cmd_round`, which calls it -- appends this block UNCONDITIONALLY whenever
|
|
12
|
+
`--if-logprobs` is supplied, with no gate.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from shellde.design_space import DesignSpace
|
|
21
|
+
from shellde.features.base import FeatureBlock
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InverseFoldingBlock(FeatureBlock):
|
|
25
|
+
"""Single-column inverse-folding log-odds-vs-WT score (optional, structure-derived)."""
|
|
26
|
+
|
|
27
|
+
name = "inverse_folding"
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
space: DesignSpace,
|
|
32
|
+
logprobs: Mapping[int, Mapping[str, float]] | None,
|
|
33
|
+
*,
|
|
34
|
+
relative_to_wt: bool = True,
|
|
35
|
+
name: str = "inverse_folding",
|
|
36
|
+
) -> None:
|
|
37
|
+
self.name = name
|
|
38
|
+
self._relative = relative_to_wt
|
|
39
|
+
self._lp = logprobs
|
|
40
|
+
if logprobs is not None:
|
|
41
|
+
missing = [p for p in space.positions if p not in logprobs]
|
|
42
|
+
if missing:
|
|
43
|
+
raise ValueError(f"inverse-folding logprobs missing positions {missing}")
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def dim(self) -> int:
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def present(self) -> bool:
|
|
51
|
+
return self._lp is not None
|
|
52
|
+
|
|
53
|
+
def _encode_present(
|
|
54
|
+
self, assignments: Sequence[Mapping[int, str]], space: DesignSpace
|
|
55
|
+
) -> np.ndarray:
|
|
56
|
+
assert self._lp is not None
|
|
57
|
+
out = np.zeros((len(assignments), 1), dtype=float)
|
|
58
|
+
for i, a in enumerate(assignments):
|
|
59
|
+
s = 0.0
|
|
60
|
+
for p in space.positions:
|
|
61
|
+
table = self._lp[p]
|
|
62
|
+
s += float(table.get(a[p], 0.0))
|
|
63
|
+
if self._relative:
|
|
64
|
+
s -= float(table.get(space.reference[p], 0.0))
|
|
65
|
+
out[i, 0] = s
|
|
66
|
+
return out
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""FeatureMatrix: compose feature blocks into one column-stable design matrix.
|
|
2
|
+
|
|
3
|
+
The column layout depends only on (space, blocks), never on the candidate pool of a
|
|
4
|
+
given round, so train and predict matrices stay aligned across active-learning
|
|
5
|
+
rounds that select different pools. This is the single encoding path (there is no
|
|
6
|
+
separate legacy encoder).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from shellde.design_space import DesignSpace, combo_indices, to_assignment
|
|
16
|
+
from shellde.features.base import FeatureBlock
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class FeatureMatrix:
|
|
21
|
+
space: DesignSpace
|
|
22
|
+
blocks: list[FeatureBlock]
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def width(self) -> int:
|
|
26
|
+
return sum(b.dim for b in self.blocks)
|
|
27
|
+
|
|
28
|
+
def column_layout(self) -> list[tuple[str, int, int]]:
|
|
29
|
+
layout: list[tuple[str, int, int]] = []
|
|
30
|
+
cursor = 0
|
|
31
|
+
for b in self.blocks:
|
|
32
|
+
layout.append((b.name, cursor, cursor + b.dim))
|
|
33
|
+
cursor += b.dim
|
|
34
|
+
return layout
|
|
35
|
+
|
|
36
|
+
def active_blocks(self) -> list[str]:
|
|
37
|
+
return [b.name for b in self.blocks if b.present]
|
|
38
|
+
|
|
39
|
+
def encode(self, variants: Sequence[str]) -> np.ndarray:
|
|
40
|
+
if not self.blocks:
|
|
41
|
+
return np.zeros((len(variants), 0), dtype=float)
|
|
42
|
+
# Fast path: every variant is a valid combo AND every block supports index encoding.
|
|
43
|
+
# Skips per-variant assignment dicts + per-element Python loops; byte-identical output.
|
|
44
|
+
if all(hasattr(b, "encode_indices") for b in self.blocks):
|
|
45
|
+
idx = combo_indices(variants, self.space)
|
|
46
|
+
if idx is not None:
|
|
47
|
+
return np.hstack([b.encode_indices(idx, self.space) for b in self.blocks])
|
|
48
|
+
assignments = [to_assignment(v, self.space) for v in variants]
|
|
49
|
+
parts = [b.encode(assignments, self.space) for b in self.blocks]
|
|
50
|
+
return np.hstack(parts)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""NaturalnessBlock: a per-variant zero-shot naturalness score as one feature column.
|
|
2
|
+
|
|
3
|
+
This is the protocol-clean realisation of FolDE's naturalness warm-start: rather than
|
|
4
|
+
a separate pretrain stage, the PLM zero-shot naturalness of each variant (spliced into
|
|
5
|
+
the full WT background, like the embedding block) is exposed as a feature the surrogate
|
|
6
|
+
weights. Because the column is available for UNMEASURED variants too, predictions stay
|
|
7
|
+
anchored to the pretraining prior where activity data is sparse, which is exactly the
|
|
8
|
+
anti-round-2-collapse benefit. Pairs naturally with the ranking surrogate.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from shellde.design_space import DesignSpace
|
|
17
|
+
from shellde.features.base import FeatureBlock
|
|
18
|
+
from shellde.protocols import NaturalnessProvider
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NaturalnessBlock(FeatureBlock):
|
|
22
|
+
"""Single-column per-variant naturalness spliced into a full WT background (optional)."""
|
|
23
|
+
|
|
24
|
+
name = "naturalness"
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
space: DesignSpace,
|
|
29
|
+
provider: NaturalnessProvider | None,
|
|
30
|
+
wt_sequence: str | None,
|
|
31
|
+
*,
|
|
32
|
+
name: str = "naturalness",
|
|
33
|
+
) -> None:
|
|
34
|
+
self.name = name
|
|
35
|
+
self._provider = provider
|
|
36
|
+
self._wt = wt_sequence
|
|
37
|
+
if provider is not None and wt_sequence is not None:
|
|
38
|
+
for p in space.positions:
|
|
39
|
+
if p < 1 or p > len(wt_sequence):
|
|
40
|
+
raise ValueError(f"position {p} outside wt_sequence length {len(wt_sequence)}")
|
|
41
|
+
if wt_sequence[p - 1] != space.reference[p]:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"wt_sequence[{p - 1}]={wt_sequence[p - 1]!r} != reference "
|
|
44
|
+
f"{space.reference[p]!r} at position {p}"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def dim(self) -> int:
|
|
49
|
+
return 1
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def present(self) -> bool:
|
|
53
|
+
return self._provider is not None and self._wt is not None
|
|
54
|
+
|
|
55
|
+
def _encode_present(
|
|
56
|
+
self, assignments: Sequence[Mapping[int, str]], space: DesignSpace
|
|
57
|
+
) -> np.ndarray:
|
|
58
|
+
assert self._provider is not None and self._wt is not None
|
|
59
|
+
seqs: list[str] = []
|
|
60
|
+
for a in assignments:
|
|
61
|
+
chars = list(self._wt)
|
|
62
|
+
for p in space.positions:
|
|
63
|
+
chars[p - 1] = a[p]
|
|
64
|
+
seqs.append("".join(chars))
|
|
65
|
+
return np.asarray(self._provider.score(seqs), dtype=float).reshape(-1, 1)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""OneHotBlock: absolute-position-keyed one-hot over the frozen design space.
|
|
2
|
+
|
|
3
|
+
Always present. This is the cheap, always-available, additive-friendly feature
|
|
4
|
+
that the surrogate falls back to when richer blocks (PLM, structure) are absent or
|
|
5
|
+
uninformative (e.g. non-native function), per the architecture's scope notes.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from shellde.design_space import DesignSpace
|
|
14
|
+
from shellde.features.base import FeatureBlock
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OneHotBlock(FeatureBlock):
|
|
18
|
+
name = "onehot"
|
|
19
|
+
|
|
20
|
+
def __init__(self, space: DesignSpace) -> None:
|
|
21
|
+
self._dim = space.n_positions * space.q
|
|
22
|
+
self._aa_to_int = {aa: i for i, aa in enumerate(space.alphabet)}
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def dim(self) -> int:
|
|
26
|
+
return self._dim
|
|
27
|
+
|
|
28
|
+
def _encode_present(
|
|
29
|
+
self, assignments: Sequence[Mapping[int, str]], space: DesignSpace
|
|
30
|
+
) -> np.ndarray:
|
|
31
|
+
q = space.q
|
|
32
|
+
x = np.zeros((len(assignments), space.n_positions * q), dtype=float)
|
|
33
|
+
for i, a in enumerate(assignments):
|
|
34
|
+
for j, pos in enumerate(space.positions):
|
|
35
|
+
idx = self._aa_to_int.get(a[pos])
|
|
36
|
+
if idx is not None:
|
|
37
|
+
x[i, j * q + idx] = 1.0
|
|
38
|
+
return x
|
|
39
|
+
|
|
40
|
+
def encode_indices(self, idx: np.ndarray, space: DesignSpace) -> np.ndarray:
|
|
41
|
+
"""Vectorized one-hot from a (N, n_positions) alphabet-index array (fast path).
|
|
42
|
+
|
|
43
|
+
Produces the IDENTICAL matrix to ``_encode_present`` for combo variants: bit set at
|
|
44
|
+
column ``j * q + idx[i, j]`` for every variant i, position j. Used by FeatureMatrix.encode
|
|
45
|
+
when every variant is a valid combo (see design_space.combo_indices); otherwise the
|
|
46
|
+
per-assignment path runs. Indices are guaranteed in-alphabet by combo_indices.
|
|
47
|
+
"""
|
|
48
|
+
n, length = idx.shape
|
|
49
|
+
q = space.q
|
|
50
|
+
x = np.zeros((n, length * q), dtype=float)
|
|
51
|
+
if n:
|
|
52
|
+
cols = (idx + np.arange(length, dtype=np.intp) * q).ravel()
|
|
53
|
+
rows = np.repeat(np.arange(n, dtype=np.intp), length)
|
|
54
|
+
x[rows, cols] = 1.0
|
|
55
|
+
return x
|