learn-intro-ml-with-phoebe / Builder session 6 of 10
Learn Intro to Machine Learning with Phoebe · Builder session 6 of 10

Cross-validation, and how to tune without cheating

Every score so far has come from one train/test split. But one split can be lucky - or unlucky - and if you keep peeking at the test set to pick a hyperparameter, you quietly cheat and ship an over-optimistic number. This session fixes both problems with cross-validation: rotate the validation role across k folds, average the scores, and read the spread. Then use it to tune max_depth with GridSearchCV - and only touch the held-out test set once, at the very end. Same Lumen regression from b4, same fit/predict API.

🟠 Getting real Builders · DA / DE / DS Python · scikit-learn 45 min
0-3 · Recap 3-20 · k-fold CV 20-40 · Tune with GridSearchCV 40-45 · Wrap
Part 0

One split can be lucky

In b4 you picked the sweet-spot tree depth by watching a single test curve. That works, but it rests on one random split - shuffle the rows differently and the peak can wobble. Cross-validation gives you a steadier grade by averaging over several splits, and it is the tool that lets you tune hyperparameters honestly, because the test set stays sealed until the end. Think of it as taking the same exam five times with different question sets and reporting your average plus how much you varied.

Live - built in session Self-study - run after ★ The one big idea The case: Lumen Skincare
★ What you walk out with today A steadier estimate of how a model will generalise - a CV mean and its spread - plus the discipline that keeps you honest: you tune on cross-validation, never on the test set. You will run cross_val_score, read the fold-to-fold spread, and let GridSearchCV re-discover b4's sweet-spot depth for you.
Part 1 · the concept

k-fold cross-validation 7 min live

Split the training data into k equal folds. Each fold takes a turn as the validation set while the other k-1 folds train the model; you do this k times, so every row is validated exactly once. Average the k scores for a steadier estimate, and look at the spread - a wide spread means the score depends heavily on which rows you happened to hold out. Crucially, all of this happens on the training data. You tune on these CV scores and never on the test set, which you spend exactly once, at the very end.

5-fold CV on the training data - each fold serves as validation once Round 1 Round 2 Round 3 Round 4 Round 5 validation fold training folds TEST SET sealed · opened once, at the end Average the 5 validation scores for a steadier grade; the spread tells you how lucky a single split could have been. The test set never enters CV.
🔍 Click to zoom - each fold validates once; the test set stays sealed
LiveWhat CV estimates3 min▶

Cross-validation does not build a better model - read that twice. It gives you a less-lucky estimate of out-of-sample performance: how the model is likely to do on data it has not seen. The mean is your best guess; the spread across folds is a second piece of information you should always report.

  • The mean - your central estimate of generalisation.
  • The spread (std) - how much the score swings depending on which rows you validate on. A wide spread is a warning that a single test split could easily mislead you.
Why this matters When you report "R² ~ 0.40" from one split, you are hiding the uncertainty. "R² ~ 0.40 +/- 0.06 across 5 folds" is honest - it says the true number is roughly in that band, and your stakeholders can trust it more, not less, because you showed the wobble.
Self-studyWhy you never tune on the test set2 min▶

The test set has exactly one job: to give you an honest, one-time estimate of performance on truly unseen data. The moment you use it to choose something - a depth, an alpha, a feature set - it stops being unseen. You have leaked information from it into your model choices, and the score it reports is now optimistic.

That is why tuning lives entirely on cross-validation (or a separate validation split). CV is your sandbox for trying settings; the test set is the sealed envelope you open once, at the very end, to report the number you will actually stand behind. Spend it early and you have nothing honest left to report.

Tune on CV, then open the test set exactly once STEP 1 TUNE HERE 5-fold CV on train rows STEP 2 WINNER max_depth 3-4 b4's sweet spot STEP 3 OPEN ONCE held-out test score, then stop Tune on cross-validation only; the test set opens once, at the end, to report the real score.
🔍 Click to zoom - the test set has one job: report the number you will stand behind
★ everyone builds · CV then tune

Cross-validate and tune in Python 18 min · everyone builds

We stay on the b4 regression - predict order_value from prior_30d_spend with a decision tree - so the numbers line up. First a plain 5-fold CV, then GridSearchCV to pick max_depth, then one final look at the sealed test set.

Reload and carve out a sealed test set. Everything else happens on the training portion.

setup.py
import numpy as np, pandas as pd
from sklearn.model_selection import train_test_split

