Skip to content
ML Academy

Core day 15 of 60 · Week 3 · Part I · evaluation depth

Validation and Optimization

A no-show model posts a validation AUC of 0.98 after a week of tuning. Which rows could the model, its features and your tuning loop have seen the answers on, and what does a split that mimics deployment say?

By the end: You choose a validation scheme from what is new at deployment, show the same model scoring very differently under random, group and time-ordered splits, show the best validation score rising with the number of tuning tries while fresh-row skill stays flat, and name the leakage type behind a too-good score.

At a glance

A score is only as honest as its split: hold out what deployment will see as new, choose on one block, and report once on another.

  • Three roles

    Training rows fit, validation rows choose, a sealed test block reports once.

  • Split = deployment

    Hold out whole entities, the future, or both: whatever is new at prediction time.

  • Five leakage paths

    Target, feature windows, preprocessing, groups and time; fit every step inside the fold.

  • Tuning inflates

    The best of k scores is a maximum: it rises with tries while fresh-row skill stays flat.

  • Report honestly

    Baseline under the same split, fold spread, number of tries, one look at the test block.

Recall · 5 min

Without notes: write the Day 2 cutoff and baseline for a no-show call-back, then the Day 9 order of operations. Then say why Day 4 expects one "significant" segment among 20 even when nothing is going on: today's tuning tries are the same arithmetic.

Open due reviews

Sign in to record this block.

Concept · 10 min

A validation score forecasts how the model will do after deployment. Every choice made with a block of rows (a feature, a setting, a stopping round) spends some of that block's independence. Keep three roles apart: training rows fit, validation rows choose, a sealed test block reports once, and hold out exactly what deployment will see as new.

Random splits and many tuning tries both report scores that deployment does not deliver

Predict first

The best of 50 tuning tries scores 0.80 accuracy on a 500-row test block, and every candidate truly scores about 0.75. Roughly what will the chosen model score on new data?

Text description of the figure

A simulated panel of customers over periods. A deployment selector (new customers or existing customers), a customer-effect slider and a leaked-column toggle drive a bar chart of validation R-squared for random, group, time and group-plus-time splits, with a line for the R-squared actually achieved on deployment rows and a map showing which customer-period cells are train or test. A second panel has a tuning-tries slider (1 to 200) and a test-size slider, plotting the best reported test accuracy against the true accuracy of the chosen model.

Which rows do which job Diagram
Labelled rowsordered by timeTraining rowsfit parametersValidation rowschoose candidatesTest blocksealed, one lookReportwith baseline, spread,triesUsed to choose?Now a validation setits score is a maximumonceanother lookyes
  1. Labelled rowsordered by time
  2. Training rowsfit parameters
  3. Validation rowschoose candidates
  4. Test blocksealed, one look
    • once: to Report
  5. Used to choose?
    • yes: to Now a validation set
  6. Reportwith baseline, spread, tries
  7. Now a validation setits score is a maximum

Every look that changes a decision moves a block one step to the left.

Pick the split from what deployment sees as new Map

Time-ordered, with a gap

Rolling origin; entity histories from before the cutoff stay legitimate.

Group plus time

Hold out later periods and unseen entities together.

Shuffled or stratified k-fold

Only when rows are exchangeable: no repeated entities, no drift.

Group k-fold

Whole patients, customers, stores or PNRs on one side only.

Entities can be patients, customers, stores, machines or PNRs. Histories of returning entities are legitimately known at prediction time, so holding them out would be too pessimistic.

The split painter: which cells train, which test, and what that promises Illustration
Random rowsweek 1 → 20entity

Test entities already in training: 100%. Training rows dated after the first test week: 95%.

Group (whole entities)week 1 → 20entity

Test entities already in training: 0%. Training rows dated after the first test week: 95%.

Time (later weeks)week 1 → 20entity

Test entities already in training: 100%. Training rows dated after the first test week: 0%.

Group + timeweek 1 → 20entity

Test entities already in training: 0%. Training rows dated after the first test week: 0%.

  • training cell
  • test cell
  • not used by this split
Validation R² under each split, against deployment00.50.83Random rows0.38Group0.68Time0.33Group + timenew entities 0.14known entities 0.64

Random rows promise R² 0.83; the model reaches 0.14 on new entities and 0.64 on known entities in later weeks. The split closest to each deployment is the one that holds out what is new: group + time (0.33) for new entities, time (0.68) for known ones. Even the right split is one noisy estimate.

Simulation from the split lab: 40 entities x 20 weeks of history, a persistent entity effect, a slow drift and a ridge model with an entity target encoding; deployment = the next 6 weeks, for 40 new or the same 40 entities. Computed on the page.

Rows are entities, columns are weeks. Each grid counts its own leaks; the bars show the validation score each split reports against what deployment delivers.

