"""GP main loop with evolution logging. Seeded RNG, tournament selection, elitism, crossover, mutation. We log the top-K candidates per generation (not the full population) so the persisted artefact stays small and the Streamlit replay can scrub instantly. Fitness results are cached by program signature so elites carrying across generations don't recompute. """ from __future__ import annotations import random from typing import Callable, Sequence import numpy as np import pandas as pd from engine.fitness import fitness_fn from engine.objectives import BinaryAUROCObjective, Objective from engine.program import Program, crossover, mutate, random_program def _assign_id(program: Program, generation: int, idx: int) -> None: program.program_id = f"g{generation}c{idx}" def _tournament_select( rng: random.Random, population: list[Program], fitnesses: list[float], k: int, ) -> Program: contenders = rng.sample(range(len(population)), k) winner = max(contenders, key=lambda i: fitnesses[i]) return population[winner] def run_gp( M_train: pd.DataFrame, y_train: np.ndarray, pool: Sequence[str], *, objective: Objective | None = None, population_size: int = 150, n_generations: int = 30, tournament_k: int = 3, elitism: int = 5, p_mutate: float = 0.7, lambda_size: float = 0.005, cv_folds: int = 5, seed: int = 42, log_top_k: int = 8, on_generation: Callable[[dict], None] | None = None, ) -> tuple[list[dict], Program, float]: """Run the GP loop. Returns (per-generation log, best program, best fitness). ``on_generation`` is invoked once per generation with the just-built log entry; the API layer uses it to stream generation events over SSE. """ obj = objective or BinaryAUROCObjective() py_rng = random.Random(seed) population: list[Program] = [ random_program(py_rng, pool) for _ in range(population_size) ] for i, p in enumerate(population): _assign_id(p, 0, i) p.born = True p.parents = [] fitness_cache: dict[tuple, float] = {} def evaluate(pop: list[Program]) -> list[float]: out = [] for prog in pop: sig = prog.signature if sig not in fitness_cache: fitness_cache[sig] = fitness_fn( M_train, y_train, prog, objective=obj, lambda_size=lambda_size, n_folds=cv_folds, random_state=seed, ) out.append(fitness_cache[sig]) return out log: list[dict] = [] best_overall: Program | None = None best_overall_fit = -np.inf for gen in range(n_generations): fitnesses = evaluate(population) ranked = sorted( zip(population, fitnesses), key=lambda kv: kv[1], reverse=True, ) top = ranked[:log_top_k] gen_entry = { "generation": gen, "best_fitness": float(top[0][1]), "median_fitness": float(np.median(fitnesses)), "elitism": elitism, "candidates": [ { "id": prog.program_id, "gene_ids": prog.gene_ids, "feature_sets": [list(s) for s in prog.feature_sets], "program_repr": prog.program_repr(), "fitness": float(fit), "n_genes": prog.n_genes, "parents": list(prog.parents), "born": bool(prog.born), "survived": rank < elitism, } for rank, (prog, fit) in enumerate(top) ], } log.append(gen_entry) if on_generation is not None: on_generation(gen_entry) if top[0][1] > best_overall_fit: best_overall = top[0][0] best_overall_fit = top[0][1] if gen == n_generations - 1: break elites = [prog for prog, _ in ranked[:elitism]] new_pop: list[Program] = [] for ei, e in enumerate(elites): carried = Program( feature_sets=[list(s) for s in e.feature_sets], parents=[e.program_id], born=False, ) _assign_id(carried, gen + 1, ei) new_pop.append(carried) i = elitism while len(new_pop) < population_size: p1 = _tournament_select(py_rng, population, fitnesses, tournament_k) p2 = _tournament_select(py_rng, population, fitnesses, tournament_k) child = crossover(py_rng, p1, p2) child = mutate(py_rng, child, pool, p_mut=p_mutate) _assign_id(child, gen + 1, i) child.born = True new_pop.append(child) i += 1 population = new_pop assert best_overall is not None return log, best_overall, float(best_overall_fit)