ml-models / Data and evaluation / Cross-validation core

Cross-validation

One train–test split gives one noisy number. Cross-validation reuses every row for both training and testing, in turn, to give a steadier estimate and a way to compare models. This page covers k-fold and its stratified, grouped and time-series variants, the variance of the estimate, and nested cross-validation for honest model selection.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. The splitters
  5. How noisy is a CV score?
  6. Choosing models: nested CV
  7. Rules that keep it honest
  8. Worked example
  9. Fold sizes
  10. Five folds of a nearest-centroid classifier
  11. The mean depends on the cuts
  12. Selection bias
  13. Implementation
  14. Tradeoffs
  15. Check yourself
  16. Connections
  17. Read first
  18. Read next
  19. References

Problem

A single holdout split of 60 rows leaves a handful of test rows, so the accuracy you measure depends heavily on which rows landed there. Hold out more and you train on less. Cross-validation (CV) splits the data into k parts, trains k times with a different part held out each time, and averages the k scores. Every row is tested exactly once and trained on k − 1 times.

The second problem is using the score to choose. Trying many models or settings and keeping the best CV score selects partly for luck, so the winning score is optimistic. Reporting an honest figure for the whole procedure, selection included, needs the nested form below.

Intuition

Think of k exams, each written by a student who has studied from every chapter except the one on that exam. Averaging the k grades says how well the student handles unseen chapters better than one exam would. How you cut the chapters matters, though: if related questions share a chapter, a student has effectively seen the answer, and if the exams run later than the study material, you must not let a student study from the future. Each splitter below is a rule about what counts as "unseen".

Three-fold cross-validation on ten rows Three rows of ten cells. In each row a different block of cells is the test fold and the remaining cells are training rows. The test folds have 4, 3 and 3 rows, and together cover every row exactly once. fold 1 0 1 2 3 4 5 6 7 8 9 fold 2 0 1 2 3 4 5 6 7 8 9 fold 3 0 1 2 3 4 5 6 7 8 9 highlighted = test fold, plain = training rows

The first n mod k folds get one extra row, so ten rows in three folds is 4, 3, 3.

Mechanics

The splitters

  • KFold: contiguous blocks, optionally shuffled first (shuffle=False by default). Plain k-fold assumes rows are independent and identically distributed.
  • StratifiedKFold: each fold keeps the class proportions of the whole set. Use it for classification, and above all when a class is rare, since an unlucky fold could otherwise contain none of it. On the labels 0 0 0 0 0 0 1 1 1 1 in two folds, each test fold gets 5 rows with exactly two positives.
  • GroupKFold: all rows of a group (a patient, a user, a document) land in the same fold, so no group is on both sides. The test folds on groups a a a b b c c d d e in three folds have sizes 3, 4, 3.
  • StratifiedGroupKFold combines both, as well as it can.
  • TimeSeriesSplit: the training set is always the past and the test fold the block right after it; the training window grows. With 12 rows and 3 splits the (train, test) sizes are (3, 3), (6, 3), (9, 3). Unlike k-fold, the folds are not a partition: the first block is never tested.
  • RepeatedKFold and ShuffleSplit repeat the procedure with different random cuts to average out the effect of any one cut.

How noisy is a CV score?

The k fold scores are not independent (the training sets overlap heavily), so the usual standard-error formula understates the uncertainty. The reliable guide is empirical: the spread of the fold scores shows how much a single fold can mislead, and repeating the CV with different shuffles shows how much the mean depends on where the cuts fell. Larger k gives training sets closer to the full data (less pessimistic bias) but folds that are more alike, and costs k fits. Five or ten is the usual compromise.

Choosing models: nested CV

Pick the best of many settings by CV score and that score is biased upward, because the maximum of many noisy estimates is larger than any one's true value. Nested CV wraps the whole selection in an outer loop: in each outer fold, an inner CV on the outer-training rows chooses the setting, the chosen model is refit on those rows and scored on the untouched outer test fold. The outer average estimates how well the procedure works. In scikit-learn this is a GridSearchCV (inner) passed as the estimator to cross_val_score (outer). The final model is then fit on all data using the same procedure.

Rules that keep it honest

  • Put every fitted step (scaling, imputation, feature selection) inside a Pipeline so it is refit within each training fold (see data leakage).
  • Split by the unit you will generalize over: patients, not visits; the future, not the past.
  • Keep a final test set the CV never touched if the number will be reported.
Nested cross-validation A large outer box holds all the data. An outer fold is held out as the test block. Inside the remaining outer-training rows, an inner cross-validation compares candidate settings and picks one, which is refit on the outer-training rows and scored on the held-out outer test block. The outer scores are averaged. outer loop: repeat for each outer fold outer-training rows inner CV compare settings pick the best refit chosen setting on all outer-training rows outer test fold scored once

The outer test fold plays no part in choosing the setting, so its score is not inflated by the choice.

ComponentShape/ParamsMeaningSource
KFold n_splits=5, shuffle=False, random_state=None Contiguous (or shuffled) blocks; the first n mod k folds are one row larger. _split.py
StratifiedKFold y: (n,) class labels Preserves class proportions in each fold. _split.py
GroupKFold groups: (n,) No group appears in both train and test; folds are balanced by group size. _split.py
StratifiedGroupKFold y and groups Groups kept whole while class proportions are matched as closely as possible. _split.py
TimeSeriesSplit n_splits=5, max_train_size, test_size, gap=0 Expanding past-only training window, then a later test block; gap skips rows between them. _split.py
cross_validate cv=None (5-fold), scoring, return_train_score=False, n_jobs Fits and scores per fold; returns a dict with fit_time, score_time and test_score arrays. _validation.py
cross_val_score estimator, X, y, cv, scoring The test scores only, one per fold. _validation.py
_fit_and_score one train/test index pair The per-fold worker: clones the estimator, fits on the train rows, scores the test rows. _validation.py
cross_val_predict returns (n,) out-of-fold predictions Each row predicted by a model that never saw it; for diagnostics and stacking, not a clean score. _validation.py