One model, three splits Chart
  • Forest, shuffled 5-foldrecognizes parties and weeks0.842 AUC
  • Forest, group 5-fold by PNRparties held out0.692 AUC
  • Forest, time-ordered, gap 6mimics deployment0.619 AUC
  • Baseline, time-ordered, gap 60.610-0.613 under every split0.613 AUC

Computed by the application script on synthetic PNRs. The honest gain over the baseline is 0.006, not 0.23.

More tuning tries, higher best score, same skill Chart
-0.0500.050.10.151020304050Best validation score — Tuning tries (k): 1, AUC above chance: -0.007Best validation score — Tuning tries (k): 3, AUC above chance: 0.076Best validation score — Tuning tries (k): 10, AUC above chance: 0.107Best validation score — Tuning tries (k): 50, AUC above chance: 0.138Best validation scoreSame pick on fresh rows — Tuning tries (k): 1, AUC above chance: 0.025Same pick on fresh rows — Tuning tries (k): 3, AUC above chance: 0.032Same pick on fresh rows — Tuning tries (k): 10, AUC above chance: 0.029Same pick on fresh rows — Tuning tries (k): 50, AUC above chance: 0.021Same pick on fresh ro…Tuning tries (k)AUC above chance
  • Best validation score
  • Same pick on fresh rows

Application script, AUC minus 0.5: the best validation AUC rises from 0.493 to 0.638; fresh rows stay at 0.521-0.532.

Key terms: train, validation and test roles, k-fold, group k-fold, rolling origin with a gap, leakage, tuning, test-set reuse and nested CV. How to pick the split, the five leakage paths and the size of tuning optimism are in the pictures above and in the optional technical depth below.

Assumptions

  • Deployment is stated: today new travellers on new PNRs, scored after training; for a hospital, new patients; for a lender, next month's applicants. A different deployment needs a different split.

  • The test block is used once, after every choice, and is large enough that its noise is below the gaps you care about (Day 4).

  • Columns exist at the cutoff in production as in the table; no split detects a column recorded after the outcome.

Sign in to record this block.

Application and workplace decision · 10 min

  • Day 2 card: call a traveller back before departure; grain one traveller on a booking reference (PNR); target no-show; baseline logistic regression on lead time; metric ROC AUC (Day 16 prices it).
  • Data: 1,500 synthetic PNRs (about 6,000 travellers, in creation order). Each party shares an unobserved habit, each week an unobserved shock.
  • Block 1: the baseline and a deep forest under shuffled 5-fold, 5-fold grouped by PNR, and five time-ordered splits with a gap of 6 rows (the largest party).
  • Block 2: keep the best of k random 4-feature subsets of 40 weak features by a 300-row validation block, then re-score it on 3,200 fresh rows.
  • Predict first: under which split does the forest fall most?

Python

import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import GroupKFold, KFold, TimeSeriesSplit
rng = np.random.default_rng(15)
n = 1500                                              # booking references (PNRs), 2-6 travellers each, in creation order
size, t = rng.integers(2, 7, n), np.sort(rng.random(n))
lead, fare, party = rng.gamma(2, 15, n), rng.lognormal(5, 0.4, n), rng.normal(0, 2, n)  # party: unobserved habit of a PNR
week = rng.normal(0, 1.5, 52)[(t * 52).astype(int)]   # unobserved shock shared by every booking made in one week
logit = -2.6 + 0.9 * (lead - lead.mean()) / lead.std() + (fare < 120) + party + week
pnr = np.repeat(np.arange(n), size)
X = np.c_[lead, fare, t * 365, rng.normal(size=n)][pnr]  # lead, fare, booking day, noise
y = rng.random(len(pnr)) < 1 / (1 + np.exp(-logit[pnr]))
models = {"baseline (logistic on lead)": (LogisticRegression(), [0]),
          "deep forest": (RandomForestClassifier(150, n_jobs=-1, random_state=0), [0, 1, 2, 3])}
for name, folds in {"random 5-fold": KFold(5, shuffle=True, random_state=0).split(X),
                    "group 5-fold (by PNR)": GroupKFold(5).split(X, y, pnr),
                    "rolling time, gap 6": TimeSeriesSplit(5, gap=6).split(X)}.items():
    folds, row = list(folds), []
    for m, (model, cols) in models.items():
        a = [roc_auc_score(y[te], model.fit(X[tr][:, cols], y[tr]).predict_proba(X[te][:, cols])[:, 1]) for tr, te in folds]
        row.append(f"{m} {np.mean(a):.3f}")
    print(f"{name:22s} " + " | ".join(row))
# tuning tries: 40 weak features; keep the best of k random 4-feature subsets by a 300-row validation block
Xs = rng.normal(size=(5000, 40))
ys = rng.random(5000) < 1 / (1 + np.exp(-(0.08 * Xs.sum(axis=1) - 1.5)))
def auc(cols, rows):
    m = LogisticRegression().fit(Xs[:1500][:, cols], ys[:1500])
    return roc_auc_score(ys[rows], m.decision_function(Xs[rows][:, cols]))
