ml_meta_perf.selection

How long an equation should be: one rule for E1, E2 and E3-Valid, and a bound for E3-MAX.

Every equation is searched once up to a horizon and cross-validated at every length, so the choice of length is a reading of a complete curve. The curve read is the floor: the worst R2 over in-sample (IS), leave-one-dataset-out (LODO), leave-one-model-out (LOMO) and doubly held-out (DHO) validation, so a length is only as good as the protocol it does worst on.

Adjacent lengths on that curve differ by 0.03 to 0.05 for reasons that belong to one fold, not to the length, so any argmax over it -- or any threshold on its raw steps -- chooses noise. plateau_knee therefore reads a smoothed curve and asks where the returns stop:

  1. smooth the floor with a running median, so no single length can choose or block;
  2. propose lengths with multi-Kneedle (the kneeliverse library) on the smoothed curve's Pareto front -- the lengths where the rate of improvement bends -- plus the smoothed maximum itself, after which by definition nothing improves;
  3. choose the first proposed length after which the smoothed floor gains at most delta over the next window lengths.

A knee alone lands where the rate of improvement first bends, five to nine terms in, well before the curve levels off; a threshold alone reads noise. Together they choose the start of the first sustained plateau, which is the shortest equation that has stopped improving. When every knee is still followed by real gains, that start is the smoothed maximum.

