ml_meta_perf.search

Choosing which terms enter the equation.

This is the search. ml_meta_perf.fit is the arithmetic it scores candidates with, and the split is the study's own: a form is chosen once, and weights are refitted per fold against a form held still -- which is what validate.cross_validate_fixed_form reports. Until 2026-09-09 both lived in a module called fit, whose entry point was a function called fit that ran a beam search, and the one distinction the results rest on was invisible in the names.

Four ideas do the work here.

Correlation-guided term design. Pearson and Spearman disagree in a informative way. A feature whose Pearson and Spearman correlations against MCC are similar is related to the target linearly, and f on its own is the right term -- adding log(f) and 1/f for it only inflates the search space. A feature whose Spearman correlation is much the larger is monotonically but non-linearly related, and that is precisely the case where a compressing or inverting transform earns its place. Screening on that gap shrinks the library before search begins, which is what keeps the selected equations short without a parsimony penalty bolted on afterwards.

Beam search over subsets, not greedy descent. Greedy forward selection commits to its first term forever, and on collinear libraries that first commitment is often wrong. A beam keeps several partial equations alive, and because the beam records its best subset at every size, one run yields the whole accuracy-versus-number-of-terms curve.

One term per feature combination. An equation that describes a against b twice -- once as a/b and once as b/a -- cannot be read a term at a time, because neither term means on its own what the table says it means. The selector therefore refuses a candidate whose feature combination is already represented, which is a constraint on form rather than on fit and is not negotiable against R2: an equation nobody can reason about has failed at the only thing this study asks of it. It is measured, not assumed, to cost nothing -- see terms.Library.feature_groups. Note that this is a different test from COLLINEARITY_LIMIT, which asks a numerical question; the pairs this removes were well inside that limit.

Pruning is a decision about form, so it is here. prune drops terms that do no work and simplifies what remains -- both changes to which terms the equation contains -- and then asks fit for the weights of what is left. An equation shortened without that refit would be the best fit of terms it no longer has.

Selector holds a ridge fit and is still in this module. Its _evaluate is arithmetic that fit would recognise, but it exists to be called several hundred thousand times by the beam, off a Gram matrix and a cache the beam's constructor builds. Splitting it would put the two halves of one constructor in two files to make a naming point, which is worse than the naming point is worth.

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

  1"""Choosing which terms enter the equation.
  2
  3This is the search. `ml_meta_perf.fit` is the arithmetic it scores candidates with, and the
  4split is the study's own: a form is chosen once, and weights are refitted per fold against a
  5form held still -- which is what `validate.cross_validate_fixed_form` reports. Until
  62026-09-09 both lived in a module called `fit`, whose entry point was a function called `fit`
  7that ran a beam search, and the one distinction the results rest on was invisible in the names.
  8
  9Four ideas do the work here.
 10
 11*Correlation-guided term design.* Pearson and Spearman disagree in a informative way.
 12A feature whose Pearson and Spearman correlations against MCC are similar is related to
 13the target linearly, and ``f`` on its own is the right term -- adding ``log(f)`` and
 14``1/f`` for it only inflates the search space. A feature whose Spearman correlation is
 15much the larger is monotonically but *non*-linearly related, and that is precisely the
 16case where a compressing or inverting transform earns its place. Screening on that gap
 17shrinks the library before search begins, which is what keeps the selected equations
 18short without a parsimony penalty bolted on afterwards.
 19
 20*Beam search over subsets, not greedy descent.* Greedy forward selection commits to its
 21first term forever, and on collinear libraries that first commitment is often wrong.
 22A beam keeps several partial equations alive, and because the beam records its best
 23subset at every size, one run yields the whole accuracy-versus-number-of-terms curve.
 24
 25*One term per feature combination.* An equation that describes ``a`` against ``b`` twice --
 26once as ``a/b`` and once as ``b/a`` -- cannot be read a term at a time, because neither
 27term means on its own what the table says it means. The selector therefore refuses a
 28candidate whose feature combination is already represented, which is a constraint on
 29*form* rather than on fit and is not negotiable against R2: an equation nobody can reason
 30about has failed at the only thing this study asks of it. It is measured, not assumed, to
 31cost nothing -- see `terms.Library.feature_groups`. Note that this is a different test
 32from ``COLLINEARITY_LIMIT``, which asks a numerical question; the pairs this removes were
 33well inside that limit.
 34
 35*Pruning is a decision about form, so it is here.* `prune` drops terms that do no work and
 36simplifies what remains -- both changes to which terms the equation contains -- and then asks
 37`fit` for the weights of what is left. An equation shortened without that refit would be the
 38best fit of terms it no longer has.
 39
 40**`Selector` holds a ridge fit and is still in this module.** Its `_evaluate` is arithmetic
 41that `fit` would recognise, but it exists to be called several hundred thousand times by the
 42beam, off a Gram matrix and a cache the beam's constructor builds. Splitting it would put the
 43two halves of one constructor in two files to make a naming point, which is worse than the
 44naming point is worth.
 45
 46Study chapter: [3. Term generation and selection][study-chapter] -- the rationale, in
 47prose, with the figures.
 48
 49[study-chapter]: https://github.com/mariolpantunes/ml-meta-perf/blob/main/assets/docs/03-term-selection.md
 50"""
 51
 52from __future__ import annotations
 53
 54from dataclasses import dataclass
 55
 56import numpy as np
 57
 58from ml_meta_perf.fit import RIDGE_DEFAULT, Standardizer, Subset, to_equation
 59from ml_meta_perf.model import Equation
 60from ml_meta_perf.stats import pearson, pearson_columns, rank_columns, rankdata, spearman
 61from ml_meta_perf.terms import Library, Term, is_trivial, simplify
 62
 63BEAM_WIDTH_DEFAULT = 6
 64CANDIDATE_POOL_DEFAULT = 24
 65COLLINEARITY_LIMIT = 0.95
 66REFINE_ROUNDS_DEFAULT = 2
 67
 68
 69def transform_gap(values: np.ndarray, target: np.ndarray) -> float:
 70    """How much more monotone than linear a feature's relation to the target is.
 71
 72    Near zero means a plain linear term is adequate. Clearly positive means a transform
 73    (log, inverse, power) or a ratio is likely to pay, because the association is there
 74    but a straight line cannot capture it.
 75    """
 76    return abs(spearman(values, target)) - abs(pearson(values, target))
 77
 78
 79def guided_screen(
 80    library: Library,
 81    target: np.ndarray,
 82    *,
 83    keep: int,
 84    linear_gap: float = 0.05,
 85) -> list[int]:
 86    """Reduce the library to the terms worth searching over.
 87
 88    Terms are ranked by the stronger of their two correlations, so a term that is
 89    strongly monotone but weakly linear survives -- it is exactly the term a linear
 90    equation needs a transform for. Terms that are near-duplicates of a stronger term
 91    already kept are dropped, since the beam would otherwise spend its width on
 92    variations of one idea.
 93    """
 94    matrix = library.matrix
 95    # Ranking is the expensive half of a Spearman correlation -- it sorts, then averages
 96    # ties -- and screening a 600-term pool inside 20 folds asks for it tens of thousands
 97    # of times. Nothing here is per-candidate: the target's ranks do not change from one
 98    # candidate to the next, the candidates' own ranks are one vectorised pass over the
 99    # matrix (`stats.rank_columns`), and both correlations are one matrix-vector product
