Search Tech Journey

Find topics, journeys and posts

6-month learning plan95 / 130
back to blog
mlintermediate 55m read

S095 · Model Selection — CV, Hyperparameter Tuning, Optuna

Cross-validation done properly, hyperparameter search that doesn't lie, and Optuna — the Bayesian optimiser that has quietly replaced GridSearch in every serious ML shop. Learn the exact CV strategies for classification, regression, time-series, and grouped data, and the two-loop pattern that keeps your test set honest.

🤖Machine LearningM11 · Classical ML· Session 095 of 130 90 min

🎯 Pick the correct cross-validation strategy for your data, tune hyperparameters with Optuna, and hold a clean test set from start to ship.

Why this session exists

Model selection is where careers are quietly destroyed. Do it wrong and every number in your report is inflated. Do it right and you'll be one of the few people on the team who can honestly say "yes, this will work in production." This session covers the four CV strategies you'll actually use (KFold, StratifiedKFold, TimeSeriesSplit, GroupKFold), the two-loop pattern (nested CV) that keeps hyperparameter search from leaking into your final metric, and Optuna — the Bayesian optimiser that has essentially replaced GridSearchCV in industry.

You will be able to
  • Pick the right CV strategy for classification, regression, time-series, or grouped data.
  • Explain why a single train/test split is not enough and what k-fold actually gains you.
  • Use nested CV to get an unbiased performance estimate under hyperparameter tuning.
  • Run an Optuna study to tune 5-10 hyperparameters in fewer trials than GridSearch would take.
  • Keep a held-out test set that is touched exactly once — at the end.

Prerequisites

  • S092 · Evaluation metrics — you know how to score a classifier.
  • S093 · Feature engineering — you know the Pipeline pattern (so CV can wrap the whole thing).
  • S094 · Imbalanced data — you know why StratifiedKFold matters.


(a) Intuition · 5 min

Choosing a chef by having them cook one meal
🌍 Real world

You're hiring a chef. Two candidates cook you one lunch each. Chef A's dish is 8/10; Chef B's is 6/10. You hire A.

Three months later, Chef A's average dish is 5/10 — turns out they had one showpiece and the rest of their range is weak. Chef B was consistently 6/10 across every meal.

One tasting is not enough. You need to sample multiple meals across different styles before you can trust the average.

💻 Code world

A single train/test split is one tasting. Depending on which rows happened to land in test, your score can be lucky (favourable slice) or unlucky. Two models scored on the same split can look very different, but score them on ten splits and the "worse" model often wins the average.

K-fold cross-validation is the multiple-meals evaluation. Ten splits, ten scores, one mean + one std dev — a much more honest number. Nested CV goes one level deeper: it lets you tune hyperparameters inside the evaluation without leaking test signal into the tuning.

The three loops of honest evaluation

Nested CV in one paragraph
  • Held-out test set — split off at the very start, locked in a drawer, touched exactly once at the end.
  • Outer CV (evaluation) — k folds over the remaining data, each fold's model is scored on its held-out slice.
  • Inner CV (tuning) — within each outer training fold, another CV runs to pick hyperparameters. The inner and outer folds never share data.
  • Optuna / GridSearch / RandomSearch — the search strategy that picks candidates for the inner loop. Optuna is Bayesian, converges 5-10× faster than grid search.
  • The rule — anything that touches the target (hyperparameter tuning, feature selection, threshold picking) must live inside the CV, never outside.

A quick history

  1. 1974
    Cross-validation formalised · Stone
    Mervyn Stone's paper ‘Cross-Validatory Choice and Assessment of Statistical Predictions’ gives the technique its name.
  2. 1995
    GridSearchCV enters the ML playbook
    Combinatorial search over hyperparameters becomes the default; still is at most bootcamps.
  3. 2012
    Random Search beats Grid Search · Bergstra & Bengio
    Random sampling of hyperparameters outperforms grid on high-dimensional spaces. The community slowly adopts.
  4. 2019
    Optuna 1.0 · Preferred Networks
    TPE (Tree-structured Parzen Estimator) with pruning. Becomes the default in industry within 2 years.
  5. 2023
    AutoML tools productionise nested CV
    auto-sklearn, TPOT, FLAML — nested CV is the invisible foundation of every AutoML result.

