ml_meta_perf.identity

Model identity: the part of a classifier the meta-features do not describe.

Chapter 4 measures a gap. Dataset identity explains 0.354 of MCC variance and the twelve dataset meta-features recover 99% of that; model identity explains 0.282 and the six model meta-features need not encode every distinction between learner histories -- the difference between what a model costs, which the features state, and what a model is good at, which they do not.

The remaining headroom for a per-model table is small, but still useful as a ceiling: it bounds what any model-side proposal could recover beyond the generated descriptors. The module is kept for that measurement, not because the correction it computes is worth publishing -- see the standing objections in chapter 4.

This module writes that difference down. Under leave-one-dataset-out (LODO), every one of the 25 models appears in every training fold, so the residual of the fitted equation can be averaged per model and the average used on the held-out dataset. Nothing about the held-out dataset enters it, and the result is two numbers per model:

MCC = [the equation]  +  b_model  +  c_model * log(gravity)

b is a level -- this model runs above or below what its meta-features predict -- and c is a slope, how that offset changes with how separable the data is. The pair is a table, not an equation, and that is the honest cost of the accuracy it buys.

Two properties of this construction decide how it may be used.

It is not a predictor for an unseen model. A model with no training rows has no effect, ModelEffects.apply returns zero for it, and the correction degenerates to the equation it corrects. The leave-one-model-out (LOMO) column measures exactly that and shows no gain, by construction rather than by accident.

It is fitted on the equation's residual, not jointly with it. Alternating the two would turn the measurement into a backfitted model with a different training procedure (Hastie & Tibshirani, 1990). The reported experiment does not fit or evaluate that variant. The equation remains a statement about MCC, and the table explains only what the finished equation leaves behind.

The construction is a factorial regression in the sense used for genotype-by- environment trials: a two-way table modelled with covariates on one side and free coefficients on the other, one of which multiplies an environmental covariate (Denis, 1988; van Eeuwijk, Denis & Kang, 1996). Where the AMMI oracle of chapter 4 estimates both sides freely and therefore predicts nothing, this estimates the dataset side from meta-features and keeps the model side free -- which is what makes it usable on a dataset nobody has run. In machine-learning terms it is the collaborative half of a hybrid recommender: the equation is content-based over meta-features, the table is a per-item effect learned from the observed grid, and algorithm selection has been posed as collaborative filtering before (Misir & Sebag, 2017; Fusi et al., 2018; Yang et al., 2019).