100    # over the whole pool (`stats.pearson_columns`) rather than a `pearson` call per column.
101    ranked_target = rankdata(target)
102    ranked = rank_columns(matrix) if matrix.shape[0] > 1 else matrix
103    linear = np.abs(pearson_columns(matrix, target))
104    monotone = np.abs(pearson_columns(ranked, ranked_target)) if matrix.shape[0] > 1 else np.zeros(matrix.shape[1])
105    # A term that is only monotone is still useful, but it is preferred a little
106    # less than an equally strong linear one: linear terms read more simply.
107    strength = np.maximum(linear, monotone) - np.where(monotone - linear > linear_gap, 0.02, 0.0)
108    # Descending by strength, ties broken by ascending library index, for the reason
109    # `_descending` sets out: the survivors of the duplicate pass below must be a property
110    # of the library rather than of the order a caller assembled it in.
111    scored = _descending(strength)
112
113    standardized = Standardizer.fit(matrix).apply(matrix)
114    rows = float(standardized.shape[0])
115
116    # The duplicate check is one matrix-vector product against the columns kept so far,
117    # not a Python loop of dot products over them. At 1599 candidates keeping 1200 the
118    # loop form ran 17 million times and accounted for over half the total runtime of a
119    # cross-validated sweep; this is the same arithmetic in one BLAS call per candidate.
120    accepted = np.empty((standardized.shape[0], min(keep, standardized.shape[1])))
121    kept: list[int] = []
122    for position in scored:
123        if len(kept) >= keep:
124            break
125        index = int(position)
126        column = standardized[:, index]
127        if kept:
128            correlations = np.abs(column @ accepted[:, : len(kept)]) / rows
129            if float(correlations.max()) > 0.995:
130                continue
131        accepted[:, len(kept)] = column
132        kept.append(index)
133    return kept
134
135
136def _descending(scores: np.ndarray) -> np.ndarray:
137    """Indices of ``scores`` from largest to smallest, ties broken by ascending index.
138
139    ``np.argsort(scores)[::-1]`` looks equivalent and is not, twice over: the default sort is
140    quicksort, which is unstable, and reversing even a stable ascending sort *inverts* the tie
141    order rather than preserving it. Either way ties were resolved by where a term happened to
142    land in the library, and `terms.build_library` emits terms in feature declaration order --
143    so handing `run_equation` the same columns in a different order produced a different
144    equation, and leave-one-dataset-out (LODO) R² moved by as much as 0.286 across seven orderings of a
145    single ten-feature set.
146
147    Sorting the negated scores with an explicitly stable kind breaks ties by ascending library
148    index instead, which is a property of the library rather than of the caller's argument
149    order.
150    """
151    return np.argsort(-scores, kind="stable")
152
153
154class Selector:
155    """Beam search over term subsets against a fixed, standardised design."""
156
157    def __init__(
158        self,
159        design: np.ndarray,
160        target: np.ndarray,
161        penalty: float,
162        groups: np.ndarray | None = None,
163    ) -> None:
164        self.design = design
165        self.centered = target - target.mean()
166        self.offset = float(target.mean())
167        self.penalty = penalty
168        self.gram = design.T @ design
169        # The ridge penalty added to the *full* Gram diagonal, once. A submatrix's
170        # diagonal is drawn from the full diagonal, so ``penalized[ix, ix]`` already
171        # equals ``gram[ix, ix] + penalty*I`` -- which removes a copy, an arange and a
172        # fancy-index assignment from every one of the million solves a study runs.
173        # ``gram`` itself stays unpenalised: the residual scoring and the collinearity
174        # guard both need it that way.
175        self.penalized = self.gram.copy()
176        if penalty:
177            self.penalized.flat[:: self.penalized.shape[0] + 1] += penalty
178        self.projection = design.T @ self.centered
179        self.total = float(self.centered @ self.centered)
180        self.normalizer = float(design.shape[0])
181        # The collinearity test as a boolean matrix, taken once. ``_blocked`` runs it for
182        # every parent of every beam step -- tens of thousands of times per fit -- and the
183        # arithmetic does not depend on the parent, only the columns selected out of it.
184        # Deciding it here turns each call into a bool gather over an eighth of the bytes.
185        self._collinear = np.abs(self.gram) / self.normalizer > COLLINEARITY_LIMIT
186        self._cache: dict[tuple[int, ...], Subset] = {}
187        # `terms.Library.feature_groups`, expanded once into a membership matrix so that
188        # `_blocked` is a row gather and an `any`, not an `np.isin` over the pool on each
189        # of several hundred thousand calls. Row ``g`` marks every term in group ``g``.
190        self.groups = groups
191        self._members: np.ndarray | None = None
192        if groups is not None and groups.size:
193            count = int(groups.max()) + 1
194            if count > 0:
195                members = np.zeros((count, groups.shape[0]), dtype=bool)
196                present = groups >= 0
197                members[groups[present], np.flatnonzero(present)] = True
198                self._members = members
199
200    def _evaluate(self, indices: tuple[int, ...]) -> Subset:
201        """Fit one subset. Memoised, because the refinement pass revisits subsets.
202
203        The residual sum of squares uses an identity rather than a second quadratic form.
204        Since ``(G + lambda I) w = b``, we have ``w'Gw = w'b - lambda w'w``, so
205
206            rss = total - 2 w'b + w'Gw = total - w'b - lambda w'w
207
208        which removes a matrix-vector product from the inner loop. The two are equal to
209        floating-point tolerance whenever the solve succeeded, which ``test_fit`` checks
210        directly against the literal definition.
211        """
212        cached = self._cache.get(indices)
213        if cached is not None:
214            return cached
215
216        order = np.array(indices, dtype=np.intp)
217        # ``penalized[order[:, None], order]`` rather than ``np.ix_(order, order)``.
218        # Identical arithmetic: ix_ exists to build exactly this pair of broadcast index
219        # arrays, and it spends most of its time on dtype checks already known to hold
220        # here. At a million calls that check cost 24 of the study's 137 seconds, and
221        # doing the reshape directly is 1.8x faster.
222        block = self.penalized[order[:, None], order]
223        rhs = self.projection[order]
224        try:
225            weights = np.linalg.solve(block, rhs)
226        except np.linalg.LinAlgError:
227            weights = np.linalg.lstsq(block, rhs, rcond=None)[0]
228        rss = self.total - float(weights @ rhs) - self.penalty * float(weights @ weights)
229        subset = Subset(indices, weights, rss)
230        self._cache[indices] = subset
231        return subset
232
233    def _evaluate_many(self, batch: list[tuple[int, ...]]) -> list[Subset]:
234        """Fit several subsets **of the same size** in one stacked solve.
235
236        A beam step and a refinement position both produce a few dozen candidate subsets
237        that differ by one index, and every one of them is a ``k``-by-``k`` solve. At
238        that size numpy's per-call wrapper -- dtype promotion, array coercion, the
239        errstate context manager -- costs several times the LAPACK call it guards, and
240        the study makes over a million of them. Stacking the blocks into one ``(n, k, k)``
241        array moves that loop into C: same routine per slice, bitwise identical weights,
242        two to ten times faster depending on ``k``.
243
244        Order is preserved and the cache is shared with `_evaluate`, so callers see
245        exactly what the one-at-a-time path gave them.
246        """
247        pending: list[tuple[int, ...]] = []
248        queued: set[tuple[int, ...]] = set()
249        for indices in batch:
250            if indices not in self._cache and indices not in queued:
251                queued.add(indices)
252                pending.append(indices)
253
254        if pending:
255            order = np.array(pending, dtype=np.intp)
256            blocks = self.penalized[order[:, :, None], order[:, None, :]]
257            rhs = self.projection[order]
258            try:
259                weights = np.linalg.solve(blocks, rhs[..., None])[..., 0]
260            except np.linalg.LinAlgError:
261                # One singular block fails the whole stack, so fall back to the
262                # per-subset path, which has its own least-squares rescue.
263                for indices in pending:
264                    self._evaluate(indices)
265            else:
266                scores = self.total - np.einsum("ij,ij->i", weights, rhs)
267                if self.penalty:
268                    scores -= self.penalty * np.einsum("ij,ij->i", weights, weights)
269                for indices, row, rss in zip(pending, weights, scores, strict=True):
270                    self._cache[indices] = Subset(indices, row, float(rss))
271
272        return [self._cache[indices] for indices in batch]
273
274    def _residual_scores(self, subset: Subset) -> np.ndarray:
275        order = np.array(subset.indices, dtype=np.intp)
276        return np.abs(self.projection - self.gram[:, order] @ subset.weights)
277
278    def _blocked(self, indices: tuple[int, ...]) -> np.ndarray | None:
279        """Which candidates this parent may not take, as one boolean mask.
280
281        Computed per parent rather than per candidate. The per-candidate form rebuilt an
282        index array and took a max on every one of several hundred thousand calls; this
283        does the same work as a single vectorised reduction over the Gram matrix.
284
285        Two independent refusals, and they answer different questions. The correlation
286        test asks whether a candidate is numerically redundant against what is already
287        chosen. The group test asks whether it would state an *already-stated relationship
288        a second time* -- see `terms.Library.feature_groups`. Neither implies the other:
289        the mirrored pairs the group test removes sat at 0.891 and 0.786, comfortably
290        inside ``COLLINEARITY_LIMIT``, and two terms may correlate above the limit while
291        describing different feature pairs entirely.
292        """
293        if not indices:
294            return None
295        order = np.array(indices, dtype=np.intp)
296        blocked = self._collinear[:, order].any(axis=1)
297        if self._members is None or self.groups is None:
298            return blocked
299        chosen = self.groups[order]
300        chosen = chosen[chosen >= 0]
301        if chosen.size == 0:
302            return blocked
303        # No `np.unique` on the way in: a repeated group id gathers the same membership row
304        # twice and an `any` over it reduces to the same mask, so the sort it costs bought
305        # nothing across several hundred thousand calls.
306        return blocked | self._members[chosen].any(axis=0)
307
308    def search(
309        self,
310        pool: list[int],
311        max_terms: int,
312        *,
313        beam_width: int = BEAM_WIDTH_DEFAULT,
314        candidates: int = CANDIDATE_POOL_DEFAULT,
315        refine_rounds: int = REFINE_ROUNDS_DEFAULT,
316    ) -> dict[int, Subset]:
317        """Return the best subset found at every size from 1 to ``max_terms``."""
318        available = np.array(pool)
319        beam: list[Subset] = [Subset((), np.zeros(0), self.total)]
320        best: dict[int, Subset] = {}
321
322        for size in range(1, max_terms + 1):
323            seen: set[tuple[int, ...]] = set()
324            children: list[tuple[int, ...]] = []
325            for parent in beam:
326                scores = self._residual_scores(parent)
327                ranked = available[_descending(scores[available])]
328                blocked = self._blocked(parent.indices)
329                taken = 0
330                for candidate in ranked:
331                    if taken >= candidates:
332                        break
333                    index = int(candidate)
334                    if index in parent.indices or (blocked is not None and blocked[index]):
335                        continue
336                    child = tuple(sorted((*parent.indices, index)))
337                    if child in seen:
338                        continue
339                    seen.add(child)
340                    children.append(child)
341                    taken += 1
342            # Every child of this step has the same size, which is what lets them go
343            # through the solver as one stack rather than one at a time.
344            generated = self._evaluate_many(children)
345            if not generated:
346                break
347            generated.sort(key=lambda item: item.rss)
348            beam = generated[:beam_width]
349            best[size] = self._refine(beam[0], available, refine_rounds, candidates)
350            if best[size].rss < beam[0].rss:
351                beam[0] = best[size]
352        return best
353
354    def _refine(
355        self,
356        subset: Subset,
357        available: np.ndarray,
358        rounds: int = REFINE_ROUNDS_DEFAULT,
359        candidates: int = CANDIDATE_POOL_DEFAULT,
360    ) -> Subset:
361        """Local search: try replacing each chosen term with a better one.
362
363        Beam search still fixes early choices under a narrow width. Swapping one term at
364        a time recovers the cases where a term that looked good at step two is dominated
365        once the rest of the equation is in place.
366        """
367        current = subset
368        for _ in range(rounds):
369            improved = False
370            warmed: tuple[int, ...] | None = None
371            for position in range(len(current.indices)):
372                # The probe for every position of one subset is a leave-one-out of it, so all
373                # of them are the same size and price in a single stacked solve. Priced up
374                # front and read out of the cache position by position; `_evaluate_many` shares
375                # the cache with `_evaluate`, so this only changes when the solves happen.
376                if warmed != current.indices:
377                    warmed = current.indices
378                    if len(warmed) > 1:
379                        self._evaluate_many(
380                            [tuple(x for i, x in enumerate(warmed) if i != p) for p in range(len(warmed))]
381                        )
382                remaining = tuple(x for i, x in enumerate(current.indices) if i != position)
383                probe = self._evaluate(remaining) if remaining else Subset((), np.zeros(0), self.total)
384                scores = self._residual_scores(probe) if remaining else np.abs(self.projection)
385                ranked = available[_descending(scores[available])][:candidates]
386                blocked = self._blocked(remaining)
387                trials = [
388                    tuple(sorted((*remaining, int(candidate))))
389                    for candidate in ranked
390                    if int(candidate) not in remaining and (blocked is None or not blocked[int(candidate)])
391                ]
392                # Scanned in ranked order for the *first* improvement, exactly as the
393                # one-at-a-time loop did. Evaluating the rest of the batch is not waste:
394                # a refinement round that improves nothing has to price every candidate
395                # anyway, and anything priced here is cached for the rounds after it.
396                for trial in self._evaluate_many(trials):
397                    if trial.rss < current.rss - 1e-12:
398                        current = trial
399                        improved = True
400                        break
401            if not improved:
402                break
403        return current
404
405
406@dataclass(frozen=True)
407class SearchResult:
408    """Everything one search produces: the equation per size, and the pool it drew from.
409
410    One search yields the whole curve rather than one equation, because the beam records its
411    best subset at every size as it grows. The selection rules read that complete curve, which
412    is why the selected length is not a parameter here.
413    """
414
415    equations: dict[int, Equation]
416    pool_size: int
417
418    def best(self, max_terms: int | None = None) -> Equation:
419        sizes = [size for size in self.equations if max_terms is None or size <= max_terms]
420        return self.equations[max(sizes)]
421
422
423def search(
424    library: Library,
425    target: np.ndarray,
426    *,
427    max_terms: int = 12,
428    penalty: float = RIDGE_DEFAULT,
429    pool_size: int = 250,
430    beam_width: int = BEAM_WIDTH_DEFAULT,
431    candidates: int = CANDIDATE_POOL_DEFAULT,
432    refine_rounds: int = REFINE_ROUNDS_DEFAULT,
433    name: str = "equation",
434) -> SearchResult:
435    """Search for the best subset of every size up to ``max_terms`` over the given library.
436
437    Screen, then beam, then fold the standardisation back out. The weights each returned
438    equation carries come from `fit`; what this function decides is which terms carry them.
439    """
440    standardizer = Standardizer.fit(library.matrix)
441    design = standardizer.apply(library.matrix)
442    pool = guided_screen(library, target, keep=pool_size)
443    selector = Selector(design, target, penalty, library.feature_groups)
444    subsets = selector.search(
445        pool,
446        max_terms,
447        beam_width=beam_width,
448        candidates=candidates,
449        refine_rounds=refine_rounds,
450    )
451    equations = {
452        size: to_equation(library, subset, standardizer, selector.offset, f"{name}_k{size}")
453        for size, subset in subsets.items()
454    }
455    return SearchResult(equations=equations, pool_size=len(pool))
456
457
458#: A term whose contribution never moves predicted MCC by this much across the data is
459#: not doing work worth printing. The default is deliberately well below the resolution
460#: anyone reads MCC at.
461MIN_CONTRIBUTION = 0.002
462
463
464def prune(
465    equation: Equation,
466    columns: dict[str, np.ndarray],
467    target: np.ndarray,
468    *,
469    penalty: float = RIDGE_DEFAULT,
470    min_contribution: float = MIN_CONTRIBUTION,
471) -> Equation:
472    """Drop terms that do no work, simplify what remains, and refit the weights.
473
474    Two things make a published equation longer than it needs to be.
475
476    A term can survive selection and then contribute nothing: beam search adds terms while
477    residual sum of squares falls, and the last few can fall by amounts invisible in the
478    output. One dendrogram-cut equation carried a term weighted -3.7e-15 -- a full
479    expression over six features, contributing zero.
480
481    A term can also be algebraically redundant, computing ``a`` while printing
482    ``([a] / [b]) * [b]``. ``terms.simplify`` handles that, and terms that reduce to a
483    constant are removed outright since they duplicate the intercept.
484
485    Weights are refitted after pruning rather than carried over. Dropping a term changes
486    what the others should be, and keeping stale weights would leave an equation that is
487    shorter but no longer the best fit of its own remaining terms.
488    """
489    if not equation.terms:
490        return equation
491
492    contributions = np.column_stack(
493        [weight * term.evaluate(columns) for term, weight in zip(equation.terms, equation.weights, strict=True)]
494    )
495    keep: list[Term] = []
496    seen: set[str] = set()
497    for index, term in enumerate(equation.terms):
498        column = contributions[:, index]
499        low, high = np.percentile(column, [1.0, 99.0])
500        if float(high - low) < min_contribution:
501            continue
502        reduced = simplify(term)
503        if is_trivial(reduced) or reduced.name in seen:
504            continue
505        seen.add(reduced.name)
506        keep.append(reduced)
507
508    if not keep:
509        return Equation(
510            intercept=float(target.mean()), terms=(), weights=(), standardized_weights=(), name=equation.name
511        )
512
513    library = Library(keep, columns)
514    standardizer = Standardizer.fit(library.matrix)
515    selector = Selector(standardizer.apply(library.matrix), target, penalty, library.feature_groups)
516    subset = selector._evaluate(tuple(range(len(library))))
517    return to_equation(library, subset, standardizer, selector.offset, equation.name)
518
519
520def selected_terms(equation: Equation) -> list[Term]:
521    """The terms of an equation, strongest standardised contribution first."""
522    return [term for term, _, _ in equation.ranked_terms()]
BEAM_WIDTH_DEFAULT = 6
CANDIDATE_POOL_DEFAULT = 24
COLLINEARITY_LIMIT = 0.95
REFINE_ROUNDS_DEFAULT = 2
def transform_gap(values: numpy.ndarray, target: numpy.ndarray) -> float:
70def transform_gap(values: np.ndarray, target: np.ndarray) -> float:
71    """How much more monotone than linear a feature's relation to the target is.
72
73    Near zero means a plain linear term is adequate. Clearly positive means a transform
74    (log, inverse, power) or a ratio is likely to pay, because the association is there
75    but a straight line cannot capture it.
76    """
77    return abs(spearman(values, target)) - abs(pearson(values, target))

