Notes / Generalization & Model Fitting
Feature Selection: Removing Low-Variance / Uncorrelated Features
Table of Contents
Filtering out uninformative features controls model complexity → helps fight overfitting (see [[regularization]] “Other Approaches to Overfitting”).
1. Low-Variance Features
A feature that barely varies carries almost no information. Compute variance per column (axis 0) and keep those above a threshold.
variances = X.var(axis=0) # shape (n_features,)mask = variances > threshold # boolean maskX_filtered = X[:, mask] # keep passing columnsCritical caveat — variance is scale-dependent. A feature in millimeters has 10⁶× the variance of the same feature in kilometers. Standardize first (or use a relative threshold), otherwise the threshold is meaningless across features with different units.
sklearn:
from sklearn.feature_selection import VarianceThresholdselector = VarianceThreshold(threshold=0.01)X_filtered = selector.fit_transform(X)Caveat: low variance ≠ uninformative
Variance filtering is unsupervised — it never looks at the target y. It removes features
that don’t vary, not features that don’t predict. Those are different questions.
- Low variance can still be highly predictive. A binary feature that’s 1 for only 2% of samples has tiny variance but may perfectly identify a rare class → variance filtering would wrongly discard it.
- High variance can be pure noise. A maximally-spread feature with zero predictive value survives the filter.
Mental model:
- Variance filter → “does this feature carry any signal at all?” (unsupervised, cheap, crude)
- Correlation / MI / model-based → “does this feature help predict the target?” (supervised — what you actually care about)
Use variance thresholding as a cheap first-pass cleanup to drop obviously-dead features (constants, near-constants), not as your primary feature selector. For selecting predictive features, use a supervised method (below).
The only universally safe threshold: exactly-zero variance
Dropping features with zero variance (constants) is the one variance-based filter with no risk of discarding signal:
- A constant feature carries zero predictive information by definition.
- It can break models — e.g. a constant column makes
XᵀXsingular, so the OLS normal equations(XᵀX)⁻¹blow up (see [[regression-metrics]]).
The moment you raise the threshold above zero (“drop low variance”), you’re making a judgment call — a low-but-nonzero-variance feature might be a rare-but-predictive signal. Validate against the target / via cross-validation before dropping those.
mask = X.var(axis=0) > 0 # always safe: drop only exact constantsmask = X.var(axis=0) > 0.01 # judgment call: might discard useful rare signalsFloat subtlety: a truly constant column may compute to a tiny nonzero variance (e.g. 1e-17)
due to rounding — use a small tolerance instead of literal == 0:
mask = X.var(axis=0) > 1e-10 # treat near-zero as constantRule of thumb: zero (~zero) variance → always drop, no judgment needed. Low-but-nonzero variance → judgment call, validate against the target first.
2. Features Uncorrelated with the Target
Needs the target y. Compute each feature’s correlation with y, drop the weak ones.
corrs = np.array([np.corrcoef(X[:, j], y)[0, 1] for j in range(X.shape[1])])mask = np.abs(corrs) > threshold # abs — strong negative correlation counts tooX_filtered = X[:, mask]Two gotchas:
- Use
np.abs()— correlation of −0.9 is highly informative, not weak. np.corrcoefmeasures linear correlation only. A feature with a strong nonlinear relationship toy(e.g. quadratic) can have near-zero correlation yet be very useful. For nonlinear dependence use mutual information (sklearn.feature_selection.mutual_info_regression).
sklearn:
from sklearn.feature_selection import SelectKBest, f_regressionselector = SelectKBest(f_regression, k=10) # top-k by correlation-based F-scoreX_filtered = selector.fit_transform(X, y)Important Caveat: Univariate Filtering Misses Interactions
Filtering features one at a time by correlation with the target is a univariate method — it ignores feature interactions:
- A feature useless alone can be valuable combined with another.
- Two features individually correlated with
ymay be redundant with each other.
For feature-feature redundancy, inspect the correlation matrix and drop one of each highly-correlated pair:
corr_matrix = np.corrcoef(X, rowvar=False) # rowvar=False → columns are variablesFor interaction-aware selection, prefer model-based methods (e.g. L1/Lasso coefficients, tree feature importances, or recursive feature elimination) over univariate filters.
Interpreting Variance Values (np.var)
Variance = average squared deviation from the mean — how spread out the values are.
| Value | Meaning |
|---|---|
| 0 | all values identical — constant feature, zero information, safe to drop |
| small | values cluster tightly around the mean |
| large | values widely spread |
Trap 1 — units are squared. A feature in meters has variance in meters², which is hard
to reason about. Standard deviation (np.std = √variance) is more interpretable — it’s back
in the original units (“typical deviation from the mean”).
np.var(x) # meters² — hard to interpretnp.std(x) # meters — same units as the dataTrap 2 — raw variance is NOT comparable across features. It depends on scale and units:
# height in meters: var ≈ 0.01 (tiny)# income in dollars: var ≈ 25_000_000 (huge)Income only looks “more variable” because dollars are a larger-magnitude unit — not because it’s genuinely more spread relative to its own scale. This is exactly why variance-threshold selection requires standardizing first.
Scale-free alternative — coefficient of variation (std relative to mean, dimensionless):
cv = np.std(x) / np.mean(x) # only meaningful for positive-mean datanumpy gotcha — population vs sample:
np.var(x) # 1/N — population (default)np.var(x, ddof=1) # 1/(N-1) — sample, unbiased (Bessel's correction)Mirror of the PyTorch quirk (torch defaults to unbiased; numpy defaults to population) — see [[numpy-basics]].
Related: [[regularization]], [[regression-metrics]], [[numpy-basics]]
