ml_meta_perf.opaque

Opaque regressors on the same rows, scored under the same protocols.

The study's central claim is a trade: accuracy given up for an equation that can be read. A trade needs both sides priced, and the expensive side is what a flexible model reaches on this meta-data. That number is quoted throughout the chapters and was, for a long time, measured once by hand and reproducible by nobody -- which for a study whose whole argument is that its analysis is generated rather than narrated was the wrong way round. This module computes it on every run.

The evaluation protocols are in-sample (IS), leave-one-dataset-out (LODO), leave-one-model-out (LOMO), and doubly held out (DHO).

Three regressors, chosen because they are what a reader would reach for rather than because they are the strongest available:

  • Ridge with a cross-validated penalty -- the linear-but-opaque control. It sees the same eighteen raw columns the grammar is built over, unstandardised structure and all.
  • Random forest -- the model that produces the R2 ~ 0.9 figures reported for opaque meta-learners, and the one whose transfer collapse is the point.
  • Gradient boosting -- the same family with a different bias, so the forest result cannot be read as an artefact of one estimator.

Every one is scored under the study's own protocols, by the same ml_meta_perf.validate.leave_one_group_out splits, with the same clip to the training fold's observed range that every reported number uses. A comparison is only a comparison if both sides are scored the same way, and this went wrong twice in this project's history in ways that flattered the equation.