E3-MAX is not put forward as an equation to read: it is the same configuration at the configured capability arity, at the raw maximum of its floor (floor_argmax), bounding what the additive form can reach.

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

  1"""How long an equation should be: one rule for E1, E2 and E3-Valid, and a bound for E3-MAX.
  2
  3Every equation is searched once up to a horizon and cross-validated at every length, so the
  4choice of length is a reading of a complete curve. The curve read is the **floor**: the worst
  5R2 over in-sample (IS), leave-one-dataset-out (LODO), leave-one-model-out (LOMO) and doubly
  6held-out (DHO) validation, so a length is only as good as the protocol it does worst on.
  7
  8Adjacent lengths on that curve differ by 0.03 to 0.05 for reasons that belong to one fold, not
  9to the length, so any argmax over it -- or any threshold on its raw steps -- chooses noise.
 10`plateau_knee` therefore reads a smoothed curve and asks where the returns stop:
 11
 121. **smooth** the floor with a running median, so no single length can choose or block;
 132. **propose** lengths with multi-Kneedle (the `kneeliverse` library) on the smoothed curve's
 14   Pareto front -- the lengths where the rate of improvement bends -- plus the smoothed
 15   maximum itself, after which by definition nothing improves;
 163. **choose** the first proposed length after which the smoothed floor gains at most ``delta``
 17   over the next ``window`` lengths.
 18
 19A knee alone lands where the *rate* of improvement first bends, five to nine terms in, well
 20before the curve levels off; a threshold alone reads noise. Together they choose the start of
 21the first sustained plateau, which is the shortest equation that has stopped improving. When
 22every knee is still followed by real gains, that start is the smoothed maximum.
 23
 24E3-MAX is not put forward as an equation to read: it is the same configuration at the configured
 25capability arity, at the raw maximum of its floor (`floor_argmax`), bounding what the additive form can
 26reach.
 27
 28Study chapter: [3. Term generation and selection][study-chapter] -- the rationale, in prose.
 29
 30[study-chapter]: https://github.com/mariolpantunes/ml-meta-perf/blob/main/assets/docs/03-term-selection.md
 31"""
 32
 33from __future__ import annotations
 34
 35import kneeliverse.kneedle as kneedle
 36import kneeliverse.multi_knee as multi_knee
 37import numpy as np
 38import polars as pl
 39
 40#: Every protocol a length is judged on; `floor_curve` takes the minimum over those present.
 41JUDGED_PROTOCOLS = ("r2_in_sample", "r2_loo_dataset", "r2_loo_model", "r2_loo_cell")
 42
 43#: multi-Kneedle's recursion settings: stop splitting a segment once a straight line explains
 44#: it to this coefficient of determination ...
 45KNEE_FIT_THRESHOLD = 0.001
 46#: ... or once it has fewer points than this.
 47KNEE_MIN_POINTS = 3
 48
 49
 50def complexity(arity: int, n_terms: int) -> int:
 51    """Feature slots an equation spends: a term of arity ``a`` names ``a`` raw features.
 52
 53    **Not a count of fitted coefficients** -- those number ``n_terms + 1``. This charges for how
 54    much of the *grammar* an equation uses, which is what tells the same term count at arity 2
 55    apart from arity 3.
 56    """
 57    return arity * n_terms
 58
 59
 60def floor_curve(curve: pl.DataFrame) -> np.ndarray:
 61    """The **worst** of every protocol present, per length. This is what a length is judged on.
 62
 63    The minimum rather than a median, because the weakest protocol is almost always DHO -- the
 64    cell where neither the dataset nor the model has been seen, which is what the study's
 65    headline is about -- and a rule that reports the strictest protocol but selects on a looser
 66    summary selects on a different quantity from the one it publishes. The minimum is always
 67    some protocol's own number.
 68    """
 69    columns = [name for name in JUDGED_PROTOCOLS if name in curve.columns]
 70    if not columns:
 71        raise ValueError("curve carries none of the protocol columns")
 72    return np.column_stack([curve[name].to_numpy() for name in columns]).min(axis=1)
 73
 74
 75def smoothed(values: np.ndarray, width: int) -> np.ndarray:
 76    """A centred running median of ``width`` points, shrinking at the ends.
 77
 78    A median rather than a mean because the noise here is spiky: one held-out dataset outside
 79    the convex hull of the other nineteen craters a single length, and a mean would spread that
 80    crater onto its neighbours where a median ignores it. The same holds for a single lucky
 81    length, which is what a raw argmax picks.
 82    """
 83    if width < 1:
 84        raise ValueError("the smoothing width must be positive")
 85    half = width // 2
 86    return np.array([np.median(values[max(index - half, 0) : index + half + 1]) for index in range(len(values))])
 87
 88
 89def knee_lengths(curve: pl.DataFrame, smoothing: int) -> list[int]:
 90    """The lengths multi-Kneedle proposes on the smoothed floor's Pareto front, ascending.
 91
 92    The front keeps only lengths that improve on every shorter one, so a knee is always a
 93    length worth its terms; the knees are where the front's rate of improvement bends.
 94    """
 95    lengths = curve["n_terms"].to_numpy()
 96    floor = smoothed(floor_curve(curve), smoothing)
 97    front = [index for index in range(len(floor)) if index == 0 or floor[index] > floor[:index].max()]
 98    if len(front) < KNEE_MIN_POINTS + 1:
 99        return []
