syntheogenesis / dee /core /active_learning.py
Tengo Gzirishvili
Learning flywheel v1: Directed-Evolution active-learning loop (DBTL)
26914b4
Raw
History Blame Contribute Delete
7.27 kB
"""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
@dataclass
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."),
)