Fixed seeds, and no tuning. Tuning the opaque side would strengthen it and change nothing about the conclusion -- the failure is that 20 dataset groups let a flexible model memorise group identity, which more trees cannot fix -- but an untuned opponent should be described as one, so n_estimators and depth are stated in the table rather than buried.

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

  1"""Opaque regressors on the same rows, scored under the same protocols.
  2
  3The study's central claim is a trade: accuracy given up for an equation that can be read.
  4A trade needs both sides priced, and the expensive side is what a flexible model reaches on
  5this meta-data. That number is quoted throughout the chapters and was, for a long time,
  6measured once by hand and reproducible by nobody -- which for a study whose whole argument is
  7that its analysis is generated rather than narrated was the wrong way round. This module
  8computes it on every run.
  9
 10The evaluation protocols are in-sample (IS), leave-one-dataset-out (LODO),
 11leave-one-model-out (LOMO), and doubly held out (DHO).
 12
 13Three regressors, chosen because they are what a reader would reach for rather than because
 14they are the strongest available:
 15
 16* **Ridge** with a cross-validated penalty -- the linear-but-opaque control. It sees the same
 17  eighteen raw columns the grammar is built over, unstandardised structure and all.
 18* **Random forest** -- the model that produces the R2 ~ 0.9 figures reported for opaque
 19  meta-learners, and the one whose transfer collapse is the point.
 20* **Gradient boosting** -- the same family with a different bias, so the forest result cannot
 21  be read as an artefact of one estimator.
 22
 23**Every one is scored under the study's own protocols**, by the same
 24`ml_meta_perf.validate.leave_one_group_out` splits, with the same clip to the training fold's
 25observed range that every reported number uses. A comparison is only a comparison if both
 26sides are scored the same way, and this went wrong twice in this project's history in ways
 27that flattered the equation.
 28
 29Fixed seeds, and no tuning. Tuning the opaque side would strengthen it and change nothing
 30about the conclusion -- the failure is that 20 dataset groups let a flexible model memorise
 31group identity, which more trees cannot fix -- but an untuned opponent should be described as
 32one, so `n_estimators` and depth are stated in the table rather than buried.
 33
 34Study chapter: [5. Evaluation][study-chapter] -- the rationale, in
 35prose, with the figures.
 36
 37[study-chapter]: https://github.com/mariolpantunes/ml-meta-perf/blob/main/assets/docs/05-evaluation.md
 38"""
 39
 40from __future__ import annotations
 41
 42from collections.abc import Callable
 43from dataclasses import dataclass
 44from typing import Protocol
 45
 46import numpy as np
 47import polars as pl
 48from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor
 49from sklearn.linear_model import RidgeCV
 50from sklearn.utils.parallel import Parallel, delayed
 51
 52from ml_meta_perf.config import OpaqueConfig, load_config
 53from ml_meta_perf.data import (
 54    ALL_FEATURES,
 55    DATASET_COLUMN,
 56    MODEL_COLUMN,
 57    columns_as_arrays,
 58    groups,
 59    target,
 60)
 61from ml_meta_perf.model import MCC_LOWER, MCC_UPPER
 62from ml_meta_perf.stats import mae, r2_score
 63from ml_meta_perf.validate import leave_one_group_out
 64
 65#: Seed for every estimator that takes one. Fixed so the comparison is reproducible; the
 66#: conclusion does not depend on it: the gap being measured is two orders of magnitude
 67#: larger than the spread a different seed produces.
 68SEED = 0
 69
 70#: The ensemble sizes are hyperparameters and live in ``config/study.json`` (`config.OpaqueConfig`).
 71#: They were chosen on a grid scored under all four protocols: a third of the features per split
 72#: transfers far better than all of them, and a hundred trees reach within noise of three
 73#: hundred at a third of the cost. Neither conclusion the study draws depends on the size: the
 74#: forest's argument is that a flexible model fits this meta-data almost perfectly and reaches
 75#: nothing on an unseen cell, which is a property of the design matrix -- twenty dataset groups,
 76#: features constant within a group -- rather than of how many trees vote on it.
 77
 78
 79class Regressor(Protocol):
 80    """The whole of what this module needs from an estimator: fit it, then predict with it.
 81
 82    Stated as a protocol rather than a union of the three scikit-learn classes, because that
 83    is the actual contract. Anything satisfying it can be handed to `evaluate`, which is what
 84    lets the tests exercise the protocols without paying for real ensembles.
 85    """
 86
 87    # Positional-only: scikit-learn names these ``X`` and ``y``, and a protocol that insisted
 88    # on its own names would match nothing in the library it exists to describe.
 89    def fit(self, design: np.ndarray, truth: np.ndarray, /) -> object: ...
 90
 91    def predict(self, design: np.ndarray, /) -> object: ...
 92
 93
 94#: A factory taking the thread budget for one fit and returning a fresh unfitted regressor.
 95#:
 96#: **A callable, not a name to dispatch on.** The protocols below are the study's contribution
 97#: on this side -- the DHO refit especially -- and the estimators are scikit-learn's
 98#: contribution, which needs no testing here. Injecting the factory separates them: a caller
 99#: that wants to check the *plumbing* passes something trivial and pays nothing, and the tests
