ml_meta_perf.validate

Validation protocols, baselines, and the term-count sweep.

Four protocols are reported, and they answer different questions.

  • In-sample (IS) fits and scores the equation on all observed rows.
  • Leave-one-dataset-out (LODO) holds out all rows of one dataset. It answers "what MCC will these models reach on a dataset nobody has run yet", which is the meta-learning use case, and it is the harder number because dataset features are constant within a fold and so the held-out dataset is genuinely unseen.
  • Leave-one-model-out (LOMO) holds out all rows of one model. It answers "what will a new model reach on datasets we know".
  • Doubly held out (DHO) removes every row sharing either the test dataset or the test model before fitting the coefficients for an observed pair.

A random k-fold split is deliberately not offered as a headline protocol. With dataset features constant across a dataset's rows, a random split puts the same dataset on both sides of the fold and can therefore leak group identity. random_kfold_groups exists only to measure that diagnostic on the current corpus, not as a reported protocol.

Study chapter: 5. Evaluation -- the rationale, in prose, with the figures.

  1"""Validation protocols, baselines, and the term-count sweep.
  2
  3Four protocols are reported, and they answer different questions.
  4
  5* In-sample (IS) fits and scores the equation on all observed rows.
  6* Leave-one-dataset-out (LODO) holds out all rows of one dataset. It answers "what MCC will
  7  these models reach on a dataset nobody has run yet", which is the meta-learning use
  8  case, and it is the harder number because dataset features are constant within a
  9  fold and so the held-out dataset is genuinely unseen.
 10* Leave-one-model-out (LOMO) holds out all rows of one model. It answers "what will a new
 11  model reach on datasets we know".
 12* Doubly held out (DHO) removes every row sharing either the test dataset or the test
 13  model before fitting the coefficients for an observed pair.
 14
 15A random k-fold split is deliberately *not* offered as a headline protocol. With
 16dataset features constant across a dataset's rows, a random split puts the same dataset
 17on both sides of the fold and can therefore leak group identity. ``random_kfold_groups``
 18exists only to measure that diagnostic on the current corpus, not as a reported protocol.
 19
 20Study chapter: [5. Evaluation][study-chapter] -- the rationale, in
 21prose, with the figures.
 22
 23[study-chapter]: https://github.com/mariolpantunes/ml-meta-perf/blob/main/assets/docs/05-evaluation.md
 24"""
 25
 26from __future__ import annotations
 27
 28from collections.abc import Iterator
 29from dataclasses import dataclass, field
 30from math import comb
 31
 32import numpy as np
 33import polars as pl
 34
 35from ml_meta_perf.fit import Standardizer
 36from ml_meta_perf.model import MCC_LOWER, MCC_UPPER, Equation
 37from ml_meta_perf.search import Selector, guided_screen
 38from ml_meta_perf.stats import mae, pearson, r2_score, rmse, smape, spearman
 39from ml_meta_perf.terms import Library
 40
 41#: A fold scoring a negative R2 while its MAE stays under this is a near-constant target, not
 42#: a bad prediction. Set at a third of the corpus's own MCC standard deviation: below that,
 43#: an error is small in absolute terms and any negative R2 is the denominator, not the fit.
 44LOW_VARIANCE_MAE = 0.10
 45
 46
 47def leave_one_group_out(groups: np.ndarray) -> Iterator[tuple[str, np.ndarray, np.ndarray]]:
 48    """Yield ``(held-out label, train mask, test mask)`` for each distinct group."""
 49    for label in np.unique(groups):
 50        test = groups == label
 51        yield str(label), ~test, test
 52
 53
 54def random_kfold_groups(size: int, folds: int = 10, seed: int = 42) -> np.ndarray:
 55    """Fold labels for a random split -- used only to demonstrate the leakage it causes."""
 56    return np.random.default_rng(seed).integers(0, folds, size=size).astype(str)
 57
 58
 59@dataclass(frozen=True)
 60class Scores:
 61    """The metric set reported for every equation and every baseline."""
 62
 63    r2: float
 64    mae: float
 65    rmse: float
 66    smape: float
 67    spearman: float
 68    n: int
 69
 70    def as_dict(self) -> dict[str, float | int]:
 71        return {
 72            "r2": self.r2,
 73            "mae": self.mae,
 74            "rmse": self.rmse,
 75            "smape": self.smape,
 76            "spearman": self.spearman,
 77            "n": self.n,
 78        }
 79
 80
 81def score(truth: np.ndarray, prediction: np.ndarray) -> Scores:
 82    return Scores(
 83        r2=r2_score(truth, prediction),
 84        mae=mae(truth, prediction),
 85        rmse=rmse(truth, prediction),
 86        smape=smape(truth, prediction),
 87        spearman=spearman(truth, prediction),
 88        n=int(truth.shape[0]),
 89    )
 90
 91
 92@dataclass
 93class CrossValidation:
 94    """Out-of-fold predictions plus the terms each fold chose."""
 95
 96    predictions: np.ndarray
 97    per_fold: dict[str, Scores] = field(default_factory=dict)
 98    selected: list[list[str]] = field(default_factory=list)
 99    #: The equation each fold fitted, keyed by the label it held out. Kept so that a
