ml_meta_perf.terms

The term library: the vocabulary the equations are allowed to use.

A term is a small, named, serialisable expression over one to three raw features -- f, log(f), 1/f, f^2, f1/f2, (f1+f2)/f3 and friends. Terms are built once from the training columns, evaluated into a design matrix, and the selected ones are what the published equation prints. Keeping them as data (rather than as closures) is what lets a fitted equation round-trip through JSON.

The raw features span wildly different magnitudes -- gravity runs from 1.6 to 1e16 -- so composite terms are built over log-compressed operands wherever the feature is strictly positive. Dividing raw gravity by anything produces a term whose weight is 1e-16 and whose meaning is unreadable.

This vocabulary assumes strictly positive, continuous features, and degrades quietly when given anything else. The assumption is worth stating because nothing here raises when it is violated -- the affected terms are simply never generated, and a caller adding a feature sees a smaller library rather than an error. For a feature that reaches zero, log, sqrt and 1/f are all undefined (Atom.is_defined_on) and it can never be a denominator (denominator_atom), which already excludes nr_norm, nr_bin and nr_outliers from three quarters of the grammar. For a binary feature only f and f^2 survive, and those are the same column twice -- see Library. What is left to a 0/1 column is addition and multiplication, and the two are not equally safe: inside a sum it contributes a level to every row, while inside a product it zeroes the term entirely on the rows where it is off, which is a per-group slope rather than a relationship. Prefer a continuous descriptor that grades the same distinction.

Study chapter: 2. The additive model -- the rationale, in prose, with the figures.

  1"""The term library: the vocabulary the equations are allowed to use.
  2
  3A term is a small, named, serialisable expression over one to three raw features --
  4``f``, ``log(f)``, ``1/f``, ``f^2``, ``f1/f2``, ``(f1+f2)/f3`` and friends. Terms are
  5built once from the training columns, evaluated into a design matrix, and the selected
  6ones are what the published equation prints. Keeping them as data (rather than as
  7closures) is what lets a fitted equation round-trip through JSON.
  8
  9The raw features span wildly different magnitudes -- ``gravity`` runs from 1.6 to 1e16 --
 10so composite terms are built over log-compressed operands wherever the feature is
 11strictly positive. Dividing raw ``gravity`` by anything produces a term whose weight is
 121e-16 and whose meaning is unreadable.
 13
 14**This vocabulary assumes strictly positive, continuous features, and degrades quietly
 15when given anything else.** The assumption is worth stating because nothing here raises
 16when it is violated -- the affected terms are simply never generated, and a caller adding
 17a feature sees a smaller library rather than an error. For a feature that reaches zero,
 18``log``, ``sqrt`` and ``1/f`` are all undefined (`Atom.is_defined_on`) and it can never be
 19a denominator (`denominator_atom`), which already excludes ``nr_norm``, ``nr_bin`` and
 20``nr_outliers`` from three quarters of the grammar. For a **binary** feature only ``f`` and
 21``f^2`` survive, and those are the same column twice -- see `Library`. What is left to a
 220/1 column is addition and multiplication, and the two are not equally safe: inside a sum
 23it contributes a level to every row, while inside a product it zeroes the term entirely on
 24the rows where it is off, which is a per-group slope rather than a relationship. Prefer a
 25continuous descriptor that grades the same distinction.
 26
 27Study chapter: [2. The additive model][study-chapter] -- the rationale, in
 28prose, with the figures.
 29
 30[study-chapter]: https://github.com/mariolpantunes/ml-meta-perf/blob/main/assets/docs/02-additive-model.md
 31"""
 32
 33from __future__ import annotations
 34
 35import itertools
 36from dataclasses import dataclass
 37from typing import Literal
 38
 39import numpy as np
 40
 41Transform = Literal["id", "log", "sqrt", "inv", "sq"]
 42Operation = Literal["atom", "ratio", "product", "sum_ratio", "ratio_of_sums"]
 43
 44DENOMINATOR_FLOOR = 1e-9
 45MAX_ABS_ZSCORE = 8.0
 46MIN_RELATIVE_SPREAD = 1e-6
 47MAX_DENOMINATOR_RANGE = 20.0
 48
 49#: Two terms count as the same column when their centred correlation reaches this. At
 50#: ``1 - 1e-9`` the pair is indistinguishable to the solver anyway, so the threshold
 51#: separates "algebraically identical" from "merely similar" rather than imposing a taste.
 52COLLINEARITY_TOLERANCE = 1.0 - 1e-9
 53
 54#: Every elementary transform a unary term may use.
 55TRANSFORMS: tuple[Transform, ...] = ("id", "log", "sqrt", "inv", "sq")
 56
 57_TRANSFORM_FORMAT: dict[Transform, str] = {
 58    "id": "{0}",
 59    "log": "log({0})",
 60    "sqrt": "sqrt({0})",
 61    "inv": "1/{0}",
 62    "sq": "{0}^2",
 63}
 64
 65
 66@dataclass(frozen=True)
 67class Atom:
 68    """One raw feature under one elementary transform."""
 69
 70    feature: str
 71    transform: Transform = "id"
 72
 73    @property
 74    def name(self) -> str:
 75        return _TRANSFORM_FORMAT[self.transform].format(self.feature)
 76
 77    def evaluate(self, columns: dict[str, np.ndarray]) -> np.ndarray:
 78        values = columns[self.feature]
 79        match self.transform:
 80            case "id":
 81                return values
 82            case "log":
 83                return np.log(values)
 84            case "sqrt":
 85                return np.sqrt(values)
 86            case "inv":
 87                return 1.0 / values
 88            case "sq":
 89                return values * values
 90
 91    def is_defined_on(self, columns: dict[str, np.ndarray]) -> bool:
 92        """Whether the transform is real-valued and finite over these columns."""
 93        values = columns.get(self.feature)
 94        if values is None:
 95            return False
 96        if self.transform in ("log", "sqrt", "inv"):
 97            return bool(np.all(values > 0.0))
 98        return True
 99
