oncodsl / engine_v2 /pipeline.py
govindbalki's picture
Upload folder using huggingface_hub
0fff343 verified
Raw
History Blame Contribute Delete
25.1 kB
"""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),
},
}