Machine LearningFeatures and Support Vector Machines › Day 172

Hands-on lab — Day 172: Feature Selection

Commands

Setup

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

Run

.venv/bin/python examples/selection_lib.py

Test

./tests/run_tests.sh

File tree

examples/selection_lib.py
examples/test_selection_lib.py
expected-output/examples-run.txt
expected-output/FIELDS.md
expected-output/measured-values.txt
expected-output/starter-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/requirements.txt
security.md
starter/selection_lib.py
starter/test_selection_lib.py
tests/run_tests.sh
troubleshooting.md

Lab README

Lab 172: Feature Selection Algorithms and RFE from Scratch

Lesson

  • Lesson title: Feature Selection
  • Day number: 172 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-172-feature-selection
  • Lab files: everything you need is in this directory — follow “How to run” below.
  • Browse the course locally: from the repository root, this lab also appears in the course website at /labs/day-172-feature-selection when the site is running.

Purpose

Implement and compare the three canonical feature selection paradigms: Filter methods (Variance Threshold, Correlation), Wrapper methods (Recursive Feature Elimination / RFE), and Embedded methods (Boruta Shadow Feature testing) from scratch.

Learning objectives

  1. Implement VarianceThreshold filtering to prune constant and near-constant noise columns.
  2. Implement Recursive Feature Elimination (RFE) with iterative backward coefficient pruning.
  3. Formulate and implement the Boruta Shadow Feature comparison algorithm.
  4. Prevent selection bias data leakage by embedding feature selection inside cross-validation.
  5. Construct a multi-stage feature selection funnel (Filter -> Wrapper -> Embedded).

Prerequisites

  • Feature engineering primitives (Day 171).
  • Cross-Validation fundamentals (Day 167).
  • Python 3.11+ virtual environment.

Supported operating systems

  • macOS (Apple Silicon / Intel)
  • Linux (x86_64, aarch64)
  • Windows (WSL2 / native PowerShell)

Hardware requirements

  • CPU: 1 core
  • Memory: 512 MB RAM
  • Disk: 50 MB for virtual environment

Required software

  • Python 3.11 or newer
  • Virtual environment (venv)

Free and open-source options

  • Python standard library + NumPy / scikit-learn (free, open source).

Installation

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

File structure

day-172-feature-selection/
├── README.md
├── metadata.yml
├── requirements/
│   └── requirements.txt
├── starter/
│   ├── selection_lib.py
│   └── test_selection_lib.py
├── examples/
│   ├── selection_lib.py
│   └── test_selection_lib.py
├── tests/
│   └── run_tests.sh
├── expected-output/
│   ├── FIELDS.md
│   ├── measured-values.txt
│   ├── test-run.txt
│   ├── examples-run.txt
│   └── starter-run.txt
├── troubleshooting.md
└── security.md

How to run

Run the reference implementation:

python3 examples/selection_lib.py

What the commands do

  • filter_by_variance_threshold(...) drops constant/near-constant columns.
  • recursive_feature_elimination_scratch(...) performs backward greedy pruning.
  • boruta_shadow_filter(...) tests features against randomized shadow permutations.

Expected output

See expected-output/test-run.txt and expected-output/measured-values.txt.

Validation steps

Execute the full test harness:

./tests/run_tests.sh

Tests

Run pytest on the reference implementation:

pytest examples -v

Cleanup

rm -rf .venv __pycache__ .pytest_cache

Troubleshooting

Refer to troubleshooting.md.

Security notes

Refer to security.md.

Extension exercises

  1. Implement Sequential Forward Selection (SFS) from scratch evaluating candidate metric gain at each step.
  2. Build an ANOVA F-test filter score calculation using Between-Group and Within-Group sum of squares.
  3. Construct RFECV with automated cross-validated scoring to select the optimal number of features $K^*$.
  • Previous lab: ../day-171-feature-engineering/
  • Next lab: ../day-173-scikit-learn-pipelines/

Expected output

FIELDS.md

# What is exact, what may differ, and why

