Machine Learning · Module 15 of 26 · 9 concepts · about 17 min

Feature Selection

Deciding which columns the model actually gets. Fewer, better features beat more features far more often than people expect — and the ranking has to be computed inside the split, not before it.

At work: Keeping only the inputs that help, so models are cheaper to run, easier to explain and less likely to break.

Why fewer features can be better

What it is

Each extra column adds parameters to fit and distance to every pairwise comparison. Past some point a model's held-out error rises as features are added, even though its training error keeps falling — the added columns carry noise the model fits as if it were signal.

Why it matters

It reframes feature selection from housekeeping into modelling. Dropping columns can raise accuracy, not merely shrink the file: it lowers variance, cuts training and inference cost, and leaves a model whose behaviour someone can still explain to the person who has to act on it.

In the real world: A credit model with 20 clear features is cheaper to run, easier to explain to regulators and breaks less often than one with 2,000.

Visual example

Curse of dimensionality

Training error falls forever as columns are added; held-out error turns around once the useful columns run out.

Training errorCross-validated error
Too fewNoise columns010203040506000.10.20.30.4Features given to the model (best first)Error ratekeep 120.175

Reading the gap

12 features: train 0.090, CV 0.175 — gap 0.085

60 features: train 0.005, CV 0.310 — gap 0.305

48 noise columns bought 0.085 of training error and cost 0.135 of real error

Why this matters

The two curves diverging is what overfitting to *width* looks like. Watching only the blue line — which is what a single fit on all the data shows you — you would add columns forever.

Common misconception

Wrong idea: more features can only help; a useless column will just get a small weight.

Correct idea: a useless column gets a nonzero weight fitted to noise. Fifty of them add up to a model that has memorised the training sample's accidents.

import numpy as np
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score

# 8 informative columns, then a rising pile of pure noise.
X, y = make_classification(n_samples=1500, n_features=8, n_informative=8,
                           n_redundant=0, random_state=0)
rng = np.random.default_rng(0)
for extra in (0, 12, 52):
    Xn = np.hstack([X, rng.standard_normal((len(X), extra))])
    score = cross_val_score(RandomForestClassifier(random_state=0),
                            Xn, y, cv=5, scoring="accuracy").mean()
    print(f"{8 + extra:>3} features -> {score:.3f}")

Judging a feature set by training score, or by the score of the same split you used to choose it. Both improve monotonically as features are added, so both will always tell you to keep everything. The only honest signal is a cross-validated score on data the selection never saw.

Filter methods: correlation with the target

What it is

Score each feature against the target on its own — Pearson or Spearman correlation for a numeric target, an ANOVA F-statistic for a numeric feature against a categorical target — then keep the top k. The model is never fitted, so the whole ranking costs one pass over the columns.

Why it matters

It is the cheapest useful triage there is: on a 4,000-column table it runs in a second and tells you which 200 columns are worth giving to something slower. Everything more sophisticated in this module costs at least one model fit per candidate subset.

In the real world: Quickly ranking 500 marketing signals by how strongly each moves with sales.

Visual example

SelectKBest

A filter ranking is one number per column, computed independently — which is its speed and its blind spot.

Kept by k=2

(the top of the ranking)

sel.scores_

one score per column, in column order

f_classif scores, sorted
featureF scorep-valuerank
balance_ratio148.23e-311
tenure_months91.62e-202
region_code4.10.04318
signup_hour0.30.5841

signup_hour at p = 0.58 is indistinguishable from a random column

Why this matters

The ranking is per column in isolation, so two columns that only predict together both land near the bottom — that is exactly the failure the wrapper and multicollinearity concepts below are for.

Common misconception

Wrong idea: the top-k filter features are the best k-feature subset.

Correct idea: they are the best k features one at a time. The best subset can contain a column that scores badly alone and a lot of information jointly.

from sklearn.feature_selection import SelectKBest, f_classif, f_regression

sel = SelectKBest(score_func=f_classif, k=20)
X_small = sel.fit_transform(X_train, y_train)

kept = sel.get_support(indices=True)      # column positions retained
scores = sel.scores_                       # the F-statistic per column

