Spaces:
Sleeping
Sleeping
| """Top-level run: prefilter -> GP -> baseline -> permutation -> final TEST eval. | |
| Strict invariants: | |
| - The engine never sees feature names other than the opaque IDs passed in; | |
| this module asserts every column matches `^g\\d+$`. | |
| - The TEST split is touched once per program: for the winner and for the | |
| baseline, after the GP is done. The permutation null uses TEST only as | |
| the score target with shuffled-label TRAIN. | |
| Two entry points: | |
| - ``run_gp_pipeline`` — the existing one-shot batch path used by | |
| ``scripts/run_h2.py``. Backwards-compatible (binary AUROC default). | |
| - ``run_gp_pipeline_streaming`` — the live-streaming path used by the | |
| Lab's FastAPI worker thread; per-generation callback for SSE. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| import time | |
| from typing import Callable | |
| import numpy as np | |
| import pandas as pd | |
| from engine.baseline import BASELINE_K, make_baseline | |
| from engine.fitness import holdout_score | |
| from engine.gp import run_gp | |
| from engine.objectives import BinaryAUROCObjective, Objective | |
| from engine.permutation import permutation_null, permutation_p_value | |
| from engine.prefilter import top_n_features | |
| from engine.split import make_split | |
| _OPAQUE_ID_RE = re.compile(r"^g\d+$") | |
| def _check_opaque_only(M: pd.DataFrame) -> None: | |
| bad = [c for c in M.columns if not _OPAQUE_ID_RE.match(str(c))] | |
| if bad: | |
| raise ValueError( | |
| "engine: matrix columns must be opaque IDs (^g\\d+$); got " | |
| f"non-conforming columns e.g. {bad[:5]}" | |
| ) | |
| def _prepare(M: pd.DataFrame, y: np.ndarray, *, seed: int, test_size: float, | |
| prefilter_n: int | None, objective: Objective, | |
| ): | |
| """Returns ``(M, M_train, M_test, gp_pool, baseline_genes, n_input, | |
| n_dropped, split)``. When ``prefilter_n`` is ``None`` the GP samples | |
| from the full opaque-ID column set; the baseline still uses the | |
| univariate top-K so it remains a meaningful sanity check.""" | |
| _check_opaque_only(M) | |
| n_genes_input = int(M.shape[1]) | |
| nan_cols = M.columns[M.isna().any(axis=0)] | |
| if len(nan_cols): | |
| M = M.drop(columns=nan_cols) | |
| split = make_split( | |
| M.index, y, test_size=test_size, random_state=seed, | |
| stratify=objective.binary, | |
| ) | |
| M_train = M.loc[split.train_ids] | |
| M_test = M.loc[split.test_ids] | |
| if prefilter_n is None: | |
| gp_pool = list(M_train.columns) | |
| baseline_genes, _ = top_n_features( | |
| M_train, split.y_train, n=BASELINE_K, objective=objective, | |
| ) | |
| else: | |
| shortlist, _ = top_n_features( | |
| M_train, split.y_train, n=prefilter_n, objective=objective, | |
| ) | |
| gp_pool = shortlist | |
| baseline_genes = shortlist[:BASELINE_K] | |
| return (M, M_train, M_test, gp_pool, baseline_genes, | |
| n_genes_input, int(len(nan_cols)), split) | |
| def _build_artefacts( | |
| *, | |
| M: pd.DataFrame, | |
| split, | |
| n_genes_input: int, | |
| n_genes_dropped_nan: int, | |
| seed: int, | |
| test_size: float, | |
| prefilter_n: int | None, | |
| cv_folds: int, | |
| n_permutations: int, | |
| population_size: int, | |
| n_generations: int, | |
| objective: Objective, | |
| gp_log: list[dict], | |
| winner, | |
| winner_cv_fitness: float, | |
| winner_holdout: float, | |
| baseline, | |
| baseline_holdout: float, | |
| null: list[float], | |
| p_value: float, | |
| gp_seconds: float, | |
| ) -> tuple[dict, dict]: | |
| fitness_label = objective.fitness_label() | |
| evolution_log = { | |
| "run": { | |
| "seed": int(seed), | |
| "objective_spec": objective.to_dict(), | |
| "fitness_label": fitness_label, | |
| "params": { | |
| "population_size": population_size, | |
| "n_generations": n_generations, | |
| "test_size": test_size, | |
| "prefilter_n": prefilter_n, | |
| "cv_folds": cv_folds, | |
| "n_permutations": n_permutations, | |
| "baseline_k": BASELINE_K, | |
| }, | |
| "n_train": int(len(split.train_ids)), | |
| "n_test": int(len(split.test_ids)), | |
| "n_genes": int(M.shape[1]), | |
| "n_genes_input": n_genes_input, | |
| "n_genes_dropped_nan": n_genes_dropped_nan, | |
| "prefilter_N": None if prefilter_n is None else int(prefilter_n), | |
| "prefilter_note": ( | |
| f"Top-N features by the objective's univariate signal " | |
| f"({fitness_label}), computed on TRAIN only, name-blind." | |
| if prefilter_n is not None | |
| else "Prefilter off: GP samples from the full set of " | |
| "opaque feature IDs (name-blind). Baseline still uses " | |
| "the univariate top-K for sanity." | |
| ), | |
| "gp_seconds": round(gp_seconds, 2), | |
| }, | |
| "generations": gp_log, | |
| } | |
| result = { | |
| "objective_spec": objective.to_dict(), | |
| "fitness_label": fitness_label, | |
| "winning": { | |
| "id": winner.program_id, | |
| "gene_ids": list(winner.gene_ids), | |
| "feature_sets": [list(s) for s in winner.feature_sets], | |
| "program_repr": winner.program_repr(), | |
| "cv_fitness": float(winner_cv_fitness), | |
| "holdout_auroc": float(winner_holdout), | |
| "holdout_score": float(winner_holdout), | |
| "permutation_p": float(p_value), | |
| }, | |
| "baseline": { | |
| "id": baseline.program_id, | |
| "gene_ids": list(baseline.gene_ids), | |
| "feature_sets": [list(s) for s in baseline.feature_sets], | |
| "program_repr": baseline.program_repr(), | |
| "holdout_auroc": float(baseline_holdout), | |
| "holdout_score": float(baseline_holdout), | |
| }, | |
| "permutation_summary": { | |
| "n_permutations": n_permutations, | |
| "null_auroc_mean": float(np.mean(null)), | |
| "null_score_mean": float(np.mean(null)), | |
| "null_auroc_p95": float(np.quantile(null, 0.95)), | |
| "null_score_p95": float(np.quantile(null, 0.95)), | |
| }, | |
| } | |
| return evolution_log, result | |
| def run_gp_pipeline( | |
| M: pd.DataFrame, | |
| y: np.ndarray, | |
| *, | |
| objective: Objective | None = None, | |
| seed: int = 42, | |
| test_size: float = 0.3, | |
| prefilter_n: int | None = 2000, | |
| population_size: int = 150, | |
| n_generations: int = 30, | |
| n_permutations: int = 200, | |
| cv_folds: int = 5, | |
| ) -> tuple[dict, dict]: | |
| """Full GP pipeline. Inputs are name-blind (opaque feature IDs only). | |
| ``prefilter_n=None`` skips the prefilter — the GP samples from the full | |
| column set (still all opaque IDs). The baseline keeps using the | |
| univariate top-K so it stays an apples-to-apples sanity check. | |
| """ | |
| obj = objective or BinaryAUROCObjective() | |
| (M, M_train, M_test, gp_pool, baseline_genes, | |
| n_genes_input, n_dropped, split) = _prepare( | |
| M, y, seed=seed, test_size=test_size, | |
| prefilter_n=prefilter_n, objective=obj, | |
| ) | |
| t0 = time.time() | |
| gp_log, winner, winner_cv_fitness = run_gp( | |
| M_train, split.y_train, gp_pool, | |
| objective=obj, | |
| population_size=population_size, | |
| n_generations=n_generations, | |
| cv_folds=cv_folds, | |
| seed=seed, | |
| ) | |
| gp_seconds = time.time() - t0 | |
| winner_holdout = holdout_score( | |
| M_train, split.y_train, M_test, split.y_test, winner, | |
| objective=obj, | |
| ) | |
| baseline = make_baseline(baseline_genes, k=BASELINE_K) | |
| baseline_holdout = holdout_score( | |
| M_train, split.y_train, M_test, split.y_test, baseline, | |
| objective=obj, | |
| ) | |
| null = permutation_null( | |
| M_train, split.y_train, M_test, split.y_test, | |
| objective=obj, | |
| n_permutations=n_permutations, seed=seed, | |
| ) | |
| p_value = permutation_p_value(winner_holdout, null) | |
| return _build_artefacts( | |
| M=M, split=split, | |
| n_genes_input=n_genes_input, n_genes_dropped_nan=n_dropped, | |
| seed=seed, test_size=test_size, prefilter_n=prefilter_n, | |
| cv_folds=cv_folds, n_permutations=n_permutations, | |
| population_size=population_size, n_generations=n_generations, | |
| objective=obj, | |
| gp_log=gp_log, winner=winner, winner_cv_fitness=winner_cv_fitness, | |
| winner_holdout=winner_holdout, | |
| baseline=baseline, baseline_holdout=baseline_holdout, | |
| null=null, p_value=p_value, gp_seconds=gp_seconds, | |
| ) | |
| def run_gp_pipeline_streaming( | |
| M: pd.DataFrame, | |
| y: np.ndarray, | |
| *, | |
| objective: Objective | None = None, | |
| on_generation: Callable[[dict], None], | |
| seed: int = 42, | |
| test_size: float = 0.3, | |
| prefilter_n: int | None = 2000, | |
| population_size: int = 150, | |
| n_generations: int = 30, | |
| n_permutations: int = 200, | |
| cv_folds: int = 5, | |
| ) -> dict: | |
| """Same as ``run_gp_pipeline`` but invokes ``on_generation`` per generation. | |
| Returns the ``result`` dict only — the caller has been receiving every | |
| generation already via the callback, so the full evolution log is | |
| rebuilt by the API layer from those events. | |
| """ | |
| obj = objective or BinaryAUROCObjective() | |
| (M, M_train, M_test, gp_pool, baseline_genes, | |
| n_genes_input, n_dropped, split) = _prepare( | |
| M, y, seed=seed, test_size=test_size, | |
| prefilter_n=prefilter_n, objective=obj, | |
| ) | |
| t0 = time.time() | |
| gp_log, winner, winner_cv_fitness = run_gp( | |
| M_train, split.y_train, gp_pool, | |
| objective=obj, | |
| population_size=population_size, | |
| n_generations=n_generations, | |
| cv_folds=cv_folds, | |
| seed=seed, | |
| on_generation=on_generation, | |
| ) | |
| gp_seconds = time.time() - t0 | |
| winner_holdout = holdout_score( | |
| M_train, split.y_train, M_test, split.y_test, winner, | |
| objective=obj, | |
| ) | |
| baseline = make_baseline(baseline_genes, k=BASELINE_K) | |
| baseline_holdout = holdout_score( | |
| M_train, split.y_train, M_test, split.y_test, baseline, | |
| objective=obj, | |
| ) | |
| null = permutation_null( | |
| M_train, split.y_train, M_test, split.y_test, | |
| objective=obj, | |
| n_permutations=n_permutations, seed=seed, | |
| ) | |
| p_value = permutation_p_value(winner_holdout, null) | |
| _evolution_log, result = _build_artefacts( | |
| M=M, split=split, | |
| n_genes_input=n_genes_input, n_genes_dropped_nan=n_dropped, | |
| seed=seed, test_size=test_size, prefilter_n=prefilter_n, | |
| cv_folds=cv_folds, n_permutations=n_permutations, | |
| population_size=population_size, n_generations=n_generations, | |
| objective=obj, | |
| gp_log=gp_log, winner=winner, winner_cv_fitness=winner_cv_fitness, | |
| winner_holdout=winner_holdout, | |
| baseline=baseline, baseline_holdout=baseline_holdout, | |
| null=null, p_value=p_value, gp_seconds=gp_seconds, | |
| ) | |
| return result | |