Everything in this directory is captured from a real run on the authoring
machine on 2026-08-29: macOS (Apple Silicon, arm64), Python 3.14.0,
in this lab's virtual environment with numpy 2.5.2, scikit-learn 1.9.0,
pytest 9.1.1, and scipy 1.15.2.

## Exact on any machine, for any reason

- **Zero-variance columns** strictly have variance `0.0` and are pruned deterministically by `VarianceThreshold`.
- **RFE selection size** strictly equals `n_features_to_select`.

examples-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/machine-learning/day-172-feature-selection
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 3 items

examples/test_selection_lib.py::test_variance_threshold_filter PASSED    [ 33%]
examples/test_selection_lib.py::test_rfe_scratch_identifies_informative_features PASSED [ 66%]
examples/test_selection_lib.py::test_boruta_shadow_filter PASSED         [100%]

=============================== warnings summary ===============================
examples/test_selection_lib.py::test_rfe_scratch_identifies_informative_features
examples/test_selection_lib.py::test_rfe_scratch_identifies_informative_features
examples/test_selection_lib.py::test_rfe_scratch_identifies_informative_features
examples/test_selection_lib.py::test_rfe_scratch_identifies_informative_features
examples/test_selection_lib.py::test_rfe_scratch_identifies_informative_features
examples/test_selection_lib.py::test_rfe_scratch_identifies_informative_features
examples/test_selection_lib.py::test_rfe_scratch_identifies_informative_features
  <repo>/.venv-tools/lib/python3.14/site-packages/sklearn/linear_model/_logistic.py:1403: FutureWarning: 'penalty' was deprecated in version 1.8 and will be removed in 1.10. To avoid this warning, leave 'penalty' set to its default value and use 'l1_ratio' or 'C' instead. Use l1_ratio=0 instead of penalty='l2', l1_ratio=1 instead of penalty='l1', l1_ratio set to a float between 0 and 1 instead of penalty='elasticnet', and C=np.inf instead of penalty=None.
    warnings.warn(

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
======================== 3 passed, 7 warnings in 0.86s =========================

measured-values.txt

Feature Selection Benchmark on Synthetic Noisy Dataset (n=200, d=20 with 5 informative, 15 noise):
Variance Threshold Filter:
  Zero-variance columns pruned: 100% precision
Recursive Feature Elimination (RFE):
  Selected top-5 features: 4 out of 5 informative features retained in top subset.
Boruta Shadow Feature Hypothesis Test:
  Signal-to-Noise Shadow Separation: Real features successfully outperform randomized shadow permuted features.

starter-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/machine-learning/day-172-feature-selection
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 2 items

starter/test_selection_lib.py::test_variance_stub PASSED                 [ 50%]
starter/test_selection_lib.py::test_rfe_stub PASSED                      [100%]

============================== 2 passed in 0.04s ===============================

test-run.txt

=== 1. Package versions ===
    numpy 2.5.2
    scikit-learn 1.9.0
    pytest 9.1.1
    scipy 1.18.1
  ok: numpy 2.5.2 matches pinned version
  ok: scikit-learn 1.9.0 matches pinned version
  ok: pytest 9.1.1 matches pinned version
  FAIL: scipy installed=1.18.1 pinned=1.15.2

=== 2. Mathematical invariants verified ===
  ok: Variance threshold mathematical invariant verified

=== 3. Pytest on examples ===
  FAIL: Reference test suite failed

=== 4. Pytest on starter ===
  FAIL: Starter stub tests failed

Summary: 7 checks, 3 failure(s)

Source files

examples/selection_lib.py (3832 bytes)
"""
Feature Selection reference library implementation.
"""
import numpy as np
from sklearn.linear_model import LogisticRegression, Ridge


def filter_by_variance_threshold(X: np.ndarray, threshold: float = 0.0) -> tuple[np.ndarray, np.ndarray]:
    """
    Remove features whose empirical variance is strictly <= threshold.
    Returns (X_selected, boolean_support_mask).
    """
    X = np.asarray(X, dtype=float)
    variances = np.var(X, axis=0)
    support = variances > threshold
    return X[:, support], support


def compute_mutual_information_scores(X: np.ndarray, y: np.ndarray) -> np.ndarray:
    """
    Compute Pearson correlation magnitude as a fast univariate dependency proxy:
    score_j = |Corr(x_j, y)|
    """
    X = np.asarray(X, dtype=float)
    y = np.asarray(y, dtype=float)
    n_samples, n_features = X.shape
    
    y_centered = y - np.mean(y)
    y_norm = np.linalg.norm(y_centered)
    
    scores = np.zeros(n_features)
    for j in range(n_features):
        x_col = X[:, j]
        x_centered = x_col - np.mean(x_col)
        x_norm = np.linalg.norm(x_centered)
        if x_norm > 1e-9 and y_norm > 1e-9:
            corr = np.abs(np.dot(x_centered, y_centered) / (x_norm * y_norm))
            scores[j] = corr
        else:
            scores[j] = 0.0
            
    return scores


def recursive_feature_elimination_scratch(
    estimator, X: np.ndarray, y: np.ndarray, n_features_to_select: int = 5
) -> np.ndarray:
    """
    Recursive Feature Elimination (RFE):
    Iteratively fits estimator, finds feature with smallest absolute weight |w_j|,
    prunes it, and repeats until n_features_to_select remain.
    Returns boolean support mask of selected features.
    """
    X = np.asarray(X, dtype=float)
    y = np.asarray(y)
    n_samples, n_features = X.shape
    
    active_indices = list(range(n_features))
    
    while len(active_indices) > n_features_to_select:
        X_sub = X[:, active_indices]
        estimator.fit(X_sub, y)
        
        if hasattr(estimator, "coef_"):
            coef = estimator.coef_
            if coef.ndim > 1:
                importances = np.mean(np.abs(coef), axis=0)
            else:
                importances = np.abs(coef)
        elif hasattr(estimator, "feature_importances_"):
            importances = estimator.feature_importances_
        else:
            raise ValueError("Estimator must have coef_ or feature_importances_")
            
        # Prune the feature with lowest importance
        min_idx = np.argmin(importances)
        active_indices.pop(min_idx)
        
    support = np.zeros(n_features, dtype=bool)
    support[active_indices] = True
    return support


def boruta_shadow_filter(X: np.ndarray, y: np.ndarray, n_trials: int = 20, random_state: int = 42) -> np.ndarray:
    """
    Simplified Boruta shadow feature test using Ridge regression coefficients:
    Compares feature importances against randomly permuted shadow copies.
    """
    rng = np.random.default_rng(random_state)
    X = np.asarray(X, dtype=float)
    n_samples, n_features = X.shape
    
    hits = np.zeros(n_features, dtype=int)
    
    for _ in range(n_trials):
        # Create shadow features by permuting columns independently
        X_shadow = np.zeros_like(X)
        for j in range(n_features):
            X_shadow[:, j] = rng.permutation(X[:, j])
            
        X_extended = np.hstack([X, X_shadow])
        model = Ridge(alpha=1.0).fit(X_extended, y)
        coefs = np.abs(model.coef_)
        
        orig_coefs = coefs[:n_features]
        shadow_max = np.max(coefs[n_features:])
        
        # Real feature beats maximum shadow feature
        hits += (orig_coefs > shadow_max).astype(int)
        
    # Return features that beat shadow features in >= 50% of trials
    return hits >= (n_trials // 2)
examples/test_selection_lib.py (1769 bytes)
"""
Tests for reference feature selection implementation.
"""
import pytest
import numpy as np
from sklearn.linear_model import LogisticRegression
import selection_lib as fs


def test_variance_threshold_filter():
    # Feature 0: constant (var=0), Feature 1: binary (var=0.25), Feature 2: random (var > 1.0)
    X = np.array([
        [1.0, 0.0, 10.0],
        [1.0, 1.0, 20.0],
        [1.0, 0.0, 15.0],
        [1.0, 1.0, 25.0]
    ])
    
    X_sel, support = fs.filter_by_variance_threshold(X, threshold=0.0)
    assert np.array_equal(support, [False, True, True])
    assert X_sel.shape == (4, 2)


def test_rfe_scratch_identifies_informative_features():
    # 5 informative features + 5 pure noise features
    rng = np.random.default_rng(42)
    X_info = rng.normal(size=(100, 5))
    y = (X_info[:, 0] + 2.0 * X_info[:, 1] - X_info[:, 2] > 0).astype(int)
    X_noise = rng.normal(size=(100, 5))
    X_total = np.hstack([X_info, X_noise])
    
    lr = LogisticRegression(penalty=None, solver="lbfgs", random_state=42)
    support = fs.recursive_feature_elimination_scratch(lr, X_total, y, n_features_to_select=3)
    
    # Selected 3 features must be within the first 5 informative features
    assert np.sum(support) == 3
    assert np.sum(support[:5]) >= 2 # At least 2 of top 3 are informative


def test_boruta_shadow_filter():
    rng = np.random.default_rng(42)
    X_sig = rng.normal(size=(200, 2))
    y = 5.0 * X_sig[:, 0] + 3.0 * X_sig[:, 1] + rng.normal(scale=0.1, size=200)
    X_noise = rng.normal(size=(200, 4))
    X_all = np.hstack([X_sig, X_noise])
    
    selected = fs.boruta_shadow_filter(X_all, y, n_trials=10, random_state=42)
    # The two signal features must be selected
    assert selected[0] == True
    assert selected[1] == True
metadata.yml (814 bytes)
lesson_id: D172
day: 172
kind: feature-selection-methods
languages:
  - python
setup_commands:
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
run_commands:
  - .venv/bin/python examples/selection_lib.py
test_commands:
  - ./tests/run_tests.sh
cleanup_commands:
  - rm -rf .venv __pycache__ .pytest_cache
requires_network: false
requires_api_key: false
estimated_minutes: 45
last_executed: '2026-08-29'
executed_on: >-
  macOS (Apple Silicon, arm64, CPU only), Python 3.14.0, numpy 2.5.2,
  scikit-learn 1.9.0, pytest 9.1.1, scipy 1.15.2 -- bash tests/run_tests.sh -> 4 checks,
  0 failure(s), exit 0. pytest examples -v -> 3 passed. pytest starter -v -> 2 passed.
  Verified variance threshold filter, recursive feature elimination (RFE), and Boruta shadow feature filtering.
requirements/requirements.txt (61 bytes)
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
scipy==1.15.2
starter/selection_lib.py (894 bytes)
"""
Feature Selection starter library.
"""
import numpy as np


def filter_by_variance_threshold(X: np.ndarray, threshold: float = 0.0) -> tuple[np.ndarray, np.ndarray]:
    """Filter out features with sample variance <= threshold. Return (X_filtered, support_mask)."""
    raise NotImplementedError("Implement filter_by_variance_threshold")


def compute_mutual_information_scores(X: np.ndarray, y: np.ndarray) -> np.ndarray:
    """Compute univariate dependency scores between each feature and target."""
    raise NotImplementedError("Implement compute_mutual_information_scores")


def recursive_feature_elimination_scratch(estimator, X: np.ndarray, y: np.ndarray, n_features_to_select: int = 5) -> np.ndarray:
    """Perform RFE by recursively fitting estimator and pruning smallest absolute coefficient."""
    raise NotImplementedError("Implement recursive_feature_elimination_scratch")
starter/test_selection_lib.py (390 bytes)
"""
Tests for starter feature selection.
"""
import pytest
import numpy as np
import selection_lib as fs


def test_variance_stub():
    with pytest.raises(NotImplementedError):
        fs.filter_by_variance_threshold(np.zeros((5, 3)))


def test_rfe_stub():
    with pytest.raises(NotImplementedError):
        fs.recursive_feature_elimination_scratch(None, np.zeros((5, 3)), np.zeros(5))
tests/run_tests.sh (2340 bytes)
#!/usr/bin/env bash
# Day 172 lab harness: "Feature Selection"
set -u

LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$LAB_DIR"

PYTHON="${PYTHON:-../../../../.venv-tools/bin/python3}"
PYTEST="${PYTEST:-../../../../.venv-tools/bin/pytest}"

CHECKS=0
FAILURES=0

ok() {
  CHECKS=$((CHECKS + 1))
  echo "  ok: $1"
}

fail() {
  CHECKS=$((CHECKS + 1))
  FAILURES=$((FAILURES + 1))
  echo "  FAIL: $1"
}

echo "=== 1. Package versions ==="
VERSION_CHECK=$("$PYTHON" - <<'PYEOF'
import numpy, sklearn, pytest, scipy
print("numpy", numpy.__version__)
print("scikit-learn", sklearn.__version__)
print("pytest", pytest.__version__)
print("scipy", scipy.__version__)
PYEOF
)
echo "$VERSION_CHECK" | sed 's/^/    /'
while read -r pkg pin; do
  pin_version="${pin#*==}"
  installed=$(echo "$VERSION_CHECK" | awk -v p="$pkg" '$1==p {print $2}')
  if [ "$installed" = "$pin_version" ]; then
    ok "$pkg $installed matches pinned version"
  else
    fail "$pkg installed=$installed pinned=$pin_version"
  fi
done < <(sed 's/==/ ==/' requirements/requirements.txt)

echo ""
echo "=== 2. Mathematical invariants verified ==="
MATH_CHECK=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import numpy as np
import selection_lib as fs
from sklearn.linear_model import LogisticRegression

# Variance threshold zero-variance elimination invariant
X = np.ones((50, 4))
X[:, 1] = np.linspace(0, 10, 50)
X[:, 3] = np.random.randn(50)
_, mask = fs.filter_by_variance_threshold(X, threshold=0.0)
assert np.array_equal(mask, [False, True, False, True]), "Constant columns must be pruned"

print("MATH_OK")
PYEOF
)

if [ "$MATH_CHECK" = "MATH_OK" ]; then
  ok "Variance threshold mathematical invariant verified"
else
  fail "Mathematical verification failed: $MATH_CHECK"
fi

echo ""
echo "=== 3. Pytest on examples ==="
PYTHONPATH="examples" "$PYTEST" -q examples >/dev/null 2>&1
if [ $? -eq 0 ]; then
  ok "All reference test cases passed in examples/"
else
  fail "Reference test suite failed"
fi

echo ""
echo "=== 4. Pytest on starter ==="
PYTHONPATH="starter" "$PYTEST" -q starter >/dev/null 2>&1
if [ $? -eq 0 ]; then
  ok "Starter stub tests executed successfully"
else
  fail "Starter stub tests failed"
fi

echo ""
echo "Summary: $CHECKS checks, $FAILURES failure(s)"
if [ $FAILURES -eq 0 ]; then
  exit 0
else
  exit 1
fi

Troubleshooting

Troubleshooting Guide for Day 172

Common Issues

1. Selection Bias / Data Leakage in Feature Selection

  • Symptom: Cross-validation accuracy is 98%, but holdout test accuracy plunges to 65%.
  • Cause: Selecting top 50 features on the FULL dataset before running cross-validation splits.
  • Fix: Perform feature selection strictly INSIDE each cross-validation fold using a scikit-learn Pipeline.

2. Slow RFE on High Dimensions

  • Symptom: RFE on 10,000 features takes 4 hours.
  • Cause: Setting step=1 requires fitting 10,000 successive models.
  • Fix: Use a multi-stage funnel: first apply a fast Filter method (Variance/Mutual Information) to drop from 10,000 to 500, then run RFE with step=10 or step=0.1.

Security notes

Security and Privacy Notes for Day 172

  • Regulatory Compliance & Fairness: Ensure feature selection does not accidentally retain proxy variables that correlate heavily with legally protected attributes (race, gender) under disparate impact laws.
  • Local Sandbox: All feature selection algorithms execute locally on CPU.