How much more monotone than linear a feature's relation to the target is.

Near zero means a plain linear term is adequate. Clearly positive means a transform (log, inverse, power) or a ratio is likely to pay, because the association is there but a straight line cannot capture it.

def guided_screen( library: ml_meta_perf.terms.Library, target: numpy.ndarray, *, keep: int, linear_gap: float = 0.05) -> list[int]:
 80def guided_screen(
 81    library: Library,
 82    target: np.ndarray,
 83    *,
 84    keep: int,
 85    linear_gap: float = 0.05,
 86) -> list[int]:
 87    """Reduce the library to the terms worth searching over.
 88
 89    Terms are ranked by the stronger of their two correlations, so a term that is
 90    strongly monotone but weakly linear survives -- it is exactly the term a linear
 91    equation needs a transform for. Terms that are near-duplicates of a stronger term
 92    already kept are dropped, since the beam would otherwise spend its width on
 93    variations of one idea.
 94    """
 95    matrix = library.matrix
 96    # Ranking is the expensive half of a Spearman correlation -- it sorts, then averages
 97    # ties -- and screening a 600-term pool inside 20 folds asks for it tens of thousands
 98    # of times. Nothing here is per-candidate: the target's ranks do not change from one
 99    # candidate to the next, the candidates' own ranks are one vectorised pass over the
