ml_meta_perf.fit

Fitting weights to a form that has already been chosen.

The study's central distinction, in a module boundary. An equation is two things -- which terms it contains and what they are multiplied by -- and they are decided by different means and validated by different protocols. ml_meta_perf.search chooses the terms; this module does the arithmetic that turns a chosen set of them into an equation, and nothing here has an opinion about which set that should be.

That line is not a tidiness preference. validate.cross_validate_fixed_form is the study's reported protocol precisely because it holds the form still and refits only the weights per fold, and until 2026-09-09 the function that performed a search was called fit, which blurred the one distinction the results rest on. Chapter 3 calls the other half "term selection" and always has.

Gram-matrix arithmetic. Every refit is a k-by-k solve against a precomputed Gram matrix rather than a least-squares call against the full design, which is what makes an exhaustive sweep inside 20 cross-validation folds tractable. ridge_solve states that arithmetic in one place; search.Selector indexes submatrices out of a Gram it holds, for speed, and tests/test_search.py checks the two agree.

Study chapter: 3. Term generation and selection -- the rationale, in prose, with the figures.

  1"""Fitting weights to a form that has already been chosen.
  2
  3The study's central distinction, in a module boundary. An equation is two things -- which
  4terms it contains and what they are multiplied by -- and they are decided by different means
  5and validated by different protocols. `ml_meta_perf.search` chooses the terms;
  6this module does the arithmetic that turns a chosen set of them into an equation, and nothing
  7here has an opinion about which set that should be.
  8
  9That line is not a tidiness preference. `validate.cross_validate_fixed_form` is the study's
 10reported protocol precisely because it holds the form still and refits only the weights per
 11fold, and until 2026-09-09 the function that performed a search was called `fit`, which
 12blurred the one distinction the results rest on. Chapter 3 calls the other half "term
 13selection" and always has.
 14
 15*Gram-matrix arithmetic.* Every refit is a k-by-k solve against a precomputed Gram matrix
 16rather than a least-squares call against the full design, which is what makes an exhaustive
 17sweep inside 20 cross-validation folds tractable. `ridge_solve` states that arithmetic in one
 18place; `search.Selector` indexes submatrices out of a Gram it holds, for speed, and
 19`tests/test_search.py` checks the two agree.
 20
 21Study chapter: [3. Term generation and selection][study-chapter] -- the rationale, in
 22prose, with the figures.
 23
 24[study-chapter]: https://github.com/mariolpantunes/ml-meta-perf/blob/main/assets/docs/03-term-selection.md
 25"""
 26
 27from __future__ import annotations
 28
 29from dataclasses import dataclass
 30
 31import numpy as np
 32
 33from ml_meta_perf.model import Equation
 34from ml_meta_perf.terms import Library
 35
 36#: The ridge penalty a caller gets if it does not choose one.
 37RIDGE_DEFAULT = 10.0
 38
 39
 40@dataclass(frozen=True)
 41class Standardizer:
 42    """Column means and scales, learned on training rows only."""
 43
 44    mean: np.ndarray
 45    scale: np.ndarray
 46
 47    @classmethod
 48    def fit(cls, matrix: np.ndarray) -> Standardizer:
 49        scale = matrix.std(axis=0)
 50        return cls(matrix.mean(axis=0), np.where(scale < 1e-12, 1.0, scale))
 51
 52    def apply(self, matrix: np.ndarray) -> np.ndarray:
 53        return (matrix - self.mean) / self.scale
 54
 55
 56@dataclass(frozen=True)
 57class Subset:
 58    """One candidate equation: which terms, their standardised weights, and its fit."""
 59
 60    indices: tuple[int, ...]
 61    weights: np.ndarray
 62    #: The **penalised** objective this subset reached, ``||y - Xw||^2 + lambda*||w||^2``,
 63    #: not a residual sum of squares. It is only ever compared -- the beam sorts on it and
 64    #: the refinement pass tests it against an incumbent -- so its scale does not matter,
 65    #: but two things follow from the name. Ridge shrinkage can drive it slightly negative
 66    #: on a near-perfect fit, which is harmless for ordering and would not be for anything
 67    #: taking a square root of it; nothing does, and nothing should without clamping at the
 68    #: point of use rather than here, since clamping would flatten the ordering the beam
 69    #: depends on among near-perfect subsets.
 70    rss: float
 71
 72
 73def ridge_solve(gram: np.ndarray, rhs: np.ndarray, penalty: float) -> np.ndarray:
 74    """Solve ``(G + penalty*I) w = rhs`` for a centred, standardised design.
 75
 76    Centring both sides removes the intercept from the system, so no penalty is ever
 77    applied to it -- shrinking an intercept toward zero would bias every prediction
 78    toward MCC = 0, which is not a value this data goes anywhere near.
 79
 80    This is the **reference form**, written to be read. `Selector._evaluate` does not call
 81    it: it adds the penalty to the full Gram diagonal once at construction and indexes the
 82    submatrix straight out of that, which is the same arithmetic without a copy and an
 83    index array per solve. ``test_fit`` asserts the two agree, so this function is what
 84    the fast path is checked against rather than documentation of something else.
 85    """
 86    system = gram.copy()
 87    if penalty:
 88        system.flat[:: system.shape[0] + 1] += penalty
 89    try:
 90        return np.linalg.solve(system, rhs)
 91    except np.linalg.LinAlgError:
 92        return np.linalg.lstsq(system, rhs, rcond=None)[0]
 93
 94
 95def to_equation(
 96    library: Library,
 97    subset: Subset,
 98    standardizer: Standardizer,
 99    offset: float,
100    name: str,
101) -> Equation:
102    """Fold the standardisation back into the weights so the equation reads in raw units."""
103    order = list(subset.indices)
104    raw = subset.weights / standardizer.scale[order]
105    intercept = offset - float(raw @ standardizer.mean[order])
106    return Equation(
107        intercept=intercept,
108        terms=tuple(library.terms[index] for index in order),
109        weights=tuple(float(value) for value in raw),
110        standardized_weights=tuple(float(value) for value in subset.weights),
111        name=name,
112    )
RIDGE_DEFAULT = 10.0
@dataclass(frozen=True)
class Standardizer:
41@dataclass(frozen=True)
42class Standardizer:
43    """Column means and scales, learned on training rows only."""
44
45    mean: np.ndarray
46    scale: np.ndarray
47
48    @classmethod
49    def fit(cls, matrix: np.ndarray) -> Standardizer:
50        scale = matrix.std(axis=0)
51        return cls(matrix.mean(axis=0), np.where(scale < 1e-12, 1.0, scale))
52
53    def apply(self, matrix: np.ndarray) -> np.ndarray:
54        return (matrix - self.mean) / self.scale