100    #: correction fitted on a fold's training rows -- `ml_meta_perf.identity` fits one -- can
101    #: reuse the search this path already paid for instead of repeating it.
102    equations: dict[str, Equation] = field(default_factory=dict)
103    #: How many out-of-fold predictions `_clip_to_training` moved. Not a diagnostic detail:
104    #: the count is reported for each equation and protocol, since clipping can affect
105    #: the reported predictions. A study that clips has to say how often the clip fired.
106    clipped: int = 0
107
108    def scores(self, truth: np.ndarray) -> Scores:
109        return score(truth, self.predictions)
110
111    def dispersion(self) -> dict[str, float]:
112        """How the per-fold error is spread, next to the pooled number.
113
114        `scores` pools every out-of-fold prediction and scores it against the *global* mean.
115        On this corpus most of the variance is between datasets, which the dataset features
116        capture almost for free, so pooled R² flatters a LODO split: a set
117        reaching a pooled 0.53 was explaining 0.29 of the variance *within* the average
118        dataset. Pooling also hides that a single fold can dominate.
119
120        **Read the MAE columns, not ``worst_fold_r2``.** A per-fold R2 divides by the variance
121        of that fold's own target, and three datasets here have almost none: every one of
122        `5G_Slicing`'s 25 models scores about 0.986, a standard deviation of 0.052, and `NSR`
123        and `DeepSlice` are the same. Dividing an ordinary error by a variance that small
124        returns a large negative number for a nearly perfect prediction -- `5G_Slicing` reads
125        -10.6 while its predictions are all within 0.05 of the truth. Those three are the only
126        strongly negative folds, and the number is an artefact of the metric rather than a
127        failure of the equation.
128
129        MAE has no such denominator, so ``worst_fold_mae`` is the column that actually catches
130        a bad fold. ``worst_fold_r2`` is kept because it is what a reader expects to see, and
131        ``low_variance_folds`` counts the groups on which it cannot be trusted.
132        """
133        if not self.per_fold:
134            return {
135                "median_fold_r2": float("nan"),
136                "worst_fold_r2": float("nan"),
137                "median_fold_mae": float("nan"),
138                "worst_fold_mae": float("nan"),
139                "low_variance_folds": 0.0,
140                "clipped_predictions": float(self.clipped),
141                "folds": 0.0,
142            }
143        r2_values = [fold.r2 for fold in self.per_fold.values()]
144        mae_values = [fold.mae for fold in self.per_fold.values()]
145        return {
146            "median_fold_r2": float(np.median(r2_values)),
147            "worst_fold_r2": float(min(r2_values)),
148            "median_fold_mae": float(np.median(mae_values)),
149            "worst_fold_mae": float(max(mae_values)),
150            "low_variance_folds": float(self._low_variance_folds()),
151            "clipped_predictions": float(self.clipped),
152            "folds": float(len(r2_values)),
153        }
154
155    def _low_variance_folds(self) -> int:
156        """Folds whose per-fold R2 is not worth reading, inferred from the scores themselves.
157
158        A fold with a large negative R2 and a small MAE has a near-constant target, not a bad
159        prediction: R2 is dividing by nothing. The pair of conditions identifies that without
160        the class needing a copy of the target.
161        """
162        return sum(1 for fold in self.per_fold.values() if fold.r2 < 0.0 and fold.mae < LOW_VARIANCE_MAE)
163
164    def stability(self) -> pl.DataFrame:
165        """How often each term was selected across folds.
166
167        A term chosen in nearly every fold has stronger support than one chosen only a few
168        times. Reporting the final all-data equation without this column would present the
169        two identically.
170        """
171        counts: dict[str, int] = {}
172        for names in self.selected:
173            for name in names:
174                counts[name] = counts.get(name, 0) + 1
175        total = max(len(self.selected), 1)
176        return (
177            pl.DataFrame({"term": list(counts), "folds": list(counts.values())})
178            .with_columns((pl.col("folds") / total).alias("frequency"))
179            .sort("folds", descending=True)
180        )
181
182
183def fold_selections(
184    library: Library,
185    target: np.ndarray,
186    groups: np.ndarray,
187    *,
188    n_terms: int,
189    penalty: float,
190    pool_size: int = 250,
191    beam_width: int = 6,
192) -> list[list[str]]:
193    """Which terms selection picks when it is re-run inside each fold.
194
195    **This returns term names and nothing else, deliberately.** Re-selecting inside the folds
196    and scoring the result answers a question about the *discovery procedure*, not about the
197    equation, and the study does not report it; `cross_validate_fixed_form` produces every
198    cross-validated number. Returning no predictions means the re-selecting protocol cannot
199    produce a reported score even by accident.
200
201    What it is for is `CrossValidation.stability`. Fixing an equation's form is a claim that
202    the form describes the phenomenon rather than these 484 rows, and the way to check that is
203    to remove one dataset and see whether the same terms come back. A form whose terms
204    churn fold to fold has not earned the fixed-form protocol.
205    """
206    selections: list[list[str]] = []
207    for _, train, _ in leave_one_group_out(groups):
208        train_matrix = library.matrix[train]
209        standardizer = Standardizer.fit(train_matrix)
210        design = standardizer.apply(train_matrix)
211        pool = guided_screen(library_rows(library, train), target[train], keep=pool_size)
212        selector = Selector(design, target[train], penalty, library.feature_groups)
213        subsets = selector.search(pool, n_terms, beam_width=beam_width)
214        if n_terms in subsets:
215            selections.append([library.terms[index].name for index in subsets[n_terms].indices])
216    return selections
217
218
219def term_stability(selections: list[list[str]], required_terms: tuple[str, ...] = ()) -> pl.DataFrame:
220    """How often each term was selected across folds.
221
222    A term chosen in nearly every fold has stronger support than one chosen only a few times.
223    Reporting the final all-data equation without this column would present the two
224    identically.
225    """
226    counts: dict[str, int] = dict.fromkeys(required_terms, 0)
227    for names in selections:
228        for name in names:
229            counts[name] = counts.get(name, 0) + 1
230    total = max(len(selections), 1)
231    return (
232        pl.DataFrame({"term": list(counts), "folds": list(counts.values())})
233        .with_columns((pl.col("folds") / total).alias("frequency"))
234        .sort("folds", descending=True)
235    )
236
237
238def cross_validate_fixed_form(
239    library: Library,
240    columns: dict[str, np.ndarray],
241    target: np.ndarray,
242    groups: np.ndarray,
243    equations: dict[int, Equation],
244    *,
245    penalty: float,
246) -> dict[int, CrossValidation]:
247    """Cross-validate each equation with its **form fixed**: only the weights are refit.
248
249    **This is the study's cross-validation protocol.** Every reported leave-one-group-out
250    number comes from here. The choice is a position about what the artefact is rather than a
251    statistical convenience. The form of an equation is its
252    conceptual claim -- a statement about which quantities govern how well a learner does on a
253    dataset -- and for a simpler problem one would write that form down from domain expertise
254    and never search for it. What cross-validation then tests is whether the claim survives
255    data it has not seen, with its constants recalibrated.
256
257    `fold_selections` re-runs selection inside every fold and so answers a different
258    question -- whether the *discovery procedure* generalises. **It cannot be used for a reported score** -- it
259    returns no predictions. Twenty folds fit twenty different equations, so its pooled R2 is an
260    average over twenty models: it moved by up to 0.3 when the requested length changed by
261    two, while the fixed form varies by 0.014 over the same range. Its one remaining job is
262    `CrossValidation.stability`.
263
264    The obligation this creates is that the form must not be an artefact of the sample, and
265    `term_stability` over a `fold_selections` run is how that is checked --
266    separately, and not as part of the reported score.
267
268    Predictions are bounded by each training fold's own target range, as in
269    `fold_selections`; see `_clip_to_training`.
270    """
271    index = {name: position for position, name in enumerate(library.names)}
272    results: dict[int, CrossValidation] = {}
273    for size, equation in equations.items():
274        positions = [index[term.name] for term in equation.terms]
275        if not positions:
276            continue
277        matrix = library.matrix[:, positions]
278        outcome = CrossValidation(predictions=np.zeros_like(target))
279        for label, train, test in leave_one_group_out(groups):
280            standardizer = Standardizer.fit(matrix[train])
281            design = standardizer.apply(matrix[train])
282            gram = design.T @ design + penalty * np.eye(len(positions))
283            offset = float(target[train].mean())
284            weights = np.linalg.solve(gram, design.T @ (target[train] - offset))
285            held = standardizer.apply(matrix[test]) @ weights + offset
286            bounded = _clip_to_training(held, target[train])
287            outcome.clipped += int(np.count_nonzero(bounded != held))
288            outcome.predictions[test] = bounded
289            outcome.per_fold[label] = score(target[test], outcome.predictions[test])
290            outcome.selected.append([term.name for term in equation.terms])
291            # The same terms every fold, carrying that fold's weights, with the standardisation
292            # folded back in exactly as `fit.to_equation` does. Recorded so a downstream
293            # correction -- `identity.correct_out_of_fold` fits one -- can reuse the fold's own
294            # equation instead of the all-data one.
295            raw = weights / standardizer.scale
296            outcome.equations[label] = Equation(
297                intercept=offset - float(raw @ standardizer.mean),
298                terms=tuple(equation.terms),
299                weights=tuple(float(value) for value in raw),
300                standardized_weights=tuple(float(value) for value in weights),
301                name=f"fold_{label}",
302            )
303        results[size] = outcome
304    return results
305
306
307def cross_validate_doubly_held_out(
308    library: Library,
309    columns: dict[str, np.ndarray],
310    target: np.ndarray,
311    first: np.ndarray,
312    second: np.ndarray,
313    equations: dict[int, Equation],
314    *,
315    penalty: float,
316) -> dict[int, CrossValidation]:
317    """Refit each equation with **both** groups of a cell removed, and predict that cell.
318
319    This is the protocol for the question the study is actually for: *what MCC will this
320    model reach on this dataset*, when neither has been run. Neither single-group protocol
321    answers it. LODO holds the dataset out and keeps the model -- it has
322    seen that learner on nineteen other problems. LOMO does the reverse. For a
323    cell to be genuinely unseen, the whole dataset row-block *and* the whole model
324    column-block have to leave the training set, which is what this does.
325
326    **It is also the only protocol under which the comparison with the trivial baselines is
327    fair, and under it they do not exist.** A per-model mean or median answers "how well does
328    this model usually do", and a model held out of every fold has no rows to average. So a
329    baseline that beats the equation on ranking under LODO is a baseline
330    being handed the model identity the equation is denied; here it cannot be computed at
331    all, while the equation still predicts from features.
332
333    Costlier than the single-group protocols -- one solve per observed cell rather than per
334    group -- but each solve is a small ridge on a fixed design, so it is seconds, not minutes.
335    """
336    index = {name: position for position, name in enumerate(library.names)}
337    results: dict[int, CrossValidation] = {}
338    for size, equation in equations.items():
339        positions = [index[term.name] for term in equation.terms]
340        if not positions:
341            continue
342        matrix = library.matrix[:, positions]
343        outcome = CrossValidation(predictions=np.zeros_like(target))
344        for row_label in np.unique(first):
345            for column_label in np.unique(second):
346                test = (first == row_label) & (second == column_label)
347                if not test.any():
348                    continue
349                train = (first != row_label) & (second != column_label)
350                if not train.any():
351                    continue
352                standardizer = Standardizer.fit(matrix[train])
353                design = standardizer.apply(matrix[train])
354                gram = design.T @ design + penalty * np.eye(len(positions))
355                offset = float(target[train].mean())
356                weights = np.linalg.solve(gram, design.T @ (target[train] - offset))
357                held = standardizer.apply(matrix[test]) @ weights + offset
358                bounded = _clip_to_training(held, target[train])
359                outcome.clipped += int(np.count_nonzero(bounded != held))
360                outcome.predictions[test] = bounded
361        # Scored per held-out *dataset* rather than per cell: a cell is one to a few rows and
362        # a per-cell R2 has no variance to divide by. The dataset grouping is what the
363        # dispersion columns and the paired tests use everywhere else.
364        for label, _, test in leave_one_group_out(first):
365            outcome.per_fold[label] = score(target[test], outcome.predictions[test])
366            outcome.selected.append([term.name for term in equation.terms])
367        results[size] = outcome
368    return results
369
370
371def _clip_to_training(prediction: np.ndarray, training_target: np.ndarray) -> np.ndarray:
372    """Bound a fold's predictions by the target range that fold was trained on.
373
374    `model.predict` already clips to ``[MCC_LOWER, MCC_UPPER]``, the range MCC can take in
375    principle. That is the right bound for the equation as published and too loose here: this
376    corpus has exactly one negative row (-0.29), so a floor at -1.0 leaves a held-out fold free
377    to be predicted at a value nothing in training ever took.
378
379    The training fold's own observed range uses no held-out information -- it is exactly what
380    the fitted equation was shown. This bounds extrapolation during coefficient refitting;
381    it does not remove optimism from global form selection.
382    """
383    return np.clip(prediction, float(training_target.min()), float(training_target.max()))
384
385
386def library_rows(library: Library, mask: np.ndarray) -> Library:
387    """A library restricted to a row subset, without re-evaluating any term."""
388    clone = object.__new__(Library)
389    clone.terms = library.terms
390    clone.matrix = library.matrix[mask]
391    return clone
392
393
394def baseline_group_centre(
395    target: np.ndarray,
396    outer: np.ndarray,
397    inner: np.ndarray | None = None,
398    *,
399    centre: str = "mean",
400) -> np.ndarray:
401    """Predict a training-fold centre, optionally conditioned on a second grouping.
402
403    With ``inner`` set to the model column under a LODO split, this is
404    "what does this model usually score", the baseline any meta-model has to beat to be
405    worth writing down.
406
407    ``centre`` picks the summary. **Which one is the fair comparison depends on the metric
408    being reported, and using the mean for all of them understates the baseline.** The mean
409    minimises squared error, so it is the right opponent for R2 and RMSE. The *median*
410    minimises absolute error, so an MAE quoted against a mean baseline is quoted against a
411    baseline that is not even trying -- and SMAPE, being an absolute-error ratio, behaves the
412    same way. Both are reported so each metric is read against the baseline that is hardest
413    to beat on it.
414
415    The leave-one-group-out loop is not decoration: with 17-25 rows per group, including the
416    row being predicted inflates the per-model mean's R2 by 0.082. A naive group centre
417    computed over all rows is not this function.
418    """
419    summarise = np.median if centre == "median" else np.mean
420    predictions = np.zeros_like(target)
421    for _, train, test in leave_one_group_out(outer):
422        fallback = float(summarise(target[train]))
423        if inner is None:
424            predictions[test] = fallback
425            continue
426        for label in np.unique(inner[test]):
427            selected = test & (inner == label)
428            source = train & (inner == label)
429            predictions[selected] = float(summarise(target[source])) if source.any() else fallback
430    return np.clip(predictions, MCC_LOWER, MCC_UPPER)
431
432
433def baseline_group_mean(
434    target: np.ndarray,
435    outer: np.ndarray,
436    inner: np.ndarray | None = None,
437) -> np.ndarray:
438    """`baseline_group_centre` at the mean. Kept as the name the rest of the study uses."""
439    return baseline_group_centre(target, outer, inner, centre="mean")
440
441
442def additive_mean_reference(target: np.ndarray, first: np.ndarray, second: np.ndarray) -> np.ndarray:
443    """Dataset and model target means combined into a descriptive IS reference.
444
445    The calculation adds each observed dataset mean and model mean, subtracts the global
446    mean, and clips the result to the MCC range. It uses target identities and is therefore
447    not a deployable predictor. With an incomplete dataset-by-model grid it is also not the
448    jointly fitted least-squares optimum or a ceiling for additive equations.
449    """
450    grand = float(target.mean())
451    prediction = np.full_like(target, grand)
452    for group in (first, second):
453        for label in np.unique(group):
454            mask = group == label
455            prediction[mask] += float(target[mask].mean()) - grand
456    return np.clip(prediction, MCC_LOWER, MCC_UPPER)
457
458
459def interaction_oracle(
460    target: np.ndarray,
461    first: np.ndarray,
462    second: np.ndarray,
463    rank: int,
464) -> np.ndarray:
465    """The additive mean-based reference plus the best rank-``rank`` approximation of what it misses.
466
467    ``additive_mean_reference`` describes how much MCC follows dataset and model means.
468    The obvious next question is what the leftover looks like, and the leftover is not
469    noise: it is a (dataset x model) matrix of interactions, and interaction matrices are
470    usually dominated by a few components.
471
472    So the residual is decomposed by SVD and its leading components added back. This is
473    the AMMI model -- additive main effects, multiplicative interaction -- long used for
474    genotype-by-environment trials, which is structurally the same problem: a grid of
475    subjects crossed with conditions where some pairings suit each other.
476
477    The result is a *ladder* rather than a single ceiling. ``rank=0`` is the additive
478    mean-based reference; each further component is one more pattern of "this kind of model suits this
479    kind of dataset". At full rank it reproduces every observed cell and R2 is 1, which is
480    why the interesting question is how fast the ladder climbs, not where it ends.
481
482    Like ``additive_mean_reference`` this is fitted with the true values and predicts nothing.
483    Unobserved cells (24 of the 500 here) contribute zero residual, so they neither
484    distort the decomposition nor are counted in any score.
485    """
486    rows = np.unique(first)
487    columns = np.unique(second)
488    row_index = {label: position for position, label in enumerate(rows)}
489    column_index = {label: position for position, label in enumerate(columns)}
490
491    grid = np.full((rows.shape[0], columns.shape[0]), np.nan)
492    for value, row, column in zip(target, first, second, strict=True):
493        grid[row_index[row], column_index[column]] = value
494    observed = ~np.isnan(grid)
495
496    grand = float(np.nanmean(grid))
497    row_effect = np.nanmean(grid, axis=1) - grand
498    column_effect = np.nanmean(grid, axis=0) - grand
499    additive = grand + row_effect[:, None] + column_effect[None, :]
500
501    if rank > 0:
502        residual = np.where(observed, grid - additive, 0.0)
503        left, values, right = np.linalg.svd(residual, full_matrices=False)
504        additive = additive + (left[:, :rank] * values[:rank]) @ right[:rank]
505
506    prediction = np.array(
507        [additive[row_index[row], column_index[column]] for row, column in zip(first, second, strict=True)]
508    )
509    return np.clip(prediction, MCC_LOWER, MCC_UPPER)
510
511
512def oracle_ladder(
513    target: np.ndarray,
514    first: np.ndarray,
515    second: np.ndarray,
516    ranks: tuple[int, ...] = (0, 1, 2, 3, 4, 6, 8),
517) -> pl.DataFrame:
518    """How much each additional interaction component would be worth."""
519    rows: list[dict[str, object]] = []
520    previous: float | None = None
521    for rank in ranks:
522        value = r2_score(target, interaction_oracle(target, first, second, rank))
523        rows.append(
524            {
525                "interaction_rank": rank,
526                "r2": value,
527                "gain": value - previous if previous is not None else float("nan"),
528            }
529        )
530        previous = value
531    return pl.DataFrame(rows)
532
533
534#: A model is a right answer for the ranking if its MCC is within this of its dataset's best.
535#: See `ranking_report`: a fixed top-k relevance set misscores the datasets whose best models
536#: are genuinely tied.
537RELEVANCE_TOLERANCE = 0.01
538
539
540def average_precision(labels: np.ndarray, scores: np.ndarray) -> float:
541    """Area under the precision-recall curve, by the step-wise sum.
542
543    ``sum (R_n - R_{n-1}) * P_n`` over the ranking, the definition that does not interpolate
544    and so cannot flatter a short list. Returns NaN when no label is positive, which is a
545    real state here: at a threshold of 0.9 some datasets have no model above it.
546    """
547    if not labels.any():
548        return float("nan")
549    order = np.argsort(-scores, kind="stable")
550    hits = np.cumsum(labels[order])
551    precision = hits / np.arange(1, labels.size + 1)
552    return float((precision * labels[order]).sum() / labels.sum())
553
554
555def decision_report(
556    target: np.ndarray,
557    prediction: np.ndarray,
558    groups: np.ndarray | None = None,
559    thresholds: tuple[float, ...] = (0.5, 0.6, 0.7, 0.8, 0.9),
560) -> pl.DataFrame:
561    """Quality of the go/no-go decision the equation supports.
562
563    An R2 of 0.47 sounds too weak to act on, and read as "how precisely can I state the
564    MCC" it is. But the question a practitioner actually asks is coarser -- *will this
565    model work on this data* -- and a regression too imprecise for the first question can
566    be accurate on the second, because thresholding discards exactly the precision it
567    lacks.
568
569    Each row thresholds both the truth and the prediction at the same value and scores the
570    resulting binary decision. ``majority`` is the accuracy of always answering with the
571    larger class, which is the bar any such rule has to clear to be worth running.
572
573    ``f1`` is reported beside accuracy because the classes are far from balanced at the outer
574    thresholds -- at 0.9 a constant predictor reaches 0.49 accuracy and an F1 of exactly zero,
575    and only the pair distinguishes a rule that works from one that has guessed the majority.
576
577    ``map`` needs ``groups`` and is the mean over datasets of the average precision of the
578    ranking the prediction induces. It is the threshold-free companion: accuracy grades one
579    cut of an ordering, average precision grades the whole ordering. The mean is taken over
580    datasets rather than pooled over all rows because the question is always "which model for
581    *this* data" -- pooling would let a correct ordering between datasets hide a wrong one
582    within a dataset, and nobody ever chooses between datasets.
583    """
584    rows: list[dict[str, object]] = []
585    for threshold in thresholds:
586        actual = target >= threshold
587        predicted = prediction >= threshold
588        hits = int(np.sum(actual & predicted))
589        correct_rejections = int(np.sum(~actual & ~predicted))
590        false_alarms = int(np.sum(~actual & predicted))
591        misses = int(np.sum(actual & ~predicted))
592
593        positives = hits + false_alarms
594        actual_positives = hits + misses
595        denominator = float(
596            np.sqrt(
597                float(positives)
598                * float(actual_positives)
599                * float(correct_rejections + false_alarms)
600                * float(correct_rejections + misses)
601            )
602        )
603        share = float(np.mean(actual))
604        rows.append(
605            {
606                "threshold": threshold,
607                "accuracy": (hits + correct_rejections) / target.shape[0],
608                "majority": max(share, 1.0 - share),
609                "precision": hits / positives if positives else float("nan"),
610                "recall": hits / actual_positives if actual_positives else float("nan"),
611                "mcc": (
612                    (hits * correct_rejections - false_alarms * misses) / denominator
613                    if denominator > 0.0
614                    else float("nan")
615                ),
616                "f1": (
617                    2 * hits / (2 * hits + false_alarms + misses)
618                    if (2 * hits + false_alarms + misses)
619                    else float("nan")
620                ),
621                "map": _grouped_average_precision(target, prediction, groups, threshold),
622                "n_positive": actual_positives,
623            }
624        )
625    return pl.DataFrame(rows)
626
627
628def _grouped_average_precision(
629    target: np.ndarray, prediction: np.ndarray, groups: np.ndarray | None, threshold: float
630) -> float:
631    if groups is None:
632        return float("nan")
633    scores = [
634        average_precision(target[groups == label] >= threshold, prediction[groups == label])
635        for label in np.unique(groups)
636    ]
637    usable = [value for value in scores if not np.isnan(value)]
638    return float(np.mean(usable)) if usable else float("nan")
639
640
641def ranking_report(
642    target: np.ndarray,
643    prediction: np.ndarray,
644    groups: np.ndarray,
645) -> pl.DataFrame:
646    """Per-group ranking quality, scored the way a search engine is scored.
647
648    Only the head of the list is ever used: a practitioner tries the top few models and never
649    sees the tail, so a metric rewarding a correct rank twenty is measuring something nobody
650    reads. Hence ``mrr``, ``hit@1`` and ``regret`` -- all head-weighted -- with ``ap`` grading
651    the whole ordering and ``spearman`` kept for continuity.
652
653    **Read ``ap``, ``mrr``, ``hit@1`` and ``regret``; treat ``spearman`` as weak evidence.**
654    Measured on this corpus it sits between 0.63 and 0.73 for every predictor *and* every
655    baseline, including a constant, so it cannot separate the things this study compares.
656
657    A model counts as a right answer if its MCC is within ``RELEVANCE_TOLERANCE`` of the best
658    on its dataset, rather than by a fixed top-k cut. That is not a convenience: multiple observed
659    rows can share MCC 1.0, so datasets may have several tied best models, and
660    a top-3 rule would score a correct answer as a miss.
661
662    Regret is the practical question in the target's own units: if you pick the model this
663    equation ranks first, how much MCC do you give up against the best you could have picked?
664    """
665    rows: list[dict[str, object]] = []
666    for label in np.unique(groups):
667        mask = groups == label
668        truth, predicted = target[mask], prediction[mask]
669        relevant = truth >= truth.max() - RELEVANCE_TOLERANCE
670        order = np.argsort(-predicted, kind="stable")
671        ranks = [position for position, index in enumerate(order, 1) if relevant[index]]
672        rows.append(
673            {
674                "group": str(label),
675                "ap": average_precision(relevant, predicted),
676                "mrr": 1.0 / ranks[0] if ranks else 0.0,
677                "hit_at_1": float(relevant[order[0]]),
678                "regret": float(truth.max() - truth[int(order[0])]),
679                "spearman": spearman(predicted, truth),
680            }
681        )
682    return pl.DataFrame(rows).sort("group")
683
684
685#: Resamples for the paired bootstrap. Twenty groups is a small enough sample that the
686#: interval is the point of the exercise, and 20k resamples costs microseconds.
687BOOTSTRAP_ROUNDS = 20_000
688BOOTSTRAP_SEED = 42
689
690
691@dataclass(frozen=True)
692class PairedResult:
693    """Whether a per-group difference is real, or inside the spread between folds.
694
695    Every headline in this study is a mean over twenty held-out datasets, and a mean over
696    twenty groups is not a measurement until something says how much of it one group could
697    have produced. A higher mean can come from a few large wins while the other predictor
698    wins on more datasets. Hit@1 is coarser still: over twenty datasets it moves in steps of
699    0.05, so an apparently visible change can be only one or two datasets changing their top
700    pick.
701
702    So: a **sign test** on how many groups improved, which no single group can swing, and
703    a **paired bootstrap** interval on the mean, which shows what the mean is worth. The
704    two answer different questions and disagreeing is informative -- a significant sign
705    test with an interval spanning zero means a consistent but tiny effect.
706
707    ``mean`` and the interval are in the metric's own units, positive meaning the first
708    argument is better. Errors must therefore be negated by the caller; `paired_comparison`
709    does it for you when told the metric is an error.
710    """
711
712    mean: float
713    wins: int
714    losses: int
715    n: int
716    p_value: float
717    low: float
718    high: float
719
720    @property
721    def significant(self) -> bool:
722        """Whether the bootstrap interval excludes zero."""
723        return self.low > 0.0 or self.high < 0.0
724
725    def as_dict(self) -> dict[str, float | int]:
726        return {
727            "mean": self.mean,
728            "wins": self.wins,
729            "losses": self.losses,
730            "n_differing": self.n,
731            "p_value": self.p_value,
732            "ci_low": self.low,
733            "ci_high": self.high,
734        }
735
736
737def sign_test(differences: np.ndarray) -> tuple[int, int, float]:
738    """Wins, comparisons that differ, and the exact two-sided binomial p-value.
739
740    Exact rather than normal-approximate because the sample is twenty groups, where the
741    approximation is poor exactly when the answer matters. Summing binomial coefficients
742    directly is also why this needs no scipy: ``math.comb`` is in the standard library and
743    twenty choose ten is not a large number.
744
745    Ties are dropped rather than split, which is the conservative convention: a fold where
746    two predictors score identically is evidence for neither.
747    """
748    wins = int((differences > 0.0).sum())
749    n = int((differences != 0.0).sum())
750    if n == 0:
751        return 0, 0, 1.0
752    tail = sum(comb(n, k) for k in range(min(wins, n - wins) + 1))
753    return wins, n, min(1.0, 2.0 * tail / 2**n)
754
755
756def paired_comparison(
757    first: np.ndarray,
758    second: np.ndarray,
759    *,
760    lower_is_better: bool = False,
761    rounds: int = BOOTSTRAP_ROUNDS,
762    seed: int = BOOTSTRAP_SEED,
763) -> PairedResult:
764    """Compare two predictors group by group, on the same folds.
765
766    ``first`` and ``second`` are per-group scores in the same group order -- a column of
767    `ranking_report`, or per-fold errors. Pass ``lower_is_better`` for an error metric and
768    the sign is handled here, so a positive ``mean`` always means ``first`` won.
769
770    The bootstrap resamples *groups*, not rows. Rows within a dataset are not exchangeable
771    with rows in another one, and the question is whether the result survives a different
772    draw of datasets, which is the sampling this study's twenty-dataset corpus is a draw
773    from.
774    """
775    if first.shape != second.shape:
776        raise ValueError("paired comparison needs one score per group on both sides")
777    differences = (second - first) if lower_is_better else (first - second)
778    wins, n, p_value = sign_test(differences)
779    losses = int((differences < 0.0).sum())
780    if differences.size == 0:
781        return PairedResult(float("nan"), 0, 0, 0, 1.0, float("nan"), float("nan"))
782    generator = np.random.default_rng(seed)
783    draws = generator.integers(0, differences.size, size=(rounds, differences.size))
784    means = differences[draws].mean(axis=1)
785    return PairedResult(
786        mean=float(differences.mean()),
787        wins=wins,
788        losses=losses,
789        n=n,
790        p_value=p_value,
791        low=float(np.percentile(means, 2.5)),
792        high=float(np.percentile(means, 97.5)),
793    )
794
795
796def interaction_capture(
797    target: np.ndarray,
798    prediction: np.ndarray,
799    first: np.ndarray,
800    second: np.ndarray,
801    rank: int = 1,
802) -> dict[str, float]:
803    """How much of the leading interaction pattern the equation actually reaches.
804
805    `interaction_oracle` measures what a rank-``k`` interaction would be *worth* if
806    someone could predict it. It says nothing about whether the fitted equation gets any
807    of it, and chapter 5 originally answered that by comparing two R2 values -- which
808    cannot distinguish an equation that misses the pattern from one that finds it and is
809    inaccurate elsewhere.
810
811    This compares the two interaction *structures* directly. Both the truth and the
812    prediction are laid on the (dataset x model) grid and stripped of their own additive
813    part, leaving each side's interaction residual; the truth's is then reduced to its
814    leading ``rank`` components. ``alignment`` is the squared correlation between the two
815    over observed cells: 1.0 means the equation's interactions lie exactly along the
816    pattern the oracle found, 0.0 means they are unrelated to it.
817
818    Only observed cells count. The 24 absent ones contribute to neither side.
819    """
820    rows, columns = np.unique(first), np.unique(second)
821    row_index = {label: position for position, label in enumerate(rows)}
822    column_index = {label: position for position, label in enumerate(columns)}
823
824    def grid_of(values: np.ndarray) -> np.ndarray:
825        grid = np.full((rows.shape[0], columns.shape[0]), np.nan)
826        for value, row, column in zip(values, first, second, strict=True):
827            grid[row_index[row], column_index[column]] = value
828        return grid
829
830    def interaction(grid: np.ndarray) -> np.ndarray:
831        grand = float(np.nanmean(grid))
832        row_effect = np.nanmean(grid, axis=1) - grand
833        column_effect = np.nanmean(grid, axis=0) - grand
834        return grid - (grand + row_effect[:, None] + column_effect[None, :])
835
836    truth_grid = grid_of(target)
837    observed = ~np.isnan(truth_grid)
838    truth_interaction = np.where(observed, interaction(truth_grid), 0.0)
839    model_interaction = np.where(observed, interaction(grid_of(prediction)), 0.0)
840
841    left, values, right = np.linalg.svd(truth_interaction, full_matrices=False)
842    leading = (left[:, :rank] * values[:rank]) @ right[:rank]
843
844    wanted, reached = leading[observed], model_interaction[observed]
845    total = float((truth_interaction[observed] ** 2).sum())
846    return {
847        "alignment": float(pearson(wanted, reached) ** 2),
848        "leading_share": float((wanted**2).sum() / total) if total > 0.0 else float("nan"),
849        "interaction_share": total / float(((truth_grid[observed] - np.nanmean(truth_grid)) ** 2).sum())
850        if total > 0.0
851        else float("nan"),
852    }
LOW_VARIANCE_MAE = 0.1
def leave_one_group_out( groups: numpy.ndarray) -> Iterator[tuple[str, numpy.ndarray, numpy.ndarray]]:
48def leave_one_group_out(groups: np.ndarray) -> Iterator[tuple[str, np.ndarray, np.ndarray]]:
49    """Yield ``(held-out label, train mask, test mask)`` for each distinct group."""
50    for label in np.unique(groups):
51        test = groups == label
52        yield str(label), ~test, test

