Search options
How the search builds its candidates, and the options that change what it
can find and what it costs. The examples that use X and y start from:
import numpy as np
from beamfeat import BeamFeatRegressor
rng = np.random.default_rng(0)
X = rng.uniform(1, 6, (400, 4))
y = X[:, 0] * X[:, 1] + rng.normal(0, 0.05, 400)
model = BeamFeatRegressor(max_depth=2, beam_width=25, random_state=0).fit(X, y)
Dimensional analysis
Give units as pint quantities or plain strings; dimensionally invalid
expressions (metres plus kilograms) are ruled out before any numerical work —
so x0 + x1 (kg plus m) is never even scored:
y_phys = X[:, 0] * X[:, 1] / X[:, 2] # kg·m/s
model = BeamFeatRegressor(
units={"x0": "kg", "x1": "m", "x2": "s"}, random_state=0
).fit(X[:, :3], y_phys)
print(model.formulas())
['((x1 / x2) * x0)']
The recovered form, kg·m/s, is exactly the target's dimension — and the dimensionally invalid spellings never consumed a beam slot.
Give every column a unit. A column without one combines freely with the
others, so the check stops binding on exactly the columns you left out. Give
unitless columns "dimensionless"; the estimator warns when coverage is
partial, and raises when no key matches a column. Keys are column names, so
with a DataFrame you can write {"rho": "kg/m**3"}.
Constants inside expressions
The linear model supplies the coefficients outside the expressions. That alone
cannot write exp(-x²/2), x^1.5 or (x0 + x1)/(1 + x0·x1), where the
constant sits inside a nonlinearity, so the estimators also use operators that
carry one fitted constant each, listed in fitted_ops. All but sign_shift
are on by default; fitted_ops=() keeps every expression free of constants
and fits about twice as fast on small data. They are a list of their own:
restricting unary_ops leaves them on, so unary_ops=() still allows
log(x + a) unless fitted_ops=() too.
| name | expression | operand |
|---|---|---|
"exp_scaled" |
exp(a·x) |
dimensionless |
"pow" |
x^a |
positive, dimensionless |
"inv_shift" |
1/(x + a) |
any; a takes the operand's unit |
"log_shift" |
log(x + a) |
x + a positive, dimensionless |
"sign_shift", off by default |
sign(x + a) |
any; a takes the operand's unit (Switches at a threshold) |
rng = np.random.default_rng(0)
X = rng.uniform(0.1, 1.0, (500, 2))
y = np.exp(-X[:, 0] ** 2 / 2) + rng.normal(0, 1e-3, 500)
model = BeamFeatRegressor(max_depth=2, random_state=0).fit(X, y)
print(model.formulas(), model.to_sympy())
['exp(-0.5*(x0)^2)'] [exp(-x0**2/2)]
With fitted_ops=() the same data give (x0 + (x0)^2) and log(x0), a
surrogate that scores well on this range and says nothing about the law.
How the constants are chosen
- Where. At each depth, every listed operator is fitted to every
expression in the previous depth's beam (at the first depth, the
beam_widthbest input columns), and to the previous depth'sbeam_widthbest candidates that the beam dropped as near-duplicates: two expressions that correlate at 0.95 need not stay alike under1/(x + a)orexp(a·x). The results join that depth's candidates, to be scored, pruned and kept like any other. The ceilingmax_candidates_per_depthcounts the operator proposals only. - How. Each constant maximises the absolute correlation with the target:
a coarse grid scaled to the operand (for
exp_scaled,|a|up to 10/max|x|; forpow,afrom -4 to 4; for the shifts, poles kept off the observed range; forsign_shift, the operand's 5% to 95% quantiles), then a golden-section refinement. A fit that runs to the edge of its grid is discarded, and so is one for which the likelihood-ratio test of the snapping step below does not reject the null that the operand on its own fits as well (each operator butsign_shiftbecomes linear inxat one end of its range, andsign_shiftis held to the same test). Correlation is used whatever thescorer; the scorer then ranks the result. - Poles.
inv_shiftandlog_shiftkeep their pole away from the data they were fitted on: at least twice the spread of the operand's ten most extreme values at that end, and at least 1% of its range. A pole just past the edge of the data is shaped by the few rows next to it, and the first new row a little further out falls beyond it: NaN for the logarithm, a value of the wrong sign and any size for the reciprocal. A fit that wants its pole closer runs into the bound and is discarded, and snapping never moves a constant past it. The margin widens where the data thin out towards the edge and narrows where they are dense. On 18 real datasets, poles inside it were crossed by a row the fit had not seen in 14% of fits, and poles outside it in 0.3%. A negative power has its pole at 0, sopowfits one only when the operand stays more than the same margin above 0. Otherwisearuns from 0.1 to 4, and a fit that wants less is discarded, sincex^athen takes the shape oflog(x), which has the same pole. The denominator of every quotient and reciprocal in the search keeps the same margin from zero, fitted constant or not; see New rows beyond the training data. - In context.
1/(x0·x1 + a)on its own correlates best with the target asaruns to the pole; only as the denominator of(x0 + x1)isa = 1identifiable. While the depth limit leaves room, each constant is also fitted as a factor of a beam member, and that product joins the next depth's candidates. - Snapping. The constant is replaced by the simplest of 0, ±1, ±2, ±3,
±1/2, ±3/2, ±1/3, ±1/4 for which a likelihood-ratio test at level 0.001
fails to reject the null that the constant takes that value, so
exp(-0.49991·x²)is reported asexp(-x²/2). If the test rejects every one of them, the constant is rounded to the fewest significant digits that cost nothing measurable. A snapped constant is exact into_sympy(),latex()and the printed formulas. - Near copies. A fitted constant can also bend an expression into a near
copy of a simple law. With 10% noise on
log(x0),(x0)^(-0.02)correlates withlog(x0)at 0.99999, and since its constant was fitted on the search rows it fits them a little better. So at the end of the search, each kept expression with fitted constants is compared with its near copies among everything the search found (correlation atredundancy_threshold) that carry fewer constants and are not near copies of another kept expression. The snapping test decides, on the same rows: if it fails to reject the null that a copy fits the target as well, the simplest such copy takes the expression's place. Without this step,log(x0),sqrt(x0),1/x0andx0²at that noise were printed as themselves in 0, 0, 2 and 2 of 10 fits; with it, in 10, 7, 10 and 8, against 10, 9, 10 and 8 withfitted_ops=(). A law that needs its constant keeps it while the data reject the simpler forms, as the Gaussian and relativistic velocity addition of the physics panel do at 0.1% noise. - Stored. The constant is part of the expression:
transform,predictand a pickled model reuse it on new data without refitting.
The constants are fitted inside the search, on the search rows only, so the screen on the selection rows sees fixed expressions and its guarantee is unchanged; see the guarantees page.
What it costs. A fit on the Feynman panel takes about five times as long (0.2 s to 1.1 s per equation). Fitted expressions also take beam places, so they can change what else a depth keeps: a constant-free candidate can be out-ranked by a fitted one, though never dropped as its near-duplicate. And constants are fitted one at a time, greedily, so a law needs its constant-free parts among a depth's best candidates first.
Laws whose parts the score cannot see
The search keeps, at each depth, the candidates that correlate best with the
target and builds the next depth from them. A law can need a part that does
not correlate with the target at all. With inputs centred on zero, x0 and
x1 are each uncorrelated with x0*x1, and every pairwise product is
uncorrelated with x0*x1*x2: the score ranks those parts among the noise,
and the beam drops them before they can be combined. On positive inputs, as
in most physical tables, each factor of a product correlates with it, and
the problem does not arise.
benchmarks/search_stress/
measures the cost. Without magnitude slots, on 500 rows with 10% noise,
x0*x1*x2 on centred inputs was recovered in 54% of fits with 20 columns,
18% with 100 and 10% with 300 (in every fit on positive inputs), and x0*x1
in 32% with 1,000 columns. A wider beam barely helps: 200 places lifted the
20-column figure to 96% at about nine times the fit time, and the 100-column
one only to 26%. A miss mostly ends in no equation and a
NoDiscoveriesWarning.
magnitude_slots adds beam slots for candidates whose magnitude tracks the
target's. Its default, "auto", adds 10 for regression; 0 turns them off,
as the first fit here does:
rng = np.random.default_rng(3)
X = rng.uniform(-1, 1, (400, 10))
y = X[:, 0] * X[:, 1] * X[:, 2] + rng.normal(0, 0.01, 400)
for slots in (0, 3):
model = BeamFeatRegressor(max_depth=2, beam_width=10, magnitude_slots=slots).fit(X, y)
print(slots, model.formulas())
0 []
3 ['(x0 * (x1 * x2))']
The first fit finds nothing and warns NoDiscoveriesWarning.
How the magnitude slots work
- The score. A candidate
cis scored by the absolute correlation oflog(1 + |c|/rms(c))with the same transform of the centred target, wherermsis the root mean square. The logarithm turns a product into a sum, so a factor's magnitude tracks the product's even where the factor itself does not correlate with it. The 1 keeps the rows where a product nears zero, and the noise there, from dominating, and it compresses a heavy tail, so that a ratio with a denominator near zero does not score by accident. On more than 1,000 search rows the score is computed on 1,000 of them, evenly spaced, which is plenty to rank a few slots and keeps its cost from growing with the table. - The slots. After the
beam_widthcandidates the score keeps, up tomagnitude_slotsmore are kept from the rest, best magnitude score first, under the same redundancy check. They are kept at every depth but the last, since they are there to be built on. - The proposals. When the first depth cannot propose every pair of
inputs, the inputs are proposed in an order that interleaves their
magnitude ranking with their score ranking. The magnitude ranking takes
the first place and
magnitude_slots / (beam_width + magnitude_slots)of those after it. - A part behind a visible term. In
x0*x1 + x2the product does correlate with the target, but hundreds of sums ofx2with another column can outrank it. The search then relies on scoring candidates against the residual left by the inputs, and with more inputs than search rows that residual is empty. The magnitude score is also taken against the residual on the best-scoring columns, at most a quarter as many as the rows, which keepsx0*x1in view.
With 10 slots the search recovered x0*x1*x2 on centred inputs in every fit
with 20, 100 and 300 columns, the centred pair in every fit with 1,000
columns, and x0*x1 + x2 with 300 columns on 250 search rows in 88% of
fits, against none without them; it lost no law the search without them
found. Fits took 1.2 to 1.7 times as long. Where no such law is hidden it
changes little: the main suite's mean held-out score over 24 regression
datasets went from 0.885 to 0.884, and the high-dimensional study's from
0.647 to 0.649.
It is for regression only, since a class label has no magnitude to track:
"auto" adds no slot for classification, and a positive number raises. The
ranking runs inside the search, on the search rows only, so the screen's
guarantee is unchanged (see the
guarantees page).
A law whose parts the magnitude score cannot see either is still out of
reach.
A weak term beside a dominant one
The search ends by ranking every candidate it kept against the target and
keeping the best max_features (beam_width by default). A dominant term
fills that ranking with restatements of itself, and a weaker term beside it
is cut although the search built it. The residual slots (residual_slots,
off by default) add to the ranking the first terms of a greedy forward path
through the candidates: each the one that adds most to a least-squares fit
of those before it. A restatement adds nothing to the term it restates, so
the weaker term comes second.
import numpy as np
from beamfeat import BeamFeatRegressor
rng = np.random.default_rng(1)
X = rng.uniform(1, 5, (2000, 4))
signal = X[:, 0] * X[:, 1] / X[:, 2] + 0.5 * np.log(X[:, 3])
y = signal + rng.normal(0, 0.1 * np.std(signal), 2000)
for slots in (0, 10):
print(BeamFeatRegressor(random_state=0, residual_slots=slots).fit(X, y).equation())
y = 0.9929*((x0 / x2) * x1) + 0.1761*((x0 + x3) + (x1 - x2)) - 0.1693*(x0 + (x1 - x2)) - 0.02271 [3 of 50 certified terms; parsimony=None prints all 50]
y = 0.9968*((x0 / x2) * x1) + 0.475*log(x3) + 0.03165 [2 of 55 certified terms; parsimony=None prints all 55]
The logarithm carries 0.6% of the target's variance. Without the slots, two sums stand in for it.
How the residual slots work
- The path. It runs over every candidate the search kept, on the search rows (on more than 20,000 of them, that many evenly spaced, and fewer on a wide table, so that the values stay within 64 MiB, though never fewer than 1,000). Gains within a relative 2e-6 of the best are tied, and the simpler expression wins, as in the beam.
- Near copies. On [1, 5],
log(x3),sqrt(x3)and1/x3correlate above the redundancy threshold, so a depth keeps one of them and drops the others. Before the final ranking, each path term that is an input column, or has a single operand, adds that column or operand and its transforms byunary_opsto the candidates, so the path chooses among them; one of them can also take a place by score. - The count. At most
residual_slotsterms, and never more thanmax_featuresor a quarter of the search rows. A path term replaces a ranked candidate that is a near copy of it, and is dropped if it is a near copy of an earlier path term; every other candidate the score ranked stays, so the screen tests at mostresidual_slotsmore candidates than before.
Over 60 fits of that law, with 4 or 10 columns, 500 to 5,000 rows and two
noise levels, the slots printed the exact law in 33, the default in none
(benchmarks/weak_terms_and_switches/). They are off by default because the
path terms explain whatever the leading terms leave, noise included, and the
forward step prints some of them as riders. In benchmarks/release_comparison/
they printed a term built only from unrelated columns in 19 of 800
calibration-design fits, where the default printed none, raised the share of
riders at a signal-to-noise ratio of 1 from 0.722 to 0.729, and left the
held-out score of 8 of the 11 real regression datasets within 0.005, raising
one and lowering two. With parsimony_alpha=0.05 beside them the riders go
(one design fit of 800, riders 0.064 against 0.061 for the check alone, the
generating term in as many fits), and the weak term prints as often. On pure
noise the two together printed a term in 4 of 200 fits, against 2 at the
default or with the check alone, within the guarantee's q = 0.1. For a law
with a weak term, use both:
model = BeamFeatRegressor(residual_slots=10, parsimony_alpha=0.05).fit(X, y)
The slots are for regression only; a positive number raises for classification. The path runs inside the search, on the search rows only, so the screen's guarantee is unchanged.
Switches at a threshold
sign (a unary operator; list it in unary_ops) and sign_shift (a fitted
one, sign(x + a); list it in fitted_ops) are off by default. With sign,
a product with a switch at zero, such as x2*sign(x0), is a candidate like
any other, and with sign_shift one with a switch at a fitted threshold,
such as x2*sign(x0 - 0.7). sign_shift tries
thresholds at the operand's 5% to 95% quantiles, then refines and snaps the
threshold as the other fitted operators do their constants
(Constants inside expressions).
import numpy as np
from beamfeat import BeamFeatRegressor
rng = np.random.default_rng(0)
X = np.column_stack([rng.uniform(-2, 2, (1000, 2)), rng.uniform(1, 5, (1000, 3))])
y = np.where(X[:, 0] > 0.7, 2.0, -1.0) * X[:, 2] + X[:, 1] + rng.normal(0, 0.3, 1000)
fitted = ("exp_scaled", "pow", "inv_shift", "log_shift", "sign_shift")
for slots in (0, 10):
print(BeamFeatRegressor(random_state=0, fitted_ops=fitted, residual_slots=slots).fit(X, y).equation())
y = 1.539*(x2 * sign(x0 - 0.7)) + 1.01*(x1 + sign(x0 - 0.7)) - 1.161*(sign(x0 - 0.7) - log(x2)) + 0.3139 [3 of 50 certified terms; parsimony=None prints all 50]
y = 1.501*(x2 * sign(x0 - 0.7)) + 0.4993*(x1 + x2) + 0.4915*x1 + 0.002704 [3 of 52 certified terms; parsimony=None prints all 52]
The slope in x2 is 2 above the threshold and -1 below it: 0.5 plus 1.5
times the sign. Both fits find the switch term; the rest of the law, x1
and 0.5*x2, is weaker, and a surrogate stands in for it until the
residual slots add it. Over 20 fits like
these, at thresholds 0 and 0.7 and two noise levels, sign_shift printed the
switch in every fit, with R² against the noiseless target of 0.997 to 0.999
(1.000 with the slots), against 0.76 to 0.88 for the default and 0.983 to
0.996 for gradient-boosted trees (benchmarks/weak_terms_and_switches/).
sign finds a switch at zero only; sign_shift finds that one too. On the main
suite's threshold_step, 3*b*1[a > 1.5], sign_shift raised the held-out
score from 0.841 to 0.999; on the other datasets of
benchmarks/release_comparison/ it moved held-out score little, raising it
on penguins by 0.025 and lowering the classifier's accuracy on breast_cancer
by 0.022.
Search constraints
max_inputs caps the distinct input columns in one feature: max_inputs=2
admits x0 * log(x0 / x1) but not x0 * x1 / x2. Use it when a feature that
mixes many columns would be uninterpretable to you anyway. It is checked as
each candidate is constructed, before any numerical work, so a candidate it
rules out costs no evaluation and takes no beam slot. search_result_.trace
counts them per depth (n_rejected_constraints).
It does not make selection more powerful. The screen's multiple-testing
correction runs over the features that reach selection, which the beam
already caps at beam_width and max_features however many expressions were
proposed; the limit changes which features those are and how long the search
takes.
It is off by default (None), and it only ever narrows the search. An
expression of depth d combines at most 2^d input columns, so at the default
max_depth=2 no feature uses more than four, and max_inputs=4 or more
changes nothing; at max_depth=3 the limit matters up to 8. Set it at or
above the number of inputs any law you look for needs. A law that needs more
cannot be built, and the search returns the best expression within the limit
instead:
import numpy as np
from beamfeat import BeamSearch
rng = np.random.default_rng(0)
X = rng.uniform(0.5, 3, (400, 8))
y = X[:, 0] * X[:, 1] + X[:, 2] * X[:, 3] + rng.normal(0, 0.1, 400)
for limit in (None, 3):
search = BeamSearch(max_depth=2, beam_width=30, random_state=0, max_inputs=limit).run(X, y)
print(limit, search.names[0])
None ((x0 * x1) + (x2 * x3))
3 ((x0 + x2) * sqrt(x1))
On wide data whose signal is spread over many columns, a low limit can still help, because it keeps the beam from filling with combinations of many columns. Compare the held-out score with and without it rather than assuming either way.
Speed and memory
On a tall table, max_search_rows runs the search on that many of its rows,
drawn at random, while the screen keeps every selection row; see
Tall data. The rest of this
section is about wide tables.
n_jobs spreads the search over cores, and -1 uses all of them. The
workers are threads, since numpy releases the GIL while it scores, or
processes for the gradient-boosting scorer, whose model fits do not. Apart
from that scorer, a depth with fewer than 4096 candidates is scored in the
calling thread, so the setting pays off on wide tables rather than on a
handful of columns. It never changes the fit: features, p-values and
predictions are identical for every value.
model = BeamFeatRegressor(random_state=0, n_jobs=-1).fit(X, y)
Memory does not grow with the number of candidates either. Each depth is
evaluated and scored in chunks of about 8 MiB and only the scores are kept,
so a default fit on 1000 columns and 600 rows peaks at about 0.35 GB.
BeamSearch(chunk_size=...) sets the chunk size directly, again without
changing the result. The exceptions are mutual information, which draws its
noise for a whole depth, and a custom scorer that does not declare itself
separable: both still receive a depth at once.