Python & Data Science
Machine Learning Under review

Nested Cross-Validation: Why Your Validation Score Is Lying to You

The ‘Lucky Guess’ Problem

You spend hours tuning your XGBoost model. Validation accuracy hits a beautiful 95%, and you feel like a hero. Then you deploy it or run it against the final test set, and the score drops to 75%.

What happened? Most likely, you ran into Optimization Bias. Hyperparameter tuning is like a multiple-choice test where you keep guessing until you find the right answer. Try 1,000 different combinations of settings and one is bound to look good by pure luck. You didn’t find a model that understands the data — you found one that got ‘lucky’ on that specific validation set.

Your validation score is now misleading. To see how bad this gets, here’s some code that tries to ‘predict’ a target using nothing but random noise.

import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score

# Create 100 rows of random noise (100 different 'features')
np.random.seed(42)
X_noise = np.random.normal(size=(100, 100))
y = np.random.randint(0, 2, size=100)

# A constant predictor that always guesses the majority class sets the floor any
# 'real' pattern needs to clear
majority_class_rate = max(np.bincount(y)) / len(y)

# Let's try to find a single feature that 'predicts' the target
best_score = 0
for i in range(100):
    score = cross_val_score(LogisticRegression(), X_noise[:, i:i+1], y, cv=5).mean()
    if score > best_score:
        best_score = score

print(f"Majority-class floor: {majority_class_rate:.2f}")
print(f"Best 'Lucky' Accuracy: {best_score:.2f}")
# Output: majority-class floor 0.54, best 'lucky' accuracy 0.60.

Running this, the majority-class floor sits at 0.54, and the best ‘lucky’ accuracy comes back at 0.60 — a real edge, but a modest one: only 0.06 above what you’d get by always guessing the majority class. That’s not the dramatic 90%+ ‘pattern’ a headline number might suggest, but it’s real: searched across 100 pure-noise features, one of them clears the floor by chance alone. That’s the seed of optimization bias — and the more knobs you tune, the more chances you give one of them to look good by luck.

The more you tune, the more you ‘leak’ information from your validation set into your model. This is the hardest part of model selection to accept: your search for the best model is itself a form of training that can overfit.

The Solution: A Test Within a Test

How do we fix this? We use Nested Cross-Validation. Think of it as a ‘Secret Exam.’

Standard cross-validation runs one loop. You split the data, train on some of it, validate on the rest. Nested CV adds a second, outer layer.

  1. The Inner Loop: This is the ‘Practice Test.’ You try different hyperparameters (like learning rate or depth) to find the best settings.
  2. The Outer Loop: This is the ‘Final Exam.’ Once the inner loop picks what it thinks is the best model, we test that model on data it has never seen—not even during the tuning process.

The distinction matters. We aren’t just checking whether a specific model is good. We are testing the entire process of how we choose models. If our tuning is prone to ‘lucky guesses,’ the outer loop catches it and shows a lower, more honest score.

Let’s Build It: Optuna Meets Scikit-Learn

Optuna runs the inner loop as our Smart Searcher. Rather than testing every combination the way Grid Search does, it uses Bayesian optimization to narrow in on the best settings fast.

We’ll use a small, noisy synthetic dataset here rather than something like the Wine dataset. Wine is small too, but its classes are nearly linearly separable — a random forest saturates near 100% accuracy almost no matter how it’s tuned, which leaves no headroom for optimization bias to show up (running the demo below on Wine, the “nested” score actually comes out higher than the naive score more often than not — the opposite of the point this article is making). The dataset below has only 3 informative features out of 100, 15% label noise, and modest class separation, so there’s real room for the inner loop to overfit its tuning.

import optuna
import numpy as np
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import KFold, cross_val_score

optuna.logging.set_verbosity(optuna.logging.WARNING)

X, y = make_classification(
    n_samples=120, n_features=100, n_informative=3, n_redundant=20,
    flip_y=0.15, class_sep=0.5, random_state=1
)

RANDOM_STATE = 0

def objective(trial, X_train, y_train, inner_cv):
    # The Inner Loop: Suggestions for hyperparameters
    n_estimators = trial.suggest_int("n_estimators", 1, 60)
    max_depth = trial.suggest_int("max_depth", 1, 40)
    min_samples_leaf = trial.suggest_int("min_samples_leaf", 1, 15)

    clf = RandomForestClassifier(
        n_estimators=n_estimators, max_depth=max_depth,
        min_samples_leaf=min_samples_leaf, random_state=RANDOM_STATE,
    )

    # Inner CV to find the best params
    score = cross_val_score(clf, X_train, y_train, cv=inner_cv).mean()
    return score

# The Outer Loop: The Final Exam
outer_cv = KFold(n_splits=5, shuffle=True, random_state=RANDOM_STATE)
inner_cv = KFold(n_splits=3, shuffle=True, random_state=RANDOM_STATE)
outer_scores = []
naive_scores = []