Yield (held-out label, train mask, test mask) for each distinct group.

def random_kfold_groups(size: int, folds: int = 10, seed: int = 42) -> numpy.ndarray:
55def random_kfold_groups(size: int, folds: int = 10, seed: int = 42) -> np.ndarray:
56    """Fold labels for a random split -- used only to demonstrate the leakage it causes."""
57    return np.random.default_rng(seed).integers(0, folds, size=size).astype(str)

Fold labels for a random split -- used only to demonstrate the leakage it causes.

@dataclass(frozen=True)
class Scores:
60@dataclass(frozen=True)
61class Scores:
62    """The metric set reported for every equation and every baseline."""
63
64    r2: float
65    mae: float
66    rmse: float
67    smape: float
68    spearman: float
69    n: int
70
71    def as_dict(self) -> dict[str, float | int]:
72        return {
73            "r2": self.r2,
74            "mae": self.mae,
75            "rmse": self.rmse,
76            "smape": self.smape,
77            "spearman": self.spearman,
78            "n": self.n,
79        }

The metric set reported for every equation and every baseline.

Scores( r2: float, mae: float, rmse: float, smape: float, spearman: float, n: int)
r2: float
mae: float
rmse: float
smape: float
spearman: float
n: int
def as_dict(self) -> dict[str, float | int]:
71    def as_dict(self) -> dict[str, float | int]:
72        return {
73            "r2": self.r2,
74            "mae": self.mae,
75            "rmse": self.rmse,
76            "smape": self.smape,
77            "spearman": self.spearman,
78            "n": self.n,
79        }
def score( truth: numpy.ndarray, prediction: numpy.ndarray) -> Scores:
82def score(truth: np.ndarray, prediction: np.ndarray) -> Scores:
83    return Scores(
84        r2=r2_score(truth, prediction),
85        mae=mae(truth, prediction),
86        rmse=rmse(truth, prediction),
87        smape=smape(truth, prediction),
88        spearman=spearman(truth, prediction),
89        n=int(truth.shape[0]),
90    )
@dataclass
class CrossValidation:
 93@dataclass
 94class CrossValidation:
 95    """Out-of-fold predictions plus the terms each fold chose."""
 96
 97    predictions: np.ndarray
 98    per_fold: dict[str, Scores] = field(default_factory=dict)
 99    selected: list[list[str]] = field(default_factory=list)