Study chapter: 4. The equation -- the rationale, in prose, with the figures.

  1"""Model identity: the part of a classifier the meta-features do not describe.
  2
  3Chapter 4 measures a gap. Dataset identity explains 0.354 of MCC variance and the twelve
  4dataset meta-features recover 99% of that; model identity explains 0.282 and the six model
  5meta-features need not encode every distinction between learner histories --
  6the difference between *what a model costs*, which the features state, and *what a model
  7is good at*, which they do not.
  8
  9The remaining headroom for a per-model table is small, but still useful as a ceiling:
 10it bounds what any model-side proposal could recover beyond the generated descriptors.
 11The module is kept for that measurement, not because the correction it computes is worth
 12publishing -- see the standing objections in chapter 4.
 13
 14This module writes that difference down. Under leave-one-dataset-out (LODO), every one of the 25
 15models appears in every training fold, so the residual of the fitted equation can be
 16averaged per model and the average used on the held-out dataset. Nothing about the
 17held-out dataset enters it, and the result is two numbers per model:
 18
 19    MCC = [the equation]  +  b_model  +  c_model * log(gravity)
 20
 21``b`` is a level -- this model runs above or below what its meta-features predict -- and
 22``c`` is a slope, how that offset changes with how separable the data is. The pair is a
 23*table*, not an equation, and that is the honest cost of the accuracy it buys.
 24
 25**Two properties of this construction decide how it may be used.**
 26
 27It is not a predictor for an unseen *model*. A model with no training rows has no
 28effect, `ModelEffects.apply` returns zero for it, and the correction degenerates to the
 29equation it corrects. The leave-one-model-out (LOMO) column measures exactly that and shows no
 30gain, by construction rather than by accident.
 31
 32It is fitted on the equation's residual, not jointly with it. Alternating the two would
 33turn the measurement into a backfitted model with a different training procedure
 34(Hastie & Tibshirani, 1990). The reported experiment does not fit or evaluate that
 35variant. The equation remains a statement about MCC, and the table explains only what
 36the finished equation leaves behind.
 37
 38The construction is a **factorial regression** in the sense used for genotype-by-
 39environment trials: a two-way table modelled with covariates on one side and free
 40coefficients on the other, one of which multiplies an environmental covariate (Denis,
 411988; van Eeuwijk, Denis & Kang, 1996). Where the AMMI oracle of chapter 4 estimates
 42both sides freely and therefore predicts nothing, this estimates the dataset side from
 43meta-features and keeps the model side free -- which is what makes it usable on a
 44dataset nobody has run. In machine-learning terms it is the collaborative half of a
 45hybrid recommender: the equation is content-based over meta-features, the table is a
 46per-item effect learned from the observed grid, and algorithm selection has been posed
 47as collaborative filtering before (Misir & Sebag, 2017; Fusi et al., 2018; Yang et al.,
 482019).
 49
 50Study chapter: [4. The equation][study-chapter]
 51-- the rationale, in prose, with the figures.
 52
 53[study-chapter]: https://github.com/mariolpantunes/ml-meta-perf/blob/main/assets/docs/04-equation.md#limitations-of-the-equation
 54"""
 55
 56from __future__ import annotations
 57
 58from dataclasses import dataclass
 59
 60import numpy as np
 61import polars as pl
 62
 63from ml_meta_perf.model import MCC_LOWER, MCC_UPPER, Equation
 64from ml_meta_perf.terms import Atom, composition_atom
 65from ml_meta_perf.validate import CrossValidation, leave_one_group_out
 66
 67#: Denominator added to a model's row count when averaging its residuals. A model with
 68#: 19 rows is shrunk by 19/24 toward zero, which is the empirical-Bayes estimator for a
 69#: group mean under a common prior (Efron & Morris, 1975) with the prior-to-noise ratio
 70#: fixed rather than estimated. Fixed because estimating it from 20 groups is noisier
 71#: than choosing it: LODO R² moves by 0.015 across 0 to 20, which is
 72#: less than the fold noise it would be tuned against.
 73INTERCEPT_SHRINKAGE = 5.0
 74
 75#: The same idea for the slopes, in units of the standardised carrying feature. R2 moves
 76#: by 0.005 across 0.5 to 10.
 77SLOPE_SHRINKAGE = 1.0
 78
 79
 80@dataclass(frozen=True)
 81class ModelEffects:
 82    """Per-model level and slope, in the units the equation is written in.
 83
 84    ``intercepts`` and ``slopes`` are keyed by model name and read directly: a prediction
 85    for model *m* on a dataset is the equation's value plus ``intercepts[m]`` plus
 86    ``slopes[m]`` times the carrying feature. Models absent from the table contribute
 87    nothing, so an equation corrected this way never becomes *worse* than uncorrected on
 88    a model it has not seen -- it simply stops being corrected.
 89    """
 90
 91    #: The dataset feature the slope multiplies, and the transform it enters under.
 92    #: ``None`` when the effects are levels only.
 93    atom: Atom | None
 94    intercepts: dict[str, float]
 95    slopes: dict[str, float]
 96
 97    @property
 98    def n_models(self) -> int:
 99        return len(self.intercepts)