100
101@dataclass(frozen=True)
102class Term:
103    """A named expression over one to three operands.
104
105    An operand is an `Atom` or, when the term is nested, another `Term`. Nesting lets one
106    term carry a deep expression -- ``([log(a)] / [b]) * [log(c)]`` -- which matters
107    because the equation's length is counted in *terms*, not in operations. A short
108    equation over deep terms can be easier to read than a long one over shallow terms,
109    and ``depth`` is what keeps that trade under control.
110    """
111
112    operation: Operation
113    operands: tuple[Atom | Term, ...]
114
115    @property
116    def name(self) -> str:
117        parts = [operand.name for operand in self.operands]
118        match self.operation:
119            case "atom":
120                return parts[0]
121            case "ratio":
122                return f"[{parts[0]}] / [{parts[1]}]"
123            case "product":
124                return f"[{parts[0]}] * [{parts[1]}]"
125            case "sum_ratio":
126                return f"([{parts[0]}] + [{parts[1]}]) / [{parts[2]}]"
127            case "ratio_of_sums":
128                return f"([{parts[0]}] + [{parts[1]}]) / ([{parts[2]}] + [{parts[3]}])"
129
130    @property
131    def features(self) -> tuple[str, ...]:
132        collected: list[str] = []
133        for operand in self.operands:
134            if isinstance(operand, Term):
135                collected.extend(operand.features)
136            else:
137                collected.append(operand.feature)
138        return tuple(collected)
139
140    @property
141    def depth(self) -> int:
142        """1 for a term over atoms, one more for each level of nesting."""
143        nested = [operand.depth for operand in self.operands if isinstance(operand, Term)]
144        return 1 + max(nested, default=0)
145
146    def evaluate(self, columns: dict[str, np.ndarray]) -> np.ndarray:
147        values = [operand.evaluate(columns) for operand in self.operands]
148        match self.operation:
149            case "atom":
150                return values[0]
151            case "ratio":
152                return values[0] / _guard(values[1])
153            case "product":
154                return values[0] * values[1]
155            case "sum_ratio":
156                return (values[0] + values[1]) / _guard(values[2])
157            case "ratio_of_sums":
158                return (values[0] + values[1]) / _guard(values[2] + values[3])
159
160    def to_dict(self) -> dict[str, object]:
161        return {
162            "operation": self.operation,
163            "operands": [
164                operand.to_dict()
165                if isinstance(operand, Term)
166                else {"feature": operand.feature, "transform": operand.transform}
167                for operand in self.operands
168            ],
169        }
170
171    @classmethod
172    def from_dict(cls, payload: dict[str, object]) -> Term:
173        operands = payload["operands"]
174        if not isinstance(operands, list):
175            raise ValueError("term payload has no operand list")
176        restored: list[Atom | Term] = []
177        for item in operands:  # pyright: ignore[reportUnknownVariableType]
178            if not isinstance(item, dict):
179                raise ValueError("term operand is not an object")
180            if "operation" in item:
181                restored.append(cls.from_dict(item))  # pyright: ignore[reportUnknownArgumentType]
182            else:
183                restored.append(Atom(str(item["feature"]), item["transform"]))  # pyright: ignore[reportArgumentType]
184        return cls(payload["operation"], tuple(restored))  # pyright: ignore[reportArgumentType]
185
186
187def simplify(term: Term) -> Term:
188    """Apply algebraic identities so a term prints in its shortest equivalent form.
189
190    Nesting produces expressions that are correct but redundant: ``([a] / [b]) * [b]``
191    computes ``a`` and says so in six symbols instead of one. Since the whole claim of
192    this project is that the equation can be read, a term that is longer than it needs to
193    be is a defect, not a cosmetic issue.
194
195    Only exact identities are applied -- cancellation of a matching factor against a
196    matching divisor, and division of a term by itself. Nothing is dropped on numerical
197    grounds here; that is ``search.prune``'s job, and keeping the two separate means this
198    function never changes what a term computes.
199    """
200    operands = tuple(simplify(operand) if isinstance(operand, Term) else operand for operand in term.operands)
201    term = Term(term.operation, operands)
202
203    if term.operation == "product":
204        left, right = term.operands
205        # (x / y) * y  ->  x
206        for numerator_side, other in ((left, right), (right, left)):
207            if isinstance(numerator_side, Term) and numerator_side.operation == "ratio":
208                inner_numerator, inner_divisor = numerator_side.operands
209                if inner_divisor == other:
210                    return _as_term(inner_numerator)
211    elif term.operation == "ratio":
212        numerator, divisor = term.operands
213        # x / x  ->  1, which is collinear with the intercept and carries no information
214        if numerator == divisor:
215            return term
216        # (x * y) / y  ->  x
217        if isinstance(numerator, Term) and numerator.operation == "product":
218            first, second = numerator.operands
219            if second == divisor:
220                return _as_term(first)
221            if first == divisor:
222                return _as_term(second)
223    return term
224
225
226def is_trivial(term: Term) -> bool:
227    """Whether a term reduces to a constant, and so duplicates the intercept."""
228    return term.operation == "ratio" and term.operands[0] == term.operands[1]
229
230
231def _as_term(operand: Atom | Term) -> Term:
232    return operand if isinstance(operand, Term) else Term("atom", (operand,))
233
234
235def _guard(denominator: np.ndarray) -> np.ndarray:
236    """Keep a denominator away from zero without changing its sign.
237
238    A backstop only. Terms whose denominator can actually approach zero are never
239    generated (see ``denominator_atom``), so in a library built by ``build_library``
240    this floor is not what any published term relies on.
241    """
242    return np.where(denominator >= 0.0, 1.0, -1.0) * (np.abs(denominator) + DENOMINATOR_FLOOR)
243
244
245def composition_atom(feature: str, columns: dict[str, np.ndarray]) -> Atom:
246    """The operand used when a feature appears as a numerator or inside a sum.
247
248    Strictly positive features enter log-compressed; the rest enter raw. This is the
249    single decision that keeps composite terms on a comparable scale.
250    """
251    values = columns[feature]
252    return Atom(feature, "log" if bool(np.all(values > 0.0)) else "id")
253
254
255def denominator_atom(feature: str, columns: dict[str, np.ndarray]) -> Atom | None:
256    """The operand to divide by, or ``None`` when this feature cannot safely be one.
257
258    Division is the one operation in the vocabulary that can manufacture a term no
259    linear solver can use: as a denominator approaches zero the term's magnitude runs
260    away, one row comes to hold all of its variance, and the fitted weight becomes an
261    artefact of that row rather than a relationship. The library therefore decides
262    eligibility per feature, before any term is built, rather than generating the term
263    and screening it afterwards.
264
265    Two candidate forms are tried in order of preference and the first that qualifies is
266    used:
267
268    * ``log(f)`` -- compresses scale, but is zero at ``f == 1`` and negative below it,
269      so it is only eligible when the feature stays clear of 1;
270    * ``f`` itself -- eligible when the feature stays clear of 0.
271
272    Eligibility is a bound on **dynamic range**, ``max|d| / min|d| <=
273    MAX_DENOMINATOR_RANGE``, not merely ``min|d| > 0``. That is the criterion that
274    matches the failure: a divisor spanning three orders of magnitude makes the ratio
275    span three orders of magnitude too, so one row holds nearly all of the term's
276    variance and its fitted weight describes that row. Requiring only a non-zero
277    minimum would admit exactly those terms.
278
279    Features that qualify under neither form are never used as denominators. In this
280    meta-dataset that rules out ``nr_norm``, ``nr_bin`` and ``nr_outliers`` (which reach
281    0), the log of anything reaching 1, and wide-range counts such as ``nr_inst``
282    (165 to 7.1M) in raw form -- though ``log(nr_inst)`` qualifies comfortably, which is
283    the compression the preference order exists to find.
284    """
285    for candidate in (Atom(feature, "log"), Atom(feature, "id")):
286        if not candidate.is_defined_on(columns):
287            continue
288        with np.errstate(all="ignore"):
289            evaluated = np.abs(candidate.evaluate(columns))
290        if not np.all(np.isfinite(evaluated)):
291            continue
292        smallest = float(np.min(evaluated))
293        if smallest <= DENOMINATOR_FLOOR:
294            continue
295        if float(np.max(evaluated)) / smallest <= MAX_DENOMINATOR_RANGE:
296            return candidate
297    return None
298
299
300def unary_terms(features: tuple[str, ...], columns: dict[str, np.ndarray]) -> list[Term]:
301    """``f``, ``log(f)``, ``sqrt(f)``, ``1/f`` and ``f^2`` for each feature that admits them."""
302    terms: list[Term] = []
303    for feature in features:
304        for transform in TRANSFORMS:
305            atom = Atom(feature, transform)
306            if atom.is_defined_on(columns):
307                terms.append(Term("atom", (atom,)))
308    return terms
309
310
311def pairwise_terms(
312    left: tuple[str, ...],
313    right: tuple[str, ...],
314    columns: dict[str, np.ndarray],
315    *,
316    both_directions: bool,
317) -> list[Term]:
318    """Ratios and products over two feature groups.
319
320    ``both_directions`` controls whether ``a/b`` and ``b/a`` are both emitted. Within a
321    single group the pairs are unordered and one direction suffices; across the dataset
322    and model groups both directions carry different meaning, so both are kept.
323    """
324    pairs = itertools.product(left, right) if both_directions else itertools.combinations(left, 2)
325    terms: list[Term] = []
326    seen: set[str] = set()
327    for first, second in pairs:
328        if first == second:
329            continue
330        a, b = composition_atom(first, columns), composition_atom(second, columns)
331        # Products are always safe; ratios only exist when the divisor is eligible.
332        candidates = [Term("product", (a, b))]
333        denominator_b = denominator_atom(second, columns)
334        if denominator_b is not None:
335            candidates.append(Term("ratio", (a, denominator_b)))
336        if both_directions:
337            denominator_a = denominator_atom(first, columns)
338            if denominator_a is not None:
339                candidates.append(Term("ratio", (b, denominator_a)))
340        for term in candidates:
341            if term.name not in seen:
342                seen.add(term.name)
343                terms.append(term)
344    return terms
345
346
347def sum_ratio_terms(features: tuple[str, ...], columns: dict[str, np.ndarray]) -> list[Term]:
348    """``(f1 + f2) / f3`` over distinct features, with ``f1`` and ``f2`` unordered.
349
350    Pass **every** feature, not one group. An earlier version was called with the dataset
351    features alone, which silently made the highest-arity operation the only one unable to
352    mix a dataset feature with a model one -- precisely the combination that carries E2's
353    entire lift over E1.
354    """
355    terms: list[Term] = []
356    for first, second in itertools.combinations(features, 2):
357        for third in features:
358            if third in (first, second):
359                continue
360            divisor = denominator_atom(third, columns)
361            if divisor is None:
362                continue
363            operands = (
364                composition_atom(first, columns),
365                composition_atom(second, columns),
366                divisor,
367            )
368            terms.append(Term("sum_ratio", operands))
369    return terms
370
371
372def ratio_of_sums_terms(features: tuple[str, ...], columns: dict[str, np.ndarray]) -> list[Term]:
373    """``(f1 + f2) / (f3 + f4)`` -- the four-feature operation.
374
375    Included so that "three features per term is enough" can be **measured** rather than
376    assumed. A sum is a safe divisor far more often than a single feature is, since adding
377    two log-compressed operands moves the result away from zero, so this reaches
378    combinations ``sum_ratio`` cannot.
379    """
380    terms: list[Term] = []
381    for numerator in itertools.combinations(features, 2):
382        rest = [feature for feature in features if feature not in numerator]
383        for divisor in itertools.combinations(rest, 2):
384            operands = (
385                composition_atom(numerator[0], columns),
386                composition_atom(numerator[1], columns),
387                composition_atom(divisor[0], columns),
388                composition_atom(divisor[1], columns),
389            )
390            with np.errstate(all="ignore"):
391                bottom = operands[2].evaluate(columns) + operands[3].evaluate(columns)
392            if not _is_safe_sum(bottom):
393                continue
394            terms.append(Term("ratio_of_sums", operands))
395    return terms
396
397
398def _is_safe_sum(values: np.ndarray) -> bool:
399    """Whether a summed divisor stays clear of zero with a bounded dynamic range."""
400    magnitude = np.abs(values)
401    if not np.all(np.isfinite(magnitude)):
402        return False
403    smallest = float(np.min(magnitude))
404    if smallest <= DENOMINATOR_FLOOR:
405        return False
406    return float(np.max(magnitude)) / smallest <= MAX_DENOMINATOR_RANGE
407
408
409def is_admissible(values: np.ndarray, max_abs_zscore: float = MAX_ABS_ZSCORE) -> bool:
410    """Whether a term is numerically usable as a column of a linear system.
411
412    This is a backstop, not the main line of defence. The terms that used to fail here
413    were unstable by construction -- ratios whose denominator could reach zero -- and
414    those are no longer generated at all (see ``denominator_atom``). What remains is a
415    cheap check that a caller assembling a ``Library`` by hand, or a transform applied
416    to an unexpected feature, cannot smuggle in a column that breaks the solve.
417
418    Three rejections:
419
420    *Not finite, or constant.* A constant column is collinear with the intercept.
421
422    *Effectively constant.* A term whose spread is negligible against its own magnitude
423    passes an absolute variance check but destroys the published equation: fitting
424    happens on standardised terms, so folding the standardisation back into raw units
425    divides the weight by that spread. One near-constant term produced a weight of
426    -1.5e9 against an intercept of +1.5e9 -- algebraically fine, arithmetically fine,
427    and completely unreadable, which defeats the purpose of the exercise.
428
429    *Driven by one row.* A term is rejected when a single row sits more than
430    ``max_abs_zscore`` standard deviations from the mean, because its fitted weight
431    would describe that row rather than a relationship.
432
433    The cap is sample-size dependent and has to be chosen with that in mind: a single
434    outlier among ``n`` rows can reach a z-score of at most about ``sqrt(n)``, so a cap
435    of 8 constrains the 484-row fit but would be inert on the 20-row aggregated fit,
436    which is why the two configurations do not share a value.
437    """
438    if not np.all(np.isfinite(values)):
439        return False
440    spread = float(np.std(values))
441    if spread <= 1e-12:
442        return False
443    if spread < MIN_RELATIVE_SPREAD * (1.0 + abs(float(np.mean(values)))):
444        return False
445    return float(np.max(np.abs(values - np.mean(values)))) / spread <= max_abs_zscore
446
447
448def _unit(values: np.ndarray) -> np.ndarray:
449    """The centred column scaled to unit length -- the form collinearity is judged in.
450
451    Centring first is what makes the test match the fit: fitting happens on standardised
452    terms, so two columns differing by an additive or multiplicative constant are one
453    column as far as the solve is concerned, however different their raw values look.
454    """
455    centred = values - np.mean(values)
456    norm = float(np.linalg.norm(centred))
457    return centred / norm if norm > 0.0 else centred
458
459
460class Library:
461    """A set of terms together with the design matrix they produce.
462
463    Terms are dropped for three reasons, in order: a repeated *name*, failing
464    `is_admissible`, and -- the subtlest -- being the same column as a term already kept.
465
466    That last check cannot be done on names. ``inst_to_attr`` is ``nr_inst / nr_attr`` in
467    this meta-dataset, so ``[log(inst_to_attr)] + [log(nr_attr)]`` **is**
468    ``log(nr_inst)``, exactly, and the grammar generates both. Three such pairs exist in
469    the published 206-term library. A binary feature produces them too and more bluntly:
470    ``log``, ``sqrt`` and ``1/f`` are all undefined at zero, leaving only ``f`` and
471    ``f^2``, which for a 0/1 column are the same numbers under two names.
472
473    Keeping both members of a pair is wasteful rather than dangerous, and the distinction
474    is worth being precise about. The beam search already refuses a candidate whose
475    correlation with a selected term exceeds ``ml_meta_perf.search.COLLINEARITY_LIMIT`` (0.95), so
476    a duplicate pair cannot both be selected on that path and no singular system arises
477    there. What the duplicates cost is candidate-pool slots, search time, and a place in
478    the reported term rankings, where they appear as two independent findings. The check
479    here also covers the paths ``_blocked`` does not: a ``Library`` assembled by hand, and
480    any consumer reading `terms` or `matrix` directly.
481
482    The first term of a pair wins. Generation order runs simple to complex, so the survivor
483    is the shorter form: ``[log(nr_inst)] / [log(Processing Units Number)]`` is kept and
484    ``([log(inst_to_attr)] + [log(nr_attr)]) / [log(Processing Units Number)]`` is dropped.
485    """
486
487    def __init__(
488        self,
489        terms: list[Term],
490        columns: dict[str, np.ndarray],
491        *,
492        max_abs_zscore: float = MAX_ABS_ZSCORE,
493    ) -> None:
494        kept: list[Term] = []
495        vectors: list[np.ndarray] = []
496        seen: set[str] = set()
497        # The survivors' unit vectors as one growing array rather than a list, so the
498        # duplicate test is a single matrix-vector product against everything kept so far
499        # instead of a Python loop of dot products over it. Identical arithmetic and
500        # identical order -- the first term of a pair still wins -- but the loop form ran
501        # 590k times per study and `search.guided_screen` already does it this way.
502        accepted: np.ndarray | None = None
503        for term in terms:
504            if term.name in seen:
505                continue
506            with np.errstate(all="ignore"):
507                values = term.evaluate(columns)
508            if not is_admissible(values, max_abs_zscore):
509                continue
510            unit = _unit(values)
511            if accepted is None:
512                accepted = np.empty((len(terms), values.shape[0]))
513            elif float(np.abs(accepted[: len(kept)] @ unit).max()) > COLLINEARITY_TOLERANCE:
514                continue
515            accepted[len(kept)] = unit
516            seen.add(term.name)
517            kept.append(term)
518            vectors.append(values)
519        if not kept:
520            raise ValueError("term library is empty after filtering")
521        self.terms: list[Term] = kept
522        self.matrix: np.ndarray = np.column_stack(vectors)
523
524    @property
525    def names(self) -> list[str]:
526        return [term.name for term in self.terms]
527
528    @property
529    def feature_groups(self) -> np.ndarray:
530        """Which terms describe the *same combination of raw features*, as group ids.
531
532        One id per term, ``-1`` for a term over a single feature. Two terms share an id
533        when their feature multisets reduce to the same set, however differently they
534        arrange it: ``[log(a)] / [log(b)]`` and ``[log(b)] / [log(a)]`` are one group, and
535        so are ``[a] * [log(b)]`` and ``[a] / [log(b)]``.
536
537        `ml_meta_perf.search.Selector` refuses to place two terms of one group in the same
538        equation. That is a **readability** rule, not a numerical one, and the distinction
539        matters because the numerical guard already passes: the two mirrored pairs this
540        removed from the previous 16-term equation correlated at 0.891 and 0.786, both
541        under ``search.COLLINEARITY_LIMIT``, in a design conditioned at 7.8. Nothing was
542        ill-posed. What was wrong is that the equation spent two of its sixteen slots
543        writing one relationship both ways up -- ``log(PUN)/log(nr_class)`` beside
544        ``log(nr_class)/log(PUN)``, *both* carrying negative weight -- and the term table
545        then reported them as two independent findings, each reading "lowers MCC". Jointly
546        they encode a curvature in one ratio; separately neither sentence is true.
547
548        An equation this study cannot reason about term by term has failed its purpose,
549        so the constraint is part of the protocol rather than an option. It costs nothing
550        measurable: paired over the twenty leave-one-dataset-out (LODO) folds it moves mean
551        absolute error by +0.0018 at twenty terms, worse on ten of twenty datasets, sign
552        test p = 1.000 and a bootstrap interval of [-0.0039, +0.0084] that spans zero.
553
554        A term over one feature is left ungrouped, so ``1/f`` may still sit beside
555        ``f^2``. Those are two points of one curve over one feature, which reads as a
556        shape; the constraint is about a *relationship* being stated twice.
557        """
558        identifiers: dict[frozenset[str], int] = {}
559        groups = np.full(len(self.terms), -1, dtype=np.int64)
560        for index, term in enumerate(self.terms):
561            features = frozenset(term.features)
562            if len(features) < 2:
563                continue
564            groups[index] = identifiers.setdefault(features, len(identifiers))
565        return groups
566
567    def __len__(self) -> int:
568        return len(self.terms)
569
570    def design(self, columns: dict[str, np.ndarray], indices: list[int]) -> np.ndarray:
571        """Re-evaluate a subset of terms on fresh columns, in the given order."""
572        with np.errstate(all="ignore"):
573            return np.column_stack([self.terms[index].evaluate(columns) for index in indices])
574
575
576def build_library(
577    dataset_features: tuple[str, ...],
578    model_features: tuple[str, ...],
579    columns: dict[str, np.ndarray],
580    *,
581    include_sum_ratio: bool = True,
582    max_arity: int = 3,
583    max_abs_zscore: float = MAX_ABS_ZSCORE,
584) -> Library:
585    """Assemble the full candidate vocabulary.
586
587    With ``model_features`` empty this yields the E1 vocabulary: unary terms over the
588    dataset features plus their internal ratios, products and sums. With model features
589    supplied it additionally yields the cross terms, which is where E2 gets its lift --
590    dataset features alone cannot express anything that varies within a dataset.
591
592    ``max_arity`` caps how many distinct raw features one term may combine: 2 for atoms,
593    ratios and products only, 3 to add ``(f1+f2)/f3``, 4 to add ``(f1+f2)/(f3+f4)``. It is
594    a parameter rather than a constant because "three is enough" is a claim that has to be
595    measured, and measuring it means being able to build the alternatives.
596    """
597    # Sorted, so the library is a function of the feature *sets* and not of the order the
598    # caller happened to pass them in. It was not, and the consequence was not cosmetic:
599    # `pairwise_terms` names a product after whichever operand it sees first, so `A * B` and
600    # `B * A` -- the same column, multiplication being commutative -- entered under two names
601    # depending on declaration order. `Library` then de-duplicated by *correlation with terms
602    # already kept*, so which of a near-collinear pair survived also depended on order, and
603    # across five orderings of one six-feature set the library came out at 270, 270, 272, 270
604    # and 271 terms with 24 of 257 pool slots differing. That is the whole of the
605    # LODO ordering band recorded in the branch notes;
606    # it was never the beam's tie-breaking.
607    dataset_features = tuple(sorted(dataset_features))
608    model_features = tuple(sorted(model_features))
609    features = dataset_features + model_features
610    terms = unary_terms(features, columns)
611    terms += pairwise_terms(dataset_features, dataset_features, columns, both_directions=False)
612    if model_features:
613        terms += pairwise_terms(model_features, model_features, columns, both_directions=False)
614        terms += pairwise_terms(dataset_features, model_features, columns, both_directions=True)
615    if include_sum_ratio and max_arity >= 3:
616        # Every feature, not just the dataset ones: the highest-arity operation must be
617        # able to mix the two groups like every other operation can.
618        terms += sum_ratio_terms(features, columns)
619    if max_arity >= 4:
620        terms += ratio_of_sums_terms(features, columns)
621    return Library(terms, columns, max_abs_zscore=max_abs_zscore)
Transform = typing.Literal['id', 'log', 'sqrt', 'inv', 'sq']
Operation = typing.Literal['atom', 'ratio', 'product', 'sum_ratio', 'ratio_of_sums']
DENOMINATOR_FLOOR = 1e-09
MAX_ABS_ZSCORE = 8.0
MIN_RELATIVE_SPREAD = 1e-06
MAX_DENOMINATOR_RANGE = 20.0
COLLINEARITY_TOLERANCE = 0.999999999
TRANSFORMS: tuple[typing.Literal['id', 'log', 'sqrt', 'inv', 'sq'], ...] = ('id', 'log', 'sqrt', 'inv', 'sq')
@dataclass(frozen=True)
class Atom:
67@dataclass(frozen=True)
68class Atom:
69    """One raw feature under one elementary transform."""
70
71    feature: str
72    transform: Transform = "id"
73
74    @property
75    def name(self) -> str:
76        return _TRANSFORM_FORMAT[self.transform].format(self.feature)
77
78    def evaluate(self, columns: dict[str, np.ndarray]) -> np.ndarray:
79        values = columns[self.feature]
80        match self.transform:
81            case "id":
82                return values
83            case "log":
84                return np.log(values)
85            case "sqrt":
86                return np.sqrt(values)
87            case "inv":
88                return 1.0 / values
89            case "sq":
90                return values * values
91
92    def is_defined_on(self, columns: dict[str, np.ndarray]) -> bool:
93        """Whether the transform is real-valued and finite over these columns."""
94        values = columns.get(self.feature)
95        if values is None:
96            return False
97        if self.transform in ("log", "sqrt", "inv"):
98            return bool(np.all(values > 0.0))
99        return True