100    #: The equation each fold fitted, keyed by the label it held out. Kept so that a
101    #: correction fitted on a fold's training rows -- `ml_meta_perf.identity` fits one -- can
102    #: reuse the search this path already paid for instead of repeating it.
103    equations: dict[str, Equation] = field(default_factory=dict)
104    #: How many out-of-fold predictions `_clip_to_training` moved. Not a diagnostic detail:
105    #: the count is reported for each equation and protocol, since clipping can affect
106    #: the reported predictions. A study that clips has to say how often the clip fired.
107    clipped: int = 0
108
109    def scores(self, truth: np.ndarray) -> Scores:
110        return score(truth, self.predictions)
111
112    def dispersion(self) -> dict[str, float]:
113        """How the per-fold error is spread, next to the pooled number.
114
115        `scores` pools every out-of-fold prediction and scores it against the *global* mean.
116        On this corpus most of the variance is between datasets, which the dataset features
117        capture almost for free, so pooled R² flatters a LODO split: a set
118        reaching a pooled 0.53 was explaining 0.29 of the variance *within* the average
119        dataset. Pooling also hides that a single fold can dominate.
120
121        **Read the MAE columns, not ``worst_fold_r2``.** A per-fold R2 divides by the variance
122        of that fold's own target, and three datasets here have almost none: every one of
123        `5G_Slicing`'s 25 models scores about 0.986, a standard deviation of 0.052, and `NSR`
124        and `DeepSlice` are the same. Dividing an ordinary error by a variance that small
125        returns a large negative number for a nearly perfect prediction -- `5G_Slicing` reads
126        -10.6 while its predictions are all within 0.05 of the truth. Those three are the only
127        strongly negative folds, and the number is an artefact of the metric rather than a
128        failure of the equation.
129
130        MAE has no such denominator, so ``worst_fold_mae`` is the column that actually catches
131        a bad fold. ``worst_fold_r2`` is kept because it is what a reader expects to see, and
132        ``low_variance_folds`` counts the groups on which it cannot be trusted.
133        """
134        if not self.per_fold:
135            return {
136                "median_fold_r2": float("nan"),
137                "worst_fold_r2": float("nan"),
138                "median_fold_mae": float("nan"),
139                "worst_fold_mae": float("nan"),
140                "low_variance_folds": 0.0,
141                "clipped_predictions": float(self.clipped),
142                "folds": 0.0,
143            }
144        r2_values = [fold.r2 for fold in self.per_fold.values()]
145        mae_values = [fold.mae for fold in self.per_fold.values()]
146        return {
147            "median_fold_r2": float(np.median(r2_values)),
148            "worst_fold_r2": float(min(r2_values)),
149            "median_fold_mae": float(np.median(mae_values)),
150            "worst_fold_mae": float(max(mae_values)),
151            "low_variance_folds": float(self._low_variance_folds()),
152            "clipped_predictions": float(self.clipped),
153            "folds": float(len(r2_values)),
154        }
155
156    def _low_variance_folds(self) -> int:
157        """Folds whose per-fold R2 is not worth reading, inferred from the scores themselves.
158
159        A fold with a large negative R2 and a small MAE has a near-constant target, not a bad
160        prediction: R2 is dividing by nothing. The pair of conditions identifies that without
161        the class needing a copy of the target.
162        """
163        return sum(1 for fold in self.per_fold.values() if fold.r2 < 0.0 and fold.mae < LOW_VARIANCE_MAE)
164
165    def stability(self) -> pl.DataFrame:
166        """How often each term was selected across folds.
167
168        A term chosen in nearly every fold has stronger support than one chosen only a few
169        times. Reporting the final all-data equation without this column would present the
170        two identically.
171        """
172        counts: dict[str, int] = {}
173        for names in self.selected:
174            for name in names:
175                counts[name] = counts.get(name, 0) + 1
176        total = max(len(self.selected), 1)
177        return (
178            pl.DataFrame({"term": list(counts), "folds": list(counts.values())})
179            .with_columns((pl.col("folds") / total).alias("frequency"))
180            .sort("folds", descending=True)
181        )

Out-of-fold predictions plus the terms each fold chose.