Column means and scales, learned on training rows only.

Standardizer(mean: numpy.ndarray, scale: numpy.ndarray)
mean: numpy.ndarray
scale: numpy.ndarray
@classmethod
def fit(cls, matrix: numpy.ndarray) -> Standardizer:
48    @classmethod
49    def fit(cls, matrix: np.ndarray) -> Standardizer:
50        scale = matrix.std(axis=0)
51        return cls(matrix.mean(axis=0), np.where(scale < 1e-12, 1.0, scale))
def apply(self, matrix: numpy.ndarray) -> numpy.ndarray:
53    def apply(self, matrix: np.ndarray) -> np.ndarray:
54        return (matrix - self.mean) / self.scale
@dataclass(frozen=True)
class Subset:
57@dataclass(frozen=True)
58class Subset:
59    """One candidate equation: which terms, their standardised weights, and its fit."""
60
61    indices: tuple[int, ...]
62    weights: np.ndarray
63    #: The **penalised** objective this subset reached, ``||y - Xw||^2 + lambda*||w||^2``,
64    #: not a residual sum of squares. It is only ever compared -- the beam sorts on it and
65    #: the refinement pass tests it against an incumbent -- so its scale does not matter,
66    #: but two things follow from the name. Ridge shrinkage can drive it slightly negative
67    #: on a near-perfect fit, which is harmless for ordering and would not be for anything
68    #: taking a square root of it; nothing does, and nothing should without clamping at the
69    #: point of use rather than here, since clamping would flatten the ordering the beam
70    #: depends on among near-perfect subsets.
71    rss: float

One candidate equation: which terms, their standardised weights, and its fit.

Subset(indices: tuple[int, ...], weights: numpy.ndarray, rss: float)
indices: tuple[int, ...]
weights: numpy.ndarray
rss: float
def ridge_solve(gram: numpy.ndarray, rhs: numpy.ndarray, penalty: float) -> numpy.ndarray:
74def ridge_solve(gram: np.ndarray, rhs: np.ndarray, penalty: float) -> np.ndarray:
75    """Solve ``(G + penalty*I) w = rhs`` for a centred, standardised design.
76
77    Centring both sides removes the intercept from the system, so no penalty is ever
78    applied to it -- shrinking an intercept toward zero would bias every prediction
79    toward MCC = 0, which is not a value this data goes anywhere near.
80
81    This is the **reference form**, written to be read. `Selector._evaluate` does not call
82    it: it adds the penalty to the full Gram diagonal once at construction and indexes the
83    submatrix straight out of that, which is the same arithmetic without a copy and an
84    index array per solve. ``test_fit`` asserts the two agree, so this function is what
85    the fast path is checked against rather than documentation of something else.
86    """
87    system = gram.copy()
88    if penalty:
89        system.flat[:: system.shape[0] + 1] += penalty
90    try:
91        return np.linalg.solve(system, rhs)
92    except np.linalg.LinAlgError:
93        return np.linalg.lstsq(system, rhs, rcond=None)[0]

Solve (G + penalty*I) w = rhs for a centred, standardised design.

Centring both sides removes the intercept from the system, so no penalty is ever applied to it -- shrinking an intercept toward zero would bias every prediction toward MCC = 0, which is not a value this data goes anywhere near.

This is the reference form, written to be read. Selector._evaluate does not call it: it adds the penalty to the full Gram diagonal once at construction and indexes the submatrix straight out of that, which is the same arithmetic without a copy and an index array per solve. test_fit asserts the two agree, so this function is what the fast path is checked against rather than documentation of something else.

def to_equation( library: ml_meta_perf.terms.Library, subset: Subset, standardizer: Standardizer, offset: float, name: str) -> ml_meta_perf.model.Equation:
 96def to_equation(
 97    library: Library,
 98    subset: Subset,
 99    standardizer: Standardizer,
100    offset: float,
101    name: str,
102) -> Equation:
103    """Fold the standardisation back into the weights so the equation reads in raw units."""
104    order = list(subset.indices)
105    raw = subset.weights / standardizer.scale[order]
106    intercept = offset - float(raw @ standardizer.mean[order])
107    return Equation(
108        intercept=intercept,
109        terms=tuple(library.terms[index] for index in order),
110        weights=tuple(float(value) for value in raw),
111        standardized_weights=tuple(float(value) for value in subset.weights),
112        name=name,
113    )

Fold the standardisation back into the weights so the equation reads in raw units.