One raw feature under one elementary transform.

Atom( feature: str, transform: Literal['id', 'log', 'sqrt', 'inv', 'sq'] = 'id')
feature: str
transform: Literal['id', 'log', 'sqrt', 'inv', 'sq'] = 'id'
name: str
74    @property
75    def name(self) -> str:
76        return _TRANSFORM_FORMAT[self.transform].format(self.feature)
def evaluate(self, columns: dict[str, numpy.ndarray]) -> numpy.ndarray:
78    def evaluate(self, columns: dict[str, np.ndarray]) -> np.ndarray:
79        values = columns[self.feature]
80        match self.transform:
81            case "id":
82                return values
83            case "log":
84                return np.log(values)
85            case "sqrt":
86                return np.sqrt(values)
87            case "inv":
88                return 1.0 / values
89            case "sq":
90                return values * values
def is_defined_on(self, columns: dict[str, numpy.ndarray]) -> bool:
92    def is_defined_on(self, columns: dict[str, np.ndarray]) -> bool:
93        """Whether the transform is real-valued and finite over these columns."""
94        values = columns.get(self.feature)
95        if values is None:
96            return False
97        if self.transform in ("log", "sqrt", "inv"):
98            return bool(np.all(values > 0.0))
99        return True

Whether the transform is real-valued and finite over these columns.

