mixle.evolve.population module

The meta-search that learns which improvement operators help: a bandit + a diversity population.

The operator-choice problem is a non-stationary bandit: each step, pick an operator (arm), apply it through the propose-and-verify gate, observe the verified gate delta as reward (0 if the challenger was rejected), and update the arm’s value. Because the reward is the anti-regression verified delta rather than an in-sample fit score, the policy cannot be fooled by a challenger overfitting the data it was FIT on.

It can still be fooled by a subtler failure: adaptive reuse of the data it is VERIFIED on. A Population run queries the SAME reusable validation set every single generation, and both survivor selection and OperatorBandit adapt to what they saw there – exactly the setting where a held-out set stops being a fair test the more times you look at it and act on what you saw (the same reason a test set quietly becomes a training signal if you tune against it repeatedly). See Population’s docstring (“Data roles”, MXR-080-0042) for the three roles this module keeps separate – fit data, the reused validation signal (a heuristic, not evidence), and the genuine one-shot final holdout (Population.evaluate_holdout()).

  • OperatorBandit – Thompson or UCB over a fixed operator pool. Reward is the verified delta from that reused validation signal (see above); cost is tracked for a report. Non-stationary: a forgetting factor decays stale arm statistics so the policy can follow a problem whose best operator changes over the run.

  • Population – a diversity-preserving population of model structures evolved by the bandit: select operators, apply them to parents, gate the challengers, reward the bandit, and keep the verified-best plus a coarse capability-diversity quota. run returns a SearchResult; champion is the incumbent.

class OperatorBandit(operators, *, policy='thompson', decay=0.97, prior_cost_aware=True, seed=0)[source]

Bases: object

A non-stationary bandit over a fixed pool of ImprovementOperator.

select(k) returns the k highest-value operators under the chosen policy; reward folds a verified delta + cost back into the chosen arm; report is the “which operators help” artifact.

Policies:
  • 'thompson' – Beta-Bernoulli Thompson sampling on the win indicator (reward > 0), scaled by the mean positive reward, so an operator that wins rarely but big and one that wins often but small are compared on expected verified delta.

  • 'ucb' – UCB1 on the mean reward with a sqrt(2 ln N / n) exploration bonus.

Non-stationarity: each reward first multiplies every arm’s statistics by decay (a forgetting factor in (0, 1]), so old evidence fades and the policy can track a shifting best operator.

Parameters:
  • operators (Sequence[ImprovementOperator])

  • policy (str)

  • decay (float)

  • prior_cost_aware (bool)

  • seed (int)

value(name)[source]

The current policy value of operator name (a Thompson draw or the UCB index).

Parameters:

name (str)

Return type:

float

select(k=1)[source]

Return the k operators with the highest policy value (a fresh Thompson draw each call).

Parameters:

k (int)

Return type:

list[ImprovementOperator]

reward(op_name, delta, cost)[source]

Fold a verified delta (0 if the challenger was rejected) and cost into the arm.

Decays every arm first (non-stationarity), then updates the chosen arm. A delta is clipped at 0 below – a rejected challenger is a zero reward, never negative, matching the anti-regression guarantee (we never punish an operator for a rejected proposal beyond not rewarding it).

delta and cost must be finite (and cost non-negative). max(nan, 0.0) returns nan, so a single non-finite update used to poison the arm permanently: mean delta, mean cost, and every subsequent Thompson draw for it came back nan, and a nan policy value sorts unpredictably against real ones. Policy state is not a place to absorb an invalid measurement.

Parameters:
Return type:

None

report()[source]

Per-operator win-rate, mean verified delta, mean cost, and (decayed) pull count.

Return type:

dict[str, Any]

class Population(seeds, *, objective, operators=None, bandit=None, size=12, diversity_quota=2, holdout=0.25, seed=0)[source]

Bases: object

A diversity-preserving population of model structures, evolved by the OperatorBandit.

