shellde 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. shellde/__init__.py +36 -0
  2. shellde/acquisition.py +135 -0
  3. shellde/advisor.py +158 -0
  4. shellde/bench/__init__.py +17 -0
  5. shellde/bench/falsification.py +95 -0
  6. shellde/bench/harness.py +134 -0
  7. shellde/bench/stats.py +46 -0
  8. shellde/campaign.py +257 -0
  9. shellde/candidates.py +341 -0
  10. shellde/cli.py +1517 -0
  11. shellde/colab.py +257 -0
  12. shellde/conformal.py +160 -0
  13. shellde/consensus.py +52 -0
  14. shellde/design_space.py +141 -0
  15. shellde/embeddings/__init__.py +6 -0
  16. shellde/embeddings/esm2.py +76 -0
  17. shellde/embeddings/esmc.py +94 -0
  18. shellde/embeddings/provider.py +105 -0
  19. shellde/features/__init__.py +22 -0
  20. shellde/features/base.py +49 -0
  21. shellde/features/defaults.py +11 -0
  22. shellde/features/embedding.py +80 -0
  23. shellde/features/inverse_folding.py +66 -0
  24. shellde/features/matrix.py +50 -0
  25. shellde/features/naturalness.py +65 -0
  26. shellde/features/onehot.py +55 -0
  27. shellde/features/pairwise.py +64 -0
  28. shellde/funclib.py +331 -0
  29. shellde/gating.py +181 -0
  30. shellde/holo.py +175 -0
  31. shellde/hotspots.py +108 -0
  32. shellde/loop.py +161 -0
  33. shellde/msa.py +186 -0
  34. shellde/naturalness.py +142 -0
  35. shellde/oracle.py +94 -0
  36. shellde/plm.py +145 -0
  37. shellde/prereg.py +41 -0
  38. shellde/protocols.py +87 -0
  39. shellde/rank.py +103 -0
  40. shellde/report.py +132 -0
  41. shellde/selector.py +63 -0
  42. shellde/sitefinder.py +465 -0
  43. shellde/structure.py +356 -0
  44. shellde/surrogate.py +323 -0
  45. shellde/types.py +66 -0
  46. shellde/zero_shot.py +160 -0
  47. shellde-0.2.0.dist-info/METADATA +285 -0
  48. shellde-0.2.0.dist-info/RECORD +51 -0
  49. shellde-0.2.0.dist-info/WHEEL +5 -0
  50. shellde-0.2.0.dist-info/entry_points.txt +2 -0
  51. shellde-0.2.0.dist-info/top_level.txt +1 -0
@@ -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
+ ]
@@ -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