100
101    def apply(self, columns: dict[str, np.ndarray], models: np.ndarray) -> np.ndarray:
102        """The correction to add to the equation's output, row by row."""
103        levels = np.array([self.intercepts.get(str(name), 0.0) for name in models])
104        if self.atom is None:
105            return levels
106        carrier = self.atom.evaluate(columns)
107        gradient = np.array([self.slopes.get(str(name), 0.0) for name in models])
108        return levels + gradient * carrier
109
110    def table(self) -> pl.DataFrame:
111        """One row per model, largest level first.
112
113        ``effect_at_median`` is the whole correction evaluated at the median of the
114        carrying feature, which is the number to compare against MCC: a level and a
115        slope in different units cannot be ranked against each other, and reading the
116        level alone overstates models whose slope cancels it.
117        """
118        names = sorted(self.intercepts, key=lambda name: -self.intercepts[name])
119        return pl.DataFrame(
120            {
121                "model": names,
122                "level": [self.intercepts[name] for name in names],
123                "slope": [self.slopes.get(name, 0.0) for name in names],
124            }
125        )
126
127    def __str__(self) -> str:
128        carrier = "" if self.atom is None else f" + c_model * {self.atom.name}"
129        lines = [f"correction = b_model{carrier}"]
130        for row in self.table().iter_rows(named=True):
131            lines.append(f"      {row['model']:<22s} b={row['level']:+.4f}  c={row['slope']:+.4f}")
132        return "\n".join(lines)
133
134
135def carrying_atoms(features: tuple[str, ...], columns: dict[str, np.ndarray]) -> list[Atom]:
136    """The candidate carriers for the slope, one per dataset feature.
137
138    Each feature enters under the library's own compression rule -- log when strictly
139    positive, raw otherwise -- so the slope is read in the same units as any composite
140    term in the equation, and no second convention is introduced here.
141    """
142    return [composition_atom(feature, columns) for feature in features]
143
144
145def fit_effects(
146    residual: np.ndarray,
147    columns: dict[str, np.ndarray],
148    models: np.ndarray,
149    features: tuple[str, ...] = (),
150    *,
151    intercept_shrinkage: float = INTERCEPT_SHRINKAGE,
152    slope_shrinkage: float = SLOPE_SHRINKAGE,
153) -> ModelEffects:
154    """Fit per-model levels, and one per-model slope, to an equation's residual.
155
156    Two stages, in this order. The level is the shrunk mean residual of the model's own
157    rows. The slope is then fitted to what the levels leave behind, and the feature that
158    carries it is chosen by training residual sum of squares over every candidate in
159    ``features`` -- which is a search, so it happens inside the fold like any other.
160
161    Pass no ``features`` for levels only.
162    """
163    names = [str(name) for name in np.unique(models)]
164    labels = np.array([str(name) for name in models])
165    intercepts = {
166        name: float(residual[labels == name].sum() / (int(np.sum(labels == name)) + intercept_shrinkage))
167        for name in names
168    }
169    inner = residual - np.array([intercepts[name] for name in labels])
170
171    candidates = carrying_atoms(features, columns) if features else []
172    best: tuple[float, Atom | None, dict[str, float]] = (float(inner @ inner), None, {})
173    for atom in candidates:
174        with np.errstate(all="ignore"):
175            carrier = atom.evaluate(columns)
176        if not np.all(np.isfinite(carrier)):
177            continue
178        # Centred and scaled so the shrinkage means the same thing whichever feature is
179        # carrying, then folded back into raw units below.
180        centre, spread = float(carrier.mean()), float(carrier.std())
181        if spread < 1e-12:
182            continue
183        scaled = (carrier - centre) / spread
184        gradient = {
185            name: float(
186                scaled[labels == name]
187                @ inner[labels == name]
188                / (float(scaled[labels == name] @ scaled[labels == name]) + slope_shrinkage)
189            )
190            for name in names
191        }
192        fitted = np.array([gradient[name] for name in labels]) * scaled
193        rss = float((inner - fitted) @ (inner - fitted))
194        if rss < best[0]:
195            raw = {name: value / spread for name, value in gradient.items()}
196            best = (rss, atom, raw)
197
198    _, atom, slopes = best
199    if atom is None:
200        return ModelEffects(atom=None, intercepts=intercepts, slopes={})
201    # The slope's centring belongs in the level, so that both numbers read in raw units.
202    centre = float(atom.evaluate(columns).mean())
203    shifted = {name: intercepts[name] - slopes[name] * centre for name in names}
204    return ModelEffects(atom=atom, intercepts=shifted, slopes=slopes)
205
206
207def correct_out_of_fold(
208    fold: CrossValidation,
209    columns: dict[str, np.ndarray],
210    target: np.ndarray,
211    groups: np.ndarray,
212    models: np.ndarray,
213    features: tuple[str, ...] = (),
214    *,
215    intercept_shrinkage: float = INTERCEPT_SHRINKAGE,
216    slope_shrinkage: float = SLOPE_SHRINKAGE,
217) -> np.ndarray:
218    """Re-score a finished cross-validation with per-model effects fitted in each fold.
219
220    The effects are cheap and the beam search is not, so this consumes the per-fold
221    equations a completed `ml_meta_perf.validate.cross_validate_fixed_form` already recorded rather
222    than searching again. Each fold's effects are fitted on that fold's training rows
223    only, from that fold's own equation, and applied to the rows it held out -- so the
224    protocol is the one the path was run under, unchanged.
225    """
226    predictions = np.zeros_like(target)
227    for label, train, test in leave_one_group_out(groups):
228        equation = fold.equations.get(label)
229        if equation is None:
230            predictions[test] = fold.predictions[test]
231            continue
232        trained = {name: values[train] for name, values in columns.items()}
233        held = {name: values[test] for name, values in columns.items()}
234        effects = fit_effects(
235            target[train] - equation.evaluate(trained),
236            trained,
237            models[train],
238            features,
239            intercept_shrinkage=intercept_shrinkage,
240            slope_shrinkage=slope_shrinkage,
241        )
242        corrected = equation.evaluate(held) + effects.apply(held, models[test])
243        predictions[test] = np.clip(corrected, MCC_LOWER, MCC_UPPER)
244    return predictions
245
246
247def predict(
248    equation: Equation,
249    effects: ModelEffects,
250    columns: dict[str, np.ndarray],
251    models: np.ndarray,
252) -> np.ndarray:
253    """Equation plus table, clipped into the range MCC can take."""
254    return np.clip(equation.evaluate(columns) + effects.apply(columns, models), MCC_LOWER, MCC_UPPER)
255
256
257def carrier_stability(
258    columns: dict[str, np.ndarray],
259    target: np.ndarray,
260    groups: np.ndarray,
261    models: np.ndarray,
262    fold: CrossValidation,
263    features: tuple[str, ...],
264) -> pl.DataFrame:
265    """Which feature carried the slope, counted across folds.
266
267    The carrier is chosen inside each fold, so it is a fitted quantity like any selected
268    term and reporting it without its selection frequency would present a choice made 20
269    times out of 20 identically to one made 6 times.
270    """
271    counts: dict[str, int] = {}
272    for label, train, _ in leave_one_group_out(groups):
273        equation = fold.equations.get(label)
274        if equation is None:
275            continue
276        trained = {name: values[train] for name, values in columns.items()}
277        effects = fit_effects(target[train] - equation.evaluate(trained), trained, models[train], features)
278        name = "none" if effects.atom is None else effects.atom.name
279        counts[name] = counts.get(name, 0) + 1
280    total = max(sum(counts.values()), 1)
281    return (
282        pl.DataFrame({"carrier": list(counts), "folds": list(counts.values())})
283        .with_columns((pl.col("folds") / total).alias("frequency"))
284        .sort("folds", descending=True)
285    )
INTERCEPT_SHRINKAGE = 5.0
SLOPE_SHRINKAGE = 1.0
@dataclass(frozen=True)
class ModelEffects:
 81@dataclass(frozen=True)
 82class ModelEffects:
 83    """Per-model level and slope, in the units the equation is written in.
 84
 85    ``intercepts`` and ``slopes`` are keyed by model name and read directly: a prediction
 86    for model *m* on a dataset is the equation's value plus ``intercepts[m]`` plus
 87    ``slopes[m]`` times the carrying feature. Models absent from the table contribute
 88    nothing, so an equation corrected this way never becomes *worse* than uncorrected on
 89    a model it has not seen -- it simply stops being corrected.
 90    """
 91
 92    #: The dataset feature the slope multiplies, and the transform it enters under.
 93    #: ``None`` when the effects are levels only.
 94    atom: Atom | None
 95    intercepts: dict[str, float]
 96    slopes: dict[str, float]
 97
 98    @property
 99    def n_models(self) -> int:
100        return len(self.intercepts)
101
102    def apply(self, columns: dict[str, np.ndarray], models: np.ndarray) -> np.ndarray:
103        """The correction to add to the equation's output, row by row."""
104        levels = np.array([self.intercepts.get(str(name), 0.0) for name in models])
105        if self.atom is None:
106            return levels
107        carrier = self.atom.evaluate(columns)
108        gradient = np.array([self.slopes.get(str(name), 0.0) for name in models])
109        return levels + gradient * carrier
110
111    def table(self) -> pl.DataFrame:
112        """One row per model, largest level first.
113
114        ``effect_at_median`` is the whole correction evaluated at the median of the
115        carrying feature, which is the number to compare against MCC: a level and a
116        slope in different units cannot be ranked against each other, and reading the
117        level alone overstates models whose slope cancels it.
118        """
119        names = sorted(self.intercepts, key=lambda name: -self.intercepts[name])
120        return pl.DataFrame(
121            {
122                "model": names,
123                "level": [self.intercepts[name] for name in names],
124                "slope": [self.slopes.get(name, 0.0) for name in names],
125            }
126        )
127
128    def __str__(self) -> str:
129        carrier = "" if self.atom is None else f" + c_model * {self.atom.name}"
130        lines = [f"correction = b_model{carrier}"]
131        for row in self.table().iter_rows(named=True):
132            lines.append(f"      {row['model']:<22s} b={row['level']:+.4f}  c={row['slope']:+.4f}")
133        return "\n".join(lines)