(b) Visual walkthrough · 15 min

K-fold cross-validation

Nested CV — the pattern that keeps you honest

The four CV strategies you'll actually use

KFold

Vanilla k-fold, iid data

  • Random shuffle + k equal chunks
  • OK for regression on iid data
  • NOT ok for classification (may create unbalanced folds)
  • NOT ok for time-series (leaks future into past)
StratifiedKFold

Preserves class ratio

  • Default for classification, always
  • Each fold has same positive/negative ratio as full data
  • Non-negotiable on imbalanced data (see S094)
  • Same API as KFold — swap it in for free
TimeSeriesSplit

Never see the future

  • Fold k trains on all data up to time t_k, tests on t_k → t_{k+1}
  • Test set is always temporally after training
  • Use for stock, sensor, sales, any temporal signal
  • Combine with expanding or rolling window
GroupKFold

Same group never spans folds

  • Rows share a group id (customer, patient, session)
  • One customer's rows are always all in train OR all in test
  • Prevents leakage from ‘I saw customer X in training, so of course I predict them well’
  • Essential for recommender systems, medical trials, session-level logs

Search strategies for hyperparameters

1grid
GridSearchCV — exhaustive

Try every combination. Explodes combinatorially with number of parameters. Fine for 2-3 hyperparameters with 3-5 values each.

2random
RandomizedSearchCV — sampled

Sample N random combinations. Beats grid on high-dim problems (Bergstra 2012). Simple, no dependencies, good baseline.

3optuna
Optuna — Bayesian (TPE)

Uses past trials to propose better ones. Converges 5-10× faster than random on non-trivial spaces. Handles conditional parameters, prunes bad trials early.

4hyperband
Hyperband / BOHB

Trial early-stopping — spend the compute budget on promising configs. Ray Tune and Optuna both support.


Common misconception
✗ What most people think

"Cross-validation gives an unbiased estimate of generalisation performance. So if I tune hyperparameters with CV and report the best CV score, that's an honest number for how the model will do in production."

✓ What is actually true

CV is unbiased for a fixed procedure evaluated once. The moment you use it to select — best of 50 hyperparameter configurations, best of 5 feature sets — the winning score is biased upward, because you picked the maximum over noisy estimates and part of what you selected for was favourable noise. Reporting the best CV score as your expected performance is the same error as reporting training error, just one level up.

Why the myth is so sticky

The myth is sticky because CV really does solve the first-order problem so convincingly. It fixes the "I fit and evaluated on the same rows" error, and that fix is dramatic and visible. Nothing signals that a second-order version of the identical error has appeared — no data was touched twice, no gradient saw the validation fold. But selection is fitting: choosing the argmax over 50 noisy scores fits the noise in those scores exactly as gradient descent fits noise in the data. The bias grows with the number of configurations tried and shrinks with fold size, so it is worst precisely in a big search on a small dataset.

Prove it to yourself

Search over pure noise and watch CV report a good score for a model that cannot possibly work:

import numpy as np
from sklearn.model_selection import cross_val_score, GridSearchCV
from sklearn.svm import SVC

X = np.random.randn(200, 50)          # pure noise
y = np.random.randint(0, 2, 200)      # unrelated labels

gs = GridSearchCV(SVC(), param_grid, cv=5)
gs.fit(X, y)
print('best CV score:', gs.best_score_)     # comfortably above 0.5

# true value, measured by an outer loop that never selected:
print(cross_val_score(gs, X, y, cv=5).mean())   # ~0.5, correctly
From first principles
Start with the question

