syntheogenesis / tests /test_active_learning.py
Tengo Gzirishvili
test: cover DE active-learning surrogate (Design→Build→Test→Learn)
2d37996
Raw
History Blame Contribute Delete
7.7 kB
"""Tests for the Directed-Evolution active-learning surrogate
(`dee.core.active_learning`) — the Design→Build→Test→Learn round-2 core.
These pin the behaviour we actually claim to users:
* mutation-label parsing (1-indexed → 0-indexed),
* honest fallback to the ESM-2 prior below the measurement floor / with no
signal (no overclaiming),
* the headline property: when the zero-shot prior is *wrong*, real bench
measurements override it and the round-2 ranking recovers the truth,
* `adjust_pool` plugs the learned scores back into the SA `Mutation` pool
without disturbing the other fields.
No torch/ESM needed — the surrogate is pure numpy over a supplied pool.
"""
import numpy as np
import pytest
from dee.core.active_learning import (
MIN_MEASUREMENTS,
Surrogate,
fit_surrogate,
parse_label,
parse_mutations,
)
from dee.optimizer.search import Mutation
# --------------------------------------------------------------------------- #
# label parsing
# --------------------------------------------------------------------------- #
def test_parse_label_basic():
assert parse_label("W58L") == (57, "L") # 1-indexed -> 0-indexed
assert parse_label(" k204r ") == (203, "R") # whitespace + case
assert parse_label("A1*") == (0, "*") # stop codon, first residue
@pytest.mark.parametrize("bad", ["", "58L", "WL", "W0L", "W-3L", "WfooL", None])
def test_parse_label_rejects_malformed(bad):
assert parse_label(bad) is None
def test_parse_mutations_string_and_list():
assert parse_mutations("W58L,K204R") == [(57, "L"), (203, "R")]
assert parse_mutations("W58L K204R; T2A") == [(57, "L"), (203, "R"), (1, "A")]
assert parse_mutations(["W58L", "K204R"]) == [(57, "L"), (203, "R")]
# malformed tokens are silently dropped, valid ones kept
assert parse_mutations("W58L, junk, K204R") == [(57, "L"), (203, "R")]
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
def _pool(prior_by_key):
"""Build a frozen-Mutation pool from {(pos, aa): delta_ll}."""
return [
Mutation(position=pos, wt_aa="A", mut_aa=aa, delta_ll=float(dll))
for (pos, aa), dll in prior_by_key.items()
]
def _label(pos, aa):
return f"A{pos + 1}{aa}" # inverse of parse_label, wt 'A' is irrelevant
def _spearman(a, b):
a, b = np.asarray(a, float), np.asarray(b, float)
ra = np.argsort(np.argsort(a))
rb = np.argsort(np.argsort(b))
return float(np.corrcoef(ra, rb)[0, 1])
# --------------------------------------------------------------------------- #
# honest fallback
# --------------------------------------------------------------------------- #
def test_fallback_below_measurement_floor():
pool = _pool({(0, "L"): 1.0, (1, "R"): 0.5})
meas = [([_label(0, "L")], 3.0), ([_label(1, "R")], 1.0)] # only 2 < floor
s = fit_surrogate(pool, meas)
assert s.learned is False
assert s.n_effects == 0
assert s.w_prior == 1.0
# adjusted == the pure prior, so round 2 == a fresh search on ESM-2
assert s.adjusted == {(0, "L"): 1.0, (1, "R"): 0.5}
assert str(MIN_MEASUREMENTS) in s.note
def test_fallback_when_no_spread_in_values():
pool = _pool({(i, "L"): 0.1 * i for i in range(5)})
meas = [([_label(i, "L")], 2.0) for i in range(5)] # ≥ floor but zero variance
s = fit_surrogate(pool, meas)
assert s.learned is False
assert s.adjusted == {(i, "L"): 0.1 * i for i in range(5)}
def test_empty_inputs_dont_crash():
s = fit_surrogate([], [])
assert s.learned is False
assert s.adjusted == {}
def test_non_numeric_and_out_of_pool_measurements_ignored():
pool = _pool({(0, "L"): 1.0})
meas = [
([_label(0, "L")], "not-a-number"), # bad value -> dropped
(["Z9Q"], 5.0), # mutation not in pool -> dropped
]
s = fit_surrogate(pool, meas)
assert s.n_train == 0
assert s.learned is False
# --------------------------------------------------------------------------- #
# the headline property: data overrides a misleading prior
# --------------------------------------------------------------------------- #
def test_data_overrides_misleading_prior():
"""Ground-truth additive effects exist; the ESM-2 prior is deliberately
*anti-correlated* with them. After logging enough bench measurements, the
round-2 acquisition ranking should recover the truth far better than the
prior did — that's the whole value proposition."""
rng = np.random.default_rng(7)
keys = [(i, "L") for i in range(8)]
true_effect = {k: v for k, v in zip(keys, [3.0, -2.0, 2.5, -1.0, 1.5, -2.5, 0.5, -0.5])}
# Prior is the NEGATION of the truth (worst case): high-prior = actually bad.
prior = {k: -true_effect[k] for k in keys}
pool = _pool(prior)
# Simulate the user measuring random multi-site variants; value = additive
# truth + small noise (scale is arbitrary; surrogate standardizes it).
measurements = []
for _ in range(40):
n_sites = int(rng.integers(1, 4))
chosen = [keys[i] for i in rng.choice(len(keys), size=n_sites, replace=False)]
val = sum(true_effect[k] for k in chosen) + rng.normal(0, 0.25)
measurements.append(([_label(p, a) for (p, a) in chosen], 10.0 + val))
s = fit_surrogate(pool, measurements)
assert s.learned is True
assert s.n_train == 40
assert s.n_effects > 0
true_vec = [true_effect[k] for k in keys]
prior_vec = [prior[k] for k in keys]
learned_vec = [s.adjusted[k] for k in keys]
rho_prior = _spearman(prior_vec, true_vec)
rho_learned = _spearman(learned_vec, true_vec)
assert rho_prior < 0 # prior is anti-correlated (by construction)
assert rho_learned > 0.6 # learned ranking recovers the truth
assert rho_learned > rho_prior + 1.0 # and is a large improvement over the prior
def test_adjust_pool_preserves_fields_and_swaps_score():
pool = _pool({(0, "L"): 1.0, (3, "R"): -0.5, (5, "K"): 0.2})
# enough varied data to learn
meas = [
([_label(0, "L")], 9.0),
([_label(3, "R")], 2.0),
([_label(5, "K")], 5.0),
([_label(0, "L"), _label(5, "K")], 8.0),
([_label(3, "R"), _label(5, "K")], 4.0),
]
s = fit_surrogate(pool, meas)
new_pool = s.adjust_pool(pool)
assert len(new_pool) == len(pool)
for old, new in zip(pool, new_pool):
# identity fields untouched...
assert (new.position, new.wt_aa, new.mut_aa) == (old.position, old.wt_aa, old.mut_aa)
# ...delta_ll replaced by the learned acquisition score
assert new.delta_ll == pytest.approx(s.adjusted[(old.position, old.mut_aa)])
# frozen dataclass: originals are unchanged
assert pool[0].delta_ll == 1.0
def test_unmeasured_mutation_falls_back_toward_prior():
"""A mutation never measured gets β≈0, so its adjusted score stays driven by
the (re-weighted) prior plus only the exploration bonus — it isn't invented."""
pool = _pool({(0, "L"): 1.0, (1, "R"): 0.8, (2, "K"): 0.6, (3, "D"): 0.4, (9, "Q"): 5.0})
# measure everything EXCEPT (9, 'Q')
meas = [
([_label(0, "L")], 3.0),
([_label(1, "R")], 2.0),
([_label(2, "K")], 1.0),
([_label(3, "D")], 0.5),
([_label(0, "L"), _label(1, "R")], 4.5),
]
s = fit_surrogate(pool, meas)
assert s.learned is True
# the unmeasured high-prior mutation is still present and finite
assert (9, "Q") in s.adjusted
assert np.isfinite(s.adjusted[(9, "Q")])