CrossValidation( predictions: numpy.ndarray, per_fold: dict[str, Scores] = <factory>, selected: list[list[str]] = <factory>, equations: dict[str, ml_meta_perf.model.Equation] = <factory>, clipped: int = 0)
predictions: numpy.ndarray
per_fold: dict[str, Scores]
selected: list[list[str]]
equations: dict[str, ml_meta_perf.model.Equation]
clipped: int = 0
def scores(self, truth: numpy.ndarray) -> Scores:
109    def scores(self, truth: np.ndarray) -> Scores:
110        return score(truth, self.predictions)
def dispersion(self) -> dict[str, float]:
112    def dispersion(self) -> dict[str, float]:
113        """How the per-fold error is spread, next to the pooled number.
114
115        `scores` pools every out-of-fold prediction and scores it against the *global* mean.
116        On this corpus most of the variance is between datasets, which the dataset features
117        capture almost for free, so pooled R² flatters a LODO split: a set
118        reaching a pooled 0.53 was explaining 0.29 of the variance *within* the average
119        dataset. Pooling also hides that a single fold can dominate.
120
121        **Read the MAE columns, not ``worst_fold_r2``.** A per-fold R2 divides by the variance
122        of that fold's own target, and three datasets here have almost none: every one of
123        `5G_Slicing`'s 25 models scores about 0.986, a standard deviation of 0.052, and `NSR`
124        and `DeepSlice` are the same. Dividing an ordinary error by a variance that small
125        returns a large negative number for a nearly perfect prediction -- `5G_Slicing` reads
126        -10.6 while its predictions are all within 0.05 of the truth. Those three are the only
127        strongly negative folds, and the number is an artefact of the metric rather than a
128        failure of the equation.
129
130        MAE has no such denominator, so ``worst_fold_mae`` is the column that actually catches
131        a bad fold. ``worst_fold_r2`` is kept because it is what a reader expects to see, and
132        ``low_variance_folds`` counts the groups on which it cannot be trusted.
133        """
134        if not self.per_fold:
135            return {
136                "median_fold_r2": float("nan"),
137                "worst_fold_r2": float("nan"),
138                "median_fold_mae": float("nan"),
139                "worst_fold_mae": float("nan"),
140                "low_variance_folds": 0.0,
141                "clipped_predictions": float(self.clipped),
142                "folds": 0.0,
143            }
144        r2_values = [fold.r2 for fold in self.per_fold.values()]
145        mae_values = [fold.mae for fold in self.per_fold.values()]
146        return {
147            "median_fold_r2": float(np.median(r2_values)),
148            "worst_fold_r2": float(min(r2_values)),
149            "median_fold_mae": float(np.median(mae_values)),
150            "worst_fold_mae": float(max(mae_values)),
151            "low_variance_folds": float(self._low_variance_folds()),
152            "clipped_predictions": float(self.clipped),
153            "folds": float(len(r2_values)),
154        }

How the per-fold error is spread, next to the pooled number.

scores pools every out-of-fold prediction and scores it against the global mean. On this corpus most of the variance is between datasets, which the dataset features capture almost for free, so pooled R² flatters a LODO split: a set reaching a pooled 0.53 was explaining 0.29 of the variance within the average dataset. Pooling also hides that a single fold can dominate.

Read the MAE columns, not worst_fold_r2. A per-fold R2 divides by the variance of that fold's own target, and three datasets here have almost none: every one of 5G_Slicing's 25 models scores about 0.986, a standard deviation of 0.052, and NSR and DeepSlice are the same. Dividing an ordinary error by a variance that small returns a large negative number for a nearly perfect prediction -- 5G_Slicing reads -10.6 while its predictions are all within 0.05 of the truth. Those three are the only strongly negative folds, and the number is an artefact of the metric rather than a failure of the equation.

MAE has no such denominator, so worst_fold_mae is the column that actually catches a bad fold. worst_fold_r2 is kept because it is what a reader expects to see, and low_variance_folds counts the groups on which it cannot be trusted.

def stability(self) -> polars.dataframe.frame.DataFrame:
165    def stability(self) -> pl.DataFrame:
166        """How often each term was selected across folds.
167
168        A term chosen in nearly every fold has stronger support than one chosen only a few
169        times. Reporting the final all-data equation without this column would present the
170        two identically.
171        """
172        counts: dict[str, int] = {}
173        for names in self.selected:
174            for name in names:
175                counts[name] = counts.get(name, 0) + 1
176        total = max(len(self.selected), 1)
177        return (
178            pl.DataFrame({"term": list(counts), "folds": list(counts.values())})
179            .with_columns((pl.col("folds") / total).alias("frequency"))
180            .sort("folds", descending=True)
181        )

How often each term was selected across folds.

A term chosen in nearly every fold has stronger support than one chosen only a few times. Reporting the final all-data equation without this column would present the two identically.

def fold_selections( library: ml_meta_perf.terms.Library, target: numpy.ndarray, groups: numpy.ndarray, *, n_terms: int, penalty: float, pool_size: int = 250, beam_width: int = 6) -> list[list[str]]:
184def fold_selections(
185    library: Library,
186    target: np.ndarray,
187    groups: np.ndarray,
188    *,
189    n_terms: int,
190    penalty: float,
191    pool_size: int = 250,
192    beam_width: int = 6,
193) -> list[list[str]]:
194    """Which terms selection picks when it is re-run inside each fold.
195
196    **This returns term names and nothing else, deliberately.** Re-selecting inside the folds
197    and scoring the result answers a question about the *discovery procedure*, not about the
198    equation, and the study does not report it; `cross_validate_fixed_form` produces every
199    cross-validated number. Returning no predictions means the re-selecting protocol cannot
200    produce a reported score even by accident.
201
202    What it is for is `CrossValidation.stability`. Fixing an equation's form is a claim that
203    the form describes the phenomenon rather than these 484 rows, and the way to check that is
204    to remove one dataset and see whether the same terms come back. A form whose terms
205    churn fold to fold has not earned the fixed-form protocol.
206    """
207    selections: list[list[str]] = []
208    for _, train, _ in leave_one_group_out(groups):
209        train_matrix = library.matrix[train]
210        standardizer = Standardizer.fit(train_matrix)
211        design = standardizer.apply(train_matrix)
212        pool = guided_screen(library_rows(library, train), target[train], keep=pool_size)
213        selector = Selector(design, target[train], penalty, library.feature_groups)
214        subsets = selector.search(pool, n_terms, beam_width=beam_width)
215        if n_terms in subsets:
216            selections.append([library.terms[index].name for index in subsets[n_terms].indices])
217    return selections

Which terms selection picks when it is re-run inside each fold.

This returns term names and nothing else, deliberately. Re-selecting inside the folds and scoring the result answers a question about the discovery procedure, not about the equation, and the study does not report it; cross_validate_fixed_form produces every cross-validated number. Returning no predictions means the re-selecting protocol cannot produce a reported score even by accident.

What it is for is CrossValidation.stability. Fixing an equation's form is a claim that the form describes the phenomenon rather than these 484 rows, and the way to check that is to remove one dataset and see whether the same terms come back. A form whose terms churn fold to fold has not earned the fixed-form protocol.

def term_stability( selections: list[list[str]], required_terms: tuple[str, ...] = ()) -> polars.dataframe.frame.DataFrame:
220def term_stability(selections: list[list[str]], required_terms: tuple[str, ...] = ()) -> pl.DataFrame:
221    """How often each term was selected across folds.
222
223    A term chosen in nearly every fold has stronger support than one chosen only a few times.
224    Reporting the final all-data equation without this column would present the two
225    identically.
226    """
227    counts: dict[str, int] = dict.fromkeys(required_terms, 0)
228    for names in selections:
229        for name in names:
230            counts[name] = counts.get(name, 0) + 1
231    total = max(len(selections), 1)
232    return (
233        pl.DataFrame({"term": list(counts), "folds": list(counts.values())})
234        .with_columns((pl.col("folds") / total).alias("frequency"))
235        .sort("folds", descending=True)
236    )

How often each term was selected across folds.

A term chosen in nearly every fold has stronger support than one chosen only a few times. Reporting the final all-data equation without this column would present the two identically.

def cross_validate_fixed_form( library: ml_meta_perf.terms.Library, columns: dict[str, numpy.ndarray], target: numpy.ndarray, groups: numpy.ndarray, equations: dict[int, ml_meta_perf.model.Equation], *, penalty: float) -> dict[int, CrossValidation]:
239def cross_validate_fixed_form(
240    library: Library,
241    columns: dict[str, np.ndarray],
242    target: np.ndarray,
243    groups: np.ndarray,
244    equations: dict[int, Equation],
245    *,
246    penalty: float,
247) -> dict[int, CrossValidation]:
248    """Cross-validate each equation with its **form fixed**: only the weights are refit.
249
250    **This is the study's cross-validation protocol.** Every reported leave-one-group-out
251    number comes from here. The choice is a position about what the artefact is rather than a
252    statistical convenience. The form of an equation is its
253    conceptual claim -- a statement about which quantities govern how well a learner does on a
254    dataset -- and for a simpler problem one would write that form down from domain expertise
255    and never search for it. What cross-validation then tests is whether the claim survives
256    data it has not seen, with its constants recalibrated.
257
258    `fold_selections` re-runs selection inside every fold and so answers a different
259    question -- whether the *discovery procedure* generalises. **It cannot be used for a reported score** -- it
260    returns no predictions. Twenty folds fit twenty different equations, so its pooled R2 is an
261    average over twenty models: it moved by up to 0.3 when the requested length changed by
262    two, while the fixed form varies by 0.014 over the same range. Its one remaining job is
263    `CrossValidation.stability`.
264
265    The obligation this creates is that the form must not be an artefact of the sample, and
266    `term_stability` over a `fold_selections` run is how that is checked --
267    separately, and not as part of the reported score.
268
269    Predictions are bounded by each training fold's own target range, as in
270    `fold_selections`; see `_clip_to_training`.
271    """
272    index = {name: position for position, name in enumerate(library.names)}
273    results: dict[int, CrossValidation] = {}
274    for size, equation in equations.items():
275        positions = [index[term.name] for term in equation.terms]
276        if not positions:
277            continue
278        matrix = library.matrix[:, positions]
279        outcome = CrossValidation(predictions=np.zeros_like(target))
280        for label, train, test in leave_one_group_out(groups):
281            standardizer = Standardizer.fit(matrix[train])
282            design = standardizer.apply(matrix[train])
283            gram = design.T @ design + penalty * np.eye(len(positions))
284            offset = float(target[train].mean())
285            weights = np.linalg.solve(gram, design.T @ (target[train] - offset))
286            held = standardizer.apply(matrix[test]) @ weights + offset
287            bounded = _clip_to_training(held, target[train])
288            outcome.clipped += int(np.count_nonzero(bounded != held))
289            outcome.predictions[test] = bounded
290            outcome.per_fold[label] = score(target[test], outcome.predictions[test])
291            outcome.selected.append([term.name for term in equation.terms])
292            # The same terms every fold, carrying that fold's weights, with the standardisation
293            # folded back in exactly as `fit.to_equation` does. Recorded so a downstream
294            # correction -- `identity.correct_out_of_fold` fits one -- can reuse the fold's own
295            # equation instead of the all-data one.
296            raw = weights / standardizer.scale
297            outcome.equations[label] = Equation(
298                intercept=offset - float(raw @ standardizer.mean),
299                terms=tuple(equation.terms),
300                weights=tuple(float(value) for value in raw),
301                standardized_weights=tuple(float(value) for value in weights),
302                name=f"fold_{label}",
303            )
304        results[size] = outcome
305    return results