subsets = [rng.choice(40, 4, replace=False) for _ in range(200)]
val = np.array([auc(c, slice(1500, 1800)) for c in subsets])
for k in (1, 3, 10, 50):
    print(f"{k:3d} tries: best validation AUC {val[:k].max():.3f} | same subset on fresh rows {auc(subsets[val[:k].argmax()], slice(1800, 5000)):.3f}")

This code is executed in the repository's checks (pnpm lesson:py / lesson:sql). It is not run on this site; copy it into your notebook.

Sign in to record this block.

Mini-assessment · 8 min

Your first answer is your evidence. You can re-answer later in practice mode; that is labelled practice.

Question 1 · easy

A model is fit on one block of rows, tuned on a second, and a third block is held back. Which use of the held-back block keeps its score an honest estimate of deployment performance?
Options

How confident are you? (optional, used for calibration)

Question 2 · medium

A churn model scores corporate accounts every month. The table holds 36 monthly rows for each of 5,000 accounts, and the model will be used on accounts signed next quarter, in future months. Which validation scheme matches this deployment?
Options

How confident are you? (optional, used for calibration)

Question 3 · hard

A no-show model scores validation AUC 0.98 under shuffled 5-fold CV after a 300-configuration search; each booking reference holds 2 to 6 travellers. You re-score the best configuration, adding one change at a time: grouped by reference 0.71; then also time-ordered with a 7-day gap 0.70; then also with the suspicious column (gate scan status) removed 0.70. What is best supported?
Options

How confident are you? (optional, used for calibration)

Question 4 · medium

A no-show model is trained on growing samples under a grouped, time-ordered validation. AUC on training / validation rows (illustrative values): 2,000 rows 0.99 / 0.62; 8,000 rows 0.97 / 0.66; 32,000 rows 0.94 / 0.70. What is the best reading and next step?
Options

How confident are you? (optional, used for calibration)

Question 5 · hard

A no-show model's feature `route_noshow_rate` is built by one SQL query that averages no-show over every booking of the route in the 2024 table and joins it to each booking. Under a time-ordered split with a gap it scores AUC 0.81 against 0.61 for the lead-time baseline (illustrative figures); in the first production month it scores 0.62. Preprocessing is inside the pipeline and no column is an outcome. What went wrong and what is the fix?
Options

How confident are you? (optional, used for calibration)

Sign in to complete lessons and keep your progress.

Technical depth · optional · 34 min in total

Open only what you need. Nothing here is required to complete the day; the assessment covers the core above.

What a validation score estimates: K-fold CV worked by hand7 minThe target quantity, the K-fold estimator, a three-fold run on six numbers you can redo on paper, and the bias and variance of the estimate.
What a validation score estimates, and the K-fold estimator Formula
Err=E[L(Y,f^(X))]CVK=1n∑k=1K∑i∈FkL(yi,f^(−k)(xi))\begin{gathered} \mathrm{Err} = \mathbb{E}\big[L(Y, \hat f(X))\big] \\ \mathrm{CV}_K = \frac{1}{n}\sum_{k=1}^{K} \sum_{i \in F_k} L\big(y_i, \hat f^{(-k)}(x_i)\big) \end{gathered}
Err\mathrm{Err}
expected loss on new rows drawn from the deployment distribution: the quantity every split tries to estimate
FkF_k
the rows of fold k; every row is held out exactly once
f^(−k)\hat f^{(-k)}
the model refit, with every learned step, on all rows outside fold k
LL
the loss per row (squared error, log loss); ranking metrics such as AUC are computed per fold and averaged instead

CV estimates the expected error of the fitting procedure over training sets of size n(K-1)/K, more than the error of the one model you ship (ESL, section 7.12).

Cross-validation by hand: six insurance claims, three folds Table
FoldTraining rowsPrediction (training mean)Squared errors on the fold
Datasix claim amounts (k EUR): 2, 4, 6, 8, 10, 12model: predict the training mean-
Fold 1 = {2, 8}4, 6, 10, 128.0(2 - 8)^2 = 36; (8 - 8)^2 = 0
Fold 2 = {4, 10}2, 6, 8, 127.09; 9
Fold 3 = {6, 12}2, 4, 8, 106.00; 36
3-fold CVmean of the six held-out squared errors-90 / 6 = 15.0
In-sample errorthe same model fitted and scored on all six7.070 / 6 = 11.7: too low
Leave-one-outeach claim predicted by the mean of the other fivemean of five16.8
Theorynew-row error of a mean of m rows: sigma^2 (1 + 1/m); sample variance 14-17.5 for m = 4; 16.8 for m = 5