Per-model level and slope, in the units the equation is written in.

intercepts and slopes are keyed by model name and read directly: a prediction for model m on a dataset is the equation's value plus intercepts[m] plus slopes[m] times the carrying feature. Models absent from the table contribute nothing, so an equation corrected this way never becomes worse than uncorrected on a model it has not seen -- it simply stops being corrected.

ModelEffects( atom: ml_meta_perf.terms.Atom | None, intercepts: dict[str, float], slopes: dict[str, float])
intercepts: dict[str, float]
slopes: dict[str, float]
n_models: int
 98    @property
 99    def n_models(self) -> int:
100        return len(self.intercepts)
def apply( self, columns: dict[str, numpy.ndarray], models: numpy.ndarray) -> numpy.ndarray:
102    def apply(self, columns: dict[str, np.ndarray], models: np.ndarray) -> np.ndarray:
103        """The correction to add to the equation's output, row by row."""
104        levels = np.array([self.intercepts.get(str(name), 0.0) for name in models])
105        if self.atom is None:
106            return levels
107        carrier = self.atom.evaluate(columns)
108        gradient = np.array([self.slopes.get(str(name), 0.0) for name in models])
109        return levels + gradient * carrier

The correction to add to the equation's output, row by row.

def table(self) -> polars.dataframe.frame.DataFrame:
111    def table(self) -> pl.DataFrame:
112        """One row per model, largest level first.
113
114        ``effect_at_median`` is the whole correction evaluated at the median of the
115        carrying feature, which is the number to compare against MCC: a level and a
116        slope in different units cannot be ranked against each other, and reading the
117        level alone overstates models whose slope cancels it.
118        """
119        names = sorted(self.intercepts, key=lambda name: -self.intercepts[name])
120        return pl.DataFrame(
121            {
122                "model": names,
123                "level": [self.intercepts[name] for name in names],
124                "slope": [self.slopes.get(name, 0.0) for name in names],
125            }
126        )

One row per model, largest level first.

effect_at_median is the whole correction evaluated at the median of the carrying feature, which is the number to compare against MCC: a level and a slope in different units cannot be ranked against each other, and reading the level alone overstates models whose slope cancels it.

def carrying_atoms( features: tuple[str, ...], columns: dict[str, numpy.ndarray]) -> list[ml_meta_perf.terms.Atom]:
136def carrying_atoms(features: tuple[str, ...], columns: dict[str, np.ndarray]) -> list[Atom]:
137    """The candidate carriers for the slope, one per dataset feature.
138
139    Each feature enters under the library's own compression rule -- log when strictly
140    positive, raw otherwise -- so the slope is read in the same units as any composite
141    term in the equation, and no second convention is introduced here.
142    """
143    return [composition_atom(feature, columns) for feature in features]

