Table of Contents
- The ML Project Life Cycle
- 1. EDA
- 2. Data Cleaning
- 3. Data Imputation
- 4. Feature Engineering & Encoding
- 5. Feature Scaling
- 6. Train-Test Split & Cross-Validation
- 7. Hyperparameter Tuning
- 8. Evaluation Metrics — Regression
- 9. Evaluation Metrics — Classification
- 10. scikit-learn Models
- 11. scikit-learn Pipeline API
- 12. Inference & Deployment Checklist
- Appendix — Legacy Notes
This file is a comprehensive reference for the IITM BSCS2008 Machine Learning Practice course, covering the full end-to-end project lifecycle in scikit-learn. Use it as your single-stop interview guide.
The ML Project Life Cycle
Every ML project follows a repeatable sequence of steps. Think of it as a pipeline where a mistake early on propagates and amplifies at every downstream stage.
Raw Data
↓
EDA (understand the data)
↓
Train–Validation–Test Split
↓
Data Cleaning (remove noise and inconsistencies)
↓
Data Imputation (handle missing values)
↓
Feature Engineering & Encoding
↓
Feature Scaling / Normalization
↓
Model Selection & Training
↓
Hyperparameter Tuning (with Cross-Validation)
↓
Evaluation on held-out Test Set
↓
Inference / Deployment
1. Exploratory Data Analysis (EDA)
Intuition: Before writing a single line of model code, look at your data. EDA helps you understand distributions, spot outliers, find relationships between features, and form hypotheses about which models might work.
Key EDA Tasks
| Task | What to do | Python / pandas call |
|---|---|---|
| Shape & types | Rows, columns, dtypes | df.shape, df.dtypes, df.info() |
| Summary stats | Mean, std, min, max, quartiles | df.describe() |
| Missing values | Count & % per column | df.isnull().sum() |
| Unique values | Cardinality of categoricals | df['col'].nunique(), df['col'].value_counts() |
| Distribution | Histograms, box plots | df['col'].hist(), df.boxplot() |
| Correlation | Pearson / Spearman heatmap | df.corr(), sns.heatmap(df.corr()) |
| Pairplots | Feature-to-feature scatter | sns.pairplot(df) |
| Class balance | For classification targets | df['target'].value_counts(normalize=True) |
What to look for
- Skewness — highly skewed features may need log-transform before training.
- Outliers — IQR fences: values below or above are suspected outliers.
- Multicollinearity — features that are highly correlated add redundant information; detect using a correlation matrix or VIF (see appendix).
- Target leakage — a feature that encodes the answer (e.g.,
loan_approvedpredictingdefault) — drop it.
2. Data Cleaning
Intuition: Garbage in → garbage out. No model — however sophisticated — can compensate for data that is wrong, inconsistent, or corrupted. Data cleaning is the process of detecting and correcting (or removing) those problems before they reach the model.
Think of it in layers:
1. Structural problems → wrong shape, wrong types, wrong schema
2. Duplicate records → exact or near-duplicate rows
3. Inconsistent values → same thing expressed differently
4. Invalid / impossible values → ages of 999, negative prices
5. Outliers → statistically extreme values
6. Target leakage → features that "cheat" by encoding the answer
2.1 Understanding Your Data Types First
Before cleaning anything, you must know what type each column should be. df.info() shows current dtypes; mismatches are the first red flag.
df.info()
# Shows: column name | non-null count | dtype
df.dtypes
# Quick dtype-per-column viewCommon dtype mismatches and their causes:
| Column should be | But dtype shows | Likely cause |
|---|---|---|
int / float | object | Mixed numeric + string values ("N/A", "-", "") |
datetime | object | Dates stored as strings |
category | object | Low-cardinality strings not cast yet |
bool | int64 | Binary flags stored as 0/1 |
2.2 Fixing Data Types
Numeric Coercion
# errors='coerce' silently converts unparseable values to NaN
# errors='raise' throws an exception (good for catching hidden issues)
# errors='ignore' leaves unparseable values unchanged
df['price'] = pd.to_numeric(df['price'], errors='coerce')
df['age'] = pd.to_numeric(df['age'], errors='coerce')After coercion, always check how many NaN values appeared — a sudden spike means the column had many masked non-numeric values:
df['price'].isnull().sum()Datetime Parsing
df['order_date'] = pd.to_datetime(df['order_date'], format='%Y-%m-%d', errors='coerce')
# Once parsed, you can extract components:
df['order_year'] = df['order_date'].dt.year
df['order_month'] = df['order_date'].dt.month
df['order_dayofweek'] = df['order_date'].dt.dayofweek # 0=Monday, 6=Sunday
df['is_weekend'] = df['order_date'].dt.dayofweek >= 5Casting to Efficient Types
# Save memory on low-cardinality string columns
df['city'] = df['city'].astype('category')
# Boolean flags
df['is_active'] = df['is_active'].astype(bool)2.3 Handling Duplicate Records
Why duplicates exist: ETL pipeline failures, form re-submissions, data merges without deduplication, web scraping.
Why they matter: Exact duplicates artificially inflate model confidence in certain patterns. Near-duplicates (same person, slightly different spelling) introduce contradictory labels.
Exact Duplicates
print(df.duplicated().sum()) # how many fully duplicate rows?
print(df.duplicated(keep='first').sum()) # same thing, keep=first/last/False
# View the actual duplicate rows
df[df.duplicated(keep=False)]
# Drop, keeping the first occurrence
df.drop_duplicates(keep='first', inplace=True)Subset-Based Deduplication
Sometimes only a key (e.g., user_id + transaction_date) must be unique, not the full row:
df.drop_duplicates(subset=['user_id', 'transaction_date'], keep='last', inplace=True)Near-Duplicates (Fuzzy Matching)
When the same entity appears with minor typos (e.g., "John Smith" vs "Jon Smith"), exact matching fails. Use the fuzzywuzzy / thefuzz library:
from thefuzz import fuzz, process
# Ratio of similarity between two strings (0–100)
fuzz.ratio("John Smith", "Jon Smith") # → 91
fuzz.token_sort_ratio("Smith John", "John Smith") # → 100 (order-independent)
# Find best match for a string from a list of candidates
process.extractOne("Jon Smith", ["John Smith", "Jane Doe", "Jack Jones"])
# → ('John Smith', 91)NOTE
Near-duplicate resolution is expensive at scale. In practice, block records by a shared key (e.g., same ZIP code, same first 3 letters of name) before fuzzy-matching within each block.
2.4 Inconsistent Categories
This is one of the most common and sneakiest problems — the same real-world entity is represented in multiple ways.
String Normalisation
# Strip whitespace and convert to lowercase
df['gender'] = df['gender'].str.strip().str.lower()
# 'Male', 'male ', ' MALE ' → 'male'
# Remove special characters
df['city'] = df['city'].str.replace(r'[^a-zA-Z\s]', '', regex=True)
# Standardise known aliases using a mapping dict
gender_map = {'m': 'male', 'f': 'female', 'man': 'male', 'woman': 'female', '1': 'male', '0': 'female'}
df['gender'] = df['gender'].map(gender_map).fillna(df['gender'])Checking Remaining Unique Values
Always verify after normalisation — surprises lurk:
df['gender'].value_counts()
# male 8200
# female 6100
# other 150
# prefer not to say 45
# n/a 12 ← still some junkReplacing Placeholder “Missing” Strings
Data engineers often use placeholder strings instead of actual NaN:
# Common placeholders to watch out for:
placeholders = ['n/a', 'na', 'none', 'null', 'missing', '-', '--', '?', 'unknown', '']
df.replace(placeholders, np.nan, inplace=True)
# Or during read:
df = pd.read_csv('data.csv', na_values=placeholders)2.5 Impossible / Invalid Values
These are values that are technically parseable (no dtype error) but semantically wrong.
Examples:
age = -5orage = 999price = -100rating = 7on a 1–5 scaleend_date < start_datepercentage = 150
Domain-Clipping (Hard Bounds)
# Clip to valid physiological range: age between 0 and 120
df['age'] = df['age'].clip(lower=0, upper=120)
# Set impossible values to NaN for later imputation
df.loc[df['age'] < 0, 'age'] = np.nan
df.loc[df['age'] > 120, 'age'] = np.nan
df.loc[df['price'] < 0, 'price'] = np.nanCross-Column Validation
# Flag rows where end_date is before start_date
invalid_dates = df['end_date'] < df['start_date']
print(f"Invalid date ranges: {invalid_dates.sum()}")
# Fix: set end_date to NaN if it precedes start_date
df.loc[invalid_dates, 'end_date'] = np.nan2.6 Outlier Detection & Treatment
An outlier is a data point that deviates markedly from the other observations. But crucially: outlier ≠ error. Whether to treat it depends on the reason for it.
| Situation | Decision |
|---|---|
| Data entry error (age = 999) | Remove or set to NaN |
| Instrument error (sensor spike) | Remove or smooth |
| Real but rare event (billionaire’s income) | Keep, but may need robust scaling |
| Ambiguous | Flag it, investigate, document |
Method 1 — IQR Fence (Tukey’s Method)
The most widely used rule-of-thumb. Does not assume normality.
Values outside these fences are flagged as suspected outliers. Using 3.0 instead of 1.5 gives “extreme” outliers.
Q1 = df['salary'].quantile(0.25)
Q3 = df['salary'].quantile(0.75)
IQR = Q3 - Q1
lower_fence = Q1 - 1.5 * IQR
upper_fence = Q3 + 1.5 * IQR
outliers = df[(df['salary'] < lower_fence) | (df['salary'] > upper_fence)]
print(f"Number of outliers: {len(outliers)}")
# Option A: Remove
df_clean = df[(df['salary'] >= lower_fence) & (df['salary'] <= upper_fence)]
# Option B: Cap (Winsorize) — clip at the fences rather than removing
df['salary'] = df['salary'].clip(lower=lower_fence, upper=upper_fence)Method 2 — Z-Score Method
Assumes the data is approximately normally distributed. A point with is typically flagged.
from scipy import stats
z_scores = np.abs(stats.zscore(df['salary'].dropna()))
outlier_mask = z_scores > 3
df_clean = df[~outlier_mask]When to prefer IQR vs. Z-score:
| Method | Best when | Weakness |
|---|---|---|
| IQR | Non-normal / skewed data | Less sensitive to mild outliers in normal data |
| Z-score | Data is roughly normal | Breaks badly on heavily skewed distributions (a single extreme outlier inflates , masking others — the masking effect) |
Method 3 — Modified Z-Score (Robust Alternative)
Uses the Median Absolute Deviation (MAD) instead of mean/std, making it robust to the masking effect.
Flag if .
median = df['salary'].median()
mad = np.median(np.abs(df['salary'] - median))
modified_z = 0.6745 * (df['salary'] - median) / mad
outliers = df[np.abs(modified_z) > 3.5]Method 4 — Isolation Forest (ML-based, multivariate)
Detects outliers in the joint distribution of multiple features simultaneously. A random forest that isolates points — anomalous points are isolated with fewer splits because they lie far from the bulk.
from sklearn.ensemble import IsolationForest
iso = IsolationForest(n_estimators=100, contamination=0.05, random_state=42)
# contamination: the expected fraction of outliers (a hyperparameter you set)
iso.fit(X_train[numeric_cols])
preds = iso.predict(X_train[numeric_cols])
# +1 = inlier, -1 = outlier
inlier_mask = preds == 1
X_clean = X_train[inlier_mask]Key hyperparameter: contamination — set this based on domain knowledge or grid-search on a validation set. Higher contamination → more points flagged as outliers.
Outlier Treatment Strategies
| Strategy | How | When to use |
|---|---|---|
| Remove | Drop the row | Clear data entry error; small number of outliers |
| Cap / Winsorize | Set value = fence value | Real but extreme; don’t want to lose the row |
| Log Transform | Right-skewed data (income, counts) — compresses the tail | |
| Sqrt / Box-Cox | Power transforms | More general normalisation of skewed features |
| Separate model | Flag as binary feature is_outlier | Outlier behaviour is genuinely different |
| Keep | Do nothing | Model is robust (tree-based); outlier is real |
import numpy as np
# Log transform (add 1 to handle zeros)
df['log_income'] = np.log1p(df['income'])
# Box-Cox (requires all values > 0)
from scipy.stats import boxcox
df['bc_income'], lambda_val = boxcox(df['income'] + 1)
# Yeo-Johnson (handles zeros and negatives)
from sklearn.preprocessing import PowerTransformer
pt = PowerTransformer(method='yeo-johnson')
df[['income_transformed']] = pt.fit_transform(df[['income']])2.7 String & Text Cleaning
Relevant for any free-text or messy string columns (product names, addresses, comments).
# Convert to lowercase
df['product_name'] = df['product_name'].str.lower()
# Remove leading/trailing whitespace
df['product_name'] = df['product_name'].str.strip()
# Remove extra internal spaces
df['product_name'] = df['product_name'].str.replace(r'\s+', ' ', regex=True)
# Remove punctuation / special characters
df['address'] = df['address'].str.replace(r'[^\w\s]', '', regex=True)
# Extract patterns with regex (e.g., extract ZIP code from address)
df['zip'] = df['address'].str.extract(r'(\d{6})')
# Pad strings to uniform length (e.g., account codes)
df['account_code'] = df['account_code'].str.zfill(8) # left-pad with zeros to length 82.8 Detecting & Preventing Target Leakage
Target leakage is when a feature in your training set contains information about the target that would not be available at prediction time. It is one of the most dangerous and hard-to-spot bugs in ML.
Example: You are predicting whether a loan will default. One of the features is days_overdue_at_last_check. But this is only known after the loan is active — at prediction time (when the loan is being considered) this value doesn’t exist yet. The model will train with nearly perfect accuracy on this feature and completely fail in production.
Two types of leakage:
| Type | Description | Example |
|---|---|---|
| Train-test contamination | Test data information bleeds into training (e.g., fitting scaler on full data) | Scaling before splitting |
| Feature leakage | Feature encodes the label directly or was computed using the label | days_overdue, fraud_score_from_label_tool |
How to detect feature leakage
- Suspiciously high validation accuracy on a feature you didn’t expect — investigate it.
- Check if the feature was derived after the target event occurred.
- Check feature importances in a trained model — if one feature dominates all others, inspect it.
- Compute time-aware features for time-series data (always use the timestamp of the prediction moment, not hindsight data).
# Quick check: correlation between each feature and target
leakage_suspects = df.corr()['target'].sort_values(ascending=False)
print(leakage_suspects.head(10))
# Correlations > 0.9 with target from a single feature are suspicious2.9 End-to-End Data Cleaning Checklist
Before passing your data to any imputer or model, run through this:
# 1. Shape and types
print(df.shape)
print(df.dtypes)
df.info()
# 2. Missing values
print(df.isnull().sum())
print((df.isnull().mean() * 100).round(2)) # percentage missing per column
# 3. Duplicate rows
print(df.duplicated().sum())
df.drop_duplicates(inplace=True)
# 4. Fix dtypes
df['price'] = pd.to_numeric(df['price'], errors='coerce')
df['date'] = pd.to_datetime(df['date'], errors='coerce')
df['rating'] = df['rating'].astype('category')
# 5. Standardise strings
for col in categorical_cols:
df[col] = df[col].str.strip().str.lower()
# 6. Replace placeholder missing-value strings
df.replace(['n/a', 'na', 'none', 'null', '-', '?', ''], np.nan, inplace=True)
# 7. Clip impossible values (domain knowledge)
df['age'] = df['age'].clip(0, 120)
df['price'] = df['price'].clip(0, None) # None means no upper bound
# 8. Detect & treat outliers (choose method based on distribution)
for col in numeric_cols:
Q1, Q3 = df[col].quantile([0.25, 0.75])
IQR = Q3 - Q1
df[col] = df[col].clip(Q1 - 1.5*IQR, Q3 + 1.5*IQR)
# 9. Verify: how much data is left?
print(f"Rows after cleaning: {len(df)}")
print(f"Remaining missing values: {df.isnull().sum().sum()}")IMPORTANT
Data cleaning should happen before the train-test split only for decisions that are truly global (e.g., dropping columns with > 90% missing). Column-level statistics (mean, Q1/Q3 fences for capping, StandardScaler mean/std) must always be computed on the training set only and then applied to the test set. Computing them on the full dataset is a subtle form of data leakage.
3. Data Imputation
Intuition: After cleaning, your data will almost always have missing values. Missing values can’t be ignored — most scikit-learn models throw an error on NaN inputs. Imputation is the process of filling in those gaps with plausible values. Choosing the wrong strategy introduces systematic bias into your model; choosing the right one recovers information that would otherwise be lost.
3.1 Diagnosing Missingness — The Three Types
Before picking an imputation strategy, you must understand why values are missing. This is the most important and most overlooked step.
| Mechanism | Definition | Example | Safe to impute? |
|---|---|---|---|
| MCAR — Missing Completely At Random | The probability of a value being missing is independent of all other variables | A sensor randomly fails regardless of the reading | ✅ Yes — any strategy works |
| MAR — Missing At Random | The probability of missing depends on other observed variables, not the missing variable itself | Older patients less likely to complete an online health survey (age is observed) | ✅ Yes — use the related observed variables |
| MNAR — Missing Not At Random | The probability of missing depends on the missing value itself | High-income individuals leave income blank; depressed patients stop reporting symptoms | ⚠️ Careful — naive imputation biases results |
Diagnosing in Practice
You cannot definitively test for MCAR vs. MAR vs. MNAR from the data alone — it requires domain knowledge. But you can look for signals:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
# 1. Overall missingness summary
missing = df.isnull().sum()
missing_pct = (df.isnull().mean() * 100).round(2)
print(pd.DataFrame({'count': missing, 'pct': missing_pct}).sort_values('pct', ascending=False))
# 2. Missingness heatmap — reveals patterns (e.g., same rows missing across multiple cols)
sns.heatmap(df.isnull(), cbar=False, yticklabels=False, cmap='viridis')
plt.title("Missingness Map")
# 3. Is missingness in column A correlated with values in column B?
# Create a binary "is_missing" indicator, then correlate with other features
df['income_missing'] = df['income'].isnull().astype(int)
print(df.corr()['income_missing'].sort_values(ascending=False))
# High correlation with 'age' → MAR (missingness depends on age, an observed variable)
# 4. Little's MCAR test (statistical test, requires the 'pyampute' or 'missingno' library)
import missingno as msno
msno.matrix(df) # visualise patterns
msno.heatmap(df) # correlations between missing columns
msno.dendrogram(df) # cluster columns by missing patternsDecision rule of thumb:
- No pattern found in missingness → assume MCAR → simple imputation is fine.
- Pattern found (missingness correlates with observed variable) → MAR → use model-based or multivariate imputation.
- Domain knowledge says the value itself drives missingness → MNAR → add a
_missingindicator flag, consider imputing with a sentinel value, and document the assumption.
3.2 Adding Missingness Indicator Flags
For MNAR (and sometimes MAR), the fact that something is missing carries information. Before imputing, create a binary flag column that the model can learn from.
from sklearn.impute import MissingIndicator
# Standalone use
indicator = MissingIndicator(features='missing-only') # only creates columns for cols that have NaN
missing_flags = indicator.fit_transform(X_train)
# Result: bool matrix with one column per feature that has at least one NaN
# Combined with an imputer in a FeatureUnion pipeline
from sklearn.pipeline import FeatureUnion, Pipeline
from sklearn.impute import SimpleImputer
# Manual version:
df['income_was_missing'] = df['income'].isnull().astype(int)
df['income'] = df['income'].fillna(df['income'].median())
# Now the model sees both the imputed value AND the flagTIP
Indicator flags are almost always worth adding for columns with high missingness rates (> 5–10%). The cost is one extra column; the benefit is that the model can learn “missing income → different behaviour” instead of being fooled by an imputed value.
3.3 Strategy 1 — Dropping Missing Data
Sometimes the best strategy is not to impute at all.
Drop Columns
Drop a column entirely if it has too many missing values to be useful:
# Rule of thumb: drop columns with > 50-70% missing (domain-dependent)
threshold = 0.5
cols_to_drop = df.columns[df.isnull().mean() > threshold]
df.drop(columns=cols_to_drop, inplace=True)Drop Rows
Drop rows where the target variable is missing (you cannot impute a label):
df.dropna(subset=['target'], inplace=True)Drop rows if only a tiny fraction are missing and the dataset is large:
# Only drop rows if < 1% of data is affected
if df.isnull().any(axis=1).mean() < 0.01:
df.dropna(inplace=True)WARNING
Dropping rows on non-MCAR data introduces selection bias. If the rows being dropped are systematically different from the rest (e.g., the sickest patients who drop out of a study), the trained model will be biased toward the “easier” cases.
3.4 Strategy 2 — Simple Statistical Imputation (SimpleImputer)
The most common baseline — fast, simple, and interpretable.
from sklearn.impute import SimpleImputer
# --- Numeric columns ---
mean_imputer = SimpleImputer(strategy='mean')
median_imputer = SimpleImputer(strategy='median')
# --- Categorical columns ---
mode_imputer = SimpleImputer(strategy='most_frequent')
# --- Custom constant ---
const_imputer = SimpleImputer(strategy='constant', fill_value=0)
# For categoricals: fill_value='Unknown'When to use which strategy
| Strategy | Use when | Risk |
|---|---|---|
mean | Feature is normally distributed, no strong outliers | Sensitive to outliers; distorts distribution |
median | Feature is skewed or has outliers | Slightly less statistically efficient than mean on normal data |
most_frequent | Categorical feature | Can overrepresent the dominant class |
constant | Domain default makes sense (e.g., 0 for “no transactions”) | May introduce a meaningless cluster at 0 |
Effect on distribution
Univariate imputation shrinks variance — every imputed value is the same (mean/median), so the tails of the distribution disappear. This is acceptable for models that don’t care about the exact marginal distribution (trees, SVM), but it subtly biases any variance-based statistic.
import matplotlib.pyplot as plt
original = df['income'].dropna()
imputed = df['income'].fillna(df['income'].median())
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
original.hist(ax=axes[0], bins=30, title='Original (with NaN excluded)')
imputed.hist(ax=axes[1], bins=30, title='After Median Imputation')
plt.tight_layout()3.5 Strategy 3 — KNN Imputation (KNNImputer)
Intuition: Instead of filling a missing value with a global statistic (mean of everyone), use the mean of the most similar rows. Similar rows are found by computing Euclidean distance across all observed features.
from sklearn.impute import KNNImputer
knn_imp = KNNImputer(
n_neighbors=5, # k — tune via cross-validation
weights='uniform', # 'uniform': equal weight; 'distance': closer = more weight
metric='nan_euclidean' # handles NaN in distance calculation
)
X_train_imputed = knn_imp.fit_transform(X_train)
X_test_imputed = knn_imp.transform(X_test) # uses training neighbours onlyChoosing n_neighbors
- Small
k(e.g., 3) → more local, can overfit to noise. - Large
k(e.g., 20) → smoother, approaches univariate mean imputation. - Tune with
GridSearchCVinside a pipeline.
When to use KNN Imputation
✅ Dataset has meaningful local structure (similar rows should have similar values).
✅ Multiple columns have related missing patterns (e.g., height and weight both missing for the same person).
✅ Dataset is moderate-sized (< 50k rows; beyond that, it becomes slow).
❌ Very large datasets — distance computation is prohibitive.
❌ High-dimensional data — Euclidean distance becomes uninformative in high dimensions (curse of dimensionality).
IMPORTANT
KNN Imputation is sensitive to feature scale. You must scale the features before fitting the
KNNImputer, otherwise a feature with a large numeric range will dominate the distance computation. In a scikit-learnPipeline, placeStandardScalerbeforeKNNImputer.
3.6 Strategy 4 — Iterative / Model-Based Imputation (IterativeImputer)
Intuition: This is the most statistically principled approach. Think of it as “impute by predicting.” For each feature with missing values, treat it as the dependent variable and all other features as predictors, then fit a regression model to estimate the missing values. Repeat this for every column with missingness, cycling through them multiple times until convergence.
This is related to the MICE (Multiple Imputation by Chained Equations) algorithm widely used in statistics.
from sklearn.experimental import enable_iterative_imputer # must come first
from sklearn.impute import IterativeImputer
from sklearn.linear_model import BayesianRidge # default estimator
iter_imp = IterativeImputer(
estimator=BayesianRidge(), # the model used to predict each column
max_iter=10, # number of full cycles through all columns
tol=1e-3, # stop early if imputed values converge
initial_strategy='mean', # strategy for the first round (cold start)
imputation_order='ascending',# order to process columns: 'ascending' (fewest missing first)
random_state=42
)
X_train_imputed = iter_imp.fit_transform(X_train)
X_test_imputed = iter_imp.transform(X_test)How one iteration works
Round 1:
Feature A (has NaN):
Temporarily fill with initial_strategy (mean).
Feature B (has NaN):
Use [A_imputed, C, D, ...] to predict B → fill B's NaN.
Feature C (has NaN):
Use [A_imputed, B_imputed, D, ...] to predict C → fill C's NaN.
Round 2 (all columns now have initial estimates):
Feature A:
Use [B_imputed, C_imputed, D, ...] to re-predict A → update A's NaN.
Feature B:
Re-predict using the updated values of all other columns.
... continue until max_iter or convergence
Swapping the internal estimator
BayesianRidge (default) works well for continuous features. You can swap it for anything:
from sklearn.ensemble import RandomForestRegressor, ExtraTreesRegressor
from sklearn.linear_model import Ridge
# More powerful — can capture non-linear relationships
iter_imp_rf = IterativeImputer(
estimator=ExtraTreesRegressor(n_estimators=10, random_state=42),
max_iter=10,
random_state=42
)NOTE
Using a tree-based estimator inside
IterativeImputeris significantly more accurate on non-linear data but also much slower. For most interview/project scenarios,BayesianRidgeis the right default to mention.
When to use Iterative Imputation
✅ Multiple features are missing and they are correlated with each other.
✅ You have time and a moderate-sized dataset (< 100k rows).
✅ Statistical validity matters (e.g., research, healthcare).
❌ Large datasets or real-time pipelines — too slow.
❌ MNAR data — even this won’t fix a fundamentally biased missing mechanism.
3.7 Strategy 5 — Target Encoding-Based Imputation (Categorical)
For categorical columns, the most_frequent strategy is a blunt instrument. A better approach for MAR data is to impute based on the conditional distribution of the category given observed variables.
Practical approach: Use a simple model to predict the missing category from other features:
from sklearn.ensemble import RandomForestClassifier
# Separate rows into those with and without missing 'city'
known = df[df['city'].notna()]
unknown = df[df['city'].isna()]
features = ['age', 'income', 'region'] # features correlated with 'city'
clf = RandomForestClassifier(n_estimators=50, random_state=42)
clf.fit(known[features], known['city'])
df.loc[df['city'].isna(), 'city'] = clf.predict(unknown[features])3.8 Strategy 6 — Using Sentinel / Placeholder Values
For MNAR situations, instead of imputing a plausible value (which would be misleading), sometimes you explicitly encode “missing” as its own category — telling the model that missingness itself is a meaningful signal.
# Numeric: fill with an impossible sentinel value and add indicator flag
df['income_was_missing'] = df['income'].isnull().astype(int)
df['income'] = df['income'].fillna(-999) # model sees -999 as a signal
# Categorical: fill with a literal 'Unknown' category
df['occupation'] = df['occupation'].fillna('Unknown')Tree-based models handle this extremely well — they will learn a specific branch for the sentinel value. Linear models will struggle (they’ll interpret -999 as a “very negative income”), which is why the _was_missing flag is critical alongside it.
3.9 Comparing Strategies — Head-to-Head
| Strategy | Computation | Handles Multivariate Patterns | Handles Categoricals | Best for |
|---|---|---|---|---|
| Drop | ✅ Fastest | ❌ | ❌ | MCAR, < 1% missing |
SimpleImputer (mean/median) | ✅ Fast | ❌ | Partial | MCAR, baseline |
SimpleImputer (most_frequent) | ✅ Fast | ❌ | ✅ | Categorical, MCAR |
KNNImputer | ⚠️ Moderate | ✅ Partial | ❌ (numeric only) | MAR, moderate-size datasets |
IterativeImputer | ❌ Slow | ✅ Full | ❌ (numeric only) | MAR, high-quality imputation |
| Model-based (categorical) | ⚠️ Moderate | ✅ | ✅ | MAR, high-cardinality categories |
| Sentinel + indicator flag | ✅ Fast | ❌ | ✅ | MNAR |
3.10 The Golden Rule — Fit on Train, Transform on Test
This is the most critical rule in all of imputation, and a very common interview question.
Wrong ❌ — data leakage:
imp = SimpleImputer(strategy='mean')
X_imputed = imp.fit_transform(X) # fits on full dataset including test!
X_train, X_test = train_test_split(X_imputed)Correct ✅ — no leakage:
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
imp = SimpleImputer(strategy='mean')
X_train_imputed = imp.fit_transform(X_train) # learn the mean from training data only
X_test_imputed = imp.transform(X_test) # apply training mean to test dataBest practice — use a Pipeline to make this automatic:
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
pipe = Pipeline([
('imputer', SimpleImputer(strategy='median')),
('scaler', StandardScaler()),
('model', LogisticRegression())
])
pipe.fit(X_train, y_train) # imputer fit only on X_train internally
pipe.score(X_test, y_test) # imputer only transforms X_testThe pipeline guarantees the correct fit/transform split automatically, even inside cross_val_score and GridSearchCV.
3.11 Checking Imputation Quality
After imputing, always sanity-check:
# 1. No NaN values remain
assert X_train_imputed.isnull().sum().sum() == 0, "Still have NaN values!"
# 2. Distribution hasn't changed dramatically
print("Before imputation:", df['income'].describe())
print("After imputation: ", pd.Series(X_train_imputed[:, income_col_idx]).describe())
# 3. If you have a baseline model, compare cross-val scores
# across imputation strategies to pick the best one
from sklearn.model_selection import cross_val_score
strategies = ['mean', 'median', 'most_frequent']
for strat in strategies:
pipe = Pipeline([
('imp', SimpleImputer(strategy=strat)),
('clf', LogisticRegression(max_iter=1000))
])
scores = cross_val_score(pipe, X_train, y_train, cv=5, scoring='f1_weighted')
print(f"{strat:15s} → mean F1: {scores.mean():.4f} ± {scores.std():.4f}")4. Feature Engineering & Encoding
Intuition: A model can only learn from the features you give it. Raw features are rarely in the best shape — they may be on wrong scales, encoded as text the model can’t read, hiding useful patterns inside them, or simply redundant. Feature engineering is the craft of transforming raw data into a representation that makes the model’s job easier.
Two distinct activities live under this umbrella:
- Feature Engineering — creating or transforming numeric features (new signals from existing ones).
- Encoding — converting categorical / text features into numbers the model can process.
4.1 Numerical Feature Transformations
Motivation: Why Transform Numeric Features?
Many models assume (or perform better when) features are roughly normally distributed and on a similar scale. Real-world data rarely cooperates — income, counts, prices, and durations are almost always right-skewed. Transformations fix this.
Log Transform
The most common transformation for right-skewed, strictly positive features.
Adding 1 handles zeros (since is undefined). The result compresses the long right tail and makes the distribution more symmetric.
import numpy as np
import pandas as pd
df['log_income'] = np.log1p(df['income']) # log(1 + x)
df['log_pageviews'] = np.log1p(df['pageviews'])When to use: Revenue, prices, word counts, population, anything with a heavy right tail.
When NOT to use: Negative values, zero-inflated distributions with a structural mass at zero.
Square Root Transform
Milder compression than log — useful for count data (Poisson-distributed):
df['sqrt_complaints'] = np.sqrt(df['complaints'])Box-Cox Transform
Finds the optimal power that best normalises the data:
Requirement: All values must be strictly positive ().
from scipy.stats import boxcox
df['bc_income'], fitted_lambda = boxcox(df['income'] + 1)
# fitted_lambda tells you what power was used
print(f"Optimal lambda: {fitted_lambda:.3f}")
# λ ≈ 0 → log transform
# λ ≈ 0.5 → square root
# λ ≈ 1 → no transform (already normal)
# λ ≈ -1 → reciprocalYeo-Johnson Transform
Like Box-Cox, but works on zero and negative values too. Usually the better default:
from sklearn.preprocessing import PowerTransformer
pt = PowerTransformer(method='yeo-johnson', standardize=True)
# standardize=True also applies z-score normalization after the power transform
X_transformed = pt.fit_transform(X_train[numeric_cols])
# pt.lambdas_ gives the fitted lambda per columnChoosing a Transform
| Feature characteristic | Recommended transform |
|---|---|
| Right-skewed, all positive, mild skew | sqrt |
| Right-skewed, all positive, heavy skew | log1p |
| Strongly skewed, needs optimal fit | Box-Cox (positive only) or Yeo-Johnson |
| Has negatives or zeros | Yeo-Johnson |
| Already roughly normal | None needed |
4.2 Binning (Discretisation)
Intuition: Sometimes a numeric feature is better modelled as a category. For example, age might have non-linear effects where the important boundary is “child vs. adult” rather than every individual year. Binning converts continuous values into discrete buckets.
from sklearn.preprocessing import KBinsDiscretizer
kbd = KBinsDiscretizer(
n_bins=5,
encode='ordinal', # 'ordinal': integer labels 0,1,2,3,4
# 'onehot': one-hot sparse matrix
# 'onehot-dense': one-hot dense matrix
strategy='quantile' # 'uniform': equal-width bins
# 'quantile': equal-frequency bins (same number of samples per bin)
# 'kmeans': bin boundaries found by k-means clustering
)
df[['age_binned']] = kbd.fit_transform(df[['age']])Uniform vs. Quantile vs. KMeans Binning
| Strategy | How bins are determined | Best when |
|---|---|---|
uniform | Equal-width: | Feature is uniformly distributed |
quantile | Equal-frequency: each bin has the same number of samples | Feature is skewed (avoids empty bins) |
kmeans | KMeans clustering on the 1D feature | There are natural clusters in the feature |
Manual / Custom Binning with pandas
# Custom age groups
bins = [0, 12, 17, 64, 120]
labels = ['child', 'teen', 'adult', 'senior']
df['age_group'] = pd.cut(df['age'], bins=bins, labels=labels, right=True)
# Quantile-based binning (quartiles)
df['income_quartile'] = pd.qcut(df['income'], q=4, labels=['Q1','Q2','Q3','Q4'])NOTE
After binning, you typically apply one-hot encoding to the result (since the bin labels are not inherently ordered, unless you chose
strategy='ordinal'intentionally). The exception is when the bins have a natural order (e.g., child < teen < adult < senior) and you’re using a tree-based model that can handle ordinal integers directly.
4.3 Polynomial & Interaction Features
Intuition: Linear models assume the relationship between a feature and the target is a straight line. But what if the true relationship curves? We can capture this by adding , , or by adding interaction terms like that capture joint effects of two features.
from sklearn.preprocessing import PolynomialFeatures
poly = PolynomialFeatures(
degree=2, # maximum degree of features to generate
include_bias=False, # whether to include the constant '1' column
interaction_only=False # if True, only cross-terms (no x^2, x^3, ...)
)
X_poly = poly.fit_transform(X_train[['age', 'income']])
print(poly.get_feature_names_out(['age', 'income']))
# → ['age', 'income', 'age^2', 'age income', 'income^2']Interaction-only terms
poly_interact = PolynomialFeatures(degree=2, interaction_only=True, include_bias=False)
X_interact = poly_interact.fit_transform(X_train[['age', 'income', 'experience']])
# → ['age', 'income', 'experience', 'age income', 'age experience', 'income experience']WARNING
Polynomial features expand the feature space combinatorially. With features and degree , the number of output features is . With
degree=3on 10 features, you get 286 features. Always combine with regularisation (Ridge/Lasso) or feature selection to prevent overfitting and to keep the model tractable.
4.4 Date & Time Feature Extraction
Datetime columns are opaque to most models — you must extract numeric signals from them.
df['order_date'] = pd.to_datetime(df['order_date'])
# Calendar components
df['year'] = df['order_date'].dt.year
df['month'] = df['order_date'].dt.month # 1–12
df['day'] = df['order_date'].dt.day # 1–31
df['dayofweek'] = df['order_date'].dt.dayofweek # 0=Mon, 6=Sun
df['quarter'] = df['order_date'].dt.quarter # 1–4
df['weekofyear'] = df['order_date'].dt.isocalendar().week.astype(int)
# Derived binary flags
df['is_weekend'] = df['order_date'].dt.dayofweek >= 5
df['is_month_end'] = df['order_date'].dt.is_month_end
df['is_month_start'] = df['order_date'].dt.is_month_start
# Time deltas (age of something in days)
df['account_age_days'] = (pd.Timestamp.now() - df['account_created']).dt.days
df['days_since_last_login'] = (pd.Timestamp.now() - df['last_login']).dt.daysCyclical Encoding for Periodic Features
Month (1–12), day-of-week (0–6), and hour (0–23) are cyclic — December is adjacent to January, not far from it. Treating them as plain integers breaks this continuity. Use sine-cosine encoding instead:
import numpy as np
# Month (1–12)
df['month_sin'] = np.sin(2 * np.pi * df['month'] / 12)
df['month_cos'] = np.cos(2 * np.pi * df['month'] / 12)
# Day of week (0–6)
df['dow_sin'] = np.sin(2 * np.pi * df['dayofweek'] / 7)
df['dow_cos'] = np.cos(2 * np.pi * df['dayofweek'] / 7)
# Hour of day (0–23)
df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24)
df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24)Now month=12 and month=1 are close in the sine-cosine space, which is the correct representation.
4.5 Encoding Categorical Variables
This is the core of the “Encoding” half. A categorical variable has a finite set of discrete values (e.g., city, colour, education_level). Models need numbers — the method you choose to convert categories to numbers has a major impact on model performance.
The Key Question: Is there an order?
Is there a natural, meaningful order between the categories?
├── YES → Ordinal Encoding
└── NO → Do you have many unique values?
├── Few (< ~15 unique) → One-Hot Encoding
└── Many (high-cardinality) → Target / Hashing / Binary Encoding
4.5.1 One-Hot Encoding (OHE)
Creates one binary column per category. A 1 in a column means “this row belongs to this category.”
from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(
drop='first', # drop one column per feature to avoid the dummy variable trap
sparse_output=False, # return a dense numpy array (easier to work with)
handle_unknown='ignore' # unseen categories at inference → all zeros (no error)
)
X_encoded = ohe.fit_transform(X_train[['city', 'colour']])
print(ohe.get_feature_names_out())
# → ['city_Delhi', 'city_Mumbai', 'colour_Blue', 'colour_Red']
# Note: 'city_Pune' and 'colour_Green' are dropped (drop='first')The Dummy Variable Trap
If you one-hot encode a feature with categories into columns without dropping one, the columns are perfectly linearly dependent (they always sum to 1). This causes multicollinearity and makes the matrix singular (non-invertible) for linear models.
Fix: Always use drop='first' (or drop='if_binary'). Tree-based models don’t suffer from this, but it’s still good practice.
When NOT to use OHE
- High-cardinality features (ZIP code with 10,000+ unique values → 10,000 extra columns). This causes:
- The curse of dimensionality
- Very sparse, mostly-zero feature matrix
- Huge memory usage
- Poor generalisation
4.5.2 Ordinal Encoding
For features with a meaningful natural order (Low < Medium < High; No < Yes; Terrible < Poor < OK < Good < Excellent).
from sklearn.preprocessing import OrdinalEncoder
oe = OrdinalEncoder(
categories=[['Low', 'Medium', 'High']], # explicit order
handle_unknown='use_encoded_value', # unseen categories get -1
unknown_value=-1
)
df[['edu_encoded']] = oe.fit_transform(df[['education_level']])
# Low → 0, Medium → 1, High → 2IMPORTANT
If you use OrdinalEncoder on a feature without a real order (e.g.,
city), linear models will incorrectly interpret the integers as having magnitude (Delhi=0 < Mumbai=1 < Pune=2, implying Pune is “more” than Delhi). Only use OrdinalEncoder when the order is semantically real.
4.5.3 Label Encoding
Converts each unique class to an integer 0, 1, 2, …, k-1. Typically used only to encode the target variable , not input features.
from sklearn.preprocessing import LabelEncoder
le = LabelEncoder()
y_encoded = le.fit_transform(y_train)
# le.classes_ gives the mapping: array(['cat', 'dog', 'fish'])
# cat→0, dog→1, fish→2
# Inverse transform predictions back to original labels
y_original = le.inverse_transform(y_pred)WARNING
Do not use LabelEncoder on input features. It assigns arbitrary integers to categories with no order, which linear models will misinterpret. Use OrdinalEncoder (with explicit categories) for ordered features, or OHE for unordered features.
4.5.4 Target Encoding (Mean Encoding)
Replaces each category with the mean of the target variable for all rows belonging to that category.
from sklearn.preprocessing import TargetEncoder
te = TargetEncoder(
target_type='continuous', # or 'binary', 'multiclass'
smooth='auto' # amount of shrinkage toward the global mean
# prevents overfitting for rare categories
)
X_train[['city_encoded']] = te.fit_transform(X_train[['city']], y_train)
X_test[['city_encoded']] = te.transform(X_test[['city']])Why it works: Encodes the predictive power of each category directly — the resulting number is meaningful (it’s the expected target value for that category).
The leakage problem: If you compute the target mean for city c using all rows including the current row’s label, the encoding leaks label information. sklearn’s TargetEncoder handles this automatically using cross-fitting (like leave-one-out), but you must still be careful to fit only on training data.
Smoothing: For rare categories (few samples), the mean is noisy. Smoothing blends the category mean toward the global mean: where is the smoothing parameter and is the number of samples in category .
4.5.5 Frequency / Count Encoding
Replace each category with the number of times it appears in the training set (count encoding), or its relative frequency (proportion).
# Count encoding
count_map = df['city'].value_counts()
df['city_count'] = df['city'].map(count_map)
# Frequency encoding (proportion)
freq_map = df['city'].value_counts(normalize=True)
df['city_freq'] = df['city'].map(freq_map)Pros: No OHE explosion, preserves some information about category rarity.
Cons: Two different categories with the same frequency get the same encoding — the model can’t distinguish them.
Best for: Tree-based models, as a simple high-cardinality baseline.
4.5.6 Hashing Encoding
Uses a hash function to map each category to one of fixed buckets. No need to store a vocabulary — works on unseen categories at inference time.
from sklearn.feature_extraction import FeatureHasher
hasher = FeatureHasher(n_features=16, input_type='string')
X_hashed = hasher.fit_transform(df['city'].values.reshape(-1, 1))Pros: Fixed, predictable output size regardless of cardinality; handles new categories.
Cons: Hash collisions — two different categories can land in the same bucket, making them indistinguishable. Increasing n_features reduces collisions.
4.5.7 Binary Encoding
A middle ground between OHE and hashing. Converts each category to an integer, then encodes that integer in binary (base-2). With unique categories, you need only binary columns instead of .
# Manual illustration: 8 cities → 3 binary columns (vs. 8 for OHE)
# city_0: 0 0 0 0 1 1 1 1
# city_1: 0 0 1 1 0 0 1 1
# city_2: 0 1 0 1 0 1 0 1Available via the category_encoders library:
import category_encoders as ce
be = ce.BinaryEncoder(cols=['city'])
X_encoded = be.fit_transform(X_train)Encoding Strategy Summary
| Method | Output columns | Handles unknown? | Best for |
|---|---|---|---|
| One-Hot Encoding | Via handle_unknown='ignore' | Low-cardinality, linear models | |
| Ordinal Encoding | 1 | Via handle_unknown | Ordered categories |
| Label Encoding | 1 | ❌ (errors on new labels) | Target variable only |
| Target Encoding | 1 | Fallback to global mean | High-cardinality, any model |
| Frequency Encoding | 1 | Map to 0 for unknown | High-cardinality, trees |
| Hashing | Fixed | ✅ (by design) | Very high-cardinality, online learning |
| Binary Encoding | ✅ | High-cardinality, moderate |
4.6 Feature Selection
Creating many features is easy; keeping only the useful ones is harder. More features → more data needed, slower training, risk of overfitting. Feature selection systematically removes uninformative or redundant features.
4.6.1 Removing Low-Variance Features
A feature with near-zero variance carries almost no information (it’s nearly constant across all samples).
from sklearn.feature_selection import VarianceThreshold
# Remove features with variance < 0.01
sel = VarianceThreshold(threshold=0.01)
X_reduced = sel.fit_transform(X_train)
print(sel.get_support()) # bool mask of retained features
print(X_train.columns[sel.get_support()]) # names of retained features4.6.2 Univariate Statistical Tests (SelectKBest)
Rank features by their statistical relationship with the target, keep the top .
from sklearn.feature_selection import SelectKBest, chi2, f_classif, mutual_info_classif
# For classification with non-negative integer features (e.g., word counts)
sel_chi2 = SelectKBest(score_func=chi2, k=10)
# For classification with continuous features
sel_f = SelectKBest(score_func=f_classif, k=10) # F-statistic (linear relationship)
sel_mi = SelectKBest(score_func=mutual_info_classif, k=10) # mutual information (non-linear)
X_selected = sel_f.fit_transform(X_train, y_train)
# Get the selected feature names
selected_mask = sel_f.get_support()
selected_names = X_train.columns[selected_mask]
print(selected_names)
# View scores for all features
scores = pd.DataFrame({'feature': X_train.columns, 'score': sel_f.scores_})
print(scores.sort_values('score', ascending=False))| Score function | Relationship captured | Target type |
|---|---|---|
chi2 | Association (non-negative features only) | Classification |
f_classif | Linear correlation via ANOVA F-test | Classification |
f_regression | Linear correlation via F-test | Regression |
mutual_info_classif | Any relationship (linear + non-linear) | Classification |
mutual_info_regression | Any relationship | Regression |
4.6.3 Recursive Feature Elimination (RFE)
Trains a model, ranks features by importance, removes the least important, repeats until n_features_to_select remain.
from sklearn.feature_selection import RFE, RFECV
from sklearn.linear_model import LogisticRegression
# Fixed number of features
rfe = RFE(
estimator=LogisticRegression(max_iter=1000),
n_features_to_select=10,
step=1 # remove 1 feature per iteration (use a larger step for speed)
)
X_rfe = rfe.fit_transform(X_train, y_train)
print(rfe.support_) # bool mask
print(rfe.ranking_) # rank of each feature (1 = selected)
# Auto-select optimal n_features via cross-validation
rfecv = RFECV(estimator=LogisticRegression(max_iter=1000), cv=5, scoring='f1')
rfecv.fit(X_train, y_train)
print(f"Optimal number of features: {rfecv.n_features_}")4.6.4 Model-Based Feature Selection (SelectFromModel)
Use a model’s learned importance scores (tree feature importances, or L1 regularisation coefficients) to select features above a threshold.
from sklearn.feature_selection import SelectFromModel
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LassoCV
# Random Forest importance (tree-based)
rf = RandomForestClassifier(n_estimators=100, random_state=42)
sf_rf = SelectFromModel(rf, threshold='mean') # keep features with importance > mean
X_rf_selected = sf_rf.fit_transform(X_train, y_train)
# L1 (Lasso) — drives coefficients to exactly zero
lasso = LassoCV(cv=5, random_state=42)
sf_lasso = SelectFromModel(lasso, threshold=1e-5) # keep non-zero coefficient features
X_lasso_selected = sf_lasso.fit_transform(X_train, y_train)TIP
L1-based feature selection (
Lasso,LogisticRegression(penalty='l1')) is one of the most principled ways to do feature selection for linear models — the regularisation and selection happen in a single step. Random Forest importance is the most practical choice for tree-based pipelines.
4.7 Putting It Together — ColumnTransformer
In a real dataset, you have a mix of numeric, ordinal, and nominal columns. ColumnTransformer applies different preprocessing pipelines to different subsets of columns and then horizontally concatenates the results.
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder, OrdinalEncoder
from sklearn.ensemble import RandomForestClassifier
# Define your column groups
numeric_cols = ['age', 'income', 'tenure']
ordinal_cols = ['education'] # Low < Medium < High
nominal_cols = ['city', 'gender'] # no order
# Preprocessing sub-pipelines
numeric_pipe = Pipeline([
('impute', SimpleImputer(strategy='median')),
('scale', StandardScaler())
])
ordinal_pipe = Pipeline([
('impute', SimpleImputer(strategy='most_frequent')),
('encode', OrdinalEncoder(categories=[['Low', 'Medium', 'High']]))
])
nominal_pipe = Pipeline([
('impute', SimpleImputer(strategy='most_frequent')),
('encode', OneHotEncoder(drop='first', handle_unknown='ignore', sparse_output=False))
])
# Combine into one preprocessor
preprocessor = ColumnTransformer([
('num', numeric_pipe, numeric_cols),
('ord', ordinal_pipe, ordinal_cols),
('nom', nominal_pipe, nominal_cols),
], remainder='drop') # 'drop': ignore remaining cols; 'passthrough': keep them as-is
# Full model pipeline
full_pipe = Pipeline([
('preprocessor', preprocessor),
('model', RandomForestClassifier(n_estimators=100, random_state=42))
])
full_pipe.fit(X_train, y_train)
print(f"Test accuracy: {full_pipe.score(X_test, y_test):.4f}")IMPORTANT
ColumnTransformeris the scikit-learn-idiomatic way to handle mixed-type data. It respects the fit-on-train / transform-on-test boundary automatically when used inside aPipeline, and it makesGridSearchCVseamless. Always prefer this over manual column-by-column transformations in production code.
5. Feature Scaling
Intuition: Most ML models don’t care about the names of features — they only see the numbers. If one feature has a range of [0, 100,000] (income in rupees) and another has a range of [0, 5] (number of children), a model that computes distances or dot products will treat the income feature as ~20,000× more important simply because its numbers are larger — not because it is actually more informative. Feature scaling removes this spurious scale-driven dominance.
5.1 Why Scaling Matters — The Mathematical Root Cause
Distance-based models (KNN, K-Means, SVM with RBF kernel)
Euclidean distance between two samples:
If feature has a range 1000× larger than feature , the distance is almost entirely determined by feature . Features with small ranges become invisible to the model.
Gradient descent convergence (Linear Regression, Logistic Regression, Neural Networks)
The loss surface of an unscaled model is an elongated ellipse — one axis is very steep (for the large-scale feature), the other is nearly flat (for the small-scale feature). Gradient descent bounces back and forth along the steep axis and converges very slowly.
After scaling, the loss surface is roughly circular, and gradient descent converges in far fewer steps.
Regularisation fairness (Ridge, Lasso)
L1/L2 penalties add or to the loss. If features are on different scales, a large raw feature value forces the model to learn a small coefficient for it — the regularisation then penalises this small coefficient less than the coefficient for a small-range feature that has a proportionally larger . This makes regularisation unfair and inconsistent across features.
5.2 Which Models Need Scaling?
| Model | Needs Scaling? | Why |
|---|---|---|
| KNN | ✅ Critical | Distance computation directly uses feature values |
| K-Means | ✅ Critical | Euclidean distance to centroids |
| SVM (RBF / poly kernel) | ✅ Critical | Kernel function is distance-based |
| SVM (linear kernel) | ✅ Yes | Affects the margin and regularisation |
| Linear Regression | ✅ Yes | Gradient descent convergence; coefficient interpretability |
| Logistic Regression | ✅ Yes | Same as above |
| Neural Networks (MLP) | ✅ Yes | Weight initialisation and gradient flow assume similar scales |
| PCA | ✅ Yes | Eigenvectors dominated by high-variance (large-scale) features |
| Decision Trees | ❌ No | Splits are threshold comparisons — scale-invariant |
| Random Forest, GBM, XGBoost | ❌ No | Ensemble of trees — same reason |
| Naïve Bayes | ❌ No | Models each feature’s distribution independently |
| LDA | ✅ Yes | Covariance-based — influenced by scale |
IMPORTANT
The rule of thumb: any model that computes distances, dot products, or uses gradient-based optimisation needs scaling. Tree-based models that make decisions via thresholds do not.
5.3 StandardScaler (Z-score Normalisation)
What it does: Subtracts the mean and divides by the standard deviation, making each feature have mean = 0 and standard deviation = 1.
where and .
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler(
with_mean=True, # subtract the mean (set False for sparse matrices)
with_std=True # divide by std dev
)
X_train_scaled = scaler.fit_transform(X_train) # fit on train, transform train
X_test_scaled = scaler.transform(X_test) # only transform test (use train's μ, σ)
# Inspect what was learnt
print(scaler.mean_) # μ per feature
print(scaler.scale_) # σ per feature (std dev)
# Inverse transform back to original space
X_original = scaler.inverse_transform(X_train_scaled)Effect on distribution
StandardScaler does NOT change the shape of the distribution — it only shifts and stretches it. A right-skewed distribution remains right-skewed after standardisation; outliers are still extreme (just expressed in units of standard deviation). For a normal distribution, ~68% of values fall in , ~95% in .
When to use
✅ Features are roughly normally distributed.
✅ You plan to use Ridge/Lasso/ElasticNet (ensures fair regularisation).
✅ PCA (ensures equal contribution from all features).
❌ Data has extreme outliers — and are both distorted by them.
5.4 MinMaxScaler
What it does: Linearly compresses every feature into a fixed range, by default [0, 1].
To scale to a custom range :
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler(feature_range=(0, 1)) # default; change to (-1, 1) if needed
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
print(scaler.data_min_) # x_min per feature (from training data)
print(scaler.data_max_) # x_max per feature (from training data)
print(scaler.data_range_) # x_max - x_min per featureThe Outlier Sensitivity Problem — Illustrated
Suppose income in the training set is mostly 20k–80k, but one value is 5,000,000 (a data entry error or a genuine billionaire):
x_min = 15,000
x_max = 5,000,000
data_range = 4,985,000
income = 50,000 → x' = (50,000 - 15,000) / 4,985,000 ≈ 0.007
income = 80,000 → x' = (80,000 - 15,000) / 4,985,000 ≈ 0.013
Nearly all data points are compressed into the range — 98% of the range is consumed by the single outlier. The scaler is effectively broken.
Fix: Remove or cap outliers before MinMaxScaling, or use RobustScaler instead.
When to use
✅ Neural networks that require inputs in (e.g., sigmoid output layer).
✅ Image pixel data (already bounded at , no outliers).
✅ Features genuinely have hard minimum/maximum bounds.
❌ Data has outliers — they ruin the scale.
5.5 RobustScaler
What it does: Uses the median and IQR instead of mean and std, making it resistant to outliers.
where = 50th percentile (median), = 25th percentile, = 75th percentile.
from sklearn.preprocessing import RobustScaler
scaler = RobustScaler(
with_centering=True, # subtract the median
with_scaling=True, # divide by IQR
quantile_range=(25.0, 75.0) # the percentiles to use for IQR (tunable)
)
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
print(scaler.center_) # median per feature
print(scaler.scale_) # IQR per featureWhy it’s robust
The median is unaffected by extreme values (changing the top 1% of values doesn’t change the median). The IQR only looks at the middle 50% of the data, completely ignoring the tails. Compare on the billionaire income example:
Data: [20k, 25k, 30k, 50k, 60k, 80k, 5,000k]
StandardScaler: μ ≈ 752k (skewed by 5M outlier), σ ≈ 1,888k
RobustScaler: median = 50k, IQR = 80k - 25k = 55k
income = 50k → StandardScaler: (50k-752k)/1888k ≈ -0.37
→ RobustScaler: (50k-50k)/55k = 0.00 (correctly centred)
When to use
✅ Data has outliers you cannot remove (genuine extreme values).
✅ Heavy-tailed distributions (income, house prices, user activity).
❌ Data is truly clean and normal — StandardScaler is slightly more efficient.
5.6 MaxAbsScaler
What it does: Divides each feature by the maximum absolute value, scaling to without shifting (no mean subtraction).
from sklearn.preprocessing import MaxAbsScaler
scaler = MaxAbsScaler()
X_scaled = scaler.fit_transform(X_train)When to use
✅ Sparse data (e.g., TF-IDF matrices, one-hot encoded matrices). With sparse matrices, subtracting the mean (like StandardScaler does) would destroy sparsity — every zero becomes a non-zero value, blowing up memory. MaxAbsScaler preserves sparsity because it only divides.
✅ Data that is already centred around zero (positive and negative values).
❌ Data with large outliers — the max absolute value is sensitive to extremes.
5.7 Normalizer (Row-wise Scaling)
Critical distinction: All scalers above operate column-wise (per feature, across all samples). Normalizer operates row-wise (per sample, across all features). It rescales each sample independently so that it has unit norm.
| Norm | Formula | Effect |
|---|---|---|
l1 | Each row sums to 1 (like a probability distribution) | |
l2 | Each row has unit Euclidean length (lies on a unit sphere) | |
max | Max element in each row becomes 1 |
from sklearn.preprocessing import Normalizer
norm = Normalizer(norm='l2') # 'l1', 'l2', or 'max'
X_normalised = norm.fit_transform(X_train)When to use
✅ Text / NLP — normalising TF-IDF vectors so document length doesn’t dominate similarity.
✅ Cosine similarity models — L2-normalising makes dot product = cosine similarity.
❌ Tabular data where absolute magnitudes matter (e.g., income = 10k vs. 100k) — normalising per-row would destroy that information.
5.8 PowerTransformer (Scale + Normalise Together)
As covered in Section 4.1, PowerTransformer applies a Yeo-Johnson (or Box-Cox) power transformation to make each feature more Gaussian, and optionally applies Z-score standardisation afterward. It’s a useful one-stop alternative to log-transform + StandardScaler.
from sklearn.preprocessing import PowerTransformer
pt = PowerTransformer(method='yeo-johnson', standardize=True)
X_scaled = pt.fit_transform(X_train)
# After transform: each feature is approximately N(0, 1)5.9 Head-to-Head Comparison
| Scaler | Formula | Output Range | Outlier Robust? | Preserves Sparsity? | Best Use Case |
|---|---|---|---|---|---|
StandardScaler | , centred at 0 | ❌ | ❌ | Normal data, Ridge/Lasso, PCA | |
MinMaxScaler | ❌ | ❌ | Neural nets, bounded data | ||
RobustScaler | Unbounded, centred at 0 | ✅ | ❌ | Skewed / outlier-heavy data | |
MaxAbsScaler | ❌ | ✅ | Sparse matrices (NLP) | ||
Normalizer | (row-wise) | Per-row unit norm | ❌ | ✅ | Text similarity, cosine distance |
PowerTransformer | Yeo-Johnson + Z-score | Partial | ❌ | Skewed numeric features |
5.10 The Fit-on-Train Rule & Pipeline Best Practice
The same rule from imputation applies here: fit the scaler only on training data, then use those learned statistics to transform both train and test sets.
Wrong ❌ — data leakage:
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X) # learns μ, σ from full dataset (including test!)
X_train, X_test = train_test_split(X_scaled)Correct ✅:
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train) # learn μ, σ from training data only
X_test_scaled = scaler.transform(X_test) # apply training μ, σ to test dataBest practice — use a Pipeline:
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC
pipe = Pipeline([
('scaler', StandardScaler()),
('model', SVC(kernel='rbf', C=1.0))
])
pipe.fit(X_train, y_train) # scaler.fit_transform(X_train) → svc.fit(X_train_scaled)
pipe.score(X_test, y_test) # scaler.transform(X_test) → svc.predict(X_test_scaled)Inside a Pipeline, the fit_transform / transform split is handled automatically — even inside cross_val_score and GridSearchCV, which re-fit the entire pipeline on each fold.
5.11 Verifying Your Scaling
After scaling, always sanity-check:
import pandas as pd
import numpy as np
X_scaled_df = pd.DataFrame(X_train_scaled, columns=feature_names)
# StandardScaler: mean ≈ 0, std ≈ 1 for each column
print(X_scaled_df.mean().round(4)) # should be near 0
print(X_scaled_df.std().round(4)) # should be near 1
# MinMaxScaler: min = 0, max = 1 for each column
print(X_scaled_df.min()) # should be 0
print(X_scaled_df.max()) # should be 1
# Check test set is also in a sensible range (not wildly outside train's range)
X_test_df = pd.DataFrame(X_test_scaled, columns=feature_names)
print("Test set min:", X_test_df.min().min())
print("Test set max:", X_test_df.max().max())
# For MinMaxScaler: test values outside [0, 1] mean test has out-of-distribution samplesTIP
After MinMaxScaling, test set values outside are a signal of distribution shift — the test set has values outside the range seen during training. This is worth investigating before deployment.
6. Train-Test Split & Cross-Validation
Intuition: You build a model to generalise to new, unseen data — not to memorise the data it was trained on. The only way to honestly estimate how well your model generalises is to evaluate it on data it has never seen during training. Everything in this section is about enforcing that separation correctly.
6.1 The Anatomy of a Dataset Split
Why you need at least two sets
- Training set — the data the model learns from (fits its parameters).
- Test set — held out entirely until the very end; used once to report final performance.
If you evaluate on the training set, you get an optimistically biased score — the model has already memorised those examples.
Why you often need three sets
When you make decisions based on validation performance (choosing hyperparameters, selecting features, picking between models), those decisions are implicitly fitted to the validation set. If you then report the validation score as your final result, it is optimistically biased. You need a third set:
- Training set — model fits its parameters.
- Validation set — model selection, hyperparameter tuning, early stopping.
- Test set — touched once, reported as the final honest estimate of generalisation.
Data
├── 60% Training set → fit model weights
├── 20% Validation set → tune hyperparameters, select model
└── 20% Test set → final evaluation (touch once, at the very end)
6.2 train_test_split — The Basics
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.2, # 20% for test; can be a float (proportion) or int (count)
random_state=42, # seed for reproducibility
stratify=y # preserve class proportions in each split
)Choosing test_size
- 80/20 — the most common default for medium datasets.
- 70/30 — when you need a larger test set (e.g., imbalanced classes, need more test examples per class).
- 90/10 — for very small datasets where training data is precious.
stratify — Why It Matters for Classification
Without stratification, a random split might put 90% of class-1 samples in training and only 10% in test. The model trains on an unrepresentative distribution, and the test set is too small to give stable estimates.
With stratify=y, each split preserves the original class proportions:
# Example: 80% class 0, 20% class 1 in full dataset
# Without stratify: train might be 82%/18%, test might be 72%/28% — lucky draw
# With stratify: train is exactly 80%/20%, test is exactly 80%/20%Always use stratify=y for classification problems, especially with imbalanced classes.
Three-Way Split (Train / Validation / Test)
# Step 1: carve off the test set first (20%)
X_trainval, X_test, y_trainval, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
# Step 2: split the remaining 80% into train (75% of 80% = 60%) and val (25% of 80% = 20%)
X_train, X_val, y_train, y_val = train_test_split(
X_trainval, y_trainval, test_size=0.25, random_state=42, stratify=y_trainval
)
print(f"Train: {len(X_train)}, Val: {len(X_val)}, Test: {len(X_test)}")IMPORTANT
The test set must be set aside before you do any EDA, feature selection, or hyperparameter tuning. If any decision you make is informed by test set statistics — even indirectly — your final evaluation is compromised.
6.3 The Problem with a Single Split
Suppose your dataset has 1,000 samples. A single 80/20 split gives you one specific 200-sample test set. Your performance score on those 200 samples has high variance — if a different 200 samples happened to be selected, you’d get a meaningfully different score. This variance is especially large when:
- The dataset is small.
- The class distribution is imbalanced.
- The data has high heterogeneity.
Solution: Cross-validation — evaluate multiple times on different held-out subsets and average the results.
6.4 K-Fold Cross-Validation
How it works: Split the data into equally-sized folds. In each of rounds, one fold serves as the validation set and the remaining folds form the training set. Average the scores.
k = 5:
Fold 1: [VAL] [TRN] [TRN] [TRN] [TRN] → Score₁
Fold 2: [TRN] [VAL] [TRN] [TRN] [TRN] → Score₂
Fold 3: [TRN] [TRN] [VAL] [TRN] [TRN] → Score₃
Fold 4: [TRN] [TRN] [TRN] [VAL] [TRN] → Score₄
Fold 5: [TRN] [TRN] [TRN] [TRN] [VAL] → Score₅
Final: mean(Score₁...₅) ± std(Score₁...₅)
Choosing
| Trade-off | Typical use | |
|---|---|---|
| 5 | Lower variance than 3-fold; fast | Default for large datasets (> 10k samples) |
| 10 | Less bias than 5-fold; standard in literature | Most common default |
| (LOO) | Lowest bias; very high variance; very slow | Tiny datasets (< 100 samples) |
Bias-variance of CV itself: Small → each training set is smaller → model sees less data → CV score underestimates true generalisation performance (pessimistic bias). Large → training sets are nearly full → less bias, but more variance in the score estimates.
from sklearn.model_selection import cross_val_score, KFold
from sklearn.ensemble import RandomForestClassifier
model = RandomForestClassifier(n_estimators=100, random_state=42)
# Simple interface
scores = cross_val_score(model, X_train, y_train, cv=5, scoring='f1_weighted', n_jobs=-1)
print(f"CV F1: {scores.mean():.4f} ± {scores.std():.4f}")
# With explicit KFold object (more control)
kf = KFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X_train, y_train, cv=kf, scoring='roc_auc')6.5 Stratified K-Fold
Standard KFold splits randomly — for imbalanced classification, some folds might end up with very few (or zero!) minority class samples. StratifiedKFold guarantees that each fold has the same class proportion as the full dataset.
from sklearn.model_selection import StratifiedKFold, cross_val_score
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X_train, y_train, cv=skf, scoring='f1_weighted')
print(f"Stratified CV F1: {scores.mean():.4f} ± {scores.std():.4f}")IMPORTANT
Always use
StratifiedKFoldfor classification tasks, especially when classes are imbalanced. StandardKFoldis appropriate only for regression (where there is no class to stratify on).
6.6 Repeated K-Fold
Run K-Fold CV multiple times with different random splits and average all results. Reduces the variance of the CV estimate itself.
from sklearn.model_selection import RepeatedStratifiedKFold, RepeatedKFold
# For classification
rskf = RepeatedStratifiedKFold(n_splits=5, n_repeats=10, random_state=42)
# Runs 5-fold CV 10 times → 50 total evaluations
scores = cross_val_score(model, X_train, y_train, cv=rskf, scoring='f1_weighted')
print(f"Repeated CV F1: {scores.mean():.4f} ± {scores.std():.4f}")When to use: When you need a high-confidence estimate of model performance, and computation time permits (50+ model fits). Common in research papers comparing models.
6.7 Leave-One-Out CV (LOO-CV)
: each sample is its own validation set. The model is trained on all remaining samples. Repeat times.
where is the prediction for sample from the model trained without sample .
from sklearn.model_selection import LeaveOneOut, cross_val_score
loo = LeaveOneOut()
scores = cross_val_score(model, X_train, y_train, cv=loo, scoring='accuracy')
print(f"LOO accuracy: {scores.mean():.4f}")
# scores has n entries, each 0 or 1 (correct/incorrect on that one test sample)Pros: Uses all available data for training in each fold — maximum training data.
Cons: model fits — extremely slow for large datasets. Also, each training set differs by only one sample, making the scores highly correlated → the variance estimate (std) is unreliable.
Use when: Dataset has < 100 samples.
6.8 ShuffleSplit & StratifiedShuffleSplit
Instead of non-overlapping folds, ShuffleSplit randomly samples a test set of fixed size on each iteration. Splits can overlap across iterations — it’s a Monte Carlo approach.
from sklearn.model_selection import ShuffleSplit, StratifiedShuffleSplit
ss = ShuffleSplit(n_splits=10, test_size=0.2, random_state=42)
# 10 independent random 80/20 splits
sss = StratifiedShuffleSplit(n_splits=10, test_size=0.2, random_state=42)
# Same but stratified — use this for classification
scores = cross_val_score(model, X_train, y_train, cv=sss, scoring='f1_weighted')When to use: Large datasets where standard K-Fold is too slow; when you want a specific train/test size ratio that doesn’t divide cleanly into folds.
6.9 GroupKFold — When Samples Are Not Independent
Standard K-Fold assumes samples are i.i.d. (independent and identically distributed). This breaks when:
- Multiple rows come from the same patient (medical data).
- Multiple transactions from the same user (financial data).
- Multiple frames from the same video.
If the same patient’s data appears in both train and test, the model can memorise that patient’s patterns — producing an optimistically biased score that won’t generalise to new patients.
Fix: GroupKFold ensures no group appears in both train and test in any fold.
from sklearn.model_selection import GroupKFold, cross_val_score
import numpy as np
groups = df['patient_id'].values # group identifier for each row
gkf = GroupKFold(n_splits=5)
scores = cross_val_score(model, X_train, y_train, cv=gkf, groups=groups, scoring='roc_auc')
print(f"Group-aware CV AUC: {scores.mean():.4f}")6.10 TimeSeriesSplit — For Sequential / Time-Series Data
Standard K-Fold shuffles the data randomly. For time-series data, future data must never be used to predict the past. TimeSeriesSplit uses only past observations for training and future observations for validation — always in forward order.
Split 1: [TRN: 1–200] → [VAL: 201–250]
Split 2: [TRN: 1–450] → [VAL: 451–500]
Split 3: [TRN: 1–700] → [VAL: 701–750]
Split 4: [TRN: 1–950] → [VAL: 951–1000]
from sklearn.model_selection import TimeSeriesSplit, cross_val_score
tss = TimeSeriesSplit(n_splits=5, gap=0)
# gap: number of samples to skip between train and test (prevents look-ahead leakage)
scores = cross_val_score(model, X_train, y_train, cv=tss, scoring='neg_mean_squared_error')
rmse_scores = np.sqrt(-scores)
print(f"Time Series CV RMSE: {rmse_scores.mean():.4f}")WARNING
Never use standard
KFoldorStratifiedKFoldon time-series data. Randomly shuffling allows the model to “see the future” during training — a form of data leakage that will produce impossibly good CV scores that fail completely in production.
6.11 cross_validate — Getting Multiple Metrics at Once
cross_val_score returns one score per fold for one metric. cross_validate returns multiple metrics and also gives train scores (useful for diagnosing overfitting).
from sklearn.model_selection import cross_validate
results = cross_validate(
model, X_train, y_train,
cv=5,
scoring=['accuracy', 'f1_weighted', 'roc_auc'],
return_train_score=True, # also compute score on training fold
n_jobs=-1
)
print(f"Val Accuracy: {results['test_accuracy'].mean():.4f}")
print(f"Val F1: {results['test_f1_weighted'].mean():.4f}")
print(f"Val AUC: {results['test_roc_auc'].mean():.4f}")
# Diagnose overfitting
print(f"Train Accuracy: {results['train_accuracy'].mean():.4f}")
print(f"Val Accuracy: {results['test_accuracy'].mean():.4f}")
# If train >> val → overfitting6.12 cross_val_predict — Out-of-Fold Predictions
Returns the prediction for each sample made when that sample was in the validation fold — i.e., every sample gets a prediction from a model that hasn’t seen it. Useful for:
- Building a confusion matrix or ROC curve that uses the whole training set.
- Generating meta-features for stacking.
from sklearn.model_selection import cross_val_predict
from sklearn.metrics import confusion_matrix, classification_report
y_pred_oof = cross_val_predict(model, X_train, y_train, cv=5, method='predict')
y_proba_oof = cross_val_predict(model, X_train, y_train, cv=5, method='predict_proba')
# Confusion matrix on the full training set, out-of-fold
print(confusion_matrix(y_train, y_pred_oof))
print(classification_report(y_train, y_pred_oof))NOTE
cross_val_predictis not the same as training one model and predicting. Each sample is predicted by a model that never saw it — this gives a more honest estimate of predictions on unseen data. Do not use these predictions to compute a final performance number and report it as test performance — use a real held-out test set for that.
6.13 CV Inside a Pipeline — Preventing Leakage
The most critical rule: all preprocessing steps (imputation, scaling, encoding) must be re-fitted from scratch on each training fold — they must never see the validation fold’s data. The correct way to enforce this is to put preprocessing inside a Pipeline and pass the pipeline to cross_val_score.
Wrong ❌ — leakage across folds:
# Scaling the entire training set BEFORE cross_val_score
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_train) # scaler sees all of X_train, including fold val sets
scores = cross_val_score(model, X_scaled, y_train, cv=5)
# The validation fold's statistics contaminated the scaler — leakage!Correct ✅ — Pipeline handles it automatically:
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC
from sklearn.model_selection import cross_val_score
pipe = Pipeline([
('scaler', StandardScaler()),
('model', SVC(kernel='rbf'))
])
# cross_val_score re-fits the entire pipeline (including scaler) on each training fold
scores = cross_val_score(pipe, X_train, y_train, cv=5, scoring='f1_weighted')
print(f"CV F1: {scores.mean():.4f} ± {scores.std():.4f}")In each of the 5 folds:
StandardScaleris fit on the 4 training folds → computes their mean and std.StandardScalertransforms the 4 training folds using those statistics.StandardScalertransforms the validation fold using the same statistics (not the validation fold’s own mean/std).SVCis fit on the scaled training folds and evaluated on the scaled validation fold.
6.14 CV Strategy Decision Guide
| Situation | Recommended CV Strategy |
|---|---|
| Large tabular dataset, classification | StratifiedKFold(n_splits=5) |
| Large tabular dataset, regression | KFold(n_splits=5, shuffle=True) |
| Imbalanced classes | StratifiedKFold or StratifiedShuffleSplit |
| Small dataset (< 200 samples) | LeaveOneOut or RepeatedStratifiedKFold |
| Non-independent samples (patients, users) | GroupKFold |
| Time-series / sequential data | TimeSeriesSplit |
| Need precise split ratio, large data | ShuffleSplit / StratifiedShuffleSplit |
| Research comparison, need stable estimate | RepeatedStratifiedKFold(n_splits=10, n_repeats=10) |
7. Hyperparameter Tuning
Intuition: A model has two kinds of parameters. Parameters (weights, biases, split thresholds) are learned from data during training. Hyperparameters (learning rate, regularisation strength, tree depth, number of neighbours) control how the training process runs — they are set before training and cannot be learned from the data directly. Hyperparameter tuning is the process of finding the best values for them.
7.1 Parameters vs. Hyperparameters
| Type | Examples | How set |
|---|---|---|
| Parameters | Linear regression weights , SVM support vectors, tree split thresholds | Learned by the optimisation algorithm during fit() |
| Hyperparameters | C in SVM, max_depth in trees, n_neighbors in KNN, alpha in Ridge | Set by you before fit(); tuned via search |
7.2 Grid Search CV (GridSearchCV)
How it works: You define a grid of discrete values for each hyperparameter. GridSearchCV evaluates every possible combination using cross-validation, and returns the combination with the best mean CV score.
from sklearn.model_selection import GridSearchCV
from sklearn.svm import SVC
param_grid = {
'C': [0.01, 0.1, 1, 10, 100],
'kernel': ['linear', 'rbf', 'poly'],
'gamma': ['scale', 'auto', 0.001, 0.01] # only relevant for rbf/poly
}
# Total combos: 5 × 3 × 4 = 60; with 5-fold CV → 300 training runs
gs = GridSearchCV(
estimator=SVC(),
param_grid=param_grid,
cv=5, # StratifiedKFold by default for classifiers
scoring='f1_weighted', # optimise this metric
n_jobs=-1, # use all CPU cores
refit=True, # after search, refit the best model on the full X_train
verbose=2, # print progress
return_train_score=True # include train scores in results_
)
gs.fit(X_train, y_train)
print(f"Best params: {gs.best_params_}")
print(f"Best CV score: {gs.best_score_:.4f}")
# The best model is already re-fitted on all of X_train (because refit=True)
best_model = gs.best_estimator_
print(f"Test score: {best_model.score(X_test, y_test):.4f}")
# Full results table
import pandas as pd
results_df = pd.DataFrame(gs.cv_results_).sort_values('rank_test_score')
print(results_df[['params', 'mean_test_score', 'std_test_score', 'rank_test_score']].head(10))GridSearchCV with a Pipeline
When tuning a Pipeline, prefix parameter names with the step name + __ (double underscore):
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC
pipe = Pipeline([
('scaler', StandardScaler()),
('model', SVC())
])
param_grid = {
'model__C': [0.1, 1, 10],
'model__kernel': ['linear', 'rbf'],
# 'scaler__with_std': [True, False] # you can even tune preprocessing steps
}
gs = GridSearchCV(pipe, param_grid, cv=5, scoring='f1_weighted', n_jobs=-1)
gs.fit(X_train, y_train)Pros: Guaranteed to find the global best within the grid.
Cons: Combinatorial explosion — if you have 5 hyperparameters each with 4 values: combinations × 5-fold CV = 5,120 training runs.
7.3 Randomised Search CV (RandomizedSearchCV)
Instead of trying every combination, RandomizedSearchCV samples n_iter random combinations from the hyperparameter distributions. This allows:
- Continuous distributions instead of discrete lists — you can search the real line.
- Much faster — 50 iterations cover more of the space than a grid with 50 points (because dimensions are explored independently).
from sklearn.model_selection import RandomizedSearchCV
from sklearn.ensemble import RandomForestClassifier
from scipy.stats import randint, uniform, loguniform
param_dist = {
'n_estimators': randint(50, 500), # uniform integer in [50, 500)
'max_depth': randint(3, 20),
'min_samples_split': randint(2, 20),
'min_samples_leaf': randint(1, 10),
'max_features': uniform(0.1, 0.9), # uniform float in [0.1, 1.0)
'bootstrap': [True, False]
}
rs = RandomizedSearchCV(
estimator=RandomForestClassifier(random_state=42),
param_distributions=param_dist,
n_iter=100, # number of random combinations to try
cv=5,
scoring='roc_auc',
n_jobs=-1,
random_state=42,
refit=True
)
rs.fit(X_train, y_train)
print(f"Best params: {rs.best_params_}")
print(f"Best AUC: {rs.best_score_:.4f}")loguniform — The Right Distribution for Scale Parameters
Hyperparameters like learning rate (eta) and regularisation strength (C, alpha) span orders of magnitude. Sampling uniformly in would almost always pick values > 1. Use loguniform to sample uniformly on a log scale:
from scipy.stats import loguniform
param_dist = {
'C': loguniform(1e-3, 1e3), # samples: 0.001, 0.01, 0.1, 1, 10, 100, 1000
'gamma': loguniform(1e-4, 1e0),
}Rule of thumb: Randomised search with 50–100 iterations almost always finds a result comparable to exhaustive grid search, in a fraction of the time.
7.4 Successive Halving (HalvingGridSearchCV)
A faster alternative that uses a tournament bracket approach. All candidates start with a small budget (few training samples). The weakest are eliminated, survivors get more data, repeat until one winner remains.
from sklearn.experimental import enable_halving_search_cv
from sklearn.model_selection import HalvingGridSearchCV, HalvingRandomSearchCV
hs = HalvingGridSearchCV(
estimator=RandomForestClassifier(),
param_grid=param_grid,
factor=3, # at each round, keep top 1/3 of candidates
resource='n_samples', # the resource to increase each round
cv=5,
scoring='f1_weighted',
n_jobs=-1
)
hs.fit(X_train, y_train)When to use: Large datasets with many hyperparameter combinations — it’s significantly faster than GridSearchCV with minimal loss in quality.
7.5 The Bias-Variance Tradeoff
Every hyperparameter tuning decision is fundamentally a bias-variance tradeoff:
| Model | High Bias (Underfitting) | High Variance (Overfitting) |
|---|---|---|
| Decision Tree | max_depth too small | max_depth too large |
| Random Forest | n_estimators too small | min_samples_leaf too small |
| SVM | C too small | C too large |
| Ridge/Lasso | alpha too large | alpha too small |
| KNN | n_neighbors too large | n_neighbors too small |
| Neural Network | Too few layers/neurons | Too many layers, no dropout |
Diagnosing from Train vs. Validation Score
Train Error: High | Val Error: High → Underfitting (high bias)
Train Error: Low | Val Error: High → Overfitting (high variance)
Train Error: Low | Val Error: Low → Good fit ✅
Train Error: High | Val Error: Low → Impossible (data bug)
from sklearn.model_selection import cross_validate
import numpy as np
results = cross_validate(model, X_train, y_train, cv=5,
scoring='neg_mean_squared_error',
return_train_score=True)
train_rmse = np.sqrt(-results['train_score'])
val_rmse = np.sqrt(-results['test_score'])
print(f"Train RMSE: {train_rmse.mean():.4f} ± {train_rmse.std():.4f}")
print(f"Val RMSE: {val_rmse.mean():.4f} ± {val_rmse.std():.4f}")7.6 Learning Curves & Validation Curves
Learning Curve — Diagnoses Bias vs. Variance
Plots model performance vs. training set size. Tells you whether adding more data will help.
from sklearn.model_selection import learning_curve
import matplotlib.pyplot as plt
import numpy as np
train_sizes, train_scores, val_scores = learning_curve(
estimator=model,
X=X_train, y=y_train,
cv=5,
train_sizes=np.linspace(0.1, 1.0, 10), # 10%, 20%, ..., 100% of training data
scoring='f1_weighted',
n_jobs=-1
)
train_mean = train_scores.mean(axis=1)
val_mean = val_scores.mean(axis=1)
train_std = train_scores.std(axis=1)
val_std = val_scores.std(axis=1)
plt.figure(figsize=(8, 5))
plt.plot(train_sizes, train_mean, 'o-', label='Train score')
plt.fill_between(train_sizes, train_mean - train_std, train_mean + train_std, alpha=0.2)
plt.plot(train_sizes, val_mean, 's-', label='Val score')
plt.fill_between(train_sizes, val_mean - val_std, val_mean + val_std, alpha=0.2)
plt.xlabel('Training set size'); plt.ylabel('Score')
plt.title('Learning Curve'); plt.legend()Reading a learning curve:
- Train ≈ Val, both low → high bias; more data won’t help; use a more complex model.
- Train >> Val → high variance; more data will help; or regularise.
- Train ≈ Val, both high → good fit; more data would help marginally.
Validation Curve — Diagnoses a Single Hyperparameter
Plots performance vs. a hyperparameter value. Reveals the sweet spot between under- and overfitting.
from sklearn.model_selection import validation_curve
param_range = [1, 2, 4, 6, 8, 10, 15, 20, 30]
train_scores, val_scores = validation_curve(
estimator=model,
X=X_train, y=y_train,
param_name='max_depth', # hyperparameter name
param_range=param_range,
cv=5,
scoring='f1_weighted',
n_jobs=-1
)
plt.figure(figsize=(8, 5))
plt.plot(param_range, train_scores.mean(axis=1), 'o-', label='Train')
plt.plot(param_range, val_scores.mean(axis=1), 's-', label='Val')
plt.xlabel('max_depth'); plt.ylabel('F1'); plt.title('Validation Curve')
plt.legend()
# Peak of the val curve is the optimal max_depth7.7 Tuning Best Practices
- Start broad, then narrow. First
RandomizedSearchCVover a wide range to identify promising regions, thenGridSearchCVfor fine-tuning within that region. - Use log-scale for scale-sensitive hyperparameters (
C,alpha,learning_rate). - Always use a Pipeline so preprocessing is re-fitted per fold — no leakage.
- Don’t tune on the test set. All tuning decisions use CV on training data only.
- Report CV score ± std, not just the mean. A model with CV score 0.85 ± 0.01 is more reliable than one with 0.87 ± 0.06.
- Use
refit=True(default) so the best model is automatically refitted on the full training set after search.
8. Evaluation Metrics — Regression
Intuition: After training a regression model, you need a single number that summarises “how wrong are the predictions?” Different metrics penalise different types of errors — choosing the right one depends on your domain and what kinds of errors are most costly.
8.1 Mean Absolute Error (MAE)
- Interpretation: The average magnitude of errors, in the same units as . If MAE = 5 for house price prediction in lakhs, the average prediction is off by ₹5 lakh.
- Robust to outliers — because errors are not squared, one very large error doesn’t dominate the metric.
- Gradient is constant (= ±1) — so the optimal prediction for MAE is the median of , not the mean. This also means gradient descent using MAE loss doesn’t slow down near the minimum.
from sklearn.metrics import mean_absolute_error
mae = mean_absolute_error(y_test, y_pred)
print(f"MAE: {mae:.4f}")When to use: When all errors are roughly equally important and you don’t want large outliers to dominate your metric (e.g., demand forecasting, sales prediction).
8.2 Mean Squared Error (MSE)
- Penalises large errors quadratically — an error of 10 contributes 100× more than an error of 1. This means MSE is very sensitive to outliers.
- Units are squared — MSE for a house price model is in “lakh²”, which is hard to interpret directly.
- The optimal prediction for MSE is the mean of — MSE is minimised by the conditional mean .
- Differentiable everywhere — gradient is smooth, making it the most common loss function for gradient-based optimisation (linear regression, neural networks).
from sklearn.metrics import mean_squared_error
mse = mean_squared_error(y_test, y_pred)
print(f"MSE: {mse:.4f}")When to use: As a training loss function; when large errors are genuinely much worse than small ones (e.g., structural safety prediction where a big miss is catastrophic).
8.3 Root Mean Squared Error (RMSE)
- Same units as — restores interpretability lost from squaring.
- Still dominated by large errors (because it’s derived from MSE).
- The most commonly reported regression metric in practice.
import numpy as np
rmse = np.sqrt(mean_squared_error(y_test, y_pred))
# or directly:
rmse = mean_squared_error(y_test, y_pred, squared=False)
print(f"RMSE: {rmse:.4f}")MAE vs. RMSE — Choosing Between Them
| MAE | RMSE | |
|---|---|---|
| Outlier sensitivity | Low (linear) | High (quadratic) |
| Interpretability | High (same units as ) | High (same units as ) |
| Optimal predictor | Median | Mean |
| Gradient | Constant ±1 | Smooth, proportional to error |
| Prefer when | Outliers exist; all errors matter equally | Large errors are disproportionately bad |
8.4 Mean Absolute Percentage Error (MAPE)
- Scale-independent — expressed as a percentage, making it easy to compare across datasets with different scales.
- Undefined when — division by zero; also heavily penalises cases where is very small.
- Asymmetric — over-predictions and under-predictions are not penalised equally.
from sklearn.metrics import mean_absolute_percentage_error
mape = mean_absolute_percentage_error(y_test, y_pred) * 100
print(f"MAPE: {mape:.2f}%")8.5 R-Squared (, Coefficient of Determination)
where = Residual Sum of Squares (model’s total error), = Total Sum of Squares (variance of around its mean).
- Interpretation: The proportion of variance in explained by the model. means the model explains 85% of the variance.
- Range: .
- : Perfect predictions.
- : Model is no better than always predicting .
- : Model is worse than the naive mean predictor — a red flag.
- Never decreases as you add more features (even noise features) — this is a flaw.
from sklearn.metrics import r2_score
r2 = r2_score(y_test, y_pred)
print(f"R²: {r2:.4f}")8.6 Adjusted R-Squared
where = number of samples, = number of features (predictors).
- Penalises the addition of uninformative features — if you add a noise feature, stays flat or increases, but Adjusted decreases.
- Can decrease when adding a feature that doesn’t improve the model enough to justify its inclusion.
- Use for feature selection and model comparison across models with different numbers of features.
n = len(y_test)
p = X_test.shape[1]
adj_r2 = 1 - (1 - r2) * (n - 1) / (n - p - 1)
print(f"Adjusted R²: {adj_r2:.4f}")8.7 Regression Metrics — When to Use What
| Metric | Use when |
|---|---|
| MAE | Outliers exist; want interpretable average error; median is the right baseline |
| RMSE | Large errors are disproportionately costly; want gradient-friendly metric |
| MAPE | Errors should be expressed as relative %; comparing across datasets |
| R² | Want to know how much variance is explained; evaluating goodness of fit |
| Adjusted R² | Comparing models with different numbers of features |
# One-liner: print all regression metrics
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
import numpy as np
def regression_report(y_true, y_pred, n_features):
n = len(y_true)
r2 = r2_score(y_true, y_pred)
adj_r2 = 1 - (1 - r2) * (n - 1) / (n - n_features - 1)
print(f"MAE: {mean_absolute_error(y_true, y_pred):.4f}")
print(f"MSE: {mean_squared_error(y_true, y_pred):.4f}")
print(f"RMSE: {np.sqrt(mean_squared_error(y_true, y_pred)):.4f}")
print(f"R²: {r2:.4f}")
print(f"Adjusted R²: {adj_r2:.4f}")
regression_report(y_test, y_pred, n_features=X_test.shape[1])9. Evaluation Metrics — Classification
Intuition: Accuracy is not enough. On a dataset with 99% negative samples, a model that always predicts “negative” achieves 99% accuracy and is completely useless. You need metrics that separately measure different types of correctness.
9.1 The Confusion Matrix
The confusion matrix is the foundation of all classification metrics. It breaks down predictions into four categories based on the actual and predicted classes.
| Predicted: Positive | Predicted: Negative | |
|---|---|---|
| Actual: Positive | TP (True Positive) | FN (False Negative) |
| Actual: Negative | FP (False Positive) | TN (True Negative) |
- TP — Correctly predicted Positive. The model said yes, it was yes.
- TN — Correctly predicted Negative. The model said no, it was no.
- FP — Type I Error (False Alarm). The model said yes, it was actually no. (e.g., flagging a healthy patient as sick)
- FN — Type II Error (Miss). The model said no, it was actually yes. (e.g., missing a cancerous tumour)
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
import matplotlib.pyplot as plt
cm = confusion_matrix(y_test, y_pred)
print(cm)
# Visualise
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['Negative', 'Positive'])
disp.plot(cmap='Blues')
plt.title('Confusion Matrix')
plt.show()
# Normalised version (show proportions instead of counts)
cm_normalised = confusion_matrix(y_test, y_pred, normalize='true')
# normalize='true': normalise by row (actual class)
# normalize='pred': normalise by column (predicted class)
# normalize='all': normalise by total samples9.2 Accuracy
The proportion of all predictions that are correct.
The imbalance problem: On a dataset with 950 negatives and 50 positives (95% negative), a model that blindly predicts “Negative” for everything achieves:
This model is completely useless — it never predicts the positive class. Accuracy gave you a 95% score and told you nothing.
from sklearn.metrics import accuracy_score
print(f"Accuracy: {accuracy_score(y_test, y_pred):.4f}")When to use: Only when classes are balanced and misclassification costs are equal. Almost never the right primary metric for real-world problems.
9.3 Precision
“Of all the samples I predicted as Positive, what fraction were actually Positive?”
Precision measures the cost of false positives. A high-precision model rarely sounds a false alarm.
Mnemonic: Precision = how precise your positive calls are. When you predict positive, you’re usually right.
from sklearn.metrics import precision_score
print(f"Precision: {precision_score(y_test, y_pred):.4f}")Use when FP is costly:
- Email spam filter — a FP (legitimate email flagged as spam) is very bad; user misses important mail.
- Legal system — wrongly convicting an innocent person (FP).
- Search engine — showing irrelevant results (FP) frustrates users.
9.4 Recall (Sensitivity, True Positive Rate)
“Of all the samples that actually are Positive, what fraction did I catch?”
Recall measures the cost of false negatives. A high-recall model misses few positive cases.
Mnemonic: Recall = how many positives you recall (retrieve). Of all the positives out there, how many did you find?
from sklearn.metrics import recall_score
print(f"Recall: {recall_score(y_test, y_pred):.4f}")Use when FN is costly:
- Cancer screening — a FN (missed tumour) means the patient goes untreated.
- Fraud detection — a FN (missed fraud) means financial loss.
- Earthquake early warning — a FN (missed earthquake) is catastrophic.
9.5 Specificity (True Negative Rate)
“Of all the actual Negatives, what fraction did I correctly identify as Negative?”
Specificity is the complement of the False Positive Rate (FPR). It’s analogous to Recall but for the negative class.
from sklearn.metrics import confusion_matrix
tn, fp, fn, tp = confusion_matrix(y_test, y_pred).ravel()
specificity = tn / (tn + fp)
print(f"Specificity: {specificity:.4f}")Sensitivity (Recall) vs. Specificity:
- Sensitivity = catching the sick patients (TP rate for Positives).
- Specificity = correctly clearing the healthy patients (TN rate for Negatives).
- In medical testing, you often want both high sensitivity AND high specificity, but there’s a tradeoff.
9.6 F-β Score
The F-score is the harmonic mean of Precision and Recall, with controlling the relative importance.
Why harmonic mean? Unlike the arithmetic mean, the harmonic mean is low when either component is low. A model with Precision = 1.0 and Recall = 0.01 should not score 0.505 — its harmonic mean is just 0.02. The harmonic mean correctly penalises imbalance between P and R.
F1 Score () — Equal weight
from sklearn.metrics import f1_score
print(f"F1: {f1_score(y_test, y_pred):.4f}")F2 Score () — Recall matters twice as much
Use when missing a positive is worse than a false alarm (medical, fraud, safety).
F0.5 Score () — Precision matters twice as much
Use when false alarms are worse than misses (spam filter, recommendation system).
from sklearn.metrics import fbeta_score
f2 = fbeta_score(y_test, y_pred, beta=2)
f05 = fbeta_score(y_test, y_pred, beta=0.5)9.7 Multiclass Averaging
For problems with classes, metrics like Precision, Recall, and F1 are computed per class and then aggregated:
| Averaging | How | When to use |
|---|---|---|
| Macro | Unweighted mean across all classes: | All classes are equally important, even rare ones |
| Micro | Pool all TP, FP, FN globally, then compute: | Class imbalance exists; overall correctness matters |
| Weighted | Weighted mean by class support (number of true instances): | Class imbalance; want to account for class frequency |
from sklearn.metrics import precision_score, recall_score, f1_score
print(f"Macro F1: {f1_score(y_test, y_pred, average='macro'):.4f}")
print(f"Micro F1: {f1_score(y_test, y_pred, average='micro'):.4f}")
print(f"Weighted F1: {f1_score(y_test, y_pred, average='weighted'):.4f}")
# Full report — the best way to see all metrics at once
from sklearn.metrics import classification_report
print(classification_report(y_test, y_pred, target_names=['Class 0', 'Class 1', 'Class 2']))9.8 Matthews Correlation Coefficient (MCC)
A single metric that captures all four cells of the confusion matrix, symmetric with respect to both classes. Particularly useful for binary classification with imbalanced classes.
- : Perfect prediction.
- : No better than random.
- : Perfect inverse prediction.
from sklearn.metrics import matthews_corrcoef
print(f"MCC: {matthews_corrcoef(y_test, y_pred):.4f}")Why MCC > Accuracy/F1 for imbalanced data: MCC uses all four cells of the confusion matrix. It will be high only if the model performs well on both classes — you can’t fool it by always predicting the majority class.
9.9 Cohen’s Kappa
Measures agreement between predictions and ground truth, corrected for chance agreement:
where = observed accuracy, = expected accuracy a random classifier would achieve.
| Interpretation | |
|---|---|
| Less agreement than chance | |
| Slight | |
| Fair | |
| Moderate | |
| Substantial | |
| Almost perfect |
from sklearn.metrics import cohen_kappa_score
print(f"Cohen's κ: {cohen_kappa_score(y_test, y_pred):.4f}")9.10 ROC-AUC Curve
What it is
The Receiver Operating Characteristic (ROC) curve plots the True Positive Rate (Recall/Sensitivity) against the False Positive Rate (1 - Specificity) at every possible classification threshold .
At : every sample predicted Positive → TPR = 1, FPR = 1 (top-right corner).
At : no sample predicted Positive → TPR = 0, FPR = 0 (bottom-left corner).
As decreases from 1 to 0, the operating point traces a curve from bottom-left to top-right.
AUC — Area Under the Curve
AUC is the area under the ROC curve.
| AUC | Interpretation |
|---|---|
| 1.0 | Perfect model — can perfectly rank all positives above all negatives |
| 0.5 | Random classifier — the diagonal line |
| < 0.5 | Model is inversely predicting (flip all predictions to get a model with AUC > 0.5) |
Probabilistic interpretation: AUC = the probability that a randomly selected positive sample gets a higher model score than a randomly selected negative sample. An AUC of 0.85 means: if you pick a random positive and a random negative, there’s an 85% chance the model ranks the positive higher.
Key property: threshold-independent. AUC evaluates the model’s ranking ability across all thresholds, not at any specific threshold. This makes it useful for comparing models independent of the operating point.
from sklearn.metrics import roc_auc_score, roc_curve
import matplotlib.pyplot as plt
# Requires probability scores, not hard predictions
y_proba = model.predict_proba(X_test)[:, 1] # probability of positive class
fpr, tpr, thresholds = roc_curve(y_test, y_proba)
auc = roc_auc_score(y_test, y_proba)
plt.figure(figsize=(7, 6))
plt.plot(fpr, tpr, lw=2, label=f'ROC curve (AUC = {auc:.3f})')
plt.plot([0, 1], [0, 1], 'k--', lw=1, label='Random (AUC = 0.5)')
plt.fill_between(fpr, tpr, alpha=0.1)
plt.xlabel('False Positive Rate (1 - Specificity)')
plt.ylabel('True Positive Rate (Recall)')
plt.title('ROC Curve')
plt.legend(loc='lower right')
plt.grid(True, alpha=0.3)
plt.show()Multiclass ROC-AUC
# OVR (One-vs-Rest): compute AUC for each class vs. all others, then average
auc_ovr = roc_auc_score(y_test, y_proba_all_classes, multi_class='ovr', average='macro')
# OVO (One-vs-One): compute AUC for each pair of classes, then average
auc_ovo = roc_auc_score(y_test, y_proba_all_classes, multi_class='ovo', average='macro')9.11 Precision-Recall Curve & Average Precision
When to prefer PR curve over ROC
On highly imbalanced datasets, the ROC curve can be misleadingly optimistic. Why? The FPR term has TN in the denominator — when the majority class is Negative, TN is huge, so FPR stays small even with many FPs. The ROC curve doesn’t “see” those false positives clearly.
The PR curve focuses entirely on the positive class — it uses TP, FP, and FN, completely ignoring TN. It is more discriminating on imbalanced data.
- High recall, low precision: Model casts a wide net — catches most positives, but many false alarms.
- High precision, low recall: Model is conservative — only predicts positive when very confident, but misses many.
Average Precision (AP)
The area under the PR curve, approximated as a weighted mean of Precisions at each recall level where recall increases:
from sklearn.metrics import precision_recall_curve, average_precision_score
import matplotlib.pyplot as plt
y_proba = model.predict_proba(X_test)[:, 1]
precision, recall, thresholds = precision_recall_curve(y_test, y_proba)
ap = average_precision_score(y_test, y_proba)
plt.figure(figsize=(7, 6))
plt.plot(recall, precision, lw=2, label=f'PR curve (AP = {ap:.3f})')
plt.axhline(y=y_test.mean(), color='k', linestyle='--', label=f'Baseline (prevalence = {y_test.mean():.3f})')
plt.xlabel('Recall')
plt.ylabel('Precision')
plt.title('Precision-Recall Curve')
plt.legend()
plt.grid(True, alpha=0.3)NOTE
The random classifier baseline for a PR curve is the prevalence (positive rate), not 0.5. If positives make up 5% of the data, a random classifier has AP ≈ 0.05, not 0.5. A good model’s AP should be significantly above the prevalence line.
9.12 Choosing & Tuning the Decision Threshold
Most classifiers output a probability score (via predict_proba), not a hard label. The default threshold is 0.5 — predict Positive if score > 0.5. This is often not the best threshold.
Tuning the threshold for a specific goal
import numpy as np
from sklearn.metrics import precision_score, recall_score, f1_score
y_proba = model.predict_proba(X_test)[:, 1]
thresholds = np.linspace(0, 1, 101)
results = []
for t in thresholds:
y_pred_t = (y_proba >= t).astype(int)
if y_pred_t.sum() == 0: # no positive predictions
continue
results.append({
'threshold': t,
'precision': precision_score(y_test, y_pred_t, zero_division=0),
'recall': recall_score(y_test, y_pred_t, zero_division=0),
'f1': f1_score(y_test, y_pred_t, zero_division=0)
})
df_thresh = pd.DataFrame(results)
best_f1_thresh = df_thresh.loc[df_thresh['f1'].idxmax(), 'threshold']
print(f"Threshold maximising F1: {best_f1_thresh:.2f}")
# Apply the chosen threshold
y_pred_final = (y_proba >= best_f1_thresh).astype(int)Finding the optimal threshold from the ROC curve
The point on the ROC curve closest to the top-left corner — perfect TPR with no FPR — often gives a good operating threshold:
fpr, tpr, thresholds = roc_curve(y_test, y_proba)
optimal_idx = np.argmax(tpr - fpr) # Youden's J statistic
optimal_threshold = thresholds[optimal_idx]
print(f"Optimal ROC threshold (Youden's J): {optimal_threshold:.3f}")9.13 Metrics Quick-Reference
| Metric | Formula | Best for |
|---|---|---|
| Accuracy | Balanced classes, equal misclassification costs | |
| Precision | FP is costly (spam, false alarms) | |
| Recall | FN is costly (cancer, fraud, safety) | |
| Specificity | Medical testing (true negative rate) | |
| F1 | Imbalanced classes; balanced P/R tradeoff | |
| F2 | F-score | Recall matters more than Precision |
| F0.5 | F-score | Precision matters more than Recall |
| MCC | All 4 cells | Binary, highly imbalanced data |
| Cohen’s κ | Chance-corrected accuracy | Agreement tasks, multiclass |
| ROC-AUC | Area under ROC | Ranking ability, threshold-independent |
| Average Precision | Area under PR | Imbalanced binary classification |
10. scikit-learn Models — Theory & Hyperparameters
10.1 Linear Regression
Intuition: Fit a hyperplane that minimises the sum of squared residuals between predictions and true values.
Closed-Form Solution (Normal Equation)
Requires to be invertible (fails with perfect multicollinearity). — slow when (features) is large.
Gradient Descent Variants
| Variant | Batch Size | Pros | Cons |
|---|---|---|---|
| Batch GD | All | Stable convergence | Slow on large data |
| Stochastic GD (SGD) | 1 | Fast, can escape local minima | Noisy, oscillates |
| Mini-Batch GD | (e.g. 32) | Best of both | Most commonly used |
from sklearn.linear_model import LinearRegression, SGDRegressor
lr = LinearRegression() # uses normal equation (via SVD internally)
sgd = SGDRegressor(
loss='squared_error',
learning_rate='invscaling', # 'constant', 'optimal', 'invscaling', 'adaptive'
eta0=0.01,
max_iter=1000,
tol=1e-3,
random_state=42
)Assumptions (Gauss-Markov): Linearity, no perfect multicollinearity, homoscedasticity, no autocorrelation of errors, .
10.2 Ridge, Lasso & Elastic Net (Regularised Regression)
Regularisation adds a penalty on the magnitude of weights to prevent overfitting. The penalty controls a bias-variance tradeoff — larger penalty = simpler model = more bias, less variance.
Ridge (L2 Regularisation)
- Shrinks all coefficients toward zero but rarely to exactly zero.
- The penalty is differentiable everywhere → smooth optimisation.
- Has a closed-form solution: — adding makes the matrix always invertible, fixing multicollinearity.
- Use when: Many features each contribute a small amount.
from sklearn.linear_model import Ridge, RidgeCV
ridge = Ridge(alpha=1.0)
ridge_cv = RidgeCV(alphas=[0.01, 0.1, 1, 10, 100], cv=5) # auto-tunes alphaLasso (L1 Regularisation)
- Can drive some coefficients to exactly zero → automatic feature selection.
- The penalty is non-differentiable at zero → solved via coordinate descent.
- Produces sparse solutions.
- Use when: Only a few features are truly relevant.
from sklearn.linear_model import Lasso, LassoCV
lasso = Lasso(alpha=0.1)
lasso_cv = LassoCV(cv=5, random_state=42) # selects best alpha via CVElastic Net (L1 + L2)
Combines Ridge’s stability with Lasso’s sparsity. l1_ratio : 0 = Ridge, 1 = Lasso.
from sklearn.linear_model import ElasticNet
en = ElasticNet(alpha=0.1, l1_ratio=0.5)Key Hyperparameter: alpha ()
alpha = 0→ plain Linear Regression.- ↑
alpha→ stronger penalty → simpler model → risk of underfitting. - Use
RidgeCV/LassoCV/ElasticNetCVto tune automatically.
TIP
Always scale features before Ridge/Lasso/ElasticNet. The penalty treats all coefficients equally, so unscaled features will be penalised unfairly.
10.3 Polynomial Regression
Intuition: Linear regression fits a line. To fit curves, augment features with polynomial terms, then run linear regression on the expanded feature space.
This is still linear in the parameters — just in a higher-dimensional feature space.
from sklearn.preprocessing import PolynomialFeatures
from sklearn.pipeline import Pipeline
from sklearn.linear_model import Ridge
pipe = Pipeline([
('poly', PolynomialFeatures(degree=3, include_bias=False)),
('scaler', StandardScaler()),
('model', Ridge(alpha=1.0)) # always regularise polynomial regression
])WARNING
High
degree→ exponential feature explosion + severe overfitting. Always combine with regularisation and cross-validate the degree.
10.4 Logistic Regression
Intuition: Models the probability that a sample belongs to class 1 by squashing a linear combination of features through the sigmoid function.
Decision rule: if , else .
Loss — Binary Cross-Entropy (Log-Loss):
Multiclass: Sigmoid is replaced by softmax; loss becomes categorical cross-entropy. multi_class='ovr' trains binary classifiers; multi_class='multinomial' trains a joint softmax model.
from sklearn.linear_model import LogisticRegression
clf = LogisticRegression(
C=1.0, # inverse of regularisation: C = 1/lambda
penalty='l2', # 'l1', 'l2', 'elasticnet', None
solver='lbfgs', # 'lbfgs' (L2), 'liblinear' (L1), 'saga' (all)
max_iter=1000,
class_weight='balanced',# corrects for class imbalance
multi_class='auto',
random_state=42
)Key Hyperparameters
| Parameter | Meaning | Notes |
|---|---|---|
C | — inverse regularisation strength | Larger C = less regularised |
penalty | Regularisation type | 'l1' needs solver='liblinear' or 'saga' |
solver | Optimisation algorithm | 'saga' supports all penalties + large datasets |
class_weight | Handle imbalance | 'balanced' weights inversely to class frequency |
max_iter | Convergence iterations | Increase if ConvergenceWarning |
10.5 Support Vector Machines (SVM)
Intuition: Find the hyperplane that separates classes with the maximum margin. The margin is the gap between the hyperplane and the nearest training samples from each class — called support vectors because they “support” (define) the decision boundary.
Equivalently:
Hard vs. Soft Margin
- Hard margin: Requires perfect linear separability. Fails with any noise or overlap.
- Soft margin: Allows violations using slack variables :
- High : Penalises misclassifications heavily → narrow margin → risk overfitting.
- Low : Allows more violations → wider margin → better generalisation.
The Kernel Trick
Data in the original space may not be linearly separable, but may be in a higher-dimensional space. Kernels compute the dot product in that space without explicitly transforming the data:
| Kernel | Formula | Use when |
|---|---|---|
| Linear | Data is linearly separable; high-dimensional (text) | |
| RBF (Gaussian) | General-purpose default; smooth non-linear boundary | |
| Polynomial | Moderate non-linearity; image classification | |
| Sigmoid | Rarely used |
from sklearn.svm import SVC, SVR, LinearSVC
clf = SVC(
kernel='rbf',
C=1.0,
gamma='scale', # 'scale'=1/(n_features*Var(X)); 'auto'=1/n_features
probability=True, # enable predict_proba (uses Platt scaling; adds cost)
class_weight='balanced',
random_state=42
)
# For large datasets: LinearSVC is much faster than SVC(kernel='linear')
lsvc = LinearSVC(C=1.0, max_iter=2000)Key Hyperparameters
| Parameter | Effect |
|---|---|
C | Margin/misclassification tradeoff — high C = harder boundary |
kernel | Shape of decision boundary |
gamma | RBF bandwidth — high gamma = narrow Gaussian = complex boundary (overfit) |
degree | Polynomial kernel degree |
IMPORTANT
SVMs are highly sensitive to feature scale — always StandardScale before fitting. They also don’t natively produce probability estimates;
probability=Trueadds a calibration step (Platt scaling) that increases training time.
10.6 Decision Trees
Intuition: Recursively partition the feature space by asking binary yes/no questions. At each internal node, pick the feature and threshold that best separates the classes. Leaves contain the predicted value.
Splitting Criteria
Gini Impurity (default for DecisionTreeClassifier):
Ranges from 0 (pure) to (maximally impure).
Entropy / Information Gain:
MSE Reduction (for regression trees): split that minimises within-child variance.
Gini and Entropy produce nearly identical results in practice. Gini is slightly faster to compute (no log).
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor, export_text, plot_tree
clf = DecisionTreeClassifier(
criterion='gini', # 'gini' or 'entropy'
max_depth=5, # None = grow fully (overfits)
min_samples_split=10, # min samples to split an internal node
min_samples_leaf=5, # min samples to be a leaf
max_features=None, # features to consider at each split
ccp_alpha=0.0, # post-pruning: higher = more pruning
class_weight='balanced',
random_state=42
)
clf.fit(X_train, y_train)
# Visualise the tree
print(export_text(clf, feature_names=list(X_train.columns)))Hyperparameter Effects
| Parameter | ↑ value → | Risk |
|---|---|---|
max_depth | More complex | Overfitting |
min_samples_split | Simpler | Underfitting |
min_samples_leaf | Simpler | Underfitting |
ccp_alpha | More pruned | Underfitting |
Feature Importance
importances = pd.Series(clf.feature_importances_, index=X_train.columns)
importances.sort_values(ascending=False).head(10).plot(kind='bar')Advantages: Interpretable, no scaling needed, handles mixed types, captures non-linear relationships. Disadvantages: High variance (small data changes → completely different tree), overfits without pruning.
10.7 Ensemble Methods — The Big Picture
The core idea: a single model has high variance or high bias. Combining many models can simultaneously reduce both.
| Method | How models are combined | What it reduces | Base learner |
|---|---|---|---|
| Bagging | Parallel training on bootstrap samples; aggregate by voting/averaging | Variance | Any |
| Random Forest | Bagging + random feature subsets at each split | Variance (more than bagging alone) | Decision Trees |
| AdaBoost | Sequential; reweight samples based on previous errors | Bias | Stumps (depth-1 trees) |
| Gradient Boosting | Sequential; each tree fits the negative gradient (residuals) of loss | Bias + Variance | Shallow trees |
| Voting | Average/majority vote of diverse models | Both | Any diverse set |
| Stacking | Meta-learner trained on base model predictions | Both | Any |
10.8 Bagging (Bootstrap AGGregatING)
Algorithm:
- Draw bootstrap samples from training data (sampling with replacement).
- Train one base learner on each sample independently (can be parallelised).
- Aggregate: majority vote (classification) or average (regression).
Why it works: Each model has high variance individually. Because each is trained on a different random sample, their errors are uncorrelated. The average of uncorrelated models with variance has variance .
Out-of-Bag (OOB) Error: Each bootstrap sample leaves out ~37% of the original data (on average). These left-out samples can be used as a free validation set — no need for a separate val split.
from sklearn.ensemble import BaggingClassifier
from sklearn.tree import DecisionTreeClassifier
bag = BaggingClassifier(
estimator=DecisionTreeClassifier(max_depth=None),
n_estimators=100,
max_samples=1.0, # fraction of samples per bootstrap
max_features=1.0, # fraction of features per bootstrap
bootstrap=True, # True = bagging; False = pasting (no replacement)
oob_score=True, # compute OOB score
n_jobs=-1,
random_state=42
)
bag.fit(X_train, y_train)
print(f"OOB Score: {bag.oob_score_:.4f}")10.9 Random Forest
Random Forest = Bagging + Random Feature Subsets at each split.
While vanilla Bagging draws bootstrap samples of rows, every tree still evaluates all features at every node split. If one feature is extremely dominant (e.g. income), every tree will pick income as its root split, making the trees highly correlated and limiting variance reduction.
Random Forest solves this by randomly sampling features ( for classification, for regression) at every single node split inside every tree.
Why Feature Sampling at Each Split Works (Mathematical Proof & Intuition)
The variance of an ensemble of trees, each with variance and pairwise correlation , is:
- As , the second term .
- The remaining irreducible ensemble variance is .
In Vanilla Bagging, because strong features appear at the top of almost every tree, the correlation remains high (~0.5–0.7).
In Random Forest, forcing trees to select from a random subset of features at every node prevents dominant features from appearing everywhere. This drives down significantly (~0.1–0.2), enabling much greater total variance reduction.
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
rf = RandomForestClassifier(
n_estimators=200, # number of trees; more is generally better (diminishing returns)
max_depth=None, # None = grow fully; limit to prevent overfitting
max_features='sqrt', # 'sqrt' (classification), 1.0 (all features = vanilla bagging), or 'log2'
min_samples_split=2,
min_samples_leaf=1,
bootstrap=True,
oob_score=True, # free validation via out-of-bag samples
class_weight='balanced',
n_jobs=-1,
random_state=42
)
rf.fit(X_train, y_train)
print(f"OOB accuracy: {rf.oob_score_:.4f}")
print(f"Test accuracy: {rf.score(X_test, y_test):.4f}")
# Feature importances
importances = pd.Series(rf.feature_importances_, index=X_train.columns)
print(importances.sort_values(ascending=False).head(10))Key Hyperparameters
| Parameter | Effect |
|---|---|
n_estimators | More trees = lower variance (diminishing returns past ~200) |
max_depth | Limit tree growth to prevent overfitting |
max_features | Lower = more decorrelation between trees = lower variance |
min_samples_leaf | Larger = smoother predictions (regression), less overfit |
oob_score | Free performance estimate on unseen data |
TIP
Random Forest’s OOB score is an unbiased estimate of generalisation performance — often close to 5-fold CV but computed for free as a byproduct of training. Use it as a quick sanity check.
10.10 AdaBoost (Adaptive Boosting)
Intuition: Train a sequence of weak learners (typically decision stumps — 1-split decision trees). After each round:
- Sample weights () are updated: misclassified samples get higher weights, forcing the next learner to focus on hard cases.
- Model weight () is calculated: more accurate weak learners receive higher voting power in the final decision.
Step-by-Step Mathematical Algorithm (AdaBoost.M1 for Classification )
-
Initialise Sample Weights:
-
For iteration : a. Fit weak learner using current sample weights . b. Compute total weighted error : c. Compute model voting weight : (If is small, is large and positive. If , .) d. Update sample weights:
- Correctly classified (): (weight drops).
- Misclassified (): (weight rises). e. Renormalise weights: so they sum to 1.
-
Final Ensemble Prediction:
Step-by-Step Numerical Toy Example
Consider 5 samples in 1D space with binary labels :
| Sample | Feature | Label | Initial Weight |
|---|---|---|---|
| 1 | 1 | +1 | 0.20 |
| 2 | 2 | +1 | 0.20 |
| 3 | 3 | -1 | 0.20 |
| 4 | 4 | +1 | 0.20 |
| 5 | 5 | -1 | 0.20 |
Round 1 ()
- Selected Stump : Split at , else .
- Predictions: .
- Sample 4 () is misclassified; all others correct.
- Weighted Error: .
- Model Weight :
- Update Weights:
- Correct ():
- Incorrect ():
- Sum of raw weights = .
- Normalised Weights : .
(Sample 4 now holds 50% of total sample weight!)
Round 2 ()
- Selected Stump : Forced to fix Sample 4 split at , else .
- Predictions: .
- Sample 3 () is misclassified; Sample 4 is now correct!
- Weighted Error: .
- Model Weight :
Ensemble Output for Sample 4 ()
- , .
- Combined Score: .
- ✅ — Stump 2’s higher weight () overrode Stump 1’s mistake.
AdaBoost for Regression (AdaBoost.R2)
In regression (), errors are continuous rather than binary:
- Relative Error ():
- Average Model Error (): .
- Confidence Factor (): .
- Sample Weight Update: (Small error weight multiplied by , decreasing it).
- Final Prediction: Takes the Weighted Median of all weak trees using tree weights , ensuring robustness against outlier predictions.
from sklearn.ensemble import AdaBoostClassifier, AdaBoostRegressor
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
ada_clf = AdaBoostClassifier(
estimator=DecisionTreeClassifier(max_depth=1), # decision stump
n_estimators=200,
learning_rate=1.0,
algorithm='SAMME.R', # uses real probabilities instead of discrete predictions
random_state=42
)
ada_reg = AdaBoostRegressor(
estimator=DecisionTreeRegressor(max_depth=3),
n_estimators=100,
learning_rate=1.0,
loss='linear', # 'linear', 'square', or 'exponential'
random_state=42
)| Hyperparameter | Effect |
|---|---|
n_estimators | Number of boosting rounds; too many can overfit |
learning_rate | Shrinks contribution of each weak learner; lower rate requires more estimators |
estimator | Base learner (default: max_depth=1 stump for clf, max_depth=3 for reg) |
loss (Regressor) | Relative loss formulation (linear, square, exponential) |
Pros: Simple, fast, low hyperparameter tuning required; often beats Random Forest on clean data.
Cons: Extremely sensitive to noise and outliers (noisy samples keep getting upweighted exponentially).
10.11 Gradient Boosting
Intuition: Instead of adjusting sample weights like AdaBoost, each new tree in Gradient Boosting directly fits the negative gradient of the loss function (the pseudo-residuals) with respect to the current model’s predictions.
Gradient Descent in Weight Space vs. Function Space
| Technique | Where Optimization Happens | Update Rule |
|---|---|---|
| Standard Gradient Descent | Parameter space (weights ) | |
| Gradient Boosting | Function space (predictions ) |
Here, the new decision tree is trained to approximate the negative gradient .
Mathematical Algorithm (Regression with MSE Loss)
Given dataset and learning rate :
-
Initialise Constant Base Prediction:
-
For iteration : a. Compute pseudo-residuals for all samples: (For MSE Loss , pseudo-residual is simply ). b. Fit a regression tree to predict targets . c. Update the ensemble prediction:
Step-by-Step Numerical Toy Example
Predict House Price (₹ Lakhs) based on Size (sq ft) with learning rate :
| House | (Size in sq ft) | (Price in ₹ Lakhs) |
|---|---|---|
| 1 | 500 | 30 |
| 2 | 1000 | 50 |
| 3 | 1500 | 70 |
Step 1: Base Prediction
Predictions: .
Round 1 ()
- Pseudo-Residuals :
- House 1 ():
- House 2 ():
- House 3 ():
- Fit Tree to :
- Split at :
- Left (): Houses 1, 2
- Right (): House 3
- Split at :
- Update Ensemble Prediction :
- House 1:
- House 2:
- House 3:
Round 2 ()
- Pseudo-Residuals :
- House 1:
- House 2:
- House 3:
- Fit Tree to :
- Split at :
- Left (): House 1
- Right (): Houses 2, 3
- Split at :
- Update Ensemble Prediction :
- House 1:
- House 2:
- House 3:
Notice how residuals shrink with each round as predictions move closer to true .
from sklearn.ensemble import GradientBoostingClassifier, GradientBoostingRegressor, HistGradientBoostingClassifier
gb = GradientBoostingClassifier(
n_estimators=300,
learning_rate=0.05, # shrinkage — lower = needs more trees
max_depth=4, # typically 3–6 for GBM
subsample=0.8, # stochastic GB: use 80% of samples per tree (reduces variance)
max_features='sqrt', # random feature subsets at each split
min_samples_leaf=10,
random_state=42
)
# HistGradientBoostingClassifier: faster sklearn implementation (like LightGBM)
hgb = HistGradientBoostingClassifier(
max_iter=300,
learning_rate=0.05,
max_depth=4,
l2_regularization=0.1,
early_stopping=True, # uses a validation set to stop early
random_state=42
)The Learning Rate — n_estimators Tradeoff
Lower learning_rate → each tree contributes less → model changes slowly → need more trees but generalises better. Rule of thumb: set learning_rate low (0.01–0.1) and use early stopping to find the right n_estimators.
10.12 XGBoost
XGBoost (Extreme Gradient Boosting) is a highly optimised implementation of gradient boosting with several algorithmic improvements:
-
Regularised objective: Adds L1 () and L2 () penalties on leaf weights directly in the loss: where = number of leaves, = leaf weights.
-
Second-order Taylor expansion: Uses both gradient (first derivative) and Hessian (second derivative) of the loss for more accurate tree fitting.
-
Column (feature) subsampling — like Random Forest, reducing overfitting.
-
Sparsity-aware split finding — handles missing values natively.
-
Histogram-based approximate splitting — much faster than exact greedy.
import xgboost as xgb
xgb_clf = xgb.XGBClassifier(
n_estimators=500,
learning_rate=0.05,
max_depth=5,
subsample=0.8, # row subsampling per tree
colsample_bytree=0.8, # feature subsampling per tree
colsample_bylevel=0.8, # feature subsampling per level
reg_alpha=0.1, # L1 on leaf weights
reg_lambda=1.0, # L2 on leaf weights
gamma=0.0, # min loss reduction to make a split
min_child_weight=1, # min sum of hessian in a child (controls overfitting)
scale_pos_weight=1, # for imbalanced: set to neg/pos ratio
eval_metric='logloss',
early_stopping_rounds=20,
use_label_encoder=False,
random_state=42,
n_jobs=-1
)
# Fitting with early stopping
eval_set = [(X_val, y_val)]
xgb_clf.fit(X_train, y_train, eval_set=eval_set, verbose=50)Key Hyperparameters
| Parameter | Effect |
|---|---|
n_estimators | Number of boosting rounds |
learning_rate | Shrinkage per tree |
max_depth | Tree depth; 3–8 typical |
subsample | Row subsampling (0.5–1.0) |
colsample_bytree | Feature subsampling per tree |
reg_alpha / reg_lambda | L1/L2 regularisation |
gamma | Min impurity reduction for a split |
min_child_weight | Larger = more conservative splits |
early_stopping_rounds | Stop if val metric doesn’t improve for N rounds |
10.13 LightGBM
LightGBM (Light Gradient Boosting Machine) by Microsoft. Key innovations over XGBoost:
- Leaf-wise (best-first) tree growth instead of level-wise: grows the leaf with the greatest loss reduction first → deeper, more asymmetric trees → faster convergence.
- Histogram-based binning: bins continuous features into ~256 buckets, dramatically reducing split-finding cost.
- GOSS (Gradient-based One-Side Sampling): keeps samples with large gradients (hard examples) and randomly samples from those with small gradients → faster without losing accuracy.
- EFB (Exclusive Feature Bundling): bundles mutually exclusive sparse features → reduces effective feature count.
import lightgbm as lgb
lgb_clf = lgb.LGBMClassifier(
n_estimators=500,
learning_rate=0.05,
max_depth=-1, # -1 = no limit; control via num_leaves instead
num_leaves=31, # key parameter: 2^max_depth is a good upper bound
subsample=0.8,
colsample_bytree=0.8,
reg_alpha=0.1,
reg_lambda=1.0,
min_child_samples=20, # min samples in a leaf
class_weight='balanced',
random_state=42,
n_jobs=-1
)
lgb_clf.fit(
X_train, y_train,
eval_set=[(X_val, y_val)],
callbacks=[lgb.early_stopping(stopping_rounds=20), lgb.log_evaluation(50)]
)LightGBM vs. XGBoost:
| LightGBM | XGBoost | |
|---|---|---|
| Tree growth | Leaf-wise (asymmetric) | Level-wise (symmetric) |
| Speed | Faster on large datasets | Slightly slower |
| Memory | Lower | Higher |
| Hyperparameter to control depth | num_leaves | max_depth |
| Categorical support | Native (categorical_feature=) | Requires encoding |
10.14 CatBoost
CatBoost (short for Categorical Boosting) by Yandex addresses two primary flaws in traditional gradient boosting: target leakage in categorical encoding and prediction drift in boosting tree construction.
1. Ordered Target Encoding
Standard Target Encoding replaces a category with the mean target value of that category in the training set. However, using sample ‘s target to calculate sample ‘s own feature encoding causes severe target leakage.
CatBoost’s Solution: For sample at position in a random permutation , compute its target statistic using only samples appearing BEFORE in that permutation:
Where is the global target mean (prior) and is a smoothing weight. Because sample ‘s target is never used to compute its own feature encoding, leakage is completely eliminated.
2. Ordered Boosting (Preventing Prediction Drift)
In standard boosting, the pseudo-residual is computed using a model trained on a dataset that already included sample . Because slightly memorises , is artificially small, causing prediction drift.
CatBoost’s Solution: Train separate auxiliary prefix models along a random permutation :
- Model is trained only on samples through .
- To compute the residual for sample , CatBoost queries .
- Since has never seen sample , the residual is an honest, out-of-sample error.
Step-by-Step Numerical Toy Example of Ordered Boosting
Consider 4 samples in a random permutation order:
| Order | Sample | (Feature) | (Target) |
|---|---|---|---|
| 1st | A | 1 | 10 |
| 2nd | B | 2 | 20 |
| 3rd | C | 3 | 30 |
| 4th | D | 4 | 40 |
-
Train Supported Prefix Models:
- : Trained only on
- : Trained only on
- : Trained only on
-
Compute Unbiased Residuals:
- For Sample B: Residual ( has never seen ).
- For Sample C: Residual ( has never seen ).
- For Sample D: Residual ( has never seen ).
-
Train Next Tree: The next boosting tree is trained on these unbiased out-of-sample residuals , preventing target leakage and prediction drift.
3. Symmetric (Oblivious) Trees
CatBoost uses Oblivious Trees, where every node at depth uses the exact same feature and split threshold.
Standard Tree Symmetric (Oblivious) Tree
[ Age ≤ 30 ] [ Age ≤ 30 ]
/ \ / \
[ Income ≤ 50k ] [ Zip ≤ 90210 ] [ Income ≤ 50k ] [ Income ≤ 50k ]
- Execution Speed: Predictions evaluate as bitwise operations/lookup tables, enabling up to 8x faster CPU/GPU inference.
- Regularisation: Forces balanced, symmetric tree structures, preventing deep overfitted branches.
from catboost import CatBoostClassifier
cat_clf = CatBoostClassifier(
iterations=500,
learning_rate=0.05,
depth=6,
l2_leaf_reg=3, # L2 regularisation
cat_features=['city', 'gender', 'occupation'], # pass raw string columns!
eval_metric='AUC',
early_stopping_rounds=50,
random_seed=42,
verbose=100
)
cat_clf.fit(X_train, y_train, eval_set=(X_val, y_val))Why CatBoost shines: You can pass raw string categorical columns directly — no encoding needed. It handles the encoding internally in a leakage-free way.
Boosting Libraries Comparison
| sklearn GBM | XGBoost | LightGBM | CatBoost | |
|---|---|---|---|---|
| Speed | Slow | Fast | Fastest | Fast |
| Native categoricals | ❌ | ❌ | Partial | ✅ |
| Missing values | ❌ | ✅ | ✅ | ✅ |
| GPU support | ❌ | ✅ | ✅ | ✅ |
| Tree structure | Asymmetric | Symmetric (level-wise) | Asymmetric (leaf-wise) | Oblivious (Symmetric) |
| Leakage-free categoricals | ❌ | ❌ | ❌ | ✅ |
10.15 Voting Classifier & Regressor
Combines predictions of diverse models. Works best when models make uncorrelated errors.
from sklearn.ensemble import VotingClassifier, VotingRegressor
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC
voting = VotingClassifier(
estimators=[
('lr', LogisticRegression(max_iter=1000)),
('rf', RandomForestClassifier(n_estimators=100, random_state=42)),
('svc', SVC(probability=True, kernel='rbf'))
],
voting='soft', # 'hard': majority vote; 'soft': average probabilities (usually better)
weights=[1, 2, 1] # optional: weight models by their quality
)
voting.fit(X_train, y_train)Hard voting — each model votes for a class; majority wins.
Soft voting — average the predicted probabilities; pick the class with the highest average. Requires all models to support predict_proba. Usually better because it accounts for prediction confidence.
10.16 Stacking
Trains a meta-learner (blender) on the out-of-fold predictions of the base models. The meta-learner learns how to combine the base predictions optimally.
from sklearn.ensemble import StackingClassifier
stacking = StackingClassifier(
estimators=[
('rf', RandomForestClassifier(n_estimators=100, random_state=42)),
('svc', SVC(probability=True, kernel='rbf')),
('lgr', LogisticRegression(max_iter=1000))
],
final_estimator=LogisticRegression(), # meta-learner
cv=5, # base models generate OOF predictions via 5-fold CV
stack_method='predict_proba', # 'predict_proba' or 'predict'
passthrough=False # if True, pass original features to meta-learner too
)
stacking.fit(X_train, y_train)Why OOF predictions? If base models predict on the same data they were trained on, the meta-learner sees over-fitted predictions. Using cross-validation ensures the meta-learner trains on out-of-fold predictions — what the base models will produce on truly unseen data.
10.17 K-Nearest Neighbours (KNN)
Intuition: A lazy learner — no training phase at all. To predict a new sample, find its nearest neighbours in the stored training data and aggregate their labels.
Distance metrics:
- Euclidean (): — most common
- Manhattan (): — more robust in high dimensions
- Minkowski (general):
from sklearn.neighbors import KNeighborsClassifier, KNeighborsRegressor
knn = KNeighborsClassifier(
n_neighbors=5,
metric='minkowski',
p=2, # p=2 → Euclidean; p=1 → Manhattan
weights='uniform', # 'uniform': equal weight; 'distance': closer = more weight
algorithm='auto', # 'auto', 'ball_tree', 'kd_tree', 'brute'
n_jobs=-1
)Choosing
- Small (e.g., 1, 3): Very local → captures fine-grained patterns → high variance, sensitive to noise.
- Large (e.g., 50, 100): Very smooth → high bias, ignores local structure.
- Rule of thumb: Start with ; tune via CV.
| Hyperparameter | Effect |
|---|---|
n_neighbors | Lower → more complex boundary |
weights | 'distance' helps when nearby points are more relevant |
metric | Euclidean for numeric; Manhattan for sparse/high-dimensional |
IMPORTANT
KNN is at prediction time — every new query scans all training points. For large datasets, use approximate nearest-neighbour libraries (FAISS, Annoy) or
algorithm='ball_tree'/'kd_tree'for exact but faster search. Always scale features first.
10.18 Naïve Bayes
Intuition: Apply Bayes’ theorem with the strong (“naïve”) assumption that all features are conditionally independent given the class.
The log is used to avoid underflow from multiplying many small probabilities.
Variants by Likelihood Model
| Variant | assumption | Use case |
|:---|:---|:---|
| GaussianNB | — Gaussian per feature per class | Continuous features |
| MultinomialNB | Multinomial distribution over counts | Text classification (word counts, TF-IDF) |
| BernoulliNB | Bernoulli (binary 0/1 per feature) | Binary text features (word present/absent) |
| ComplementNB | Uses complement class statistics | Imbalanced text classification (often outperforms MultinomialNB) |
| CategoricalNB | Categorical distribution per feature | Purely categorical tabular data |
from sklearn.naive_bayes import GaussianNB, MultinomialNB, BernoulliNB, ComplementNB
gnb = GaussianNB(var_smoothing=1e-9) # adds small variance to prevent zero probabilities
mnb = MultinomialNB(alpha=1.0) # alpha = Laplace smoothing (prevents zero likelihoods)
bnb = BernoulliNB(alpha=1.0, binarize=0.0) # binarize: threshold for binary featuresLaplace / Additive Smoothing (alpha): If a word never appeared in class during training, , making the entire product zero. Smoothing adds a small count to every feature count.
Pros: Extremely fast ( training), works well on small data, naturally multiclass, robust to irrelevant features. Cons: The independence assumption is almost always violated; correlated features cause systematic bias.
10.19 Multi-Layer Perceptron (MLP)
Intuition: A neural network made of layers of neurons. Each neuron computes a weighted sum of its inputs, applies a non-linear activation function, and passes the result to the next layer. By stacking many such layers, the network can approximate any function.
Architecture
Input Layer → [Hidden Layer 1] → [Hidden Layer 2] → ... → Output Layer
x h₁ = σ(W₁x + b₁) h₂ = σ(W₂h₁ + b₂) ŷ
Forward pass:
Activation Functions
| Function | Formula | Properties | Use in |
|---|---|---|---|
| ReLU | Fast, no vanishing gradient for | Hidden layers (default) | |
| Leaky ReLU | Fixes “dying ReLU” (zero gradient for ) | Hidden layers | |
| Tanh | Output in , zero-centred | Hidden layers (older) | |
| Sigmoid | Output in | Binary output layer | |
| Softmax | Outputs sum to 1 (probability distribution) | Multiclass output layer | |
| Linear / Identity | No non-linearity | Regression output layer |
Backpropagation
The loss gradient is propagated backward through the chain rule:
This is computed efficiently layer-by-layer using the chain rule. The computed gradients are used by the optimiser to update weights.
Optimisers
| Optimiser | Update Rule | Notes |
|---|---|---|
| SGD | Simple; needs careful lr tuning | |
| SGD + Momentum | ; | Smoother convergence |
| Adam | Adaptive per-parameter lr using first and second moment estimates | Default for most deep learning; robust |
| lbfgs | Quasi-Newton method | Good for small datasets; full-batch |
MLPClassifier / MLPRegressor
from sklearn.neural_network import MLPClassifier, MLPRegressor
mlp = MLPClassifier(
hidden_layer_sizes=(256, 128, 64), # 3 hidden layers
activation='relu', # 'relu', 'tanh', 'logistic', 'identity'
solver='adam', # 'adam', 'sgd', 'lbfgs'
alpha=1e-4, # L2 regularisation on weights
batch_size='auto', # 'auto' = min(200, n_samples) for adam/sgd
learning_rate='adaptive', # 'constant', 'invscaling', 'adaptive' (sgd only)
learning_rate_init=1e-3, # initial learning rate
max_iter=500,
early_stopping=True, # hold out 10% of train as validation set
validation_fraction=0.1,
n_iter_no_change=10, # stop if val score doesn't improve for 10 epochs
random_state=42
)Key Hyperparameters
| Parameter | Effect |
|---|---|
hidden_layer_sizes | Network architecture — more/deeper layers → more capacity |
activation | Non-linearity; 'relu' is almost always the right default |
solver | 'adam' for most cases; 'lbfgs' for small datasets |
alpha | L2 regularisation — controls overfitting |
learning_rate_init | Step size for weight updates |
early_stopping | Prevents overfitting using a validation hold-out |
IMPORTANT
MLP is sensitive to feature scale — always StandardScale before fitting. Also, initialisation is random — results vary across runs unless
random_stateis fixed. For serious deep learning work, use PyTorch or TensorFlow instead of sklearn’s MLP.
10.20 K-Means Clustering
Intuition: Partition samples into clusters by iteratively assigning each sample to its nearest centroid and updating centroids.
Algorithm:
1. Initialise K centroids (randomly or via K-Means++)
2. Repeat until convergence:
a. Assign each sample to the nearest centroid (Voronoi assignment)
b. Update each centroid = mean of all samples assigned to it
Objective (Inertia = Within-Cluster Sum of Squares):
K-Means++ Initialisation: Instead of random initialisation, choose each subsequent centroid with probability proportional to its squared distance from the nearest already-chosen centroid. This gives better starting points and faster convergence.
from sklearn.cluster import KMeans
import matplotlib.pyplot as plt
km = KMeans(
n_clusters=5,
init='k-means++', # 'k-means++' (smart init) or 'random'
n_init=10, # run 10 times with different seeds; keep the best
max_iter=300,
tol=1e-4,
random_state=42
)
km.fit(X_scaled)
labels = km.labels_ # cluster assignment for each sample
centers = km.cluster_centers_ # centroid coordinates
inertia = km.inertia_ # total WCSS
# Predict cluster for new data
new_labels = km.predict(X_new)Choosing K — Elbow Method
inertias = []
K_range = range(2, 15)
for k in K_range:
km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42)
km.fit(X_scaled)
inertias.append(km.inertia_)
plt.plot(K_range, inertias, 'bo-')
plt.xlabel('K'); plt.ylabel('Inertia'); plt.title('Elbow Method')
# Pick K at the "elbow" — where the rate of decrease slows down sharplySilhouette Score
Measures how tight and well-separated clusters are. For sample : where = mean distance to other samples in the same cluster, = mean distance to samples in the nearest different cluster.
Range: ; higher is better. → well-clustered; → on boundary; → misassigned.
from sklearn.metrics import silhouette_score, silhouette_samples
# Overall score
sil = silhouette_score(X_scaled, km.labels_)
print(f"Silhouette Score: {sil:.4f}")
# Per-sample scores (plot to identify poorly clustered samples)
sample_sil = silhouette_samples(X_scaled, km.labels_)Limitations of K-Means: Assumes spherical clusters of equal size; sensitive to outliers (which pull centroids); must specify upfront; non-deterministic (use n_init > 1).
10.21 DBSCAN
Intuition: Density-Based Spatial Clustering. Groups together samples in dense regions; marks sparse samples as noise. Can find arbitrarily shaped clusters; no need to specify .
Two parameters:
eps(): neighbourhood radius.min_samples: minimum points in the -neighbourhood to be a core point.
Point types:
- Core point: has
min_samplespoints withineps(including itself). - Border point: within
epsof a core point, but not a core point itself. - Noise point: not a core point and not reachable from any core point. Labelled .
Cluster formation: Start at an unvisited core point; expand by density-reachability (recursively add all points within eps).
from sklearn.cluster import DBSCAN
db = DBSCAN(
eps=0.5, # neighbourhood radius — tune with a k-distance plot
min_samples=5, # min points to form a core point
metric='euclidean',
n_jobs=-1
)
labels = db.fit_predict(X_scaled)
n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
n_noise = (labels == -1).sum()
print(f"Clusters: {n_clusters}, Noise points: {n_noise}")Tuning eps — k-Distance Plot
from sklearn.neighbors import NearestNeighbors
import numpy as np
k = 5 # same as min_samples
nbrs = NearestNeighbors(n_neighbors=k).fit(X_scaled)
distances, _ = nbrs.kneighbors(X_scaled)
# Sort the k-th nearest neighbour distances
kth_distances = np.sort(distances[:, -1])[::-1]
plt.plot(kth_distances)
plt.xlabel('Points (sorted)'); plt.ylabel(f'{k}th nearest neighbour distance')
plt.title('k-Distance Graph — pick eps at the "elbow"')| K-Means | DBSCAN | |
|---|---|---|
| Cluster shape | Spherical only | Arbitrary |
| specification | Required | Not needed |
| Noise handling | None (all assigned) | Labels noise as |
| Scalability | — fast | with index |
| Sensitivity | To outliers (centroid shift) | To eps, min_samples |
10.22 Principal Component Analysis (PCA)
Intuition: Find the directions of maximum variance in the data. Project the data onto these directions to get a lower-dimensional representation that preserves as much information as possible.
Algorithm
- Centre the data: .
- Compute the covariance matrix: .
- Compute eigenvectors and eigenvalues of .
- The -th principal component = projection onto .
- Keep top- eigenvectors: where .
Explained variance ratio of PC : — the fraction of total variance captured.
from sklearn.decomposition import PCA
import matplotlib.pyplot as plt
import numpy as np
# Fit PCA
pca = PCA(n_components=None, random_state=42) # None = keep all components
pca.fit(X_scaled)
# Scree plot — choose k where explained variance levels off
plt.figure(figsize=(8, 4))
plt.plot(np.cumsum(pca.explained_variance_ratio_), 'o-')
plt.xlabel('Number of Components')
plt.ylabel('Cumulative Explained Variance')
plt.axhline(y=0.95, color='r', linestyle='--', label='95% variance')
plt.legend()
plt.title('PCA Scree Plot')
# Apply with chosen k
pca_k = PCA(n_components=0.95) # keep enough components to explain 95% variance
X_reduced = pca_k.fit_transform(X_scaled)
print(f"Components to explain 95% variance: {pca_k.n_components_}")
print(f"Explained variance ratio: {pca_k.explained_variance_ratio_}")PCA in a Pipeline
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.svm import SVC
pipe = Pipeline([
('scaler', StandardScaler()),
('pca', PCA(n_components=50)),
('model', SVC(kernel='rbf'))
])
pipe.fit(X_train, y_train)Use Cases
- Visualisation: Compress to 2–3 components for scatter plots.
- Noise reduction: Later PCs capture noise more than signal; dropping them denoises data.
- Speeding up models: Reduce dimensionality before passing to expensive models.
- Multicollinearity removal: PCs are orthogonal by construction.
CAUTION
Always scale before PCA. A feature with variance 10,000 (e.g., income in rupees) will dominate the first principal component over a feature with variance 1 (e.g., number of children), regardless of which is more informative. After scaling, PCA treats all features equally.
11. scikit-learn Pipeline API
Intuition: A machine learning workflow consists of multiple sequential steps — missing value imputation, scaling, encoding, dimensionality reduction, and finally a model estimator. A Pipeline chains all these steps into a single composite estimator. It enforces strict separation between training and test sets, preventing data leakage automatically during cross-validation.
11.1 Why Pipelines Are Essential
Without a Pipeline, you have to manually call fit_transform() on training data and transform() on test/validation data for every single transformer. This leads to three major issues:
- Data Leakage: Manually scaling or imputing before
cross_val_scoreleaks test fold statistics into the training fold. - Code Duplication & Bugs: Forgetting to apply the exact same transformation sequence during test/inference leads to prediction errors.
- Complex Deployment: You have to save and load 5 different pickle files (imputer.pkl, scaler.pkl, encoder.pkl, model.pkl). A Pipeline packs everything into 1 pickle file.
11.2 Pipeline vs. make_pipeline
Pipeline requires explicit step names. make_pipeline automatically names steps based on their class names (lowercased).
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
# 1. Standard Pipeline (explicit names — best for GridSearchCV)
pipe1 = Pipeline([
('imputer', SimpleImputer(strategy='median')),
('scaler', StandardScaler()),
('model', LogisticRegression())
])
# 2. make_pipeline (shorthand — best for quick code)
pipe2 = make_pipeline(
SimpleImputer(strategy='median'),
StandardScaler(),
LogisticRegression()
)
# Automatically names steps: 'simpleimputer', 'standardscaler', 'logisticregression'11.3 Mixed Data Types: ColumnTransformer
Real-world datasets contain a mixture of numeric, nominal categorical, and ordinal categorical columns. ColumnTransformer applies separate transformation pipelines to specific column subsets.
from sklearn.compose import ColumnTransformer, make_column_selector
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder, OrdinalEncoder
from sklearn.ensemble import RandomForestClassifier
# Define column groups
num_cols = ['age', 'fare', 'family_size']
cat_cols = ['embarked', 'sex']
ord_cols = ['pclass'] # 1, 2, 3
# 1. Numeric pipeline
num_pipe = Pipeline([
('imputer', SimpleImputer(strategy='median')),
('scaler', StandardScaler())
])
# 2. Nominal categorical pipeline
cat_pipe = Pipeline([
('imputer', SimpleImputer(strategy='most_frequent')),
('ohe', OneHotEncoder(handle_unknown='ignore', sparse_output=False))
])
# 3. Ordinal categorical pipeline
ord_pipe = Pipeline([
('imputer', SimpleImputer(strategy='most_frequent')),
('ordinal', OrdinalEncoder(categories=[[3, 2, 1]])) # explicit ordering
])
# Combine into a ColumnTransformer
preprocessor = ColumnTransformer(
transformers=[
('num', num_pipe, num_cols),
('cat', cat_pipe, cat_cols),
('ord', ord_pipe, ord_cols)
],
remainder='drop' # 'drop': drop remaining cols; 'passthrough': keep unmentioned cols as-is
)
# Full end-to-end Pipeline
full_pipeline = Pipeline([
('preprocessor', preprocessor),
('classifier', RandomForestClassifier(n_estimators=100, random_state=42))
])
# Fit on training data, predict on test data
full_pipeline.fit(X_train, y_train)
y_pred = full_pipeline.predict(X_test)Dynamic Column Selection with make_column_selector
Instead of hardcoding column names, select columns dynamically by data type:
preprocessor = ColumnTransformer(
transformers=[
('num', num_pipe, make_column_selector(dtype_include=['int64', 'float64'])),
('cat', cat_pipe, make_column_selector(dtype_include=['object', 'category']))
]
)11.4 Parallel Extraction: FeatureUnion vs. ColumnTransformer
ColumnTransformer: Applies different transformers to different disjoint subsets of columns (non-overlapping).FeatureUnion: Applies multiple transformers to the SAME columns in parallel, and concatenates their outputs horizontally.
from sklearn.pipeline import FeatureUnion
from sklearn.decomposition import PCA
from sklearn.feature_selection import SelectKBest, f_classif
# Extract both PCA components AND top univariate features from the SAME numeric data
parallel_features = FeatureUnion([
('pca', PCA(n_components=5)),
('k_best', SelectKBest(score_func=f_classif, k=5))
])
# Output matrix will have 5 + 5 = 10 features11.5 Custom Transformers (BaseEstimator & TransformerMixin)
You can write your own custom transformation step (e.g. outlier clipping, log-transform, custom feature engineering) that integrates seamlessly into a scikit-learn Pipeline.
import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, TransformerMixin
class OutlierCapper(BaseEstimator, TransformerMixin):
"""Caps numerical features at specified upper and lower quantiles."""
def __init__(self, lower_quantile=0.01, upper_quantile=0.99):
self.lower_quantile = lower_quantile
self.upper_quantile = upper_quantile
def fit(self, X, y=None):
# Compute capping thresholds on training data ONLY
X_df = pd.DataFrame(X)
self.lower_bounds_ = X_df.quantile(self.lower_quantile).values
self.upper_bounds_ = X_df.quantile(self.upper_quantile).values
return self
def transform(self, X):
# Clip data using learned thresholds
X_array = np.asarray(X)
return np.clip(X_array, self.lower_bounds_, self.upper_bounds_)
# Use inside a Pipeline
custom_pipe = Pipeline([
('capper', OutlierCapper(lower_quantile=0.05, upper_quantile=0.95)),
('scaler', StandardScaler()),
('model', LogisticRegression())
])NOTE
Inheriting from
TransformerMixingives youfit_transform()for free. Inheriting fromBaseEstimatorgives youget_params()andset_params()for free, enablingGridSearchCVhyperparameter tuning on your custom transformer parameters!
11.6 Hyperparameter Tuning Inside Pipelines
Use double underscores __ to traverse nested pipeline steps in GridSearchCV or RandomizedSearchCV.
from sklearn.model_selection import GridSearchCV
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
# Define hyperparameter grid
param_grid = {
# Tune preprocessing parameters
'preprocessor__num__imputer__strategy': ['mean', 'median'],
'preprocessor__num__scaler__with_std': [True, False],
# Tune model parameters
'classifier__n_estimators': [100, 200],
'classifier__max_depth': [5, 10, None]
}
grid_search = GridSearchCV(full_pipeline, param_grid, cv=5, scoring='f1_weighted', n_jobs=-1)
grid_search.fit(X_train, y_train)
print("Best Parameters:", grid_search.best_params_)
print("Best CV Score:", grid_search.best_score_)Swapping Model Algorithms Dynamically
You can even pass different classifier instances into GridSearchCV to test multiple algorithms inside the same pipeline:
param_grid = [
{
'classifier': [RandomForestClassifier(random_state=42)],
'classifier__n_estimators': [100, 200],
'classifier__max_depth': [5, 10]
},
{
'classifier': [LogisticRegression(random_state=42)],
'classifier__C': [0.1, 1.0, 10.0],
'classifier__solver': ['lbfgs', 'saga']
}
]
grid_search = GridSearchCV(full_pipeline, param_grid, cv=5, scoring='f1_weighted')
grid_search.fit(X_train, y_train)11.7 Inspecting Pipeline Steps & Extracting Feature Names
Accessing Internal Steps
# Access fitted preprocessor step
fitted_preprocessor = full_pipeline.named_steps['preprocessor']
# Access fitted classifier step
fitted_model = full_pipeline.named_steps['classifier']
print("Tree feature importances:", fitted_model.feature_importances_)Getting Transformed Feature Names (get_feature_names_out)
After one-hot encoding, column names change. Extract the exact feature names output by the preprocessor:
# Get transformed feature names
feature_names = full_pipeline.named_steps['preprocessor'].get_feature_names_out()
print(feature_names)
# Output: ['num__age', 'num__fare', 'cat__embarked_C', 'cat__embarked_S', 'cat__sex_female', ...]
# Create a DataFrame of transformed training data for inspection
X_train_transformed = pd.DataFrame(
full_pipeline.named_steps['preprocessor'].transform(X_train),
columns=feature_names
)11.8 Target Transformation: TransformedTargetRegressor
A Pipeline only transforms features . It does not transform the target . If your target is right-skewed and needs a log-transform (), wrap your pipeline inside TransformedTargetRegressor:
import numpy as np
from sklearn.compose import TransformedTargetRegressor
from sklearn.linear_model import Ridge
# Automatically applies log1p to y during fit(), and expm1 to predictions during predict()
tt_model = TransformedTargetRegressor(
regressor=full_pipeline,
func=np.log1p,
inverse_func=np.expm1
)
tt_model.fit(X_train, y_train)
y_pred_original_scale = tt_model.predict(X_test) # predictions are automatically back on the original scale!11.9 Common Pipeline Pitfalls & Summary Checklist
| Rule | Explanation |
|---|---|
❌ Don’t transform target inside Pipeline | Use TransformedTargetRegressor instead. |
| ❌ Don’t drop rows inside a custom Transformer | Scikit-learn transformers must preserve the row count (). Dropping rows breaks downstream alignment. |
✅ Always return a 2D array or DataFrame from transform() | Scikit-learn expects 2D outputs (n_samples, n_features). |
✅ Pass sparse_output=False to OneHotEncoder | If combining with StandardScaler in ColumnTransformer, dense arrays avoid sparsity type conflicts. |
✅ Fit on X_train, transform on X_test | Never call .fit() or .fit_transform() on test data. pipe.predict(X_test) calls .transform(X_test) internally. |
12. Inference & Deployment Checklist
Once the final model is trained and evaluated:
- Retrain on full data (train + validation) before deployment if you used a hold-out val set.
- Save the fitted pipeline (not just the model — you need the scaler/imputer too).
import joblib joblib.dump(pipe, 'model_pipeline.pkl') loaded_pipe = joblib.load('model_pipeline.pkl') - Prediction on new data:
y_pred = loaded_pipe.predict(X_new) y_proba = loaded_pipe.predict_proba(X_new) # for probability scores - Check for distribution shift — monitor whether real-world input features start drifting from the training distribution.
- Calibration — if probabilities matter (not just class labels), check if
predict_probais well-calibrated using a calibration curve. UseCalibratedClassifierCVif needed.
Appendix — Legacy Notes
R-squared
is used to quantify how much of variance in the data is explained by a relationship.
One major drawback of is that it never decreases as we add more features/predictors to our model. This is why we use Adjusted R-squared. See full R² section above.
Gauss-Markov Assumptions
The Gauss–Markov assumptions are the conditions under which the Ordinary Least Squares (OLS) estimator is guaranteed to be BLUE (Best Linear Unbiased Estimator). These assumptions only work for OLS estimators and not all regression models.
- Linearity - The model must be linear in the coefficients.
- Homoscedasticity - must be constant for all values of .
- No autocorrelation in errors - .
- Normality of errors - . Not needed for BLUE, but needed for t-test, F-test.
- No perfect multicollinearity - must be invertible.
- Exogeneity - — omitted factors must not be correlated with features.
Variance Inflation Factor (VIF)
VIF checks for multicollinearity. Treat each feature as a dependent variable predicted by the others; compute ; then:
If or Tolerance , the feature is redundant.