seeds are fitted models (the starting structures). Each step() selects operators via the bandit, applies them to parents chosen by fitness, gates the challengers with the Phase-1 champion/challenger rule – pooling the whole generation’s p-values for a single Benjamini-Hochberg correction before the per-candidate gate, since a generation produces many challengers at once – rewards the bandit with the verified deltas, and keeps the verified-best plus a coarse capability-diversity quota.

Data roles (MXR-080-0042). This class keeps three distinct data roles separate:

  1. Fit data – what operators actually fit challengers on (data in step() / run(), or its auto-derived fit-split when verify_data is omitted – see below).

  2. Reusable validation data (verify_data) – read by step() every single generation, to gate every challenger (challenger_beats_champion()) AND to score population fitness / drive survivor selection. When omitted, step() / run() derive a genuinely disjoint split from data (_auto_split()) rather than the old default of reusing data itself verbatim – but disjointness from the fit data is only half the fix. This SAME verify_data is then queried again, adaptively, by EVERY generation of a run(): survivor selection keeps whoever currently scores best against it, and OperatorBandit learns from whichever operators currently win against it. The per-generation Benjamini-Hochberg pool (see step()) only controls multiplicity WITHIN one generation’s simultaneous comparisons; it does nothing to correct for that same set being re-queried, and acted on, by every OTHER generation – the textbook way a held-out set stops being a fair test once you repeatedly tune against it. Treat every Verdict/score this class computes against a reused verify_data as a NOISY HEURISTIC that steers the search, never as a promotion-grade statistical claim on its own.

  3. One-shot final holdout – data never passed to this population as data or verify_data at any point, evaluated exactly ONCE via evaluate_holdout() after every intended step() / run() call is done. This is the only comparison this class makes that carries real evidentiary weight – a caller that needs a defensible promotion decision (as opposed to search guidance) must use it rather than trusting champion or a GenerationReport alone.

Parameters:
  • seeds (Sequence[Any]) – the initial fitted models (at least one).

  • objective (Objective) – the Objective to optimize (lower-is-better aware).

  • operators (Sequence[ImprovementOperator] | None) – the proposal-move pool; defaults to the Phase-1 safe set.

  • bandit (OperatorBandit | None) – an OperatorBandit over operators (built with the default policy if omitted).

  • size (int) – the carrying capacity of the population.

  • diversity_quota (int) – how many of size slots are reserved for capability-diverse members (the rest go to the fittest); the quota keeps the search from collapsing onto one structure too early.

  • holdout (float) – fraction of data reserved for the auto-derived verify_data split (_auto_split()) whenever a caller omits verify_data – irrelevant if the caller always passes an explicit, already-disjoint verify_data.

  • seed (int) – RNG seed for parent sampling, the bandit, and the auto-derived split.

step(data, verify_data=None)[source]

Run one generation: select -> propose -> gate -> reward -> survivor selection.

Parameters:
  • data (Any) – the pool operators FIT candidates from (data role 1, “fit data”, in the class docstring). When verify_data is omitted, data is split (_auto_split()) and only the fit-half is actually fit on – the other half becomes the auto-derived verify_data below, so a challenger is never fit and scored on the same rows.

  • verify_data (Any | None) – the held-out data used to score population fitness and to gate every challenger against its parent (challenger_beats_champion()) – data role 2, “reusable validation data”, in the class docstring: read again every generation, and its repeated, adaptive use across a run() is a HEURISTIC search signal, not evidence on its own – see evaluate_holdout() for the genuine one-shot check. Defaults to the verify-half of _auto_split() applied to data when omitted (never data itself – MXR-080-0042: the old default silently made verification equal training data). Pass an explicit, already-disjoint split here (e.g. search() does) to control the split yourself instead.

Return type:

GenerationReport

A generation proposes many challengers at once, so the gate applies a genuine population-wide Benjamini-Hochberg correction: every candidate is first compared on its own RAW p-value (pass 1 below), the whole generation’s raw p-values are then pooled and corrected together in ONE adjust_pvalues() call (pass 2), and only then is each candidate’s promotion finalized against its adjusted p-value (pass 3). Correcting each candidate’s single p-value in isolation – family size 1 – is the identity transform for every method in multiple_testing, which is why challenger_beats_champion() refuses to do that itself. This pooled correction operates WITHIN one generation only – it does not, and cannot, correct for the same verify_data being queried again by every OTHER generation of a run() (see the class docstring’s “Data roles” note and evaluate_holdout()).