Hypothetical claims, verified in numpy. In-sample error reuses the rows that fitted the mean, so it is optimistic; held-out rows are not. Leave-one-out matches the theory for m = 5 exactly; 3-fold trains on 4 rows (theory 17.5), and with six rows its 15.0 is itself noisy.

Choosing K: a little bias against a lot of variance Comparison
CriterionK = 5 or 10the usual compromiseLeave-one-out (K = n)nearly all rows each time
Each fold model trains on80-90% of the rowsn - 1 rows
Bias of the estimateSlightly pessimistic; most where the learning curve is still steepNearly unbiased
Variance of the estimateModerateCan be high: the n fold models are almost identical
CostK fitsn fits, unless a shortcut exists

Both assume exchangeable rows: held-out rows as new as deployment rows; repeated entities and time break it (next chapters). Fold scores share training rows, and no unbiased estimator of the variance of K-fold CV exists in general (Bengio and Grandvalet, 2004), so sd / sqrt(K) understates the uncertainty.

Three roles and a protocol3 minWhat each block of rows is for, in which order to use them, and how a test block gets spent.
A validation protocol in five steps Sequence
  1. State the deployment

    What is new at prediction time: customers, patients, stores, machines, weeks, or travellers on new PNRs.

  2. Seal the test block

    Latest period and whole groups; log every access.

  3. Fix the split rule

    Group, time with a gap, or both; record seeds, row ids and library versions.

  4. Search inside the folds

    A pipeline refits every learned step per fold; count every try.

  5. Score once

    Report test score, baseline, fold spread and the number of tries.

With plenty of data a sealed latest-period block is the simplest honest report; with little data nested CV replaces it. A score without its split rule, seeds, versions and tries cannot be re-derived.

Choosing a split from deployment4 minShuffled, stratified, group and time-ordered schemes, and the question that picks between them.
Split schemes and when they are honest Table
SchemeHolds outHonest whenscikit-learn splitter
Shuffled k-foldRandom rowsRows are exchangeable: no repeated entities or periodsKFold(shuffle=True)
Stratified k-foldRandom rows, class shares keptAs above, with a rare class; keeps fold counts stableStratifiedKFold
Group k-foldWhole entities (patient, customer, store, PNR)The model will score entities it never sawGroupKFold
Stratified group k-foldWhole entities, class shares kept as far as groups allowNew entities and a rare classStratifiedGroupKFold
Time-orderedThe latest periodThe model predicts the future (Day 25)TimeSeriesSplit

A baseline with nothing to memorize barely moves between schemes; a large move in the model's score is the signature of memorization.

Time-ordered splits: expanding, rolling and the gapadvanced4 minHow rolling-origin folds are laid out, when to slide the window, and how wide the gap must be.
Time-ordered schemes: expanding, rolling, gap Comparison
CriterionExpanding originRolling windowGap or embargo
Training windowAll rows before the cutoff; grows with each foldA fixed recent span that slides forwardRows dropped between the end of training and the test start
Good forStable patterns and small dataDrift, when recent behaviour matters mostLate-maturing labels, multi-step horizons, shocks shared by neighbours
Watch out forEarly folds train on little data; old regimes keep their weightLess data per fit; the window length is itself a choice to validateMake it at least the horizon plus the label delay
In scikit-learnTimeSeriesSplit, defaultTimeSeriesSplit(max_train_size=...)TimeSeriesSplit(gap=...)

The gap combines with either window, and with groups: inside each time fold, entities seen before are allowed or excluded to match deployment. Day 25 adds horizon-wise scoring for forecasts.

Rolling origin with a gap, fold by fold (example layout) Table
FoldTrainGap, droppedTest
1Weeks 1-20Week 21Weeks 22-25
2Weeks 1-25Week 26Weeks 27-30
3Weeks 1-30Week 31Weeks 32-35
3, rolling windowWeeks 11-30Week 31Weeks 32-35

Each test block lies after its training rows; the gap stops labels and shared shocks bridging the cutoff. The application's gap is 6 rows, the largest party, so no PNR straddles it. Late-maturing labels (a no-show at departure, a default after 90 days) need the gap to cover that delay. TimeSeriesSplit (1.9.1) defaults: 5 splits, expanding, gap 0.

Five leakage paths and the pipeline guard4 minWhere answers sneak into training rows, which re-split exposes each, and why preprocessing belongs inside the fold.
Five leakage paths and their guards Table
TypeWhat leaksExampleGuard
TargetThe outcome or its consequenceA 'claim settled' flag in an insurance fraud model; gate scan status for no-showsAvailability audit at the cutoff
Feature generationWindows that reach past the cutoffA 30-day card-transaction count that ends after the decision datePoint-in-time joins
PreprocessingSteps fit before the splitScaler, selection, encoder or resampling on all rows (Day 9)Pipeline refit inside each fold
GroupThe same entity on both sidesOne patient's visits, or one PNR's travellers, in train and testGroup k-fold
TemporalNeighbouring or future periods in trainingA weekly promotion shock in retail sales shared by train and testTime-ordered split with a gap

