What it actually does
You have p candidate features and want a short list. Trying every subset means 2^p fits, so stepwise walks instead: start with nothing, add whichever single feature improves an information criterion the most, repeat until no addition helps. Backward elimination runs the same walk in reverse from the full model, and the bidirectional version lets a feature re-enter or leave at every step. Each step costs a handful of refits, which is why it dominated statistics software for decades. The catch is what the procedure hides: the data chose the model, and then the same data are used to report on it. The p-values, confidence intervals and R-squared printed for the final model are computed as though the features had been specified in advance, so they are optimistically wrong, and nothing in the output says so.
- Everything the base model assumes still holds: a linear signal, independent errors, and homoscedastic Gaussian noise for OLS inference
- The candidate pool was fixed before the outcome was inspected, and a sparse subset of it is adequate; if the true signal is dense, any short list is wrong
- n is comfortably larger than the number of selected terms, and well above the pool size for backward elimination to be defined at all
- The criterion matches the goal: AIC for prediction, BIC for recovering a true subset; neither is a licence to read the result as an effect estimate
- Selection and estimation use separate data if the output is to be used for inference, or the output is treated as descriptive only
Loss function
A fit term plus a fee per feature, minimized over every possible subset. AIC charges 2 per parameter and approximates out-of-sample predictive loss; BIC charges ln n, which is harsher once n exceeds about eight and is consistent at recovering the true subset if one exists in the pool. Written this way the problem is best-subset selection, an L0 penalty: it counts features and does not care how large their coefficients are. Stepwise does not solve that problem. It solves a much smaller one — at each step, compare only the p neighbouring subsets one feature away — and reports the result as though it had solved the whole thing.
- the subset of candidate features the model is allowed to use
- maximized likelihood of the base model (OLS, logistic, Poisson) restricted to the features in S
- price charged per parameter: 2 for AIC, ln n for BIC. This is the only knob that separates a permissive search from a strict one
- residual sum of squares of the least-squares fit on S; under Gaussian errors the log-likelihood reduces to a function of it
- number of observations and number of candidate features
How it is minimized
Greedy discrete search over the subset lattice: add (forward) or drop (backward) the single feature that most reduces the criterion, and stop at the first step that cannot improve it
- the current subset after t steps; empty for forward selection, full for backward elimination
- the candidate whose addition lowers the criterion the most
- change in the criterion from adding feature j: the log-ratio of residual error, plus the fee c for one more parameter
- the mirror move for backward elimination: drop the feature whose removal raises the criterion least, and stop when every drop hurts
Exact best-subset selection is combinatorial: there are 2^p subsets, and the underlying sparse-approximation problem is NP-hard in general. Branch-and-bound over the lattice (the leaps-and-bounds family) stays practical to a few dozen features, and modern mixed-integer solvers push exact search further, into the hundreds of features under favourable conditions. Stepwise gives up exactness for a cost of roughly p^2 / 2 fits and takes the greedy path through the lattice. The lasso takes a different exit from the same wall: it relaxes the L0 count into an L1 sum, which makes the problem convex and the answer unique, at the price of shrinking the coefficients it keeps. The add step is also a similarity search in disguise. Adding the feature that most reduces RSS is the same as picking the candidate with the largest partial correlation with the current residual — the cosine between the residual and that candidate after it has been orthogonalized against the features already in the model. That is why correlation between candidates, not their marginal importance, drives who gets in, and why the optimized implementation below can score every candidate in one matrix product. Swap this similarity for the raw correlation with the response and the method silently selects redundant features, because it stops seeing what the model already explains.
criterion (c)
The per-feature price. AIC is permissive and targets prediction; BIC is stricter and targets recovering the true subset; AICc corrects AIC when n is small relative to the model size (AIC c = 2; BIC c = ln n; AICc when n / k < 40)
direction
Forward starts empty and needs only n > 1 observations; backward starts full and needs n > p; both allows features to re-enter or leave. The three can end at different models on the same data (forward, backward, or both)
p-enter / p-remove
The older significance-test variant of the same search: add when the partial F-test p-value is below p-enter, drop when it is above p-remove. Keeping p-remove above p-enter prevents a feature cycling in and out (commonly 0.05 to 0.15; p-remove >= p-enter)
max terms
A hard cap on subset size, which bounds the cost and the damage when the pool is large relative to n (well under n / 10)
forced terms and hierarchy
Features that are always kept (the treatment indicator, a known driver) and a rule that an interaction may not enter before its main effects
Termination is guaranteed: each accepted move strictly lowers a criterion over a finite lattice, so the walk cannot cycle and stops within p steps (2p with bidirectional moves). Optimality is not guaranteed, and the failure modes are structural. The search is path-dependent, so forward and backward selection routinely disagree on the same data. Forward selection misses suppressor variables — a feature that is useless alone and valuable alongside another — because it never gets the chance to be added jointly. Among correlated candidates the winner is arbitrary and flips under a bootstrap resample, so the selected set is unstable even when its predictions are not. And the selected model carries a winner’s curse: coefficients are biased away from zero, standard errors are too small, R-squared is inflated, and the F and t p-values are far too optimistic because they ignore that the model was selected from 2^p possibilities. Almost none of that is a convergence problem. It is the cost of using one sample for selection and for estimation.
A full forward path evaluates about p + (p - 1) + ... = O(p^2) candidate models, each refit costing O(n k^2 + k^3) with normal equations on k selected terms, so O(p^2 n k^2) naively. Incremental orthogonalization scores all remaining candidates in O(n p k) per step. Best subset is 2^p fits. The lasso path costs about one OLS fit with warm starts, which is why it replaced stepwise as the default.
How, where, and why it is used
Time-Series Forecasting
AdaptedStepwise selects regressors and lags inside a regression with ARMA errors (ARIMAX): the candidate pool holds lags of the target, lags of exogenous drivers, calendar dummies and Fourier terms, and the search picks a subset by AICc. The same idea appears in automatic ARIMA order search, which walks the neighbours of the current (p, d, q) order by criterion rather than fitting the whole grid — arguably the one place greedy selection is the unexamined industry default.
- Automatic ARIMA order selection across thousands of series, where a stepwise neighbourhood search keeps the cost per series low
- Demand forecasting with exogenous drivers (price, promotion, weather), choosing which drivers and which of their lags enter
- Macroeconomic nowcasting from a modest panel of indicators, historically
- A first-pass lag screen on a new series before a more careful model is built
Likelihood-based criteria respect the time-series structure in a way a naive shuffled hold-out does not, and the search is cheap enough to run per series. It stops being defensible as the lag pool grows: with dozens of correlated lags the greedy search picks spurious ones and the choice is unstable between refits, and a penalized method or a model with the dynamics built in does the job better. Criterion values are also only comparable across models fitted to the same effective observations and the same order of differencing, so letting the search change either quietly breaks the comparison.
- Fix the differencing order before the search; AIC values from models with different differencing are not comparable
- Build every candidate lag over the same observation window so the sample is identical across models
- Fourier terms rather than one dummy per period, so the seasonal structure costs a few candidates instead of dozens
- Standardize exogenous drivers and keep a forced-in baseline (the intercept, the AR term that the domain demands)
Rolling-origin cross-validation with the selection step repeated inside every fold, scored by MASE against seasonal-naive. Selecting once on the full series and then cross-validating the survivor leaks the future into the choice of features and overstates accuracy. Check support stability across folds: a subset that changes between origins is selecting noise.
- Selecting features on the whole series and then evaluating on a hold-out carved from it, which is the standard leak here
- Letting the search change differencing or the sample window, so criterion values are no longer comparable
- Reading the selected lags as the causal lag structure; they are the subset that minimized a criterion under correlation
- Trusting the final model’s reported standard errors for the exogenous coefficients, which ignore the selection step
Anomaly Detection
Not applicableStepwise chooses predictors for a labelled target and defines no notion of normal behaviour or anomaly score; a residual-based detector built on its output also inherits selection-shrunk residuals on the training window, so penalized regression or a density model is the right starting point instead.
Optimization
AdaptedStepwise is a greedy hill-climb over the Boolean lattice of feature subsets, which makes it the smallest textbook instance of discrete local search rather than a tool for optimization problems. The objective is the information criterion above; the neighbourhood is "one feature added or dropped"; the stopping rule is the first local minimum. What it teaches transfers to wrapper-style feature selection around any model, and to the general question of when a greedy walk over a combinatorial space is good enough.
- Wrapper feature selection around a non-linear model, using the same add-or-drop neighbourhood with cross-validated error as the score
- Sensor or measurement-subset selection when each feature has an acquisition cost and the budget is a hard constraint
- Seeding an exact solver: a stepwise solution is a cheap, feasible incumbent for a branch-and-bound or mixed-integer best-subset search
It is a legitimate local search with no optimality guarantee, and the honest comparison is against its alternatives: exact best-subset for small p, the lasso for a convex relaxation, and a genetic algorithm or simulated annealing when the subset score is expensive and non-smooth. When the criterion is cheap and p is small, it is a good enough incumbent. When features interact so that a pair is valuable only together, a one-move neighbourhood cannot see it and the greedy walk stalls at a poor local minimum.
- Encode a subset as a bit vector, so the add and drop moves are bit flips and the lattice is explicit
- Attach per-feature costs and fold them into the price c when features differ in acquisition cost
- Cache the Gram matrix or orthogonal basis so each neighbour is scored without a refit
Compare the subset against an exact best-subset or mixed-integer solution on a small instance, and report the criterion gap. Restart from several random starting subsets and check whether they converge to the same set; if they do not, the landscape is rugged and a single greedy run is not reporting the optimum.
- Treating the first local minimum as the best subset
- A one-move neighbourhood that cannot find pairs of features valuable only together
- Optimizing a noisy criterion on a small sample, so the walk follows noise and the winner does not reproduce
Risk & Fraud
ViableStepwise logistic regression is the historical default in credit scorecard development: a pool of binned or weight-of-evidence encoded variables goes in, and the search returns a short list a model risk reviewer can inspect. The same likelihood-based search applies, with the Bernoulli log-likelihood in place of the Gaussian one.
- Legacy credit scorecards and probability-of-default models built in standard statistical packages
- Application scoring where the regulator expects a small, justifiable variable list
- Quick variable screening before a more careful penalized or boosted model is fit
It produces the short, reviewable variable list that governance demands, and it is deeply embedded in existing tooling and documentation. The defensible modern practice is to use it for screening, then refit the chosen subset on held-out data and report intervals from that refit, or to replace it with an L1-penalized logistic regression, which delivers the same short list with a stable, reproducible path.
- Weight-of-evidence or monotone binned encodings so every kept coefficient has a sign a reviewer can sanity-check
- Group the dummy columns of one categorical so the search keeps or drops the variable as a unit
- Fix the candidate pool before looking at the outcome
Out-of-time validation: select on an earlier window and report AUC, KS and calibration on a later one. A random hold-out hides the temporal gap that dominates deployment error in this domain, and the selected subset is the first thing that fails to carry across it.
- Reporting the selected model’s p-values and intervals as if the variables had been chosen in advance
- Selecting and validating on the same sample, which makes the scorecard look better than it is
- A subset that churns between quarterly refits, forcing a model-risk re-review every time
Causal Inference & Uplift
AdaptedUsed to choose which covariates to adjust for, stepwise is a recognised error: selecting confounders by their association with the outcome alone discards variables that matter mainly through treatment assignment, and the treatment effect is then estimated from a model chosen to fit the outcome. It is acceptable only in guarded forms — the treatment is forced in, covariates are selected against both the outcome and the treatment, and the effect is estimated on a sample not used for selection.
- Propensity-score model specification in observational studies, with the treatment forced into the outcome model
- Selecting among pre-specified candidate adjustment sets, reported with sensitivity analysis
The honest framing is that stepwise answers a prediction question and causal inference asks an identification one. Post-double-selection or an orthogonalized (double machine learning) estimator is the right tool, because it keeps selection error away from the effect estimate; stepwise is acceptable only as one component inside that structure.
- Force the treatment indicator into every candidate model so it is never subject to selection
- Take the union of covariates selected in the treatment equation and the outcome equation
- Specify the candidate pool and any hierarchy from subject-matter knowledge before looking at the data
Coverage of the effect interval under simulation with a known effect, not predictive fit. Use sample splitting — select on one half, estimate on the other — to see how much of the reported precision came from the selection step.
- Reporting the treatment effect and its interval from the post-selection model as though selection had not happened
- Dropping a confounder that is weakly related to the outcome but strongly related to treatment
- Letting the search remove the treatment variable itself
What it costs to run
Minutes at most. The search is O(p^2) cheap fits, and a single-core fit over a few hundred candidates on tens of thousands of rows is seconds with an orthogonalized implementation. That cheapness is exactly what tempts people to stack interaction terms into the candidate pool until the multiple-testing damage swamps the method.
A dot product over the selected features only, which is its real operational virtue: the selected subset is often a small fraction of the candidates, so serving needs only those feature fetches and the audit trail is a short list.
Whenever the data distribution shifts, but with a discipline most teams skip: rerunning the search each time produces a possibly different feature set, which breaks downstream consumers and any review that keyed on it. Freeze the selected subset between scheduled refits, and rerun selection on a calendar rather than automatically.
- Track the selected subset across refits; churn in the support is the earliest warning that the selection is chasing noise rather than signal
- Bootstrap the whole selection procedure periodically and report each feature’s inclusion frequency, so a feature that is in 40% of resamples is not presented as a stable finding
- Compare out-of-sample error against the in-sample error the search optimized; a widening gap means the selection is overfitting
- Monitor the distribution of every selected feature, since a drifting feature can be the one the search happened to favour
- Selection must happen inside every cross-validation fold; selecting once and cross-validating the survivor gives performance estimates that are systematically too good
- The final model’s printed standard errors, confidence intervals and R-squared are invalid because they ignore the selection step; never ship them as uncertainty estimates
- Persist the selected subset and the encoding of every feature as part of the model artefact; a refit that reselects silently changes the model signature
- Candidate pools that grow over time make the multiple-testing problem grow with them, so the same procedure becomes steadily less trustworthy without any change in the code
Pros and cons, in context
Cheap, deterministic and available everywhere
O(p^2) cheap fits and one-line implementations in every statistics package. That makes it the right choice for a quick screen on a small number of features. It is no argument for trusting the result, since the lasso path costs about the same and is more stable.
Produces a short list a human can read and a reviewer can approve
In regulated or low-data settings where the deliverable is a defensible variable list, that is a real requirement. The condition is that the list is validated on data the search did not see; the list alone is a hypothesis, not a finding.
A serviceable baseline for any selection method to beat
It is the reference every sparse method is measured against. If a lasso or a gradient-boosted model cannot clearly beat stepwise on out-of-time data, the extra machinery is not paying for itself, and a team that never ran the baseline cannot know.
With a small pool and strong signal, it often finds the same subset an exhaustive search would
When p is below about 20, the signal is strong and the features are close to orthogonal, the greedy path and the global optimum usually coincide, so the pathologies below are mild. Exhaustive search is then affordable anyway, so the cheapness buys little.
Post-selection inference is invalid
The reported p-values, confidence intervals and R-squared treat the model as pre-specified, but it was chosen from 2^p candidates using the same data. Intervals have well below nominal coverage and p-values are far too small. This is fatal wherever the coefficient or its uncertainty is the deliverable; the correct repair is sample splitting or selective-inference methods, not a different print statement.
The selected set is unstable under resampling
Among correlated features the winner is close to arbitrary, so a bootstrap of the same data produces different models and different coefficients. Predictions often stay put, which hides the problem; the interpretation does not. Anyone reading the list as "the important variables" is reading noise.
Coefficients are biased away from zero and the fit is optimistic
Features are admitted because they looked strong in this sample, so their estimated effects are inflated (the winner’s curse) and the in-sample R-squared overstates future accuracy. It is the opposite bias to the lasso, which shrinks, and arguably the more dangerous one because it flatters the result.
Greedy search misses interactions between features and is path-dependent
Forward selection never adds a suppressor that only helps alongside another feature; backward elimination can find it but needs n > p; and the two end at different models on the same data. If the signal is carried by combinations of features, stepwise fails in a way no choice of criterion repairs.
Multiple testing is hidden inside the procedure
Every step compares dozens of candidates and the best is kept, which is a repeated test with no correction. With 100 pure-noise candidates the search will still return a model with several apparently significant terms. The larger the pool, the worse it gets, and the procedure does not warn you.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""Forward stepwise selection by AIC - the objective, transcribed.
Each step refits one least-squares model per remaining candidate and keeps the
feature whose addition lowers AIC the most. Features and target are assumed
centred, which is the same as including an intercept in every model: the
intercept and the noise variance add the same constant to every candidate's
AIC, so they drop out of the comparison.
"""
import math
SINGULAR_TOLERANCE = 1e-12
def solve(matrix, rhs):
"""Gaussian elimination with partial pivoting. None if singular."""
size = len(rhs)
a = [row[:] for row in matrix]
b = rhs[:]
for col in range(size):
pivot = col
for row in range(col + 1, size):
if abs(a[row][col]) > abs(a[pivot][col]):
pivot = row
if abs(a[pivot][col]) < SINGULAR_TOLERANCE:
return None
a[col], a[pivot] = a[pivot], a[col]
b[col], b[pivot] = b[pivot], b[col]
for row in range(col + 1, size):
factor = a[row][col] / a[col][col]
for inner in range(col, size):
a[row][inner] -= factor * a[col][inner]
b[row] -= factor * b[col]
solution = [0.0] * size
for row in range(size - 1, -1, -1):
total = b[row]
for col in range(row + 1, size):
total -= a[row][col] * solution[col]
solution[row] = total / a[row][row]
return solution
def aic(n, rss, k):
# AIC = n ln(RSS / n) + 2k for Gaussian errors, up to a constant.
return n * math.log(max(rss, 1e-300) / n) + 2.0 * k
def rss_of(X, y, subset):
"""Residual sum of squares of the least-squares fit on the given columns."""
n = len(y)
k = len(subset)
if k == 0:
return sum(value * value for value in y)
# Normal equations (X_S^T X_S) beta = X_S^T y, built entry by entry.
gram = [[0.0] * k for _ in range(k)]
rhs = [0.0] * k
for a in range(k):
for b in range(k):
total = 0.0
for i in range(n):
total += X[i][subset[a]] * X[i][subset[b]]
gram[a][b] = total
total = 0.0
for i in range(n):
total += X[i][subset[a]] * y[i]
rhs[a] = total
beta = solve(gram, rhs)
if beta is None:
return math.inf # collinear with the current model: never chosen
rss = 0.0
for i in range(n):
prediction = 0.0
for a in range(k):
prediction += X[i][subset[a]] * beta[a]
rss += (y[i] - prediction) ** 2
return rss
def forward_stepwise(X, y):
n = len(y)
p = len(X[0])
selected = []
best_aic = aic(n, rss_of(X, y, selected), 0)
for _ in range(p): # at most p additions
best_feature = None
best_candidate = best_aic
for j in range(p):
if j in selected:
continue
trial = selected + [j]
score = aic(n, rss_of(X, y, trial), len(trial))
if score < best_candidate:
best_candidate = score
best_feature = j
if best_feature is None: # no addition lowers AIC: stop
break
selected.append(best_feature)
best_aic = best_candidate
return selectedO(p^2 * (n*k^2 + k^3)) — one complete refit per candidate per step, with the normal equations rebuilt from the raw data every time.
How this language actually wants it written.
"""Stepwise selection by AIC or BIC - typed, validated, direction-aware."""
from dataclasses import dataclass
from typing import Literal
import numpy as np
from numpy.typing import NDArray
Matrix = NDArray[np.float64]
Vector = NDArray[np.float64]
Direction = Literal["forward", "backward", "both"]
Criterion = Literal["aic", "bic"]
MIN_RSS = float(np.finfo(np.float64).tiny)
@dataclass(frozen=True)
class Step:
"""One accepted move on the path, kept so the search can be audited."""
action: Literal["add", "drop"]
feature: int
criterion: float
@dataclass(frozen=True)
class StepwiseResult:
selected: tuple[int, ...]
criterion: float
path: tuple[Step, ...]
def _criterion_value(X: Matrix, y: Vector, subset: list[int], penalty: float) -> float:
n = y.size
if subset:
design = X[:, subset]
coefficients, *_ = np.linalg.lstsq(design, y, rcond=None)
residual = y - design @ coefficients
else:
residual = y
rss = max(float(residual @ residual), MIN_RSS)
return n * float(np.log(rss / n)) + penalty * len(subset)
def stepwise(
X: Matrix,
y: Vector,
direction: Direction = "both",
criterion: Criterion = "aic",
max_steps: int | None = None,
) -> StepwiseResult:
"""Greedy subset search. Raises ValueError on malformed input."""
if X.ndim != 2:
raise ValueError(f"X must be 2-D, got shape {X.shape}")
if X.shape[0] != y.shape[0]:
raise ValueError(f"X has {X.shape[0]} rows but y has {y.shape[0]}")
if criterion not in ("aic", "bic"):
raise ValueError(f"criterion must be 'aic' or 'bic', got {criterion!r}")
n, p = X.shape
if direction == "backward" and n <= p:
raise ValueError(f"backward elimination needs n > p, got n={n}, p={p}")
penalty = 2.0 if criterion == "aic" else float(np.log(n))
# Centring stands in for an intercept that is always in the model.
design = X - X.mean(axis=0)
target = y - y.mean()
selected: list[int] = list(range(p)) if direction == "backward" else []
current = _criterion_value(design, target, selected, penalty)
path: list[Step] = []
# Bounded loop: each accepted move strictly lowers the criterion, so the
# walk cannot cycle, but the explicit cap makes termination checkable.
for _ in range(max_steps if max_steps is not None else 2 * p):
moves: list[tuple[str, int, float]] = []
if direction in ("forward", "both"):
for feature in range(p):
if feature in selected:
continue
trial = sorted([*selected, feature])
moves.append(("add", feature, _criterion_value(design, target, trial, penalty)))
if direction in ("backward", "both"):
for feature in selected:
trial = [kept for kept in selected if kept != feature]
moves.append(("drop", feature, _criterion_value(design, target, trial, penalty)))
if not moves:
break
action, feature, score = min(moves, key=lambda move: move[2])
if score >= current:
break
selected = sorted([*selected, feature]) if action == "add" else [
kept for kept in selected if kept != feature
]
path.append(Step(action=action, feature=feature, criterion=score)) # type: ignore[arg-type]
current = score
return StepwiseResult(selected=tuple(selected), criterion=current, path=tuple(path))What changed: The hand-built normal equations and Gaussian elimination are replaced by a library least-squares solve, which also removes the silent failure on collinear candidates. The structural changes matter more than the numerical one. The search becomes direction-aware (forward, backward, or both) and is parameterized by a named criterion rather than a hard-coded 2k. Every accepted move is recorded as a typed Step, so the path is auditable rather than discarded, and the result is an immutable dataclass instead of a bare list. Input is validated before any work, including the n > p requirement that backward elimination silently breaks without.
Same O(p^2) refit count as the first stage, but each refit is a LAPACK least-squares call instead of interpreted loops. The algorithm itself has not changed.
Faster, and the justification for each change.
"""Forward selection by incremental orthogonalization - one GEMM per step."""
import numpy as np
from numpy.typing import NDArray
Matrix = NDArray[np.float64]
Vector = NDArray[np.float64]
COLLINEARITY_TOLERANCE = 1e-10
MIN_RSS = float(np.finfo(np.float64).tiny)
def forward_select(
X: Matrix,
y: Vector,
penalty: float = 2.0,
max_terms: int | None = None,
) -> list[int]:
"""Forward selection without a single refit.
Each step keeps an orthonormal basis Q of the features chosen so far.
Residualizing every candidate against Q turns "how much would RSS fall if I
added column j" into a closed form: (q_j . r)^2, where q_j is that candidate
after orthogonalization and r is the current residual. All p candidates are
scored together, so the p refits per step become two matrix products.
"""
if X.ndim != 2 or X.shape[0] != y.shape[0]:
raise ValueError(f"shape mismatch: X={X.shape}, y={y.shape}")
n, p = X.shape
limit = min(p, n - 2) if max_terms is None else min(max_terms, p, n - 2)
if limit <= 0:
return []
design = np.ascontiguousarray(X, dtype=np.float64)
design = design - design.mean(axis=0) # intercept via centring
residual = np.ascontiguousarray(y, dtype=np.float64) - y.mean()
column_energy = np.einsum("ij,ij->j", design, design)
basis = np.empty((n, limit), dtype=np.float64) # allocated once, filled per step
available = np.ones(p, dtype=bool)
selected: list[int] = []
rss = max(float(residual @ residual), MIN_RSS)
for step in range(limit):
q = basis[:, :step]
# Residualize every candidate against the current basis: two GEMMs.
projected = design - q @ (q.T @ design)
energy = np.einsum("ij,ij->j", projected, projected)
usable = available & (energy > COLLINEARITY_TOLERANCE * column_energy)
# The residual is already orthogonal to q, so projected.T @ residual
# equals design.T @ residual and the extra product is skipped.
covariance = design.T @ residual
gain = np.full(p, -np.inf)
gain[usable] = covariance[usable] ** 2 / energy[usable]
best = int(np.argmax(gain))
if not usable[best]:
break
new_rss = max(rss - gain[best], MIN_RSS)
if n * np.log(new_rss / rss) + penalty >= 0.0: # delta-AIC: no longer pays
break
direction = projected[:, best] / np.sqrt(energy[best])
basis[:, step] = direction
residual -= direction * (direction @ residual) # in place
rss = new_rss
available[best] = False
selected.append(best)
return selectedWhat changed: The change is algorithmic first and numerical second. The earlier stages refit a model for every candidate at every step; here an orthonormal basis of the chosen features is maintained, every candidate is residualized against it in one pair of matrix products, and the RSS reduction for adding column j is read off as (q_j . r)^2 / ||q_j||^2. Selecting a feature costs one Gram-Schmidt update instead of a refit, and no normal equations are ever formed, which is also the numerically safer route because the condition number is not squared. The trade is that the code now supports forward selection only: backward elimination needs the full-model factorization and a downdate, which is a different implementation.
Vectorize to NumPy/BLAS (@, np.dot, einsum)
Residualizing all p candidates against the basis is q @ (q.T @ design), two GEMMs, and the squared norms come from one einsum. The p separate refits per step collapse into a few BLAS calls.
Trade-off: Allocates an n x p temporary every step. For very wide pools it dominates memory, and it can be worse than a loop when only a small subset of candidates is still eligible.
Replace a closed-form solve with a numerically stabler factorization
The normal-equations solve squares the condition number of the design. Orthogonalizing against an explicit basis never forms X^T X, so near-collinear candidates degrade gracefully instead of corrupting the solve.
Trade-off: Classical Gram-Schmidt loses orthogonality as the basis grows with correlated columns. A stricter implementation re-orthogonalizes or uses Householder QR, at roughly twice the cost per step.
Pre-allocate output arrays and use in-place operations
The basis is allocated once at its final size and filled a column per step, and the residual is updated in place, so the loop never grows or copies the accumulated results.
Trade-off: The cap on the number of terms must be known up front, and an in-place residual update means an earlier state cannot be recovered without keeping a copy.
O(n*p*k) per step, O(n*p*k^2) in total for k selected terms, against O(p*n*k^2) per step for the refitting stages. Illustrative, not a measured benchmark.