A scalar-only self.objective (calibration_objective(), decision_regret_objective()) has no paired test to run at all – every candidate’s Verdict then carries p_value = nan by design (see that function’s scalar-only branch) – so pass 2 excludes non-finite p-values from the pooled family entirely rather than handing them to adjust_pvalues (which rejects non-finite input outright); such a candidate’s own unadjusted verdict.promote stands as its final promotion decision, since there is no p-value for a population-wide correction to act on – which, per promote, is now ALWAYS False for a scalar-only objective regardless of favored (no sampling-uncertainty estimate backs a bare scalar delta – see has_statistical_evidence).

run(data, verify_data=None, generations=5)[source]

Evolve for generations steps; return a SearchResult.

Parameters:
  • data (Any) – the pool operators fit candidates from each generation (data role 1, “fit data”, in the class docstring). When verify_data is omitted, data is split ONCE, up front (_auto_split()), and only the fit-half is fit on for every generation of this run.

  • verify_data (Any | None) – the held-out data used to score fitness and gate every challenger – data role 2, “reusable validation data” (see the class docstring’s “Data roles” note): the SAME split is reused for EVERY generation, and both survivor selection and OperatorBandit adapt to it, which is exactly the sequential/adaptive reuse the class docstring warns is a heuristic search signal, not evidence. Defaults to the verify-half of _auto_split() applied to data when omitted (never data itself – MXR-080-0042). Pass an explicit, already-disjoint split for a caller-controlled gate (e.g. search() always does).

  • generations (int) – number of step() calls.

Return type:

Any

The returned best_model is the run incumbent, guaranteed no worse than the best seed on whatever ``verify_data`` this run used (anti-regression is relative to that reused validation signal, not an unconditional guarantee about unseen data – see evaluate_holdout() for the one-shot check that actually earns that stronger claim). history is one row per generation (proposals / verified / score).

evaluate_holdout(holdout_data, *, reference=None, **verdict_kwargs)[source]

The one genuinely evidence-bearing check this class can make (data role 3, “one-shot final holdout”, in the class docstring) – call this EXACTLY ONCE, after every step() / run() call you intend to make is already done, on holdout_data that was never passed to this population as data or verify_data at any point.

Every Verdict / score step() computes internally along the way is checked against the SAME reused verify_data, generation after generation – a heuristic search signal, not a defensible promotion claim (see the class docstring’s “Data roles” note). This method is the genuine, single-look alternative: ONE challenger_beats_champion() comparison between reference (defaults to this population’s incumbent BEFORE its first step() / run() call – i.e. the best seed) and champion (the current incumbent).

Calling this more than once against the same holdout_data reintroduces exactly the adaptive-reuse problem it exists to avoid – treat holdout_data as spent after one call, the same as any other held-out test set.

Parameters:
  • holdout_data (Any) – fresh data this population has never scored anything on. Verifying that is the caller’s responsibility – Population has no way to check it from the object alone.

  • reference (Any | None) – the champion to compare champion against; defaults to the pre-run incumbent (see above). Pass the currently-deployed production model explicitly if that differs.

  • verdict_kwargs (Any) – forwarded to challenger_beats_champion() (e.g. alpha, min_effect, seed); nonnested defaults to whether reference and champion are different model types, but can be overridden.

Returns:

The one-shot Verdict; verdict.promote is the genuine, evidence-backed promotion decision.

Return type:

Verdict

property champion: Any

The current run incumbent (the best model seen, anti-regression guaranteed).

class GenerationReport(proposals=0, verified=0, best_score=nan, operators_used=<factory>, rewards=<factory>)[source]

Bases: object

One Population.step(): which operators ran, the verified wins, and the new champion score.

Parameters: