Skip to content

K-Fold Cross-Validation

What you’ll learn

  • how much a reported score moves purely by changing the random seed — measured
  • the k-fold procedure, and why every row is validated exactly once
  • how to choose kk, with the bias-variance argument behind the usual answer of 5 or 10
  • the standard deviation across folds, which is the number people forget to report
  • four splitters — plain, stratified, grouped and time-series — and when each is mandatory
  • cross_validatecross_validate for several metrics and train scores in one pass

Intuition

You split 80/20, train, score, and report 0.9737.

Change random_staterandom_state from 0 to 1 and rerun. Now it is 0.9561. Neither number is wrong; both are measurements of the same model on different samples of the same data. Reporting either alone presents a coin flip as a fact.

Cross-validation replaces one measurement with kk of them, each on a different fifth of the data, and reports the mean. It also gives you something a single split never can: a spread, which is how you know whether a difference between two models is real.

figure5-fold cross-validationmatplotlib
Five rows of forty cells each. In each row a different contiguous fifth is amber (validation) and the rest blue (training), with a score label on the right.Five rows of forty cells each. In each row a different contiguous fifth is amber (validation) and the rest blue (training), with a score label on the right.
Every row is validated exactly once and trained on four times. Five scores come out, and both their mean and their spread are meaningful.

How much does a single split move?

figureThe same model and data, 60 different random seedsmatplotlib
Two overlaid histograms of reported accuracy across 60 random seeds. The single-split distribution is wide, spanning 0.947 to 1.000; the 5-fold mean distribution is narrow, spanning 0.968 to 0.984.Two overlaid histograms of reported accuracy across 60 random seeds. The single-split distribution is wide, spanning 0.947 to 1.000; the 5-fold mean distribution is narrow, spanning 0.968 to 0.984.
A single 80/20 split reports anywhere from 0.9474 to 1.0000 — a spread of 5.3 points. The 5-fold mean spans 0.9684 to 0.9842, and its standard deviation is 3.5 times smaller.
MeanStd devObserved range
Single 80/20 split0.97850.01180.9474 – 1.0000
5-fold CV mean0.97730.00340.9684 – 0.9842

Reading the plot

  1. The single split reaches 1.0000 on some seeds. Perfect accuracy, from a model that averages 0.978. Pick that seed and report it and you are not lying, exactly — but nobody could reproduce it.
  2. The spread is 5.3 points wide. Two models differing by 2 points cannot be distinguished on a single split of this dataset.
  3. CV reduces the standard deviation by 3.5×. Not by being cleverer, but by averaging five measurements instead of taking one — the same 1/k1/\sqrt{k} that governs any mean.
  4. The means agree (0.9785 against 0.9773). CV is not more optimistic or more pessimistic; it is more precise.

The math

Given kk fold scores s1,,sks_1, \ldots, s_k:

sˉ=1ki=1ksisd=1ki=1k(sisˉ)2\bar{s} = \frac{1}{k}\sum_{i=1}^{k} s_i \qquad \text{sd} = \sqrt{\frac{1}{k}\sum_{i=1}^{k}\left(s_i - \bar{s}\right)^2}

Report both. “0.85 ± 0.07” and “0.85 ± 0.005” describe very different situations, and only the first tells you a rival at 0.87 might be noise.

Choosing k

kk trades three things against each other:

kkTraining set per foldBias of the estimateVariance of the estimateCost
250%High — trained on half the dataLow2 fits
580%SmallModerate5 fits
1090%SmallerModerate10 fits
nn (leave-one-out)n1n-1Almost noneHighnn fits

Small kk means each model trains on less data, so it underperforms the model you will actually ship — a pessimistic bias. Large kk removes that bias, but the kk training sets then overlap almost completely, so their scores are highly correlated and averaging them removes less noise than you would expect.

Leave-one-out is the surprise. It sounds like the gold standard and is usually a poor choice: nn fits, and nn nearly identical models, so the variance of the estimate stays high. Use it only when nn is tiny.

5 or 10 is the answer for almost everything, and 5 is fine.

Worked example by hand

Five fold scores from a classifier:

[0.80,  0.90,  0.85,  0.95,  0.75][\,0.80,\; 0.90,\; 0.85,\; 0.95,\; 0.75\,]

Step 1 — the mean.

sˉ=0.80+0.90+0.85+0.95+0.755=4.255=0.85\bar{s} = \frac{0.80 + 0.90 + 0.85 + 0.95 + 0.75}{5} = \frac{4.25}{5} = 0.85

Step 2 — the deviations and their squares.

foldsis_isisˉs_i - \bar{s}(sisˉ)2(s_i - \bar{s})^2
10.80−0.050.0025
20.90+0.050.0025
30.850.000.0000
40.95+0.100.0100
50.75−0.100.0100
0.000.0250

Step 3 — the standard deviation.