@dataclass(frozen=True)
class Term:
102@dataclass(frozen=True)
103class Term:
104    """A named expression over one to three operands.
105
106    An operand is an `Atom` or, when the term is nested, another `Term`. Nesting lets one
107    term carry a deep expression -- ``([log(a)] / [b]) * [log(c)]`` -- which matters
108    because the equation's length is counted in *terms*, not in operations. A short
109    equation over deep terms can be easier to read than a long one over shallow terms,
110    and ``depth`` is what keeps that trade under control.
111    """
112
113    operation: Operation
114    operands: tuple[Atom | Term, ...]
115
116    @property
117    def name(self) -> str:
118        parts = [operand.name for operand in self.operands]
119        match self.operation:
120            case "atom":
121                return parts[0]
122            case "ratio":
123                return f"[{parts[0]}] / [{parts[1]}]"
124            case "product":
125                return f"[{parts[0]}] * [{parts[1]}]"
126            case "sum_ratio":
127                return f"([{parts[0]}] + [{parts[1]}]) / [{parts[2]}]"
128            case "ratio_of_sums":
129                return f"([{parts[0]}] + [{parts[1]}]) / ([{parts[2]}] + [{parts[3]}])"
130
131    @property
132    def features(self) -> tuple[str, ...]:
133        collected: list[str] = []
134        for operand in self.operands:
135            if isinstance(operand, Term):
136                collected.extend(operand.features)
137            else:
138                collected.append(operand.feature)
139        return tuple(collected)
140
141    @property
142    def depth(self) -> int:
143        """1 for a term over atoms, one more for each level of nesting."""
144        nested = [operand.depth for operand in self.operands if isinstance(operand, Term)]
145        return 1 + max(nested, default=0)
146
147    def evaluate(self, columns: dict[str, np.ndarray]) -> np.ndarray:
148        values = [operand.evaluate(columns) for operand in self.operands]
149        match self.operation:
150            case "atom":
151                return values[0]
152            case "ratio":
153                return values[0] / _guard(values[1])
154            case "product":
155                return values[0] * values[1]
156            case "sum_ratio":
157                return (values[0] + values[1]) / _guard(values[2])
158            case "ratio_of_sums":
159                return (values[0] + values[1]) / _guard(values[2] + values[3])
160
161    def to_dict(self) -> dict[str, object]:
162        return {
163            "operation": self.operation,
164            "operands": [
165                operand.to_dict()
166                if isinstance(operand, Term)
167                else {"feature": operand.feature, "transform": operand.transform}
168                for operand in self.operands
169            ],
170        }
171
172    @classmethod
173    def from_dict(cls, payload: dict[str, object]) -> Term:
174        operands = payload["operands"]
175        if not isinstance(operands, list):
176            raise ValueError("term payload has no operand list")
177        restored: list[Atom | Term] = []
178        for item in operands:  # pyright: ignore[reportUnknownVariableType]
179            if not isinstance(item, dict):
180                raise ValueError("term operand is not an object")
181            if "operation" in item:
182                restored.append(cls.from_dict(item))  # pyright: ignore[reportUnknownArgumentType]
183            else:
184                restored.append(Atom(str(item["feature"]), item["transform"]))  # pyright: ignore[reportArgumentType]
185        return cls(payload["operation"], tuple(restored))  # pyright: ignore[reportArgumentType]