Cross-validate each equation with its form fixed: only the weights are refit.

This is the study's cross-validation protocol. Every reported leave-one-group-out number comes from here. The choice is a position about what the artefact is rather than a statistical convenience. The form of an equation is its conceptual claim -- a statement about which quantities govern how well a learner does on a dataset -- and for a simpler problem one would write that form down from domain expertise and never search for it. What cross-validation then tests is whether the claim survives data it has not seen, with its constants recalibrated.

fold_selections re-runs selection inside every fold and so answers a different question -- whether the discovery procedure generalises. It cannot be used for a reported score -- it returns no predictions. Twenty folds fit twenty different equations, so its pooled R2 is an average over twenty models: it moved by up to 0.3 when the requested length changed by two, while the fixed form varies by 0.014 over the same range. Its one remaining job is CrossValidation.stability.

The obligation this creates is that the form must not be an artefact of the sample, and term_stability over a fold_selections run is how that is checked -- separately, and not as part of the reported score.

Predictions are bounded by each training fold's own target range, as in fold_selections; see _clip_to_training.

def cross_validate_doubly_held_out( library: ml_meta_perf.terms.Library, columns: dict[str, numpy.ndarray], target: numpy.ndarray, first: numpy.ndarray, second: numpy.ndarray, equations: dict[int, ml_meta_perf.model.Equation], *, penalty: float) -> dict[int, CrossValidation]:
308def cross_validate_doubly_held_out(
309    library: Library,
310    columns: dict[str, np.ndarray],
311    target: np.ndarray,
312    first: np.ndarray,
313    second: np.ndarray,
314    equations: dict[int, Equation],
315    *,
316    penalty: float,
317) -> dict[int, CrossValidation]:
318    """Refit each equation with **both** groups of a cell removed, and predict that cell.
319
320    This is the protocol for the question the study is actually for: *what MCC will this
321    model reach on this dataset*, when neither has been run. Neither single-group protocol
322    answers it. LODO holds the dataset out and keeps the model -- it has
323    seen that learner on nineteen other problems. LOMO does the reverse. For a
324    cell to be genuinely unseen, the whole dataset row-block *and* the whole model
325    column-block have to leave the training set, which is what this does.
326
327    **It is also the only protocol under which the comparison with the trivial baselines is
328    fair, and under it they do not exist.** A per-model mean or median answers "how well does
329    this model usually do", and a model held out of every fold has no rows to average. So a
330    baseline that beats the equation on ranking under LODO is a baseline
331    being handed the model identity the equation is denied; here it cannot be computed at
332    all, while the equation still predicts from features.
333
334    Costlier than the single-group protocols -- one solve per observed cell rather than per
335    group -- but each solve is a small ridge on a fixed design, so it is seconds, not minutes.
336    """
337    index = {name: position for position, name in enumerate(library.names)}
338    results: dict[int, CrossValidation] = {}
339    for size, equation in equations.items():
340        positions = [index[term.name] for term in equation.terms]
341        if not positions:
342            continue
343        matrix = library.matrix[:, positions]
344        outcome = CrossValidation(predictions=np.zeros_like(target))
345        for row_label in np.unique(first):
346            for column_label in np.unique(second):
347                test = (first == row_label) & (second == column_label)
348                if not test.any():
349                    continue
350                train = (first != row_label) & (second != column_label)
351                if not train.any():
352                    continue
353                standardizer = Standardizer.fit(matrix[train])
354                design = standardizer.apply(matrix[train])
355                gram = design.T @ design + penalty * np.eye(len(positions))
356                offset = float(target[train].mean())
357                weights = np.linalg.solve(gram, design.T @ (target[train] - offset))
358                held = standardizer.apply(matrix[test]) @ weights + offset
359                bounded = _clip_to_training(held, target[train])
360                outcome.clipped += int(np.count_nonzero(bounded != held))
361                outcome.predictions[test] = bounded
362        # Scored per held-out *dataset* rather than per cell: a cell is one to a few rows and
363        # a per-cell R2 has no variance to divide by. The dataset grouping is what the
364        # dispersion columns and the paired tests use everywhere else.
365        for label, _, test in leave_one_group_out(first):
366            outcome.per_fold[label] = score(target[test], outcome.predictions[test])
367            outcome.selected.append([term.name for term in equation.terms])
368        results[size] = outcome
369    return results

Refit each equation with both groups of a cell removed, and predict that cell.

This is the protocol for the question the study is actually for: what MCC will this model reach on this dataset, when neither has been run. Neither single-group protocol answers it. LODO holds the dataset out and keeps the model -- it has seen that learner on nineteen other problems. LOMO does the reverse. For a cell to be genuinely unseen, the whole dataset row-block and the whole model column-block have to leave the training set, which is what this does.

It is also the only protocol under which the comparison with the trivial baselines is fair, and under it they do not exist. A per-model mean or median answers "how well does this model usually do", and a model held out of every fold has no rows to average. So a baseline that beats the equation on ranking under LODO is a baseline being handed the model identity the equation is denied; here it cannot be computed at all, while the equation still predicts from features.

Costlier than the single-group protocols -- one solve per observed cell rather than per group -- but each solve is a small ridge on a fixed design, so it is seconds, not minutes.

def library_rows( library: ml_meta_perf.terms.Library, mask: numpy.ndarray) -> ml_meta_perf.terms.Library:
387def library_rows(library: Library, mask: np.ndarray) -> Library:
388    """A library restricted to a row subset, without re-evaluating any term."""
389    clone = object.__new__(Library)
390    clone.terms = library.terms
391    clone.matrix = library.matrix[mask]
392    return clone

A library restricted to a row subset, without re-evaluating any term.

def baseline_group_centre( target: numpy.ndarray, outer: numpy.ndarray, inner: numpy.ndarray | None = None, *, centre: str = 'mean') -> numpy.ndarray:
395def baseline_group_centre(
396    target: np.ndarray,
397    outer: np.ndarray,
398    inner: np.ndarray | None = None,
399    *,
400    centre: str = "mean",
401) -> np.ndarray:
402    """Predict a training-fold centre, optionally conditioned on a second grouping.
403
404    With ``inner`` set to the model column under a LODO split, this is
405    "what does this model usually score", the baseline any meta-model has to beat to be
406    worth writing down.
407
408    ``centre`` picks the summary. **Which one is the fair comparison depends on the metric
409    being reported, and using the mean for all of them understates the baseline.** The mean
410    minimises squared error, so it is the right opponent for R2 and RMSE. The *median*
411    minimises absolute error, so an MAE quoted against a mean baseline is quoted against a
412    baseline that is not even trying -- and SMAPE, being an absolute-error ratio, behaves the
413    same way. Both are reported so each metric is read against the baseline that is hardest
414    to beat on it.
415
416    The leave-one-group-out loop is not decoration: with 17-25 rows per group, including the
417    row being predicted inflates the per-model mean's R2 by 0.082. A naive group centre
418    computed over all rows is not this function.
419    """
420    summarise = np.median if centre == "median" else np.mean
421    predictions = np.zeros_like(target)
422    for _, train, test in leave_one_group_out(outer):
423        fallback = float(summarise(target[train]))
424        if inner is None:
425            predictions[test] = fallback
426            continue
427        for label in np.unique(inner[test]):
428            selected = test & (inner == label)
429            source = train & (inner == label)
430            predictions[selected] = float(summarise(target[source])) if source.any() else fallback
431    return np.clip(predictions, MCC_LOWER, MCC_UPPER)

Predict a training-fold centre, optionally conditioned on a second grouping.

With inner set to the model column under a LODO split, this is "what does this model usually score", the baseline any meta-model has to beat to be worth writing down.

centre picks the summary. Which one is the fair comparison depends on the metric being reported, and using the mean for all of them understates the baseline. The mean minimises squared error, so it is the right opponent for R2 and RMSE. The median minimises absolute error, so an MAE quoted against a mean baseline is quoted against a baseline that is not even trying -- and SMAPE, being an absolute-error ratio, behaves the same way. Both are reported so each metric is read against the baseline that is hardest to beat on it.

The leave-one-group-out loop is not decoration: with 17-25 rows per group, including the row being predicted inflates the per-model mean's R2 by 0.082. A naive group centre computed over all rows is not this function.

def baseline_group_mean( target: numpy.ndarray, outer: numpy.ndarray, inner: numpy.ndarray | None = None) -> numpy.ndarray:
434def baseline_group_mean(
435    target: np.ndarray,
436    outer: np.ndarray,
437    inner: np.ndarray | None = None,
438) -> np.ndarray:
439    """`baseline_group_centre` at the mean. Kept as the name the rest of the study uses."""
440    return baseline_group_centre(target, outer, inner, centre="mean")

baseline_group_centre at the mean. Kept as the name the rest of the study uses.

def additive_mean_reference( target: numpy.ndarray, first: numpy.ndarray, second: numpy.ndarray) -> numpy.ndarray:
443def additive_mean_reference(target: np.ndarray, first: np.ndarray, second: np.ndarray) -> np.ndarray:
444    """Dataset and model target means combined into a descriptive IS reference.
445
446    The calculation adds each observed dataset mean and model mean, subtracts the global
447    mean, and clips the result to the MCC range. It uses target identities and is therefore
448    not a deployable predictor. With an incomplete dataset-by-model grid it is also not the
449    jointly fitted least-squares optimum or a ceiling for additive equations.
450    """
451    grand = float(target.mean())
452    prediction = np.full_like(target, grand)
453    for group in (first, second):
454        for label in np.unique(group):
455            mask = group == label
456            prediction[mask] += float(target[mask].mean()) - grand
457    return np.clip(prediction, MCC_LOWER, MCC_UPPER)

Dataset and model target means combined into a descriptive IS reference.

The calculation adds each observed dataset mean and model mean, subtracts the global mean, and clips the result to the MCC range. It uses target identities and is therefore not a deployable predictor. With an incomplete dataset-by-model grid it is also not the jointly fitted least-squares optimum or a ceiling for additive equations.