100#: do exactly that. It also makes these factories what this module always said they were.
101Builder = Callable[[int], Regressor]
102
103
104def estimators(opaque: OpaqueConfig) -> tuple[tuple[str, Builder], ...]:
105    """The published regressors, as (label, factory).
106
107    Factories rather than instances so each fold gets a fresh unfitted estimator -- refitting
108    a fitted scikit-learn estimator is fine, but sharing one across folds is the kind of thing
109    that silently leaks if an estimator ever caches.
110
111    **The size is in the label**, so a table produced at a reduced size says so rather than
112    claiming a size it never grew.
113    """
114    return (
115        # A wide log-spaced grid rather than a chosen penalty: the point of the control is
116        # that a *well-regularised* linear model on raw columns still transfers badly, so
117        # picking the penalty by hand would leave the result open to that objection.
118        ("RidgeCV (linear)", lambda _threads=-1: RidgeCV(alphas=np.logspace(-3, 3, 25))),
119        (
120            f"RandomForest ({opaque.forest_trees} trees)",
121            lambda threads=-1: RandomForestRegressor(
122                n_estimators=opaque.forest_trees,
123                max_features=opaque.forest_max_features,
124                random_state=SEED,
125                n_jobs=threads,
126            ),
127        ),
128        (
129            f"GradientBoosting ({opaque.boosting_stages} stages)",
130            lambda _threads=-1: GradientBoostingRegressor(
131                n_estimators=opaque.boosting_stages,
132                learning_rate=opaque.boosting_learning_rate,
133                max_depth=opaque.boosting_max_depth,
134                random_state=SEED,
135            ),
136        ),
137    )
138
139
140def _design(frame: pl.DataFrame) -> np.ndarray:
141    """The eighteen raw meta-feature columns, in `ALL_FEATURES` order.
142
143    Raw and untransformed on purpose. The grammar's transforms are part of what this study
144    contributes, so handing them to the opaque side would be comparing the equation against
145    itself; handing the opaque side nothing but the columns is the comparison a reader means
146    by "would a forest have done better".
147    """
148    columns = columns_as_arrays(frame, ALL_FEATURES)
149    return np.column_stack([columns[name] for name in ALL_FEATURES])
150
151
152def _cross_validate(design: np.ndarray, truth: np.ndarray, labels: np.ndarray, build: Builder) -> np.ndarray:
153    """Out-of-fold predictions under one leave-one-group-out split.
154
155    Clipped to the training fold's own observed range, exactly as
156    `validate._clip_to_training` does for every reported number. Without it the ridge control
157    is scored on an unbounded extrapolation the equation is never scored on, which would make
158    the comparison unfair in the equation's favour.
159    """
160    predictions = np.zeros_like(truth)
161    for _, train, test in leave_one_group_out(labels):
162        estimator = build(-1)
163        estimator.fit(design[train], truth[train])
164        held = np.asarray(estimator.predict(design[test]), dtype=np.float64)
165        predictions[test] = np.clip(held, float(truth[train].min()), float(truth[train].max()))
166    return predictions
167
168
169@dataclass(frozen=True)
170class OpaqueRun:
171    """One pass over the opaque regressors: the score table, and the predictions behind it.
172
173    Both together because the folds are the expensive part -- a random forest refits 45 times
174    across the two protocols -- and the ranking and threshold comparisons need the same
175    predictions the regression table was scored from. Computing them twice would double the
176    cost and, worse, leave open the possibility of the two disagreeing.
177    """
178
179    table: pl.DataFrame
180    #: ``predictions[label][protocol]``, for every estimator `estimators` names and all four
181    #: protocols -- ``in_sample``, ``loo_dataset``, ``loo_model`` and ``loo_cell``. Clipped
182    #: exactly as the reported numbers are.
183    predictions: dict[str, dict[str, np.ndarray]]
184
185
186def evaluate(frame: pl.DataFrame, *, models: tuple[tuple[str, Builder], ...] | None = None) -> OpaqueRun:
187    """Fit and score every opaque regressor once, keeping the predictions.
188
189    **Read the IS and DHO columns of the forest row together.** That pair is
190    the study's argument in two numbers: a flexible model fits this meta-data almost perfectly
191    and, with neither the dataset nor the model in training, reaches nothing. With twenty
192    dataset groups and features constant within a group it identifies the dataset and looks the
193    answer up, and identification is worth nothing on a cell nobody has run.
194
195    It is also the likely provenance of the R2 ~ 0.9 figures reported for opaque meta-models
196    elsewhere: a forest evaluated under IS or a random split reproduces them exactly.
197    """
198    design = _design(frame)
199    truth = target(frame)
200    datasets, model_labels = groups(frame, DATASET_COLUMN), groups(frame, MODEL_COLUMN)
201    splits = {"loo_dataset": datasets, "loo_model": model_labels}
202
203    rows: list[dict[str, object]] = []
204    predictions: dict[str, dict[str, np.ndarray]] = {}
205    for label, build in models or estimators(load_config().opaque):
206        fitted = build(-1)
207        fitted.fit(design, truth)
208        held: dict[str, np.ndarray] = {
209            "in_sample": np.clip(np.asarray(fitted.predict(design), dtype=np.float64), MCC_LOWER, MCC_UPPER)
210        }
211        for name, labels in splits.items():
212            held[name] = _cross_validate(design, truth, labels, build)
213        held["loo_cell"] = _doubly_held_out(design, truth, datasets, model_labels, build)
214        predictions[label] = held
215        rows.append(
216            {
217                "model": label,
218                "features": len(ALL_FEATURES),
219                **{
220                    key: value
221                    for name, values in held.items()
222                    for key, value in ((f"r2_{name}", r2_score(truth, values)), (f"mae_{name}", mae(truth, values)))
223                },
224            }
225        )
226    return OpaqueRun(table=pl.DataFrame(rows), predictions=predictions)
227
228
229def _doubly_held_out(
230    design: np.ndarray,
231    truth: np.ndarray,
232    first: np.ndarray,
233    second: np.ndarray,
234    build: Builder,
235    jobs: int = -1,
236) -> np.ndarray:
237    """Predictions with **both** the dataset and the model of each cell removed from training.
238
239    The protocol the study is actually for -- *what will this model reach on this dataset*,
240    when neither has been run -- and the only one under which the equation and an opaque
241    regressor are denied the same things. Under LODO a forest still has the
242    held-out learner on nineteen other problems; under LOMO it still has the
243    held-out dataset. Here it has neither, and neither does the equation.
244
245    That makes this the like-for-like comparison in the study, and it is also the protocol on
246    which the trivial per-model baselines cannot be computed at all: a model held out of every
247    fold has no rows to average. A feature-based predictor still predicts.
248
249    One refit per observed cell rather than per group -- 484 of them against 20 or 25 -- which
250    for a forest is the difference between seconds and minutes, so the cells are run in
251    parallel. Each estimator is given a single thread for the same reason: at 434 training rows
252    the thread pool costs more than the trees, and 484 pools of 16 would oversubscribe the
253    machine by an order of magnitude.
254    """
255    cells = [
256        (row, column)
257        for row in np.unique(first)
258        for column in np.unique(second)
259        if ((first == row) & (second == column)).any()
260    ]
261
262    def one(row: object, column: object) -> tuple[np.ndarray, np.ndarray]:
263        test = (first == row) & (second == column)
264        train = (first != row) & (second != column)
265        if not train.any():
266            return test, np.zeros(int(test.sum()))
267        estimator = build(1)
268        estimator.fit(design[train], truth[train])
269        held = np.asarray(estimator.predict(design[test]), dtype=np.float64)
270        return test, np.clip(held, float(truth[train].min()), float(truth[train].max()))
271
272    # A threading backend rather than processes: the estimators release the GIL inside their
273    # own fits, and 484 process spawns would each have to pickle the design across.
274    completed: list[tuple[np.ndarray, np.ndarray]] = Parallel(n_jobs=jobs, backend="threading")(
275        delayed(one)(row, column) for row, column in cells
276    )  # pyright: ignore[reportAssignmentType]
277    predictions = np.zeros_like(truth)
278    for test, values in completed:
279        predictions[test] = values
280    return predictions
SEED = 0
class Regressor(typing.Protocol):
80class Regressor(Protocol):
81    """The whole of what this module needs from an estimator: fit it, then predict with it.
82
83    Stated as a protocol rather than a union of the three scikit-learn classes, because that
84    is the actual contract. Anything satisfying it can be handed to `evaluate`, which is what
85    lets the tests exercise the protocols without paying for real ensembles.
86    """
87
88    # Positional-only: scikit-learn names these ``X`` and ``y``, and a protocol that insisted
89    # on its own names would match nothing in the library it exists to describe.
90    def fit(self, design: np.ndarray, truth: np.ndarray, /) -> object: ...
91
92    def predict(self, design: np.ndarray, /) -> object: ...