# For a numeric target, swap the score function:
SelectKBest(score_func=f_regression, k=20).fit(X_train, y_train)

Reading a correlation of 0 as 'no relationship'. Pearson measures the *linear* part only: a feature that is perfectly predictive through a U-shape scores about zero and gets dropped. Filtering on correlation quietly favours the features a linear model would have liked anyway.

Mutual information and chi-square

What it is

Mutual information measures how much knowing a feature reduces uncertainty about the target, for any relationship shape, not just a monotone one. Chi-square (chi2) tests a non-negative categorical or count feature against a categorical target on a contingency table.

Why it matters

It is the filter that survives nonlinearity. A feature that interacts with the target through a curve, a threshold, or a handful of categories is invisible to correlation and obvious to mutual information, so the two filters disagree exactly where it matters.

In the real world: Finding which survey answers are linked to cancellations, even when the link isn't a straight line.

Visual example

mutual_info_regression

A feature that predicts the target through a curve: correlation 0.00, mutual information 0.91 nats.

observationsbest straight line (r = 0.00)
-3-2-1012302468feature xtarget yminimum, not noise

Two filters, same column

Pearson r(x, y) = 0.00 -> f_regression drops the column

mutual_info_regression = 0.91 nats -> the column ranks first

A depth-2 tree on x alone reaches R2 = 0.93

Why this matters

This is the single most common way a filter throws away the best feature in the table. The flat dashed line is literally what f_regression is fitting, and it explains the score of zero.

Common misconception

Wrong idea: a correlation near zero means the feature is useless.

Correct idea: it means no straight line through the cloud helps. Half the scatter slopes down and half slopes up, and the two cancel to exactly zero.

import numpy as np
from sklearn.feature_selection import mutual_info_classif, mutual_info_regression, chi2

rng = np.random.default_rng(0)
x = rng.uniform(-3, 3, 2000)
y = x ** 2 + rng.normal(0, 0.4, 2000)      # perfectly predictive, not linear

np.corrcoef(x, y)[0, 1]                     # about 0.0 — correlation sees nothing
mutual_info_regression(x.reshape(-1, 1), y, random_state=0)   # about 0.9 nats

# chi2 needs non-negative features; use it on counts or one-hot columns.
chi2(counts_matrix, y_class)

Handing chi2 standardised or otherwise negative features — it raises, and the usual fix (shifting everything to be positive) makes the statistic meaningless. chi2 is for counts and indicator columns; use f_classif or mutual_info_classif for continuous features.

Wrapper methods: recursive feature elimination

What it is

RFE fits the estimator on all features, drops the weakest by the model's own coef_ or feature_importances_, refits, and repeats down to the requested count. RFECV does the same while cross-validating at each size, so the number of features is chosen rather than guessed.

Why it matters

It scores features in the presence of the others, which is the thing a filter structurally cannot do: a column that is redundant given another gets eliminated, and a column that only pays off alongside a second column survives.

In the real world: Repeatedly dropping the weakest input until the churn model starts to get worse.

Visual example

RFECV

RFECV scores every subset size and picks the peak — not the biggest set, and usually not the smallest either.

Redundant051015200.550.60.650.70.750.80.850.9Features retainedCross-validated ROC-AUCn_features_ = 100.874

What the search cost

20 columns, step=1, 5-fold CV

20 + 19 + ... + 3 = 207 subset sizes' worth of fits x 5 folds = 1,035 fits

The same ranking by f_classif: 1 pass, 0 fits

Why this matters

The curve is nearly flat from 8 to 12 features. That plateau is the honest answer — 'about ten' — and picking 10 rather than 20 costs 0.002 AUC and halves the number of columns you have to keep collecting.

Common misconception

Wrong idea: RFECV finds the optimal feature subset.

Correct idea: it finds a good subset along one greedy path. Once a column is eliminated it is never reconsidered, so a pair that only works together can be broken apart in the first round.

from sklearn.feature_selection import RFECV
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

