Cross-Validation
Cross-validation estimates how a model generalises by training on rotating subsets of data. K-fold is the default; stratified for classification; time series needs its own scheme.
K-fold, stratified, GridSearchCV, leakage
EXAMPLE
import numpy as np
from sklearn.datasets import load_iris, fetch_california_housing
from sklearn.model_selection import (
train_test_split, cross_val_score, cross_validate,
KFold, StratifiedKFold, TimeSeriesSplit, GroupKFold,
GridSearchCV, RandomizedSearchCV,
)
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression, LinearRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report
# 1) Why cross-validation?
# A single train/test split gives one noisy estimate. CV reduces variance + lets you tune
# hyperparameters without contaminating the held-out test set.
# 2) Basic K-fold (regression)
X, y = fetch_california_housing(return_X_y=True)
pipe = Pipeline([
('scaler', StandardScaler()),
('lr', LinearRegression()),
])
scores = cross_val_score(pipe, X, y, cv=5, scoring='neg_mean_absolute_error')
print('MAE:', -scores.mean(), '±', scores.std())
# 3) Stratified K-fold (classification) — preserves class proportions
X, y = load_iris(return_X_y=True)
clf = Pipeline([('scaler', StandardScaler()), ('lr', LogisticRegression(max_iter=1000))])
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(clf, X, y, cv=skf, scoring='accuracy')
print(f'acc: {scores.mean():.3f} ± {scores.std():.3f}')
# 4) cross_validate — multiple metrics + train scores + fit times
results = cross_validate(
clf, X, y,
cv = skf,
scoring = ['accuracy', 'f1_macro', 'roc_auc_ovr'],
return_train_score = True,
n_jobs = -1,
)
print(results['test_accuracy'].mean())
print(results['fit_time'].sum())
# 5) Time-series split — no future leakage
# Standard K-fold shuffles → trains on future, tests on past. Use TimeSeriesSplit.
tscv = TimeSeriesSplit(n_splits=5, test_size=100, gap=0)
for train_idx, test_idx in tscv.split(X):
print(f'train: {train_idx.min()}..{train_idx.max()}, test: {test_idx.min()}..{test_idx.max()}')
scores = cross_val_score(model, X, y, cv=tscv, scoring='neg_mean_absolute_error')
# 6) Group K-fold — when rows aren't independent (multiple readings per patient)
groups = patient_ids # one group per patient
gkf = GroupKFold(n_splits=5)
for train_idx, test_idx in gkf.split(X, y, groups):
# Same patient never appears in both train and test
pass
scores = cross_val_score(model, X, y, groups=groups, cv=gkf, scoring='accuracy')
# 7) GridSearchCV — tune hyperparameters via nested CV
param_grid = {
'lr__C': [0.01, 0.1, 1, 10],
'lr__penalty': ['l2'],
}
grid = GridSearchCV(
clf, param_grid,
cv = StratifiedKFold(5, shuffle=True, random_state=42),
scoring = 'f1_macro',
n_jobs = -1,
return_train_score = True,
)
grid.fit(X, y)
print(grid.best_params_, grid.best_score_)
print(grid.cv_results_['mean_test_score'])
# 8) RandomizedSearchCV — when the grid is huge
from scipy.stats import uniform, randint
rand = RandomizedSearchCV(
RandomForestClassifier(random_state=42),
param_distributions = {
'n_estimators': randint(100, 1000),
'max_depth': randint(3, 30),
'min_samples_leaf': randint(1, 20),
'max_features': uniform(0.1, 0.9),
},
n_iter = 50,
cv = 5,
scoring = 'f1_macro',
n_jobs = -1,
random_state = 42,
)
rand.fit(X, y)
# 9) Avoid leakage — fit preprocessing INSIDE the pipeline, NOT before CV
# WRONG
X_scaled = StandardScaler().fit_transform(X) # uses TEST statistics → leakage
cross_val_score(LogisticRegression(), X_scaled, y, cv=5)
# RIGHT
pipe = Pipeline([('scaler', StandardScaler()), ('lr', LogisticRegression())])
cross_val_score(pipe, X, y, cv=5)
# The scaler is re-fit on each fold's train data.
# 10) Nested CV — unbiased estimate of tuned model performance
# Outer loop = evaluate; inner loop = tune
outer = StratifiedKFold(5, shuffle=True, random_state=42)
inner = StratifiedKFold(3, shuffle=True, random_state=43)
estimator = GridSearchCV(
pipe, param_grid, cv=inner, scoring='accuracy', n_jobs=-1,
)
scores = cross_val_score(estimator, X, y, cv=outer, scoring='accuracy', n_jobs=-1)
print(scores.mean(), scores.std())
# This is the FAIR estimate; grid.best_score_ alone is optimistic.
# 11) Stratification for regression — by binned target
from sklearn.model_selection import StratifiedKFold
bins = pd.qcut(y, q=10, labels=False)
for train_idx, test_idx in StratifiedKFold(5, shuffle=True, random_state=42).split(X, bins):
# Each fold has similar y distribution
pass
# 12) When NOT to use K-fold
# • Tiny dataset (< 100 samples) → use leave-one-out (LeaveOneOut())
# • Time series → TimeSeriesSplit
# • Grouped / repeated measurements → GroupKFold
# • Highly imbalanced → StratifiedKFold + custom split logic
# 13) Scoring options worth knowing
# Classification: 'accuracy', 'f1', 'f1_macro', 'roc_auc', 'roc_auc_ovr', 'average_precision', 'neg_log_loss'
# Regression: 'neg_mean_absolute_error', 'neg_mean_squared_error', 'r2'
# Multi-output / multilabel: append '_samples' / '_weighted'
# Custom scorer
from sklearn.metrics import make_scorer
def profit(y_true, y_pred):
return (y_pred * y_true).sum() - 0.1 * (y_pred * (1 - y_true)).sum()
cross_val_score(model, X, y, scoring=make_scorer(profit), cv=5)
# 14) Best practices
# • Always shuffle for I.I.D. data; never for time series
# • Use Pipelines — preprocessing inside, never outside CV
# • Stratify for classification (especially imbalanced)
# • Hold a separate test set after all CV / tuning — never touch until final
# • cv=5 is the practical default; cv=10 for small / noisy data
# • Set random_state for reproducible splits
# 15) Common pitfalls
# • Tuning + reporting on the same CV → optimistic score (use nested CV)
# • Scaling outside the pipeline → test leakage
# • K-fold on time series → future-into-past leakage
# • Stratification by class only, ignoring groups → person appears in both folds
Why it matters
Pipeline + cross_val_score is the leakage-proof default. Preprocessing inside the pipeline gets re-fit on each fold — fits TRAIN, applies to TEST. Skip this and the score is fiction.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
from sklearn.model_selection import cross_val_score
scores = cross_val_score(pipe, X, y, cv=5, scoring='f1_macro')
print('mean F1:', scores.mean(), '±', scores.std())
Try it Yourself »
Exercise
K-fold cross-validation entry point.
from sklearn.model_selection import
Snake case.
Discussion
Loading…