Skip to content
intermediate Phase 15 · Model Evaluation and Experiment Tracking

Cross-Validation Strategies

Rotate validation splits to estimate generalization on limited data.

55m
0 problems
Topic Progress 0%

One Holdout, One Lucky Score: Rotating the Test Set with K-Fold

You report 94.2% accuracy from your single 80/20 split. A teammate reruns with random_state=7 and reports 91.0%. Both numbers are real; neither is the answer. A single holdout is one draw from a sampling distribution, and on a 1,200-row dataset that distribution is wide enough that the gap between you is split luck rather than modeling skill.

K-fold cross-validation narrows it. Shuffle the data, cut it into k equal blocks, and run k evaluations: each block takes a turn as the test set while the other k minus 1 train the model. Every row is scored exactly once, every row trains on k minus 1 folds, and the reported number becomes a mean with a standard deviation attached.

from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import KFold, cross_validate

cv = KFold(n_splits=5, shuffle=True, random_state=42)
res = cross_validate(GradientBoostingClassifier(), X, y, cv=cv,
                     scoring=["accuracy", "f1"], return_train_score=True)

print(f"val  accuracy {res['test_accuracy'].mean():.3f} +/- {res['test_accuracy'].std():.3f}")
print(f"train accuracy {res['train_accuracy'].mean():.3f}")   # the gap is your variance signal

Choosing k is a bias-variance trade-off on the estimate itself:

k Train fraction Runs Character
2 50% 2 fast smoke test; high bias, trains on far less data than the final model
5 80% 5 the default: stable enough, affordable, close to what the deployed model sees
10 90% 10 lower bias, twice the cost, each test fold only 50 rows so per-fold scores get noisier
LOOCV n - 1 n near-zero bias, n trainings, highly correlated folds make the estimate surprisingly noisy

Two subtleties worth saying out loud. First, each fold trains on less data than the final model will have, so the CV mean slightly underestimates the deployed model; expect production to land a little above the fold average. Second, the fold-to-fold standard deviation is the number to argue about: 94.2% +/- 0.4 and 94.2% +/- 3.1 are different claims about the same model, and only one of them is ready for a launch review.

Practical discipline follows. Pin random_state so the fold assignment is reproducible, and reuse the same folds for every candidate you compare. Model A on one split versus model B on another measures the models plus the split; both on identical folds is a paired experiment, and paired comparisons detect small real differences with far fewer samples.

Do the cost arithmetic before you commit to a protocol: 5 folds times 4 hyperparameter configurations times 2 model families is 50 fits, and each fit costs a full training run. When one run takes two hours, that is a hundred-hour model-selection job, so the standard move is cheaper search on subsampled data or 3 folds during tuning, then 5 or 10 folds once for the finalists.

One boundary matters: cross-validation does not give you a test set. Every row trains a model k minus 1 times, so the moment you use CV scores to pick a winner, the winner's score is optimistic. Keep a final untouched holdout - or a later time period - for the single number you quote outside the team, and when data is too scarce to spare one, nested cross-validation (an outer loop estimating performance, an inner loop tuning) restores the separation rigorously.

That is also the harness that keeps the many hyperparameters of Transformer Architecture Overview honest - every architecture claim needs the same rotating folds behind it. Whatever number comes out still carries noise, and Bias-Variance and Error Analysis is how you read that noise.

Rare Classes and Repeated Patients: Stratified and Grouped Splits, and Where Each Leaks

Two data properties break plain KFold the moment they appear, and each has a dedicated splitter. Using the wrong one does not produce a slightly noisy score; it produces a confidently wrong one.

Class imbalance. Fraud sits at 0.4% of transactions. Shuffle-and-cut KFold can hand one fold two positives and another forty, so on the two-positive fold a single prediction swings recall from 0.0 to 1.0. StratifiedKFold keeps each class proportion identical across folds by distributing each class members round-robin:

from sklearn.model_selection import StratifiedKFold
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for tr, te in skf.split(X, y):
    print(y[tr].mean().round(4), y[te].mean().round(4))   # both about 0.004

Interview caveats: stratification assumes test folds should mirror the training distribution - true for classification, misleading when you are deliberately evaluating under drift. Multi-label targets need iterative stratification, not plain StratifiedKFold. And a class with fewer than k members cannot appear in every fold; sklearn will tell you so rather than silently mislead.

Repeated entities. One patient contributes 12 visits, one user contributes 500 events, one video yields 30 clips. Rows from the same entity are near-duplicates: demographics constant, device constant, style constant. Scatter them across folds and the model recognizes the entity instead of learning the phenomenon, so the test score measures memorization. GroupKFold keeps every row of an entity in one fold:

from sklearn.model_selection import StratifiedGroupKFold
sgk = StratifiedGroupKFold(n_splits=5, shuffle=True, random_state=42)
for tr, te in sgk.split(X, y, groups=patient_id):
    assert set(patient_id[tr]).isdisjoint(patient_id[te])   # no patient leaks

The assertion above belongs in your test suite: it costs one line and catches the silent failure mode where a pipeline refactor drops the groups argument from split and quietly reverts to row-level leakage. Print the group counts per fold too - a fold holding 3 of the 62 patients tells you how few entities carry that fold's estimate.

What the premium of leakage buys is measurable: take 500 rows coming from 62 patients, score the model with a row-level split, then score it again with patients held out. A move from 0.94 to 0.78 is the memorization premium - what the metric gained by recognizing entities rather than learning the phenomenon. Quote that difference when someone objects that the corrected evaluation made the model worse: nothing about the model changed, the evaluation stopped crediting it for abilities it will not have in production.

Which splitter for which dependency:

Data structure Splitter Leakage prevented
Imbalanced classes StratifiedKFold folds that miss the rare class entirely
Repeated users, patients, documents GroupKFold / GroupShuffleSplit the same entity on both sides of the split
Molecular scaffolds scaffold or StratifiedGroupKFold near-duplicate structures inflating chemistry scores
Time order TimeSeriesSplit training rows that postdate the test rows
Spatial regions GroupKFold on region id spatial autocorrelation flattering the score

The scaffold row has published evidence: on MoleculeNet benchmarks random splits routinely report several points higher than scaffold splits on identical data, because structurally related molecules land on both sides. The general rule is simple to state: identify what one row shares with another, then forbid that shared thing from crossing the split - target value, entity id, timestamp, structure, or geography.

Choose your splitter first, then treat the resulting mean and standard deviation as one measurement. After you have fixed the split design, the next question is whether the resulting number is stable or lucky - that is what Bias-Variance and Error Analysis tells you.

Leave-One-Out and the Time-Series Trap: Splits That Respect Order

Leave-one-out cross-validation sets k = n: each evaluation holds exactly one row out. Its appeal is training size - the model trains on n minus 1 rows, essentially the data it will finally receive, so bias is near zero. Its cost is n training runs, and its dirty secret is that any two training sets overlap in n minus 2 rows. Those fold errors are so highly correlated that LOOCV's estimate can be noisier than 5-fold. Use it when n is genuinely small (30 experimental samples, each one expensive to spare) and never to squeeze a marginal result into significance.

Leave-one-group-out is the useful sibling: one fold per group, every group evaluated once. With 6 countries and a requirement to report per-country generalization, GroupKFold shuffles groups into folds; LOGO gives the honest per-country number instead. The trade is transparent: with 30 groups you get 30 cheap folds, with 3 groups you get 3 expensive and extremely coarse estimates, and below about 5 groups you should report per-group results individually rather than pretending the average means anything.

LOOCV's variance trap deserves its own intuition. Because the n training sets overlap in n minus 2 rows, a hard-to-fit example poisons nearly every fold at once: it is the test row once and a training row everywhere else, so all n errors move together instead of independently. The standard error of the mean does not shrink like 1 over sqrt(n) the way the run count suggests. Five-fold trades a small bias (each fold trains on 80% rather than n minus 1) for far more independent error estimates, which is why it usually wins.

Time series is where imprecise splits turn into wrong ones. Two failures, escalating:

  1. KFold(shuffle=True) trains on 2026 rows to predict 2024 rows - the model has seen the answer key.
  2. KFold(shuffle=False) avoids shuffling but still trains on the future whenever the test fold precedes part of the training data (test = rows 0-99, train = 100-999).

The correct shape is an expanding window: train only on history, test on the next block, roll forward.

from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5, gap=7)      # 7-row embargo between train and test
for i, (tr, te) in enumerate(tscv.split(X)):
    print(f"fold {i}: train {tr[0]}..{tr[-1]}   test {te[0]}..{te[-1]}")
# fold 0: train 0..165   test 173..206
# fold 4: train 0..837   test 845..878

The gap argument earns its keep: if your features are 7-day aggregates or the label horizon spans a week, rows adjacent to the cut share information with training rows. Purged cross-validation goes further - drop any training row whose label window overlaps a test row, and add an embargo of extra rows around each cut. In finance this is not pedantry; overlapping labels are exactly how a backtest becomes fiction.

Selection table:

Situation Splitter Failure it prevents
Small n, expensive rows LeaveOneOut throwing away scarce training data
Few groups, report each one LeaveOneGroupOut group-level overconfidence from shuffled groups
Ordered observations TimeSeriesSplit training on the future
Ordered plus windowed features TimeSeriesSplit(gap=...) or purged CV feature and label windows crossing the cut