100    points = np.column_stack([lengths[front], floor[front]]).astype(np.float64)
101    found = multi_knee.multi_knee(kneedle.knee, points, t1=KNEE_FIT_THRESHOLD, t2=KNEE_MIN_POINTS)
102    return sorted({int(lengths[front[int(index)]]) for index in found})
103
104
105def plateau_start(curve: pl.DataFrame, *, delta: float, window: int, smoothing: int) -> tuple[int, str]:
106    """The equation length and how it was reached: ``"knee"`` or ``"maximum"``.
107
108    Candidates are the lengths `knee_lengths` proposes and the smoothed maximum. The chosen one
109    is the first, in increasing length, whose smoothed floor gains at most ``delta`` over the
110    next ``window`` lengths. The maximum always qualifies -- nothing after it gains -- so the
111    rule always returns; a maximum closer to the horizon than ``window`` is still returned,
112    and `experiment.selection_summary` flags a length that sits at the horizon.
113    """
114    if delta < 0.0 or window < 1:
115        raise ValueError("delta must be non-negative and the window positive")
116    lengths = curve["n_terms"].to_numpy()
117    floor = smoothed(floor_curve(curve), smoothing)
118    peak = int(lengths[int(np.argmax(floor))])
119    position = {int(length): index for index, length in enumerate(lengths)}
120    for candidate in sorted({*knee_lengths(curve, smoothing), peak}):
121        if candidate == peak:
122            return peak, "maximum"
123        index = position[candidate]
124        ahead = floor[index + 1 : index + 1 + window]
125        if len(ahead) == window and float(ahead.max() - floor[index]) <= delta:
126            return candidate, "knee"
127    return peak, "maximum"  # unreachable: the peak is always a candidate
128
129
130def plateau_knee(curve: pl.DataFrame, *, delta: float, window: int, smoothing: int) -> int:
131    """The equation length: the start of the first sustained plateau. **The length rule.**
132
133    See `plateau_start`, which also says whether the length is a knee or the smoothed maximum.
134    """
135    return plateau_start(curve, delta=delta, window=window, smoothing=smoothing)[0]
136
137
138def protocol_spread(curve: pl.DataFrame) -> np.ndarray:
139    """How far a length falls from its fit to its worst protocol: ``IS - floor``.
140
141    **Reported, not selected on.** An equation that fits well and transfers badly is a
142    different object from one that does both moderately, and the floor alone cannot tell them
143    apart. Folding the spread into the selection would need a weight between level and spread,
144    which is a free parameter; the two are reported side by side instead.
145    """
146    if "r2_in_sample" not in curve.columns:
147        raise ValueError("a spread needs the IS column to measure the drop from")
148    return curve["r2_in_sample"].to_numpy() - floor_curve(curve)
149
150
151def floor_argmax_index(curve: pl.DataFrame) -> int:
152    """The *row position* of `floor_argmax`'s length, for callers indexing the curve."""
153    return int(np.argmax(floor_curve(curve)))
154
155
156def floor_argmax(curve: pl.DataFrame) -> int:
157    """The length maximising the raw floor: **E3-MAX's rule**, a capability bound.
158
159    No smoothing and no complexity penalty, because this equation is not put forward to be
160    read -- it bounds what the additive form can reach, and a bound should not be discounted
161    for being long. Ties go to the shorter equation. A result at the horizon means the bound
162    is the horizon's, not the data's; `experiment` reports that.
163    """
164    return int(curve["n_terms"].to_numpy()[floor_argmax_index(curve)])
JUDGED_PROTOCOLS = ('r2_in_sample', 'r2_loo_dataset', 'r2_loo_model', 'r2_loo_cell')
KNEE_FIT_THRESHOLD = 0.001
KNEE_MIN_POINTS = 3
def complexity(arity: int, n_terms: int) -> int:
51def complexity(arity: int, n_terms: int) -> int:
52    """Feature slots an equation spends: a term of arity ``a`` names ``a`` raw features.
53
54    **Not a count of fitted coefficients** -- those number ``n_terms + 1``. This charges for how
55    much of the *grammar* an equation uses, which is what tells the same term count at arity 2
56    apart from arity 3.
57    """
58    return arity * n_terms

Feature slots an equation spends: a term of arity a names a raw features.

Not a count of fitted coefficients -- those number n_terms + 1. This charges for how much of the grammar an equation uses, which is what tells the same term count at arity 2 apart from arity 3.

def floor_curve(curve: polars.dataframe.frame.DataFrame) -> numpy.ndarray:
61def floor_curve(curve: pl.DataFrame) -> np.ndarray:
62    """The **worst** of every protocol present, per length. This is what a length is judged on.
63
64    The minimum rather than a median, because the weakest protocol is almost always DHO -- the
65    cell where neither the dataset nor the model has been seen, which is what the study's
66    headline is about -- and a rule that reports the strictest protocol but selects on a looser
67    summary selects on a different quantity from the one it publishes. The minimum is always
68    some protocol's own number.
69    """
70    columns = [name for name in JUDGED_PROTOCOLS if name in curve.columns]
71    if not columns:
72        raise ValueError("curve carries none of the protocol columns")
73    return np.column_stack([curve[name].to_numpy() for name in columns]).min(axis=1)