The candidate carriers for the slope, one per dataset feature.

Each feature enters under the library's own compression rule -- log when strictly positive, raw otherwise -- so the slope is read in the same units as any composite term in the equation, and no second convention is introduced here.

def fit_effects( residual: numpy.ndarray, columns: dict[str, numpy.ndarray], models: numpy.ndarray, features: tuple[str, ...] = (), *, intercept_shrinkage: float = 5.0, slope_shrinkage: float = 1.0) -> ModelEffects:
146def fit_effects(
147    residual: np.ndarray,
148    columns: dict[str, np.ndarray],
149    models: np.ndarray,
150    features: tuple[str, ...] = (),
151    *,
152    intercept_shrinkage: float = INTERCEPT_SHRINKAGE,
153    slope_shrinkage: float = SLOPE_SHRINKAGE,
154) -> ModelEffects:
155    """Fit per-model levels, and one per-model slope, to an equation's residual.
156
157    Two stages, in this order. The level is the shrunk mean residual of the model's own
158    rows. The slope is then fitted to what the levels leave behind, and the feature that
159    carries it is chosen by training residual sum of squares over every candidate in
160    ``features`` -- which is a search, so it happens inside the fold like any other.
161
162    Pass no ``features`` for levels only.
163    """
164    names = [str(name) for name in np.unique(models)]
165    labels = np.array([str(name) for name in models])
166    intercepts = {
167        name: float(residual[labels == name].sum() / (int(np.sum(labels == name)) + intercept_shrinkage))
168        for name in names
169    }
170    inner = residual - np.array([intercepts[name] for name in labels])
171
172    candidates = carrying_atoms(features, columns) if features else []
173    best: tuple[float, Atom | None, dict[str, float]] = (float(inner @ inner), None, {})
174    for atom in candidates:
175        with np.errstate(all="ignore"):
176            carrier = atom.evaluate(columns)
177        if not np.all(np.isfinite(carrier)):
178            continue
179        # Centred and scaled so the shrinkage means the same thing whichever feature is
180        # carrying, then folded back into raw units below.
181        centre, spread = float(carrier.mean()), float(carrier.std())
182        if spread < 1e-12:
183            continue
184        scaled = (carrier - centre) / spread
185        gradient = {
186            name: float(
187                scaled[labels == name]
188                @ inner[labels == name]
189                / (float(scaled[labels == name] @ scaled[labels == name]) + slope_shrinkage)
190            )
191            for name in names
192        }
193        fitted = np.array([gradient[name] for name in labels]) * scaled
194        rss = float((inner - fitted) @ (inner - fitted))
195        if rss < best[0]:
196            raw = {name: value / spread for name, value in gradient.items()}
197            best = (rss, atom, raw)
198
199    _, atom, slopes = best
200    if atom is None:
201        return ModelEffects(atom=None, intercepts=intercepts, slopes={})
202    # The slope's centring belongs in the level, so that both numbers read in raw units.
203    centre = float(atom.evaluate(columns).mean())
204    shifted = {name: intercepts[name] - slopes[name] * centre for name in names}
205    return ModelEffects(atom=atom, intercepts=shifted, slopes=slopes)

Fit per-model levels, and one per-model slope, to an equation's residual.

Two stages, in this order. The level is the shrunk mean residual of the model's own rows. The slope is then fitted to what the levels leave behind, and the feature that carries it is chosen by training residual sum of squares over every candidate in features -- which is a search, so it happens inside the fold like any other.

Pass no features for levels only.