sd=0.02505=0.0050.0707\text{sd} = \sqrt{\frac{0.0250}{5}} = \sqrt{0.005} \approx 0.0707

Step 4 — report it. 0.850±0.0710.850 \pm 0.071.

Step 5 — read it. The folds range from 0.75 to 0.95, a 20-point swing on the same model. A rival model scoring 0.87 is not better on this evidence. If you need to distinguish models at that resolution, you need more data or more folds — not a bolder claim.

In code

basic_cv.py
from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
 
X, y = load_breast_cancer(return_X_y=True)
 
# The pipeline matters: the scaler is refitted inside each fold
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000))
 
scores = cross_val_score(model, X, y, cv=5)
print(scores.round(4))                    # one score per fold
print(f"{scores.mean():.4f} +/- {scores.std():.4f}")
basic_cv.py
from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
 
X, y = load_breast_cancer(return_X_y=True)
 
# The pipeline matters: the scaler is refitted inside each fold
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000))
 
scores = cross_val_score(model, X, y, cv=5)
print(scores.round(4))                    # one score per fold
print(f"{scores.mean():.4f} +/- {scores.std():.4f}")

Several metrics, and train scores, in one pass

cross_validate.py
from sklearn.model_selection import cross_validate
 
results = cross_validate(
    model, X, y, cv=5,
    scoring=["accuracy", "precision", "recall", "roc_auc"],
    return_train_score=True,
)
 
for name in ("accuracy", "precision", "recall", "roc_auc"):
    train = results[f"train_{name}"].mean()
    test = results[f"test_{name}"].mean()
    print(f"{name:<10} train {train:.4f}   test {test:.4f}   gap {train - test:+.4f}")
 
# accuracy   train 0.9895   test 0.9807   gap +0.0088
# precision  train 0.9862   test 0.9782   gap +0.0080
# recall     train 0.9972   test 0.9916   gap +0.0056
# roc_auc    train 0.9977   test 0.9952   gap +0.0025
cross_validate.py
from sklearn.model_selection import cross_validate
 
results = cross_validate(
    model, X, y, cv=5,
    scoring=["accuracy", "precision", "recall", "roc_auc"],
    return_train_score=True,
)
 
for name in ("accuracy", "precision", "recall", "roc_auc"):
    train = results[f"train_{name}"].mean()
    test = results[f"test_{name}"].mean()
    print(f"{name:<10} train {train:.4f}   test {test:.4f}   gap {train - test:+.4f}")
 
# accuracy   train 0.9895   test 0.9807   gap +0.0088
# precision  train 0.9862   test 0.9782   gap +0.0080
# recall     train 0.9972   test 0.9916   gap +0.0056
# roc_auc    train 0.9977   test 0.9952   gap +0.0025

return_train_score=Truereturn_train_score=True gives you the overfitting diagnosis from Underfitting vs Overfitting for free, on every metric, in the same call. Gaps under 0.01 here — a well-behaved model.

See it move

The validation block rotates through the dataset. Each pass produces one score; the running mean and spread build up on the right.

sketch Five folds, five scores, one estimate p5.js
The amber validation block rotates through the rows. Each fold contributes a score, and the running mean plus spread settle as the folds accumulate.

Four splitters

figureBlue trains, amber validatesmatplotlib
Four stacked strips showing row assignment for KFold, StratifiedKFold, GroupKFold and TimeSeriesSplit. TimeSeriesSplit's training portion grows and validation always sits immediately after it.Four stacked strips showing row assignment for KFold, StratifiedKFold, GroupKFold and TimeSeriesSplit. TimeSeriesSplit's training portion grows and validation always sits immediately after it.
KFold takes contiguous blocks. StratifiedKFold preserves the class ratio. GroupKFold keeps whole groups together. TimeSeriesSplit never lets training data come from after validation data.
SplitterGuaranteesMandatory when
KFoldKFoldEvery row validated onceThe default, for i.i.d. data
StratifiedKFoldStratifiedKFoldClass ratio preserved per foldAny classification, especially imbalanced
GroupKFoldGroupKFoldA group never spans foldsRepeated measures — patients, users, devices
TimeSeriesSplitTimeSeriesSplitTraining always precedes validationAny temporal ordering

Choosing between them is mechanical once you know what your rows are:

diagram Diagram mermaid

Two of those branches are guards against leakage rather than preferences, which is why the table calls them mandatory: a group spanning folds and a future row in the training set both produce a score that is higher than the truth and gives no warning.

Time series

A random split lets the model train on Thursday to predict Tuesday. The score is excellent and entirely meaningless.

time_series_cv.py
from sklearn.model_selection import TimeSeriesSplit
 
splitter = TimeSeriesSplit(n_splits=4)
for i, (train_idx, val_idx) in enumerate(splitter.split(range(20))):
    print(f"fold {i}: train {train_idx.min()}-{train_idx.max()}"
          f"   validate {val_idx.min()}-{val_idx.max()}")
 