100    # matrix (`stats.rank_columns`), and both correlations are one matrix-vector product
101    # over the whole pool (`stats.pearson_columns`) rather than a `pearson` call per column.
102    ranked_target = rankdata(target)
103    ranked = rank_columns(matrix) if matrix.shape[0] > 1 else matrix
104    linear = np.abs(pearson_columns(matrix, target))
105    monotone = np.abs(pearson_columns(ranked, ranked_target)) if matrix.shape[0] > 1 else np.zeros(matrix.shape[1])
106    # A term that is only monotone is still useful, but it is preferred a little
107    # less than an equally strong linear one: linear terms read more simply.
108    strength = np.maximum(linear, monotone) - np.where(monotone - linear > linear_gap, 0.02, 0.0)
109    # Descending by strength, ties broken by ascending library index, for the reason
110    # `_descending` sets out: the survivors of the duplicate pass below must be a property
111    # of the library rather than of the order a caller assembled it in.
112    scored = _descending(strength)
113
114    standardized = Standardizer.fit(matrix).apply(matrix)
115    rows = float(standardized.shape[0])
116
117    # The duplicate check is one matrix-vector product against the columns kept so far,
118    # not a Python loop of dot products over them. At 1599 candidates keeping 1200 the
119    # loop form ran 17 million times and accounted for over half the total runtime of a
120    # cross-validated sweep; this is the same arithmetic in one BLAS call per candidate.
121    accepted = np.empty((standardized.shape[0], min(keep, standardized.shape[1])))
122    kept: list[int] = []
123    for position in scored:
124        if len(kept) >= keep:
125            break
126        index = int(position)
127        column = standardized[:, index]
128        if kept:
129            correlations = np.abs(column @ accepted[:, : len(kept)]) / rows
130            if float(correlations.max()) > 0.995:
131                continue
132        accepted[:, len(kept)] = column
133        kept.append(index)
134    return kept