The whole of what this module needs from an estimator: fit it, then predict with it.

Stated as a protocol rather than a union of the three scikit-learn classes, because that is the actual contract. Anything satisfying it can be handed to evaluate, which is what lets the tests exercise the protocols without paying for real ensembles.

Regressor(*args, **kwargs)
1771def _no_init_or_replace_init(self, *args, **kwargs):
1772    cls = type(self)
1773
1774    if cls._is_protocol:
1775        raise TypeError('Protocols cannot be instantiated')
1776
1777    # Already using a custom `__init__`. No need to calculate correct
1778    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1779    if cls.__init__ is not _no_init_or_replace_init:
1780        return
1781
1782    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1783    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1784    # searches for a proper new `__init__` in the MRO. The new `__init__`
1785    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1786    # instantiation of the protocol subclass will thus use the new
1787    # `__init__` and no longer call `_no_init_or_replace_init`.
1788    for base in cls.__mro__:
1789        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1790        if init is not _no_init_or_replace_init:
1791            cls.__init__ = init
1792            break
1793    else:
1794        # should not happen
1795        cls.__init__ = object.__init__
1796
1797    cls.__init__(self, *args, **kwargs)
def fit(self, design: numpy.ndarray, truth: numpy.ndarray, /) -> object:
90    def fit(self, design: np.ndarray, truth: np.ndarray, /) -> object: ...
def predict(self, design: numpy.ndarray, /) -> object:
92    def predict(self, design: np.ndarray, /) -> object: ...
Builder = collections.abc.Callable[[int], Regressor]
def estimators( opaque: ml_meta_perf.config.OpaqueConfig) -> tuple[tuple[str, Callable[[int], Regressor]], ...]:
105def estimators(opaque: OpaqueConfig) -> tuple[tuple[str, Builder], ...]:
106    """The published regressors, as (label, factory).
107
108    Factories rather than instances so each fold gets a fresh unfitted estimator -- refitting
109    a fitted scikit-learn estimator is fine, but sharing one across folds is the kind of thing
110    that silently leaks if an estimator ever caches.
111
112    **The size is in the label**, so a table produced at a reduced size says so rather than
113    claiming a size it never grew.
114    """
115    return (
116        # A wide log-spaced grid rather than a chosen penalty: the point of the control is
117        # that a *well-regularised* linear model on raw columns still transfers badly, so
118        # picking the penalty by hand would leave the result open to that objection.
119        ("RidgeCV (linear)", lambda _threads=-1: RidgeCV(alphas=np.logspace(-3, 3, 25))),
120        (
121            f"RandomForest ({opaque.forest_trees} trees)",
122            lambda threads=-1: RandomForestRegressor(
123                n_estimators=opaque.forest_trees,
124                max_features=opaque.forest_max_features,
125                random_state=SEED,
126                n_jobs=threads,
127            ),
128        ),
129        (
130            f"GradientBoosting ({opaque.boosting_stages} stages)",
131            lambda _threads=-1: GradientBoostingRegressor(
132                n_estimators=opaque.boosting_stages,
133                learning_rate=opaque.boosting_learning_rate,
134                max_depth=opaque.boosting_max_depth,
135                random_state=SEED,
136            ),
137        ),
138    )