# fold 0: train 0-3    validate 4-7
# fold 1: train 0-7    validate 8-11
# fold 2: train 0-11   validate 12-15
# fold 3: train 0-15   validate 16-19
time_series_cv.py
from sklearn.model_selection import TimeSeriesSplit
 
splitter = TimeSeriesSplit(n_splits=4)
for i, (train_idx, val_idx) in enumerate(splitter.split(range(20))):
    print(f"fold {i}: train {train_idx.min()}-{train_idx.max()}"
          f"   validate {val_idx.min()}-{val_idx.max()}")
 
# fold 0: train 0-3    validate 4-7
# fold 1: train 0-7    validate 8-11
# fold 2: train 0-11   validate 12-15
# fold 3: train 0-15   validate 16-19

The training window grows and validation always sits immediately after it. Note the consequence: fold 0 trains on 4 rows and fold 3 on 16, so the folds are not comparable to each other. That is the price of respecting time.

Groups

One row per hospital visit, several visits per patient. A random split puts the same patient on both sides, the model recognises the patient rather than the condition, and the score is inflated.

group_cv.py
import numpy as np
from sklearn.model_selection import GroupKFold, cross_val_score
 
groups = np.repeat(np.arange(100), 5)      # 100 patients, 5 visits each
 
naive = cross_val_score(model, X_visits, y_visits, cv=5).mean()
honest = cross_val_score(model, X_visits, y_visits,
                         cv=GroupKFold(5), groups=groups).mean()
 
print(f"random split: {naive:.4f}")        # optimistic
print(f"group split:  {honest:.4f}")       # the number that transfers
group_cv.py
import numpy as np
from sklearn.model_selection import GroupKFold, cross_val_score
 
groups = np.repeat(np.arange(100), 5)      # 100 patients, 5 visits each
 
naive = cross_val_score(model, X_visits, y_visits, cv=5).mean()
honest = cross_val_score(model, X_visits, y_visits,
                         cv=GroupKFold(5), groups=groups).mean()
 
print(f"random split: {naive:.4f}")        # optimistic
print(f"group split:  {honest:.4f}")       # the number that transfers

Pitfalls

quizCheck yourself
  1. On this dataset a single 80/20 split reports between 0.9474 and 1.0000 depending on the seed. What does that imply?

    Show answer

    B — A single split cannot distinguish two models differing by less than about 5 points, so its score should never be reported alone — The model is fine; the measurement is noisy. Averaging five folds cuts that noise by 3.5 times and yields a number that reproduces.

  2. Why is leave-one-out cross-validation usually a poor choice despite its low bias?

    Show answer

    B — The n training sets overlap almost completely, so the n scores are highly correlated and averaging them removes little noise — at n times the cost — Low bias, high variance, high cost. Five or ten folds give a better estimate for a fraction of the compute.

  3. You have several rows per patient. Which splitter do you need?

    Show answer

    C — GroupKFold with patient as the group — Without grouping, a patient's other visits sit in the training set and the model can identify the patient rather than learn the condition. GroupKFold keeps every patient wholly on one side.

  4. Your five folds score 0.80, 0.90, 0.85, 0.95 and 0.75. A rival model reports 0.87. What can you conclude?

    Show answer

    B — Nothing — your own folds span 20 points with a standard deviation of 0.071, so 0.87 is well inside the noise — 0.850 ± 0.071 comfortably contains 0.87. Distinguishing models at that resolution needs more data or more folds, not a bolder claim.

🧪 Try It Yourself

Exercise 1 – Mean and spread by hand

Exercise 2 – Run 5-fold cross-validation

Exercise 3 – Measure the noise a single split adds

Exercise 4 – Several metrics in one pass

Exercise 5 – Respect the arrow of time

Recap

  • A single 80/20 split on this dataset reports anywhere from 0.9474 to 1.0000 across seeds. The 5-fold mean spans 0.9684 to 0.9842, with 3.5 times less spread.
  • Every row is validated exactly once; you get kk scores, a mean and a standard deviation.
  • Report both. 0.850±0.0710.850 \pm 0.071 and 0.850±0.0050.850 \pm 0.005 demand different conclusions.
  • k=5k = 5 or 10. Leave-one-out is expensive and, because its training sets overlap almost entirely, higher-variance than it sounds.
  • Four splitters: plain, stratified (any classification), grouped (repeated measures), time-series (anything temporal). cv=5cv=5 does not shuffle.
  • cross_validatecross_validate returns several metrics plus train scores in one pass.
  • Always pass the pipeline, never a pre-transformed matrix.

Exercise 6 – What does raising the fold count buy?

Next

Continue to Hyperparameter Tuning with GridSearchCV — use this machinery not just to measure a model but to choose between hundreds of them.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did