Skip to content

User guide

Everything below is runnable as-is; outputs shown are from real runs.

Install

pip install beamfeat            # core: numpy + scikit-learn only
pip install "beamfeat[units]"   # + pint, for dimensional analysis

See Installation for running the test suite or reproducing the benchmarks.

Sixty seconds to a vetted equation

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)
print(model.equation())
print(model.fdr_controlled_)
y = 0.9999*(x0 * x1) - 0.003943   [1 of 25 certified terms; parsimony=None prints all 25]
True

Three things matter. equation() is the fitted model itself — evaluate it on raw feature values and you reproduce predict(). fdr_controlled_ states whether the features carry the false-discovery-rate guarantee; check it before treating them as statistically vetted. And the suffix says that those two do not describe the same set of terms.

That last point has an attribute as well as a suffix, because a script never reads the printed string:

print(model.fdr_controlled_, "|", model.fdr_scope_)
True | screened set

fdr_controlled_ says whether there is a guarantee. fdr_scope_ says what it is over: "screened set" when the printed terms were pruned from a larger certified set, "printed equation" when the guarantee covers the terms you are looking at, None when there is no guarantee to scope. Branch on fdr_scope_, not on the equation text.

Why the equation says "1 of 25"

Screening certified twenty-five formulas. On a strong signal the marginal null correctly passes every near-duplicate of the true feature — each one really is associated with the target — so twenty-five is the honest size of the certified set. A parsimony step then keeps the compact predictive subset and fits that, because an equation of dozens of near-duplicate terms defeats the interpretability the library exists for.

The subset is chosen on the rows selection already used, so the q-level guarantee covers the twenty-five it came from and not the one that survived. That is what the suffix records, and fdp_inflation_ prices it:

print(model.fdp_inflation_)
25.0

|S|/|S'|, the factor by which pruning can inflate the realised false discovery proportion, since the denominator shrinks faster than the numerator can. Set parsimony=None to fit the screened set entire, at which point the printed equation is the certified object 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 = 1.077*(x0 * x1) - 0.2264*(x0 * sqrt(x1)) - 0.5879*(sqrt(x1) - 1/(x0)) + 0.09804

What the default costs you in fit is small, and measured rather than asserted: about a thousandth of held-out R² across 308 paired fits, for an equation some twenty-five times shorter, with no case where pruning inflated the realised false discovery proportion. The measurement is benchmarks/PARSIMONY_COST.md. Short and certified costs rows instead of the guarantee: see parsimony_holdout on the guarantees page.

Column names flow into formulas

Fit on a DataFrame and formulas use your names:

import pandas as pd

df = pd.DataFrame({"mass": rng.uniform(1, 5, 300),
                   "vol":  rng.uniform(1, 5, 300)})
target = df["mass"] / df["vol"] + rng.normal(0, 0.02, 300)
print(BeamFeatRegressor(random_state=0).fit(df, target).formulas())
['(mass / vol)']

Dimensional analysis

Give units as pint quantities or plain strings; dimensionally invalid expressions (metres plus kilograms) are rejected 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.

Cover every column. A column without a unit is dimensionally unconstrained, so it combines freely with the labelled ones and the check stops binding on exactly the columns you did not vouch for — labelling your real measurements and leaving the noise blank is the case worth naming, because it looks careful and is not. Give the genuinely unitless columns "dimensionless"; the estimator warns when coverage is partial, and raises when the keys match no column at all. Keys are column names, so a DataFrame lets you write {"rho": "kg/m**3"} rather than {"x0": ...}.

Columns that carry no data

A column with no variation cannot be selected — it standardises to zeros, so its association with the target is zero wherever it appears. That is the right outcome and costs nothing, but in the output it looks the same as a column that simply does not matter, so it is reported:

beamfeat: 55 of 521 columns are constant (WAP003, WAP004, WAP092, ...) and
cannot be selected. They are ignored and cost nothing, but check whether they
are meant to carry data.

Constancy is relative to the column's magnitude, not an absolute floor on the spread — a column varying over 0 to 1e-9 has a smaller variance than a stuck sensor reading 9.81, and only the second is constant. Use is_constant to apply the same test when screening a table before fitting.

A constant with a genuine unit is a different matter: gravity is m/s² whether or not it varies, and expressions built from it are unit-checked, so label it. Its absence from a recovered formula is not a miss — a multiplicative constant is absorbed into the fitted coefficient rather than returned as a symbol.

Audit what selection did

Every candidate's exact p-value and q-value is kept:

for row in model.selection_report_[:3]:
    print(row["formula"], round(row["q_value"], 4), row["kept"])

Features are kept only if they pass FDR screening at target_fdr (default 0.1, Benjamini–Yekutieli) on a held-out split, then survive the parsimony pass. screened marks the certified set, which is what the guarantee covers; kept marks the compact subset the equation prints. Set parsimony=None and the two agree.

Honest failure, by default

On a target with no structure, nothing passes — and the model says so instead of returning junk:

noise = rng.standard_normal(400)
model = BeamFeatRegressor(random_state=3).fit(X, noise)
# NoDiscoveriesWarning: ... Returning no constructed features ...
print(model.equation())
y = 0.0353  (no feature passed selection)

Prefer the old behaviour? on_no_discoveries="fallback" keeps the unfiltered search output (flagged fdr_controlled_=False); "raise" raises.

Classification

from beamfeat import BeamFeatClassifier

labels = (X[:, 0] * X[:, 1] > X[:, 2] * X[:, 3]).astype(int)
clf = BeamFeatClassifier(max_depth=2, random_state=0).fit(X, labels)
print(clf.equation())          # log-odds form; boundary = zero level set

Real data with missing values or categoricals

beamfeat never imputes or encodes silently — compose explicitly:

from sklearn.pipeline import make_pipeline
from sklearn.impute import SimpleImputer

pipe = make_pipeline(SimpleImputer(strategy="median"),
                     BeamFeatRegressor(random_state=0))

When to reach for something else

If a tree model beats beamfeat by a wide margin, your signal is likely piecewise, not algebraic — use the tree. If you need constants fitted inside expressions (exp(-3.2*x)), use a symbolic regressor such as PySR. The guarantees page states every boundary with the measurement behind it.