A working five-way grouping; published taxonomies split these further. Only the first two need a timing audit; no re-split detects them.

Feature selection before or inside the folds Diagram
All rowslabels are purenoiseSelect 20 of 2,000features, using everylabel5-fold CVAccuracy 0.870pure leakageAll rowssame data5-fold CVSelect inside eachfoldtraining rows only, in apipelineAccuracy 0.500the truth
  1. All rowslabels are pure noise
    • to Select 20 of 2,000
  2. All rowssame data
    • to 5-fold CV
  3. Select 20 of 2,000features, using every label
    • to 5-fold CV
  4. 5-fold CV
    • to Select inside each fold
  5. 5-fold CV
    • to Accuracy 0.870
  6. Select inside each foldtraining rows only, in a pipeline
    • to Accuracy 0.500
  7. Accuracy 0.870pure leakage
  8. Accuracy 0.500the truth

Computed in the hands-on lab (block B). Scalers, encoders and resampling leak the same way, usually by less: a Pipeline passed to cross_val_score or GridSearchCV refits every step on each training fold.

Locate a leak by re-splitting one factor at a time Diagram
Score too goodshuffled foldsDrops undergroup folds?Group leakagehold out whole entitiesDrops undertime folds?Temporal leakagetime-ordered split witha gapHigh underevery split?Target or windowleakaudit each column'stimestampCount the trieswhat remains isselectionyesnoyesnoyesno
  1. Score too goodshuffled folds
  2. Drops under group folds?
    • no: to Drops under time folds?
  3. Group leakagehold out whole entities
  4. Drops under time folds?
    • no: to High under every split?
  5. Temporal leakagetime-ordered split with a gap
  6. High under every split?
    • no: to Count the tries
  7. Target or window leakaudit each column's timestamp
  8. Count the trieswhat remains is selection

A target or feature-window leak lifts every split alike, so no re-split exposes it; only an audit of each column's timestamp against the cutoff finds it.

Tuning and the winner's curse4 minWhy the best of many scores is too high, how much too high, and why a stopping round counts as a try.
How much the best of k scores overstates skill Formula
E[max⁡j≤ks^j]=s+σ mk\mathbb{E}\left[\max_{j \le k} \hat s_j\right] = s + \sigma\, m_k
ss
true score shared by k equally good candidates
σ\sigma
noise of one score on the validation block; about 0.019 for accuracy 0.75 on 500 rows
mkm_k
expected maximum of k standard normals: 1.16 at k = 5, 1.54 at 10, 2.25 at 50, 2.75 at 200

Assumes independent, normal score noise. Candidates scored on the same rows are correlated, which shrinks the optimism without removing it.

The expected-maximum table behind tuning optimism Table
Tries km_k: expected max of k standard normalssqrt(2 ln k)Optimism at 500 rows (points)At 5,000 rows (points)
10000
20.5641.181.090.35
30.8461.481.640.52
51.1631.792.250.71
101.5392.152.980.94
201.8672.453.621.14
502.2492.804.361.38
1002.5083.034.861.54
2002.7463.265.321.68

The maximum of k independent standard normals has CDF Phi(t)^k, so m_k is the mean of its density k phi(t) Phi(t)^(k-1), integrated numerically. Optimism = 100 x sigma x m_k with sigma = sqrt(0.75 x 0.25 / rows), 0.0194 at 500 rows. The sqrt(2 ln k) shortcut overstates every row here.

The winner of a search is mostly the luckiest draw Illustration
One search: 50 tries, 500-row validation block0.700.750.80reported winnercandidates, by validation score

50 candidates sorted by their validation score (dots) with their true accuracy (bars). The winner scores 0.815; its true accuracy is 0.761. The truly best candidate (0.768) scored 0.786.

Reported best versus true skill of the pick0.700.750.80125102050100200reported best, 500 rowsreported best, 5,000 rowstrue skill of the pick (solid 500, dashed 5,000)accuracytuning tries (log scale)

Average over 200 searches. At 50 tries the reported best is 0.798 with 500 rows, while the chosen model is truly 0.760; with 5,000 rows the gap shrinks from 0.038 to 0.007, and the less noisy block also picks a truly better model (0.770).

Simulation: candidates' true accuracies drawn around 0.75 with SD 0.01; each validation score adds binomial sampling noise (normal approximation); independent noise across candidates. Computed on the page with the split lab's engine.

Left, one search of 50 near-equal candidates; right, the average over many searches as the number of tries grows.