The published regressors, as (label, factory).

Factories rather than instances so each fold gets a fresh unfitted estimator -- refitting a fitted scikit-learn estimator is fine, but sharing one across folds is the kind of thing that silently leaks if an estimator ever caches.

The size is in the label, so a table produced at a reduced size says so rather than claiming a size it never grew.

@dataclass(frozen=True)
class OpaqueRun:
170@dataclass(frozen=True)
171class OpaqueRun:
172    """One pass over the opaque regressors: the score table, and the predictions behind it.
173
174    Both together because the folds are the expensive part -- a random forest refits 45 times
175    across the two protocols -- and the ranking and threshold comparisons need the same
176    predictions the regression table was scored from. Computing them twice would double the
177    cost and, worse, leave open the possibility of the two disagreeing.
178    """
179
180    table: pl.DataFrame
181    #: ``predictions[label][protocol]``, for every estimator `estimators` names and all four
182    #: protocols -- ``in_sample``, ``loo_dataset``, ``loo_model`` and ``loo_cell``. Clipped
183    #: exactly as the reported numbers are.
184    predictions: dict[str, dict[str, np.ndarray]]

One pass over the opaque regressors: the score table, and the predictions behind it.

Both together because the folds are the expensive part -- a random forest refits 45 times across the two protocols -- and the ranking and threshold comparisons need the same predictions the regression table was scored from. Computing them twice would double the cost and, worse, leave open the possibility of the two disagreeing.