def interaction_oracle( target: numpy.ndarray, first: numpy.ndarray, second: numpy.ndarray, rank: int) -> numpy.ndarray:
460def interaction_oracle(
461    target: np.ndarray,
462    first: np.ndarray,
463    second: np.ndarray,
464    rank: int,
465) -> np.ndarray:
466    """The additive mean-based reference plus the best rank-``rank`` approximation of what it misses.
467
468    ``additive_mean_reference`` describes how much MCC follows dataset and model means.
469    The obvious next question is what the leftover looks like, and the leftover is not
470    noise: it is a (dataset x model) matrix of interactions, and interaction matrices are
471    usually dominated by a few components.
472
473    So the residual is decomposed by SVD and its leading components added back. This is
474    the AMMI model -- additive main effects, multiplicative interaction -- long used for
475    genotype-by-environment trials, which is structurally the same problem: a grid of
476    subjects crossed with conditions where some pairings suit each other.
477
478    The result is a *ladder* rather than a single ceiling. ``rank=0`` is the additive
479    mean-based reference; each further component is one more pattern of "this kind of model suits this
480    kind of dataset". At full rank it reproduces every observed cell and R2 is 1, which is
481    why the interesting question is how fast the ladder climbs, not where it ends.
482
483    Like ``additive_mean_reference`` this is fitted with the true values and predicts nothing.
484    Unobserved cells (24 of the 500 here) contribute zero residual, so they neither
485    distort the decomposition nor are counted in any score.
486    """
487    rows = np.unique(first)
488    columns = np.unique(second)
489    row_index = {label: position for position, label in enumerate(rows)}
490    column_index = {label: position for position, label in enumerate(columns)}
491
492    grid = np.full((rows.shape[0], columns.shape[0]), np.nan)
493    for value, row, column in zip(target, first, second, strict=True):
494        grid[row_index[row], column_index[column]] = value
495    observed = ~np.isnan(grid)
496
497    grand = float(np.nanmean(grid))
498    row_effect = np.nanmean(grid, axis=1) - grand
499    column_effect = np.nanmean(grid, axis=0) - grand
500    additive = grand + row_effect[:, None] + column_effect[None, :]
501
502    if rank > 0:
503        residual = np.where(observed, grid - additive, 0.0)
504        left, values, right = np.linalg.svd(residual, full_matrices=False)
505        additive = additive + (left[:, :rank] * values[:rank]) @ right[:rank]
506
507    prediction = np.array(
508        [additive[row_index[row], column_index[column]] for row, column in zip(first, second, strict=True)]
509    )
510    return np.clip(prediction, MCC_LOWER, MCC_UPPER)

The additive mean-based reference plus the best rank-rank approximation of what it misses.

additive_mean_reference describes how much MCC follows dataset and model means. The obvious next question is what the leftover looks like, and the leftover is not noise: it is a (dataset x model) matrix of interactions, and interaction matrices are usually dominated by a few components.

So the residual is decomposed by SVD and its leading components added back. This is the AMMI model -- additive main effects, multiplicative interaction -- long used for genotype-by-environment trials, which is structurally the same problem: a grid of subjects crossed with conditions where some pairings suit each other.

The result is a ladder rather than a single ceiling. rank=0 is the additive mean-based reference; each further component is one more pattern of "this kind of model suits this kind of dataset". At full rank it reproduces every observed cell and R2 is 1, which is why the interesting question is how fast the ladder climbs, not where it ends.

Like additive_mean_reference this is fitted with the true values and predicts nothing. Unobserved cells (24 of the 500 here) contribute zero residual, so they neither distort the decomposition nor are counted in any score.

def oracle_ladder( target: numpy.ndarray, first: numpy.ndarray, second: numpy.ndarray, ranks: tuple[int, ...] = (0, 1, 2, 3, 4, 6, 8)) -> polars.dataframe.frame.DataFrame:
513def oracle_ladder(
514    target: np.ndarray,
515    first: np.ndarray,
516    second: np.ndarray,
517    ranks: tuple[int, ...] = (0, 1, 2, 3, 4, 6, 8),
518) -> pl.DataFrame:
519    """How much each additional interaction component would be worth."""
520    rows: list[dict[str, object]] = []
521    previous: float | None = None
522    for rank in ranks:
523        value = r2_score(target, interaction_oracle(target, first, second, rank))
524        rows.append(
525            {
526                "interaction_rank": rank,
527                "r2": value,
528                "gain": value - previous if previous is not None else float("nan"),
529            }
530        )
531        previous = value
532    return pl.DataFrame(rows)

How much each additional interaction component would be worth.

RELEVANCE_TOLERANCE = 0.01
def average_precision(labels: numpy.ndarray, scores: numpy.ndarray) -> float:
541def average_precision(labels: np.ndarray, scores: np.ndarray) -> float:
542    """Area under the precision-recall curve, by the step-wise sum.
543
544    ``sum (R_n - R_{n-1}) * P_n`` over the ranking, the definition that does not interpolate
545    and so cannot flatter a short list. Returns NaN when no label is positive, which is a
546    real state here: at a threshold of 0.9 some datasets have no model above it.
547    """
548    if not labels.any():
549        return float("nan")
550    order = np.argsort(-scores, kind="stable")
551    hits = np.cumsum(labels[order])
552    precision = hits / np.arange(1, labels.size + 1)
553    return float((precision * labels[order]).sum() / labels.sum())

Area under the precision-recall curve, by the step-wise sum.

sum (R_n - R_{n-1}) * P_n over the ranking, the definition that does not interpolate and so cannot flatter a short list. Returns NaN when no label is positive, which is a real state here: at a threshold of 0.9 some datasets have no model above it.

def decision_report( target: numpy.ndarray, prediction: numpy.ndarray, groups: numpy.ndarray | None = None, thresholds: tuple[float, ...] = (0.5, 0.6, 0.7, 0.8, 0.9)) -> polars.dataframe.frame.DataFrame:
556def decision_report(
557    target: np.ndarray,
558    prediction: np.ndarray,
559    groups: np.ndarray | None = None,
560    thresholds: tuple[float, ...] = (0.5, 0.6, 0.7, 0.8, 0.9),
561) -> pl.DataFrame:
562    """Quality of the go/no-go decision the equation supports.
563
564    An R2 of 0.47 sounds too weak to act on, and read as "how precisely can I state the
565    MCC" it is. But the question a practitioner actually asks is coarser -- *will this
566    model work on this data* -- and a regression too imprecise for the first question can
567    be accurate on the second, because thresholding discards exactly the precision it
568    lacks.
569
570    Each row thresholds both the truth and the prediction at the same value and scores the
571    resulting binary decision. ``majority`` is the accuracy of always answering with the
572    larger class, which is the bar any such rule has to clear to be worth running.
573
574    ``f1`` is reported beside accuracy because the classes are far from balanced at the outer
575    thresholds -- at 0.9 a constant predictor reaches 0.49 accuracy and an F1 of exactly zero,
576    and only the pair distinguishes a rule that works from one that has guessed the majority.
577
578    ``map`` needs ``groups`` and is the mean over datasets of the average precision of the
579    ranking the prediction induces. It is the threshold-free companion: accuracy grades one
580    cut of an ordering, average precision grades the whole ordering. The mean is taken over
581    datasets rather than pooled over all rows because the question is always "which model for
582    *this* data" -- pooling would let a correct ordering between datasets hide a wrong one
583    within a dataset, and nobody ever chooses between datasets.
584    """
585    rows: list[dict[str, object]] = []
586    for threshold in thresholds:
587        actual = target >= threshold
588        predicted = prediction >= threshold
589        hits = int(np.sum(actual & predicted))
590        correct_rejections = int(np.sum(~actual & ~predicted))
591        false_alarms = int(np.sum(~actual & predicted))
592        misses = int(np.sum(actual & ~predicted))
593
594        positives = hits + false_alarms
595        actual_positives = hits + misses
596        denominator = float(
597            np.sqrt(
598                float(positives)
599                * float(actual_positives)
600                * float(correct_rejections + false_alarms)
601                * float(correct_rejections + misses)
602            )
603        )
604        share = float(np.mean(actual))
605        rows.append(
606            {
607                "threshold": threshold,
608                "accuracy": (hits + correct_rejections) / target.shape[0],
609                "majority": max(share, 1.0 - share),
610                "precision": hits / positives if positives else float("nan"),
611                "recall": hits / actual_positives if actual_positives else float("nan"),
612                "mcc": (
613                    (hits * correct_rejections - false_alarms * misses) / denominator
614                    if denominator > 0.0
615                    else float("nan")
616                ),
617                "f1": (
618                    2 * hits / (2 * hits + false_alarms + misses)
619                    if (2 * hits + false_alarms + misses)
620                    else float("nan")
621                ),
622                "map": _grouped_average_precision(target, prediction, groups, threshold),
623                "n_positive": actual_positives,
624            }
625        )
626    return pl.DataFrame(rows)

Quality of the go/no-go decision the equation supports.

An R2 of 0.47 sounds too weak to act on, and read as "how precisely can I state the MCC" it is. But the question a practitioner actually asks is coarser -- will this model work on this data -- and a regression too imprecise for the first question can be accurate on the second, because thresholding discards exactly the precision it lacks.

Each row thresholds both the truth and the prediction at the same value and scores the resulting binary decision. majority is the accuracy of always answering with the larger class, which is the bar any such rule has to clear to be worth running.

f1 is reported beside accuracy because the classes are far from balanced at the outer thresholds -- at 0.9 a constant predictor reaches 0.49 accuracy and an F1 of exactly zero, and only the pair distinguishes a rule that works from one that has guessed the majority.

map needs groups and is the mean over datasets of the average precision of the ranking the prediction induces. It is the threshold-free companion: accuracy grades one cut of an ordering, average precision grades the whole ordering. The mean is taken over datasets rather than pooled over all rows because the question is always "which model for this data" -- pooling would let a correct ordering between datasets hide a wrong one within a dataset, and nobody ever chooses between datasets.

def ranking_report( target: numpy.ndarray, prediction: numpy.ndarray, groups: numpy.ndarray) -> polars.dataframe.frame.DataFrame:
642def ranking_report(
643    target: np.ndarray,
644    prediction: np.ndarray,
645    groups: np.ndarray,
646) -> pl.DataFrame:
647    """Per-group ranking quality, scored the way a search engine is scored.
648
649    Only the head of the list is ever used: a practitioner tries the top few models and never
650    sees the tail, so a metric rewarding a correct rank twenty is measuring something nobody
651    reads. Hence ``mrr``, ``hit@1`` and ``regret`` -- all head-weighted -- with ``ap`` grading
652    the whole ordering and ``spearman`` kept for continuity.
653
654    **Read ``ap``, ``mrr``, ``hit@1`` and ``regret``; treat ``spearman`` as weak evidence.**
655    Measured on this corpus it sits between 0.63 and 0.73 for every predictor *and* every
656    baseline, including a constant, so it cannot separate the things this study compares.
657
658    A model counts as a right answer if its MCC is within ``RELEVANCE_TOLERANCE`` of the best
659    on its dataset, rather than by a fixed top-k cut. That is not a convenience: multiple observed
660    rows can share MCC 1.0, so datasets may have several tied best models, and
661    a top-3 rule would score a correct answer as a miss.
662
663    Regret is the practical question in the target's own units: if you pick the model this
664    equation ranks first, how much MCC do you give up against the best you could have picked?
665    """
666    rows: list[dict[str, object]] = []
667    for label in np.unique(groups):
668        mask = groups == label
669        truth, predicted = target[mask], prediction[mask]
670        relevant = truth >= truth.max() - RELEVANCE_TOLERANCE
671        order = np.argsort(-predicted, kind="stable")
672        ranks = [position for position, index in enumerate(order, 1) if relevant[index]]
673        rows.append(
674            {
675                "group": str(label),
676                "ap": average_precision(relevant, predicted),
677                "mrr": 1.0 / ranks[0] if ranks else 0.0,
678                "hit_at_1": float(relevant[order[0]]),
679                "regret": float(truth.max() - truth[int(order[0])]),
680                "spearman": spearman(predicted, truth),
681            }
682        )
683    return pl.DataFrame(rows).sort("group")

Per-group ranking quality, scored the way a search engine is scored.

