Cross-validation (CV): scoring a recipe without depending on the luck of one split
| train | the model learns its parameters here | touched always |
| validation | chooses between models and hyperparameters | touched many times |
| test | simulates the future | touched once |
Pure noise: 20 random columns, a coin-flip target. No model can beat 50%. Train 100 trees with random hyperparameters and keep the best on validation.
Predict. The winner scores above 50% on validation. What will the same tree score on untouched data? Does the gap grow or shrink as you compare more configurations?
This is the winner's curse: the bias grows with the number of things you tried.
Predict. Only the random_state of the split changes. How far apart can two reported accuracies be? And two means of a 5-fold CV?
Same model, same data, 300 seeds of an 80/20 split:
| one hold-out | 93.0% to 100.0% |
| 5-fold CV mean | 96.8% to 98.4% |
CV greatly reduces the luck of the split; it cannot remove the luck of the sample — all folds come from the same rows.
Predict. With \(n = 80\) rows (\(n\) = number of rows), press Draw 100: which band is wider, the hold-out or the CV mean? Then press New dataset a few times: does the CV band stay on the white line?
Cut the training data into \(k\) folds; each round trains on the other \(k-1\) and scores on the one left out; report the mean and the standard deviation of the \(k\) scores.
The CV runs on X_train only: the test set stays locked away, as in the diagram.
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(pipe, X_train, y_train, cv=cv, scoring='f1')
print(f"{scores.mean():.3f} ± {scores.std():.3f}")
The splitters differ in which rows may land together in a validation fold.
| method | when to use | watch out for |
|---|---|---|
| K-Fold | regression, balanced data (with shuffle=True) | folds with no rows of a rare class |
| Stratified K-Fold | classification, especially imbalanced | only stratifies on the target |
| Leave-One-Out | very small datasets | \(n\) fits (\(n\) = number of rows); one row per fold: no per-fold std |
| TimeSeriesSplit | temporal data | early folds train on little; gap if the target looks ahead |
| Group K-Fold | grouped data (patients, users) | needs a group column |
Predict. 100 rows, 8 positives, KFold shuffled into 5 folds: how often does some fold get no positive at all?
Predict. If the scaler is fit on all of X before CV, will the score come out higher, lower or about the same? And if the step were a feature selector that looks at y?
# WRONG: the validation rows' statistics leak into training
X_scaled = StandardScaler().fit_transform(X)
cross_val_score(LogisticRegression(), X_scaled, y, cv=cv)
# RIGHT: each fold gets its own scaler
pipe = make_pipeline(StandardScaler(), LogisticRegression())
cross_val_score(pipe, X, y, cv=cv)
Pipeline, every step is fit only on the fold's training portion and applied frozen to its validation portion.stratify for classification.StratifiedKFold(5, shuffle=True, random_state=...).Pipeline, with every fitted step inside.cv object across candidates — the comparison becomes paired.Pace: steps 1 to 3 in 10 min; step 4 and its simulation in 12 min; steps 5 and 6 with the simulation in 13 min.