Why 5 or 10 folds? Leave-one-out uses the most training data per fit and gives the largest training sets, which sounds strictly better. It is usually worse, and the reason is not just compute.

  1. 1
    The quantity you want is the expected error of a model trained on n examples. Each CV fold trains on only (k−1)/k of the data, so every fold's estimate is pessimistic — it evaluates a model trained on less data than you will finally use.
    forced by · learning curves are increasing, so a smaller training set gives a genuinely worse model
  2. 2
    Increasing k shrinks that pessimism: at k = n (leave-one-out), each model trains on n−1 examples, essentially the full amount. So on bias, more folds is strictly better.
    forced by · the training-set-size gap between the CV models and the final model closes as k grows
  3. 3
    But the k fold estimates are averaged, and their variance matters too. As k grows, the training sets overlap more and more — two LOO training sets differ in exactly two rows — so the k models are nearly identical, and their errors are almost perfectly correlated.
    forced by · models fit on near-identical data make near-identical mistakes
  4. 4
    Averaging highly correlated estimates does not reduce variance (the same ρσ² floor that governs ensembles). So LOO gives you n estimates with roughly the variance of one, plus each individual test fold is a single point, which is maximally noisy.
    forced by · the variance of an average is bounded below by the shared correlation term
  5. 5
    Therefore k trades bias against variance, and both extremes are bad: k = 2 has large bias (half the data), k = n has large variance and n model fits. The empirical sweet spot at 5–10 keeps the training-size gap small (80–90% of the data) while keeping folds different enough to actually average.
    forced by · the bias term is already small at 90% training data, so further increases in k buy little bias and cost real variance and compute
⇒ Therefore

Therefore 5 or 10 folds is not folklore — it is the point where the bias from a smaller training set has become negligible but the folds are still decorrelated enough for averaging to help.

And note what this predicts: LOO should be preferable specifically when n is very small, since there the bias from holding out 20% is severe and you cannot afford it. It also predicts that repeated k-fold (several shuffles of 5-fold) is the better variance reducer than raising k, because reshuffling produces genuinely different partitions rather than increasingly overlapping ones — which is exactly the recommended practice on small datasets.

Mental modelEvery look at data spends some of its evaluative power

Treat each dataset as having a finite budget of honesty. Every time you use it to make a decision — fit parameters, pick a hyperparameter, choose a feature set, decide to stop — you spend some, and its estimates get a little more optimistic. Data that has informed no decision is the only data that can tell you the truth.

That is why the split is a hierarchy rather than a pair: train pays for parameters, validation pays for choices, test is opened once and then it is gone. And nested CV exists for exactly this reason — the inner loop spends its budget on selection, the outer loop keeps a clean pocket to measure the whole selection procedure.

  • Any dataset used for a decision is contaminated for measurement. Selection is fitting.
  • The split must mirror the deployment gap. Time series need forward-chaining splits; grouped data (multiple rows per user, patient, device) needs group-aware splits or the entity leaks across the boundary.
  • Fit every preprocessing step inside the fold. A scaler or imputer fitted on all data before splitting has already shown the validation fold to the training procedure.
  • Report a confidence interval, not a point. If fold scores span 0.78–0.91, a 0.02 difference between two models is noise and choosing between them is a coin flip.
🔔 Fires when you see

Fire this the moment you see: a best-of-grid CV score quoted as expected production performance · random splits on time-ordered data · a test set that has been looked at more than once · standardisation applied before train_test_split · two models compared on mean CV score with no spread reported · rows from one user appearing in both train and validation.

The tradeoff

You have a fixed compute budget for model selection. Do you run nested CV, a simple train/validation/test split, or plain k-fold and accept the optimism?

Nested cross-validation
+ you gain the only option that gives an unbiased estimate of the entire selection procedure, because the outer folds never participate in choosing anything; it also uses all data for both selection and evaluation, which matters most when data is scarce
− you pay cost multiplies — outer folds × inner folds × configurations, so a 5×5 nested search over 50 configs is 1,250 fits. And it evaluates the procedure, not a specific model: you still have to refit on everything at the end, and the model you ship is not one of the models you measured.
pick when the dataset is small enough that a dedicated holdout would be unreliable, and the performance estimate itself is a deliverable — research, a paper, a go/no-go decision
Single train / validation / test split
+ you gain cheapest by far — one fit per configuration — and conceptually unambiguous: the test set is a specific set of rows nobody has touched. It scales to large data and expensive models where CV is simply infeasible, and it maps naturally onto time-based splits.
− you pay each estimate comes from one partition, so it is high-variance on small data — an unlucky split can be misleading by several points; and it wastes data, since the held-out portions never contribute to training the models you evaluate
pick when the dataset is large (tens of thousands of rows or more, so a single fold is statistically stable), or training is expensive enough that k fits are out of reach — the default for deep learning
Plain k-fold for selection, with a clean final holdout
+ you gain low-variance comparison between configurations from the k-fold average, plus one honest number from a holdout that was never used for selection; roughly the cost of ordinary CV and far cheaper than nested
− you pay the holdout is a single split, so the final number still carries the variance of one partition; and the discipline only works if the holdout truly stays untouched, which is a human problem rather than a technical one
pick when most real projects — you want reliable relative comparisons plus one defensible absolute number, and cannot afford nested CV
What a senior engineer actually does