A named expression over one to three operands.

An operand is an Atom or, when the term is nested, another Term. Nesting lets one term carry a deep expression -- ([log(a)] / [b]) * [log(c)] -- which matters because the equation's length is counted in terms, not in operations. A short equation over deep terms can be easier to read than a long one over shallow terms, and depth is what keeps that trade under control.

Term( operation: Literal['atom', 'ratio', 'product', 'sum_ratio', 'ratio_of_sums'], operands: tuple[Atom | Term, ...])
operation: Literal['atom', 'ratio', 'product', 'sum_ratio', 'ratio_of_sums']
operands: tuple[Atom | Term, ...]
name: str
116    @property
117    def name(self) -> str:
118        parts = [operand.name for operand in self.operands]
119        match self.operation:
120            case "atom":
121                return parts[0]
122            case "ratio":
123                return f"[{parts[0]}] / [{parts[1]}]"
124            case "product":
125                return f"[{parts[0]}] * [{parts[1]}]"
126            case "sum_ratio":
127                return f"([{parts[0]}] + [{parts[1]}]) / [{parts[2]}]"
128            case "ratio_of_sums":
129                return f"([{parts[0]}] + [{parts[1]}]) / ([{parts[2]}] + [{parts[3]}])"
features: tuple[str, ...]
131    @property
132    def features(self) -> tuple[str, ...]:
133        collected: list[str] = []
134        for operand in self.operands:
135            if isinstance(operand, Term):
136                collected.extend(operand.features)
137            else:
138                collected.append(operand.feature)
139        return tuple(collected)
depth: int
141    @property
142    def depth(self) -> int:
143        """1 for a term over atoms, one more for each level of nesting."""
144        nested = [operand.depth for operand in self.operands if isinstance(operand, Term)]
145        return 1 + max(nested, default=0)

1 for a term over atoms, one more for each level of nesting.

def evaluate(self, columns: dict[str, numpy.ndarray]) -> numpy.ndarray:
147    def evaluate(self, columns: dict[str, np.ndarray]) -> np.ndarray:
148        values = [operand.evaluate(columns) for operand in self.operands]
149        match self.operation:
150            case "atom":
151                return values[0]
152            case "ratio":
153                return values[0] / _guard(values[1])
154            case "product":
155                return values[0] * values[1]
156            case "sum_ratio":
157                return (values[0] + values[1]) / _guard(values[2])
158            case "ratio_of_sums":
159                return (values[0] + values[1]) / _guard(values[2] + values[3])
def to_dict(self) -> dict[str, object]:
161    def to_dict(self) -> dict[str, object]:
162        return {
163            "operation": self.operation,
164            "operands": [
165                operand.to_dict()
166                if isinstance(operand, Term)
167                else {"feature": operand.feature, "transform": operand.transform}
168                for operand in self.operands
169            ],
170        }
@classmethod
def from_dict(cls, payload: dict[str, object]) -> Term:
172    @classmethod
173    def from_dict(cls, payload: dict[str, object]) -> Term:
174        operands = payload["operands"]
175        if not isinstance(operands, list):
176            raise ValueError("term payload has no operand list")
177        restored: list[Atom | Term] = []
178        for item in operands:  # pyright: ignore[reportUnknownVariableType]
179            if not isinstance(item, dict):
180                raise ValueError("term operand is not an object")
181            if "operation" in item:
182                restored.append(cls.from_dict(item))  # pyright: ignore[reportUnknownArgumentType]
183            else:
184                restored.append(Atom(str(item["feature"]), item["transform"]))  # pyright: ignore[reportArgumentType]
185        return cls(payload["operation"], tuple(restored))  # pyright: ignore[reportArgumentType]
def simplify(term: Term) -> Term:
188def simplify(term: Term) -> Term:
189    """Apply algebraic identities so a term prints in its shortest equivalent form.
190
191    Nesting produces expressions that are correct but redundant: ``([a] / [b]) * [b]``
192    computes ``a`` and says so in six symbols instead of one. Since the whole claim of
193    this project is that the equation can be read, a term that is longer than it needs to
194    be is a defect, not a cosmetic issue.
195
196    Only exact identities are applied -- cancellation of a matching factor against a
197    matching divisor, and division of a term by itself. Nothing is dropped on numerical
198    grounds here; that is ``search.prune``'s job, and keeping the two separate means this
199    function never changes what a term computes.
200    """
201    operands = tuple(simplify(operand) if isinstance(operand, Term) else operand for operand in term.operands)
202    term = Term(term.operation, operands)
203
204    if term.operation == "product":
205        left, right = term.operands
206        # (x / y) * y  ->  x
207        for numerator_side, other in ((left, right), (right, left)):
208            if isinstance(numerator_side, Term) and numerator_side.operation == "ratio":
209                inner_numerator, inner_divisor = numerator_side.operands
210                if inner_divisor == other:
211                    return _as_term(inner_numerator)
212    elif term.operation == "ratio":
213        numerator, divisor = term.operands
214        # x / x  ->  1, which is collinear with the intercept and carries no information
215        if numerator == divisor:
216            return term
217        # (x * y) / y  ->  x
218        if isinstance(numerator, Term) and numerator.operation == "product":
219            first, second = numerator.operands
220            if second == divisor:
221                return _as_term(first)
222            if first == divisor:
223                return _as_term(second)
224    return term

Apply algebraic identities so a term prints in its shortest equivalent form.

Nesting produces expressions that are correct but redundant: ([a] / [b]) * [b] computes a and says so in six symbols instead of one. Since the whole claim of this project is that the equation can be read, a term that is longer than it needs to be is a defect, not a cosmetic issue.

Only exact identities are applied -- cancellation of a matching factor against a matching divisor, and division of a term by itself. Nothing is dropped on numerical grounds here; that is search.prune's job, and keeping the two separate means this function never changes what a term computes.

def is_trivial(term: Term) -> bool:
227def is_trivial(term: Term) -> bool:
228    """Whether a term reduces to a constant, and so duplicates the intercept."""
229    return term.operation == "ratio" and term.operands[0] == term.operands[1]

Whether a term reduces to a constant, and so duplicates the intercept.

def composition_atom( feature: str, columns: dict[str, numpy.ndarray]) -> Atom:
246def composition_atom(feature: str, columns: dict[str, np.ndarray]) -> Atom:
247    """The operand used when a feature appears as a numerator or inside a sum.
248
249    Strictly positive features enter log-compressed; the rest enter raw. This is the
250    single decision that keeps composite terms on a comparable scale.
251    """
252    values = columns[feature]
253    return Atom(feature, "log" if bool(np.all(values > 0.0)) else "id")

The operand used when a feature appears as a numerator or inside a sum.

Strictly positive features enter log-compressed; the rest enter raw. This is the single decision that keeps composite terms on a comparable scale.

def denominator_atom( feature: str, columns: dict[str, numpy.ndarray]) -> Atom | None:
256def denominator_atom(feature: str, columns: dict[str, np.ndarray]) -> Atom | None:
257    """The operand to divide by, or ``None`` when this feature cannot safely be one.
258
259    Division is the one operation in the vocabulary that can manufacture a term no
260    linear solver can use: as a denominator approaches zero the term's magnitude runs
261    away, one row comes to hold all of its variance, and the fitted weight becomes an
262    artefact of that row rather than a relationship. The library therefore decides
263    eligibility per feature, before any term is built, rather than generating the term
264    and screening it afterwards.
265
266    Two candidate forms are tried in order of preference and the first that qualifies is
267    used:
268
269    * ``log(f)`` -- compresses scale, but is zero at ``f == 1`` and negative below it,
270      so it is only eligible when the feature stays clear of 1;
271    * ``f`` itself -- eligible when the feature stays clear of 0.
272
273    Eligibility is a bound on **dynamic range**, ``max|d| / min|d| <=
274    MAX_DENOMINATOR_RANGE``, not merely ``min|d| > 0``. That is the criterion that
275    matches the failure: a divisor spanning three orders of magnitude makes the ratio
276    span three orders of magnitude too, so one row holds nearly all of the term's
277    variance and its fitted weight describes that row. Requiring only a non-zero
278    minimum would admit exactly those terms.
279
280    Features that qualify under neither form are never used as denominators. In this
281    meta-dataset that rules out ``nr_norm``, ``nr_bin`` and ``nr_outliers`` (which reach
282    0), the log of anything reaching 1, and wide-range counts such as ``nr_inst``
283    (165 to 7.1M) in raw form -- though ``log(nr_inst)`` qualifies comfortably, which is
284    the compression the preference order exists to find.
285    """
286    for candidate in (Atom(feature, "log"), Atom(feature, "id")):
287        if not candidate.is_defined_on(columns):
288            continue
289        with np.errstate(all="ignore"):
290            evaluated = np.abs(candidate.evaluate(columns))
291        if not np.all(np.isfinite(evaluated)):
292            continue
293        smallest = float(np.min(evaluated))
294        if smallest <= DENOMINATOR_FLOOR:
295            continue
296        if float(np.max(evaluated)) / smallest <= MAX_DENOMINATOR_RANGE:
297            return candidate
298    return None

