Skip to main content
Machine Learning & Deep Learning

Cross-Validation Without Fooling Yourself

Master k-fold, stratified k-fold, time series, and group k-fold cross-validation. Learn how naive folds leak information and how to validate correctly for temporal and grouped data.

Intermediate24 minBy ToolDix Editorial

Learning objectives

  • Understand why k-fold cross-validation gives a more reliable performance estimate than a single train/test split
  • Implement KFold, StratifiedKFold, TimeSeriesSplit, and GroupKFold from scikit-learn correctly
  • Recognize why naive k-fold fails on time-series and grouped data, and when to use alternatives
  • Avoid common cross-validation mistakes: leaking preprocessing, using wrong fold type, and tuning on CV folds

ToolDix original visual

ML & Deep Learning practice loop
1

Frame

Name the outcome and constraints.

2

Build

Try one bounded workflow.

3

Review

Keep evidence, revise, and share.

Why k-Fold Is Better Than a Single Train/Test Split

ToolDix original diagram
k-fold cross-validation: five evaluations from one dataset
Each fold holds out one fifth of the data for testing while training on the other four. Scores from all folds are averaged to estimate model performance on unseen data more reliably than a single train/test split.
k-fold cross-validation is especially valuable when training data is limited -- it extracts maximum information by using every sample both for training and evaluation.

Imagine you split your data once: 80% train, 20% test. You train a model and measure 92% accuracy. Is this reliable? Maybe. But if you happened to put all the easy examples in the training set and all the hard examples in the test set, the 92% is an overestimate. With a different (unlucky) split, you might get 78%.

k-Fold cross-validation repeats this process k times with different splits, yielding k performance estimates. The average (and standard deviation) of these k results gives a more reliable picture of how the model generalizes. It also uses the data more efficiently: every example is in exactly one test fold, so no data is wasted.

With k=5 (a common choice), you do 5 train/test rounds. Each round uses 4/5 of the data for training and 1/5 for testing, but the test fold rotates. The final estimate is the average of the 5 test scores, and the standard deviation shows variability.

Simple k-fold

from sklearn.model_selection import cross_val_score, KFold
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import load_breast_cancer

X, y = load_breast_cancer(return_X_y=True)

model = RandomForestClassifier(n_jobs=-1, random_state=42)

# 5-fold cross-validation
cv = KFold(n_splits=5, shuffle=True, random_state=42)

# cross_val_score trains the model 5 times and returns 5 test scores
scores = cross_val_score(model, X, y, cv=cv, scoring='accuracy')

print(f"Fold scores: {scores}")
print(f"Mean: {scores.mean():.3f}, Std: {scores.std():.3f}")
# Output example: [0.96, 0.94, 0.95, 0.97, 0.94]
# Mean: 0.952, Std: 0.011

# This tells you:
# - Expected accuracy on unseen data: ~95.2%
# - Variability: ±1.1% (one standard deviation)

Cross-validation gives you two pieces of information: a point estimate (the mean) and confidence (the std). A low std means the model is consistently good; a high std means its performance depends on the specific fold.


Stratified k-Fold for Imbalanced Classes

In classification with imbalanced classes (e.g., 95% negative, 5% positive), a naive random split might accidentally put all positive examples in one fold, making that fold's test set unrealistic.

Stratified k-fold ensures each fold has roughly the same class distribution as the original data:

from sklearn.model_selection import StratifiedKFold, cross_val_score

# Create imbalanced data: 90% class 0, 10% class 1
X_imbalanced = X[y < 300]  # Take first 300 samples (mostly one class)
y_imbalanced = y[y < 300]

print(f"Class distribution: {(y_imbalanced == 0).sum()} vs {(y_imbalanced == 1).sum()}")

# Naive k-fold might have uneven class distribution in folds
cv_naive = KFold(n_splits=5, shuffle=True, random_state=42)
scores_naive = cross_val_score(model, X_imbalanced, y_imbalanced, cv=cv_naive)
print(f"Naive KFold scores: {scores_naive}")

# Stratified k-fold maintains class distribution in each fold
cv_stratified = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores_stratified = cross_val_score(model, X_imbalanced, y_imbalanced, cv=cv_stratified)
print(f"Stratified KFold scores: {scores_stratified}")

# For imbalanced data, stratified is usually more reliable

Use StratifiedKFold whenever you have imbalanced classification. The class distribution in each fold mimics the original distribution, giving more representative validation scores.


Time Series Cross-Validation: The Causality Constraint

For time-series data, a naive k-fold violates causality: you are training on future data and testing on the past, which is impossible in real deployment.

TimeSeriesSplit respects the temporal order: for each fold, training data is always before the test data:

from sklearn.model_selection import TimeSeriesSplit