rfe = RFECV(
    estimator=LogisticRegression(max_iter=2000),
    step=1,                       # drop one column per round; use 0.1 for 10% per round
    cv=StratifiedKFold(5, shuffle=True, random_state=0),
    scoring="roc_auc",
    min_features_to_select=3,
)
pipe = make_pipeline(StandardScaler(), rfe).fit(X_train, y_train)
rfe.n_features_                   # the size RFECV settled on
rfe.ranking_                       # 1 = kept, 2 = dropped last, and so on

The cost. Eliminating one feature at a time from 500 columns under 5-fold CV is roughly 500 × 5 model fits — thousands of them. Filter first down to a shortlist, then wrap; and raise step to drop a fraction per round instead of a single column.

Embedded selection: L1 (Lasso)

What it is

An L1 penalty adds alpha * sum(|w|) to the loss. Unlike L2, its gradient does not vanish as a weight approaches zero, so the optimum sits *at* zero for weak features: the fit and the selection happen in the same call.

Why it matters

It is selection at the price of one model. Raising alpha walks a whole path from 'keep everything' to 'keep nothing', and the order in which coefficients hit zero is itself a ranking — with each feature judged alongside all the others, as in a wrapper, but without the thousands of refits.

In the real world: Letting the model itself zero out useless ad-channel features while it trains.

Visual example

L1 regularisation

The Lasso path: as the penalty grows, weak coefficients reach exactly zero and stay there. That is the selection.

balance_ratiotenure_monthsregion_codesignup_hour
Sparse-3-2.5-2-1.5-1-0.50-0.200.20.40.60.81log10(alpha) — penalty strengthStandardised coefficientzeroLassoCV alpha

Reading the path right to left

signup_hour: last to leave zero — hits 0 at alpha = 0.03

region_code: hits 0 at alpha = 0.03 as well

tenure_months: survives to alpha = 0.32

balance_ratio: survives longest — the strongest feature

Why this matters

The order the lines hit zero is a feature ranking that accounts for the other features, and the chosen alpha is a vertical line through it. One fitted path replaces a thousand RFE refits.

Common misconception

Wrong idea: L1 and L2 both shrink weak coefficients towards zero, so either one selects features.

Correct idea: L2 shrinks them towards zero and never reaches it — every feature stays in the model. Only L1 has a kink at zero, and that kink is why the coefficient lands exactly on it.

import numpy as np
from sklearn.linear_model import LassoCV, LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.feature_selection import SelectFromModel

# Regression: LassoCV picks alpha by cross-validation.
lasso = make_pipeline(StandardScaler(), LassoCV(cv=5, random_state=0))
lasso.fit(X_train, y_train)
coef = lasso[-1].coef_
print(f"{np.sum(coef != 0)} of {len(coef)} features kept")

# Classification: an L1-penalised logistic regression, wrapped as a selector.
sel = SelectFromModel(
    LogisticRegression(penalty="l1", solver="liblinear", C=0.1),
    threshold=1e-5,
)
sel.fit(X_train_scaled, y_train)

Not standardising first. The penalty is applied to raw coefficient magnitudes, so a feature measured in thousands gets a tiny coefficient and is zeroed for its units rather than its usefulness. Also: among a group of correlated features, Lasso keeps one essentially arbitrarily and zeroes the rest — resample the data and it may keep a different one. Use ElasticNet when you want the group kept or dropped together.

Embedded selection: tree importances

What it is

A fitted tree ensemble exposes feature_importances_ — for scikit-learn's forests, the total impurity decrease each feature achieved across every split, normalised to sum to 1. It is a byproduct of training, not a separate computation.

Why it matters

It is free and it captures nonlinearity and interactions, which no correlation filter does. For a first look at a wide table, fitting one forest and reading its importances is usually the highest information per minute available.

In the real world: Using a forest to shortlist the 30 inputs worth keeping from hundreds of customer attributes.

Visual example

feature_importances_

The same four columns, ranked two ways: impurity importance on the training fit, and permutation importance on held-out data.

forest.feature_importances_

impurity decrease on the training fit

permutation_importance(...)

score drop when the column is shuffled, on the test set

forest importances vs permutation importance
featuredistinct valuesimpuritypermutation
customer_id149820.310.002
balance_ratio12040.280.141
tenure_months960.190.088
is_autopay20.040.061