The worst of every protocol present, per length. This is what a length is judged on.

The minimum rather than a median, because the weakest protocol is almost always DHO -- the cell where neither the dataset nor the model has been seen, which is what the study's headline is about -- and a rule that reports the strictest protocol but selects on a looser summary selects on a different quantity from the one it publishes. The minimum is always some protocol's own number.

def smoothed(values: numpy.ndarray, width: int) -> numpy.ndarray:
76def smoothed(values: np.ndarray, width: int) -> np.ndarray:
77    """A centred running median of ``width`` points, shrinking at the ends.
78
79    A median rather than a mean because the noise here is spiky: one held-out dataset outside
80    the convex hull of the other nineteen craters a single length, and a mean would spread that
81    crater onto its neighbours where a median ignores it. The same holds for a single lucky
82    length, which is what a raw argmax picks.
83    """
84    if width < 1:
85        raise ValueError("the smoothing width must be positive")
86    half = width // 2
87    return np.array([np.median(values[max(index - half, 0) : index + half + 1]) for index in range(len(values))])

A centred running median of width points, shrinking at the ends.

A median rather than a mean because the noise here is spiky: one held-out dataset outside the convex hull of the other nineteen craters a single length, and a mean would spread that crater onto its neighbours where a median ignores it. The same holds for a single lucky length, which is what a raw argmax picks.

def knee_lengths(curve: polars.dataframe.frame.DataFrame, smoothing: int) -> list[int]:
 90def knee_lengths(curve: pl.DataFrame, smoothing: int) -> list[int]:
 91    """The lengths multi-Kneedle proposes on the smoothed floor's Pareto front, ascending.
 92
 93    The front keeps only lengths that improve on every shorter one, so a knee is always a
 94    length worth its terms; the knees are where the front's rate of improvement bends.
 95    """
 96    lengths = curve["n_terms"].to_numpy()
 97    floor = smoothed(floor_curve(curve), smoothing)
 98    front = [index for index in range(len(floor)) if index == 0 or floor[index] > floor[:index].max()]
 99    if len(front) < KNEE_MIN_POINTS + 1:
100        return []
101    points = np.column_stack([lengths[front], floor[front]]).astype(np.float64)
102    found = multi_knee.multi_knee(kneedle.knee, points, t1=KNEE_FIT_THRESHOLD, t2=KNEE_MIN_POINTS)
103    return sorted({int(lengths[front[int(index)]]) for index in found})

The lengths multi-Kneedle proposes on the smoothed floor's Pareto front, ascending.

The front keeps only lengths that improve on every shorter one, so a knee is always a length worth its terms; the knees are where the front's rate of improvement bends.

def plateau_start( curve: polars.dataframe.frame.DataFrame, *, delta: float, window: int, smoothing: int) -> tuple[int, str]:
106def plateau_start(curve: pl.DataFrame, *, delta: float, window: int, smoothing: int) -> tuple[int, str]:
107    """The equation length and how it was reached: ``"knee"`` or ``"maximum"``.
108
109    Candidates are the lengths `knee_lengths` proposes and the smoothed maximum. The chosen one
110    is the first, in increasing length, whose smoothed floor gains at most ``delta`` over the
111    next ``window`` lengths. The maximum always qualifies -- nothing after it gains -- so the
112    rule always returns; a maximum closer to the horizon than ``window`` is still returned,
113    and `experiment.selection_summary` flags a length that sits at the horizon.
114    """
115    if delta < 0.0 or window < 1:
116        raise ValueError("delta must be non-negative and the window positive")
117    lengths = curve["n_terms"].to_numpy()
118    floor = smoothed(floor_curve(curve), smoothing)
119    peak = int(lengths[int(np.argmax(floor))])
120    position = {int(length): index for index, length in enumerate(lengths)}
121    for candidate in sorted({*knee_lengths(curve, smoothing), peak}):
122        if candidate == peak:
123            return peak, "maximum"
124        index = position[candidate]
125        ahead = floor[index + 1 : index + 1 + window]
126        if len(ahead) == window and float(ahead.max() - floor[index]) <= delta:
127            return candidate, "knee"
128    return peak, "maximum"  # unreachable: the peak is always a candidate