# Create time-series-like data: 1000 samples with a temporal order
np.random.seed(42)
X_ts = np.random.randn(1000, 10)
y_ts = (X_ts[:, 0] + 0.5 * X_ts[:, 1]).astype(int)

# Wrong approach: naive k-fold
cv_wrong = KFold(n_splits=5, shuffle=True)
# This mixes past and future, violating temporal causality

# Correct approach: time series split
cv_ts = TimeSeriesSplit(n_splits=5)

# Visualize the splits
for fold_idx, (train_idx, test_idx) in enumerate(cv_ts.split(X_ts)):
    print(f"Fold {fold_idx}: train [{train_idx[0]}, {train_idx[-1]}], test [{test_idx[0]}, {test_idx[-1]}]")

# Output:
# Fold 0: train [0, 199], test [200, 249]
# Fold 1: train [0, 299], test [300, 399]
# Fold 2: train [0, 499], test [500, 599]
# ... each fold grows the training set, tests on the future

# Evaluate with TimeSeriesSplit
from sklearn.linear_model import LinearRegression
model_ts = LinearRegression()
scores_ts = cross_val_score(model_ts, X_ts, y_ts, cv=cv_ts, scoring='r2')
print(f"Time-series CV scores: {scores_ts}")

Key difference: TimeSeriesSplit grows the training window over time, ensuring that each test fold is always in the future relative to the training fold. This mimics real deployment: train on historical data, predict on upcoming data.


GroupKFold: For Grouped/Hierarchical Data

Sometimes you have multiple samples from the same group (user, patient, location) and you want to avoid leakage across groups.

Example: you have 1000 photos from 100 users. A naive k-fold might split photos from the same user across train and test, allowing the model to memorize user-specific patterns. Instead, use GroupKFold: ensure all samples from the same group are in either train or test, never both.

from sklearn.model_selection import GroupKFold

# Example: 1000 samples from 100 groups (users)
X_grouped = np.random.randn(1000, 10)
y_grouped = np.random.randint(0, 2, 1000)
groups = np.repeat(np.arange(100), 10)  # 100 groups, 10 samples each

# Naive k-fold: samples from the same group can leak across folds
cv_naive = KFold(n_splits=5)
# Problem: user 0's samples might be split across train and test

# GroupKFold: all samples from the same group stay together
cv_group = GroupKFold(n_splits=5)

for fold_idx, (train_idx, test_idx) in enumerate(cv_group.split(X_grouped, y_grouped, groups)):
    train_groups = set(groups[train_idx])
    test_groups = set(groups[test_idx])
    overlap = train_groups & test_groups
    print(f"Fold {fold_idx}: train groups={len(train_groups)}, test groups={len(test_groups)}, overlap={len(overlap)}")

# Output:
# Fold 0: train groups=80, test groups=20, overlap=0
# Fold 1: train groups=80, test groups=20, overlap=0
# ... no overlap, groups never leak

# Evaluate
model = RandomForestClassifier(n_jobs=-1, random_state=42)
scores = cross_val_score(
    model, X_grouped, y_grouped, cv=cv_group, groups=groups, scoring='accuracy'
)
print(f"GroupKFold scores: {scores}")

Use GroupKFold whenever you have nested data: multiple images per person, multiple purchases per customer, multiple measurements per patient. It prevents data leakage at the group level.


Leaking Preprocessing: A Critical Mistake

Preprocessing (scaling, imputation, encoding) must be fit on training data only, then applied to validation and test. If you fit preprocessing on the entire dataset before splitting, information leaks from test to train.

Wrong approach (leaking)

from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline

# Load data
X, y = load_breast_cancer(return_X_y=True)

# WRONG: fit scaler on entire dataset
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)  # Fit on all data!

# Now split
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2)

# Problem: test set was used to compute the scale (mean, std)
# This inflates the test score unrealistically

Correct approach (no leaking)

from sklearn.model_selection import cross_validate

# Use Pipeline: fit scaler within each fold
pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('model', RandomForestClassifier(n_jobs=-1, random_state=42))
])

# cross_validate ensures preprocessing is fit on training data only
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
cv_results = cross_validate(pipeline, X, y, cv=cv, scoring='accuracy')

print(f"Cross-validation scores: {cv_results['test_score']}")
# These are honest estimates; no leakage

The Pipeline is key: it fits preprocessing on training data and applies it to test data within each fold automatically. Never fit preprocessing on the entire dataset.


Nested Cross-Validation: For Hyperparameter Tuning

If you tune hyperparameters on the same CV folds where you estimate performance, you are tuning on the validation data. This inflates your estimates. Use nested cross-validation:

  • Outer loop: estimate generalization performance (e.g., 5-fold)
  • Inner loop: tune hyperparameters on training data (e.g., 3-fold within each outer fold)