customer_id ranks first by impurity and dead last by permutation

Why the ID wins on impurity

14,982 distinct values -> a split point between almost any two rows

Every such split lowers training impurity, so importance accumulates

Shuffle the column on held-out data: ROC-AUC drops 0.002. It never generalised.

Why this matters

A high-cardinality identifier at the top of an importance list is the classic tell. Reading both columns side by side is how you catch it before it becomes a deployed model keyed on a row number.

Common misconception

Wrong idea: feature importance tells you which features matter for the outcome.

Correct idea: it tells you which columns this fitted model leaned on. Two perfectly correlated features split the credit and both look half as important as either really is.

import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance

forest = RandomForestClassifier(n_estimators=400, random_state=0).fit(X_train, y_train)
order = np.argsort(forest.feature_importances_)[::-1]
for i in order[:5]:
    print(f"{feature_names[i]:<20} {forest.feature_importances_[i]:.3f}")

# Measured on held-out data instead of on impurity, so it cannot be gamed by cardinality.
perm = permutation_importance(forest, X_test, y_test, n_repeats=20, random_state=0)
perm.importances_mean

Impurity importance is biased towards high-cardinality and continuous features: a random ID column with thousands of distinct values gets many chances to split and can outrank a genuinely useful binary flag. Prefer permutation_importance on held-out data, and remember that importance means 'this model used it', not 'this feature causes the outcome'.

Multicollinearity and VIF

What it is

Multicollinearity is one feature being predictable from the others. The variance inflation factor quantifies it per column: regress feature j on all the remaining features, take its R², and compute VIF_j = 1 / (1 - R²_j). Above roughly 5–10 the coefficient on that column is barely identified.

Why it matters

It explains the most confusing outcome in feature selection: two features that clearly matter together, each individually reported as unimportant, with coefficients that flip sign when the data is resampled. The pair carries the signal; neither column carries it alone.

In the real world: Income, salary and monthly earnings all say the same thing; keeping one makes the credit model clearer.

Visual example

Variance inflation factor

Two near-duplicate columns: VIF 25 each, coefficients that cancel, and a pair that predicts well regardless.

1 / (1 - 0.96)

the VIF for spend_total

collinear features, fitted with OLS
featureR2 vs othersVIFcoef +/- se
spend_total0.9625+4.10 +/- 3.80
spend_avg0.9625-3.35 +/- 3.72
tenure_months0.111.1+0.88 +/- 0.09
is_autopay0.041+0.41 +/- 0.07

Each coefficient's standard error is 5x the coefficient itself

The arithmetic

VIF = 1 / (1 - R2) = 1 / (1 - 0.96) = 25

se is inflated by sqrt(25) = 5x relative to an uncorrelated column

+4.10 and -3.35 nearly cancel: the sum, +0.75, is what is actually identified

Drop either one and the survivor's coefficient becomes +0.74 +/- 0.08

Why this matters

A per-feature selector sees two columns whose coefficients are statistically indistinguishable from zero and drops both — discarding the signal that only the pair expresses. VIF is what makes that visible before the ranking does the damage.

Common misconception

Wrong idea: if neither of two features is individually significant, neither matters.

Correct idea: they may be interchangeable. Each is only unidentified *given the other*; their sum is pinned down tightly, and dropping one identifies the other immediately.

import numpy as np
from statsmodels.stats.outliers_influence import variance_inflation_factor

vif = [variance_inflation_factor(X_scaled, j) for j in range(X_scaled.shape[1])]
for name, v in sorted(zip(feature_names, vif), key=lambda t: -t[1])[:5]:
    print(f"{name:<20} VIF {v:6.1f}")

# The usual remedy: drop one of each near-duplicate pair, or combine them.
corr = np.corrcoef(X_scaled, rowvar=False)
dupes = np.argwhere(np.triu(np.abs(corr), k=1) > 0.95)

Dropping a column purely because its VIF is high. Collinearity hurts *coefficient interpretation*, not predictive accuracy — a random forest or a ridge regression is largely untroubled by it. Fix it when you need to read the coefficients, and leave it alone when you only need predictions.

Selection leakage: ranking before the split

