Nested Cross-Validation
Inner and Outer Loops
Read aloud is unavailable in this browser.
The Architecture of Unbiased Evaluation
Having understood why model selection bias corrupts performance estimates, we now turn to how nested cross-validation solves this problem through its ingenious two-loop structure.
Nested cross-validation (nested CV, double cross-validation, or NCV) isn't merely running cross-validation twice—it's a carefully designed separation of concerns that ensures the data used for model selection is completely independent from the data used for performance evaluation. This separation is what makes unbiased estimation possible.
This page provides a complete, implementation-ready understanding of nested CV's structure. You'll learn the precise mechanics of inner and outer loops, how data flows between them, the role of each component, and common variations. By the end, you'll be able to implement nested CV from scratch or diagnose issues in existing pipelines.
The Two-Loop Structure
Nested CV consists of two distinct, hierarchically organized cross-validation procedures:
The Outer Loop (Evaluation Loop)
- Purpose: Estimate the generalization performance of the entire model selection procedure
- Splits the full dataset into K_outer folds
- Each iteration holds out one fold as the outer test set
- The remaining K_outer - 1 folds form the outer training set
- Never touches hyperparameter tuning directly—it just evaluates whatever the inner loop produces
The Inner Loop (Selection Loop)
- Purpose: Select the best hyperparameters using only the outer training set
- Runs inside each outer loop iteration
- Splits the outer training set into K_inner folds
- Evaluates each hyperparameter configuration via inner CV
- Returns the best configuration to the outer loop
The outer test set is never seen by the inner loop. This means the hyperparameter selection process cannot possibly overfit to the outer test set. When we evaluate the selected model on the outer test set, we get an honest measure of performance on truly unseen data.
Visual representation of one outer iteration:
Full Dataset
├── Outer Training Set (K_outer - 1 folds) ──────────────────────┐
│ ├── Inner Fold 1 (test) ←─┐ │
│ ├── Inner Fold 2 (test) ←─┤ │
│ ├── Inner Fold 3 (test) ←─┼── Inner CV evaluates │
│ ├── Inner Fold 4 (test) ←─┤ each hyperparameter config │
│ └── Inner Fold 5 (test) ←─┘ │
│ ↓ │
│ Best hyperparameters selected │
│ ↓ │
│ Train final model on ALL outer training data │
│ │ │
└──────────────────────────────│──────────────────────────────────┘
↓
└── Outer Test Set (1 fold) ← Evaluate final model (this is one outer score)
This process repeats K_outer times, each time with a different fold held out as the outer test set. The K_outer outer test scores are averaged to produce the final nested CV estimate.
Step-by-Step Algorithm
Let's formalize the nested CV algorithm with precise pseudocode that can be directly translated to implementation.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
def nested_cross_validation( X, y, # Full dataset model_class, # Model class to instantiate param_grid, # Hyperparameter search space K_outer=5, # Number of outer folds K_inner=5, # Number of inner folds scoring='accuracy' # Evaluation metric): """ Nested Cross-Validation for Unbiased Hyperparameter Tuning and Evaluation Returns: - outer_scores: List of K_outer test performance scores - best_params_per_fold: Parameters selected in each outer fold - mean_score: Unbiased estimate of generalization performance - std_score: Standard deviation across outer folds """ outer_scores = [] best_params_per_fold = [] # Create outer cross-validation splitter outer_cv = KFold(n_splits=K_outer, shuffle=True, random_state=42) for fold_idx, (outer_train_idx, outer_test_idx) in enumerate(outer_cv.split(X)): print(f"Outer Fold {fold_idx + 1}/{K_outer}") # Step 1: Split data into outer train and outer test X_outer_train = X[outer_train_idx] y_outer_train = y[outer_train_idx] X_outer_test = X[outer_test_idx] y_outer_test = y[outer_test_idx] # ======================================== # INNER LOOP: Model Selection # ======================================== # Step 2: Create inner CV splitter (operates ONLY on outer train) inner_cv = KFold(n_splits=K_inner, shuffle=True, random_state=42) # Step 3: Grid search with inner CV to find best hyperparameters best_inner_score = -float('inf') best_params = None for params in param_grid: inner_scores = [] for inner_train_idx, inner_val_idx in inner_cv.split(X_outer_train): # Inner train/validation split X_inner_train = X_outer_train[inner_train_idx] y_inner_train = y_outer_train[inner_train_idx] X_inner_val = X_outer_train[inner_val_idx] y_inner_val = y_outer_train[inner_val_idx] # Train model with current hyperparameters model = model_class(**params) model.fit(X_inner_train, y_inner_train) # Evaluate on inner validation fold score = evaluate(model, X_inner_val, y_inner_val, scoring) inner_scores.append(score) # Average inner CV score for this hyperparameter config mean_inner_score = np.mean(inner_scores) if mean_inner_score > best_inner_score: best_inner_score = mean_inner_score best_params = params best_params_per_fold.append(best_params) print(f" Selected params: {best_params} (inner CV: {best_inner_score:.4f})") # ======================================== # OUTER EVALUATION # ======================================== # Step 4: Retrain with best params on ALL outer training data final_model = model_class(**best_params) final_model.fit(X_outer_train, y_outer_train) # Step 5: Evaluate on outer test set (NEVER seen by inner loop) outer_score = evaluate(final_model, X_outer_test, y_outer_test, scoring) outer_scores.append(outer_score) print(f" Outer test score: {outer_score:.4f}") # Step 6: Aggregate outer scores for final unbiased estimate mean_score = np.mean(outer_scores) std_score = np.std(outer_scores, ddof=1) return { 'outer_scores': outer_scores, 'best_params_per_fold': best_params_per_fold, 'mean_score': mean_score, 'std_score': std_score, 'confidence_interval': (mean_score - 1.96*std_score/np.sqrt(K_outer), mean_score + 1.96*std_score/np.sqrt(K_outer)) }Key algorithmic details:
- Outer split before inner: The outer test set is separated before any inner loop processing occurs
- Inner loop is self-contained: It only sees outer training data and makes no reference to outer test
- Retrain after selection: After selecting best hyperparameters, we retrain on the entire outer training set—not just the inner training folds
- One score per outer fold: Each outer iteration produces exactly one test score
- Different fold randomization: Inner and outer splitters can use different random states
Data Flow Between Loops
Understanding precisely what data flows where is crucial for implementing nested CV correctly and avoiding subtle bugs that reintroduce selection bias.
| Component | Input Data | Output | Purpose |
|---|---|---|---|
| Outer Splitter | Full dataset (N samples) | K_outer train/test splits | Separate evaluation data |
| Outer Train Set | ~(1-1/K_outer)×N samples | Input to inner loop | Data for model selection |
| Outer Test Set | ~N/K_outer samples | Final evaluation | Unbiased performance measure |
| Inner Splitter | Outer train set | K_inner train/val splits | Hyperparameter evaluation |
| Inner Train Set | ~(1-1/K_inner)×outer_train | Model training | Fit model for inner eval |
| Inner Validation | ~outer_train/K_inner | Inner CV score | Estimate hyperparameter quality |
| Best Params | — | Hyperparameter config | Selected model configuration |
| Final Model | All of outer train | Trained model | Best config trained on max data |
A frequent mistake is retraining the final model on the inner training folds instead of the entire outer training set. This wastes data and produces suboptimal models. After selecting hyperparameters via inner CV, always retrain on ALL outer training data before evaluating on the outer test set.
Data sizes through the pipeline (example):
| Stage | Samples (N=1000, K_outer=5, K_inner=5) |
|---|---|
| Full dataset | 1000 |
| Outer train set | 800 (80%) |
| Outer test set | 200 (20%) |
| Inner train set | 640 (80% of 800) |
| Inner validation | 160 (20% of 800) |
Note how inner validation sets are quite small. With 1000 samples, inner validation uses only 160 samples—potentially high variance. This is one reason nested CV is most valuable for moderately-sized datasets where variance matters but is manageable.
The Distinct Roles of Inner and Outer Loops
Despite their structural similarity (both are cross-validation), the inner and outer loops serve fundamentally different purposes. Understanding this distinction is key to proper interpretation.
- •Goal: Select best hyperparameters
- •Question answered: Which config works best on this outer training set?
- •Score usage: For ranking/comparison only
- •Scores are biased: They suffer from selection bias within this fold
- •Output: Best hyperparameter configuration
- •NOT for reporting: Inner CV scores should never be your final reported metric
- •Goal: Estimate generalization performance
- •Question answered: How well will this procedure work on new data?
- •Score usage: For performance estimation
- •Scores are unbiased: Selection hasn't touched outer test
- •Output: Unbiased performance estimate
- •For reporting: The outer CV mean is your reportable metric
Think of outer CV as asking: 'If I gave this entire model selection procedure to a colleague with a similar but different dataset, what performance would they achieve?' The inner loop is implementation detail; the outer loop answers the actual question you care about.
Why inner scores don't matter for reporting:
Inner CV scores are computed during the selection process. They're used to rank hyperparameter configurations, and the best-scoring configuration is selected. This means inner scores suffer from exactly the selection bias we discussed in the previous page.
However, this is fine because we don't use inner scores for reporting. The outer loop provides unbiased evaluation by testing on data the inner loop never saw. The inner loop's job is simply to make reasonable selections—it doesn't need to be unbiased, just effective.
Analogy:
Imagine a sports team holding tryouts (inner loop) and then playing in a tournament (outer loop). The tryout scores help select the roster but don't predict tournament performance. A player might score high in tryouts partly due to lucky matchups against other candidates. The tournament games (against external opponents) give a true measure of the selected team's ability.
Choosing K_outer and K_inner
The choice of fold numbers for inner and outer loops involves tradeoffs between bias, variance, and computational cost.
K_outer (Outer Folds):
The outer loop determines how many independent performance estimates you'll average. Standard choices:
| K_outer | Pros | Cons | Typical Use |
|---|---|---|---|
| 5 | Good balance, moderate compute | Moderate variance | Standard default |
| 10 | Lower variance, more stable | Higher compute, smaller test sets | If compute allows |
| N (LOOCV) | Minimal bias | Maximum variance, very expensive | Rarely used |
For the outer loop, K_outer = 5 is the most common choice over even K_outer = 10 because:
- Outer test sets need sufficient size for reliable performance measurement
- With K_outer = 10, each outer test set is only 10% of data—potentially high variance
- The computational cost multiplier of K_outer = 10 is rarely justified
K_inner (Inner Folds):
The inner loop is for model selection, and its requirements differ:
| K_inner | Pros | Cons | Typical Use |
|---|---|---|---|
| 3 | Much faster, moderate selection quality | Higher selection variance | Large search spaces |
| 5 | Good selection quality, reasonable speed | Moderate compute | Standard default |
| 10 | Excellent selection quality | Very expensive | Small outer train sets |
For inner loops, there's more flexibility because:
- Inner scores are only used for ranking, not reporting
- A slightly suboptimal selection isn't catastrophic
- Computational savings multiply across the parameter grid
Default: K_outer = 5, K_inner = 5 (5×5 nested CV). This balances bias, variance, and computational cost for most scenarios. Speed priority: K_outer = 5, K_inner = 3 for large-scale tuning. Maximum reliability: K_outer = 10, K_inner = 5 when compute budget allows.
Asymmetric choices:
There's no requirement for K_outer = K_inner. In fact, different values are often appropriate:
- Larger K_outer, smaller K_inner: Prioritizes evaluation quality over selection precision
- Smaller K_outer, larger K_inner: Prioritizes selection precision (unusual but valid for very expensive models)
Leave-One-Out considerations:
LOOCV (K = N) is almost never appropriate for nested CV:
- Outer LOOCV provides unstable, high-variance estimates
- Inner LOOCV is computationally prohibitive
- The combination is essentially never practical
Stratification in Nested Cross-Validation
For classification problems, stratified nested CV maintains class proportions in both inner and outer folds. This is especially important for imbalanced datasets.
1234567891011121314151617181920212223242526272829303132333435363738
from sklearn.model_selection import StratifiedKFoldfrom sklearn.model_selection import cross_val_score, GridSearchCV def stratified_nested_cv(X, y, model, param_grid, K_outer=5, K_inner=5): """ Stratified Nested Cross-Validation Maintains class proportions in both inner and outer folds, essential for imbalanced classification problems. """ # Stratified outer splitter - maintains class proportions outer_cv = StratifiedKFold(n_splits=K_outer, shuffle=True, random_state=42) outer_scores = [] for outer_train_idx, outer_test_idx in outer_cv.split(X, y): # Note: y is used! X_outer_train, y_outer_train = X[outer_train_idx], y[outer_train_idx] X_outer_test, y_outer_test = X[outer_test_idx], y[outer_test_idx] # Stratified inner CV for hyperparameter selection inner_cv = StratifiedKFold(n_splits=K_inner, shuffle=True, random_state=42) # GridSearchCV automatically handles stratification when given StratifiedKFold grid_search = GridSearchCV( estimator=model, param_grid=param_grid, cv=inner_cv, scoring='accuracy', n_jobs=-1 ) grid_search.fit(X_outer_train, y_outer_train) # Evaluate on outer test (already stratified by outer_cv) outer_score = grid_search.score(X_outer_test, y_outer_test) outer_scores.append(outer_score) return np.mean(outer_scores), np.std(outer_scores)If you stratify the outer loop, you should stratify the inner loop too. Inconsistent stratification can lead to inner folds that are unrepresentative of outer test conditions, potentially biasing hyperparameter selection.
When stratification is critical:
- Imbalanced classes: Minority class might be missing from some folds without stratification
- Small datasets: Random variation in class proportions is high
- Multi-class problems: Many classes increase the importance of balanced representation
Regression stratification:
For regression problems, you can create synthetic strata by binning the target variable:
from sklearn.model_selection import StratifiedKFold
import pandas as pd
# Create bins for stratification
y_binned = pd.cut(y, bins=5, labels=False)
# Use binned y for stratification
outer_cv = StratifiedKFold(n_splits=5)
for train_idx, test_idx in outer_cv.split(X, y_binned):
# ... rest of nested CV
This ensures each fold has a similar distribution of target values.
Handling Different Configurations Per Fold
An important feature of nested CV: the best hyperparameters can differ across outer folds. This behavior is expected and informative.
| Outer Fold | Best Hyperparameters | Outer Test Score |
|---|---|---|
| Fold 1 | C=1.0, gamma=0.1 | 0.847 |
| Fold 2 | C=1.0, gamma=0.1 | 0.832 |
| Fold 3 | C=10.0, gamma=0.01 | 0.858 |
| Fold 4 | C=1.0, gamma=0.1 | 0.841 |
| Fold 5 | C=0.1, gamma=0.1 | 0.829 |
Why configurations vary:
- Statistical variation: With limited data, different train/test splits favor different configurations
- Flat optima: Often many configurations perform similarly; the 'winner' is determined by noise
- Data heterogeneity: Different folds may emphasize different data regions
What to do about it:
- Don't force consistency: Different configurations are allowed and expected
- Look for patterns: If configurations vary wildly, the choice may not matter much
- Final model selection: For deployment, run a final non-nested CV on all data to pick one configuration
Final model training process:
- Report nested CV score as your unbiased performance estimate
- Run standard (non-nested) CV on full dataset to select final hyperparameters
- Train final model with selected hyperparameters on all available data
- Deploy this model
High variability in selected configurations across folds suggests a flat optimization landscape—many configurations perform similarly. This is actually good news: it means hyperparameter tuning isn't critical, and simpler (faster) configurations likely suffice.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.