Optimism grows with tries and shrinks only slowly with rows Chart
024650100150200Validation block, 500 rows — Tuning tries (k): 1, Optimism, accuracy points: 0Validation block, 500 rows — Tuning tries (k): 2, Optimism, accuracy points: 1.09Validation block, 500 rows — Tuning tries (k): 3, Optimism, accuracy points: 1.64Validation block, 500 rows — Tuning tries (k): 5, Optimism, accuracy points: 2.25Validation block, 500 rows — Tuning tries (k): 10, Optimism, accuracy points: 2.98Validation block, 500 rows — Tuning tries (k): 20, Optimism, accuracy points: 3.62Validation block, 500 rows — Tuning tries (k): 50, Optimism, accuracy points: 4.36Validation block, 500 rows — Tuning tries (k): 100, Optimism, accuracy points: 4.86Validation block, 500 rows — Tuning tries (k): 200, Optimism, accuracy points: 5.32Validation block, 500…Validation block, 5,000 rows — Tuning tries (k): 1, Optimism, accuracy points: 0Validation block, 5,000 rows — Tuning tries (k): 2, Optimism, accuracy points: 0.35Validation block, 5,000 rows — Tuning tries (k): 3, Optimism, accuracy points: 0.52Validation block, 5,000 rows — Tuning tries (k): 5, Optimism, accuracy points: 0.71Validation block, 5,000 rows — Tuning tries (k): 10, Optimism, accuracy points: 0.94Validation block, 5,000 rows — Tuning tries (k): 20, Optimism, accuracy points: 1.14Validation block, 5,000 rows — Tuning tries (k): 50, Optimism, accuracy points: 1.38Validation block, 5,000 rows — Tuning tries (k): 100, Optimism, accuracy points: 1.54Validation block, 5,000 rows — Tuning tries (k): 200, Optimism, accuracy points: 1.68Validation block, 5,0…Tuning tries (k)Optimism, accuracy points
  • Validation block, 500 rows
  • Validation block, 5,000 rows

Computed from the formula above with true accuracy 0.75. Ten times the rows cuts the optimism by only about 3.2 (the square root of 10), so a smaller search helps more than a bigger block. Report the number of tries next to the score.

Early stopping is tuning too Chart
  • Validation, at the chosen roundround 16 was picked on these rows0.759 accuracy
  • Sealed test, same roundthe number to report0.719 accuracy

Computed in the hands-on lab (block D). Each boosting round is one more candidate, as are features, settings and model families (Day 4: many looks, one maximum).

Nested cross-validationadvanced3 minAn outer loop for the estimate, an inner loop for the search, and what the result actually estimates.
Nested cross-validation Diagram
Outer splitk folds forassessmentOuter training foldsInner CVthe whole search,incl. selectionRefit the winneron the outer trainingfoldsScore the outer foldthe search never saw itAverage outer scoresestimates the procedureFinal modelrerun the search on allrowsper foldthen
  1. Outer splitk folds for assessment
  2. Outer training folds
  3. Inner CVthe whole search, incl. selection
    • to Refit the winner
  4. Final modelrerun the search on all rows
  5. Refit the winneron the outer training folds
  6. Score the outer foldthe search never saw it
  7. Average outer scoresestimates the procedure
    • then: back to Final model

The pick in each outer fold depends on the outer training rows only, so the outer test fold is fresh for the whole procedure, search and feature selection included. Cost multiplies by the outer folds. In scikit-learn, GridSearchCV's best_score_ is non-nested; cross_val_score(GridSearchCV(...), X, y) is nested.

Non-nested versus nested CV against fresh rows Illustration
R² by dataset (150 rows each, 30 candidates)0.00.20.3123456best CV score of the searchnested CV estimatethe pick on 2,000 fresh rowsdashed: meanssimulated dataset

Simulation: 20 standard-normal inputs, y = 0.4 x1 + 0.4 x2 + noise (a model with both true inputs reaches R² about 0.24); each candidate is a linear regression on a random set of three inputs, 30 candidates per search. Computed on the page.

Means over 6 datasets: best CV score 0.194, nested 0.168, fresh rows 0.143. The search's own best score overstates the pick by 0.052 on average; nested CV, which re-runs the search inside each outer fold, lands closer. Where every outer fold picks the same candidate, nested and non-nested coincide. Single datasets scatter widely either way.

Each column is one small simulated dataset and one 30-candidate search, scored three ways.

Tuned and reported on the same folds, or nested Chart
  • Best CV score of the grid searchchosen and reported on the same folds0.568 accuracy
  • Nested CVouter folds untouched by the search0.515 accuracy

Hands-on lab, block A, mean over 10 simulated datasets of 120 rows with a weak signal. The nested number estimates the procedure, not the one model you ship; GridSearchCV(refit=True), the default, then refits the winner on all rows.

How uncertain is a CV score?advanced3 minFold-to-fold spread, paired comparisons and the one-standard-error rule.
Fold by fold: a 0.006 gain is a coin flip Table
Time-ordered foldBaseline AUCForest AUCForest minus baseline
10.6270.608-0.019
20.5860.565-0.021
30.5500.626+0.075
40.6740.655-0.020
50.6270.640+0.013
Mean (sd)0.613 (0.047)0.619 (0.035)+0.006 (0.041)