The operand to divide by, or None when this feature cannot safely be one.

Division is the one operation in the vocabulary that can manufacture a term no linear solver can use: as a denominator approaches zero the term's magnitude runs away, one row comes to hold all of its variance, and the fitted weight becomes an artefact of that row rather than a relationship. The library therefore decides eligibility per feature, before any term is built, rather than generating the term and screening it afterwards.

Two candidate forms are tried in order of preference and the first that qualifies is used:

  • log(f) -- compresses scale, but is zero at f == 1 and negative below it, so it is only eligible when the feature stays clear of 1;
  • f itself -- eligible when the feature stays clear of 0.

Eligibility is a bound on dynamic range, max|d| / min|d| <= MAX_DENOMINATOR_RANGE, not merely min|d| > 0. That is the criterion that matches the failure: a divisor spanning three orders of magnitude makes the ratio span three orders of magnitude too, so one row holds nearly all of the term's variance and its fitted weight describes that row. Requiring only a non-zero minimum would admit exactly those terms.

Features that qualify under neither form are never used as denominators. In this meta-dataset that rules out nr_norm, nr_bin and nr_outliers (which reach 0), the log of anything reaching 1, and wide-range counts such as nr_inst (165 to 7.1M) in raw form -- though log(nr_inst) qualifies comfortably, which is the compression the preference order exists to find.

def unary_terms( features: tuple[str, ...], columns: dict[str, numpy.ndarray]) -> list[Term]:
301def unary_terms(features: tuple[str, ...], columns: dict[str, np.ndarray]) -> list[Term]:
302    """``f``, ``log(f)``, ``sqrt(f)``, ``1/f`` and ``f^2`` for each feature that admits them."""
303    terms: list[Term] = []
304    for feature in features:
305        for transform in TRANSFORMS:
306            atom = Atom(feature, transform)
307            if atom.is_defined_on(columns):
308                terms.append(Term("atom", (atom,)))
309    return terms

f, log(f), sqrt(f), 1/f and f^2 for each feature that admits them.

def pairwise_terms( left: tuple[str, ...], right: tuple[str, ...], columns: dict[str, numpy.ndarray], *, both_directions: bool) -> list[Term]:
312def pairwise_terms(
313    left: tuple[str, ...],
314    right: tuple[str, ...],
315    columns: dict[str, np.ndarray],
316    *,
317    both_directions: bool,
318) -> list[Term]:
319    """Ratios and products over two feature groups.
320
321    ``both_directions`` controls whether ``a/b`` and ``b/a`` are both emitted. Within a
322    single group the pairs are unordered and one direction suffices; across the dataset
323    and model groups both directions carry different meaning, so both are kept.
324    """
325    pairs = itertools.product(left, right) if both_directions else itertools.combinations(left, 2)
326    terms: list[Term] = []
327    seen: set[str] = set()
328    for first, second in pairs:
329        if first == second:
330            continue
331        a, b = composition_atom(first, columns), composition_atom(second, columns)
332        # Products are always safe; ratios only exist when the divisor is eligible.
333        candidates = [Term("product", (a, b))]
334        denominator_b = denominator_atom(second, columns)
335        if denominator_b is not None:
336            candidates.append(Term("ratio", (a, denominator_b)))
337        if both_directions:
338            denominator_a = denominator_atom(first, columns)
339            if denominator_a is not None:
340                candidates.append(Term("ratio", (b, denominator_a)))
341        for term in candidates:
342            if term.name not in seen:
343                seen.add(term.name)
344                terms.append(term)
345    return terms

Ratios and products over two feature groups.

both_directions controls whether a/b and b/a are both emitted. Within a single group the pairs are unordered and one direction suffices; across the dataset and model groups both directions carry different meaning, so both are kept.

def sum_ratio_terms( features: tuple[str, ...], columns: dict[str, numpy.ndarray]) -> list[Term]:
348def sum_ratio_terms(features: tuple[str, ...], columns: dict[str, np.ndarray]) -> list[Term]:
349    """``(f1 + f2) / f3`` over distinct features, with ``f1`` and ``f2`` unordered.
350
351    Pass **every** feature, not one group. An earlier version was called with the dataset
352    features alone, which silently made the highest-arity operation the only one unable to
353    mix a dataset feature with a model one -- precisely the combination that carries E2's
354    entire lift over E1.
355    """
356    terms: list[Term] = []
357    for first, second in itertools.combinations(features, 2):
358        for third in features:
359            if third in (first, second):
360                continue
361            divisor = denominator_atom(third, columns)
362            if divisor is None:
363                continue
364            operands = (
365                composition_atom(first, columns),
366                composition_atom(second, columns),
367                divisor,
368            )
369            terms.append(Term("sum_ratio", operands))
370    return terms

(f1 + f2) / f3 over distinct features, with f1 and f2 unordered.

Pass every feature, not one group. An earlier version was called with the dataset features alone, which silently made the highest-arity operation the only one unable to mix a dataset feature with a model one -- precisely the combination that carries E2's entire lift over E1.

def ratio_of_sums_terms( features: tuple[str, ...], columns: dict[str, numpy.ndarray]) -> list[Term]:
373def ratio_of_sums_terms(features: tuple[str, ...], columns: dict[str, np.ndarray]) -> list[Term]:
374    """``(f1 + f2) / (f3 + f4)`` -- the four-feature operation.
375
376    Included so that "three features per term is enough" can be **measured** rather than
377    assumed. A sum is a safe divisor far more often than a single feature is, since adding
378    two log-compressed operands moves the result away from zero, so this reaches
379    combinations ``sum_ratio`` cannot.
380    """
381    terms: list[Term] = []
382    for numerator in itertools.combinations(features, 2):
383        rest = [feature for feature in features if feature not in numerator]
384        for divisor in itertools.combinations(rest, 2):
385            operands = (
386                composition_atom(numerator[0], columns),
387                composition_atom(numerator[1], columns),
388                composition_atom(divisor[0], columns),
389                composition_atom(divisor[1], columns),
390            )
391            with np.errstate(all="ignore"):
392                bottom = operands[2].evaluate(columns) + operands[3].evaluate(columns)
393            if not _is_safe_sum(bottom):
394                continue
395            terms.append(Term("ratio_of_sums", operands))
396    return terms

(f1 + f2) / (f3 + f4) -- the four-feature operation.

Included so that "three features per term is enough" can be measured rather than assumed. A sum is a safe divisor far more often than a single feature is, since adding two log-compressed operands moves the result away from zero, so this reaches combinations sum_ratio cannot.

def is_admissible(values: numpy.ndarray, max_abs_zscore: float = 8.0) -> bool:
410def is_admissible(values: np.ndarray, max_abs_zscore: float = MAX_ABS_ZSCORE) -> bool:
411    """Whether a term is numerically usable as a column of a linear system.
412
413    This is a backstop, not the main line of defence. The terms that used to fail here
414    were unstable by construction -- ratios whose denominator could reach zero -- and
415    those are no longer generated at all (see ``denominator_atom``). What remains is a
416    cheap check that a caller assembling a ``Library`` by hand, or a transform applied
417    to an unexpected feature, cannot smuggle in a column that breaks the solve.
418
419    Three rejections:
420
421    *Not finite, or constant.* A constant column is collinear with the intercept.
422
423    *Effectively constant.* A term whose spread is negligible against its own magnitude
424    passes an absolute variance check but destroys the published equation: fitting
425    happens on standardised terms, so folding the standardisation back into raw units
426    divides the weight by that spread. One near-constant term produced a weight of
427    -1.5e9 against an intercept of +1.5e9 -- algebraically fine, arithmetically fine,
428    and completely unreadable, which defeats the purpose of the exercise.
429
430    *Driven by one row.* A term is rejected when a single row sits more than
431    ``max_abs_zscore`` standard deviations from the mean, because its fitted weight
432    would describe that row rather than a relationship.
433
434    The cap is sample-size dependent and has to be chosen with that in mind: a single
435    outlier among ``n`` rows can reach a z-score of at most about ``sqrt(n)``, so a cap
436    of 8 constrains the 484-row fit but would be inert on the 20-row aggregated fit,
437    which is why the two configurations do not share a value.
438    """
439    if not np.all(np.isfinite(values)):
440        return False
441    spread = float(np.std(values))
442    if spread <= 1e-12:
443        return False
444    if spread < MIN_RELATIVE_SPREAD * (1.0 + abs(float(np.mean(values)))):
445        return False
446    return float(np.max(np.abs(values - np.mean(values)))) / spread <= max_abs_zscore