def correct_out_of_fold( fold: ml_meta_perf.validate.CrossValidation, columns: dict[str, numpy.ndarray], target: numpy.ndarray, groups: numpy.ndarray, models: numpy.ndarray, features: tuple[str, ...] = (), *, intercept_shrinkage: float = 5.0, slope_shrinkage: float = 1.0) -> numpy.ndarray:
208def correct_out_of_fold(
209    fold: CrossValidation,
210    columns: dict[str, np.ndarray],
211    target: np.ndarray,
212    groups: np.ndarray,
213    models: np.ndarray,
214    features: tuple[str, ...] = (),
215    *,
216    intercept_shrinkage: float = INTERCEPT_SHRINKAGE,
217    slope_shrinkage: float = SLOPE_SHRINKAGE,
218) -> np.ndarray:
219    """Re-score a finished cross-validation with per-model effects fitted in each fold.
220
221    The effects are cheap and the beam search is not, so this consumes the per-fold
222    equations a completed `ml_meta_perf.validate.cross_validate_fixed_form` already recorded rather
223    than searching again. Each fold's effects are fitted on that fold's training rows
224    only, from that fold's own equation, and applied to the rows it held out -- so the
225    protocol is the one the path was run under, unchanged.
226    """
227    predictions = np.zeros_like(target)
228    for label, train, test in leave_one_group_out(groups):
229        equation = fold.equations.get(label)
230        if equation is None:
231            predictions[test] = fold.predictions[test]
232            continue
233        trained = {name: values[train] for name, values in columns.items()}
234        held = {name: values[test] for name, values in columns.items()}
235        effects = fit_effects(
236            target[train] - equation.evaluate(trained),
237            trained,
238            models[train],
239            features,
240            intercept_shrinkage=intercept_shrinkage,
241            slope_shrinkage=slope_shrinkage,
242        )
243        corrected = equation.evaluate(held) + effects.apply(held, models[test])
244        predictions[test] = np.clip(corrected, MCC_LOWER, MCC_UPPER)
245    return predictions

Re-score a finished cross-validation with per-model effects fitted in each fold.

The effects are cheap and the beam search is not, so this consumes the per-fold equations a completed ml_meta_perf.validate.cross_validate_fixed_form already recorded rather than searching again. Each fold's effects are fitted on that fold's training rows only, from that fold's own equation, and applied to the rows it held out -- so the protocol is the one the path was run under, unchanged.

def predict( equation: ml_meta_perf.model.Equation, effects: ModelEffects, columns: dict[str, numpy.ndarray], models: numpy.ndarray) -> numpy.ndarray:
248def predict(
249    equation: Equation,
250    effects: ModelEffects,
251    columns: dict[str, np.ndarray],
252    models: np.ndarray,
253) -> np.ndarray:
254    """Equation plus table, clipped into the range MCC can take."""
255    return np.clip(equation.evaluate(columns) + effects.apply(columns, models), MCC_LOWER, MCC_UPPER)

Equation plus table, clipped into the range MCC can take.

def carrier_stability( columns: dict[str, numpy.ndarray], target: numpy.ndarray, groups: numpy.ndarray, models: numpy.ndarray, fold: ml_meta_perf.validate.CrossValidation, features: tuple[str, ...]) -> polars.dataframe.frame.DataFrame:
258def carrier_stability(
259    columns: dict[str, np.ndarray],
260    target: np.ndarray,
261    groups: np.ndarray,
262    models: np.ndarray,
263    fold: CrossValidation,
264    features: tuple[str, ...],
265) -> pl.DataFrame:
266    """Which feature carried the slope, counted across folds.
267
268    The carrier is chosen inside each fold, so it is a fitted quantity like any selected
269    term and reporting it without its selection frequency would present a choice made 20
270    times out of 20 identically to one made 6 times.
271    """
272    counts: dict[str, int] = {}
273    for label, train, _ in leave_one_group_out(groups):
274        equation = fold.equations.get(label)
275        if equation is None:
276            continue
277        trained = {name: values[train] for name, values in columns.items()}
278        effects = fit_effects(target[train] - equation.evaluate(trained), trained, models[train], features)
279        name = "none" if effects.atom is None else effects.atom.name
280        counts[name] = counts.get(name, 0) + 1
281    total = max(sum(counts.values()), 1)
282    return (
283        pl.DataFrame({"carrier": list(counts), "folds": list(counts.values())})
284        .with_columns((pl.col("folds") / total).alias("frequency"))
285        .sort("folds", descending=True)
286    )

Which feature carried the slope, counted across folds.

The carrier is chosen inside each fold, so it is a fitted quantity like any selected term and reporting it without its selection frequency would present a choice made 20 times out of 20 identically to one made 6 times.