Computed from the application script's time-ordered folds. The forest wins 2 of 5; the spread between folds is seven times the mean gain.

Reading the spread of a CV score Overview
  • Pair the folds

    Compare models fold by fold on the same splits; the paired differences are what matter.

  • Correlated folds

    Folds share training rows, so their scores are correlated and the naive standard error is too small.

  • Repeats

    Repeated k-fold averages away the luck of one split; it does not fix a split that misses deployment.

  • One-standard-error rule

    Among candidates within one standard error of the best, prefer the simplest.

Learning curves: more data or more model?2 minRead the train and validation curves to decide whether rows, features or regularization will help.
Learning curve: a deep tree Chart
00.250.50.751500100015002000Training — Training rows: 240, Accuracy: 0.971Training — Training rows: 720, Accuracy: 0.911Training — Training rows: 1440, Accuracy: 0.875Training — Training rows: 2400, Accuracy: 0.841TrainingValidation — Training rows: 240, Accuracy: 0.603Validation — Training rows: 720, Accuracy: 0.635Validation — Training rows: 1440, Accuracy: 0.67Validation — Training rows: 2400, Accuracy: 0.671ValidationTraining rowsAccuracy
  • Training
  • Validation

Hands-on lab, block C: validation rises from 0.603 to 0.671 and then flattens, while a gap of 0.17 stays open.

What the two learning curves tell you to do next Hierarchy
wide gap, narrowinggap open, val flatboth low, closeLearning curvestrain and validation scoreby rowsVariancemore rows or moreregularizationRows buy littleregularize or find betterfeaturesBiasadd features or capacity,not rows
  • Learning curvestrain and validation score by rows
    • wide gap, narrowingVariancemore rows or more regularization
    • gap open, val flatRows buy littleregularize or find better features
    • both low, closeBiasadd features or capacity, not rows

The lab's deep tree (block C) is the middle case: validation flattens near 0.67 while a gap of 0.17 stays open.

Memory anchor

Hold out what deployment will not have seen, choose on one block, report once on another.

Useful for

  • Designing the split and tuning protocol before any model is fit, and writing it down so reviewers can check it.

  • Diagnosing a validation-to-production gap by re-splitting one factor at a time.

  • Deciding how much tuning a comparison can afford before the reported maximum stops meaning anything.

Not suitable for

  • Settling whether a column is available at prediction time; no split detects that, only a timing audit does.

  • Strongly time-dependent forecasting with changing structure, where one expanding window is not enough (Day 25 gives rolling origins and horizon-wise scoring).

Traps

Shuffled folds that recognize the entity

Rows of one entity share traits: travellers on one PNR, visits of one patient, months of one store. A flexible model memorizes the entity from some of its rows and predicts the rest; AUC rises and production does not follow. Stratification and repeats do nothing for it.

How to avoid it

Hold out whole entities when deployment sees new ones, and compare the model with a baseline that has nothing to memorize.

One more look at the test block

Each check "just to confirm" is a selection. The score creeps up, nobody can say when the test block turned into a validation set, and the report becomes a maximum.

How to avoid it

Seal the block, log every access, and report tuning tries next to the score; use nested CV when data are too few to seal a block.

A leak that survives every split

A column recorded after the outcome lifts group, time and random splits alike, so re-splitting never exposes it, and the model collapses on the first live day.

How to avoid it

Audit each column's timestamp against the decision cutoff (Day 2) before reading any validation score.

Senior reasoning

A senior treats a validation number as the output of a protocol, not of a model. They ask what is new at prediction time, which rows influenced which choice, how many candidates produced the maximum, and what a baseline with nothing to memorize scores under the same split. They distrust implausible scores first as bug reports, re-split one factor at a time to locate the leak, and keep a sealed block so the final number is a single, pre-registered look.

Interview question

"Your validation AUC is 0.98. Why might you still not trust it?" How would you structure the answer?

Recall cards scheduled after completion

  • Which rows fit, which choose and which report, and how do you pick a split scheme?
  • Why is the best validation score of many tries optimistic, and what fixes it?
  • Name five leakage types and one guard for each.

Reviewed on a 1 · 3 · 7 · 14 · 30-day schedule; a miss returns the card to 1 day.

References

Optional sections

Deep dive · 10 min

How big is the maximum? For kk equally good candidates scored with independent noise of standard deviation σ\sigma, the expected best score exceeds the true value by roughly 1.2σ at k=5k=5, 1.5σ at 10, 2.2σ at 50 and 2.7σ at 200 (expected maximum of standard normal draws; the 2ln⁡k\sqrt{2\ln k} rule overstates it for small kk). The optimism grows slowly with kk and shrinks only with the square root of the validation size. That is why a bigger validation block helps less than a smaller search.