What it is

Computing any feature statistic — a correlation, a mutual information score, an importance — on the full table and then splitting means the ranking was derived partly from the rows you are about to evaluate on. The selection step is a fitted transformer, and it must be fitted inside the training fold.

Why it matters

It is the most reliable way to manufacture a score you can never reproduce. With enough candidate columns, selecting on all the data produces cross-validated accuracy far above chance on a target that is pure noise — the selector found the columns that happened to match the validation rows.

In the real world: Picking features using all the data makes a fraud model look better in testing than it will be live.

Visual example

Leakage

The same noise data, the same k=20 filter, the same folds — only the order of split and select differs.

5,000 random columns, a random binary target
procedurereported CV accuracyhonest test accuracy
select on all rows, then CV0.780.51
select inside each fold (Pipeline)0.50.5

SelectKBest(...).fit(X, y)

outside the split: 0.78 on data with no signal at all

Pipeline([...]).fit(X_tr, y_tr)

refitted per fold: 0.50, the truth

0.28 of accuracy conjured out of noise

Where the 0.78 comes from

5,000 noise columns, 200 rows: some columns match y by chance

The filter finds exactly those 20, using all 200 rows

Every fold's validation rows helped choose the columns, so the match persists in validation

On genuinely new rows the match evaporates: 0.51

Why this matters

There is no signal in this dataset whatsoever, and the wrong procedure reports 0.78. Any selection score you cannot trace to a per-fold refit should be assumed to contain some of this.

Common misconception

Wrong idea: feature selection is unsupervised bookkeeping, so it is safe to do once up front.

Correct idea: selection that uses y is supervised fitting. It belongs inside the pipeline, next to the model, and gets refitted on every training fold.

import numpy as np
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import Pipeline

rng = np.random.default_rng(0)
X = rng.standard_normal((200, 5000))        # pure noise
y = rng.integers(0, 2, 200)                 # unrelated to X

# WRONG: the filter sees every row, including each fold's validation rows.
sel = SelectKBest(f_classif, k=20).fit(X, y)
print(cross_val_score(LogisticRegression(), sel.transform(X), y, cv=5).mean())

# RIGHT: the filter is refitted inside every fold.
pipe = Pipeline([("sel", SelectKBest(f_classif, k=20)), ("clf", LogisticRegression())])
print(cross_val_score(pipe, X, y, cv=5).mean())    # about 0.5, as it must be

Believing a Pipeline is only about tidiness. It is the mechanism that makes selection refit per fold: anything you call .fit on outside the pipeline has already seen everything you pass it. The same applies to imputation, scaling and target encoding — but selection is where the inflation is largest, because the number of columns being ranked is large.

A workflow that is worth following

What it is

Start from the columns a domain expert would defend, drop what is structurally unusable (identifiers, anything unavailable at prediction time, near-constant columns), filter the remaining width cheaply, then wrap or regularise on the shortlist — with every data-driven step inside a pipeline.

Why it matters

Searching subsets blindly is both intractable and statistically dangerous: 30 columns have over a billion subsets, and with enough tries the best-scoring subset is the luckiest one rather than the best one. Structure the search so data is used to prune a defensible set, not to discover one from nothing.

In the real world: A repeatable routine that trims a feature list without fooling yourself, used before every model refresh.

from sklearn.ensemble import GradientBoostingClassifier
from sklearn.feature_selection import SelectKBest, SelectFromModel, VarianceThreshold, f_classif
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

pipe = Pipeline([
    ("drop_constant", VarianceThreshold(threshold=0.0)),   # structurally useless
    ("scale", StandardScaler()),
    ("filter", SelectKBest(f_classif, k=200)),             # cheap width reduction
    ("embed", SelectFromModel(                              # judged jointly
        GradientBoostingClassifier(random_state=0), threshold="median")),
    ("model", GradientBoostingClassifier(random_state=0)),
])
# Every selector above is refitted inside each fold of this search.

Treating a feature set as final. The ranking depends on the sample: refit the same selector on a bootstrap resample and the tail of the list reshuffles. If a decision rests on 'these are the important features', check stability across resamples before saying it out loud.