Whether a term is numerically usable as a column of a linear system.

This is a backstop, not the main line of defence. The terms that used to fail here were unstable by construction -- ratios whose denominator could reach zero -- and those are no longer generated at all (see denominator_atom). What remains is a cheap check that a caller assembling a Library by hand, or a transform applied to an unexpected feature, cannot smuggle in a column that breaks the solve.

Three rejections:

Not finite, or constant. A constant column is collinear with the intercept.

Effectively constant. A term whose spread is negligible against its own magnitude passes an absolute variance check but destroys the published equation: fitting happens on standardised terms, so folding the standardisation back into raw units divides the weight by that spread. One near-constant term produced a weight of -1.5e9 against an intercept of +1.5e9 -- algebraically fine, arithmetically fine, and completely unreadable, which defeats the purpose of the exercise.

Driven by one row. A term is rejected when a single row sits more than max_abs_zscore standard deviations from the mean, because its fitted weight would describe that row rather than a relationship.

The cap is sample-size dependent and has to be chosen with that in mind: a single outlier among n rows can reach a z-score of at most about sqrt(n), so a cap of 8 constrains the 484-row fit but would be inert on the 20-row aggregated fit, which is why the two configurations do not share a value.

class Library:
461class Library:
462    """A set of terms together with the design matrix they produce.
463
464    Terms are dropped for three reasons, in order: a repeated *name*, failing
465    `is_admissible`, and -- the subtlest -- being the same column as a term already kept.
466
467    That last check cannot be done on names. ``inst_to_attr`` is ``nr_inst / nr_attr`` in
468    this meta-dataset, so ``[log(inst_to_attr)] + [log(nr_attr)]`` **is**
469    ``log(nr_inst)``, exactly, and the grammar generates both. Three such pairs exist in
470    the published 206-term library. A binary feature produces them too and more bluntly:
471    ``log``, ``sqrt`` and ``1/f`` are all undefined at zero, leaving only ``f`` and
472    ``f^2``, which for a 0/1 column are the same numbers under two names.
473
474    Keeping both members of a pair is wasteful rather than dangerous, and the distinction
475    is worth being precise about. The beam search already refuses a candidate whose
476    correlation with a selected term exceeds ``ml_meta_perf.search.COLLINEARITY_LIMIT`` (0.95), so
477    a duplicate pair cannot both be selected on that path and no singular system arises
478    there. What the duplicates cost is candidate-pool slots, search time, and a place in
479    the reported term rankings, where they appear as two independent findings. The check
480    here also covers the paths ``_blocked`` does not: a ``Library`` assembled by hand, and
481    any consumer reading `terms` or `matrix` directly.
482
483    The first term of a pair wins. Generation order runs simple to complex, so the survivor
484    is the shorter form: ``[log(nr_inst)] / [log(Processing Units Number)]`` is kept and
485    ``([log(inst_to_attr)] + [log(nr_attr)]) / [log(Processing Units Number)]`` is dropped.
486    """
487
488    def __init__(
489        self,
490        terms: list[Term],
491        columns: dict[str, np.ndarray],
492        *,
493        max_abs_zscore: float = MAX_ABS_ZSCORE,
494    ) -> None:
495        kept: list[Term] = []
496        vectors: list[np.ndarray] = []
497        seen: set[str] = set()
498        # The survivors' unit vectors as one growing array rather than a list, so the
499        # duplicate test is a single matrix-vector product against everything kept so far
500        # instead of a Python loop of dot products over it. Identical arithmetic and
501        # identical order -- the first term of a pair still wins -- but the loop form ran
502        # 590k times per study and `search.guided_screen` already does it this way.
503        accepted: np.ndarray | None = None
504        for term in terms:
505            if term.name in seen:
506                continue
507            with np.errstate(all="ignore"):
508                values = term.evaluate(columns)
509            if not is_admissible(values, max_abs_zscore):
510                continue
511            unit = _unit(values)
512            if accepted is None:
513                accepted = np.empty((len(terms), values.shape[0]))
514            elif float(np.abs(accepted[: len(kept)] @ unit).max()) > COLLINEARITY_TOLERANCE:
515                continue
516            accepted[len(kept)] = unit
517            seen.add(term.name)
518            kept.append(term)
519            vectors.append(values)
520        if not kept:
521            raise ValueError("term library is empty after filtering")
522        self.terms: list[Term] = kept
523        self.matrix: np.ndarray = np.column_stack(vectors)
524
525    @property
526    def names(self) -> list[str]:
527        return [term.name for term in self.terms]
528
529    @property
530    def feature_groups(self) -> np.ndarray:
531        """Which terms describe the *same combination of raw features*, as group ids.
532
533        One id per term, ``-1`` for a term over a single feature. Two terms share an id
534        when their feature multisets reduce to the same set, however differently they
535        arrange it: ``[log(a)] / [log(b)]`` and ``[log(b)] / [log(a)]`` are one group, and
536        so are ``[a] * [log(b)]`` and ``[a] / [log(b)]``.
537
538        `ml_meta_perf.search.Selector` refuses to place two terms of one group in the same
539        equation. That is a **readability** rule, not a numerical one, and the distinction
540        matters because the numerical guard already passes: the two mirrored pairs this
541        removed from the previous 16-term equation correlated at 0.891 and 0.786, both
542        under ``search.COLLINEARITY_LIMIT``, in a design conditioned at 7.8. Nothing was
543        ill-posed. What was wrong is that the equation spent two of its sixteen slots
544        writing one relationship both ways up -- ``log(PUN)/log(nr_class)`` beside
545        ``log(nr_class)/log(PUN)``, *both* carrying negative weight -- and the term table
546        then reported them as two independent findings, each reading "lowers MCC". Jointly
547        they encode a curvature in one ratio; separately neither sentence is true.
548
549        An equation this study cannot reason about term by term has failed its purpose,
550        so the constraint is part of the protocol rather than an option. It costs nothing
551        measurable: paired over the twenty leave-one-dataset-out (LODO) folds it moves mean
552        absolute error by +0.0018 at twenty terms, worse on ten of twenty datasets, sign
553        test p = 1.000 and a bootstrap interval of [-0.0039, +0.0084] that spans zero.
554
555        A term over one feature is left ungrouped, so ``1/f`` may still sit beside
556        ``f^2``. Those are two points of one curve over one feature, which reads as a
557        shape; the constraint is about a *relationship* being stated twice.
558        """
559        identifiers: dict[frozenset[str], int] = {}
560        groups = np.full(len(self.terms), -1, dtype=np.int64)
561        for index, term in enumerate(self.terms):
562            features = frozenset(term.features)
563            if len(features) < 2:
564                continue
565            groups[index] = identifiers.setdefault(features, len(identifiers))
566        return groups
567
568    def __len__(self) -> int:
569        return len(self.terms)
570
571    def design(self, columns: dict[str, np.ndarray], indices: list[int]) -> np.ndarray:
572        """Re-evaluate a subset of terms on fresh columns, in the given order."""
573        with np.errstate(all="ignore"):
574            return np.column_stack([self.terms[index].evaluate(columns) for index in indices])

A set of terms together with the design matrix they produce.

Terms are dropped for three reasons, in order: a repeated name, failing is_admissible, and -- the subtlest -- being the same column as a term already kept.

That last check cannot be done on names. inst_to_attr is nr_inst / nr_attr in this meta-dataset, so [log(inst_to_attr)] + [log(nr_attr)] is log(nr_inst), exactly, and the grammar generates both. Three such pairs exist in the published 206-term library. A binary feature produces them too and more bluntly: log, sqrt and 1/f are all undefined at zero, leaving only f and f^2, which for a 0/1 column are the same numbers under two names.

Keeping both members of a pair is wasteful rather than dangerous, and the distinction is worth being precise about. The beam search already refuses a candidate whose correlation with a selected term exceeds ml_meta_perf.search.COLLINEARITY_LIMIT (0.95), so a duplicate pair cannot both be selected on that path and no singular system arises there. What the duplicates cost is candidate-pool slots, search time, and a place in the reported term rankings, where they appear as two independent findings. The check here also covers the paths _blocked does not: a Library assembled by hand, and any consumer reading terms or matrix directly.

The first term of a pair wins. Generation order runs simple to complex, so the survivor is the shorter form: [log(nr_inst)] / [log(Processing Units Number)] is kept and ([log(inst_to_attr)] + [log(nr_attr)]) / [log(Processing Units Number)] is dropped.