For most production work, use k-fold on a development set to compare configurations and keep a genuinely untouched holdout for the single number you report. That gives you the low-variance comparisons where they matter (relative ranking of models) and an honest estimate where it matters (the number you tell stakeholders), at ordinary CV cost.

Two disciplines matter more than which scheme you pick. First, make the split mirror deployment — a time-ordered problem evaluated with random folds will look excellent and fail in production, and no amount of nesting fixes that. Second, decide the number of configurations you will try before you start; the selection bias derived above grows with that number, and an unbounded search on a small validation set will eventually find a configuration that is only good at fitting your validation noise.


(c) Hands-on · 25 min

Nested CV + Optuna tuning of an XGBoost classifier, evaluated with PR-AUC on an imbalanced dataset. Save as model_selection_lab.py, uv pip install optuna xgboost imbalanced-learn, uv run model_selection_lab.py.

"""model_selection_lab.py — nested CV with Optuna tuning on an imbalanced problem.
 
Pattern:
  outer StratifiedKFold(5)   — reports unbiased mean ± std PR-AUC
    for each outer fold:
      inner Optuna study      — tunes XGBoost hyperparameters via 3-fold inner CV
      refit best on outer train
      score on outer held-out
Final: train on ALL data with best params from the LAST outer fold, ship.
Held-out test set is scored ONCE at the very end.
"""
from __future__ import annotations
import numpy as np
import optuna
from optuna.samplers import TPESampler
from sklearn.datasets import make_classification
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.metrics import average_precision_score, classification_report
from xgboost import XGBClassifier
 
optuna.logging.set_verbosity(optuna.logging.WARNING)  # keep output readable
RNG = 42
N_OUTER = 5
N_INNER = 3
N_TRIALS = 30
 
 
def make_data(pos_frac: float = 0.05, n: int = 15_000):
    return make_classification(
        n_samples=n, n_features=25, n_informative=8,
        weights=[1 - pos_frac, pos_frac], random_state=RNG,
    )
 
 
def objective(trial: optuna.Trial, X, y) -> float:
    """A single Optuna trial: sample params, run inner CV, return mean PR-AUC."""
    params = {
        "n_estimators": trial.suggest_int("n_estimators", 100, 500, step=50),
        "max_depth": trial.suggest_int("max_depth", 3, 10),
        "learning_rate": trial.suggest_float("learning_rate", 0.01, 0.3, log=True),
        "subsample": trial.suggest_float("subsample", 0.5, 1.0),
        "colsample_bytree": trial.suggest_float("colsample_bytree", 0.5, 1.0),
        "min_child_weight": trial.suggest_int("min_child_weight", 1, 10),
        "reg_lambda": trial.suggest_float("reg_lambda", 1e-3, 10.0, log=True),
        "scale_pos_weight": trial.suggest_float("scale_pos_weight", 1.0, 20.0),
    }
    clf = XGBClassifier(
        **params,
        eval_metric="aucpr",
        random_state=RNG,
        tree_method="hist",
        verbosity=0,
    )
    inner_cv = StratifiedKFold(n_splits=N_INNER, shuffle=True, random_state=RNG)
    scores = cross_val_score(clf, X, y, cv=inner_cv, scoring="average_precision", n_jobs=-1)
    return float(scores.mean())
 
 