OpaqueRun( table: polars.dataframe.frame.DataFrame, predictions: dict[str, dict[str, numpy.ndarray]])
table: polars.dataframe.frame.DataFrame
predictions: dict[str, dict[str, numpy.ndarray]]
def evaluate( frame: polars.dataframe.frame.DataFrame, *, models: tuple[tuple[str, Callable[[int], Regressor]], ...] | None = None) -> OpaqueRun:
187def evaluate(frame: pl.DataFrame, *, models: tuple[tuple[str, Builder], ...] | None = None) -> OpaqueRun:
188    """Fit and score every opaque regressor once, keeping the predictions.
189
190    **Read the IS and DHO columns of the forest row together.** That pair is
191    the study's argument in two numbers: a flexible model fits this meta-data almost perfectly
192    and, with neither the dataset nor the model in training, reaches nothing. With twenty
193    dataset groups and features constant within a group it identifies the dataset and looks the
194    answer up, and identification is worth nothing on a cell nobody has run.
195
196    It is also the likely provenance of the R2 ~ 0.9 figures reported for opaque meta-models
197    elsewhere: a forest evaluated under IS or a random split reproduces them exactly.
198    """
199    design = _design(frame)
200    truth = target(frame)
201    datasets, model_labels = groups(frame, DATASET_COLUMN), groups(frame, MODEL_COLUMN)
202    splits = {"loo_dataset": datasets, "loo_model": model_labels}
203
204    rows: list[dict[str, object]] = []
205    predictions: dict[str, dict[str, np.ndarray]] = {}
206    for label, build in models or estimators(load_config().opaque):
207        fitted = build(-1)
208        fitted.fit(design, truth)
209        held: dict[str, np.ndarray] = {
210            "in_sample": np.clip(np.asarray(fitted.predict(design), dtype=np.float64), MCC_LOWER, MCC_UPPER)
211        }
212        for name, labels in splits.items():
213            held[name] = _cross_validate(design, truth, labels, build)
214        held["loo_cell"] = _doubly_held_out(design, truth, datasets, model_labels, build)
215        predictions[label] = held
216        rows.append(
217            {
218                "model": label,
219                "features": len(ALL_FEATURES),
220                **{
221                    key: value
222                    for name, values in held.items()
223                    for key, value in ((f"r2_{name}", r2_score(truth, values)), (f"mae_{name}", mae(truth, values)))
224                },
225            }
226        )
227    return OpaqueRun(table=pl.DataFrame(rows), predictions=predictions)

Fit and score every opaque regressor once, keeping the predictions.

Read the IS and DHO columns of the forest row together. That pair is the study's argument in two numbers: a flexible model fits this meta-data almost perfectly and, with neither the dataset nor the model in training, reaches nothing. With twenty dataset groups and features constant within a group it identifies the dataset and looks the answer up, and identification is worth nothing on a cell nobody has run.

It is also the likely provenance of the R2 ~ 0.9 figures reported for opaque meta-models elsewhere: a forest evaluated under IS or a random split reproduces them exactly.