Reduce the library to the terms worth searching over.

Terms are ranked by the stronger of their two correlations, so a term that is strongly monotone but weakly linear survives -- it is exactly the term a linear equation needs a transform for. Terms that are near-duplicates of a stronger term already kept are dropped, since the beam would otherwise spend its width on variations of one idea.

class Selector:
155class Selector:
156    """Beam search over term subsets against a fixed, standardised design."""
157
158    def __init__(
159        self,
160        design: np.ndarray,
161        target: np.ndarray,
162        penalty: float,
163        groups: np.ndarray | None = None,
164    ) -> None:
165        self.design = design
166        self.centered = target - target.mean()
167        self.offset = float(target.mean())
168        self.penalty = penalty
169        self.gram = design.T @ design
170        # The ridge penalty added to the *full* Gram diagonal, once. A submatrix's
171        # diagonal is drawn from the full diagonal, so ``penalized[ix, ix]`` already
172        # equals ``gram[ix, ix] + penalty*I`` -- which removes a copy, an arange and a
173        # fancy-index assignment from every one of the million solves a study runs.
174        # ``gram`` itself stays unpenalised: the residual scoring and the collinearity
175        # guard both need it that way.
176        self.penalized = self.gram.copy()
177        if penalty:
178            self.penalized.flat[:: self.penalized.shape[0] + 1] += penalty
179        self.projection = design.T @ self.centered
180        self.total = float(self.centered @ self.centered)
181        self.normalizer = float(design.shape[0])
182        # The collinearity test as a boolean matrix, taken once. ``_blocked`` runs it for
183        # every parent of every beam step -- tens of thousands of times per fit -- and the
184        # arithmetic does not depend on the parent, only the columns selected out of it.
185        # Deciding it here turns each call into a bool gather over an eighth of the bytes.
186        self._collinear = np.abs(self.gram) / self.normalizer > COLLINEARITY_LIMIT
187        self._cache: dict[tuple[int, ...], Subset] = {}
188        # `terms.Library.feature_groups`, expanded once into a membership matrix so that
189        # `_blocked` is a row gather and an `any`, not an `np.isin` over the pool on each
190        # of several hundred thousand calls. Row ``g`` marks every term in group ``g``.
191        self.groups = groups
192        self._members: np.ndarray | None = None
193        if groups is not None and groups.size:
194            count = int(groups.max()) + 1
195            if count > 0:
196                members = np.zeros((count, groups.shape[0]), dtype=bool)
197                present = groups >= 0
198                members[groups[present], np.flatnonzero(present)] = True
199                self._members = members
200
201    def _evaluate(self, indices: tuple[int, ...]) -> Subset:
202        """Fit one subset. Memoised, because the refinement pass revisits subsets.
203
204        The residual sum of squares uses an identity rather than a second quadratic form.
205        Since ``(G + lambda I) w = b``, we have ``w'Gw = w'b - lambda w'w``, so
206
207            rss = total - 2 w'b + w'Gw = total - w'b - lambda w'w
208
209        which removes a matrix-vector product from the inner loop. The two are equal to
210        floating-point tolerance whenever the solve succeeded, which ``test_fit`` checks
211        directly against the literal definition.
212        """
213        cached = self._cache.get(indices)
214        if cached is not None:
215            return cached
216
217        order = np.array(indices, dtype=np.intp)
218        # ``penalized[order[:, None], order]`` rather than ``np.ix_(order, order)``.
219        # Identical arithmetic: ix_ exists to build exactly this pair of broadcast index
220        # arrays, and it spends most of its time on dtype checks already known to hold
221        # here. At a million calls that check cost 24 of the study's 137 seconds, and
222        # doing the reshape directly is 1.8x faster.
223        block = self.penalized[order[:, None], order]
224        rhs = self.projection[order]
225        try:
226            weights = np.linalg.solve(block, rhs)
227        except np.linalg.LinAlgError:
228            weights = np.linalg.lstsq(block, rhs, rcond=None)[0]
229        rss = self.total - float(weights @ rhs) - self.penalty * float(weights @ weights)
230        subset = Subset(indices, weights, rss)
231        self._cache[indices] = subset
232        return subset
233
234    def _evaluate_many(self, batch: list[tuple[int, ...]]) -> list[Subset]:
235        """Fit several subsets **of the same size** in one stacked solve.
236
237        A beam step and a refinement position both produce a few dozen candidate subsets
238        that differ by one index, and every one of them is a ``k``-by-``k`` solve. At
239        that size numpy's per-call wrapper -- dtype promotion, array coercion, the
240        errstate context manager -- costs several times the LAPACK call it guards, and
241        the study makes over a million of them. Stacking the blocks into one ``(n, k, k)``
242        array moves that loop into C: same routine per slice, bitwise identical weights,
243        two to ten times faster depending on ``k``.
244
245        Order is preserved and the cache is shared with `_evaluate`, so callers see
246        exactly what the one-at-a-time path gave them.
247        """
248        pending: list[tuple[int, ...]] = []
249        queued: set[tuple[int, ...]] = set()
250        for indices in batch:
251            if indices not in self._cache and indices not in queued:
252                queued.add(indices)
253                pending.append(indices)
254
255        if pending:
256            order = np.array(pending, dtype=np.intp)
257            blocks = self.penalized[order[:, :, None], order[:, None, :]]
258            rhs = self.projection[order]
259            try:
260                weights = np.linalg.solve(blocks, rhs[..., None])[..., 0]
261            except np.linalg.LinAlgError:
262                # One singular block fails the whole stack, so fall back to the
263                # per-subset path, which has its own least-squares rescue.
264                for indices in pending:
265                    self._evaluate(indices)
266            else:
267                scores = self.total - np.einsum("ij,ij->i", weights, rhs)
268                if self.penalty:
269                    scores -= self.penalty * np.einsum("ij,ij->i", weights, weights)
270                for indices, row, rss in zip(pending, weights, scores, strict=True):
271                    self._cache[indices] = Subset(indices, row, float(rss))
272
273        return [self._cache[indices] for indices in batch]
274
275    def _residual_scores(self, subset: Subset) -> np.ndarray:
276        order = np.array(subset.indices, dtype=np.intp)
277        return np.abs(self.projection - self.gram[:, order] @ subset.weights)
278
279    def _blocked(self, indices: tuple[int, ...]) -> np.ndarray | None:
280        """Which candidates this parent may not take, as one boolean mask.
281
282        Computed per parent rather than per candidate. The per-candidate form rebuilt an
283        index array and took a max on every one of several hundred thousand calls; this
284        does the same work as a single vectorised reduction over the Gram matrix.
285
286        Two independent refusals, and they answer different questions. The correlation
287        test asks whether a candidate is numerically redundant against what is already
288        chosen. The group test asks whether it would state an *already-stated relationship
289        a second time* -- see `terms.Library.feature_groups`. Neither implies the other:
290        the mirrored pairs the group test removes sat at 0.891 and 0.786, comfortably
291        inside ``COLLINEARITY_LIMIT``, and two terms may correlate above the limit while
292        describing different feature pairs entirely.
293        """
294        if not indices:
295            return None
296        order = np.array(indices, dtype=np.intp)
297        blocked = self._collinear[:, order].any(axis=1)
298        if self._members is None or self.groups is None:
299            return blocked
300        chosen = self.groups[order]
301        chosen = chosen[chosen >= 0]
302        if chosen.size == 0:
303            return blocked
304        # No `np.unique` on the way in: a repeated group id gathers the same membership row
305        # twice and an `any` over it reduces to the same mask, so the sort it costs bought
306        # nothing across several hundred thousand calls.
307        return blocked | self._members[chosen].any(axis=0)
308
309    def search(
310        self,
311        pool: list[int],
312        max_terms: int,
313        *,
314        beam_width: int = BEAM_WIDTH_DEFAULT,
315        candidates: int = CANDIDATE_POOL_DEFAULT,
316        refine_rounds: int = REFINE_ROUNDS_DEFAULT,
317    ) -> dict[int, Subset]:
318        """Return the best subset found at every size from 1 to ``max_terms``."""
319        available = np.array(pool)
320        beam: list[Subset] = [Subset((), np.zeros(0), self.total)]
321        best: dict[int, Subset] = {}
322
323        for size in range(1, max_terms + 1):
324            seen: set[tuple[int, ...]] = set()
325            children: list[tuple[int, ...]] = []
326            for parent in beam:
327                scores = self._residual_scores(parent)
328                ranked = available[_descending(scores[available])]
329                blocked = self._blocked(parent.indices)
330                taken = 0
331                for candidate in ranked:
332                    if taken >= candidates:
333                        break
334                    index = int(candidate)
335                    if index in parent.indices or (blocked is not None and blocked[index]):
336                        continue
337                    child = tuple(sorted((*parent.indices, index)))
338                    if child in seen:
339                        continue
340                    seen.add(child)
341                    children.append(child)
342                    taken += 1
343            # Every child of this step has the same size, which is what lets them go
344            # through the solver as one stack rather than one at a time.
345            generated = self._evaluate_many(children)
346            if not generated:
347                break
348            generated.sort(key=lambda item: item.rss)
349            beam = generated[:beam_width]
350            best[size] = self._refine(beam[0], available, refine_rounds, candidates)
351            if best[size].rss < beam[0].rss:
352                beam[0] = best[size]
353        return best
354
355    def _refine(
356        self,
357        subset: Subset,
358        available: np.ndarray,
359        rounds: int = REFINE_ROUNDS_DEFAULT,
360        candidates: int = CANDIDATE_POOL_DEFAULT,
361    ) -> Subset:
362        """Local search: try replacing each chosen term with a better one.
363
364        Beam search still fixes early choices under a narrow width. Swapping one term at
365        a time recovers the cases where a term that looked good at step two is dominated
366        once the rest of the equation is in place.
367        """
368        current = subset
369        for _ in range(rounds):
370            improved = False
371            warmed: tuple[int, ...] | None = None
372            for position in range(len(current.indices)):
373                # The probe for every position of one subset is a leave-one-out of it, so all
374                # of them are the same size and price in a single stacked solve. Priced up
375                # front and read out of the cache position by position; `_evaluate_many` shares
376                # the cache with `_evaluate`, so this only changes when the solves happen.
377                if warmed != current.indices:
378                    warmed = current.indices
379                    if len(warmed) > 1:
380                        self._evaluate_many(
381                            [tuple(x for i, x in enumerate(warmed) if i != p) for p in range(len(warmed))]
382                        )
383                remaining = tuple(x for i, x in enumerate(current.indices) if i != position)
384                probe = self._evaluate(remaining) if remaining else Subset((), np.zeros(0), self.total)
385                scores = self._residual_scores(probe) if remaining else np.abs(self.projection)
386                ranked = available[_descending(scores[available])][:candidates]
387                blocked = self._blocked(remaining)
388                trials = [
389                    tuple(sorted((*remaining, int(candidate))))
390                    for candidate in ranked
391                    if int(candidate) not in remaining and (blocked is None or not blocked[int(candidate)])
392                ]
393                # Scanned in ranked order for the *first* improvement, exactly as the
394                # one-at-a-time loop did. Evaluating the rest of the batch is not waste:
395                # a refinement round that improves nothing has to price every candidate
396                # anyway, and anything priced here is cached for the rounds after it.
397                for trial in self._evaluate_many(trials):
398                    if trial.rss < current.rss - 1e-12:
399                        current = trial
400                        improved = True
401                        break
402            if not improved:
403                break
404        return current