def tune_one_outer_fold(X_train_outer, y_train_outer) -> dict:
    study = optuna.create_study(direction="maximize", sampler=TPESampler(seed=RNG))
    study.optimize(
        lambda t: objective(t, X_train_outer, y_train_outer),
        n_trials=N_TRIALS,
        show_progress_bar=False,
    )
    return study.best_params
 
 
def nested_cv(X, y) -> tuple[list[float], list[dict]]:
    """Outer 5-fold. Returns (scores_per_fold, best_params_per_fold)."""
    outer_cv = StratifiedKFold(n_splits=N_OUTER, shuffle=True, random_state=RNG)
    scores, params_list = [], []
    for i, (tr_idx, te_idx) in enumerate(outer_cv.split(X, y), 1):
        Xtr, Xte = X[tr_idx], X[te_idx]
        ytr, yte = y[tr_idx], y[te_idx]
 
        best_params = tune_one_outer_fold(Xtr, ytr)
        clf = XGBClassifier(
            **best_params, eval_metric="aucpr", random_state=RNG,
            tree_method="hist", verbosity=0,
        )
        clf.fit(Xtr, ytr)
        prob = clf.predict_proba(Xte)[:, 1]
        pr_auc = average_precision_score(yte, prob)
        print(f"  outer fold {i}/{N_OUTER}: PR-AUC = {pr_auc:.3f}  best_params={best_params}")
        scores.append(pr_auc)
        params_list.append(best_params)
    return scores, params_list
 
 
if __name__ == "__main__":
    X, y = make_data()
    print(f"Dataset: n={len(y)}, positives={int(y.sum())} ({y.mean():.2%})")
 
    # Held-out set — locked in a drawer until the very end
    X_dev, X_holdout, y_dev, y_holdout = train_test_split(
        X, y, test_size=0.15, stratify=y, random_state=RNG,
    )
    print(f"\nHeld-out size: {len(y_holdout)}  positives: {int(y_holdout.sum())}")
 
    print("\nNested CV on the dev set:")
    scores, params_list = nested_cv(X_dev, y_dev)
    print(f"\nNested CV mean PR-AUC: {np.mean(scores):.3f} ± {np.std(scores):.3f}")
 
    # Final model: tune ONE more time on ALL dev data, refit, score held-out ONCE
    print("\nFinal tuning on full dev set...")
    final_params = tune_one_outer_fold(X_dev, y_dev)
    print(f"Final params: {final_params}")
    final = XGBClassifier(
        **final_params, eval_metric="aucpr", random_state=RNG,
        tree_method="hist", verbosity=0,
    ).fit(X_dev, y_dev)
 
    prob = final.predict_proba(X_holdout)[:, 1]
    holdout_pr_auc = average_precision_score(y_holdout, prob)
    print(f"\nHELD-OUT PR-AUC (scored once): {holdout_pr_auc:.3f}")
    print("\nClassification report @ default 0.5 threshold:")
    print(classification_report(y_holdout, (prob >= 0.5).astype(int), digits=3))

Anatomy of the script

Anatomy of the script

Line 26 · N_TRIALS = 30
Optuna trials per outer fold. Bump to 100+ for a real project. TPE typically finds a near-optimal region within 20-30 trials on 5-10 dims.
budget
Line 38 · trial.suggest_float(..., log=True)
Log-scale sampling for learning rate and regularisation — orders of magnitude matter more than linear increments here.
search
Line 51 · cross_val_score(..., scoring='average_precision')
PR-AUC as the search metric (see S092). If you use ‘accuracy’ on imbalanced data, Optuna will happily find a config that predicts all-negatives.
eval
Line 60 · optuna.create_study(direction='maximize', sampler=TPESampler(seed=RNG))
TPE is the default Bayesian sampler. seed for reproducibility. For very expensive trials, add MedianPruner for early stopping.
optuna
Line 71 · outer_cv = StratifiedKFold(...)
The OUTER loop. Never touched by tuning. This is the honest performance estimate.
safety
Line 89 · train_test_split(test_size=0.15)
Held-out set locked away BEFORE any CV happens. It is scored exactly once, at the very end. This is what you'd quote on a slide to the exec.
safety
Line 103 · final_params = tune_one_outer_fold(X_dev, y_dev)
One final tuning round on ALL dev data. This is the model you ship. The nested-CV score is the honest estimate of how it'll perform; this final refit is the actual artifact.
ship
Try itSee the nested-CV overhead pay for itself