Report the mean with the standard deviation, state the split strategy next to the number, and be ready for the follow-up question: how would your score change under a different split? If you cannot answer, you do not yet know your model's performance - and the follow-on diagnosis of that uncertainty lives in Bias-Variance and Error Analysis.

Quiz

1. A 1,200-row dataset has a 0.5% positive class. Which evaluation gives the most defensible single number?

Question 1 options

2. Each of 500 rows is a lab visit and patients average 8 visits. Which statement is accurate?

Question 2 options

3. You evaluate a churn model built on January to June data. Which design leaks the future?

Question 3 options

4. Why can 10-fold CV give a less reliable estimate than 5-fold on a 500-row dataset?

Question 4 options

Flashcards

Question

What does 5-fold cross-validation produce, and what two numbers should you report from it?

Answer

Five evaluations in which each fold serves as the test set once while the other four train the model, so every row is scored exactly once. Report the mean of the fold scores together with the fold-to-fold standard deviation - the spread is the claim, not just the average.

Question

When is StratifiedKFold required, and when is stratifying misleading?

Answer

Required for imbalanced classification so every fold carries the class proportion and the rare class never vanishes from a test fold. Misleading when you are evaluating under distribution drift, since mirroring training proportions hides the shift, and unusable for multi-label targets, which need iterative stratification.

Question

What leakage does GroupKFold prevent, and how do you verify it in code?

Answer

It keeps all rows from one entity (patient, user, document, video) inside a single fold so the model cannot score well by recognizing an entity it trained on. Verify with an assertion that the group sets of train and test indexes are disjoint.

Question

Why does plain KFold fail on time-ordered data, and what replaces it?

Answer

With shuffling, training rows postdate the test rows, so the model trains on the future; without shuffling, folds still place the future in training whenever the test block precedes it. TimeSeriesSplit with an expanding window fixes this, and a gap or purging prevents windowed features and label horizons from crossing the cut.

Question

What are the trade-offs of leave-one-out cross-validation?

Answer

Bias is near zero because training uses n-1 rows, close to the final model. Cost is n training runs, and because training sets overlap in n-2 rows the fold errors are highly correlated, so the estimate can be noisier than 5-fold; justified only when n is small and each row is scarce.

Revision Notes

Key Takeaways

  • 1. A single holdout is one draw from a wide sampling distribution; k-fold rotates the test set so every row is scored once and the result carries a mean plus a standard deviation
  • 2. Each fold trains on less data than the final model, so the CV mean slightly underestimates production, while the fold-to-fold standard deviation is the number that decides launch readiness
  • 3. Stratified folds protect class proportions; grouped folds keep one entity on one side - identify what a row shares with another row and forbid it from crossing the split
  • 4. Random splits on time-ordered data train on the answer key; use expanding windows with a gap or purge when features and labels span time
  • 5. LOOCV buys near-zero bias at n runs but its nearly identical training sets correlate the fold errors, so 5-fold often estimates more stably

Interview Tips

  • • "Your model scores 94% - on what?" - answer with splitter, k, shuffle and seed, mean and standard deviation; candidates who quote a lone number without a split strategy get probed next
  • • "How would you evaluate a model on user-level data?" - GroupKFold by user id with a disjointness assertion; mention that row-level splitting measures memorization of seen users
  • • "Why is random cross-validation wrong for a forecasting model?" - future rows enter training; show TimeSeriesSplit output and name the gap for windowed features or overlapping label horizons
  • • "5-fold or 10-fold?" - trade-off framing: 10-fold trains on 90% so less bias, but smaller test folds and double the compute; say you default to 5 and lower k only when data is scarce

Cheat Sheet

Cross-Validation Strategies

Pick the splitter from the data's dependency structure:

If rows share... Splitter Leakage prevented
class imbalance only StratifiedKFold rare class missing from a fold
an entity id (patient/user/doc) GroupKFold / GroupShuffleSplit same entity on both sides
a scaffold/structure scaffold split near-duplicate molecules across folds
timestamps TimeSeriesSplit training on the future
geography GroupKFold on region spatial autocorrelation
windowed features/labels TimeSeriesSplit(gap=...) or purged CV windows crossing the cut

k selection: 5 folds is the default (80% train, 5 runs); 10 lowers bias but halves test-fold size; 2 is a cheap smoke test; LOOCV is near-zero bias, n runs, correlated folds.

Report: mean +/- std over folds, the split strategy beside the number, and the same fold assignment for every candidate you compare.

Baselines first:

from sklearn.model_selection import cross_validate, StratifiedGroupKFold
res = cross_validate(model, X, y, cv=StratifiedGroupKFold(5, shuffle=True, random_state=42),
                     scoring=["accuracy", "f1"], return_train_score=True)
print(res["test_f1"].mean(), res["test_f1"].std(), res["train_f1"].mean())

Continue Learning