Beam search over term subsets against a fixed, standardised design.

Selector( design: numpy.ndarray, target: numpy.ndarray, penalty: float, groups: numpy.ndarray | None = None)
158    def __init__(
159        self,
160        design: np.ndarray,
161        target: np.ndarray,
162        penalty: float,
163        groups: np.ndarray | None = None,
164    ) -> None:
165        self.design = design
166        self.centered = target - target.mean()
167        self.offset = float(target.mean())
168        self.penalty = penalty
169        self.gram = design.T @ design
170        # The ridge penalty added to the *full* Gram diagonal, once. A submatrix's
171        # diagonal is drawn from the full diagonal, so ``penalized[ix, ix]`` already
172        # equals ``gram[ix, ix] + penalty*I`` -- which removes a copy, an arange and a
173        # fancy-index assignment from every one of the million solves a study runs.
174        # ``gram`` itself stays unpenalised: the residual scoring and the collinearity
175        # guard both need it that way.
176        self.penalized = self.gram.copy()
177        if penalty:
178            self.penalized.flat[:: self.penalized.shape[0] + 1] += penalty
179        self.projection = design.T @ self.centered
180        self.total = float(self.centered @ self.centered)
181        self.normalizer = float(design.shape[0])
182        # The collinearity test as a boolean matrix, taken once. ``_blocked`` runs it for
183        # every parent of every beam step -- tens of thousands of times per fit -- and the
184        # arithmetic does not depend on the parent, only the columns selected out of it.
185        # Deciding it here turns each call into a bool gather over an eighth of the bytes.
186        self._collinear = np.abs(self.gram) / self.normalizer > COLLINEARITY_LIMIT
187        self._cache: dict[tuple[int, ...], Subset] = {}
188        # `terms.Library.feature_groups`, expanded once into a membership matrix so that
189        # `_blocked` is a row gather and an `any`, not an `np.isin` over the pool on each
190        # of several hundred thousand calls. Row ``g`` marks every term in group ``g``.
191        self.groups = groups
192        self._members: np.ndarray | None = None
193        if groups is not None and groups.size:
194            count = int(groups.max()) + 1
195            if count > 0:
196                members = np.zeros((count, groups.shape[0]), dtype=bool)
197                present = groups >= 0
198                members[groups[present], np.flatnonzero(present)] = True
199                self._members = members
design
centered
offset
penalty
gram
penalized
projection
total
normalizer
groups
def search( self, pool: list[int], max_terms: int, *, beam_width: int = 6, candidates: int = 24, refine_rounds: int = 2) -> dict[int, ml_meta_perf.fit.Subset]:
309    def search(
310        self,
311        pool: list[int],
312        max_terms: int,
313        *,
314        beam_width: int = BEAM_WIDTH_DEFAULT,
315        candidates: int = CANDIDATE_POOL_DEFAULT,
316        refine_rounds: int = REFINE_ROUNDS_DEFAULT,
317    ) -> dict[int, Subset]:
318        """Return the best subset found at every size from 1 to ``max_terms``."""
319        available = np.array(pool)
320        beam: list[Subset] = [Subset((), np.zeros(0), self.total)]
321        best: dict[int, Subset] = {}
322
323        for size in range(1, max_terms + 1):
324            seen: set[tuple[int, ...]] = set()
325            children: list[tuple[int, ...]] = []
326            for parent in beam:
327                scores = self._residual_scores(parent)
328                ranked = available[_descending(scores[available])]
329                blocked = self._blocked(parent.indices)
330                taken = 0
331                for candidate in ranked:
332                    if taken >= candidates:
333                        break
334                    index = int(candidate)
335                    if index in parent.indices or (blocked is not None and blocked[index]):
336                        continue
337                    child = tuple(sorted((*parent.indices, index)))
338                    if child in seen:
339                        continue
340                    seen.add(child)
341                    children.append(child)
342                    taken += 1
343            # Every child of this step has the same size, which is what lets them go
344            # through the solver as one stack rather than one at a time.
345            generated = self._evaluate_many(children)
346            if not generated:
347                break
348            generated.sort(key=lambda item: item.rss)
349            beam = generated[:beam_width]
350            best[size] = self._refine(beam[0], available, refine_rounds, candidates)
351            if best[size].rss < beam[0].rss:
352                beam[0] = best[size]
353        return best

