Spaces:
Sleeping
Sleeping
| """engine_v2 top-level pipeline. | |
| Inputs are name-blind (opaque feature IDs only). Two entry points: | |
| - ``run_v2_pipeline`` — batch path returning ``(evolution_log, result)``. | |
| - ``run_v2_pipeline_streaming`` — on_generation callback for SSE. | |
| Steps: split TRAIN/TEST → optional prefilter → typed GP → winner | |
| held-out → winner permutation null (winner held FIXED, target | |
| shuffled). The baseline is dropped from v2 — its old "univariate | |
| top-K" interpretation doesn't transfer cleanly to typed trees. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| import time | |
| from typing import Callable | |
| import numpy as np | |
| import pandas as pd | |
| from engine.prefilter import top_n_features | |
| from engine.split import make_split | |
| # We reuse the v1 prefilter only via the BinaryAUROCObjective signal, so | |
| # import lazily inside the prefilter branch — engine_v2 itself stays free | |
| # of v1 fitness coupling. | |
| from engine_v2.fitness import ( | |
| V2Objective, | |
| evaluate_holdout, | |
| fitness_fn, | |
| make_ctx, | |
| ) | |
| from engine_v2.gp import run_gp_v2 | |
| from engine_v2.nodes import ExecContext, Node | |
| from engine_v2.permutation import ( | |
| permutation_null, | |
| permutation_p_value, | |
| unsup_random_null, | |
| ) | |
| _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_v2: matrix columns must be opaque IDs (^g\\d+$); " | |
| f"got non-conforming columns e.g. {bad[:5]}" | |
| ) | |
| def _prefilter_pool( | |
| M_train: pd.DataFrame, | |
| y_train: np.ndarray, | |
| n: int, | |
| objective: V2Objective, | |
| ) -> list[str]: | |
| """Narrow the column pool. We dispatch on the objective so that: | |
| - MSI uses the binary-AUROC univariate ranking | |
| (engine.objectives.BinaryAUROCObjective). | |
| - TMB uses the absolute-Spearman ranking | |
| (engine.objectives.CorrelationObjective). | |
| """ | |
| if objective.target == "msi": | |
| from engine.objectives import BinaryAUROCObjective | |
| obj = BinaryAUROCObjective() | |
| else: | |
| from engine.objectives import CorrelationObjective | |
| obj = CorrelationObjective(direction="neg") | |
| shortlist, _ = top_n_features(M_train, y_train, n=n, objective=obj) | |
| return shortlist | |
| def _align_priors( | |
| M: pd.DataFrame, residualize_scores: pd.DataFrame, | |
| ) -> tuple[pd.DataFrame, pd.DataFrame]: | |
| """Drop cohort rows whose prior axis scores are missing or | |
| non-finite — they can't be projected. Returns (M, priors) on the | |
| same valid index. Defining the cohort this way is NOT leakage; it | |
| just says "we can only score patients we have priors for".""" | |
| aligned = residualize_scores.apply(pd.to_numeric, errors="coerce") | |
| aligned = aligned.reindex(M.index) | |
| valid = aligned.notna().all(axis=1) & np.isfinite(aligned).all(axis=1) | |
| if not bool(valid.all()): | |
| M = M.loc[valid] | |
| aligned = aligned.loc[valid] | |
| return M, aligned | |
| def _fit_residualise_beta( | |
| M_train: pd.DataFrame, priors_train: pd.DataFrame, | |
| ) -> np.ndarray | None: | |
| """Vectorised OLS of every column of M on ``[intercept, *priors]``, | |
| fit using TRAIN rows ONLY. Returns the (1+k, g) coefficient matrix, | |
| or None when the train slice is too small to fit. The key | |
| invariant: these coefficients depend on TRAIN rows only — they're | |
| later applied to the full cohort (train + test) so the test rows | |
| are never used to choose the projection. This is what makes | |
| held-out AUROC honest under peel-off.""" | |
| if M_train.shape[0] < 5: | |
| return None | |
| n = M_train.shape[0] | |
| P = np.column_stack( | |
| [np.ones(n), priors_train.to_numpy(dtype=float)], | |
| ) | |
| Y = M_train.to_numpy(dtype=float) | |
| beta, *_ = np.linalg.lstsq(P, Y, rcond=None) | |
| return beta | |
| def _apply_residualise( | |
| M: pd.DataFrame, priors: pd.DataFrame, beta: np.ndarray, | |
| ) -> pd.DataFrame: | |
| """Apply a previously-fit residualisation projection to ``M``. | |
| Test rows are residualised using train-fit ``beta`` — never their | |
| own. ``priors`` must be aligned to ``M.index``; columns must match | |
| the priors used at fit time.""" | |
| n = len(M) | |
| P = np.column_stack([np.ones(n), priors.to_numpy(dtype=float)]) | |
| Y = M.to_numpy(dtype=float) | |
| resid = Y - P @ beta | |
| return pd.DataFrame(resid, index=M.index, columns=M.columns) | |
| def _make_full_ctx( | |
| M: pd.DataFrame, | |
| clinical: pd.DataFrame | None, | |
| ) -> ExecContext: | |
| """Full-cohort ExecContext for executing the winner across every | |
| patient (so the iterative-discovery chain can residualise against | |
| the per-patient scores on the next run). | |
| Labels are deliberately empty for both supervised and unsupervised | |
| peel-off. For supervised winners that contain ``FitApply(..., | |
| target)``, executing with empty labels causes FitApply to | |
| short-circuit to its raw inner vector (the pre-fit score) — which | |
| is what we want as the residualisation target: it's monotonic | |
| with the LR-fitted prediction in the single-1D-input case so the | |
| chain is consistent, and it sidesteps any test-row leakage from | |
| re-fitting on the full cohort here. | |
| """ | |
| return ExecContext( | |
| M=M, | |
| clinical=( | |
| clinical if clinical is not None | |
| else pd.DataFrame(index=M.index) | |
| ), | |
| labels={}, | |
| ) | |
| def _full_cohort_winner_scores( | |
| winner: Node, | |
| M: pd.DataFrame, | |
| clinical: pd.DataFrame | None, | |
| ) -> tuple[list[float | None], list[str]]: | |
| """Re-execute the winner on the full cohort and return per-patient | |
| scores (finite-guarded) + their sample-id labels. Used to feed the | |
| iterative-discovery chain's residualisation step on the NEXT run. | |
| Currently only emitted for the unsupervised objective.""" | |
| full_ctx = _make_full_ctx(M, clinical) | |
| sample_ids = [str(s) for s in M.index] | |
| try: | |
| out = winner.execute(full_ctx) | |
| except Exception: | |
| return [], sample_ids | |
| if not isinstance(out, pd.Series): | |
| return [], sample_ids | |
| scores: list[float | None] = [] | |
| for v in out.values: | |
| try: | |
| f = float(v) | |
| except (TypeError, ValueError): | |
| scores.append(None) | |
| continue | |
| scores.append(f if np.isfinite(f) else None) | |
| return scores, sample_ids | |
| def _build_ctxs( | |
| M: pd.DataFrame, | |
| split, | |
| *, | |
| primary_target_name: str, | |
| clinical: pd.DataFrame | None, | |
| extra_labels: dict[str, np.ndarray] | None, | |
| confounders: tuple[str, ...] = ("stage", "age"), | |
| ) -> tuple[ExecContext, ExecContext]: | |
| """Build TRAIN and TEST ExecContexts from a split + optional clinical | |
| + optional other-target labels. The primary y is keyed by the | |
| objective's target name. | |
| For the unsupervised objective (target='none'), every label is | |
| stripped — the engine literally cannot see msi / tmb during search. | |
| The held-out labels are recovered in api/_worker for the post-hoc | |
| alignment check. | |
| """ | |
| M_train = M.loc[split.train_ids] | |
| M_test = M.loc[split.test_ids] | |
| clin_train = clinical.loc[split.train_ids] if clinical is not None else None | |
| clin_test = clinical.loc[split.test_ids] if clinical is not None else None | |
| is_unsup = primary_target_name == "none" | |
| def slice_labels(side_ids, side_y) -> dict[str, np.ndarray]: | |
| if is_unsup: | |
| return {} # airgap: no labels to the engine during unsup search | |
| labels: dict[str, np.ndarray] = {primary_target_name: side_y} | |
| if extra_labels: | |
| id_to_pos = {sid: i for i, sid in enumerate(M.index)} | |
| pos = np.array([id_to_pos[sid] for sid in side_ids]) | |
| for k, v in extra_labels.items(): | |
| if k == primary_target_name: | |
| continue | |
| arr = np.asarray(v) | |
| if len(arr) == len(M): | |
| labels[k] = arr[pos] | |
| return labels | |
| ctx_train = ExecContext( | |
| M=M_train, | |
| clinical=clin_train if clin_train is not None else pd.DataFrame(index=M_train.index), | |
| labels=slice_labels(split.train_ids, split.y_train), | |
| confounders=confounders, | |
| ) | |
| ctx_test = ExecContext( | |
| M=M_test, | |
| clinical=clin_test if clin_test is not None else pd.DataFrame(index=M_test.index), | |
| labels=slice_labels(split.test_ids, split.y_test), | |
| confounders=confounders, | |
| ) | |
| if is_unsup: | |
| assert ctx_train.labels == {} and ctx_test.labels == {}, ( | |
| "unsup ExecContext must carry no labels — engine airgap" | |
| ) | |
| return ctx_train, ctx_test | |
| def run_v2_pipeline( | |
| M: pd.DataFrame, | |
| y: np.ndarray | None, | |
| *, | |
| objective: V2Objective, | |
| seed: int = 42, | |
| test_size: float = 0.3, | |
| prefilter_n: int | None = None, | |
| population_size: int = 200, | |
| n_generations: int = 40, | |
| n_permutations: int = 200, | |
| cv_folds: int = 5, | |
| tournament_k: int = 3, | |
| elitism: int = 5, | |
| p_mutate: float = 0.7, | |
| lambda_size: float = 0.005, | |
| max_depth: int = 4, | |
| max_genes_per_set: int = 8, | |
| max_nodes: int = 64, | |
| clinical: pd.DataFrame | None = None, | |
| extra_labels: dict[str, np.ndarray] | None = None, | |
| residualize_scores: pd.DataFrame | None = None, | |
| coherence_weight: float = 0.0, | |
| confounders: tuple[str, ...] = ("stage", "age"), | |
| immigrant_fraction: float = 0.0, | |
| rates_override: dict | None = None, | |
| scalar_share_override: float | None = None, | |
| ) -> tuple[dict, dict]: | |
| """Full engine_v2 pipeline. | |
| ``clinical`` (stage / age, indexed like M) and ``extra_labels`` | |
| (e.g. include "tmb" when the objective targets "msi", and vice | |
| versa) feed the full-DSL operators (Split / Effect / Associate / | |
| FitApply) without compromising the airgap — genes stay opaque. | |
| """ | |
| _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) | |
| # Peel-off chain: align M to patients with prior-axis scores | |
| # BEFORE the split (defining the cohort isn't leakage). The actual | |
| # residualisation projection is fit AFTER the split, on TRAIN rows | |
| # only, then applied to the full M — so test features are | |
| # residualised with train-fit coefficients, never their own. This | |
| # is what makes held-out AUROC honest under peel-off. | |
| priors_aligned: pd.DataFrame | None = None | |
| if residualize_scores is not None and len(residualize_scores.columns) > 0: | |
| M, priors_aligned = _align_priors(M, residualize_scores) | |
| if clinical is not None: | |
| clinical = clinical.reindex(M.index) | |
| is_unsup = objective.target == "none" | |
| split_y = y if y is not None else np.zeros(len(M), dtype=float) | |
| split = make_split( | |
| M.index, split_y, test_size=test_size, random_state=seed, | |
| stratify=objective.binary, | |
| ) | |
| # Fit residualisation on TRAIN rows only; apply to the full M so | |
| # train + test features sit in the same residualised space without | |
| # the test rows ever being seen by the projection fit. | |
| if priors_aligned is not None: | |
| beta = _fit_residualise_beta( | |
| M.loc[split.train_ids], priors_aligned.loc[split.train_ids], | |
| ) | |
| if beta is not None: | |
| M = _apply_residualise(M, priors_aligned, beta) | |
| ctx_train, ctx_test = _build_ctxs( | |
| M, split, | |
| primary_target_name=objective.target, | |
| clinical=clinical, | |
| extra_labels=extra_labels, | |
| confounders=confounders, | |
| ) | |
| # Prefilter is target-driven; for unsup there's no target, fall back | |
| # to the full opaque pool. | |
| pool = ( | |
| _prefilter_pool(ctx_train.M, split.y_train, prefilter_n, objective) | |
| if (prefilter_n is not None and not is_unsup) | |
| else list(ctx_train.M.columns) | |
| ) | |
| gp_y_train = None if is_unsup else split.y_train | |
| gp_y_test = None if is_unsup else split.y_test | |
| t0 = time.time() | |
| log, winner, winner_cv_fitness = run_gp_v2( | |
| ctx_train, gp_y_train, pool, | |
| objective=objective, | |
| population_size=population_size, | |
| n_generations=n_generations, | |
| tournament_k=tournament_k, | |
| elitism=elitism, | |
| p_mutate=p_mutate, | |
| lambda_size=lambda_size, | |
| coherence_weight=coherence_weight, | |
| immigrant_fraction=immigrant_fraction, | |
| rates_override=rates_override, | |
| scalar_share_override=scalar_share_override, | |
| cv_folds=cv_folds, | |
| seed=seed, | |
| max_depth=max_depth, | |
| max_genes_per_set=max_genes_per_set, | |
| max_nodes=max_nodes, | |
| ) | |
| gp_seconds = time.time() - t0 | |
| winner_holdout = evaluate_holdout( | |
| winner, ctx_test, gp_y_test, | |
| objective=objective, ctx_train=ctx_train, | |
| ) | |
| # Re-execute the winner to extract per-patient held-out scores so the | |
| # API worker can compute post-hoc alignment (unsup) without parsing | |
| # the program_repr back into a Node. Falls back to empty if execution | |
| # was degenerate. | |
| try: | |
| _w_scores_series = winner.execute(ctx_test) | |
| if isinstance(_w_scores_series, pd.Series): | |
| winner_holdout_scores = [ | |
| float(v) if np.isfinite(v) else None for v in _w_scores_series.values | |
| ] | |
| else: | |
| winner_holdout_scores = [] | |
| except Exception: | |
| winner_holdout_scores = [] | |
| holdout_sample_ids = [str(s) for s in ctx_test.M.index] | |
| # Full-cohort scores: re-execute the winner on every patient | |
| # (train+test combined, labels stripped) so the iterative- | |
| # discovery chain can residualise against this axis on the next | |
| # run. Emitted for every objective now — MSI/HPV/TMB can enumerate | |
| # axes too via Find next axis. | |
| full_scores, full_sample_ids = _full_cohort_winner_scores( | |
| winner, M, clinical, | |
| ) | |
| if is_unsup: | |
| rates = objective.synthesis_overrides().get("rates") | |
| nulls = unsup_random_null( | |
| ctx_test, pool, | |
| objective=objective, | |
| n_permutations=n_permutations, | |
| rates=rates, | |
| max_depth=max_depth, | |
| max_genes_per_set=max_genes_per_set, | |
| seed=seed, | |
| ctx_train=ctx_train, | |
| ) | |
| else: | |
| nulls = permutation_null( | |
| winner, ctx_test, split.y_test, | |
| objective=objective, | |
| n_permutations=n_permutations, | |
| seed=seed, | |
| ) | |
| p_value = permutation_p_value(winner_holdout, nulls) | |
| winner_repr = winner.repr_typed() | |
| winner_gene_ids = list(dict.fromkeys(winner.feature_ids())) # dedup keep-order | |
| evolution_log = { | |
| "engine": "v2", | |
| "run": { | |
| "seed": int(seed), | |
| "objective_spec": objective.to_dict(), | |
| "fitness_label": objective.fitness_label(), | |
| "params": { | |
| "population_size": population_size, | |
| "n_generations": n_generations, | |
| "tournament_k": tournament_k, | |
| "elitism": elitism, | |
| "p_mutate": p_mutate, | |
| "lambda_size": lambda_size, | |
| "test_size": test_size, | |
| "prefilter_n": prefilter_n, | |
| "cv_folds": cv_folds, | |
| "n_permutations": n_permutations, | |
| "max_depth": max_depth, | |
| "max_genes_per_set": max_genes_per_set, | |
| "max_nodes": max_nodes, | |
| }, | |
| "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": int(len(nan_cols)), | |
| "prefilter_N": None if prefilter_n is None else int(prefilter_n), | |
| "prefilter_note": ( | |
| "Prefilter off: typed GP samples FeatureSets from the full " | |
| "opaque-ID column set." | |
| if prefilter_n is None | |
| else f"Prefilter on: typed GP samples FeatureSets from the " | |
| f"top-{prefilter_n} univariate features (TRAIN only)." | |
| ), | |
| "gp_seconds": round(gp_seconds, 2), | |
| }, | |
| "generations": log, | |
| } | |
| worst = objective.worst_score() | |
| finite_nulls = [n for n in nulls if np.isfinite(n)] | |
| def _f(x: float) -> float: | |
| return float(x) if np.isfinite(x) else worst | |
| result = { | |
| "engine": "v2", | |
| "objective_spec": objective.to_dict(), | |
| "fitness_label": objective.fitness_label(), | |
| "winning": { | |
| "id": "winner", | |
| "program_repr": winner_repr, | |
| "gene_ids": winner_gene_ids, | |
| "n_nodes": int(winner.node_count()), | |
| "depth": int(winner.depth()), | |
| "cv_fitness": _f(winner_cv_fitness), | |
| "holdout_score": _f(winner_holdout), | |
| "holdout_auroc": _f(winner_holdout), | |
| "permutation_p": ( | |
| float(p_value) if np.isfinite(p_value) else 1.0 | |
| ), | |
| "holdout_scores": winner_holdout_scores, | |
| "holdout_sample_ids": holdout_sample_ids, | |
| "full_scores": full_scores, | |
| "full_sample_ids": full_sample_ids, | |
| }, | |
| "permutation_summary": { | |
| "n_permutations": n_permutations, | |
| "null_kind": ( | |
| "random_vector_programs" if is_unsup | |
| else "winner_fixed_target_shuffle" | |
| ), | |
| "null_score_mean": ( | |
| float(np.mean(finite_nulls)) if finite_nulls else worst | |
| ), | |
| "null_score_p95": ( | |
| float(np.quantile(finite_nulls, 0.95)) | |
| if finite_nulls else worst | |
| ), | |
| }, | |
| } | |
| return evolution_log, result | |
| def run_v2_pipeline_streaming( | |
| M: pd.DataFrame, | |
| y: np.ndarray | None, | |
| *, | |
| objective: V2Objective, | |
| on_generation: Callable[[dict], None], | |
| seed: int = 42, | |
| test_size: float = 0.3, | |
| prefilter_n: int | None = None, | |
| population_size: int = 200, | |
| n_generations: int = 40, | |
| n_permutations: int = 200, | |
| cv_folds: int = 5, | |
| tournament_k: int = 3, | |
| elitism: int = 5, | |
| p_mutate: float = 0.7, | |
| lambda_size: float = 0.005, | |
| max_depth: int = 4, | |
| max_genes_per_set: int = 8, | |
| max_nodes: int = 64, | |
| clinical: pd.DataFrame | None = None, | |
| extra_labels: dict[str, np.ndarray] | None = None, | |
| residualize_scores: pd.DataFrame | None = None, | |
| coherence_weight: float = 0.0, | |
| confounders: tuple[str, ...] = ("stage", "age"), | |
| immigrant_fraction: float = 0.0, | |
| rates_override: dict | None = None, | |
| scalar_share_override: float | None = None, | |
| ) -> dict: | |
| """Streaming variant — calls ``on_generation(entry)`` each generation | |
| and returns the final result dict. The caller stores the full | |
| population in its own log via the callback.""" | |
| _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) | |
| # Peel-off chain: align M to patients with prior-axis scores | |
| # BEFORE the split (defining the cohort isn't leakage). The actual | |
| # residualisation projection is fit AFTER the split, on TRAIN rows | |
| # only, then applied to the full M — so test features are | |
| # residualised with train-fit coefficients, never their own. This | |
| # is what makes held-out AUROC honest under peel-off. | |
| priors_aligned: pd.DataFrame | None = None | |
| if residualize_scores is not None and len(residualize_scores.columns) > 0: | |
| M, priors_aligned = _align_priors(M, residualize_scores) | |
| if clinical is not None: | |
| clinical = clinical.reindex(M.index) | |
| is_unsup = objective.target == "none" | |
| split_y = y if y is not None else np.zeros(len(M), dtype=float) | |
| split = make_split( | |
| M.index, split_y, test_size=test_size, random_state=seed, | |
| stratify=objective.binary, | |
| ) | |
| # Fit residualisation on TRAIN rows only; apply to the full M so | |
| # train + test features sit in the same residualised space without | |
| # the test rows ever being seen by the projection fit. | |
| if priors_aligned is not None: | |
| beta = _fit_residualise_beta( | |
| M.loc[split.train_ids], priors_aligned.loc[split.train_ids], | |
| ) | |
| if beta is not None: | |
| M = _apply_residualise(M, priors_aligned, beta) | |
| ctx_train, ctx_test = _build_ctxs( | |
| M, split, | |
| primary_target_name=objective.target, | |
| clinical=clinical, | |
| extra_labels=extra_labels, | |
| confounders=confounders, | |
| ) | |
| pool = ( | |
| _prefilter_pool(ctx_train.M, split.y_train, prefilter_n, objective) | |
| if (prefilter_n is not None and not is_unsup) | |
| else list(ctx_train.M.columns) | |
| ) | |
| gp_y_train = None if is_unsup else split.y_train | |
| gp_y_test = None if is_unsup else split.y_test | |
| t0 = time.time() | |
| _log, winner, winner_cv_fitness = run_gp_v2( | |
| ctx_train, gp_y_train, pool, | |
| objective=objective, | |
| population_size=population_size, | |
| n_generations=n_generations, | |
| tournament_k=tournament_k, | |
| elitism=elitism, | |
| p_mutate=p_mutate, | |
| lambda_size=lambda_size, | |
| coherence_weight=coherence_weight, | |
| immigrant_fraction=immigrant_fraction, | |
| rates_override=rates_override, | |
| scalar_share_override=scalar_share_override, | |
| cv_folds=cv_folds, | |
| seed=seed, | |
| max_depth=max_depth, | |
| max_genes_per_set=max_genes_per_set, | |
| max_nodes=max_nodes, | |
| on_generation=on_generation, | |
| ) | |
| gp_seconds = time.time() - t0 | |
| winner_holdout = evaluate_holdout( | |
| winner, ctx_test, gp_y_test, | |
| objective=objective, ctx_train=ctx_train, | |
| ) | |
| # Re-execute the winner to extract per-patient held-out scores so the | |
| # API worker can compute post-hoc alignment (unsup) without parsing | |
| # the program_repr back into a Node. Falls back to empty if execution | |
| # was degenerate. | |
| try: | |
| _w_scores_series = winner.execute(ctx_test) | |
| if isinstance(_w_scores_series, pd.Series): | |
| winner_holdout_scores = [ | |
| float(v) if np.isfinite(v) else None for v in _w_scores_series.values | |
| ] | |
| else: | |
| winner_holdout_scores = [] | |
| except Exception: | |
| winner_holdout_scores = [] | |
| holdout_sample_ids = [str(s) for s in ctx_test.M.index] | |
| # Full-cohort scores: feeds the peel-off chain's residualisation on | |
| # the next run. Emitted for every objective now — MSI/HPV/TMB can | |
| # enumerate axes too. | |
| full_scores, full_sample_ids = _full_cohort_winner_scores( | |
| winner, M, clinical, | |
| ) | |
| if is_unsup: | |
| rates = objective.synthesis_overrides().get("rates") | |
| nulls = unsup_random_null( | |
| ctx_test, pool, | |
| objective=objective, | |
| n_permutations=n_permutations, | |
| rates=rates, | |
| max_depth=max_depth, | |
| max_genes_per_set=max_genes_per_set, | |
| seed=seed, | |
| ctx_train=ctx_train, | |
| ) | |
| else: | |
| nulls = permutation_null( | |
| winner, ctx_test, split.y_test, | |
| objective=objective, | |
| n_permutations=n_permutations, | |
| seed=seed, | |
| ) | |
| p_value = permutation_p_value(winner_holdout, nulls) | |
| worst = objective.worst_score() | |
| finite_nulls = [n for n in nulls if np.isfinite(n)] | |
| def _f(x: float) -> float: | |
| return float(x) if np.isfinite(x) else worst | |
| return { | |
| "engine": "v2", | |
| "objective_spec": objective.to_dict(), | |
| "fitness_label": objective.fitness_label(), | |
| "winning": { | |
| "id": "winner", | |
| "program_repr": winner.repr_typed(), | |
| "gene_ids": list(dict.fromkeys(winner.feature_ids())), | |
| "n_nodes": int(winner.node_count()), | |
| "depth": int(winner.depth()), | |
| "cv_fitness": _f(winner_cv_fitness), | |
| "holdout_score": _f(winner_holdout), | |
| "holdout_auroc": _f(winner_holdout), | |
| "permutation_p": ( | |
| float(p_value) if np.isfinite(p_value) else 1.0 | |
| ), | |
| "holdout_scores": winner_holdout_scores, | |
| "holdout_sample_ids": holdout_sample_ids, | |
| "full_scores": full_scores, | |
| "full_sample_ids": full_sample_ids, | |
| }, | |
| "permutation_summary": { | |
| "n_permutations": n_permutations, | |
| "null_kind": ( | |
| "random_vector_programs" if is_unsup | |
| else "winner_fixed_target_shuffle" | |
| ), | |
| "null_score_mean": ( | |
| float(np.mean(finite_nulls)) if finite_nulls else worst | |
| ), | |
| "null_score_p95": ( | |
| float(np.quantile(finite_nulls, 0.95)) | |
| if finite_nulls else worst | |
| ), | |
| }, | |
| "run_meta": { | |
| "seed": int(seed), | |
| "n_genes": int(M.shape[1]), | |
| "n_genes_input": n_genes_input, | |
| "n_genes_dropped_nan": int(len(nan_cols)), | |
| "prefilter_N": None if prefilter_n is None else int(prefilter_n), | |
| "gp_seconds": round(gp_seconds, 2), | |
| }, | |
| } | |