Cross-Validation Done Properly
Stratified, grouped and time-ordered folds, and the nested version
You have used cross_val_score since week 4 without asking how the folds are made. That choice is where most silently broken evaluations come from.
Why one split is not enough
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'churned'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
def preprocessor():
return ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), NUM),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), CAT),
])
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
import numpy as np
X, y = df[NUM + CAT], df[TARGET]
scores = []
for seed in range(12):
a, b, c, d = train_test_split(X, y, test_size=0.25, stratify=y,
random_state=seed)
m = Pipeline([('prep', preprocessor()),
('clf', LogisticRegression(max_iter=1000))]).fit(a, c)
scores.append(roc_auc_score(d, m.predict_proba(b)[:, 1]))
scores = np.array(scores)
print('twelve different random splits of the same data:')
print(' min %.4f max %.4f spread %.4f'
% (scores.min(), scores.max(), scores.max() - scores.min()))
print(' mean %.4f std %.4f' % (scores.mean(), scores.std()))
min 0.8030 max 0.8364 spread 0.0334
mean 0.8207 std 0.0108
Two points of AUC, from nothing but the seed
Same data, same model, same code, only the split changed. If you compare two models on one split each and they differ by less than this spread, you have measured the seed, not the models. Reporting a single split's score to four decimal places implies a precision that does not exist.
The splitters, and when each is required
| Splitter | Use when | Failure if you use KFold instead |
|---|---|---|
KFold | Rows are independent and balanced | - |
StratifiedKFold | Classification, any imbalance | Folds get different class rates; scores wobble |
GroupKFold | Rows cluster by entity | The same entity lands in train and test, leakage |
TimeSeriesSplit | Rows are ordered in time | The model trains on the future |
RepeatedStratifiedKFold | You need a tighter estimate | - |
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'churned'
from sklearn.model_selection import KFold, StratifiedKFold
import numpy as np
y = df[TARGET].to_numpy()
print('overall churn rate %.4f\n' % y.mean())
for name, splitter in [('KFold ', KFold(5, shuffle=True, random_state=0)),
('StratifiedKFold', StratifiedKFold(5, shuffle=True,
random_state=0))]:
rates = [y[test].mean() for _, test in splitter.split(np.zeros(len(y)), y)]
print('%s fold churn rates %s spread %.4f'
% (name, np.round(rates, 4), max(rates) - min(rates)))
KFold fold churn rates [0.265 0.2733 0.25 0.2933 0.2583] spread 0.0433
StratifiedKFold fold churn rates [0.2683 0.2683 0.2683 0.2683 0.2667] spread 0.0017
Grouped data, where the trap is worst
import pandas as pd
from sklearn.model_selection import cross_val_score, KFold, GroupKFold
from sklearn.ensemble import RandomForestClassifier
rng = np.random.default_rng(0)
n_customers, per_customer = 200, 5
# Five readings per customer. The label belongs to the customer, not the
# row, and each reading is that customer's traits plus a little noise --
# which is exactly what repeated measurement of one entity looks like.
customer = np.repeat(np.arange(n_customers), per_customer)
label = np.repeat(rng.integers(0, 2, n_customers), per_customer)
traits = np.repeat(rng.normal(size=(n_customers, 4)), per_customer, axis=0)
X = traits + rng.normal(0, 0.02, traits.shape)
clf = RandomForestClassifier(n_estimators=100, random_state=0)
naive = cross_val_score(clf, X, label, cv=KFold(5, shuffle=True, random_state=0))
grouped = cross_val_score(clf, X, label, cv=GroupKFold(5), groups=customer)
print('KFold accuracy %.4f <- the same customer is in both halves'
% naive.mean())
print('GroupKFold accuracy %.4f <- honest' % grouped.mean())
GroupKFold accuracy 0.5880 <- honest
Ninety-nine percent, and the model has learned nothing
Forty-one points of pure illusion. Repeated measurements of one patient, several sessions from one user, multiple photographs of one object, split at random and near-duplicates of every test row sit in the training set. The model does not learn the pattern; it memorises which customer each reading came from and looks up their label. GroupKFold keeps a customer's rows together and reveals the truth: 0.59, barely above chance.
This is the most expensive mistake in this course, because the model looks superb right up until it meets someone new. Whenever rows cluster by an entity, pass groups.
Nested cross-validation
If you tune hyperparameters by cross-validation and then report that same cross-validated score, the score is optimistic: you chose the settings that happened to suit those folds.
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'churned'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
def preprocessor():
return ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), NUM),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), CAT),
])
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import (GridSearchCV, cross_val_score,
StratifiedKFold)
import numpy as np
X, y = df[NUM + CAT], df[TARGET]
grid = {'clf__min_samples_leaf': [1, 5, 20, 50]}
pipe = Pipeline([('prep', preprocessor()),
('clf', RandomForestClassifier(n_estimators=150,
random_state=42))])
inner = StratifiedKFold(4, shuffle=True, random_state=1)
outer = StratifiedKFold(4, shuffle=True, random_state=2)
search = GridSearchCV(pipe, grid, cv=inner, scoring='roc_auc')
search.fit(X, y)
print('best inner score (optimistic) %.4f' % search.best_score_)
nested = cross_val_score(search, X, y, cv=outer, scoring='roc_auc')
print('nested score (honest) %.4f +/- %.4f'
% (nested.mean(), nested.std()))
nested score (honest) 0.8176 +/- 0.0122
The nested figure re-runs the entire search inside each outer fold, so the settings are never chosen using the rows they are scored on. It costs inner times outer fits, which is why people skip it, but when you need to report a number you will be held to, this is the number.