df = pd.read_parquet("lumen_sessions.parquet")
buyers = df[df.converted == 1].copy()

X = buyers[["prior_30d_spend"]]
y = buyers["order_value"]

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, random_state=42)     # test set: do not touch until the end

Run 5-fold cross-validation. Score a depth-4 tree and read both the mean and the spread.

cv.py
from sklearn.tree import DecisionTreeRegressor
from sklearn.model_selection import cross_val_score

tree = DecisionTreeRegressor(max_depth=4, random_state=0)
scores = cross_val_score(tree, X_train, y_train, cv=5, scoring="r2")

print("fold scores:", scores.round(2))          # e.g. ~0.33 .. ~0.50
print("mean R2 = %.2f  +/- %.2f" % (scores.mean(), scores.std()))
# -> mean ~ 0.40  +/- ~ 0.06

The mean lands around R² ~ 0.40 with a spread of about +/- 0.06, folds ranging roughly 0.33 to 0.50. That spread is the point: a single unlucky split could have handed you 0.33 and made a fine model look weak. The average is steadier, and the range is information you keep.

5-fold CV: a mean you can trust, plus its spread 0 0.60 0.33 0.50 mean 0.40 Mean R² 0.40 across 5 folds, ranging 0.33 to 0.50 - report the spread, not just the average.
🔍 Click to zoom - a wide fold spread is a warning that one split could mislead you

Tune max_depth with GridSearchCV. Let CV, not your eyeballs, pick the depth - and watch it re-find b4's sweet spot.

grid.py
from sklearn.model_selection import GridSearchCV

grid = GridSearchCV(
    DecisionTreeRegressor(random_state=0),
    param_grid={"max_depth": range(1, 13)},
    cv=5, scoring="r2")
grid.fit(X_train, y_train)                       # searches on TRAIN only, via CV

print("best params:", grid.best_params_)         # -> max_depth around 3-4
print("best CV R2 : %.2f" % grid.best_score_)    # -> ~ 0.40
# matches b4's U-curve peak at depth 3 - CV found it without touching the test set

GridSearchCV lands on max_depth around 3-4 - exactly the sweet spot of b4's U-shaped curve. No peeking at the test set, no guessing: cross-validation picked the complexity for you.

Now, and only now, open the test set. Score the tuned model once and report that number.

final.py
best = grid.best_estimator_
print("final held-out test R2 = %.2f" % best.score(X_test, y_test))
# report THIS number - it is the one you never tuned against
Real world

A team once tuned dozens of settings by watching the test score climb, then shipped the winning number to their VP. In production it fell flat - they had quietly overfit to the test set by choosing against it over and over. Cross-validation is the guardrail: tune on CV, keep the test set sealed, and the number you promise is the number you get.

Before Session 7

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What does cross-validation give you?

CV estimates how the model will generalise by averaging over folds. It does not train a better model - it gives you a less-lucky estimate of the same model's performance.

2 · What must you never do?

The test set is spent once, at the very end. Tuning against it leaks information and produces an over-optimistic score. Tune on cross-validation or a validation split instead.

3 · In k-fold CV, how is each fold used?

Each of the k folds takes a turn as the validation set while the remaining k-1 folds train the model, so every row is validated exactly once across the k rounds.

Source material

What this session covers

This builder session covers k-fold cross-validation and hyperparameter tuning on the Lumen dataset, building directly on b4's overfitting curve and b5's alpha dial. Classification metrics in depth come next in b7.

✓k-fold CV; mean + spread; tuning on CV not test; GridSearchCV re-finding the sweet-spot depthPart 1 + demo
◐scikit-learn - cross-validation and GridSearchCV (model selection)the API spine
◐StatQuest - cross validationthe intuition

Builder Session 6 cheat sheet · pin this

k-fold CVSplit train into k folds; each fold validates once while the rest train. Average the k scores for a steadier grade.
What it estimatesOut-of-sample (generalisation) performance - not a better model. Report the mean and the spread.
cross_val_scorecross_val_score(model, X, y, cv=5, scoring="r2"). Returns one score per fold; take .mean() and .std().
GridSearchCVSearches a param_grid using CV, refits the best on all training data. Read best_params_ and best_score_.
The sacred ruleTune on CV / validation, never on the test set. The test set is opened once, at the very end.
The Lumen result5-fold on a depth-4 tree: R² ~ 0.40 +/- 0.06 (folds ~0.33-0.50). GridSearchCV picks depth ~3-4 - b4's sweet spot.