from sklearn.model_selection import GridSearchCV, cross_validate

X, y = load_breast_cancer(return_X_y=True)

# Define the hyperparameter grid
param_grid = {
    'n_estimators': [50, 100, 200],
    'max_depth': [5, 10, 20],
}

# Inner loop: GridSearchCV tunes hyperparameters on training data
model = RandomForestClassifier(n_jobs=-1, random_state=42)
inner_cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)
grid_search = GridSearchCV(model, param_grid, cv=inner_cv, n_jobs=-1)

# Outer loop: cross_validate measures generalization on unseen folds
outer_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
nested_scores = cross_validate(grid_search, X, y, cv=outer_cv, scoring='accuracy')

print(f"Nested CV scores: {nested_scores['test_score']}")
print(f"Mean: {nested_scores['test_score'].mean():.3f}")

# This is an honest estimate: hyperparameters were tuned on inner-fold training data,
# performance measured on outer-fold test data that the inner tuning never saw.

Without nested CV, hyperparameter tuning optimizes for the validation set, making the reported CV score unrealistically high. Nested CV prevents this.


Choosing k: A Practical Guide

How many folds should you use?

| Scenario | Recommendation | Rationale | |---|---|---| | Small dataset (< 100 samples) | 5-fold or LOO | Fewer folds increase variance in estimates; LOO gives stable estimates but O(n) training required | | Medium dataset (100-10k samples) | 5-fold or 10-fold | Standard; balances bias (k=5) and variance (k=10) in performance estimate | | Large dataset (> 10k samples) | 3-fold or 5-fold | More folds reduce bias negligibly; training time scales linearly with k | | Very imbalanced classes | Stratified 5-fold to 10-fold | Each fold preserves class distribution; critical for minority class accuracy | | Time series | TimeSeriesSplit, 3-5 folds | Number depends on forecast horizon (higher k = shorter test window) | | High-variance model (e.g., neural network) | 10-fold | Higher variance requires more folds for stable estimates; same cost as 5-fold if batch-parallel |

For most situations, 5-fold stratified cross-validation is a safe default. For models with high variance (tree ensemble with small trees, neural networks), increase to 10-fold.

Bias-variance trade-off in k-fold CV

| Aspect | Small k (e.g., 3) | Large k (e.g., 10) | |---|---|---| | Bias in estimate | Higher: training set is smaller (72% of data), test size larger | Lower: training set closer to full data (90% of data) | | Variance in estimate | Lower: fewer folds average out | Higher: folds more similar to each other (less independent) | | Train time | Faster: fewer folds to train | Slower: more folds | | When to use | Large dataset; fast iteration needed | Small dataset; final report accuracy critical |


Example: A Complete Cross-Validation Workflow

from sklearn.model_selection import StratifiedKFold, cross_validate, GridSearchCV
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.ensemble import RandomForestClassifier

X, y = load_breast_cancer(return_X_y=True)

# 1. Define a robust pipeline
pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('model', RandomForestClassifier(n_jobs=-1, random_state=42))
])

# 2. Set up nested cross-validation for honest hyperparameter tuning and evaluation
outer_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
inner_cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)

param_grid = {
    'model__n_estimators': [50, 100, 200],
    'model__max_depth': [5, 10, 20],
}

grid_search = GridSearchCV(
    pipeline,
    param_grid,
    cv=inner_cv,
    n_jobs=-1,
    scoring='accuracy'
)

# 3. Evaluate with outer cross-validation
results = cross_validate(
    grid_search,
    X, y,
    cv=outer_cv,
    scoring=['accuracy', 'precision', 'recall'],
    n_jobs=-1,
    return_train_score=False
)

# 4. Report results
print(f"Accuracy: {results['test_accuracy'].mean():.3f} ± {results['test_accuracy'].std():.3f}")
print(f"Precision: {results['test_precision'].mean():.3f} ± {results['test_precision'].std():.3f}")
print(f"Recall: {results['test_recall'].mean():.3f} ± {results['test_recall'].std():.3f}")

# 5. Final model: retrain on entire dataset with best hyperparameters
grid_search.fit(X, y)
print(f"Best hyperparameters (from nested CV): {grid_search.best_params_}")
# Note: the best_params_ are based on inner folds, representing what the outer folds converged on

# 6. For deployment, evaluate once on a truly held-out test set
X_train_full, X_test_final, y_train_full, y_test_final = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

grid_search_final = GridSearchCV(pipeline, param_grid, cv=inner_cv, n_jobs=-1)
grid_search_final.fit(X_train_full, y_train_full)
test_score = grid_search_final.score(X_test_final, y_test_final)
print(f"Final held-out test score: {test_score:.3f}")