print("Starting Nested CV...")
for train_idx, test_idx in outer_cv.split(X):
    X_train, X_test = X[train_idx], X[test_idx]
    y_train, y_test = y[train_idx], y[test_idx]
    
    # Optimize hyperparameters for THIS specific outer fold
    study = optuna.create_study(direction="maximize", sampler=optuna.samplers.TPESampler(seed=RANDOM_STATE))
    study.optimize(lambda trial: objective(trial, X_train, y_train, inner_cv), n_trials=20)
    naive_scores.append(study.best_value)  # the score Optuna itself reported as "best"
    
    # Build a model using the best params found
    best_model = RandomForestClassifier(**study.best_params, random_state=RANDOM_STATE)
    best_model.fit(X_train, y_train)
    
    # Evaluate on the 'Secret' test set
    final_score = best_model.score(X_test, y_test)
    outer_scores.append(final_score)

print(f"Naive Average Score (what Optuna reported as best): {np.mean(naive_scores):.4f}")
print(f"Nested CV Average Score: {np.mean(outer_scores):.4f}")

Interpreting the Numbers: The Reality Check

Run the code above and you’ll see the Naive Average Score land at 0.7063, while the Nested CV Average Score comes back at 0.6500 — a real six-point gap, in the direction the theory predicts.

That gap isn’t a one-seed fluke. Re-running the outer/inner splits and the Optuna sampler with several different seeds on this same dataset (while keeping the random forest’s own internal randomness fixed), the nested score landed lower than the naive score in every single repetition, with the gap ranging from about +0.03 to +0.07. Contrast that with the Wine dataset from the previous section’s caveat: run this exact demo on Wine instead, and the “nested” score comes out higher than the naive score most of the time, because Wine is too easy for a random forest to overfit while tuning. The gap here is real precisely because the dataset gives the tuning process real noise to chase.

The takeaway: trust the lower number. It’s the honest one.

  • The Naive Score: What Optuna sees. Optimistic, because it picks the best of many attempts.
  • The Nested Score: The reality check. It tells you: ‘If I run this tuning process on new data, here is how it actually performs.’

A Naive score of 0.98 against a Nested score of 0.85 would be an even more dramatic version of the same story: your tuning process is hallucinating. The hyperparameters are tuned to noise in your validation set, not signal in the data.

When Should You Actually Use This?

Nested CV is computationally expensive. Five outer folds combined with three inner folds means training your model 15 times per trial.

So, when is the extra wait time worth it? Think of it as insurance. The less data you have, the more insurance you need.

Dataset SizeStrategyWhy?
Small (< 2,000 rows)Nested CVHigh risk of ‘lucky’ parameter sets.
Medium (2k - 20k rows)Nested CV or Repeated CVStill prone to variance; worth the extra compute.
Large (> 100k rows)Simple Train/Val/TestData is plenty; a single hold-out set is usually stable.
  1. Tuning hyperparameters is a form of training, so it can overfit.
  2. Standard CV scores can be overly optimistic due to ‘Optimization Bias.’
  3. Nested CV uses an outer loop to provide a ‘Final Exam’ the tuning process never sees.
  4. Use Nested CV whenever you have a small dataset and want the truth about your model’s performance.

Validating your process builds real confidence — but only in the process, and only on data like what you validated on. Nested CV tells you what your tuning procedure is worth on data drawn from the same distribution it was tested against; whether your 90% today is still 90% tomorrow is a question about drift and monitoring, not something nested validation can answer on its own.

Check Your Understanding

Questions below move from simple recall up to open-ended design, roughly following Bloom’s Taxonomy.

Remember What is Optimization Bias, and why does the article say that hyperparameter tuning can “leak” information from the validation set into the model?

Understand Explain in your own words the difference between the inner loop (“Practice Test”) and the outer loop (“Final Exam”) in Nested Cross-Validation, and what question each loop is answering.

Apply Using the dataset-size table from the “When Should You Actually Use This?” section, you have 5,000 rows and are doing a Kaggle-style tuning search. Which strategy does the table recommend, and what risk are you protecting against?

Analyze The article says the “Naive Score” is systematically higher than the “Nested Score.” Walk through why picking the best of many trials inflates the naive number, and why the outer loop is immune to that inflation.

Evaluate The article frames Nested CV as “insurance” that costs extra compute. Critique that framing: describe a realistic scenario where running Nested CV is not worth the cost, and one where it clearly is, based on the dataset-size logic.

Create Design a small experiment (using the random-noise example as a template) that would demonstrate Optimization Bias to a skeptical colleague. Describe the data, the tuning loop, and the two scores you’d compare to prove that aggressive tuning on noise produces a “lucky” but meaningless result.

Looking for something else?

Search every article by title, summary or topic.