The equation length and how it was reached: "knee" or "maximum".

Candidates are the lengths knee_lengths proposes and the smoothed maximum. The chosen one is the first, in increasing length, whose smoothed floor gains at most delta over the next window lengths. The maximum always qualifies -- nothing after it gains -- so the rule always returns; a maximum closer to the horizon than window is still returned, and experiment.selection_summary flags a length that sits at the horizon.

def plateau_knee( curve: polars.dataframe.frame.DataFrame, *, delta: float, window: int, smoothing: int) -> int:
131def plateau_knee(curve: pl.DataFrame, *, delta: float, window: int, smoothing: int) -> int:
132    """The equation length: the start of the first sustained plateau. **The length rule.**
133
134    See `plateau_start`, which also says whether the length is a knee or the smoothed maximum.
135    """
136    return plateau_start(curve, delta=delta, window=window, smoothing=smoothing)[0]

The equation length: the start of the first sustained plateau. The length rule.

See plateau_start, which also says whether the length is a knee or the smoothed maximum.

def protocol_spread(curve: polars.dataframe.frame.DataFrame) -> numpy.ndarray:
139def protocol_spread(curve: pl.DataFrame) -> np.ndarray:
140    """How far a length falls from its fit to its worst protocol: ``IS - floor``.
141
142    **Reported, not selected on.** An equation that fits well and transfers badly is a
143    different object from one that does both moderately, and the floor alone cannot tell them
144    apart. Folding the spread into the selection would need a weight between level and spread,
145    which is a free parameter; the two are reported side by side instead.
146    """
147    if "r2_in_sample" not in curve.columns:
148        raise ValueError("a spread needs the IS column to measure the drop from")
149    return curve["r2_in_sample"].to_numpy() - floor_curve(curve)

How far a length falls from its fit to its worst protocol: IS - floor.

Reported, not selected on. An equation that fits well and transfers badly is a different object from one that does both moderately, and the floor alone cannot tell them apart. Folding the spread into the selection would need a weight between level and spread, which is a free parameter; the two are reported side by side instead.

def floor_argmax_index(curve: polars.dataframe.frame.DataFrame) -> int:
152def floor_argmax_index(curve: pl.DataFrame) -> int:
153    """The *row position* of `floor_argmax`'s length, for callers indexing the curve."""
154    return int(np.argmax(floor_curve(curve)))

The row position of floor_argmax's length, for callers indexing the curve.

def floor_argmax(curve: polars.dataframe.frame.DataFrame) -> int:
157def floor_argmax(curve: pl.DataFrame) -> int:
158    """The length maximising the raw floor: **E3-MAX's rule**, a capability bound.
159
160    No smoothing and no complexity penalty, because this equation is not put forward to be
161    read -- it bounds what the additive form can reach, and a bound should not be discounted
162    for being long. Ties go to the shorter equation. A result at the horizon means the bound
163    is the horizon's, not the data's; `experiment` reports that.
164    """
165    return int(curve["n_terms"].to_numpy()[floor_argmax_index(curve)])

The length maximising the raw floor: E3-MAX's rule, a capability bound.

No smoothing and no complexity penalty, because this equation is not put forward to be read -- it bounds what the additive form can reach, and a bound should not be discounted for being long. Ties go to the shorter equation. A result at the horizon means the bound is the horizon's, not the data's; experiment reports that.