Nested CV. The outer loop splits for assessment; inside each outer training set an inner loop runs the whole search, including any feature selection. The outer score estimates "the procedure of tuning and fitting", not one fixed model. Cost multiplies by the outer folds. With plentiful data a sealed block is cheaper and simpler.

Early stopping. The round is chosen on validation, so its score is mildly optimistic; the lab shows the size. Keep a block that took no part in stopping or tuning.

Learning curves. A training score near 1 with a lower validation score that is still rising with rows is a variance problem and more data helps. Two converged, low curves signal bias: add features or capacity, not rows.

Deployment decides the split. Returning customers' histories are legitimately known, so a time split that keeps them is right; for a new-patient or new-customer product a group split is right. Neither is "the correct" scheme.

Reproducibility. Store the split rule or row ids, seeds, library versions and the number of tries. A score without them cannot be re-derived or compared.

Hands-on lab: Nested CV, selection before CV, learning curves and early stopping · 20 min

Run the script. Block A compares a grid search's own best score with nested CV over 10 small datasets, B selects features on all rows before CV with pure-noise labels, C draws a learning curve for a deep tree, D picks a boosting round on validation and rescores it on a sealed block. Then:

  1. Why is A's first number higher, and which number belongs in a report?
  2. Why does B reach about 0.87 on labels that are random, and what is the fix?
  3. In C, would 10,000 more rows close the gap? What would you change instead?
  4. In D, how far is the validation accuracy at the chosen round above the sealed-test accuracy, and what does that say about a stopping round as a tuning try?
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, KFold, cross_val_score, learning_curve, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier

rng = np.random.default_rng(15)

# A) tuning on the score you report: non-nested vs nested cross-validation, 10 simulated datasets (120 rows, weak signal)
grid = {"C": [0.01, 0.1, 1, 10, 100], "gamma": [1e-4, 1e-3, 1e-2, 1e-1]}
inner, outer = KFold(5, shuffle=True, random_state=1), KFold(5, shuffle=True, random_state=2)
non_nested, nested = [], []
for _ in range(10):
    X = rng.normal(size=(120, 30))
    y = (0.3 * X[:, 0] + rng.normal(size=120) > 0).astype(int)
    non_nested.append(GridSearchCV(SVC(), grid, cv=inner).fit(X, y).best_score_)
    nested.append(cross_val_score(GridSearchCV(SVC(), grid, cv=inner), X, y, cv=outer).mean())
print(f"A) best CV accuracy of the search {np.mean(non_nested):.3f} | nested CV accuracy {np.mean(nested):.3f}")

# B) feature selection on all rows before CV, with labels that are pure noise (the wrong-way example in Elements of Statistical Learning, ch. 7)
Xn = rng.normal(size=(100, 2000))
yn = rng.integers(0, 2, 100)
wrong = cross_val_score(LogisticRegression(), SelectKBest(f_classif, k=20).fit_transform(Xn, yn), yn, cv=5).mean()
right = cross_val_score(make_pipeline(SelectKBest(f_classif, k=20), LogisticRegression()), Xn, yn, cv=5).mean()
print(f"B) select 20 of 2,000 noise features: before CV {wrong:.3f} | inside each fold {right:.3f} (truth 0.5)")

# C) learning curve: does more data close the gap?
Xc = rng.normal(size=(3000, 10))
yc = (Xc[:, :3].sum(axis=1) + rng.normal(size=3000) * 2 > 0).astype(int)
sizes, tr, va = learning_curve(DecisionTreeClassifier(max_depth=8, random_state=0), Xc, yc, cv=5, train_sizes=[0.1, 0.3, 0.6, 1.0])
for s, a, b in zip(sizes, tr.mean(axis=1), va.mean(axis=1)):
    print(f"C) {s:5d} training rows: train accuracy {a:.3f} | validation accuracy {b:.3f}")

# D) early stopping is one tuning try per round: the validation score at the chosen round is optimistic
Xa, Xte, ya, yte = train_test_split(Xc, yc, test_size=0.4, random_state=0)
Xtr, Xva, ytr, yva = train_test_split(Xa, ya, test_size=0.3, random_state=0)
gb = GradientBoostingClassifier(n_estimators=300, max_depth=3, learning_rate=0.1, random_state=0).fit(Xtr, ytr)
va_acc = [(p == yva).mean() for p in gb.staged_predict(Xva)]
te_acc = [(p == yte).mean() for p in gb.staged_predict(Xte)]
best = int(np.argmax(va_acc))
print(f"D) best round by validation: {best + 1}; validation accuracy {va_acc[best]:.3f} | sealed test at that round {te_acc[best]:.3f}")
Interview practice · 10 min

A colleague tuned 500 configurations with 5-fold CV and reports the best CV score as the expected production performance. What do you say and what do you propose?

Content version 6 · last verified 2026-10-11 · depends on scikit-learn, numpy