Reading a fit
What a fitted model prints, what each part means, and what to check before relying on it. The examples continue from the quickstart's fit:
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)
Why the equation says "1 of 25"
The screen rejected the null for twenty-five formulas, the certified set. Each formula's null is that it is independent of the target. On a strong signal every near-duplicate of the true feature really is associated with the target, so rejecting its null is correct. A forward step then picks the compact subset that predicts best, since an equation of dozens of near-duplicates would be unreadable.
The subset is chosen on the rows the screen already used, so the guarantee
covers the twenty-five, not the one term printed. The suffix records that,
and fdp_inflation_ prices it:
print(model.fdp_inflation_)
25.0
That is |S|/|S'|, the factor by which pruning can inflate the realised false
discovery proportion. With parsimony=None the model fits the whole
screened set, the printed equation is the certified set, and the suffix
disappears:
full = BeamFeatRegressor(max_depth=2, beam_width=25, random_state=0,
parsimony=None).fit(X, y)
print(full.n_features_out_)
print(full.equation(max_terms=3))
25
y = 0.077*(x1 * (x0 * x1)) + 0.4333*(x0 * x1) - 0.0842*((x0 * x1) / (1/(x1 - 8.17))) - 1.127
max_terms shortens the display, not the model: the terms it leaves out are
held at their training mean, and their average contribution moves into the
constant, which is why it differs from the full equation's.
The short equation costs little: about a thousandth of R² over 308 paired
fits, for an equation about twenty-five times shorter, and pruning never
inflated the realised false discovery proportion
(benchmarks/PARSIMONY_COST.md). For an equation both short and certified,
spend rows instead:
Re-testing the printed terms.
The equation at every size
The forward step adds terms one at a time and stops when the next adds less
than parsimony_tol to R². equations() shows the step continued through the
whole screened set, one equation per number of terms, so you can see what the
stopping point traded away:
print(model.equations(max_n_terms=3))
Forward path through the 25 screened terms. R^2 is in-sample, on the rows screening used; gain is the R^2 the line's last term adds.
k complexity R^2 gain equation
* 1 4 0.9999 0.9999 y ~ (x0 * x1)
2 12 0.9999 1.45e-06 y ~ (x0 * x1) + (1/(x0 - 8.1) * 1/(x1 - 8.17))
3 18 0.9999 8.674e-07 y ~ (x0 * x1) + (1/(x0 - 8.1) * 1/(x1 - 8.17)) + ((x0 + x3) * x1)
...
* the fitted model (the forward step stops before the first gain below parsimony_tol=0.001).
The FDR guarantee covers the screened set of 25 terms (line 25), not an equation chosen from this list.
On a noisy target the marked line can sit further down the list, each term
after the first adding a little by fitting noise.
parsimony_alpha
stops the step earlier; the list is the same, and only the marked line moves.
At the default settings, a line marked a is where parsimony_alpha=0.05
would stop, and a footer line says so; equation() then ends with a note
such as parsimony_alpha=0.05 prints 1, selection-row R^2 0.935 against
0.939. Neither appears when that level would print as many terms.
Complexity counts the nodes in each term's expression tree — operators and
input columns, (x0 * x1) being 3 — plus one per term for its coefficient
and one per fitted constant.
The lines are written y ~ a + b, without coefficients, because only the
marked one has been fitted; equation() prints its coefficients. For a
classifier the R² is that of the integer-coded labels, the heuristic the
forward step ranks by, not a classification metric.
Keep the output's last line in mind: every line is chosen on the rows the
screen used, so picking line 3 because it looks better is the same kind of
choice the forward step makes, and the guarantee covers it no more than the
marked line. The records behind the text are in equations_, one dict per
line (n_terms, complexity, r2, formulas, features, and model,
which marks the fitted line).
Audit what selection did
Every candidate's permutation p-value and its adjusted q-value is recorded:
for row in model.selection_report_[:3]:
print(row["formula"], round(row["q_value"], 4), row["kept"])
A feature is kept only if the screen rejects its null at target_fdr
(default 0.1, Benjamini–Yekutieli) on the held-out split and the parsimony
step then retains it. screened marks the certified set, the candidates
whose nulls the screen rejected, which is what the guarantee covers; kept
marks the compact subset the equation prints. Set parsimony=None and the
two agree.
One equation or one of many
A fit prints one equation, found on one random split of the training rows
into the part the search sees and the part selection tests on. Where many
expressions predict about equally well, another split prints another
equation, and nothing in a single fit says which case you are in.
stability_report finds out. It refits clones of the estimator on random
splits of the data, scores each once on a test part its fit never saw, and
counts the distinct equations:
from beamfeat import stability_report
report = stability_report(BeamFeatRegressor(max_depth=2, beam_width=25), X, y, n_splits=30)
print(report)
BeamFeatRegressor refitted on 30 random splits (random_state=0), each fitted on 300 rows and scored on the other 100.
Held-out R^2: mean 1.000, sd 0.000, min 1.000, max 1.000.
1 distinct equation in 30 splits: every split printed it.
1 distinct feature, selected in 30 of the 30 splits.
Equations printed in more than one split, most frequent first:
30/30 y ~ (x0 * x1)
Features, most frequent first:
30/30 (x0 * x1)
Features whose values on the rows of X correlate above 0.999 in absolute value are counted as one; an equation is the set of its features, whatever its coefficients.
Every split found the planted product. The diabetes data are another matter:
from sklearn.datasets import load_diabetes
diabetes = load_diabetes(as_frame=True)
report = stability_report(BeamFeatRegressor(), diabetes.data, diabetes.target, n_splits=30)
print(report.summary(max_rows=3))
BeamFeatRegressor refitted on 30 random splits (random_state=0), each fitted on 331 rows and scored on the other 111.
Held-out R^2: mean 0.491, sd 0.052, min 0.395, max 0.575.
30 distinct equations in 30 splits; none recurs.
98 distinct features; the most frequent in 12 of the 30 splits.
Features, most frequent first:
12/30 ((bmi + s5) + (bp - s3))
10/30 ((bmi + s5) - (s3 + sex))
7/30 ((bmi + s5) + (age * sex))
... and 95 more features
Features whose values on the rows of X correlate above 0.999 in absolute value are counted as one; an equation is the set of its features, whatever its coefficients.
No two of the thirty splits printed the same equation, and held-out R²
ranges from 0.395 to 0.575, a spread one fit's score would hide. What
recurs is a family of sums and differences of bmi, s5, bp and s3.
Report that family, with its frequencies, rather than one equation.
What the report holds, and how it scores
Two formulas count as one feature when their values on the rows of X
correlate above 0.999 in absolute value. A rewriting such as (x1 - x0) for
(x0 - x1) is then not counted twice, and neither is a rescaling or a
near-duplicate. An equation is the set of its features, whatever its
coefficients. The report keeps everything behind the summary:
report.splits[i]has the rows splitiwas fitted and scored on, its formulas, itsequation(), its score and the fitted clone;report.featuresandreport.equationshold the classes, most frequent first, each with the splits it appeared in;report.scoresholds the held-out scores.
The splits and the clones' seeds come from random_state (default 0), so a
report is reproducible, and n_jobs fits splits in parallel processes
without changing it. The regressor is scored by R² and the classifier by
accuracy, as their score methods are. A transformer has no score of its
own, so it is scored by a ridge regression on its features, or by a logistic
regression for classification. When a test row is outside a selected
formula's domain, the classifier predicts
its most frequent training class there, as its predict does. A regressor or
transformer has no score on such a test part, so that split goes unscored,
and the summary counts it.
resample chooses what varies. The default, "splits", draws new
training and test rows and a new seed for every split: how far the equation
and its score move with another sample of the data. resample="seeds"
keeps the first split's rows and varies only the seed, as in the paper's
stability study (Appendix E): how much the equation owes to one random draw.
On diabetes it still prints 30 distinct equations in 30 fits, but the
standard deviation of R² falls from 0.052 to 0.009, so most of the score's
spread comes from the test rows, while the turnover of equations remains.
When no null is rejected
On a target with no structure the screen rejects no null, and the model says so instead of returning junk:
noise = np.random.default_rng(1).standard_normal(400)
empty = BeamFeatRegressor(random_state=0).fit(X, noise)
# NoDiscoveriesWarning: ... Returning no constructed features ...
print(empty.equation())
y = -0.08207 (no feature selected)
Not on every draw. Under the global null every rejection is a false
rejection, so the FDR is the probability that a fit rejects any null at all:
up to target_fdr of pure-noise fits still certify features, and when one
does, the nulls of correlated candidates tend to be rejected together. The
same noise under another search seed can certify a dozen formulas with
fdr_controlled_ = True: that is the error the guarantee bounds in
expectation, not a breach of it.
Prefer the old behaviour? on_no_discoveries="fallback" keeps the
unfiltered search output (flagged fdr_controlled_=False); "raise"
raises.
LaTeX
latex() typesets equation(): the same terms, coefficients and options
(precision, max_terms, style), and the same note when the guarantee
covers more terms than are printed. It needs sympy (pip install
"beamfeat[display]"), as to_sympy() does.
print(model.latex())
print(model.latex_formulas())
y = 0.9999 x_{0} x_{1} - 0.003943 \quad \text{[1 of 25 certified terms; parsimony=None prints all 25]}
['x_{0} x_{1}']
latex(note=False) leaves the note off, for pasting into a document; the
guarantee still covers the screened set, so say so where the equation
appears. The string has no math delimiters; wrap it in $...$, or in a
notebook use IPython.display.Math(model.latex()). The classifier's latex() gives the
log-odds of the positive class, or one softmax score per class in an
aligned block, as its equation() does.
Export to NumPy, JAX or PyTorch
to_sympy() returns each feature as a sympy expression in real symbols named
after the input columns, so sympy.lambdify turns the features into a
function for any array library sympy can print to. No extra dependency is
involved beyond sympy and the target library:
import sympy
columns = sympy.symbols("x0:4", real=True) # one symbol per input column, in order
features = sympy.lambdify(columns, model.to_sympy(), modules="numpy")
raw = model.coef_ / model.scaler_.scale_
offset = model.intercept_ - raw @ model.scaler_.mean_
prediction = np.column_stack(features(*X.T)) @ raw + offset # model.predict(X), to rounding
modules="jax" or modules="torch" (sympy 1.14 or newer) gives the same
function on JAX or PyTorch arrays, with jnp.stack(..., axis=1) or torch.stack(..., dim=1) in
place of np.column_stack; the coefficients and offset are plain arrays to
convert once. Columns of a DataFrame keep their names as symbols when the
names are valid identifiers, and are sanitised otherwise; read the symbols
from free_symbols of the expressions in that case.
Two differences from transform remain. sympy may rewrite an expression into
an equivalent form, so a value can overflow in one and not the other. And a
row outside a formula's domain gives whatever the array library gives there,
with no warning from beamfeat.