This workflow:

  1. Uses a Pipeline to prevent preprocessing leakage.
  2. Tunes hyperparameters with an inner CV loop.
  3. Evaluates performance with an outer CV loop (nested).
  4. Reports multiple metrics (accuracy, precision, recall) to capture different aspects.
  5. Holds out one final test set for a truly unbiased final evaluation.

Cross-Validation Strategy Comparison

When evaluating a model, you have multiple options. Here is how they compare:

| Strategy | Use Case | Bias in Estimate | Data Efficiency | Train Time | Stability | |---|---|---|---|---|---| | Single train/test split | Sanity check; quick experiment | High: one unlucky split biases result | Poor: 20% data wasted in test set | Fastest | Low: depends on split | | k-fold (k=5) | Standard model evaluation | Low: k estimates average out | Good: 100% data used, 80% train | Moderate: 5× slower than single split | Medium: some fold variance | | Stratified k-fold (k=5) | Classification with imbalanced classes | Low | Good | Moderate | Higher: class distribution maintained | | Nested k-fold (inner k=3, outer k=5) | Best: hyperparameter tuning + evaluation | Lowest: inner tuning separated from outer evaluation | Moderate: nested loops require care | Slowest: 15 model trainings per metric | Highest: gold standard for unbiased estimates | | Leave-one-out (LOO) | Very small datasets (< 50 samples) | Minimal | Perfect (all data used) | Expensive (n trainings) | Highest (each sample independent test) |

Recommendation: Use nested cross-validation (outer loop for evaluation, inner loop for tuning) on moderately-sized datasets. For very large datasets, 5-fold validation is sufficient.


Advanced: Custom CV Splitters for Specialized Data

Sometimes standard CV strategies don't fit your data structure. scikit-learn allows custom splitters:

Time-aware stratified cross-validation: You need temporal order AND class balance.

class TimeAwareStratifiedSplit:
    """Temporally ordered folds that maintain class distribution."""
    def __init__(self, n_splits=5):
        self.n_splits = n_splits

    def split(self, X, y=None, groups=None):
        # Sort by time (groups = timestamps)
        sorted_idx = np.argsort(groups)
        y_sorted = y[sorted_idx]

        # Stratified split on sorted data
        n = len(y_sorted)
        fold_size = n // self.n_splits

        for fold in range(self.n_splits):
            test_start = fold * fold_size
            test_end = (fold + 1) * fold_size if fold < self.n_splits - 1 else n

            test_idx = sorted_idx[test_start:test_end]
            train_idx = np.concatenate([sorted_idx[:test_start], sorted_idx[test_end:]])

            yield train_idx, test_idx

This ensures temporal order (train before test) while maintaining class balance in each fold.


Common mistake: using the same fold for tuning and evaluation

If you tune hyperparameters and report CV scores on the same folds, you are reporting optimistic estimates. Always use nested cross-validation or a separate hold-out set for final evaluation.

Another mistake: forgetting to pass groups to cross-validation functions when using GroupKFold. The function needs to know the group labels:

# Correct
scores = cross_val_score(model, X, y, cv=GroupKFold(5), groups=groups)

# Wrong: groups are ignored if not passed
scores = cross_val_score(model, X, y, cv=GroupKFold(5))

And for time series: confusing TimeSeriesSplit with forward-chaining. TimeSeriesSplit grows the training window (test on [200-249], [300-399], [500-599], ...). If you want a fixed window (test on [200-299], [300-399], ...), you need a custom CV splitter. For most applications, growing windows (TimeSeriesSplit) better simulate real deployment, where you retrain on accumulated historical data.

Case study: Why CV matters in practice

On the UCI breast cancer dataset (569 samples, 30 features, 2 classes):

| Validation Method | Mean Accuracy | Std Dev | Realistic? | |---|---|---|---| | Single 80/20 split (bad luck) | 0.91 | N/A | No; test set happened to have easier examples | | Single 80/20 split (good luck) | 0.98 | N/A | No; test set easier, overestimate | | 5-fold CV | 0.954 | 0.013 | Yes; reflects true generalization ± 1.3% | | Nested CV (inner 3-fold, outer 5-fold) | 0.950 | 0.016 | Most realistic; hyperparameters not optimized on test folds |

A single train/test split is unreliable: you might get 91% or 98% depending on which split you get. Cross-validation gives you 95.4% ± 1.3%, which is a far more honest picture. Nested CV ensures hyperparameter tuning doesn't bias the estimate.


Sources and license context

These references informed the lesson. ToolDix adds its own explanation, workflow, and practice rather than reproducing source material. Every link below leaves ToolDix and opens the publisher's own site in a new tab.

Keep going

Read these next on ToolDix.

Original lessons that build on what you just read.