oncodsl / tests /test_engine.py
govindbalki's picture
Upload folder using huggingface_hub
0fff343 verified
Raw
History Blame Contribute Delete
11.5 kB
"""Smoke tests for the GP engine. Synthetic data only — no network, no parquet."""
from __future__ import annotations
import random
import numpy as np
import pandas as pd
import pytest
from engine.baseline import make_baseline
from engine.fitness import cv_auroc, fitness_fn, program_state
from engine.gp import run_gp
from engine.permutation import permutation_null, permutation_p_value
from engine.pipeline import run_gp_pipeline
from engine.prefilter import top_n_features
from engine.program import (
MAX_SET_SIZE,
Program,
crossover,
mutate,
random_program,
)
from engine.split import make_split
# --- synthetic data --------------------------------------------------------
def _synthetic_matrix(
n_samples: int = 80,
n_features: int = 200,
n_informative: int = 10,
seed: int = 0,
) -> tuple[pd.DataFrame, np.ndarray]:
rng = np.random.default_rng(seed)
half = n_samples // 2
y = np.array([1] * half + [0] * (n_samples - half))
cols = [f"g{i+1:05d}" for i in range(n_features)]
sample_ids = pd.Index([f"s{i}" for i in range(n_samples)], name="sample_id")
X = rng.normal(loc=0.0, scale=1.0, size=(n_samples, n_features))
# Inject signal in the first `n_informative` features.
X[:half, :n_informative] += 2.5
M = pd.DataFrame(X, index=sample_ids, columns=cols)
return M, y
# --- prefilter --------------------------------------------------------------
def test_prefilter_surfaces_the_informative_features():
M, y = _synthetic_matrix(n_samples=100, n_features=200, n_informative=10)
top, scores = top_n_features(M, y, n=20)
informative = {f"g{i+1:05d}" for i in range(10)}
overlap = informative & set(top)
assert len(overlap) >= 8 # the prefilter should grab most/all of them
# --- program operators -----------------------------------------------------
def test_random_program_well_formed():
rng = random.Random(0)
pool = [f"g{i:05d}" for i in range(50)]
p = random_program(rng, pool)
assert 1 <= len(p.feature_sets) <= 2
for fs in p.feature_sets:
assert 2 <= len(fs) <= MAX_SET_SIZE
assert all(g in pool for g in fs)
# Gene IDs should be unique across sets.
assert len(p.gene_ids) == len(set(p.gene_ids))
def test_mutate_preserves_invariants():
rng = random.Random(1)
pool = [f"g{i:05d}" for i in range(50)]
p = random_program(rng, pool)
for _ in range(20):
p = mutate(rng, p, pool, p_mut=1.0)
assert 1 <= len(p.feature_sets) <= 2
for fs in p.feature_sets:
assert 2 <= len(fs) <= MAX_SET_SIZE
assert len(p.gene_ids) == len(set(p.gene_ids))
def test_crossover_produces_valid_child():
rng = random.Random(2)
pool = [f"g{i:05d}" for i in range(50)]
p1 = random_program(rng, pool)
p2 = random_program(rng, pool)
child = crossover(rng, p1, p2)
assert child.feature_sets
assert len(child.gene_ids) == len(set(child.gene_ids))
assert child.parents == [p1.program_id, p2.program_id]
# --- fitness ----------------------------------------------------------------
def test_program_state_shape():
M, _ = _synthetic_matrix(n_samples=20, n_features=10)
p = Program(feature_sets=[["g00001", "g00002"], ["g00005", "g00006", "g00007"]])
state = program_state(M, p)
assert state.shape == (20, 2)
def test_cv_auroc_strong_for_informative_program():
M, y = _synthetic_matrix(n_samples=120, n_features=50, n_informative=8)
p = Program(feature_sets=[[f"g{i+1:05d}" for i in range(8)]])
score = cv_auroc(M, y, p, n_folds=5, random_state=0)
assert score > 0.95
def test_fitness_penalises_size():
M, y = _synthetic_matrix(n_samples=120, n_features=50, n_informative=8)
small = Program(feature_sets=[[f"g{i+1:05d}" for i in range(2)]])
big = Program(feature_sets=[[f"g{i+1:05d}" for i in range(8)]])
small_fit = fitness_fn(M, y, small, lambda_size=0.01)
big_fit = fitness_fn(M, y, big, lambda_size=0.01)
# both should be high; size penalty makes big at least slightly closer.
assert (big_fit + 0.01 * (big.n_genes - small.n_genes)) >= small_fit - 0.05
# --- GP loop ---------------------------------------------------------------
def test_run_gp_improves_or_holds_fitness_over_generations():
M, y = _synthetic_matrix(n_samples=120, n_features=80, n_informative=10)
pool, _ = top_n_features(M, y, n=40)
log, winner, fit = run_gp(
M, y, pool,
population_size=20, n_generations=8, seed=0,
)
assert len(log) == 8
assert log[-1]["best_fitness"] >= log[0]["best_fitness"] - 1e-9
# --- baseline / permutation ------------------------------------------------
def test_baseline_uses_first_k():
shortlist = [f"g{i:05d}" for i in range(20)]
b = make_baseline(shortlist, k=5)
assert b.feature_sets == [shortlist[:5]]
assert b.program_id == "baseline"
def test_permutation_null_is_around_chance():
M, y = _synthetic_matrix(n_samples=120, n_features=100, n_informative=10)
split = make_split(M.index, y, test_size=0.3, random_state=0)
nulls = permutation_null(
M.loc[split.train_ids], split.y_train,
M.loc[split.test_ids], split.y_test,
n_permutations=20, seed=0,
)
assert 0.3 < np.mean(nulls) < 0.7
p = permutation_p_value(0.99, nulls)
assert 0.0 < p <= 1.0
# --- pipeline / airgap enforcement -----------------------------------------
def test_pipeline_rejects_named_columns():
M = pd.DataFrame(
{"MLH1": [1.0, 2.0, 3.0, 4.0], "TP53": [5.0, 6.0, 7.0, 8.0]},
index=["s1", "s2", "s3", "s4"],
)
y = np.array([1, 0, 1, 0])
with pytest.raises(ValueError, match="opaque IDs"):
run_gp_pipeline(M, y)
def test_pipeline_end_to_end_on_synthetic():
M, y = _synthetic_matrix(n_samples=120, n_features=80, n_informative=10)
evolution_log, result = run_gp_pipeline(
M, y,
seed=0,
prefilter_n=30,
population_size=15,
n_generations=4,
n_permutations=10,
)
assert "winning" in result
assert result["winning"]["holdout_auroc"] > 0.8
assert 0.0 < result["winning"]["permutation_p"] <= 1.0
assert len(evolution_log["generations"]) == 4
# Top-of-rank candidates carry the survived flag.
cand0 = evolution_log["generations"][0]["candidates"][0]
assert cand0["survived"] is True
# --- objective dispatch + correlation path --------------------------------
def _synthetic_continuous(
n_samples: int = 120,
n_features: int = 100,
n_informative: int = 8,
seed: int = 0,
) -> tuple[pd.DataFrame, np.ndarray]:
"""Build a name-blind matrix where the first `n_informative` features
NEGATIVELY track a continuous y (good test for CorrelationObjective
with direction='neg')."""
rng = np.random.default_rng(seed)
cols = [f"g{i+1:05d}" for i in range(n_features)]
sample_ids = pd.Index([f"s{i}" for i in range(n_samples)], name="sample_id")
y = rng.normal(loc=10.0, scale=3.0, size=n_samples)
X = rng.normal(loc=5.0, scale=1.0, size=(n_samples, n_features))
# Negatively correlated informative cols: feature = -y + noise
for j in range(n_informative):
X[:, j] = -0.8 * y + rng.normal(scale=0.5, size=n_samples)
M = pd.DataFrame(X, index=sample_ids, columns=cols)
return M, y
def test_correlation_objective_prefilter_finds_informative():
from engine.objectives import CorrelationObjective
from engine.prefilter import top_n_features
M, y = _synthetic_continuous(n_samples=150, n_features=200, n_informative=10)
obj = CorrelationObjective(direction="neg")
top, scores = top_n_features(M, y, n=20, objective=obj)
informative = {f"g{i+1:05d}" for i in range(10)}
assert len(informative & set(top)) >= 8
def test_correlation_objective_pipeline_end_to_end():
from engine.objectives import CorrelationObjective
M, y = _synthetic_continuous(n_samples=180, n_features=120, n_informative=10)
evolution_log, result = run_gp_pipeline(
M, y,
objective=CorrelationObjective(direction="neg"),
seed=0,
prefilter_n=40,
population_size=20,
n_generations=4,
n_permutations=10,
)
# Direction-adjusted score should be solidly positive on this signal.
assert result["winning"]["holdout_score"] > 0.5
assert result["objective_spec"]["target"] == "tmb"
assert result["objective_spec"]["direction"] == "neg"
assert evolution_log["run"]["fitness_label"] == "|spearman|"
def test_pipeline_unknown_objective_rejected_by_spec_builder():
from engine.objectives import objective_from_spec
with pytest.raises(ValueError):
objective_from_spec({"target": "survival", "metric": "auroc"})
def test_pipeline_no_prefilter_samples_full_pool():
"""With prefilter_n=None the GP must be able to reach genes a tight
prefilter would have excluded."""
from engine.gp import run_gp
from engine.objectives import BinaryAUROCObjective
# 60 features; the informative ones are at indices 50..59 — they would
# never make a top-10 univariate prefilter because of how we build the
# synthetic noise, but they MUST be reachable when prefilter is off.
rng = np.random.default_rng(2)
n_samples = 80
n_features = 60
cols = [f"g{i+1:05d}" for i in range(n_features)]
sample_ids = pd.Index([f"s{i}" for i in range(n_samples)], name="sample_id")
y = np.array([1] * 40 + [0] * 40)
X = rng.normal(loc=0.0, scale=1.0, size=(n_samples, n_features))
# Mild signal in late features only — visible enough to be picked when
# the GP can reach them, but no single one would dominate a prefilter.
for j in range(50, 60):
X[:40, j] += 0.6
M = pd.DataFrame(X, index=sample_ids, columns=cols)
log_off, result_off = run_gp_pipeline(
M, y,
seed=0,
prefilter_n=None,
population_size=30,
n_generations=6,
n_permutations=5,
)
assert log_off["run"]["prefilter_N"] is None
# The GP saw the full pool (n_features) — confirmed by the n_genes
# field in the run header.
assert log_off["run"]["n_genes"] == n_features
# And it can pick from late features; check at least one late feature
# appears anywhere in the top-K candidates over the run.
late = {f"g{i+1:05d}" for i in range(50, 60)}
seen = {g for gen in log_off["generations"]
for c in gen["candidates"] for g in c["gene_ids"]}
assert late & seen, (
"GP with prefilter off never sampled any late-index feature — "
"init/mutation are not drawing from the full pool"
)
def test_streaming_pipeline_emits_one_event_per_generation():
from engine.pipeline import run_gp_pipeline_streaming
M, y = _synthetic_matrix(n_samples=120, n_features=60, n_informative=8)
received: list[dict] = []
result = run_gp_pipeline_streaming(
M, y,
on_generation=lambda e: received.append(e),
seed=0,
prefilter_n=20,
population_size=12,
n_generations=3,
n_permutations=5,
)
assert [e["generation"] for e in received] == [0, 1, 2]
# Every event has the lab-shape candidates.
for e in received:
assert "best_fitness" in e and "median_fitness" in e
assert all("survived" in c for c in e["candidates"])
# The final result is the same shape as the batch path.
assert "winning" in result and "permutation_p" in result["winning"]