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 )
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.
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.
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.
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.
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.
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.
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.
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.