Only the head of the list is ever used: a practitioner tries the top few models and never sees the tail, so a metric rewarding a correct rank twenty is measuring something nobody reads. Hence mrr, hit@1 and regret -- all head-weighted -- with ap grading the whole ordering and spearman kept for continuity.

Read ap, mrr, hit@1 and regret; treat spearman as weak evidence. Measured on this corpus it sits between 0.63 and 0.73 for every predictor and every baseline, including a constant, so it cannot separate the things this study compares.

A model counts as a right answer if its MCC is within RELEVANCE_TOLERANCE of the best on its dataset, rather than by a fixed top-k cut. That is not a convenience: multiple observed rows can share MCC 1.0, so datasets may have several tied best models, and a top-3 rule would score a correct answer as a miss.

Regret is the practical question in the target's own units: if you pick the model this equation ranks first, how much MCC do you give up against the best you could have picked?

BOOTSTRAP_ROUNDS = 20000
BOOTSTRAP_SEED = 42
@dataclass(frozen=True)
class PairedResult:
692@dataclass(frozen=True)
693class PairedResult:
694    """Whether a per-group difference is real, or inside the spread between folds.
695
696    Every headline in this study is a mean over twenty held-out datasets, and a mean over
697    twenty groups is not a measurement until something says how much of it one group could
698    have produced. A higher mean can come from a few large wins while the other predictor
699    wins on more datasets. Hit@1 is coarser still: over twenty datasets it moves in steps of
700    0.05, so an apparently visible change can be only one or two datasets changing their top
701    pick.
702
703    So: a **sign test** on how many groups improved, which no single group can swing, and
704    a **paired bootstrap** interval on the mean, which shows what the mean is worth. The
705    two answer different questions and disagreeing is informative -- a significant sign
706    test with an interval spanning zero means a consistent but tiny effect.
707
708    ``mean`` and the interval are in the metric's own units, positive meaning the first
709    argument is better. Errors must therefore be negated by the caller; `paired_comparison`
710    does it for you when told the metric is an error.
711    """
712
713    mean: float
714    wins: int
715    losses: int
716    n: int
717    p_value: float
718    low: float
719    high: float
720
721    @property
722    def significant(self) -> bool:
723        """Whether the bootstrap interval excludes zero."""
724        return self.low > 0.0 or self.high < 0.0
725
726    def as_dict(self) -> dict[str, float | int]:
727        return {
728            "mean": self.mean,
729            "wins": self.wins,
730            "losses": self.losses,
731            "n_differing": self.n,
732            "p_value": self.p_value,
733            "ci_low": self.low,
734            "ci_high": self.high,
735        }

Whether a per-group difference is real, or inside the spread between folds.

Every headline in this study is a mean over twenty held-out datasets, and a mean over twenty groups is not a measurement until something says how much of it one group could have produced. A higher mean can come from a few large wins while the other predictor wins on more datasets. Hit@1 is coarser still: over twenty datasets it moves in steps of 0.05, so an apparently visible change can be only one or two datasets changing their top pick.

So: a sign test on how many groups improved, which no single group can swing, and a paired bootstrap interval on the mean, which shows what the mean is worth. The two answer different questions and disagreeing is informative -- a significant sign test with an interval spanning zero means a consistent but tiny effect.

mean and the interval are in the metric's own units, positive meaning the first argument is better. Errors must therefore be negated by the caller; paired_comparison does it for you when told the metric is an error.

PairedResult( mean: float, wins: int, losses: int, n: int, p_value: float, low: float, high: float)
mean: float
wins: int
losses: int
n: int
p_value: float
low: float
high: float
significant: bool
721    @property
722    def significant(self) -> bool:
723        """Whether the bootstrap interval excludes zero."""
724        return self.low > 0.0 or self.high < 0.0

Whether the bootstrap interval excludes zero.

def as_dict(self) -> dict[str, float | int]:
726    def as_dict(self) -> dict[str, float | int]:
727        return {
728            "mean": self.mean,
729            "wins": self.wins,
730            "losses": self.losses,
731            "n_differing": self.n,
732            "p_value": self.p_value,
733            "ci_low": self.low,
734            "ci_high": self.high,
735        }
def sign_test(differences: numpy.ndarray) -> tuple[int, int, float]:
738def sign_test(differences: np.ndarray) -> tuple[int, int, float]:
739    """Wins, comparisons that differ, and the exact two-sided binomial p-value.
740
741    Exact rather than normal-approximate because the sample is twenty groups, where the
742    approximation is poor exactly when the answer matters. Summing binomial coefficients
743    directly is also why this needs no scipy: ``math.comb`` is in the standard library and
744    twenty choose ten is not a large number.
745
746    Ties are dropped rather than split, which is the conservative convention: a fold where
747    two predictors score identically is evidence for neither.
748    """
749    wins = int((differences > 0.0).sum())
750    n = int((differences != 0.0).sum())
751    if n == 0:
752        return 0, 0, 1.0
753    tail = sum(comb(n, k) for k in range(min(wins, n - wins) + 1))
754    return wins, n, min(1.0, 2.0 * tail / 2**n)

Wins, comparisons that differ, and the exact two-sided binomial p-value.

Exact rather than normal-approximate because the sample is twenty groups, where the approximation is poor exactly when the answer matters. Summing binomial coefficients directly is also why this needs no scipy: math.comb is in the standard library and twenty choose ten is not a large number.

Ties are dropped rather than split, which is the conservative convention: a fold where two predictors score identically is evidence for neither.

def paired_comparison( first: numpy.ndarray, second: numpy.ndarray, *, lower_is_better: bool = False, rounds: int = 20000, seed: int = 42) -> PairedResult:
757def paired_comparison(
758    first: np.ndarray,
759    second: np.ndarray,
760    *,
761    lower_is_better: bool = False,
762    rounds: int = BOOTSTRAP_ROUNDS,
763    seed: int = BOOTSTRAP_SEED,
764) -> PairedResult:
765    """Compare two predictors group by group, on the same folds.
766
767    ``first`` and ``second`` are per-group scores in the same group order -- a column of
768    `ranking_report`, or per-fold errors. Pass ``lower_is_better`` for an error metric and
769    the sign is handled here, so a positive ``mean`` always means ``first`` won.
770
771    The bootstrap resamples *groups*, not rows. Rows within a dataset are not exchangeable
772    with rows in another one, and the question is whether the result survives a different
773    draw of datasets, which is the sampling this study's twenty-dataset corpus is a draw
774    from.
775    """
776    if first.shape != second.shape:
777        raise ValueError("paired comparison needs one score per group on both sides")
778    differences = (second - first) if lower_is_better else (first - second)
779    wins, n, p_value = sign_test(differences)
780    losses = int((differences < 0.0).sum())
781    if differences.size == 0:
782        return PairedResult(float("nan"), 0, 0, 0, 1.0, float("nan"), float("nan"))
783    generator = np.random.default_rng(seed)
784    draws = generator.integers(0, differences.size, size=(rounds, differences.size))
785    means = differences[draws].mean(axis=1)
786    return PairedResult(
787        mean=float(differences.mean()),
788        wins=wins,
789        losses=losses,
790        n=n,
791        p_value=p_value,
792        low=float(np.percentile(means, 2.5)),
793        high=float(np.percentile(means, 97.5)),
794    )

Compare two predictors group by group, on the same folds.

first and second are per-group scores in the same group order -- a column of ranking_report, or per-fold errors. Pass lower_is_better for an error metric and the sign is handled here, so a positive mean always means first won.

The bootstrap resamples groups, not rows. Rows within a dataset are not exchangeable with rows in another one, and the question is whether the result survives a different draw of datasets, which is the sampling this study's twenty-dataset corpus is a draw from.

def interaction_capture( target: numpy.ndarray, prediction: numpy.ndarray, first: numpy.ndarray, second: numpy.ndarray, rank: int = 1) -> dict[str, float]:
797def interaction_capture(
798    target: np.ndarray,
799    prediction: np.ndarray,
800    first: np.ndarray,
801    second: np.ndarray,
802    rank: int = 1,
803) -> dict[str, float]:
804    """How much of the leading interaction pattern the equation actually reaches.
805
806    `interaction_oracle` measures what a rank-``k`` interaction would be *worth* if
807    someone could predict it. It says nothing about whether the fitted equation gets any
808    of it, and chapter 5 originally answered that by comparing two R2 values -- which
809    cannot distinguish an equation that misses the pattern from one that finds it and is
810    inaccurate elsewhere.
811
812    This compares the two interaction *structures* directly. Both the truth and the
813    prediction are laid on the (dataset x model) grid and stripped of their own additive
814    part, leaving each side's interaction residual; the truth's is then reduced to its
815    leading ``rank`` components. ``alignment`` is the squared correlation between the two
816    over observed cells: 1.0 means the equation's interactions lie exactly along the
817    pattern the oracle found, 0.0 means they are unrelated to it.
818
819    Only observed cells count. The 24 absent ones contribute to neither side.
820    """
821    rows, columns = np.unique(first), np.unique(second)
822    row_index = {label: position for position, label in enumerate(rows)}
823    column_index = {label: position for position, label in enumerate(columns)}
824
825    def grid_of(values: np.ndarray) -> np.ndarray:
826        grid = np.full((rows.shape[0], columns.shape[0]), np.nan)
827        for value, row, column in zip(values, first, second, strict=True):
828            grid[row_index[row], column_index[column]] = value
829        return grid
830
831    def interaction(grid: np.ndarray) -> np.ndarray:
832        grand = float(np.nanmean(grid))
833        row_effect = np.nanmean(grid, axis=1) - grand
834        column_effect = np.nanmean(grid, axis=0) - grand
835        return grid - (grand + row_effect[:, None] + column_effect[None, :])
836
837    truth_grid = grid_of(target)
838    observed = ~np.isnan(truth_grid)
839    truth_interaction = np.where(observed, interaction(truth_grid), 0.0)
840    model_interaction = np.where(observed, interaction(grid_of(prediction)), 0.0)
841
842    left, values, right = np.linalg.svd(truth_interaction, full_matrices=False)
843    leading = (left[:, :rank] * values[:rank]) @ right[:rank]
844
845    wanted, reached = leading[observed], model_interaction[observed]
846    total = float((truth_interaction[observed] ** 2).sum())
847    return {
848        "alignment": float(pearson(wanted, reached) ** 2),
849        "leading_share": float((wanted**2).sum() / total) if total > 0.0 else float("nan"),
850        "interaction_share": total / float(((truth_grid[observed] - np.nanmean(truth_grid)) ** 2).sum())
851        if total > 0.0
852        else float("nan"),
853    }

How much of the leading interaction pattern the equation actually reaches.

interaction_oracle measures what a rank-k interaction would be worth if someone could predict it. It says nothing about whether the fitted equation gets any of it, and chapter 5 originally answered that by comparing two R2 values -- which cannot distinguish an equation that misses the pattern from one that finds it and is inaccurate elsewhere.

This compares the two interaction structures directly. Both the truth and the prediction are laid on the (dataset x model) grid and stripped of their own additive part, leaving each side's interaction residual; the truth's is then reduced to its leading rank components. alignment is the squared correlation between the two over observed cells: 1.0 means the equation's interactions lie exactly along the pattern the oracle found, 0.0 means they are unrelated to it.

Only observed cells count. The 24 absent ones contribute to neither side.