Add a "non-nested" comparison that runs a single Optuna study on ALL dev data and reports the study.best_value:

def non_nested_score(X_dev, y_dev):
    study = optuna.create_study(direction="maximize", sampler=TPESampler(seed=RNG))
    study.optimize(lambda t: objective(t, X_dev, y_dev), n_trials=N_TRIALS)
    return study.best_value

Compare it to np.mean(scores) from nested_cv. Non-nested is usually 2-5 points higher — that's the amount of over-fitting to the tuning process. If you'd reported the non-nested number, you'd be lying to the exec by that amount.

💡 Hint · The non-nested score is optimistic — it's the mean of the INNER CV scores at the best-found hyperparameters. Nested is the honest score.

(d) Production reality · 15 min

War story Kaggle · every competitiontens of thousands of participants
🔥 What broke

Standard failure: participant tunes 500 hyperparameter combinations, picks the one with the best public leaderboard score, and celebrates. Private leaderboard rank drops 200+ places because they've been over-fitting to the public leaderboard as if it were a validation set.

🧯 The fix

The pros never use the public leaderboard as a validation set. They: (a) build a local CV that mirrors the public/private split (usually StratifiedKFold or GroupKFold), (b) trust local CV over the public leaderboard when they disagree, (c) submit only 1-2 models per day to avoid leaking public-test signal into their choices.

🎓 Lesson to steal
Any metric you tune against becomes optimised, and over-fits. Keep a true held-out set — locked in a drawer — that you touch once at the end. That's your only unbiased number.
Post-mortem
War story Netflix — recommendation systems200M+ users, billions of interactions
🔥 What broke
An early recommender was cross-validated with random KFold on (user, movie, rating) rows. Offline RMSE was excellent. In A/B test, engagement dropped. Root cause: the same user appeared in both train and test folds — the model was memorising ‘user X likes movies like this’, which doesn't work for new users.
🧯 The fix

Switched to GroupKFold on user_id: a user's rows are entirely in train or entirely in test, never both. Cold-start performance suddenly became visible and the team could optimise for it.

🎓 Lesson to steal
Random KFold is wrong whenever rows are not independent. Groups (user, session, patient), time (yesterday's stock predicting tomorrow's), and hierarchies (posts nested in threads) all break i.i.d. and need a grouped or time-aware CV.
War story A quant hedge fund · common failure modemillions of dollars per model
🔥 What broke
A researcher used KFold on 5 years of daily stock returns, tuned a random forest to a beautiful 0.75 IC (information coefficient). Deployed → lost money for 6 months. Root cause: random KFold shuffled the days, so training data included days after the test days. The model had ‘seen the future.’
🧯 The fix

TimeSeriesSplit with expanding window: fold k trains on days 1..t\_k, tests on days t\_k+1..t\_k+30. All out-of-sample. IC dropped to 0.12 — the honest number. The model was still profitable, but at 1/3 the confidence.

🎓 Lesson to steal
On time-series data, using random KFold is a bug that will silently look great and lose money in production. TimeSeriesSplit is not optional — it's the whole difference between research and reality.

Where this shows up in the rest of the plan

Every model you build from now on lives inside a CV loop
S099 · Optimisers
Learning rate is a hyperparameter — Optuna tunes it in the same pattern.
S101 · Regularisation in DL
Dropout rate + weight decay are hyperparameters — same nested CV story.
S104 · Feature stores in production
Point-in-time joins from feature stores make time-aware CV feasible at scale.
S121 · LLM evaluation
Cross-validated evaluation on prompt templates instead of hyperparameters — same discipline.
S128 · MLOps monitoring
Compare production performance to the held-out estimate; alert when they diverge.
S130 · Capstone
Your final review deck opens with the nested-CV number and the held-out score.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three to a friend without notes, redo the session:

  1. Why is k-fold better than a single train/test split?
  2. When do you use TimeSeriesSplit or GroupKFold instead of KFold?
  3. What is nested CV and why does it matter?

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.