Spaces:
Running
Running
| """Active-learning surrogate for the Directed-Evolution loop (Design→Build→Test→Learn). | |
| A user evolves a protein (round 1, zero-shot ESM-2 ΔLL), orders the top library, | |
| measures variants on the bench, and logs the results. This module fits a light | |
| surrogate on THEIR measurements and produces an *adjusted* per-mutation score | |
| that the existing simulated-annealing search (`dee.optimizer.search.evolve`) | |
| consumes unchanged — so round 2 is conditioned on real data, not just the prior. | |
| Design (deliberately simple + honest, runs in milliseconds on CPU; numpy only, | |
| no scikit-learn/scipy in the image): | |
| * The SA objective is ADDITIVE over single-site effects: | |
| fitness(variant) = Σ_{m∈variant} score(m) | |
| In round 1, score(m) = ΔLL_m (the ESM-2 wild-type-marginal prior). | |
| * We model the measured fitness as a ridge over single-site effects WITH the | |
| ΔLL prior as a feature: | |
| y_std ≈ b + w_prior · (Σ ΔLL of the variant) + Σ_{m} β_m · 1[m∈variant] | |
| Fit [b, w_prior, β] by closed-form ridge (β strongly shrunk toward 0, so | |
| mutations the user never measured fall back to the pure prior). y is | |
| standardized so the (arbitrary) assay scale doesn't matter — ranking is | |
| scale-invariant anyway. | |
| * The round-2 per-mutation acquisition score (additive ⇒ plugs straight into | |
| the SA) is: | |
| score(m) = w_prior · ΔLL_m + β_m + κ · uncertainty_m | |
| where uncertainty_m rewards under-measured mutations (exploration). Unseen | |
| mutations get β_m = 0 and high uncertainty → the search explores them while | |
| still respecting the ΔLL prior. | |
| * Below a small floor of measurements we DON'T pretend to learn: we return the | |
| pure prior with an honest note. No overclaiming. | |
| Privacy: operates only on the data passed in (one user's own measurements). | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from dataclasses import dataclass, replace | |
| from typing import Dict, List, Optional, Sequence, Tuple | |
| import numpy as np | |
| # Need at least this many measured variants before we trust a learned signal; | |
| # below it, round 2 = re-search on the zero-shot prior (still a fresh library). | |
| MIN_MEASUREMENTS = 4 | |
| _LABEL_RE = re.compile(r"^([A-Za-z])(\d+)([A-Za-z*])$") # e.g. "W58L" | |
| def parse_label(label: str) -> Optional[Tuple[int, str]]: | |
| """'W58L' → (57, 'L') (0-indexed position, mutant AA). None if malformed.""" | |
| m = _LABEL_RE.match((label or "").strip()) | |
| if not m: | |
| return None | |
| pos = int(m.group(2)) - 1 | |
| if pos < 0: | |
| return None | |
| return (pos, m.group(3).upper()) | |
| def parse_mutations(labels: str | Sequence[str]) -> List[Tuple[int, str]]: | |
| """Accept 'W58L,K204R' or ['W58L','K204R'] → [(57,'L'),(203,'R')].""" | |
| if isinstance(labels, str): | |
| parts = re.split(r"[,\s;]+", labels.strip()) | |
| else: | |
| parts = list(labels) | |
| out = [] | |
| for p in parts: | |
| pm = parse_label(p) | |
| if pm is not None: | |
| out.append(pm) | |
| return out | |
| class Surrogate: | |
| """Result of fitting on a user's measurements.""" | |
| adjusted: Dict[Tuple[int, str], float] # (pos, mut_aa) → round-2 acquisition score | |
| w_prior: float # learned weight on the ΔLL prior | |
| n_train: int # measured variants used | |
| n_effects: int # mutations that got a learned correction | |
| learned: bool # False ⇒ fell back to the prior | |
| note: str | |
| def adjust_pool(self, pool: list) -> list: | |
| """Return a copy of a `search.Mutation` pool with delta_ll replaced by | |
| the round-2 acquisition score (so `evolve()` runs unchanged).""" | |
| return [replace(m, delta_ll=self.adjusted.get((m.position, m.mut_aa), m.delta_ll)) | |
| for m in pool] | |
| def fit_surrogate( | |
| pool: list, | |
| measurements: List[Tuple[Sequence[str], float]], | |
| *, | |
| kappa: float = 0.4, | |
| ridge_lambda: float = 1.0, | |
| prior_lambda: float = 0.1, | |
| ) -> Surrogate: | |
| """Fit the additive surrogate. | |
| pool: list of search.Mutation (round-1 single-site pool; each has | |
| .position, .mut_aa, .delta_ll). | |
| measurements: [(mutation_labels, measured_value), …] from the user's bench. | |
| Returns a Surrogate whose `adjusted` maps (pos, mut_aa) → acquisition score. | |
| """ | |
| prior = {(m.position, m.mut_aa): float(m.delta_ll) for m in pool} | |
| index = {key: i for i, key in enumerate(prior.keys())} | |
| keys = list(prior.keys()) | |
| M = len(keys) | |
| # Parse + keep only measurements with a numeric value and ≥1 in-pool mutation. | |
| rows: List[Tuple[List[int], float, float]] = [] # (col_indices, prior_sum, y) | |
| for labels, value in (measurements or []): | |
| try: | |
| y = float(value) | |
| except (TypeError, ValueError): | |
| continue | |
| cols, psum = [], 0.0 | |
| for key in parse_mutations(labels): | |
| if key in index: | |
| cols.append(index[key]); psum += prior[key] | |
| if cols: | |
| rows.append((cols, psum, y)) | |
| n = len(rows) | |
| counts = np.zeros(M) | |
| for cols, _, _ in rows: | |
| for c in cols: | |
| counts[c] += 1 | |
| # Exploration bonus: under-measured mutations get a larger nudge. | |
| unc = 1.0 / np.sqrt(1.0 + counts) | |
| # Not enough signal → honest fallback to the pure ΔLL prior. | |
| y_all = np.array([y for _, _, y in rows], dtype=float) | |
| if n < MIN_MEASUREMENTS or M == 0 or (n and np.std(y_all) < 1e-9): | |
| return Surrogate( | |
| adjusted=dict(prior), w_prior=1.0, n_train=n, n_effects=0, learned=False, | |
| note=(f"{n} measurement(s) logged — need ≥{MIN_MEASUREMENTS} with a spread of " | |
| "values to learn; round 2 uses the ESM-2 prior."), | |
| ) | |
| # Standardize y (assay scale is arbitrary; ranking is scale-invariant). | |
| y_std = (y_all - y_all.mean()) / (y_all.std() + 1e-9) | |
| # Design matrix A = [intercept | prior_sum | incidence(M)]. | |
| A = np.zeros((n, 2 + M)) | |
| A[:, 0] = 1.0 | |
| for i, (cols, psum, _) in enumerate(rows): | |
| A[i, 1] = psum | |
| for c in cols: | |
| A[i, 2 + c] = 1.0 | |
| # Ridge: don't regularize the intercept; lightly regularize the prior weight; | |
| # strongly shrink the per-mutation β toward 0 (→ unseen muts default to prior). | |
| reg = np.concatenate([[0.0], [prior_lambda], np.full(M, ridge_lambda)]) | |
| try: | |
| w = np.linalg.solve(A.T @ A + np.diag(reg), A.T @ y_std) | |
| except np.linalg.LinAlgError: | |
| w = np.linalg.lstsq(A.T @ A + np.diag(reg), A.T @ y_std, rcond=None)[0] | |
| w_prior = float(w[1]) | |
| beta = w[2:] | |
| # Acquisition per pool mutation: prior (re-weighted) + learned correction + explore. | |
| adjusted = {} | |
| for key in keys: | |
| c = index[key] | |
| adjusted[key] = w_prior * prior[key] + float(beta[c]) + kappa * float(unc[c]) | |
| n_effects = int(np.sum(np.abs(beta) > 1e-6)) | |
| return Surrogate( | |
| adjusted=adjusted, w_prior=w_prior, n_train=n, n_effects=n_effects, learned=True, | |
| note=(f"Learned from {n} measured variants (prior weight {w_prior:.2f}; " | |
| f"{n_effects} mutation effects corrected). Round 2 balances the " | |
| "learned model with exploration of under-tested positions."), | |
| ) | |