Library( terms: list[Term], columns: dict[str, numpy.ndarray], *, max_abs_zscore: float = 8.0)
488    def __init__(
489        self,
490        terms: list[Term],
491        columns: dict[str, np.ndarray],
492        *,
493        max_abs_zscore: float = MAX_ABS_ZSCORE,
494    ) -> None:
495        kept: list[Term] = []
496        vectors: list[np.ndarray] = []
497        seen: set[str] = set()
498        # The survivors' unit vectors as one growing array rather than a list, so the
499        # duplicate test is a single matrix-vector product against everything kept so far
500        # instead of a Python loop of dot products over it. Identical arithmetic and
501        # identical order -- the first term of a pair still wins -- but the loop form ran
502        # 590k times per study and `search.guided_screen` already does it this way.
503        accepted: np.ndarray | None = None
504        for term in terms:
505            if term.name in seen:
506                continue
507            with np.errstate(all="ignore"):
508                values = term.evaluate(columns)
509            if not is_admissible(values, max_abs_zscore):
510                continue
511            unit = _unit(values)
512            if accepted is None:
513                accepted = np.empty((len(terms), values.shape[0]))
514            elif float(np.abs(accepted[: len(kept)] @ unit).max()) > COLLINEARITY_TOLERANCE:
515                continue
516            accepted[len(kept)] = unit
517            seen.add(term.name)
518            kept.append(term)
519            vectors.append(values)
520        if not kept:
521            raise ValueError("term library is empty after filtering")
522        self.terms: list[Term] = kept
523        self.matrix: np.ndarray = np.column_stack(vectors)
terms: list[Term]
matrix: numpy.ndarray
names: list[str]
525    @property
526    def names(self) -> list[str]:
527        return [term.name for term in self.terms]
feature_groups: numpy.ndarray
529    @property
530    def feature_groups(self) -> np.ndarray:
531        """Which terms describe the *same combination of raw features*, as group ids.
532
533        One id per term, ``-1`` for a term over a single feature. Two terms share an id
534        when their feature multisets reduce to the same set, however differently they
535        arrange it: ``[log(a)] / [log(b)]`` and ``[log(b)] / [log(a)]`` are one group, and
536        so are ``[a] * [log(b)]`` and ``[a] / [log(b)]``.
537
538        `ml_meta_perf.search.Selector` refuses to place two terms of one group in the same
539        equation. That is a **readability** rule, not a numerical one, and the distinction
540        matters because the numerical guard already passes: the two mirrored pairs this
541        removed from the previous 16-term equation correlated at 0.891 and 0.786, both
542        under ``search.COLLINEARITY_LIMIT``, in a design conditioned at 7.8. Nothing was
543        ill-posed. What was wrong is that the equation spent two of its sixteen slots
544        writing one relationship both ways up -- ``log(PUN)/log(nr_class)`` beside
545        ``log(nr_class)/log(PUN)``, *both* carrying negative weight -- and the term table
546        then reported them as two independent findings, each reading "lowers MCC". Jointly
547        they encode a curvature in one ratio; separately neither sentence is true.
548
549        An equation this study cannot reason about term by term has failed its purpose,
550        so the constraint is part of the protocol rather than an option. It costs nothing
551        measurable: paired over the twenty leave-one-dataset-out (LODO) folds it moves mean
552        absolute error by +0.0018 at twenty terms, worse on ten of twenty datasets, sign
553        test p = 1.000 and a bootstrap interval of [-0.0039, +0.0084] that spans zero.
554
555        A term over one feature is left ungrouped, so ``1/f`` may still sit beside
556        ``f^2``. Those are two points of one curve over one feature, which reads as a
557        shape; the constraint is about a *relationship* being stated twice.
558        """
559        identifiers: dict[frozenset[str], int] = {}
560        groups = np.full(len(self.terms), -1, dtype=np.int64)
561        for index, term in enumerate(self.terms):
562            features = frozenset(term.features)
563            if len(features) < 2:
564                continue
565            groups[index] = identifiers.setdefault(features, len(identifiers))
566        return groups

Which terms describe the same combination of raw features, as group ids.

One id per term, -1 for a term over a single feature. Two terms share an id when their feature multisets reduce to the same set, however differently they arrange it: [log(a)] / [log(b)] and [log(b)] / [log(a)] are one group, and so are [a] * [log(b)] and [a] / [log(b)].

ml_meta_perf.search.Selector refuses to place two terms of one group in the same equation. That is a readability rule, not a numerical one, and the distinction matters because the numerical guard already passes: the two mirrored pairs this removed from the previous 16-term equation correlated at 0.891 and 0.786, both under search.COLLINEARITY_LIMIT, in a design conditioned at 7.8. Nothing was ill-posed. What was wrong is that the equation spent two of its sixteen slots writing one relationship both ways up -- log(PUN)/log(nr_class) beside log(nr_class)/log(PUN), both carrying negative weight -- and the term table then reported them as two independent findings, each reading "lowers MCC". Jointly they encode a curvature in one ratio; separately neither sentence is true.

An equation this study cannot reason about term by term has failed its purpose, so the constraint is part of the protocol rather than an option. It costs nothing measurable: paired over the twenty leave-one-dataset-out (LODO) folds it moves mean absolute error by +0.0018 at twenty terms, worse on ten of twenty datasets, sign test p = 1.000 and a bootstrap interval of [-0.0039, +0.0084] that spans zero.

A term over one feature is left ungrouped, so 1/f may still sit beside f^2. Those are two points of one curve over one feature, which reads as a shape; the constraint is about a relationship being stated twice.

def design( self, columns: dict[str, numpy.ndarray], indices: list[int]) -> numpy.ndarray:
571    def design(self, columns: dict[str, np.ndarray], indices: list[int]) -> np.ndarray:
572        """Re-evaluate a subset of terms on fresh columns, in the given order."""
573        with np.errstate(all="ignore"):
574            return np.column_stack([self.terms[index].evaluate(columns) for index in indices])

Re-evaluate a subset of terms on fresh columns, in the given order.

def build_library( dataset_features: tuple[str, ...], model_features: tuple[str, ...], columns: dict[str, numpy.ndarray], *, include_sum_ratio: bool = True, max_arity: int = 3, max_abs_zscore: float = 8.0) -> Library:
577def build_library(
578    dataset_features: tuple[str, ...],
579    model_features: tuple[str, ...],
580    columns: dict[str, np.ndarray],
581    *,
582    include_sum_ratio: bool = True,
583    max_arity: int = 3,
584    max_abs_zscore: float = MAX_ABS_ZSCORE,
585) -> Library:
586    """Assemble the full candidate vocabulary.
587
588    With ``model_features`` empty this yields the E1 vocabulary: unary terms over the
589    dataset features plus their internal ratios, products and sums. With model features
590    supplied it additionally yields the cross terms, which is where E2 gets its lift --
591    dataset features alone cannot express anything that varies within a dataset.
592
593    ``max_arity`` caps how many distinct raw features one term may combine: 2 for atoms,
594    ratios and products only, 3 to add ``(f1+f2)/f3``, 4 to add ``(f1+f2)/(f3+f4)``. It is
595    a parameter rather than a constant because "three is enough" is a claim that has to be
596    measured, and measuring it means being able to build the alternatives.
597    """
598    # Sorted, so the library is a function of the feature *sets* and not of the order the
599    # caller happened to pass them in. It was not, and the consequence was not cosmetic:
600    # `pairwise_terms` names a product after whichever operand it sees first, so `A * B` and
601    # `B * A` -- the same column, multiplication being commutative -- entered under two names
602    # depending on declaration order. `Library` then de-duplicated by *correlation with terms
603    # already kept*, so which of a near-collinear pair survived also depended on order, and
604    # across five orderings of one six-feature set the library came out at 270, 270, 272, 270
605    # and 271 terms with 24 of 257 pool slots differing. That is the whole of the
606    # LODO ordering band recorded in the branch notes;
607    # it was never the beam's tie-breaking.
608    dataset_features = tuple(sorted(dataset_features))
609    model_features = tuple(sorted(model_features))
610    features = dataset_features + model_features
611    terms = unary_terms(features, columns)
612    terms += pairwise_terms(dataset_features, dataset_features, columns, both_directions=False)
613    if model_features:
614        terms += pairwise_terms(model_features, model_features, columns, both_directions=False)
615        terms += pairwise_terms(dataset_features, model_features, columns, both_directions=True)
616    if include_sum_ratio and max_arity >= 3:
617        # Every feature, not just the dataset ones: the highest-arity operation must be
618        # able to mix the two groups like every other operation can.
619        terms += sum_ratio_terms(features, columns)
620    if max_arity >= 4:
621        terms += ratio_of_sums_terms(features, columns)
622    return Library(terms, columns, max_abs_zscore=max_abs_zscore)

Assemble the full candidate vocabulary.

With model_features empty this yields the E1 vocabulary: unary terms over the dataset features plus their internal ratios, products and sums. With model features supplied it additionally yields the cross terms, which is where E2 gets its lift -- dataset features alone cannot express anything that varies within a dataset.

max_arity caps how many distinct raw features one term may combine: 2 for atoms, ratios and products only, 3 to add (f1+f2)/f3, 4 to add (f1+f2)/(f3+f4). It is a parameter rather than a constant because "three is enough" is a claim that has to be measured, and measuring it means being able to build the alternatives.