ml_meta_perf.experiment
The end-to-end experiment: fit E1, E2 and E3, validate all three, and report.
The three equations differ only in which features they may draw on -- E1 sees dataset
meta-features, E2 sees model meta-features, E3 sees both -- so the gaps between them
measure what each half of the meta-data is worth. Everything else is shared: one
configuration (config.Configuration, read from config/study.json), one search, one
set of protocols, and one length rule, selection.plateau_knee. Each equation searches
every feature it is allowed; the search, not the configuration, decides which it keeps.
E3-Valid is the E3 the study publishes. E3-MAX is the same configuration at the configured
capability arity (selection.capability_arity) at the raw maximum of its worst-protocol curve: a
capability bound on the additive form, not an equation put forward to be read.
Study chapter: 4. The equation -- the rationale, in prose, with the figures.
1"""The end-to-end experiment: fit E1, E2 and E3, validate all three, and report. 2 3The three equations differ only in which features they may draw on -- E1 sees dataset 4meta-features, E2 sees model meta-features, E3 sees both -- so the gaps between them 5measure what each half of the meta-data is worth. Everything else is shared: **one 6configuration** (`config.Configuration`, read from ``config/study.json``), one search, one 7set of protocols, and **one length rule**, `selection.plateau_knee`. Each equation searches 8every feature it is allowed; the search, not the configuration, decides which it keeps. 9 10E3-Valid is the E3 the study publishes. E3-MAX is the same configuration at the configured 11capability arity (``selection.capability_arity``) at the raw maximum of its worst-protocol curve: a 12capability bound on the additive form, not an equation put forward to be read. 13 14Study chapter: [4. The equation][study-chapter] -- the rationale, in 15prose, with the figures. 16 17[study-chapter]: https://github.com/mariolpantunes/ml-meta-perf/blob/main/assets/docs/04-equation.md 18""" 19 20from __future__ import annotations 21 22import dataclasses 23from dataclasses import dataclass, field 24 25import numpy as np 26import polars as pl 27 28from ml_meta_perf.analysis import feature_reach, grammar_ceiling, saturated_fit, screen 29from ml_meta_perf.attribution import group_shares, term_effects, variance_decomposition 30from ml_meta_perf.config import Configuration, OpaqueConfig, SelectionConfig, load_config 31from ml_meta_perf.data import ( 32 DATASET_COLUMN, 33 DATASET_FEATURES, 34 MODEL_COLUMN, 35 MODEL_FEATURES, 36 columns_as_arrays, 37 groups, 38 load, 39 target, 40) 41from ml_meta_perf.identity import correct_out_of_fold 42from ml_meta_perf.model import Equation 43from ml_meta_perf.opaque import Builder as OpaqueBuilder 44from ml_meta_perf.opaque import OpaqueRun 45from ml_meta_perf.opaque import estimators as opaque_estimators 46from ml_meta_perf.opaque import evaluate as opaque_evaluate 47from ml_meta_perf.practices import best_practices, feature_practices 48from ml_meta_perf.search import prune, search 49from ml_meta_perf.selection import complexity, floor_argmax, floor_curve, plateau_knee, protocol_spread, smoothed 50from ml_meta_perf.stats import mae, r2_score 51from ml_meta_perf.terms import Library, build_library 52from ml_meta_perf.validate import ( 53 CrossValidation, 54 Scores, 55 additive_mean_reference, 56 baseline_group_centre, 57 cross_validate_doubly_held_out, 58 cross_validate_fixed_form, 59 decision_report, 60 fold_selections, 61 interaction_capture, 62 leave_one_group_out, 63 library_rows, 64 oracle_ladder, 65 paired_comparison, 66 random_kfold_groups, 67 ranking_report, 68 score, 69 term_stability, 70) 71 72#: E1 is fitted on all 484 rows like the other two. An earlier version fitted it on the 20 73#: aggregated per-dataset means, on the grounds that a predictor constant inside a group can 74#: only ever predict that group's mean anyway. That was true and still the wrong choice: it put 75#: E1's R2 on a 20-point denominator, so its headline could not be compared with E3's without a 76#: paragraph of explanation. See [chapter 4][study-chapter]. 77 78 79@dataclass 80class EquationReport: 81 """One equation together with everything said about it.""" 82 83 equation: Equation 84 #: The selected length: `selection.plateau_knee` for E1, E2 and E3-Valid, the raw floor 85 #: maximum for E3-MAX. Every output reads the result from here. 86 n_terms: int 87 #: The grammar this equation was searched under -- the configuration's for E1, E2 and 88 #: E3-Valid, ``selection.capability_arity`` for E3-MAX. 89 arity: int 90 in_sample: dict[str, float | int] 91 curve: pl.DataFrame 92 cross_validated: dict[str, dict[str, float | int]] = field(default_factory=dict) 93 stability: pl.DataFrame | None = None 94 #: The full cross-validated path per protocol, kept so later tables can reuse it. 95 #: ``leakage_demonstration`` and ``decision_quality`` ask for the same folds at the 96 #: same settings, and recomputing them was a third of the study's runtime. 97 paths: dict[str, dict[int, CrossValidation]] = field(default_factory=dict) 98 99 100def _curve( 101 sizes: tuple[int, ...], 102 in_sample: dict[int, Scores], 103 paths: dict[str, dict[int, CrossValidation]], 104 truth: np.ndarray, 105) -> pl.DataFrame: 106 """One row per equation length, carrying every metric under every protocol. 107 108 IS gets the same three metrics as the cross-validated columns, not just R²: 109 an error curve that omits the IS line cannot show how far apart fit and 110 transfer run, which is the whole point of plotting them together. 111 """ 112 rows: list[dict[str, object]] = [] 113 for size in sizes: 114 if size not in in_sample: 115 continue 116 row: dict[str, object] = { 117 "n_terms": size, 118 "r2_in_sample": in_sample[size].r2, 119 "mae_in_sample": in_sample[size].mae, 120 "smape_in_sample": in_sample[size].smape, 121 } 122 for label, path in paths.items(): 123 scores = path[size].scores(truth) 124 row[f"r2_{label}"] = scores.r2 125 row[f"mae_{label}"] = scores.mae 126 row[f"smape_{label}"] = scores.smape 127 rows.append(row) 128 return pl.DataFrame(rows) 129 130 131def _fixed_form_path( 132 columns: dict[str, np.ndarray], 133 truth: np.ndarray, 134 labels: np.ndarray, 135 config: Configuration, 136 library: Library | None = None, 137 model_features: tuple[str, ...] = MODEL_FEATURES, 138) -> tuple[dict[int, CrossValidation], Library]: 139 """Fit once, then cross-validate the resulting forms with only their weights refit. 140 141 The fallback used when a caller has no precomputed path to reuse. It exists so that every 142 cross-validated number in this module comes from the same protocol: one equation, its 143 terms fixed, its constants recalibrated per fold. Re-running selection inside the folds 144 would answer a different question and is never done for a reported figure. 145 """ 146 if library is None: 147 library = build_library( 148 DATASET_FEATURES, 149 model_features, 150 columns, 151 max_arity=config.max_arity, 152 max_abs_zscore=config.max_abs_zscore, 153 ) 154 result = search( 155 library, 156 truth, 157 max_terms=config.max_terms, 158 penalty=config.penalty, 159 pool_size=config.pool_size, 160 beam_width=config.beam_width, 161 ) 162 path = cross_validate_fixed_form(library, columns, truth, labels, result.equations, penalty=config.penalty) 163 return path, library 164 165 166@dataclass(frozen=True) 167class FittedPath: 168 """Everything `run_equation` computes before a length is chosen. 169 170 Searching and cross-validating every length is the expensive half of an equation; choosing 171 one length and finalising it is cheap. Keeping the two apart means a length rule only ever 172 reads a curve that was computed once, and lets `ml_meta_perf.sweep` score hundreds of 173 configurations without finalising any of them. 174 """ 175 176 library: Library 177 columns: dict[str, np.ndarray] 178 truth: np.ndarray 179 datasets: np.ndarray 180 equations: dict[int, Equation] 181 paths: dict[str, dict[int, CrossValidation]] 182 curve: pl.DataFrame 183 config: Configuration 184 185 186def fit_path( 187 frame: pl.DataFrame, 188 dataset_features: tuple[str, ...], 189 model_features: tuple[str, ...], 190 config: Configuration, 191 name: str, 192) -> FittedPath: 193 """Search one grammar and cross-validate every length on it, choosing none of them.""" 194 columns = columns_as_arrays(frame, dataset_features + model_features) 195 truth = target(frame) 196 datasets = groups(frame, DATASET_COLUMN) 197 models = groups(frame, MODEL_COLUMN) 198 199 library = build_library( 200 dataset_features, 201 model_features, 202 columns, 203 max_arity=config.max_arity, 204 max_abs_zscore=config.max_abs_zscore, 205 ) 206 result = search( 207 library, 208 truth, 209 max_terms=config.max_terms, 210 penalty=config.penalty, 211 pool_size=config.pool_size, 212 beam_width=config.beam_width, 213 name=name, 214 ) 215 # Fixed form: the terms are chosen once, here, and only the weights are refit in each 216 # fold. See `validate.cross_validate_fixed_form` for why that is the reported protocol. 217 paths = { 218 label: cross_validate_fixed_form(library, columns, truth, labels, result.equations, penalty=config.penalty) 219 for label, labels in (("loo_dataset", datasets), ("loo_model", models)) 220 } 221 # DHO at **every** length, not just the selected one: every selection rule reads the 222 # complete curve, and E3-MAX selects on this protocol through `selection.floor_curve`. 223 paths["loo_cell"] = cross_validate_doubly_held_out( 224 library, columns, truth, datasets, models, result.equations, penalty=config.penalty 225 ) 226 in_sample = {k: score(truth, eq.predict(columns)) for k, eq in result.equations.items()} 227 curve = _curve(tuple(sorted(result.equations)), in_sample, paths, truth) 228 return FittedPath(library, columns, truth, datasets, result.equations, paths, curve, config) 229 230 231def finalise(fitted: FittedPath, size: int) -> EquationReport: 232 """Publish one length of a fitted path: prune it, and measure how stable its form is.""" 233 if size not in fitted.equations: 234 raise ValueError(f"selected equation length {size} is unavailable") 235 config, truth = fitted.config, fitted.truth 236 # The same folds with selection re-run inside them, kept only for `stability`: the share 237 # of the equation's terms that survive when one dataset is removed. That is what 238 # licenses fixing the form, and it is not part of any reported score. 239 reselected = fold_selections( 240 fitted.library, 241 truth, 242 fitted.datasets, 243 n_terms=size, 244 penalty=config.penalty, 245 pool_size=config.pool_size, 246 beam_width=config.beam_width, 247 ) 248 equation = prune(fitted.equations[size], fitted.columns, truth, penalty=config.penalty) 249 250 return EquationReport( 251 equation=equation, 252 n_terms=size, 253 arity=config.max_arity, 254 in_sample=score(truth, equation.predict(fitted.columns)).as_dict(), 255 curve=fitted.curve, 256 cross_validated={ 257 label: path[size].scores(truth).as_dict() | path[size].dispersion() for label, path in fitted.paths.items() 258 }, 259 stability=term_stability(reselected, tuple(term.name for term in equation.terms)), 260 paths=fitted.paths, 261 ) 262 263 264def _resolve(config: Configuration | None, selection: SelectionConfig | None) -> tuple[Configuration, SelectionConfig]: 265 """Fill whichever of the two was not given from ``config/study.json``.""" 266 if config is None or selection is None: 267 study = load_config() 268 return config or study.search, selection or study.selection 269 return config, selection 270 271 272def select_length(curve: pl.DataFrame, selection: SelectionConfig) -> int: 273 """The length rule every published equation shares: `selection.plateau_knee`.""" 274 return plateau_knee(curve, delta=selection.delta, window=selection.window, smoothing=selection.smoothing) 275 276 277def run_equation( 278 frame: pl.DataFrame, 279 dataset_features: tuple[str, ...], 280 model_features: tuple[str, ...], 281 config: Configuration, 282 selection: SelectionConfig, 283 name: str, 284) -> EquationReport: 285 """Fit one equation and validate it. The three equations differ **only** in the 286 features they may draw on, and this is the single code path that says so. 287 288 Every one of them is fitted on all 484 rows and scored on all 484 rows, under all four 289 protocols, and has its length chosen by the same rule. That uniformity is the point: the 290 gaps between E1, E2 and E3 are only evidence about what each half of the meta-data is 291 worth if nothing else differs between them -- not the fitting scale, not the protocol, 292 not the denominator of the R2, not the way the length was picked. 293 294 **The length is derived, not asserted**: `select_length` reads it off the complete 295 cross-validated curve. 296 """ 297 fitted = fit_path(frame, dataset_features, model_features, config, name) 298 return finalise(fitted, select_length(fitted.curve, selection)) 299 300 301def run_e1( 302 frame: pl.DataFrame, config: Configuration | None = None, selection: SelectionConfig | None = None 303) -> EquationReport: 304 """Dataset features only -- how much of MCC the data alone explains. 305 306 Every model on a given dataset shares one feature vector, so this equation can only 307 ever predict a per-dataset constant. That is not a flaw to be corrected, it is the 308 control: whatever E3 reaches beyond this is what knowing the model buys. 309 310 Least squares finds that per-dataset constant on its own, so aggregating the rows to 311 twenty dataset means first would be unnecessary as well as harmful to the comparison. 312 """ 313 config, selection = _resolve(config, selection) 314 return run_equation(frame, DATASET_FEATURES, (), config, selection, "E1") 315 316 317def run_e2( 318 frame: pl.DataFrame, config: Configuration | None = None, selection: SelectionConfig | None = None 319) -> EquationReport: 320 """Model features only -- the mirror image of E1. 321 322 There are six model features and five of them are constant per model, so the library 323 is tiny and the equation is short by necessity rather than by choice. That is itself 324 the finding: the meta-data describes datasets far better than it describes models. 325 326 Aggregating this one to 25 per-model means -- the mirror of aggregating E1 -- was 327 measured and is worse. `Processing Units Number` is intentionally allowed to vary with 328 the dataset because it is a capacity calculation over that dataset's shape. Averaging 329 it away discards real variation, and the optimum collapses to a single term. 330 """ 331 config, selection = _resolve(config, selection) 332 return run_equation(frame, (), MODEL_FEATURES, config, selection, "E2") 333 334 335def run_e3( 336 frame: pl.DataFrame, config: Configuration | None = None, selection: SelectionConfig | None = None 337) -> EquationReport: 338 """Dataset and model features: E3-Valid, the equation the study publishes. 339 340 Every dataset feature and every model feature is in the term pool; the configuration 341 restricts the search only through its hyperparameters. 342 """ 343 config, selection = _resolve(config, selection) 344 return run_equation(frame, DATASET_FEATURES, MODEL_FEATURES, config, selection, "E3") 345 346 347def run_capability( 348 frame: pl.DataFrame, config: Configuration | None = None, selection: SelectionConfig | None = None 349) -> EquationReport: 350 """E3-MAX: E3's configuration at the configured capability arity, at the raw maximum of its floor. 351 352 **A capability measurement, not a recommendation.** It answers the question E3-Valid cannot 353 answer about itself -- whether the additive form is out of reach or merely out of the 354 readable range -- so it takes `selection.floor_argmax` rather than the length rule, with 355 no smoothing and no complexity penalty. Only the arity differs from E3-Valid. 356 """ 357 config, selection = _resolve(config, selection) 358 wider = dataclasses.replace(config, max_arity=selection.capability_arity) 359 fitted = fit_path(frame, DATASET_FEATURES, MODEL_FEATURES, wider, "E3-MAX") 360 return finalise(fitted, floor_argmax(fitted.curve)) 361 362 363def selection_summary( 364 e3: EquationReport, capability: EquationReport, config: Configuration, selection: SelectionConfig 365) -> pl.DataFrame: 366 """E3-Valid and E3-MAX side by side: the rule each used and where it landed. 367 368 ``smoothed_floor`` is the quantity the length rule reads; ``floor`` is the raw worst 369 protocol at that length; ``at_horizon`` flags a bound that stopped at ``max_terms`` rather 370 than at a maximum of the data. 371 """ 372 rows: list[dict[str, object]] = [] 373 for role, report, rule in ( 374 ("E3-Valid", e3, "first plateau knee"), 375 ("E3-MAX", capability, "raw floor maximum"), 376 ): 377 curve = report.curve 378 position = list(curve["n_terms"]).index(report.n_terms) 379 rows.append( 380 { 381 "role": role, 382 "rule": rule, 383 "arity": report.arity, 384 "n_terms": report.n_terms, 385 "complexity": complexity(report.arity, report.n_terms), 386 "r2_in_sample": float(curve["r2_in_sample"][position]), 387 "r2_loo_dataset": float(curve["r2_loo_dataset"][position]), 388 "r2_loo_model": float(curve["r2_loo_model"][position]), 389 "r2_loo_cell": float(curve["r2_loo_cell"][position]), 390 "floor": float(floor_curve(curve)[position]), 391 "smoothed_floor": float(smoothed(floor_curve(curve), selection.smoothing)[position]), 392 "spread": float(protocol_spread(curve)[position]), 393 "at_horizon": report.n_terms == config.max_terms, 394 } 395 ) 396 return pl.DataFrame(rows) 397 398 399def reach_analysis(frame: pl.DataFrame, config: Configuration) -> tuple[pl.DataFrame, pl.DataFrame]: 400 """What each raw feature is worth, and how far the grammar reaches before any search. 401 402 Returns the per-feature table and the three-level ceiling ladder as a one-row frame. 403 Both are computed from the library alone, so they are available *before* an equation 404 exists and can be read as an expectation the fitted equation is then held against. 405 """ 406 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 407 library = build_library( 408 DATASET_FEATURES, 409 MODEL_FEATURES, 410 columns, 411 max_arity=config.max_arity, 412 max_abs_zscore=config.max_abs_zscore, 413 ) 414 truth = target(frame) 415 features = DATASET_FEATURES + MODEL_FEATURES 416 ladder = grammar_ceiling(library, truth, features) 417 return feature_reach(library, truth, features), pl.DataFrame([ladder]) 418 419 420def saturated_analysis(frame: pl.DataFrame, config: Configuration) -> dict[str, float]: 421 """`analysis.saturated_fit` over the library E3 actually searches. 422 423 The library rather than the screened pool, and the E3 grammar rather than the capability 424 one, because the claim it supports is about the procedure the study publishes: handing 425 *these* candidates to least squares in one go is what selection is being compared with. 426 """ 427 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 428 library = build_library( 429 DATASET_FEATURES, 430 MODEL_FEATURES, 431 columns, 432 max_arity=config.max_arity, 433 max_abs_zscore=config.max_abs_zscore, 434 ) 435 return saturated_fit(library, target(frame), groups(frame, DATASET_COLUMN)) 436 437 438def correlation_analysis(frame: pl.DataFrame, config: Configuration, top: int = 15) -> pl.DataFrame: 439 """Rank candidate terms by how they relate to MCC, linearly and monotonically. 440 441 Both correlations are reported because they say different things and the difference 442 is what guides term design. A term whose Pearson and Spearman correlations agree is 443 linear in MCC and needs no transform. A term whose Spearman correlation is clearly 444 the larger is monotone but curved, which is the signal that a log, an inverse or a 445 ratio will pay for itself. The ``within_pearson`` column is the strict one: both the 446 term and the target are centred inside each dataset first, so a term is credited 447 only for variation that dataset identity does not already account for. 448 """ 449 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 450 library = build_library( 451 DATASET_FEATURES, 452 MODEL_FEATURES, 453 columns, 454 max_arity=config.max_arity, 455 max_abs_zscore=config.max_abs_zscore, 456 ) 457 table = screen(library, target(frame), groups(frame, DATASET_COLUMN)) 458 return table.with_columns((pl.col("spearman").abs() - pl.col("pearson").abs()).alias("monotone_gap")).head(top) 459 460 461def baselines(frame: pl.DataFrame) -> pl.DataFrame: 462 """What the equations have to beat, and the ceiling neither of them can pass. 463 464 Every trivial predictor is reported at both its mean and its median, because the metrics 465 disagree about which is the honest opponent. R2 and RMSE are squared-error metrics and the 466 mean minimises squared error; MAE is minimised by the median, and SMAPE is an 467 absolute-error ratio that behaves the same way. Quoting the equation's MAE against a 468 *mean* baseline compares it with a predictor that is not minimising the metric it is being 469 judged on, which flatters the equation. Both rows are here so each metric can be read 470 against whichever centre is hardest to beat on it. 471 """ 472 truth = target(frame) 473 datasets = groups(frame, DATASET_COLUMN) 474 models = groups(frame, MODEL_COLUMN) 475 476 rows: list[dict[str, object]] = [] 477 for centre in ("mean", "median"): 478 for label, outer, inner in ( 479 (f"global {centre} (loo-dataset)", datasets, None), 480 (f"per-model {centre} (loo-dataset)", datasets, models), 481 (f"global {centre} (loo-model)", models, None), 482 (f"per-dataset {centre} (loo-model)", models, datasets), 483 ): 484 prediction = baseline_group_centre(truth, outer, inner, centre=centre) 485 rows.append({"baseline": label, **score(truth, prediction).as_dict()}) 486 rows.append( 487 { 488 "baseline": "additive mean-based reference (in-sample)", 489 **score(truth, additive_mean_reference(truth, datasets, models)).as_dict(), 490 } 491 ) 492 return pl.DataFrame(rows) 493 494 495def leakage_demonstration( 496 frame: pl.DataFrame, 497 n_terms: int, 498 config: Configuration, 499 known: dict[str, dict[int, CrossValidation]] | None = None, 500) -> pl.DataFrame: 501 """The same equation scored under a random split and under a grouped split. 502 503 Dataset features are constant across a dataset's rows, so a random k-fold split puts 504 the same dataset on both sides of the fold. The equation then recognises the dataset 505 rather than generalising to it. This table exists so the difference is visible in 506 numbers rather than asserted in prose. 507 508 ``known`` supplies grouped paths that `run_e3` has already computed. They are the same 509 folds over the same library at the same settings, and beam search records its best 510 subset at every size as it goes, so a path built to ``max_terms`` contains the shorter 511 entry this table needs and does not have to be recomputed. 512 """ 513 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 514 truth = target(frame) 515 protocols = { 516 "random 10-fold (leaky)": ("random", random_kfold_groups(truth.shape[0])), 517 "leave-one-dataset-out": ("loo_dataset", groups(frame, DATASET_COLUMN)), 518 "leave-one-model-out": ("loo_model", groups(frame, MODEL_COLUMN)), 519 } 520 library: Library | None = None 521 rows: list[dict[str, object]] = [] 522 for label, (key, labels) in protocols.items(): 523 path = (known or {}).get(key) 524 if path is None or n_terms not in path: 525 path, library = _fixed_form_path(columns, truth, labels, config, library) 526 size = n_terms if n_terms in path else max(path) 527 rows.append({"protocol": label, **path[size].scores(truth).as_dict()}) 528 return pl.DataFrame(rows) 529 530 531def selection_optimism(frame: pl.DataFrame, e3: EquationReport, config: Configuration) -> pl.DataFrame: 532 """LODO with the terms **re-chosen inside every fold**, beside the reported fixed-form LODO. 533 534 The reported protocol fixes the form -- chosen once, on all rows -- and refits only the 535 weights per fold (`validate.cross_validate_fixed_form`). That is a position about what the 536 equation is, and it has a price: the form was chosen with the held-out dataset in view. This 537 measures the price, by repeating the selection itself without the held-out dataset, at 538 E3-Valid's length and configuration, and scoring those predictions. 539 540 **A diagnostic, not a score.** Twenty folds fit twenty different equations, so the nested 541 row describes the discovery procedure rather than the published equation; it is reported 542 so the fixed-form transfer numbers are read as the upper estimate they are. 543 """ 544 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 545 truth, datasets = target(frame), groups(frame, DATASET_COLUMN) 546 library = build_library( 547 DATASET_FEATURES, MODEL_FEATURES, columns, max_arity=config.max_arity, max_abs_zscore=config.max_abs_zscore 548 ) 549 position = {name: index for index, name in enumerate(library.names)} 550 nested = np.zeros_like(truth) 551 for _, train, test in leave_one_group_out(datasets): 552 result = search( 553 library_rows(library, train), 554 truth[train], 555 max_terms=e3.n_terms, 556 penalty=config.penalty, 557 pool_size=config.pool_size, 558 beam_width=config.beam_width, 559 ) 560 equation = result.equations[max(size for size in result.equations if size <= e3.n_terms)] 561 held = library.matrix[test][:, [position[term.name] for term in equation.terms]] 562 nested[test] = np.clip( 563 equation.intercept + held @ np.asarray(equation.weights), truth[train].min(), truth[train].max() 564 ) 565 fixed = e3.paths["loo_dataset"][e3.n_terms].predictions 566 return pl.DataFrame( 567 [ 568 {"LODO": "form chosen once, on all rows (reported)", **score(truth, fixed).as_dict()}, 569 {"LODO": "form re-chosen inside every fold", **score(truth, nested).as_dict()}, 570 ] 571 ) 572 573 574def identity_ceiling(frame: pl.DataFrame, e3: EquationReport) -> pl.DataFrame: 575 """The upper bound on what any model descriptor could add, measured rather than argued. 576 577 Under LODO **every one of the 25 models appears in every training fold**, 578 so the equation's residual can be averaged per model on the training rows and applied to 579 the held-out dataset with no leak. That replaces the model descriptors with the best 580 possible substitute -- the model's *identity*, fitted freely -- and what it adds is 581 therefore a ceiling on what any descriptor set could reach by telling these classifiers 582 apart. It is the number the study's central negative result is measured against: every 583 rejected model-side encoding is rejected for failing to recover it. 584 585 Two rungs, because the ceiling depends on what the identity is allowed to carry. A level 586 per model shifts every one of its rows equally; a level and a slope on a dataset feature 587 lets a model's advantage depend on the data, which is what a *capability* descriptor would 588 have to do. `identity.correct_out_of_fold` reuses the per-fold equations the reported path 589 already recorded, so the protocol is unchanged and no search is repeated. 590 591 Generated because the chapter's hand-written copy of this table had all three rows wrong 592 and listed its two rungs as identical, which no run of this function can produce. 593 """ 594 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 595 truth = target(frame) 596 datasets, models = groups(frame, DATASET_COLUMN), groups(frame, MODEL_COLUMN) 597 path = e3.paths.get("loo_dataset") 598 size = len(e3.equation.terms) 599 if path is None or size not in path: 600 return pl.DataFrame(schema={"correction": pl.String, "r2_loo_dataset": pl.Float64, "mae": pl.Float64}) 601 fold = path[size] 602 rungs = { 603 f"none (E3-Valid, {size} terms)": fold.predictions, 604 "per-model level": correct_out_of_fold(fold, columns, truth, datasets, models), 605 "per-model level and slope": correct_out_of_fold( 606 fold, columns, truth, datasets, models, features=DATASET_FEATURES 607 ), 608 } 609 610 def per_dataset_mae(prediction: np.ndarray) -> np.ndarray: 611 return np.array([mae(truth[test], prediction[test]) for _, _, test in leave_one_group_out(datasets)]) 612 613 # **Whether the gap is real is a paired question, not a difference of two pooled numbers.** 614 # That distinction has produced three wrong conclusions on this project, and this gap is 615 # the one the study's central negative result is measured against -- so each rung is paired 616 # against the uncorrected equation over the twenty held-out datasets, on per-dataset MAE. 617 baseline = per_dataset_mae(fold.predictions) 618 rows: list[dict[str, object]] = [] 619 for label, values in rungs.items(): 620 row: dict[str, object] = { 621 "correction": label, 622 "r2_loo_dataset": r2_score(truth, values), 623 "mae": mae(truth, values), 624 } 625 if label.startswith("none"): 626 row |= { 627 "gain": 0.0, 628 "ci_low": float("nan"), 629 "ci_high": float("nan"), 630 "sign_p": float("nan"), 631 "wins": 0, 632 "verdict": "baseline", 633 } 634 else: 635 paired = paired_comparison(per_dataset_mae(values), baseline, lower_is_better=True) 636 row |= { 637 "gain": paired.mean, 638 "ci_low": paired.low, 639 "ci_high": paired.high, 640 "sign_p": paired.p_value, 641 "wins": paired.wins, 642 "verdict": "real" if paired.significant else "tie", 643 } 644 rows.append(row) 645 return pl.DataFrame(rows) 646 647 648def interaction_reached(frame: pl.DataFrame, e3: EquationReport) -> pl.DataFrame: 649 """How much of the leading interaction pattern the fitted equation actually reaches. 650 651 The oracle ladder prices interaction components; this says whether E3 gets any of 652 them. Reported under both the fit and the held-out protocol, because an equation can 653 align with a pattern under IS and lose it out of fold -- and that difference is the 654 whole question for a component nobody can predict from meta-features. 655 """ 656 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 657 truth = target(frame) 658 datasets, models = groups(frame, DATASET_COLUMN), groups(frame, MODEL_COLUMN) 659 size = len(e3.equation.terms) 660 predictions = {"in-sample": e3.equation.predict(columns)} 661 path = e3.paths.get("loo_dataset") 662 if path is not None and size in path: 663 predictions["leave-one-dataset-out"] = path[size].predictions 664 rows = [ 665 {"protocol": label, "rank": rank, **interaction_capture(truth, prediction, datasets, models, rank=rank)} 666 for label, prediction in predictions.items() 667 for rank in (1, 2) 668 ] 669 return pl.DataFrame(rows) 670 671 672def comparison( 673 frame: pl.DataFrame, 674 e1: EquationReport, 675 e3: EquationReport, 676 e2: EquationReport | None = None, 677 e3_capability: EquationReport | None = None, 678) -> pl.DataFrame: 679 """E1 against E3 on the one scale where they are comparable: all rows. 680 681 All three equations are already fitted and scored on the same 484 rows, so this table 682 is not correcting a denominator -- an earlier version of E1 was fitted on 20 aggregated 683 dataset means and it was. What it adds is the two *ceilings* beside the equations: the 684 true per-dataset and per-model means, which are the most any equation restricted to 685 that half of the meta-data could reach. Without them a reader can compare the raw E1 and 686 E3 scores and conclude the dataset side is weak, even when E1 is already close to its 687 own structural ceiling. 688 """ 689 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 690 truth = target(frame) 691 datasets = groups(frame, DATASET_COLUMN) 692 models = groups(frame, MODEL_COLUMN) 693 694 dataset_ceiling = np.zeros_like(truth) 695 for label in np.unique(datasets): 696 mask = datasets == label 697 dataset_ceiling[mask] = truth[mask].mean() 698 model_ceiling = np.zeros_like(truth) 699 for label in np.unique(models): 700 mask = models == label 701 model_ceiling[mask] = truth[mask].mean() 702 if e2 is None: 703 e2 = run_e2(frame) 704 705 return pl.DataFrame( 706 [ 707 { 708 "equation": f"E1, dataset only ({e1.equation.n_terms} terms)", 709 "n_terms": e1.equation.n_terms, 710 **score(truth, e1.equation.predict(columns)).as_dict(), 711 }, 712 { 713 "equation": "E1 reference: true dataset means", 714 "n_terms": None, 715 **score(truth, dataset_ceiling).as_dict(), 716 }, 717 { 718 "equation": f"E2, model only ({e2.equation.n_terms} terms)", 719 "n_terms": e2.equation.n_terms, 720 **score(truth, e2.equation.predict(columns)).as_dict(), 721 }, 722 { 723 "equation": "E2 reference: true model means", 724 "n_terms": None, 725 **score(truth, model_ceiling).as_dict(), 726 }, 727 { 728 "equation": f"E3-Valid, dataset + model ({e3.equation.n_terms} terms)", 729 "n_terms": e3.equation.n_terms, 730 **score(truth, e3.equation.predict(columns)).as_dict(), 731 }, 732 *( 733 [ 734 { 735 "equation": ( 736 f"E3-MAX, arity " 737 f"{max((len(term.features) for term in e3_capability.equation.terms), default=0)}" 738 f" ({len(e3_capability.equation.terms)} terms)" 739 ), 740 "n_terms": len(e3_capability.equation.terms), 741 **score(truth, e3_capability.equation.predict(columns)).as_dict(), 742 } 743 ] 744 if e3_capability is not None and e3_capability is not e3 745 else [] 746 ), 747 { 748 "equation": "reference: additive mean-based reference", 749 "n_terms": None, 750 **score(truth, additive_mean_reference(truth, datasets, models)).as_dict(), 751 }, 752 ] 753 ) 754 755 756def decision_quality( 757 frame: pl.DataFrame, 758 n_terms: int, 759 config: Configuration, 760 known: dict[int, CrossValidation] | None = None, 761) -> pl.DataFrame: 762 """Go/no-go decision quality, with both the dataset and the model held out. 763 764 Same requirement as `model_selection` and for the same reason: the question is asked about 765 a pair nobody has run, so neither half of it may be in the training set. ``known`` is 766 `run_e3`'s LODO path and is the fallback when the DHO fit 767 cannot be built. 768 """ 769 truth = target(frame) 770 datasets = groups(frame, DATASET_COLUMN) 771 doubly = doubly_held_out_predictions(frame, n_terms, config) 772 if doubly is not None: 773 return decision_report(truth, doubly, datasets) 774 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 775 path = known 776 if path is None or n_terms not in path: 777 path, _ = _fixed_form_path(columns, truth, datasets, config) 778 size = n_terms if n_terms in path else max(path) 779 return decision_report(truth, path[size].predictions, datasets) 780 781 782def length_comparison(frame: pl.DataFrame, e3: EquationReport, config: Configuration) -> pl.DataFrame: 783 """Every equation length paired against the E3-Valid selection, per held-out dataset. 784 785 E3-Valid is selected before this diagnostic is computed. The table measures how each 786 length differs from that retained equation; it does not define another selection rule. 787 788 Per-dataset **MAE** rather than per-dataset R2, because three datasets here have almost no 789 within-dataset variance -- `5G_Slicing`'s 25 models all score about 0.986 -- and an R2 790 over a near-constant target is dominated by its denominator. See 791 `validate.CrossValidation.dispersion`. 792 793 A row marked ``worse`` differs significantly from E3-Valid under this paired comparison. 794 """ 795 truth = target(frame) 796 datasets = groups(frame, DATASET_COLUMN) 797 labels = np.unique(datasets) 798 path = e3.paths.get("loo_dataset", {}) 799 if not path: 800 return pl.DataFrame() 801 # Referenced to what the rule chose, not to a length passed in: the table has to be able 802 # to say that the rule's own pick is the right one, which it cannot do if the pick is the 803 # thing being assumed. 804 chosen = e3.n_terms 805 if chosen not in path: 806 chosen = max(path) 807 if chosen not in path: 808 return pl.DataFrame() 809 published = chosen 810 811 def per_fold(size: int) -> np.ndarray: 812 prediction = path[size].predictions 813 return np.array([mae(truth[datasets == label], prediction[datasets == label]) for label in labels]) 814 815 reference = per_fold(published) 816 rows: list[dict[str, object]] = [] 817 for size in sorted(path): 818 result = paired_comparison(reference, per_fold(size), lower_is_better=True) 819 # `mean` is positive when the first argument -- the published equation -- is better. 820 verdict = ( 821 "selected" 822 if size == published 823 else ("worse" if result.significant and result.mean > 0 else ("better" if result.significant else "tie")) 824 ) 825 rows.append( 826 { 827 "n_terms": size, 828 "r2_loo_dataset": path[size].scores(truth).r2, 829 "mae_loo_dataset": float(per_fold(size).mean()), 830 "mean_difference": result.mean, 831 "p_value": result.p_value, 832 "ci_low": result.low, 833 "ci_high": result.high, 834 "verdict": verdict, 835 } 836 ) 837 return pl.DataFrame(rows) 838 839 840def doubly_held_out_predictions(frame: pl.DataFrame, n_terms: int, config: Configuration) -> np.ndarray | None: 841 """E3's predictions with **both** the dataset and the model of each cell held out. 842 843 Used for the ranking and the threshold decision and for nothing else. Those two are the 844 questions a practitioner actually asks -- *which model should I run on this data*, and 845 *will it clear my bar* -- and both are asked about a pair that has not been run. Neither 846 single-group protocol answers that: LODO has seen the learner on nineteen 847 other problems, LOMO has seen the dataset. 848 849 The R2 curve and the length rule stay on the single-group protocols, which is the right 850 scope for them: they are about how the equation degrades as one axis becomes unfamiliar, 851 and a per-cell refit would answer a different question at 32 times the cost. 852 """ 853 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 854 truth = target(frame) 855 datasets = groups(frame, DATASET_COLUMN) 856 models = groups(frame, MODEL_COLUMN) 857 library = build_library( 858 DATASET_FEATURES, 859 MODEL_FEATURES, 860 columns, 861 max_arity=config.max_arity, 862 max_abs_zscore=config.max_abs_zscore, 863 ) 864 result = search( 865 library, 866 truth, 867 max_terms=config.max_terms, 868 penalty=config.penalty, 869 pool_size=config.pool_size, 870 beam_width=config.beam_width, 871 ) 872 if n_terms not in result.equations: 873 return None 874 size = n_terms 875 path = cross_validate_doubly_held_out( 876 library, 877 columns, 878 truth, 879 datasets, 880 models, 881 {size: result.equations[size]}, 882 penalty=config.penalty, 883 ) 884 return path[size].predictions if size in path else None 885 886 887def _e3_predictions(frame: pl.DataFrame, e3: EquationReport, protocol: str, config: Configuration) -> np.ndarray | None: 888 """E3's predictions under one protocol. 889 890 ``in_sample``, ``loo_dataset``, ``loo_model``, or ``loo_cell`` -- the last holding out both 891 the dataset and the model of every cell, which is the protocol the ranking and the 892 threshold decision are reported under. 893 """ 894 if protocol == "loo_cell": 895 return doubly_held_out_predictions(frame, e3.n_terms, config) 896 if protocol == "in_sample": 897 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 898 return e3.equation.predict(columns) 899 path = e3.paths.get(protocol) 900 if not path: 901 return None 902 size = e3.n_terms if e3.n_terms in path else max(path) 903 return path[size].predictions if size in path else None 904 905 906def model_selection( 907 frame: pl.DataFrame, 908 e3: EquationReport, 909 config: Configuration, 910 protocol: str = "loo_cell", 911) -> pl.DataFrame: 912 """Can the equation pick a good model for a dataset it has never run it on? 913 914 **Reported under ``loo_cell``: both the dataset and the model of every cell are out of the 915 training set.** Anything weaker answers a different question. LODO has 916 seen the learner on nineteen other problems; LOMO has seen the dataset. A 917 recommendation is asked about a pair that has not been run, so both have to go. 918 919 The cost of that is small, and structurally so rather than by luck: a ranking depends only 920 on the *within-dataset* ordering of models, and the dataset-side terms shift every model 921 on a dataset by the same amount, so holding the dataset out moves the level and not the 922 order. `ranking_baselines` reports every protocol side by side so that stays visible. 923 """ 924 truth = target(frame) 925 datasets = groups(frame, DATASET_COLUMN) 926 prediction = _e3_predictions(frame, e3, protocol, config) 927 if prediction is None: 928 return pl.DataFrame() 929 return ranking_report(truth, prediction, datasets) 930 931 932#: How an opaque estimator's protocol keys are labelled in the comparison tables. Every row 933#: names its protocol, because a comparison is only a comparison if both sides were scored 934#: under the same one -- which this project has shipped wrong twice. 935OPAQUE_PROTOCOLS = { 936 "in_sample": "in-sample", 937 "loo_dataset": "loo-dataset", 938 "loo_model": "loo-model", 939 "loo_cell": "loo-cell: both held out", 940} 941 942 943def _opaque_candidates(opaque: OpaqueRun) -> dict[str, np.ndarray]: 944 """Every opaque estimator under every protocol, keyed the way the equation's rows are.""" 945 return { 946 f"{label} ({OPAQUE_PROTOCOLS[protocol]})": values 947 for label, held in opaque.predictions.items() 948 for protocol, values in held.items() 949 if protocol in OPAQUE_PROTOCOLS 950 } 951 952 953def ranking_baselines( 954 frame: pl.DataFrame, 955 e3: EquationReport, 956 config: Configuration, 957 opaque: OpaqueRun | None = None, 958) -> pl.DataFrame: 959 """The equation's ranking against the trivial rankings, averaged over datasets. 960 961 **Every predictor is scored under the same protocol**, which is what makes the comparison 962 a comparison. The equation appears under IS, LODO, LOMO, and DHO, so the cost of 963 generalisation on this task is visible rather than assumed. The trivial predictors appear 964 under LODO, which is the only way a per-model 965 centre can be computed honestly. 966 967 Both centres are reported for the same reason the error metrics report both: the per-model 968 mean and the per-model median are different orderings, and which is harder to beat is a 969 question rather than an assumption. 970 """ 971 truth = target(frame) 972 datasets = groups(frame, DATASET_COLUMN) 973 models = groups(frame, MODEL_COLUMN) 974 975 candidates: dict[str, np.ndarray] = {} 976 for label, protocol in ( 977 ("equation (in-sample)", "in_sample"), 978 ("equation (loo-dataset)", "loo_dataset"), 979 ("equation (loo-model)", "loo_model"), 980 ): 981 prediction = _e3_predictions(frame, e3, protocol, config) 982 if prediction is not None: 983 candidates[label] = prediction 984 doubly = doubly_held_out_predictions(frame, e3.n_terms, config) 985 if doubly is not None: 986 candidates["equation (loo-cell: both held out)"] = doubly 987 # The trivial predictors cannot be computed under DHO (internally ``loo_cell``) at all: a model 988 # held out of every fold has no rows to average, so "how well does this model usually do" 989 # has no value. They appear under LODO, which is the only way they exist -- and 990 # that hands them the model identity denied to the DHO row of the equation. 991 candidates["per-model mean (loo-dataset)"] = baseline_group_centre(truth, datasets, models, centre="mean") 992 candidates["per-model median (loo-dataset)"] = baseline_group_centre(truth, datasets, models, centre="median") 993 # The opaque opponent under every protocol it has, so each of its rows can be read against 994 # the equation row that was allowed to see the same things. The DHO rows are the 995 # comparison that matters: there neither side has the dataset or the model, and the trivial 996 # baselines cannot be computed at all. 997 if opaque is not None: 998 candidates |= _opaque_candidates(opaque) 999 1000 tables = { 1001 label: ranking_report(truth, prediction, datasets).sort("group") for label, prediction in candidates.items() 1002 } 1003 reference = tables["equation (loo-dataset)"] if "equation (loo-dataset)" in tables else next(iter(tables.values())) 1004 1005 rows: list[dict[str, object]] = [] 1006 for label, table in tables.items(): 1007 row: dict[str, object] = { 1008 "predictor": label, 1009 **{ 1010 column: float(table[column].to_numpy().mean()) 1011 for column in ("ap", "mrr", "hit_at_1", "regret") 1012 if column in table.columns 1013 }, 1014 "datasets": table.height, 1015 } 1016 # The mean over twenty datasets is not the comparison. hit@1 moves only in steps of 1017 # 0.05 on twenty folds, so a single dataset flipping its top pick shifts it by more 1018 # than the gaps being read. The paired test against the reported equation is what 1019 # licenses any claim from this table. 1020 if label != "equation (loo-dataset)" and "ap" in table.columns: 1021 paired = paired_comparison(reference["ap"].to_numpy(), table["ap"].to_numpy()) 1022 row["ap_vs_e3_p"] = paired.p_value 1023 row["ap_vs_e3_significant"] = paired.significant 1024 rows.append(row) 1025 return pl.DataFrame(rows) 1026 1027 1028def decision_baselines( 1029 frame: pl.DataFrame, 1030 config: Configuration, 1031 known: dict[int, CrossValidation] | None = None, 1032 e3: EquationReport | None = None, 1033 opaque: OpaqueRun | None = None, 1034) -> pl.DataFrame: 1035 """The above-or-below-threshold decision, for the equation and both trivial centres. 1036 1037 Every predictor under the same protocol, as in `ranking_baselines`, and the equation under 1038 all four so the cost of generalisation on the decision is measured rather than assumed. 1039 `decision_report` already carries a majority-class column, which is the floor any rule has 1040 to clear; these are the harder comparison. 1041 """ 1042 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 1043 truth = target(frame) 1044 datasets = groups(frame, DATASET_COLUMN) 1045 models = groups(frame, MODEL_COLUMN) 1046 1047 candidates: dict[str, np.ndarray] = {} 1048 if e3 is not None: 1049 for label, protocol in ( 1050 ("equation (in-sample)", "in_sample"), 1051 ("equation (loo-dataset)", "loo_dataset"), 1052 ("equation (loo-model)", "loo_model"), 1053 ): 1054 prediction = _e3_predictions(frame, e3, protocol, config) 1055 if prediction is not None: 1056 candidates[label] = prediction 1057 doubly = doubly_held_out_predictions(frame, e3.n_terms, config) 1058 if doubly is not None: 1059 candidates["equation (loo-cell: both held out)"] = doubly 1060 else: 1061 path = known 1062 if path is None or (e3 is not None and e3.n_terms not in path): 1063 path, _ = _fixed_form_path(columns, truth, datasets, config) 1064 candidates["equation (loo-dataset)"] = path[e3.n_terms if e3 is not None else max(path)].predictions 1065 candidates["per-model mean (loo-dataset)"] = baseline_group_centre(truth, datasets, models, centre="mean") 1066 candidates["per-model median (loo-dataset)"] = baseline_group_centre(truth, datasets, models, centre="median") 1067 if opaque is not None: 1068 candidates |= _opaque_candidates(opaque) 1069 1070 rows: list[dict[str, object]] = [] 1071 for label, prediction in candidates.items(): 1072 for row in decision_report(truth, prediction, datasets).to_dicts(): 1073 rows.append({"predictor": label, **row}) 1074 return pl.DataFrame(rows) 1075 1076 1077@dataclass 1078class Report: 1079 """Everything the experiment produces.""" 1080 1081 e1: EquationReport 1082 e3: EquationReport 1083 e2: EquationReport 1084 #: E3-MAX: the same configuration at the configured capability arity at the raw floor maximum. 1085 #: Reported to show how far the additive form reaches, not as the study's 1086 #: recommendation. See `run_capability`. 1087 e3_capability: EquationReport 1088 #: E3-Valid and E3-MAX side by side, with the rule each used. See `selection_summary`. 1089 grammars: pl.DataFrame 1090 #: Feature-level effects and stability before evidence thresholds are applied. 1091 feature_practices: pl.DataFrame 1092 practices: pl.DataFrame 1093 effects: pl.DataFrame 1094 shares: pl.DataFrame 1095 decomposition: pl.DataFrame 1096 correlations: pl.DataFrame 1097 #: What each raw feature carries on its own, and the three-level ceiling the grammar 1098 #: implies before any search runs. See `analysis.grammar_ceiling`. 1099 reach: pl.DataFrame 1100 ceiling: pl.DataFrame 1101 baselines: pl.DataFrame 1102 comparison: pl.DataFrame 1103 leakage: pl.DataFrame 1104 selection: pl.DataFrame 1105 #: Every length paired against E3-Valid as a sensitivity analysis. 1106 length_choice: pl.DataFrame 1107 oracles: pl.DataFrame 1108 #: How far the equation's own interactions lie along the leading component the 1109 #: oracle ladder finds. Without it the ladder measures a ceiling and says nothing 1110 #: about whether the equation reaches any of it. 1111 interaction: pl.DataFrame 1112 decision: pl.DataFrame 1113 #: The ranking and threshold decisions against the trivial predictors, at both 1114 #: centres. An evaluation without them says how well the equation does and not 1115 #: whether it beats ordering the models by how well they usually do. 1116 ranking_baselines: pl.DataFrame 1117 decision_baselines: pl.DataFrame 1118 #: Standard opaque regressors on the same rows under the same protocols. The priced 1119 #: other side of the study's trade: see `ml_meta_perf.opaque`. 1120 opaque: pl.DataFrame 1121 #: What per-model *identity* adds to the reported path, which is the ceiling on what any 1122 #: model descriptor could reach. See `identity_ceiling`. 1123 identity: pl.DataFrame 1124 #: What an unpenalised least-squares fit over the *whole* library reaches under IS and 1125 #: held out. The control for the selection stage: if this transferred, the beam search 1126 #: and the length rule would be machinery in search of a problem. See 1127 #: `analysis.saturated_fit`. 1128 saturated: pl.DataFrame 1129 #: LODO with E3-Valid's terms re-chosen in every fold, beside the reported fixed form: how 1130 #: much of the transfer number belongs to choosing the form on all rows. See 1131 #: `selection_optimism`. 1132 optimism: pl.DataFrame 1133 1134 1135def run( 1136 path: str | None = None, 1137 *, 1138 config: Configuration | None = None, 1139 selection: SelectionConfig | None = None, 1140 opaque: OpaqueConfig | None = None, 1141 opaque_models: tuple[tuple[str, OpaqueBuilder], ...] | None = None, 1142) -> Report: 1143 """Run the whole study. 1144 1145 ``config``, ``selection`` and ``opaque`` default to ``config/study.json``. **One 1146 configuration serves all three equations**: every table that mentions an equation is 1147 computed from the same object, so an override changes the study consistently. 1148 1149 ``opaque_models`` replaces the estimators the comparison is run against, in the shape 1150 `opaque.evaluate` takes. The opaque side is scikit-learn's and the DHO refit around it is 1151 this project's, so a caller checking the wiring can exercise the second without paying 1152 for the first -- which at 484 refits per estimator is most of what a run costs. 1153 """ 1154 frame = load(path) 1155 config, selection = _resolve(config, selection) 1156 e1 = run_e1(frame, config, selection) 1157 e2 = run_e2(frame, config, selection) 1158 e3 = run_e3(frame, config, selection) 1159 e3_capability = run_capability(frame, config, selection) 1160 config_e3 = config 1161 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 1162 reach, ceiling = reach_analysis(frame, config_e3) 1163 # Fitted once and shared: the folds are the expensive part, and the regression table and 1164 # the two decision comparisons have to be scored from the same predictions or they can 1165 # disagree with each other. 1166 if opaque_models is None: 1167 opaque_models = opaque_estimators(opaque or load_config().opaque) 1168 opaque_run = opaque_evaluate(frame, models=opaque_models) 1169 return Report( 1170 opaque=opaque_run.table, 1171 saturated=pl.DataFrame([saturated_analysis(frame, config_e3)]), 1172 optimism=selection_optimism(frame, e3, config_e3), 1173 e1=e1, 1174 e2=e2, 1175 e3=e3, 1176 e3_capability=e3_capability, 1177 grammars=selection_summary(e3, e3_capability, config, selection), 1178 feature_practices=feature_practices(e3.equation, columns, e3.stability), 1179 practices=best_practices(e3.equation, columns, e3.stability), 1180 effects=term_effects(e3.equation, columns, DATASET_FEATURES, MODEL_FEATURES), 1181 shares=group_shares(e3.equation, columns, DATASET_FEATURES, MODEL_FEATURES), 1182 decomposition=variance_decomposition(target(frame), groups(frame, DATASET_COLUMN), groups(frame, MODEL_COLUMN)), 1183 correlations=correlation_analysis(frame, config_e3), 1184 reach=reach, 1185 ceiling=ceiling, 1186 baselines=baselines(frame), 1187 comparison=comparison(frame, e1, e3, e2, e3_capability), 1188 leakage=leakage_demonstration(frame, e3.n_terms, config_e3, e3.paths), 1189 selection=model_selection(frame, e3, config_e3, "loo_cell"), 1190 decision=decision_quality(frame, e3.n_terms, config_e3, e3.paths.get("loo_dataset")), 1191 ranking_baselines=ranking_baselines(frame, e3, config_e3, opaque_run), 1192 decision_baselines=decision_baselines(frame, config_e3, e3.paths.get("loo_dataset"), e3, opaque_run), 1193 length_choice=length_comparison(frame, e3, config_e3), 1194 oracles=oracle_ladder(target(frame), groups(frame, DATASET_COLUMN), groups(frame, MODEL_COLUMN)), 1195 interaction=interaction_reached(frame, e3), 1196 identity=identity_ceiling(frame, e3), 1197 )
80@dataclass 81class EquationReport: 82 """One equation together with everything said about it.""" 83 84 equation: Equation 85 #: The selected length: `selection.plateau_knee` for E1, E2 and E3-Valid, the raw floor 86 #: maximum for E3-MAX. Every output reads the result from here. 87 n_terms: int 88 #: The grammar this equation was searched under -- the configuration's for E1, E2 and 89 #: E3-Valid, ``selection.capability_arity`` for E3-MAX. 90 arity: int 91 in_sample: dict[str, float | int] 92 curve: pl.DataFrame 93 cross_validated: dict[str, dict[str, float | int]] = field(default_factory=dict) 94 stability: pl.DataFrame | None = None 95 #: The full cross-validated path per protocol, kept so later tables can reuse it. 96 #: ``leakage_demonstration`` and ``decision_quality`` ask for the same folds at the 97 #: same settings, and recomputing them was a third of the study's runtime. 98 paths: dict[str, dict[int, CrossValidation]] = field(default_factory=dict)
One equation together with everything said about it.
167@dataclass(frozen=True) 168class FittedPath: 169 """Everything `run_equation` computes before a length is chosen. 170 171 Searching and cross-validating every length is the expensive half of an equation; choosing 172 one length and finalising it is cheap. Keeping the two apart means a length rule only ever 173 reads a curve that was computed once, and lets `ml_meta_perf.sweep` score hundreds of 174 configurations without finalising any of them. 175 """ 176 177 library: Library 178 columns: dict[str, np.ndarray] 179 truth: np.ndarray 180 datasets: np.ndarray 181 equations: dict[int, Equation] 182 paths: dict[str, dict[int, CrossValidation]] 183 curve: pl.DataFrame 184 config: Configuration
Everything run_equation computes before a length is chosen.
Searching and cross-validating every length is the expensive half of an equation; choosing
one length and finalising it is cheap. Keeping the two apart means a length rule only ever
reads a curve that was computed once, and lets ml_meta_perf.sweep score hundreds of
configurations without finalising any of them.
187def fit_path( 188 frame: pl.DataFrame, 189 dataset_features: tuple[str, ...], 190 model_features: tuple[str, ...], 191 config: Configuration, 192 name: str, 193) -> FittedPath: 194 """Search one grammar and cross-validate every length on it, choosing none of them.""" 195 columns = columns_as_arrays(frame, dataset_features + model_features) 196 truth = target(frame) 197 datasets = groups(frame, DATASET_COLUMN) 198 models = groups(frame, MODEL_COLUMN) 199 200 library = build_library( 201 dataset_features, 202 model_features, 203 columns, 204 max_arity=config.max_arity, 205 max_abs_zscore=config.max_abs_zscore, 206 ) 207 result = search( 208 library, 209 truth, 210 max_terms=config.max_terms, 211 penalty=config.penalty, 212 pool_size=config.pool_size, 213 beam_width=config.beam_width, 214 name=name, 215 ) 216 # Fixed form: the terms are chosen once, here, and only the weights are refit in each 217 # fold. See `validate.cross_validate_fixed_form` for why that is the reported protocol. 218 paths = { 219 label: cross_validate_fixed_form(library, columns, truth, labels, result.equations, penalty=config.penalty) 220 for label, labels in (("loo_dataset", datasets), ("loo_model", models)) 221 } 222 # DHO at **every** length, not just the selected one: every selection rule reads the 223 # complete curve, and E3-MAX selects on this protocol through `selection.floor_curve`. 224 paths["loo_cell"] = cross_validate_doubly_held_out( 225 library, columns, truth, datasets, models, result.equations, penalty=config.penalty 226 ) 227 in_sample = {k: score(truth, eq.predict(columns)) for k, eq in result.equations.items()} 228 curve = _curve(tuple(sorted(result.equations)), in_sample, paths, truth) 229 return FittedPath(library, columns, truth, datasets, result.equations, paths, curve, config)
Search one grammar and cross-validate every length on it, choosing none of them.
232def finalise(fitted: FittedPath, size: int) -> EquationReport: 233 """Publish one length of a fitted path: prune it, and measure how stable its form is.""" 234 if size not in fitted.equations: 235 raise ValueError(f"selected equation length {size} is unavailable") 236 config, truth = fitted.config, fitted.truth 237 # The same folds with selection re-run inside them, kept only for `stability`: the share 238 # of the equation's terms that survive when one dataset is removed. That is what 239 # licenses fixing the form, and it is not part of any reported score. 240 reselected = fold_selections( 241 fitted.library, 242 truth, 243 fitted.datasets, 244 n_terms=size, 245 penalty=config.penalty, 246 pool_size=config.pool_size, 247 beam_width=config.beam_width, 248 ) 249 equation = prune(fitted.equations[size], fitted.columns, truth, penalty=config.penalty) 250 251 return EquationReport( 252 equation=equation, 253 n_terms=size, 254 arity=config.max_arity, 255 in_sample=score(truth, equation.predict(fitted.columns)).as_dict(), 256 curve=fitted.curve, 257 cross_validated={ 258 label: path[size].scores(truth).as_dict() | path[size].dispersion() for label, path in fitted.paths.items() 259 }, 260 stability=term_stability(reselected, tuple(term.name for term in equation.terms)), 261 paths=fitted.paths, 262 )
Publish one length of a fitted path: prune it, and measure how stable its form is.
273def select_length(curve: pl.DataFrame, selection: SelectionConfig) -> int: 274 """The length rule every published equation shares: `selection.plateau_knee`.""" 275 return plateau_knee(curve, delta=selection.delta, window=selection.window, smoothing=selection.smoothing)
The length rule every published equation shares: selection.plateau_knee.
278def run_equation( 279 frame: pl.DataFrame, 280 dataset_features: tuple[str, ...], 281 model_features: tuple[str, ...], 282 config: Configuration, 283 selection: SelectionConfig, 284 name: str, 285) -> EquationReport: 286 """Fit one equation and validate it. The three equations differ **only** in the 287 features they may draw on, and this is the single code path that says so. 288 289 Every one of them is fitted on all 484 rows and scored on all 484 rows, under all four 290 protocols, and has its length chosen by the same rule. That uniformity is the point: the 291 gaps between E1, E2 and E3 are only evidence about what each half of the meta-data is 292 worth if nothing else differs between them -- not the fitting scale, not the protocol, 293 not the denominator of the R2, not the way the length was picked. 294 295 **The length is derived, not asserted**: `select_length` reads it off the complete 296 cross-validated curve. 297 """ 298 fitted = fit_path(frame, dataset_features, model_features, config, name) 299 return finalise(fitted, select_length(fitted.curve, selection))
Fit one equation and validate it. The three equations differ only in the features they may draw on, and this is the single code path that says so.
Every one of them is fitted on all 484 rows and scored on all 484 rows, under all four protocols, and has its length chosen by the same rule. That uniformity is the point: the gaps between E1, E2 and E3 are only evidence about what each half of the meta-data is worth if nothing else differs between them -- not the fitting scale, not the protocol, not the denominator of the R2, not the way the length was picked.
The length is derived, not asserted: select_length reads it off the complete
cross-validated curve.
302def run_e1( 303 frame: pl.DataFrame, config: Configuration | None = None, selection: SelectionConfig | None = None 304) -> EquationReport: 305 """Dataset features only -- how much of MCC the data alone explains. 306 307 Every model on a given dataset shares one feature vector, so this equation can only 308 ever predict a per-dataset constant. That is not a flaw to be corrected, it is the 309 control: whatever E3 reaches beyond this is what knowing the model buys. 310 311 Least squares finds that per-dataset constant on its own, so aggregating the rows to 312 twenty dataset means first would be unnecessary as well as harmful to the comparison. 313 """ 314 config, selection = _resolve(config, selection) 315 return run_equation(frame, DATASET_FEATURES, (), config, selection, "E1")
Dataset features only -- how much of MCC the data alone explains.
Every model on a given dataset shares one feature vector, so this equation can only ever predict a per-dataset constant. That is not a flaw to be corrected, it is the control: whatever E3 reaches beyond this is what knowing the model buys.
Least squares finds that per-dataset constant on its own, so aggregating the rows to twenty dataset means first would be unnecessary as well as harmful to the comparison.
318def run_e2( 319 frame: pl.DataFrame, config: Configuration | None = None, selection: SelectionConfig | None = None 320) -> EquationReport: 321 """Model features only -- the mirror image of E1. 322 323 There are six model features and five of them are constant per model, so the library 324 is tiny and the equation is short by necessity rather than by choice. That is itself 325 the finding: the meta-data describes datasets far better than it describes models. 326 327 Aggregating this one to 25 per-model means -- the mirror of aggregating E1 -- was 328 measured and is worse. `Processing Units Number` is intentionally allowed to vary with 329 the dataset because it is a capacity calculation over that dataset's shape. Averaging 330 it away discards real variation, and the optimum collapses to a single term. 331 """ 332 config, selection = _resolve(config, selection) 333 return run_equation(frame, (), MODEL_FEATURES, config, selection, "E2")
Model features only -- the mirror image of E1.
There are six model features and five of them are constant per model, so the library is tiny and the equation is short by necessity rather than by choice. That is itself the finding: the meta-data describes datasets far better than it describes models.
Aggregating this one to 25 per-model means -- the mirror of aggregating E1 -- was
measured and is worse. Processing Units Number is intentionally allowed to vary with
the dataset because it is a capacity calculation over that dataset's shape. Averaging
it away discards real variation, and the optimum collapses to a single term.
336def run_e3( 337 frame: pl.DataFrame, config: Configuration | None = None, selection: SelectionConfig | None = None 338) -> EquationReport: 339 """Dataset and model features: E3-Valid, the equation the study publishes. 340 341 Every dataset feature and every model feature is in the term pool; the configuration 342 restricts the search only through its hyperparameters. 343 """ 344 config, selection = _resolve(config, selection) 345 return run_equation(frame, DATASET_FEATURES, MODEL_FEATURES, config, selection, "E3")
Dataset and model features: E3-Valid, the equation the study publishes.
Every dataset feature and every model feature is in the term pool; the configuration restricts the search only through its hyperparameters.
348def run_capability( 349 frame: pl.DataFrame, config: Configuration | None = None, selection: SelectionConfig | None = None 350) -> EquationReport: 351 """E3-MAX: E3's configuration at the configured capability arity, at the raw maximum of its floor. 352 353 **A capability measurement, not a recommendation.** It answers the question E3-Valid cannot 354 answer about itself -- whether the additive form is out of reach or merely out of the 355 readable range -- so it takes `selection.floor_argmax` rather than the length rule, with 356 no smoothing and no complexity penalty. Only the arity differs from E3-Valid. 357 """ 358 config, selection = _resolve(config, selection) 359 wider = dataclasses.replace(config, max_arity=selection.capability_arity) 360 fitted = fit_path(frame, DATASET_FEATURES, MODEL_FEATURES, wider, "E3-MAX") 361 return finalise(fitted, floor_argmax(fitted.curve))
E3-MAX: E3's configuration at the configured capability arity, at the raw maximum of its floor.
A capability measurement, not a recommendation. It answers the question E3-Valid cannot
answer about itself -- whether the additive form is out of reach or merely out of the
readable range -- so it takes selection.floor_argmax rather than the length rule, with
no smoothing and no complexity penalty. Only the arity differs from E3-Valid.
364def selection_summary( 365 e3: EquationReport, capability: EquationReport, config: Configuration, selection: SelectionConfig 366) -> pl.DataFrame: 367 """E3-Valid and E3-MAX side by side: the rule each used and where it landed. 368 369 ``smoothed_floor`` is the quantity the length rule reads; ``floor`` is the raw worst 370 protocol at that length; ``at_horizon`` flags a bound that stopped at ``max_terms`` rather 371 than at a maximum of the data. 372 """ 373 rows: list[dict[str, object]] = [] 374 for role, report, rule in ( 375 ("E3-Valid", e3, "first plateau knee"), 376 ("E3-MAX", capability, "raw floor maximum"), 377 ): 378 curve = report.curve 379 position = list(curve["n_terms"]).index(report.n_terms) 380 rows.append( 381 { 382 "role": role, 383 "rule": rule, 384 "arity": report.arity, 385 "n_terms": report.n_terms, 386 "complexity": complexity(report.arity, report.n_terms), 387 "r2_in_sample": float(curve["r2_in_sample"][position]), 388 "r2_loo_dataset": float(curve["r2_loo_dataset"][position]), 389 "r2_loo_model": float(curve["r2_loo_model"][position]), 390 "r2_loo_cell": float(curve["r2_loo_cell"][position]), 391 "floor": float(floor_curve(curve)[position]), 392 "smoothed_floor": float(smoothed(floor_curve(curve), selection.smoothing)[position]), 393 "spread": float(protocol_spread(curve)[position]), 394 "at_horizon": report.n_terms == config.max_terms, 395 } 396 ) 397 return pl.DataFrame(rows)
E3-Valid and E3-MAX side by side: the rule each used and where it landed.
smoothed_floor is the quantity the length rule reads; floor is the raw worst
protocol at that length; at_horizon flags a bound that stopped at max_terms rather
than at a maximum of the data.
400def reach_analysis(frame: pl.DataFrame, config: Configuration) -> tuple[pl.DataFrame, pl.DataFrame]: 401 """What each raw feature is worth, and how far the grammar reaches before any search. 402 403 Returns the per-feature table and the three-level ceiling ladder as a one-row frame. 404 Both are computed from the library alone, so they are available *before* an equation 405 exists and can be read as an expectation the fitted equation is then held against. 406 """ 407 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 408 library = build_library( 409 DATASET_FEATURES, 410 MODEL_FEATURES, 411 columns, 412 max_arity=config.max_arity, 413 max_abs_zscore=config.max_abs_zscore, 414 ) 415 truth = target(frame) 416 features = DATASET_FEATURES + MODEL_FEATURES 417 ladder = grammar_ceiling(library, truth, features) 418 return feature_reach(library, truth, features), pl.DataFrame([ladder])
What each raw feature is worth, and how far the grammar reaches before any search.
Returns the per-feature table and the three-level ceiling ladder as a one-row frame. Both are computed from the library alone, so they are available before an equation exists and can be read as an expectation the fitted equation is then held against.
421def saturated_analysis(frame: pl.DataFrame, config: Configuration) -> dict[str, float]: 422 """`analysis.saturated_fit` over the library E3 actually searches. 423 424 The library rather than the screened pool, and the E3 grammar rather than the capability 425 one, because the claim it supports is about the procedure the study publishes: handing 426 *these* candidates to least squares in one go is what selection is being compared with. 427 """ 428 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 429 library = build_library( 430 DATASET_FEATURES, 431 MODEL_FEATURES, 432 columns, 433 max_arity=config.max_arity, 434 max_abs_zscore=config.max_abs_zscore, 435 ) 436 return saturated_fit(library, target(frame), groups(frame, DATASET_COLUMN))
analysis.saturated_fit over the library E3 actually searches.
The library rather than the screened pool, and the E3 grammar rather than the capability one, because the claim it supports is about the procedure the study publishes: handing these candidates to least squares in one go is what selection is being compared with.
439def correlation_analysis(frame: pl.DataFrame, config: Configuration, top: int = 15) -> pl.DataFrame: 440 """Rank candidate terms by how they relate to MCC, linearly and monotonically. 441 442 Both correlations are reported because they say different things and the difference 443 is what guides term design. A term whose Pearson and Spearman correlations agree is 444 linear in MCC and needs no transform. A term whose Spearman correlation is clearly 445 the larger is monotone but curved, which is the signal that a log, an inverse or a 446 ratio will pay for itself. The ``within_pearson`` column is the strict one: both the 447 term and the target are centred inside each dataset first, so a term is credited 448 only for variation that dataset identity does not already account for. 449 """ 450 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 451 library = build_library( 452 DATASET_FEATURES, 453 MODEL_FEATURES, 454 columns, 455 max_arity=config.max_arity, 456 max_abs_zscore=config.max_abs_zscore, 457 ) 458 table = screen(library, target(frame), groups(frame, DATASET_COLUMN)) 459 return table.with_columns((pl.col("spearman").abs() - pl.col("pearson").abs()).alias("monotone_gap")).head(top)
Rank candidate terms by how they relate to MCC, linearly and monotonically.
Both correlations are reported because they say different things and the difference
is what guides term design. A term whose Pearson and Spearman correlations agree is
linear in MCC and needs no transform. A term whose Spearman correlation is clearly
the larger is monotone but curved, which is the signal that a log, an inverse or a
ratio will pay for itself. The within_pearson column is the strict one: both the
term and the target are centred inside each dataset first, so a term is credited
only for variation that dataset identity does not already account for.
462def baselines(frame: pl.DataFrame) -> pl.DataFrame: 463 """What the equations have to beat, and the ceiling neither of them can pass. 464 465 Every trivial predictor is reported at both its mean and its median, because the metrics 466 disagree about which is the honest opponent. R2 and RMSE are squared-error metrics and the 467 mean minimises squared error; MAE is minimised by the median, and SMAPE is an 468 absolute-error ratio that behaves the same way. Quoting the equation's MAE against a 469 *mean* baseline compares it with a predictor that is not minimising the metric it is being 470 judged on, which flatters the equation. Both rows are here so each metric can be read 471 against whichever centre is hardest to beat on it. 472 """ 473 truth = target(frame) 474 datasets = groups(frame, DATASET_COLUMN) 475 models = groups(frame, MODEL_COLUMN) 476 477 rows: list[dict[str, object]] = [] 478 for centre in ("mean", "median"): 479 for label, outer, inner in ( 480 (f"global {centre} (loo-dataset)", datasets, None), 481 (f"per-model {centre} (loo-dataset)", datasets, models), 482 (f"global {centre} (loo-model)", models, None), 483 (f"per-dataset {centre} (loo-model)", models, datasets), 484 ): 485 prediction = baseline_group_centre(truth, outer, inner, centre=centre) 486 rows.append({"baseline": label, **score(truth, prediction).as_dict()}) 487 rows.append( 488 { 489 "baseline": "additive mean-based reference (in-sample)", 490 **score(truth, additive_mean_reference(truth, datasets, models)).as_dict(), 491 } 492 ) 493 return pl.DataFrame(rows)
What the equations have to beat, and the ceiling neither of them can pass.
Every trivial predictor is reported at both its mean and its median, because the metrics disagree about which is the honest opponent. R2 and RMSE are squared-error metrics and the mean minimises squared error; MAE is minimised by the median, and SMAPE is an absolute-error ratio that behaves the same way. Quoting the equation's MAE against a mean baseline compares it with a predictor that is not minimising the metric it is being judged on, which flatters the equation. Both rows are here so each metric can be read against whichever centre is hardest to beat on it.
496def leakage_demonstration( 497 frame: pl.DataFrame, 498 n_terms: int, 499 config: Configuration, 500 known: dict[str, dict[int, CrossValidation]] | None = None, 501) -> pl.DataFrame: 502 """The same equation scored under a random split and under a grouped split. 503 504 Dataset features are constant across a dataset's rows, so a random k-fold split puts 505 the same dataset on both sides of the fold. The equation then recognises the dataset 506 rather than generalising to it. This table exists so the difference is visible in 507 numbers rather than asserted in prose. 508 509 ``known`` supplies grouped paths that `run_e3` has already computed. They are the same 510 folds over the same library at the same settings, and beam search records its best 511 subset at every size as it goes, so a path built to ``max_terms`` contains the shorter 512 entry this table needs and does not have to be recomputed. 513 """ 514 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 515 truth = target(frame) 516 protocols = { 517 "random 10-fold (leaky)": ("random", random_kfold_groups(truth.shape[0])), 518 "leave-one-dataset-out": ("loo_dataset", groups(frame, DATASET_COLUMN)), 519 "leave-one-model-out": ("loo_model", groups(frame, MODEL_COLUMN)), 520 } 521 library: Library | None = None 522 rows: list[dict[str, object]] = [] 523 for label, (key, labels) in protocols.items(): 524 path = (known or {}).get(key) 525 if path is None or n_terms not in path: 526 path, library = _fixed_form_path(columns, truth, labels, config, library) 527 size = n_terms if n_terms in path else max(path) 528 rows.append({"protocol": label, **path[size].scores(truth).as_dict()}) 529 return pl.DataFrame(rows)
The same equation scored under a random split and under a grouped split.
Dataset features are constant across a dataset's rows, so a random k-fold split puts the same dataset on both sides of the fold. The equation then recognises the dataset rather than generalising to it. This table exists so the difference is visible in numbers rather than asserted in prose.
known supplies grouped paths that run_e3 has already computed. They are the same
folds over the same library at the same settings, and beam search records its best
subset at every size as it goes, so a path built to max_terms contains the shorter
entry this table needs and does not have to be recomputed.
532def selection_optimism(frame: pl.DataFrame, e3: EquationReport, config: Configuration) -> pl.DataFrame: 533 """LODO with the terms **re-chosen inside every fold**, beside the reported fixed-form LODO. 534 535 The reported protocol fixes the form -- chosen once, on all rows -- and refits only the 536 weights per fold (`validate.cross_validate_fixed_form`). That is a position about what the 537 equation is, and it has a price: the form was chosen with the held-out dataset in view. This 538 measures the price, by repeating the selection itself without the held-out dataset, at 539 E3-Valid's length and configuration, and scoring those predictions. 540 541 **A diagnostic, not a score.** Twenty folds fit twenty different equations, so the nested 542 row describes the discovery procedure rather than the published equation; it is reported 543 so the fixed-form transfer numbers are read as the upper estimate they are. 544 """ 545 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 546 truth, datasets = target(frame), groups(frame, DATASET_COLUMN) 547 library = build_library( 548 DATASET_FEATURES, MODEL_FEATURES, columns, max_arity=config.max_arity, max_abs_zscore=config.max_abs_zscore 549 ) 550 position = {name: index for index, name in enumerate(library.names)} 551 nested = np.zeros_like(truth) 552 for _, train, test in leave_one_group_out(datasets): 553 result = search( 554 library_rows(library, train), 555 truth[train], 556 max_terms=e3.n_terms, 557 penalty=config.penalty, 558 pool_size=config.pool_size, 559 beam_width=config.beam_width, 560 ) 561 equation = result.equations[max(size for size in result.equations if size <= e3.n_terms)] 562 held = library.matrix[test][:, [position[term.name] for term in equation.terms]] 563 nested[test] = np.clip( 564 equation.intercept + held @ np.asarray(equation.weights), truth[train].min(), truth[train].max() 565 ) 566 fixed = e3.paths["loo_dataset"][e3.n_terms].predictions 567 return pl.DataFrame( 568 [ 569 {"LODO": "form chosen once, on all rows (reported)", **score(truth, fixed).as_dict()}, 570 {"LODO": "form re-chosen inside every fold", **score(truth, nested).as_dict()}, 571 ] 572 )
LODO with the terms re-chosen inside every fold, beside the reported fixed-form LODO.
The reported protocol fixes the form -- chosen once, on all rows -- and refits only the
weights per fold (validate.cross_validate_fixed_form). That is a position about what the
equation is, and it has a price: the form was chosen with the held-out dataset in view. This
measures the price, by repeating the selection itself without the held-out dataset, at
E3-Valid's length and configuration, and scoring those predictions.
A diagnostic, not a score. Twenty folds fit twenty different equations, so the nested row describes the discovery procedure rather than the published equation; it is reported so the fixed-form transfer numbers are read as the upper estimate they are.
575def identity_ceiling(frame: pl.DataFrame, e3: EquationReport) -> pl.DataFrame: 576 """The upper bound on what any model descriptor could add, measured rather than argued. 577 578 Under LODO **every one of the 25 models appears in every training fold**, 579 so the equation's residual can be averaged per model on the training rows and applied to 580 the held-out dataset with no leak. That replaces the model descriptors with the best 581 possible substitute -- the model's *identity*, fitted freely -- and what it adds is 582 therefore a ceiling on what any descriptor set could reach by telling these classifiers 583 apart. It is the number the study's central negative result is measured against: every 584 rejected model-side encoding is rejected for failing to recover it. 585 586 Two rungs, because the ceiling depends on what the identity is allowed to carry. A level 587 per model shifts every one of its rows equally; a level and a slope on a dataset feature 588 lets a model's advantage depend on the data, which is what a *capability* descriptor would 589 have to do. `identity.correct_out_of_fold` reuses the per-fold equations the reported path 590 already recorded, so the protocol is unchanged and no search is repeated. 591 592 Generated because the chapter's hand-written copy of this table had all three rows wrong 593 and listed its two rungs as identical, which no run of this function can produce. 594 """ 595 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 596 truth = target(frame) 597 datasets, models = groups(frame, DATASET_COLUMN), groups(frame, MODEL_COLUMN) 598 path = e3.paths.get("loo_dataset") 599 size = len(e3.equation.terms) 600 if path is None or size not in path: 601 return pl.DataFrame(schema={"correction": pl.String, "r2_loo_dataset": pl.Float64, "mae": pl.Float64}) 602 fold = path[size] 603 rungs = { 604 f"none (E3-Valid, {size} terms)": fold.predictions, 605 "per-model level": correct_out_of_fold(fold, columns, truth, datasets, models), 606 "per-model level and slope": correct_out_of_fold( 607 fold, columns, truth, datasets, models, features=DATASET_FEATURES 608 ), 609 } 610 611 def per_dataset_mae(prediction: np.ndarray) -> np.ndarray: 612 return np.array([mae(truth[test], prediction[test]) for _, _, test in leave_one_group_out(datasets)]) 613 614 # **Whether the gap is real is a paired question, not a difference of two pooled numbers.** 615 # That distinction has produced three wrong conclusions on this project, and this gap is 616 # the one the study's central negative result is measured against -- so each rung is paired 617 # against the uncorrected equation over the twenty held-out datasets, on per-dataset MAE. 618 baseline = per_dataset_mae(fold.predictions) 619 rows: list[dict[str, object]] = [] 620 for label, values in rungs.items(): 621 row: dict[str, object] = { 622 "correction": label, 623 "r2_loo_dataset": r2_score(truth, values), 624 "mae": mae(truth, values), 625 } 626 if label.startswith("none"): 627 row |= { 628 "gain": 0.0, 629 "ci_low": float("nan"), 630 "ci_high": float("nan"), 631 "sign_p": float("nan"), 632 "wins": 0, 633 "verdict": "baseline", 634 } 635 else: 636 paired = paired_comparison(per_dataset_mae(values), baseline, lower_is_better=True) 637 row |= { 638 "gain": paired.mean, 639 "ci_low": paired.low, 640 "ci_high": paired.high, 641 "sign_p": paired.p_value, 642 "wins": paired.wins, 643 "verdict": "real" if paired.significant else "tie", 644 } 645 rows.append(row) 646 return pl.DataFrame(rows)
The upper bound on what any model descriptor could add, measured rather than argued.
Under LODO every one of the 25 models appears in every training fold, so the equation's residual can be averaged per model on the training rows and applied to the held-out dataset with no leak. That replaces the model descriptors with the best possible substitute -- the model's identity, fitted freely -- and what it adds is therefore a ceiling on what any descriptor set could reach by telling these classifiers apart. It is the number the study's central negative result is measured against: every rejected model-side encoding is rejected for failing to recover it.
Two rungs, because the ceiling depends on what the identity is allowed to carry. A level
per model shifts every one of its rows equally; a level and a slope on a dataset feature
lets a model's advantage depend on the data, which is what a capability descriptor would
have to do. identity.correct_out_of_fold reuses the per-fold equations the reported path
already recorded, so the protocol is unchanged and no search is repeated.
Generated because the chapter's hand-written copy of this table had all three rows wrong and listed its two rungs as identical, which no run of this function can produce.
649def interaction_reached(frame: pl.DataFrame, e3: EquationReport) -> pl.DataFrame: 650 """How much of the leading interaction pattern the fitted equation actually reaches. 651 652 The oracle ladder prices interaction components; this says whether E3 gets any of 653 them. Reported under both the fit and the held-out protocol, because an equation can 654 align with a pattern under IS and lose it out of fold -- and that difference is the 655 whole question for a component nobody can predict from meta-features. 656 """ 657 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 658 truth = target(frame) 659 datasets, models = groups(frame, DATASET_COLUMN), groups(frame, MODEL_COLUMN) 660 size = len(e3.equation.terms) 661 predictions = {"in-sample": e3.equation.predict(columns)} 662 path = e3.paths.get("loo_dataset") 663 if path is not None and size in path: 664 predictions["leave-one-dataset-out"] = path[size].predictions 665 rows = [ 666 {"protocol": label, "rank": rank, **interaction_capture(truth, prediction, datasets, models, rank=rank)} 667 for label, prediction in predictions.items() 668 for rank in (1, 2) 669 ] 670 return pl.DataFrame(rows)
How much of the leading interaction pattern the fitted equation actually reaches.
The oracle ladder prices interaction components; this says whether E3 gets any of them. Reported under both the fit and the held-out protocol, because an equation can align with a pattern under IS and lose it out of fold -- and that difference is the whole question for a component nobody can predict from meta-features.
673def comparison( 674 frame: pl.DataFrame, 675 e1: EquationReport, 676 e3: EquationReport, 677 e2: EquationReport | None = None, 678 e3_capability: EquationReport | None = None, 679) -> pl.DataFrame: 680 """E1 against E3 on the one scale where they are comparable: all rows. 681 682 All three equations are already fitted and scored on the same 484 rows, so this table 683 is not correcting a denominator -- an earlier version of E1 was fitted on 20 aggregated 684 dataset means and it was. What it adds is the two *ceilings* beside the equations: the 685 true per-dataset and per-model means, which are the most any equation restricted to 686 that half of the meta-data could reach. Without them a reader can compare the raw E1 and 687 E3 scores and conclude the dataset side is weak, even when E1 is already close to its 688 own structural ceiling. 689 """ 690 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 691 truth = target(frame) 692 datasets = groups(frame, DATASET_COLUMN) 693 models = groups(frame, MODEL_COLUMN) 694 695 dataset_ceiling = np.zeros_like(truth) 696 for label in np.unique(datasets): 697 mask = datasets == label 698 dataset_ceiling[mask] = truth[mask].mean() 699 model_ceiling = np.zeros_like(truth) 700 for label in np.unique(models): 701 mask = models == label 702 model_ceiling[mask] = truth[mask].mean() 703 if e2 is None: 704 e2 = run_e2(frame) 705 706 return pl.DataFrame( 707 [ 708 { 709 "equation": f"E1, dataset only ({e1.equation.n_terms} terms)", 710 "n_terms": e1.equation.n_terms, 711 **score(truth, e1.equation.predict(columns)).as_dict(), 712 }, 713 { 714 "equation": "E1 reference: true dataset means", 715 "n_terms": None, 716 **score(truth, dataset_ceiling).as_dict(), 717 }, 718 { 719 "equation": f"E2, model only ({e2.equation.n_terms} terms)", 720 "n_terms": e2.equation.n_terms, 721 **score(truth, e2.equation.predict(columns)).as_dict(), 722 }, 723 { 724 "equation": "E2 reference: true model means", 725 "n_terms": None, 726 **score(truth, model_ceiling).as_dict(), 727 }, 728 { 729 "equation": f"E3-Valid, dataset + model ({e3.equation.n_terms} terms)", 730 "n_terms": e3.equation.n_terms, 731 **score(truth, e3.equation.predict(columns)).as_dict(), 732 }, 733 *( 734 [ 735 { 736 "equation": ( 737 f"E3-MAX, arity " 738 f"{max((len(term.features) for term in e3_capability.equation.terms), default=0)}" 739 f" ({len(e3_capability.equation.terms)} terms)" 740 ), 741 "n_terms": len(e3_capability.equation.terms), 742 **score(truth, e3_capability.equation.predict(columns)).as_dict(), 743 } 744 ] 745 if e3_capability is not None and e3_capability is not e3 746 else [] 747 ), 748 { 749 "equation": "reference: additive mean-based reference", 750 "n_terms": None, 751 **score(truth, additive_mean_reference(truth, datasets, models)).as_dict(), 752 }, 753 ] 754 )
E1 against E3 on the one scale where they are comparable: all rows.
All three equations are already fitted and scored on the same 484 rows, so this table is not correcting a denominator -- an earlier version of E1 was fitted on 20 aggregated dataset means and it was. What it adds is the two ceilings beside the equations: the true per-dataset and per-model means, which are the most any equation restricted to that half of the meta-data could reach. Without them a reader can compare the raw E1 and E3 scores and conclude the dataset side is weak, even when E1 is already close to its own structural ceiling.
757def decision_quality( 758 frame: pl.DataFrame, 759 n_terms: int, 760 config: Configuration, 761 known: dict[int, CrossValidation] | None = None, 762) -> pl.DataFrame: 763 """Go/no-go decision quality, with both the dataset and the model held out. 764 765 Same requirement as `model_selection` and for the same reason: the question is asked about 766 a pair nobody has run, so neither half of it may be in the training set. ``known`` is 767 `run_e3`'s LODO path and is the fallback when the DHO fit 768 cannot be built. 769 """ 770 truth = target(frame) 771 datasets = groups(frame, DATASET_COLUMN) 772 doubly = doubly_held_out_predictions(frame, n_terms, config) 773 if doubly is not None: 774 return decision_report(truth, doubly, datasets) 775 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 776 path = known 777 if path is None or n_terms not in path: 778 path, _ = _fixed_form_path(columns, truth, datasets, config) 779 size = n_terms if n_terms in path else max(path) 780 return decision_report(truth, path[size].predictions, datasets)
Go/no-go decision quality, with both the dataset and the model held out.
Same requirement as model_selection and for the same reason: the question is asked about
a pair nobody has run, so neither half of it may be in the training set. known is
run_e3's LODO path and is the fallback when the DHO fit
cannot be built.
783def length_comparison(frame: pl.DataFrame, e3: EquationReport, config: Configuration) -> pl.DataFrame: 784 """Every equation length paired against the E3-Valid selection, per held-out dataset. 785 786 E3-Valid is selected before this diagnostic is computed. The table measures how each 787 length differs from that retained equation; it does not define another selection rule. 788 789 Per-dataset **MAE** rather than per-dataset R2, because three datasets here have almost no 790 within-dataset variance -- `5G_Slicing`'s 25 models all score about 0.986 -- and an R2 791 over a near-constant target is dominated by its denominator. See 792 `validate.CrossValidation.dispersion`. 793 794 A row marked ``worse`` differs significantly from E3-Valid under this paired comparison. 795 """ 796 truth = target(frame) 797 datasets = groups(frame, DATASET_COLUMN) 798 labels = np.unique(datasets) 799 path = e3.paths.get("loo_dataset", {}) 800 if not path: 801 return pl.DataFrame() 802 # Referenced to what the rule chose, not to a length passed in: the table has to be able 803 # to say that the rule's own pick is the right one, which it cannot do if the pick is the 804 # thing being assumed. 805 chosen = e3.n_terms 806 if chosen not in path: 807 chosen = max(path) 808 if chosen not in path: 809 return pl.DataFrame() 810 published = chosen 811 812 def per_fold(size: int) -> np.ndarray: 813 prediction = path[size].predictions 814 return np.array([mae(truth[datasets == label], prediction[datasets == label]) for label in labels]) 815 816 reference = per_fold(published) 817 rows: list[dict[str, object]] = [] 818 for size in sorted(path): 819 result = paired_comparison(reference, per_fold(size), lower_is_better=True) 820 # `mean` is positive when the first argument -- the published equation -- is better. 821 verdict = ( 822 "selected" 823 if size == published 824 else ("worse" if result.significant and result.mean > 0 else ("better" if result.significant else "tie")) 825 ) 826 rows.append( 827 { 828 "n_terms": size, 829 "r2_loo_dataset": path[size].scores(truth).r2, 830 "mae_loo_dataset": float(per_fold(size).mean()), 831 "mean_difference": result.mean, 832 "p_value": result.p_value, 833 "ci_low": result.low, 834 "ci_high": result.high, 835 "verdict": verdict, 836 } 837 ) 838 return pl.DataFrame(rows)
Every equation length paired against the E3-Valid selection, per held-out dataset.
E3-Valid is selected before this diagnostic is computed. The table measures how each length differs from that retained equation; it does not define another selection rule.
Per-dataset MAE rather than per-dataset R2, because three datasets here have almost no
within-dataset variance -- 5G_Slicing's 25 models all score about 0.986 -- and an R2
over a near-constant target is dominated by its denominator. See
validate.CrossValidation.dispersion.
A row marked worse differs significantly from E3-Valid under this paired comparison.
841def doubly_held_out_predictions(frame: pl.DataFrame, n_terms: int, config: Configuration) -> np.ndarray | None: 842 """E3's predictions with **both** the dataset and the model of each cell held out. 843 844 Used for the ranking and the threshold decision and for nothing else. Those two are the 845 questions a practitioner actually asks -- *which model should I run on this data*, and 846 *will it clear my bar* -- and both are asked about a pair that has not been run. Neither 847 single-group protocol answers that: LODO has seen the learner on nineteen 848 other problems, LOMO has seen the dataset. 849 850 The R2 curve and the length rule stay on the single-group protocols, which is the right 851 scope for them: they are about how the equation degrades as one axis becomes unfamiliar, 852 and a per-cell refit would answer a different question at 32 times the cost. 853 """ 854 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 855 truth = target(frame) 856 datasets = groups(frame, DATASET_COLUMN) 857 models = groups(frame, MODEL_COLUMN) 858 library = build_library( 859 DATASET_FEATURES, 860 MODEL_FEATURES, 861 columns, 862 max_arity=config.max_arity, 863 max_abs_zscore=config.max_abs_zscore, 864 ) 865 result = search( 866 library, 867 truth, 868 max_terms=config.max_terms, 869 penalty=config.penalty, 870 pool_size=config.pool_size, 871 beam_width=config.beam_width, 872 ) 873 if n_terms not in result.equations: 874 return None 875 size = n_terms 876 path = cross_validate_doubly_held_out( 877 library, 878 columns, 879 truth, 880 datasets, 881 models, 882 {size: result.equations[size]}, 883 penalty=config.penalty, 884 ) 885 return path[size].predictions if size in path else None
E3's predictions with both the dataset and the model of each cell held out.
Used for the ranking and the threshold decision and for nothing else. Those two are the questions a practitioner actually asks -- which model should I run on this data, and will it clear my bar -- and both are asked about a pair that has not been run. Neither single-group protocol answers that: LODO has seen the learner on nineteen other problems, LOMO has seen the dataset.
The R2 curve and the length rule stay on the single-group protocols, which is the right scope for them: they are about how the equation degrades as one axis becomes unfamiliar, and a per-cell refit would answer a different question at 32 times the cost.
907def model_selection( 908 frame: pl.DataFrame, 909 e3: EquationReport, 910 config: Configuration, 911 protocol: str = "loo_cell", 912) -> pl.DataFrame: 913 """Can the equation pick a good model for a dataset it has never run it on? 914 915 **Reported under ``loo_cell``: both the dataset and the model of every cell are out of the 916 training set.** Anything weaker answers a different question. LODO has 917 seen the learner on nineteen other problems; LOMO has seen the dataset. A 918 recommendation is asked about a pair that has not been run, so both have to go. 919 920 The cost of that is small, and structurally so rather than by luck: a ranking depends only 921 on the *within-dataset* ordering of models, and the dataset-side terms shift every model 922 on a dataset by the same amount, so holding the dataset out moves the level and not the 923 order. `ranking_baselines` reports every protocol side by side so that stays visible. 924 """ 925 truth = target(frame) 926 datasets = groups(frame, DATASET_COLUMN) 927 prediction = _e3_predictions(frame, e3, protocol, config) 928 if prediction is None: 929 return pl.DataFrame() 930 return ranking_report(truth, prediction, datasets)
Can the equation pick a good model for a dataset it has never run it on?
Reported under loo_cell: both the dataset and the model of every cell are out of the
training set. Anything weaker answers a different question. LODO has
seen the learner on nineteen other problems; LOMO has seen the dataset. A
recommendation is asked about a pair that has not been run, so both have to go.
The cost of that is small, and structurally so rather than by luck: a ranking depends only
on the within-dataset ordering of models, and the dataset-side terms shift every model
on a dataset by the same amount, so holding the dataset out moves the level and not the
order. ranking_baselines reports every protocol side by side so that stays visible.
954def ranking_baselines( 955 frame: pl.DataFrame, 956 e3: EquationReport, 957 config: Configuration, 958 opaque: OpaqueRun | None = None, 959) -> pl.DataFrame: 960 """The equation's ranking against the trivial rankings, averaged over datasets. 961 962 **Every predictor is scored under the same protocol**, which is what makes the comparison 963 a comparison. The equation appears under IS, LODO, LOMO, and DHO, so the cost of 964 generalisation on this task is visible rather than assumed. The trivial predictors appear 965 under LODO, which is the only way a per-model 966 centre can be computed honestly. 967 968 Both centres are reported for the same reason the error metrics report both: the per-model 969 mean and the per-model median are different orderings, and which is harder to beat is a 970 question rather than an assumption. 971 """ 972 truth = target(frame) 973 datasets = groups(frame, DATASET_COLUMN) 974 models = groups(frame, MODEL_COLUMN) 975 976 candidates: dict[str, np.ndarray] = {} 977 for label, protocol in ( 978 ("equation (in-sample)", "in_sample"), 979 ("equation (loo-dataset)", "loo_dataset"), 980 ("equation (loo-model)", "loo_model"), 981 ): 982 prediction = _e3_predictions(frame, e3, protocol, config) 983 if prediction is not None: 984 candidates[label] = prediction 985 doubly = doubly_held_out_predictions(frame, e3.n_terms, config) 986 if doubly is not None: 987 candidates["equation (loo-cell: both held out)"] = doubly 988 # The trivial predictors cannot be computed under DHO (internally ``loo_cell``) at all: a model 989 # held out of every fold has no rows to average, so "how well does this model usually do" 990 # has no value. They appear under LODO, which is the only way they exist -- and 991 # that hands them the model identity denied to the DHO row of the equation. 992 candidates["per-model mean (loo-dataset)"] = baseline_group_centre(truth, datasets, models, centre="mean") 993 candidates["per-model median (loo-dataset)"] = baseline_group_centre(truth, datasets, models, centre="median") 994 # The opaque opponent under every protocol it has, so each of its rows can be read against 995 # the equation row that was allowed to see the same things. The DHO rows are the 996 # comparison that matters: there neither side has the dataset or the model, and the trivial 997 # baselines cannot be computed at all. 998 if opaque is not None: 999 candidates |= _opaque_candidates(opaque) 1000 1001 tables = { 1002 label: ranking_report(truth, prediction, datasets).sort("group") for label, prediction in candidates.items() 1003 } 1004 reference = tables["equation (loo-dataset)"] if "equation (loo-dataset)" in tables else next(iter(tables.values())) 1005 1006 rows: list[dict[str, object]] = [] 1007 for label, table in tables.items(): 1008 row: dict[str, object] = { 1009 "predictor": label, 1010 **{ 1011 column: float(table[column].to_numpy().mean()) 1012 for column in ("ap", "mrr", "hit_at_1", "regret") 1013 if column in table.columns 1014 }, 1015 "datasets": table.height, 1016 } 1017 # The mean over twenty datasets is not the comparison. hit@1 moves only in steps of 1018 # 0.05 on twenty folds, so a single dataset flipping its top pick shifts it by more 1019 # than the gaps being read. The paired test against the reported equation is what 1020 # licenses any claim from this table. 1021 if label != "equation (loo-dataset)" and "ap" in table.columns: 1022 paired = paired_comparison(reference["ap"].to_numpy(), table["ap"].to_numpy()) 1023 row["ap_vs_e3_p"] = paired.p_value 1024 row["ap_vs_e3_significant"] = paired.significant 1025 rows.append(row) 1026 return pl.DataFrame(rows)
The equation's ranking against the trivial rankings, averaged over datasets.
Every predictor is scored under the same protocol, which is what makes the comparison a comparison. The equation appears under IS, LODO, LOMO, and DHO, so the cost of generalisation on this task is visible rather than assumed. The trivial predictors appear under LODO, which is the only way a per-model centre can be computed honestly.
Both centres are reported for the same reason the error metrics report both: the per-model mean and the per-model median are different orderings, and which is harder to beat is a question rather than an assumption.
1029def decision_baselines( 1030 frame: pl.DataFrame, 1031 config: Configuration, 1032 known: dict[int, CrossValidation] | None = None, 1033 e3: EquationReport | None = None, 1034 opaque: OpaqueRun | None = None, 1035) -> pl.DataFrame: 1036 """The above-or-below-threshold decision, for the equation and both trivial centres. 1037 1038 Every predictor under the same protocol, as in `ranking_baselines`, and the equation under 1039 all four so the cost of generalisation on the decision is measured rather than assumed. 1040 `decision_report` already carries a majority-class column, which is the floor any rule has 1041 to clear; these are the harder comparison. 1042 """ 1043 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 1044 truth = target(frame) 1045 datasets = groups(frame, DATASET_COLUMN) 1046 models = groups(frame, MODEL_COLUMN) 1047 1048 candidates: dict[str, np.ndarray] = {} 1049 if e3 is not None: 1050 for label, protocol in ( 1051 ("equation (in-sample)", "in_sample"), 1052 ("equation (loo-dataset)", "loo_dataset"), 1053 ("equation (loo-model)", "loo_model"), 1054 ): 1055 prediction = _e3_predictions(frame, e3, protocol, config) 1056 if prediction is not None: 1057 candidates[label] = prediction 1058 doubly = doubly_held_out_predictions(frame, e3.n_terms, config) 1059 if doubly is not None: 1060 candidates["equation (loo-cell: both held out)"] = doubly 1061 else: 1062 path = known 1063 if path is None or (e3 is not None and e3.n_terms not in path): 1064 path, _ = _fixed_form_path(columns, truth, datasets, config) 1065 candidates["equation (loo-dataset)"] = path[e3.n_terms if e3 is not None else max(path)].predictions 1066 candidates["per-model mean (loo-dataset)"] = baseline_group_centre(truth, datasets, models, centre="mean") 1067 candidates["per-model median (loo-dataset)"] = baseline_group_centre(truth, datasets, models, centre="median") 1068 if opaque is not None: 1069 candidates |= _opaque_candidates(opaque) 1070 1071 rows: list[dict[str, object]] = [] 1072 for label, prediction in candidates.items(): 1073 for row in decision_report(truth, prediction, datasets).to_dicts(): 1074 rows.append({"predictor": label, **row}) 1075 return pl.DataFrame(rows)
The above-or-below-threshold decision, for the equation and both trivial centres.
Every predictor under the same protocol, as in ranking_baselines, and the equation under
all four so the cost of generalisation on the decision is measured rather than assumed.
decision_report already carries a majority-class column, which is the floor any rule has
to clear; these are the harder comparison.
1078@dataclass 1079class Report: 1080 """Everything the experiment produces.""" 1081 1082 e1: EquationReport 1083 e3: EquationReport 1084 e2: EquationReport 1085 #: E3-MAX: the same configuration at the configured capability arity at the raw floor maximum. 1086 #: Reported to show how far the additive form reaches, not as the study's 1087 #: recommendation. See `run_capability`. 1088 e3_capability: EquationReport 1089 #: E3-Valid and E3-MAX side by side, with the rule each used. See `selection_summary`. 1090 grammars: pl.DataFrame 1091 #: Feature-level effects and stability before evidence thresholds are applied. 1092 feature_practices: pl.DataFrame 1093 practices: pl.DataFrame 1094 effects: pl.DataFrame 1095 shares: pl.DataFrame 1096 decomposition: pl.DataFrame 1097 correlations: pl.DataFrame 1098 #: What each raw feature carries on its own, and the three-level ceiling the grammar 1099 #: implies before any search runs. See `analysis.grammar_ceiling`. 1100 reach: pl.DataFrame 1101 ceiling: pl.DataFrame 1102 baselines: pl.DataFrame 1103 comparison: pl.DataFrame 1104 leakage: pl.DataFrame 1105 selection: pl.DataFrame 1106 #: Every length paired against E3-Valid as a sensitivity analysis. 1107 length_choice: pl.DataFrame 1108 oracles: pl.DataFrame 1109 #: How far the equation's own interactions lie along the leading component the 1110 #: oracle ladder finds. Without it the ladder measures a ceiling and says nothing 1111 #: about whether the equation reaches any of it. 1112 interaction: pl.DataFrame 1113 decision: pl.DataFrame 1114 #: The ranking and threshold decisions against the trivial predictors, at both 1115 #: centres. An evaluation without them says how well the equation does and not 1116 #: whether it beats ordering the models by how well they usually do. 1117 ranking_baselines: pl.DataFrame 1118 decision_baselines: pl.DataFrame 1119 #: Standard opaque regressors on the same rows under the same protocols. The priced 1120 #: other side of the study's trade: see `ml_meta_perf.opaque`. 1121 opaque: pl.DataFrame 1122 #: What per-model *identity* adds to the reported path, which is the ceiling on what any 1123 #: model descriptor could reach. See `identity_ceiling`. 1124 identity: pl.DataFrame 1125 #: What an unpenalised least-squares fit over the *whole* library reaches under IS and 1126 #: held out. The control for the selection stage: if this transferred, the beam search 1127 #: and the length rule would be machinery in search of a problem. See 1128 #: `analysis.saturated_fit`. 1129 saturated: pl.DataFrame 1130 #: LODO with E3-Valid's terms re-chosen in every fold, beside the reported fixed form: how 1131 #: much of the transfer number belongs to choosing the form on all rows. See 1132 #: `selection_optimism`. 1133 optimism: pl.DataFrame
Everything the experiment produces.
1136def run( 1137 path: str | None = None, 1138 *, 1139 config: Configuration | None = None, 1140 selection: SelectionConfig | None = None, 1141 opaque: OpaqueConfig | None = None, 1142 opaque_models: tuple[tuple[str, OpaqueBuilder], ...] | None = None, 1143) -> Report: 1144 """Run the whole study. 1145 1146 ``config``, ``selection`` and ``opaque`` default to ``config/study.json``. **One 1147 configuration serves all three equations**: every table that mentions an equation is 1148 computed from the same object, so an override changes the study consistently. 1149 1150 ``opaque_models`` replaces the estimators the comparison is run against, in the shape 1151 `opaque.evaluate` takes. The opaque side is scikit-learn's and the DHO refit around it is 1152 this project's, so a caller checking the wiring can exercise the second without paying 1153 for the first -- which at 484 refits per estimator is most of what a run costs. 1154 """ 1155 frame = load(path) 1156 config, selection = _resolve(config, selection) 1157 e1 = run_e1(frame, config, selection) 1158 e2 = run_e2(frame, config, selection) 1159 e3 = run_e3(frame, config, selection) 1160 e3_capability = run_capability(frame, config, selection) 1161 config_e3 = config 1162 columns = columns_as_arrays(frame, DATASET_FEATURES + MODEL_FEATURES) 1163 reach, ceiling = reach_analysis(frame, config_e3) 1164 # Fitted once and shared: the folds are the expensive part, and the regression table and 1165 # the two decision comparisons have to be scored from the same predictions or they can 1166 # disagree with each other. 1167 if opaque_models is None: 1168 opaque_models = opaque_estimators(opaque or load_config().opaque) 1169 opaque_run = opaque_evaluate(frame, models=opaque_models) 1170 return Report( 1171 opaque=opaque_run.table, 1172 saturated=pl.DataFrame([saturated_analysis(frame, config_e3)]), 1173 optimism=selection_optimism(frame, e3, config_e3), 1174 e1=e1, 1175 e2=e2, 1176 e3=e3, 1177 e3_capability=e3_capability, 1178 grammars=selection_summary(e3, e3_capability, config, selection), 1179 feature_practices=feature_practices(e3.equation, columns, e3.stability), 1180 practices=best_practices(e3.equation, columns, e3.stability), 1181 effects=term_effects(e3.equation, columns, DATASET_FEATURES, MODEL_FEATURES), 1182 shares=group_shares(e3.equation, columns, DATASET_FEATURES, MODEL_FEATURES), 1183 decomposition=variance_decomposition(target(frame), groups(frame, DATASET_COLUMN), groups(frame, MODEL_COLUMN)), 1184 correlations=correlation_analysis(frame, config_e3), 1185 reach=reach, 1186 ceiling=ceiling, 1187 baselines=baselines(frame), 1188 comparison=comparison(frame, e1, e3, e2, e3_capability), 1189 leakage=leakage_demonstration(frame, e3.n_terms, config_e3, e3.paths), 1190 selection=model_selection(frame, e3, config_e3, "loo_cell"), 1191 decision=decision_quality(frame, e3.n_terms, config_e3, e3.paths.get("loo_dataset")), 1192 ranking_baselines=ranking_baselines(frame, e3, config_e3, opaque_run), 1193 decision_baselines=decision_baselines(frame, config_e3, e3.paths.get("loo_dataset"), e3, opaque_run), 1194 length_choice=length_comparison(frame, e3, config_e3), 1195 oracles=oracle_ladder(target(frame), groups(frame, DATASET_COLUMN), groups(frame, MODEL_COLUMN)), 1196 interaction=interaction_reached(frame, e3), 1197 identity=identity_ceiling(frame, e3), 1198 )
Run the whole study.
config, selection and opaque default to config/study.json. One
configuration serves all three equations: every table that mentions an equation is
computed from the same object, so an override changes the study consistently.
opaque_models replaces the estimators the comparison is run against, in the shape
opaque.evaluate takes. The opaque side is scikit-learn's and the DHO refit around it is
this project's, so a caller checking the wiring can exercise the second without paying
for the first -- which at 484 refits per estimator is most of what a run costs.