Return the best subset found at every size from 1 to max_terms.

@dataclass(frozen=True)
class SearchResult:
407@dataclass(frozen=True)
408class SearchResult:
409    """Everything one search produces: the equation per size, and the pool it drew from.
410
411    One search yields the whole curve rather than one equation, because the beam records its
412    best subset at every size as it grows. The selection rules read that complete curve, which
413    is why the selected length is not a parameter here.
414    """
415
416    equations: dict[int, Equation]
417    pool_size: int
418
419    def best(self, max_terms: int | None = None) -> Equation:
420        sizes = [size for size in self.equations if max_terms is None or size <= max_terms]
421        return self.equations[max(sizes)]

Everything one search produces: the equation per size, and the pool it drew from.

One search yields the whole curve rather than one equation, because the beam records its best subset at every size as it grows. The selection rules read that complete curve, which is why the selected length is not a parameter here.

SearchResult(equations: dict[int, ml_meta_perf.model.Equation], pool_size: int)
equations: dict[int, ml_meta_perf.model.Equation]
pool_size: int
def best(self, max_terms: int | None = None) -> ml_meta_perf.model.Equation:
419    def best(self, max_terms: int | None = None) -> Equation:
420        sizes = [size for size in self.equations if max_terms is None or size <= max_terms]
421        return self.equations[max(sizes)]
MIN_CONTRIBUTION = 0.002
def prune( equation: ml_meta_perf.model.Equation, columns: dict[str, numpy.ndarray], target: numpy.ndarray, *, penalty: float = 10.0, min_contribution: float = 0.002) -> ml_meta_perf.model.Equation:
465def prune(
466    equation: Equation,
467    columns: dict[str, np.ndarray],
468    target: np.ndarray,
469    *,
470    penalty: float = RIDGE_DEFAULT,
471    min_contribution: float = MIN_CONTRIBUTION,
472) -> Equation:
473    """Drop terms that do no work, simplify what remains, and refit the weights.
474
475    Two things make a published equation longer than it needs to be.
476
477    A term can survive selection and then contribute nothing: beam search adds terms while
478    residual sum of squares falls, and the last few can fall by amounts invisible in the
479    output. One dendrogram-cut equation carried a term weighted -3.7e-15 -- a full
480    expression over six features, contributing zero.
481
482    A term can also be algebraically redundant, computing ``a`` while printing
483    ``([a] / [b]) * [b]``. ``terms.simplify`` handles that, and terms that reduce to a
484    constant are removed outright since they duplicate the intercept.
485
486    Weights are refitted after pruning rather than carried over. Dropping a term changes
487    what the others should be, and keeping stale weights would leave an equation that is
488    shorter but no longer the best fit of its own remaining terms.
489    """
490    if not equation.terms:
491        return equation
492
493    contributions = np.column_stack(
494        [weight * term.evaluate(columns) for term, weight in zip(equation.terms, equation.weights, strict=True)]
495    )
496    keep: list[Term] = []
497    seen: set[str] = set()
498    for index, term in enumerate(equation.terms):
499        column = contributions[:, index]
500        low, high = np.percentile(column, [1.0, 99.0])
501        if float(high - low) < min_contribution:
502            continue
503        reduced = simplify(term)
504        if is_trivial(reduced) or reduced.name in seen:
505            continue
506        seen.add(reduced.name)
507        keep.append(reduced)
508
509    if not keep:
510        return Equation(
511            intercept=float(target.mean()), terms=(), weights=(), standardized_weights=(), name=equation.name
512        )
513
514    library = Library(keep, columns)
515    standardizer = Standardizer.fit(library.matrix)
516    selector = Selector(standardizer.apply(library.matrix), target, penalty, library.feature_groups)
517    subset = selector._evaluate(tuple(range(len(library))))
518    return to_equation(library, subset, standardizer, selector.offset, equation.name)

Drop terms that do no work, simplify what remains, and refit the weights.

Two things make a published equation longer than it needs to be.

A term can survive selection and then contribute nothing: beam search adds terms while residual sum of squares falls, and the last few can fall by amounts invisible in the output. One dendrogram-cut equation carried a term weighted -3.7e-15 -- a full expression over six features, contributing zero.

A term can also be algebraically redundant, computing a while printing ([a] / [b]) * [b]. terms.simplify handles that, and terms that reduce to a constant are removed outright since they duplicate the intercept.

Weights are refitted after pruning rather than carried over. Dropping a term changes what the others should be, and keeping stale weights would leave an equation that is shorter but no longer the best fit of its own remaining terms.

def selected_terms(equation: ml_meta_perf.model.Equation) -> list[ml_meta_perf.terms.Term]:
521def selected_terms(equation: Equation) -> list[Term]:
522    """The terms of an equation, strongest standardised contribution first."""
523    return [term for term, _, _ in equation.ranked_terms()]

The terms of an equation, strongest standardised contribution first.