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.
🎯 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.
- 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
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.
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
- 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
- 1974Cross-validation formalised · StoneMervyn Stone's paper ‘Cross-Validatory Choice and Assessment of Statistical Predictions’ gives the technique its name.
- 1995GridSearchCV enters the ML playbookCombinatorial search over hyperparameters becomes the default; still is at most bootcamps.
- 2012Random Search beats Grid Search · Bergstra & BengioRandom sampling of hyperparameters outperforms grid on high-dimensional spaces. The community slowly adopts.
- 2019Optuna 1.0 · Preferred NetworksTPE (Tree-structured Parzen Estimator) with pruning. Becomes the default in industry within 2 years.
- 2023AutoML tools productionise nested CVauto-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
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)
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
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
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
Try every combination. Explodes combinatorially with number of parameters. Fine for 2-3 hyperparameters with 3-5 values each.
Sample N random combinations. Beats grid on high-dim problems (Bergstra 2012). Simple, no dependencies, good baseline.
Uses past trials to propose better ones. Converges 5-10× faster than random on non-trivial spaces. Handles conditional parameters, prunes bad trials early.
Trial early-stopping — spend the compute budget on promising configs. Ray Tune and Optuna both support.
"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."
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.
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.
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, correctlyWhy 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.
- 1The 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
- 2Increasing 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
- 3But 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
- 4Averaging 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
- 5Therefore 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 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.
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.
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.
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?
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
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_valueCompare 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.
(d) Production reality · 15 min
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 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.
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.
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.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three to a friend without notes, redo the session:
- Why is k-fold better than a single train/test split?
- When do you use TimeSeriesSplit or GroupKFold instead of KFold?
- 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.