Worked example

Fold sizes

Ten rows in three folds: the test folds are 0, 1, 2, 3; 4, 5, 6; 7, 8, 9. Their sizes are 4, 3, 3 because 10 = 3 × 3 + 1, so one fold takes the extra row. Every index appears once as test and twice as training.

Five folds of a nearest-centroid classifier

The data are 60 points from two overlapping Gaussians (means −1 and +1, standard deviation 1.4). The classifier puts a cutoff halfway between the two class means of the training rows. The five fold accuracies are 0.75, 0.75, 0.75, 0.75, 0.917, so the CV estimate is 0.783 with a fold-to-fold standard deviation of 0.075. A single fold would have said anything from 0.750 to 0.917.

The mean depends on the cuts

Repeat the 5-fold CV with 100 different shuffles. The mean score ranges from 0.717 to 0.800, with mean 0.767 and standard deviation 0.017. With ten folds the spread of the mean is 0.013. The shuffle alone moves the headline number by a few points, which is why a 1-point difference between two models on one CV run means little.

Selection bias

Fifty candidate "models" that are pure coin flips are scored on 40 rows whose labels are also coin flips, so every candidate's true accuracy is 0.5. Keeping the best candidate's score on all the rows reports 0.677 on average (200 trials). Choosing the candidate on four fifths of the rows and scoring it on the held-out fifth, as the outer loop of nested CV does, gives 0.500. The first number is the best of 50 noisy estimates; the second is an honest estimate of a selection procedure that has no skill.

Implementation

from sklearn.model_selection import (
    KFold, StratifiedKFold, GroupKFold, TimeSeriesSplit,
    cross_val_score, cross_validate, GridSearchCV,
)
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

model = make_pipeline(StandardScaler(), LogisticRegression())

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
scores = cross_val_score(model, X, y, cv=cv)           # shape (5,)
print(scores.mean(), scores.std(ddof=1))

result = cross_validate(model, X, y, cv=cv,
                        scoring=["accuracy", "roc_auc"],
                        return_train_score=True)
result["test_roc_auc"]                                  # one per fold

cross_val_score(model, X, y, groups=patient_id, cv=GroupKFold(n_splits=5))
cross_val_score(model, X, y, cv=TimeSeriesSplit(n_splits=5))

# nested: the search is the estimator, the outer CV scores it
search = GridSearchCV(model, {"logisticregression__C": [0.01, 0.1, 1, 10]},
                      cv=KFold(5, shuffle=True, random_state=1))
nested = cross_val_score(search, X, y, cv=KFold(5, shuffle=True, random_state=2))

Passing an integer or None as cv builds the right splitter: stratified k-fold for a classifier with discrete targets, plain k-fold otherwise (via check_cv). Pass groups alongside a group-aware splitter; the splitter ignores it otherwise.

Tradeoffs

  • Choice of k. Small k trains on less data (pessimistic estimate) and has more overlap-free noise; large k is closer to unbiased but costs k fits and gives highly correlated folds. Leave-one-out is the extreme and is rarely worth its cost.
  • Cost. k fits per candidate, and nested CV is k_outer × k_inner × the number of candidates. Budget for it or reduce the grid.
  • Wrong splitter, optimistic score. Plain k-fold on grouped or time-ordered data leaks across the split and overstates performance, often by a lot.
  • Shuffling. Shuffle independent rows; never shuffle time series.
  • Reusing the CV score. The more choices you make by looking at it, the less it means. A final untouched test set, or nested CV, restores an honest number.
  • Large data. With millions of rows a single holdout is already stable and CV is an expense, not a need. For deep networks CV is usually skipped for the same reason.
  • The estimate is of a procedure, not a model. The k models differ from the final one trained on everything; the CV score describes how the recipe performs.

Check yourself

Nine rows in four folds: what are the test-fold sizes?

9 = 4 × 2 + 1, so one fold gets the extra row: 3, 2, 2, 2.

Rows come from 100 patients with about 20 visits each. Why use GroupKFold?

Visits from one patient are alike. With plain k-fold, a patient's other visits are in training and the model partly recognizes the patient, inflating the score; grouping ensures every test patient is wholly unseen.

You tuned five hyperparameters with 5-fold CV and report the best CV score. What is wrong?

That score was used to choose, so it is biased upward. Report a score from rows the selection never saw: nested CV or a held-out test set.

Connections

Read first

Read next

References

  • Hastie, Tibshirani and Friedman. The Elements of Statistical Learning, chapter 7: model assessment and selection.
  • Varma and Simon. Bias in error estimation when using cross-validation for model selection. BMC Bioinformatics, 2006.
  • Cawley and Talbot. On Over-fitting in Model Selection and Subsequent Selection Bias in Performance Evaluation. JMLR, 2010.
  • Bengio and Grandvalet. No Unbiased Estimator of the Variance of K-Fold Cross-Validation. JMLR, 2004.
  • scikit-learn 1.9.1: KFold, cross_validate.