Python Lesson 48 of 71

scikit-learn Part 3 — Cross-Validation & Hyperparameter Tuning

Every model you have trained so far had numbers you never chose. When you wrote DecisionTreeClassifier(max_depth=3) or SVC(C=10, gamma=0.01), where did the 3, the 10, the 0.01 come from? In this course, from a demonstration that happened to work. In the real world, that guess is the difference between a model that ships and a model that embarrasses you — and the process of replacing the guess with a defensible choice is called hyperparameter tuning. It is one of the few parts of applied machine learning that is almost pure engineering: a search problem, with a compute budget, an easy way to cheat yourself, and a rigorous way not to.

This is the third and final part of the scikit-learn arc. Part 1 on preprocessing built the Pipeline that makes everything here leakage-safe; Part 2 on algorithms introduced the estimators — logistic regression, SVMs, trees, k-NN — whose knobs we are about to turn. This lesson is the part that makes those two into a method: given a pipeline and a family of models, how do you choose the model, the preprocessing, and the hyperparameters — and then produce a number you can actually defend? The engine underneath is cross-validation, developed for evaluation in the train/test-split and metrics lesson; here it becomes the engine of selection.

Everything below was executed on Python 3.12.3 with scikit-learn 1.9.0 (plus NumPy 2.5.1, pandas 3.0.3, and matplotlib 3.11.0); every score, grid, timing, and traceback is copied from those runs. One install, in a virtual environment:

python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
python -m pip install scikit-learn numpy pandas matplotlib scipy

If you have met the idea that a model is — the fit/predict shape, supervised versus unsupervised — from the ML fundamentals lesson, you have everything you need. The grids here are deliberately tiny so every example runs in seconds; the ideas scale to grids that run for days.


Why this matters

There is a comfortable illusion, once fit() and predict() feel easy, that machine learning is about picking the right algorithm. You reach for a random forest, or an SVM, or gradient boosting, as if the name were the decision. It is not. The same RandomForestClassifier can be a state-of-the-art model or a useless one depending entirely on numbers you set before you ever call fit — how deep the trees may grow, how many of them there are, how many samples a leaf must hold. Those numbers are hyperparameters, and choosing them badly wastes the model’s entire potential. Choosing them well, systematically, is what separates a practitioner from someone who copied a notebook.

The trouble is that there is no formula for the right values. The best max_depth depends on your data, your features, your sample size, and the other hyperparameters — it is a knotted, interacting mess with no closed-form solution. So you do the only thing you can: you search. You try combinations, measure each one honestly, and keep the winner. That single sentence hides two hard problems, and this lesson is about both. The first is combinatorial: three hyperparameters with five values each is 125 combinations, and if each combination needs a five-fold cross-validation that is 625 model fits for a search you might repeat a dozen times. The second, and more dangerous, is statistical honesty: the very act of searching for the best score on some data biases that score upward, so the number your search proudly reports is a lie unless you take specific steps to keep it honest.

Get the search wrong in the first way and you burn hours or days of compute for a marginal gain. Get it wrong in the second way — and almost everyone does, at first — and you ship a model whose “0.94 cross-validated accuracy” becomes 0.88 in production, because that 0.94 was the luckiest of two hundred combinations you tried, and luck does not generalize. This lesson gives you the tools for the first problem (GridSearchCV, RandomizedSearchCV, successive halving) and the discipline for the second (the sealed test set, tuning inside a pipeline, and nested cross-validation). By the end you will be able to tune a real model, defend the number you report, and — just as importantly — know when to stop, because the last honest truth of this lesson is that better features usually beat better hyperparameters, and a saturated model does not care how hard you tune it.


Parameters vs hyperparameters

The vocabulary matters here because two words that sound identical name opposite things, and conflating them is the root of a lot of confusion. A model has parameters and hyperparameters, and the clean distinction is who sets them.

Parameters are learned by fit. They are the model’s internal state — the numbers the training algorithm adjusts to fit the data. For a logistic regression they are the coefficients (one weight per feature) and the intercept. For a decision tree they are the entire tree: which feature to split on at each node, at what threshold. You never set these by hand; fit() computes them from the data, and they are the output of training.

Hyperparameters are set by you, before fit. They configure how the learning happens — the strength of regularization (C), how deep a tree may grow (max_depth), how many neighbours to poll (n_neighbors), the kernel of an SVM. The model cannot learn these from the data by ordinary fitting, because they control the fitting itself; they are the input to training. Tuning is the search for good values of exactly these.

See both at once on the breast-cancer dataset. The hyperparameters are what you passed in and can read back with get_params(); the parameters are the coef_ and intercept_ that only exist after fit:

import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier

X, y = load_breast_cancer(return_X_y=True)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, stratify=y, random_state=42)

logreg = LogisticRegression(C=0.1, max_iter=5000)          # YOU set C, max_iter
logreg.fit(StandardScaler().fit_transform(Xtr), ytr)
print("hyperparameters YOU set:",
      {k: logreg.get_params()[k] for k in ["C", "solver", "max_iter", "tol"]})
print("parameters fit() LEARNED: coef_ shape", logreg.coef_.shape,
      " intercept_", np.round(logreg.intercept_, 3))
print("first 3 learned weights:", np.round(logreg.coef_[0, :3], 3))

tree = DecisionTreeClassifier(max_depth=3, random_state=0).fit(Xtr, ytr)
print("tree hyperparameter max_depth=3 -> learned node count:", tree.tree_.node_count)
hyperparameters YOU set: {'C': 0.1, 'solver': 'lbfgs', 'max_iter': 5000, 'tol': 0.0001}
parameters fit() LEARNED: coef_ shape (1, 30)  intercept_ [0.525]
first 3 learned weights: [-0.398 -0.432 -0.384]
tree hyperparameter max_depth=3 -> learned node count: 13

You chose C=0.1; fit computed the 30 weights and the intercept. You chose max_depth=3; fit decided the 13 nodes and their splits. That is the whole distinction, and the table below makes it a reflex.

Parameters Hyperparameters
Set by fit() — the learning algorithm You, before fitting
Learned from data? Yes, that is training No — they configure training
Examples coef_, intercept_, tree splits, cluster centres C, max_depth, n_neighbors, n_estimators, learning_rate
Where they live Trailing-underscore attributes (coef_) Constructor arguments; readable via get_params()
Tuning changes… — (indirect: better hyperparameters → better learned parameters) These directly — tuning is searching over them
Count Often thousands to millions A handful you deliberately choose

Every estimator ships with default hyperparameters, and the defaults are chosen to be reasonable, not optimal — they cannot be optimal, because the optimum depends on your data. Tuning is the act of beating the defaults for your specific problem. Here are the knobs that actually move the needle on the algorithms from Part 2, so you know what you are searching over before you search:

Estimator Hyperparameters worth tuning What they trade
LogisticRegression C (inverse regularization), class_weight, l1_ratio Underfit ↔ overfit; minority-class emphasis
SVC (RBF) C, gamma Margin softness; how local each support vector’s influence is
DecisionTreeClassifier max_depth, min_samples_leaf, min_samples_split, ccp_alpha Simplicity ↔ capacity to memorize
RandomForestClassifier n_estimators, max_depth, max_features, min_samples_leaf Compute ↔ accuracy; tree correlation
Gradient boosting learning_rate, n_estimators, max_depth, subsample The classic rate-vs-count tradeoff
KNeighborsClassifier n_neighbors, weights, p (metric) Bias ↔ variance; smoothness of the boundary

Note the recurring theme in that last column: nearly every hyperparameter is, at bottom, a capacity dial — it moves the model along the underfit-to-overfit axis you met in the evaluation lesson. Tuning is largely the art of finding where on that axis your data wants the model to sit, and the only honest way to find it is to measure generalization, which means cross-validation.


Cross-validation, the engine of selection

You already know cross-validation as a way to evaluate a model; now it becomes the way to choose one, so a fast recap with the emphasis shifted to selection is worth it. The core problem it solves is that a single train/validation split gives a score that depends on which rows happened to land where — a number too noisy to make decisions on. Watch the same model, same data, score differently across six split seeds, and then watch what cross-validation does instead:

from sklearn.datasets import make_classification
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import cross_val_score, StratifiedKFold

Xc, yc = make_classification(n_samples=2000, n_features=20, n_informative=8,
                             weights=[0.8], flip_y=0.01, class_sep=1.2, random_state=7)
pipe = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))

for s in range(6):                                   # one split, six seeds
    a, b, c, d = train_test_split(Xc, yc, test_size=0.25, stratify=yc, random_state=s)
    print(f"{pipe.fit(a, c).score(b, d):.3f}", end="  ")

cv = StratifiedKFold(5, shuffle=True, random_state=0)
scores = cross_val_score(pipe, Xc, yc, cv=cv, scoring="roc_auc")
print(f"\nper-fold ROC-AUC: {' '.join(f'{v:.3f}' for v in scores)}")
print(f"mean = {scores.mean():.3f}  std = {scores.std():.3f}")
0.906  0.914  0.904  0.906  0.912  0.914
per-fold ROC-AUC: 0.944 0.934 0.929 0.928 0.930
mean = 0.933  std = 0.006

The single-split accuracy wanders between 0.904 and 0.914 — a spread of 0.010 for changing nothing but the seed. If you tuned a hyperparameter and it moved the score by 0.005, was that the hyperparameter or the seed? You cannot tell. That is why you never tune against a single split: the noise floor is higher than the signal you are chasing. Cross-validation replaces the one lucky number with 0.933 ± 0.006 — a mean and a spread — and now a genuine improvement is one that clears the noise band.

That ± 0.006 is not decoration; it is the single most important habit in this whole lesson. A single number lies, because it hides its own uncertainty. Two models that score 0.933 and 0.935 look ordered until you see they are 0.933 ± 0.006 and 0.935 ± 0.011 — the “better” one is inside the other’s noise and swings twice as much. Always carry the standard deviation alongside the mean; a tuning decision that ignores the spread is a coin flip you have dressed up as a measurement.

Two workhorses drive every search. cross_val_score returns one array of fold scores for one metric; cross_validate returns a dict for several metrics at once and can hand back the train scores too, which is how you watch the train-vs-validation gap that diagnoses overfitting:

from sklearn.model_selection import cross_validate
res = cross_validate(pipe, Xc, yc, cv=cv,
                     scoring=["accuracy", "f1", "roc_auc", "average_precision"],
                     return_train_score=True)
for k in ["accuracy", "f1", "roc_auc", "average_precision"]:
    print(f"{k:<18} train={res['train_'+k].mean():.3f}  "
          f"test={res['test_'+k].mean():.3f} ± {res['test_'+k].std():.3f}")
accuracy           train=0.911  test=0.903 ± 0.010
f1                 train=0.763  test=0.742 ± 0.026
roc_auc            train=0.940  test=0.933 ± 0.006
average_precision  train=0.851  test=0.837 ± 0.026
Function Returns Metrics Use when
cross_val_score 1-D array of k fold scores Exactly one (scoring=) Quick single-metric read
cross_validate dict: test_*, fit_time, opt. train_* Several at once You want more than one metric, or the train gap
GridSearchCV / RandomizedSearchCV A fitted search object One to optimize (+ others to record) Tuning — CV plus the choosing

The splitter you pass as cv is not an afterthought — it is your defence against the leakage types from the evaluation lesson, and the choice is dictated by the structure of your data:

Splitter Use for Guards against
KFold Plain regression, balanced classes — (the baseline)
StratifiedKFold Classification — the default Class-balance drift across folds
GroupKFold Repeated entities (patient, user, device) Group leakage
TimeSeriesSplit Ordered / time-series data Temporal leakage (never shuffles)
RepeatedStratifiedKFold Small data, need a tighter mean Single-CV noise (repeats with new seeds)

One subtlety that becomes a troubleshooting row later: when you pass a plain integer, cv=5, scikit-learn picks the splitter for you based on the estimator — StratifiedKFold for a classifier, KFold otherwise — but the folds it makes are not shuffled and not seeded. For reproducibility and for data that might arrive in a pathological order, pass an explicit StratifiedKFold(5, shuffle=True, random_state=0) rather than the bare 5.


The selection workflow: tune on train, test once

Before any specific search tool, internalize the shape of the whole process, because every mistake in tuning is a violation of this shape. There is exactly one honest workflow, and it has three movements.

  1. Split once, and seal the test set. The very first thing you do — before scaling, before selecting features, before touching a model — is carve off a test set and put it in a vault. It does not come out until step 3.
  2. Search on the training data with cross-validation. All of it happens here: comparing models, choosing hyperparameters, picking a threshold. Every decision is scored by CV on the training portion only. The winner is whatever maximizes the cross-validated score.
  3. Unseal the test set once, for the final number. With every choice locked, you evaluate the chosen model on the sealed test set exactly one time, and that is the number you report. Then you stop.
Movement Data it uses What you decide Touched
1. Split & seal The full dataset Nothing yet — just partition Split made once; test sealed
2. Search with CV Training data only Model, hyperparameters, threshold Repeatedly, via cross-validation
3. Report Sealed test set Nothing — you only measure Exactly once, at the very end

The diagram below is this workflow as the search tools actually execute it, left to right: a space of hyperparameters feeds a cross-validation loop that runs on the training data only; the best mean CV score names the winner; that winner is refit on all the training data; and it is scored once on the sealed test. The red node marks the trap the middle sections of this lesson exist to defuse — reporting the search’s own best cross-validation score as if it were an honest estimate — and the purple outer loop is the nested cross-validation that fixes it.

The hyperparameter tuning loop drawn left to right: a parameter grid or distribution feeds k-fold cross-validation that runs on the training data only, refitting a leakage-safe Pipeline on each fold; the best mean CV score selects best_params_; a nested outer loop gives the honest estimate while reporting the inner best_score_ is marked as the optimistic trap; the winning model is refit on all training data and scored exactly once on the sealed test set

Hold onto the reason step 3 exists even after all that careful cross-validation in step 2. Cross-validation is for selection, not for the final reported number. Every choice you make to maximize the CV score adapts to that score — so by the time you have picked the winner, the CV score of that winner is optimistic, for precisely the same reason a test set you tuned against is no longer a test set. The CV number chooses; the sealed test number reports. Keeping those two jobs in two different pools of data is the entire discipline, and the tools below are just efficient, automated ways to do step 2 without ever peeking at the vault.


GridSearchCV: exhaustive search over a grid

The most direct way to search is to list the values you care about for each hyperparameter and try every combination. That is GridSearchCV: you hand it an estimator, a dictionary mapping hyperparameter names to lists of values, and a cross-validation strategy, and it fits and scores every point on the grid, then hands you the winner refit on all your data. Here it tunes an RBF SVM’s two hyperparameters — C and gamma — inside a scaling pipeline, on the breast-cancer training set:

import pandas as pd
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.svm import SVC

pipe = Pipeline([("scaler", StandardScaler()), ("svc", SVC(kernel="rbf"))])
grid = {"svc__C":     [0.1, 1, 10, 100],
        "svc__gamma": [1e-3, 1e-2, 1e-1, 1]}
cv = StratifiedKFold(5, shuffle=True, random_state=0)

gs = GridSearchCV(pipe, grid, scoring="roc_auc", cv=cv, n_jobs=-1, refit=True)
gs.fit(Xtr, ytr)                                          # Xtr, ytr = breast-cancer TRAIN

print("best_params_:", gs.best_params_)
print(f"best_score_ (mean CV ROC-AUC) = {gs.best_score_:.4f}")
print(f"sealed test ROC-AUC (touched ONCE) = {gs.score(Xte, yte):.4f}")
best_params_: {'svc__C': 10, 'svc__gamma': 0.01}
best_score_ (mean CV ROC-AUC) = 0.9960
sealed test ROC-AUC (touched ONCE) = 0.9987

Four attributes carry everything a fitted search knows, and you will reach for all four:

Attribute Holds Use it to
best_params_ The winning hyperparameter dict See what won; reproduce it elsewhere
best_score_ Mean CV score of the winner Compare searches — never report as test score
best_estimator_ The winner, refit on all training data Predict, or score on the sealed test
cv_results_ Every combo’s per-fold and mean scores + timings Read the whole landscape, not just the peak

cv_results_ is the one beginners ignore and experts live in, because the shape of the score surface tells you where to search next. Turn it into a DataFrame and read the top few, or pivot it into the actual grid:

res = pd.DataFrame(gs.cv_results_)
print(res[["param_svc__C", "param_svc__gamma", "mean_test_score",
           "std_test_score", "rank_test_score"]]
      .sort_values("rank_test_score").head(4).to_string(index=False))
print(res.pivot_table(index="param_svc__C", columns="param_svc__gamma",
                      values="mean_test_score").round(3).to_string())
 param_svc__C  param_svc__gamma  mean_test_score  std_test_score  rank_test_score
         10.0             0.010         0.996006        0.005292                1
          1.0             0.010         0.995306        0.004412                2
         10.0             0.001         0.994593        0.004377                3
        100.0             0.001         0.994365        0.004310                4
param_svc__gamma  0.001  0.010  0.100  1.000
param_svc__C
0.1               0.984  0.988  0.984   0.95
1.0               0.990  0.995  0.990   0.95
10.0              0.995  0.996  0.986   0.95
100.0             0.994  0.992  0.985   0.95

Read the pivot like a topographic map. The good region is the middle — moderate C, gamma around 0.01 — and the whole right column (gamma=1) collapses to 0.95, an over-local model whose influence radius is too small. The winner at (C=10, gamma=0.01) sits on a broad plateau, not a lonely spike, which is reassuring: a peak surrounded by other good scores is a real optimum, while a peak surrounded by bad scores is often noise you would not reproduce. This is the single best reason to look past best_params_ at the full cv_results_.

The constructor arguments you will actually set:

Argument Default What it controls
estimator The model or Pipeline to tune
param_grid dict (or list of dicts) of name → list of values
scoring estimator’s .score The metric to optimize — set it deliberately
cv 5 Int or splitter; use a stratified, seeded splitter
refit True Refit the winner on all data → enables best_estimator_, predict
n_jobs None -1 uses all cores; the folds run in parallel
return_train_score False Add train scores to cv_results_ to see the gap
error_score np.nan What to record when a fit raises; set "raise" to debug

Now the catch that defines grid search. Its cost is the product of the grid dimensions times the number of folds, and that product explodes. Our little 4×4 grid is 16 combinations, at 5 folds that is 80 fits — instant. But the brief’s example of a 4×4×5 grid is 80 combinations, and at 5 folds that is 400 model fits; add a fourth hyperparameter with 5 values and you are at 2000. Every hyperparameter you add multiplies the whole search:

Grid Combinations × 5-fold CV Feel
4 × 4 16 80 fits Instant
4 × 4 × 5 80 400 fits Seconds to a minute
5 × 5 × 5 125 625 fits A coffee
6 × 6 × 6 × 6 1,296 6,480 fits A long lunch
10 × 10 × 10 × 10 10,000 50,000 fits Overnight — and probably wasteful

This curse of dimensionality in the search space is why grid search, for all its simplicity, is often the wrong first tool. Most of those thousands of fits explore combinations that were never going to win — and there is a smarter way to spend the same budget.


RandomizedSearchCV: sample, don’t enumerate

Grid search spends its budget evenly, including on the vast regions of the grid that are obviously bad. RandomizedSearchCV spends it randomly but proportionally: you give it distributions to sample from rather than lists to enumerate, and a budget n_iter of how many combinations to try. It draws n_iter random points from the space and cross-validates each. The insight — from Bergstra and Bengio’s 2012 result that random search beats grid search for the same budget — is that when only a few hyperparameters actually matter (which is usually), random sampling covers the important dimensions far more densely than a grid wastes points on the unimportant ones.

Demonstrate it directly. Take a bigger SVM grid — 6×6 = 36 combinations, 180 fits — and pit it against a randomized search that samples just 12 points (60 fits) from continuous log-uniform distributions over the same ranges:

from scipy.stats import loguniform
from sklearn.model_selection import RandomizedSearchCV

grid = {"svc__C": np.logspace(-2, 3, 6), "svc__gamma": np.logspace(-4, 1, 6)}
gs = GridSearchCV(pipe, grid, scoring="roc_auc", cv=cv, n_jobs=-1).fit(Xtr, ytr)

dists = {"svc__C": loguniform(1e-2, 1e3), "svc__gamma": loguniform(1e-4, 1e1)}
rs = RandomizedSearchCV(pipe, dists, n_iter=12, scoring="roc_auc",
                        cv=cv, n_jobs=-1, random_state=0).fit(Xtr, ytr)

print(f"GridSearchCV      fits=180  best CV={gs.best_score_:.4f}  test={gs.score(Xte, yte):.4f}")
print(f"RandomizedSearch  fits= 60  best CV={rs.best_score_:.4f}  test={rs.score(Xte, yte):.4f}")
for n in (6, 12, 24):
    r = RandomizedSearchCV(pipe, dists, n_iter=n, scoring="roc_auc",
                           cv=cv, n_jobs=-1, random_state=0).fit(Xtr, ytr)
    print(f"n_iter={n:2d}  fits={n*5:3d}  best CV={r.best_score_:.4f}  test={r.score(Xte, yte):.4f}")
GridSearchCV      fits=180  best CV=0.9960  test=0.9987
RandomizedSearch  fits= 60  best CV=0.9908  test=0.9922
n_iter= 6  fits= 30  best CV=0.9908  test=0.9922
n_iter=12  fits= 60  best CV=0.9908  test=0.9922
n_iter=24  fits=120  best CV=0.9962  test=0.9985

Read the fits-versus-score table, because it is the whole argument for randomized search:

Method Fits Best CV ROC-AUC Sealed test Fraction of grid’s fits
GridSearchCV (6×6) 180 0.9960 0.9987 100%
RandomizedSearchCV n_iter=6 30 0.9908 0.9922 17%
RandomizedSearchCV n_iter=12 60 0.9908 0.9922 33%
RandomizedSearchCV n_iter=24 120 0.9962 0.9985 67%

With 17% of the fits, random search lands within 0.006 test ROC-AUC of the exhaustive grid; with 67% it ties it (0.9985 vs 0.9987, a difference that is pure noise). That is the bargain: near-best answers for a fraction of the compute, and — crucially — a budget you set with n_iter rather than one dictated by the grid’s dimensions. You want the search to run for an hour? Set n_iter to whatever fits in an hour. Grid search offers no such dial; you either run the whole grid or you do not.

The distributions are the new idea, and choosing them well is most of the skill. Use scipy’s continuous distributions, and mind the scale — regularization and learning rates live on a log scale, so sample them log-uniformly, not uniformly:

Hyperparameter kind Distribution Why
C, gamma, learning_rate, alpha loguniform(1e-4, 1e2) Orders of magnitude matter; uniform would over-sample the large end
n_estimators, max_depth (int) randint(50, 500) Discrete counts, sampled uniformly
subsample, max_features (fraction) uniform(0.5, 0.5) → [0.5, 1.0] Genuinely linear quantities
A small set of choices a plain list Random search accepts lists too, and samples them

RandomizedSearchCV shares every attribute and argument with GridSearchCVbest_params_, best_estimator_, cv_results_, refit, scoring, n_jobs — and adds two: n_iter (the budget) and random_state (so the same random points are drawn every run, which you must pin for reproducibility). The practical rule that falls out of all this: reach for randomized search first, especially with three or more hyperparameters, and only fall back to a grid when the space is small and you truly want every corner checked.


Successive halving and Bayesian search

Both searches so far give every candidate the same full cross-validation, even the obvious losers. Successive halving refuses to: it starts all candidates on a small slice of the data (or a few trees, a few iterations), throws out the worst fraction, and promotes the survivors to a larger slice — repeating until a few finalists are evaluated on the full budget. Cheap eliminations early, expensive evaluation only for contenders. scikit-learn ships HalvingGridSearchCV and HalvingRandomSearchCV, still behind an experimental import:

from sklearn.datasets import make_classification
from sklearn.experimental import enable_halving_search_cv   # noqa: F401 — required import
from sklearn.model_selection import HalvingGridSearchCV
from sklearn.ensemble import RandomForestClassifier

Xr, yr = make_classification(n_samples=3000, n_features=20, n_informative=8,
                             weights=[0.7], random_state=0)
Xrf_tr, _, yrf_tr, _ = train_test_split(Xr, yr, test_size=0.25, stratify=yr, random_state=0)

rf = RandomForestClassifier(random_state=0)
grid = {"n_estimators": [50, 100, 200], "max_depth": [3, 5, 8, None],
        "min_samples_leaf": [1, 5, 20]}                       # 3×4×3 = 36 combos

hs = HalvingGridSearchCV(rf, grid, scoring="f1", cv=cv, factor=3,
                         random_state=0, n_jobs=-1).fit(Xrf_tr, yrf_tr)
print("best CV F1 =", round(hs.best_score_, 3))
print("candidates per iteration:", list(hs.n_candidates_))
print("resources (samples) per iteration:", list(hs.n_resources_))
best CV F1 = 0.907
candidates per iteration: [36, 12, 4, 2]
resources (samples) per iteration: [83, 249, 747, 2241]

The schedule is the algorithm, laid bare:

Iteration Candidates Resources (samples) What happens
0 36 83 All candidates on the cheapest data slice
1 12 249 Keep the best 1/3, triple their data
2 4 747 Keep the best 1/3 again
3 2 2,241 Two finalists on (nearly) all the data

36 candidates each get 83 samples; the best third (12) advance to 249 samples; then 4 to 747; then the 2 finalists to 2241. With factor=3, each round keeps 1/3 of the candidates and triples their data, so the expensive full-data fits happen only for the two survivors instead of all thirty-six. Against a plain GridSearchCV on the same grid it reached an essentially identical F1 (0.907 versus the grid’s 0.912) — and picked a slightly different winner, a reminder that halving is an approximation: a candidate that would have won on full data can occasionally be eliminated early on a small, unlucky slice. That is the price of the speed.

The landscape of search strategies, from dumbest-but-thorough to smartest-but-fiddliest:

Strategy How it explores Best when Cost
GridSearchCV Every combination Small space, want exhaustiveness Product of dims — explodes
RandomizedSearchCV n_iter random samples 3+ hyperparameters; a fixed budget You set it via n_iter
HalvingGridSearchCV Grid + successive halving Big grid, cheap-to-subsample data Much less than full grid
HalvingRandomSearchCV Random + successive halving Large space and tight budget The cheapest of the four
Bayesian (Optuna, scikit-optimize) Model where to look next from past trials Expensive fits, want few of them Extra library; sequential

That last row points beyond scikit-learn. Bayesian optimization — the popular library is Optuna, with scikit-optimize and hyperopt as alternatives — treats tuning as an optimization problem in its own right: it builds a probabilistic model of “score as a function of hyperparameters” from the trials so far and uses it to propose the next combination most likely to improve, rather than sampling blindly. In practice you write an objective(trial) function that asks the trial for values — trial.suggest_float("C", 1e-4, 1e2, log=True) — builds the estimator, and returns a cross_val_score(...).mean(); then study.optimize(objective, n_trials=50) runs the search, each trial informed by the last. When each fit is expensive — a deep network, a gradient-boosting model on millions of rows — spending a little compute to choose the next trial wisely pays for itself many times over. For the small, fast models in this course, randomized or halving search is plenty; file “Optuna” under the tool you reach for when a single fit takes minutes and you can afford only fifty of them.


Tuning inside a Pipeline: the real-world pattern

Here is where tuning stops being a toy and becomes the thing you actually do at work. Real problems do not just tune a model — they tune the preprocessing and the model together, because the best amount of feature selection depends on the model and vice versa. A Pipeline makes this not only possible but leakage-safe by construction, and the syntax is the one new thing to learn: address a step’s hyperparameter as stepname__paramname, with a double underscore.

Build a three-step pipeline — scale, select the k best features, classify — and tune the selector’s k and the classifier’s C in one search. Because SelectKBest lives inside the pipeline, it is refit on each fold’s training rows only, so choosing k by peeking at every label — the feature-selection leakage that inflated a pure-noise model to 0.865 in the evaluation lesson — cannot happen here:

from sklearn.feature_selection import SelectKBest, f_classif

pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("select", SelectKBest(f_classif)),
    ("clf",    LogisticRegression(max_iter=5000)),
])
grid = {"select__k": [5, 10, 20, 30],        # a PREPROCESSING hyperparameter
        "clf__C":    [0.01, 0.1, 1, 10]}      # a MODEL hyperparameter
gs = GridSearchCV(pipe, grid, scoring="roc_auc", cv=cv, n_jobs=-1).fit(Xtr, ytr)

print("best_params_ (preprocessing AND model together):", gs.best_params_)
print(f"best CV ROC-AUC     = {gs.best_score_:.4f}")
print(f"sealed test ROC-AUC = {gs.score(Xte, yte):.4f}")
print(pd.DataFrame(gs.cv_results_).pivot_table(
      index="param_select__k", columns="param_clf__C",
      values="mean_test_score").round(4).to_string())
best_params_ (preprocessing AND model together): {'clf__C': 10, 'select__k': 20}
best CV ROC-AUC     = 0.9971
sealed test ROC-AUC = 0.9958
param_clf__C      0.01    0.10    1.00    10.00
param_select__k
5                0.9873  0.9873  0.9880  0.9870
10               0.9866  0.9871  0.9880  0.9882
20               0.9900  0.9946  0.9960  0.9971
30               0.9918  0.9953  0.9945  0.9844

The pivot shows the two hyperparameters interacting, which is exactly why you must tune them jointly rather than one at a time. At k=20 more regularization-relaxation helps (C=10 wins), but at k=30 — all features in — C=10 overfits and the score drops to 0.9844 while a gentler C=0.1 does best. Tune C alone at fixed k, or k alone at fixed C, and you would miss the joint optimum at (k=20, C=10). The grid found it because it searched the plane, not two lines.

To know what you can address with step__param, ask the pipeline for its parameter names:

print([p for p in pipe.get_params() if "__" in p][:8])
# ['scaler__copy', 'scaler__with_mean', 'scaler__with_std', 'select__k',
#  'select__score_func', 'clf__C', 'clf__class_weight', 'clf__dual']
You want to tune… Grid key Reaches
Features kept by the selector "select__k" SelectKBest(k=…)
Classifier regularization "clf__C" LogisticRegression(C=…)
Whether the scaler centres "scaler__with_mean" StandardScaler(with_mean=…)
Swap the whole model "clf" Pass a list of estimators as the value

That last row is a genuinely powerful trick: because a pipeline step is itself a hyperparameter, you can put estimators in the grid — {"clf": [LogisticRegression(...), RandomForestClassifier(...)]} — and let the search choose the algorithm alongside its hyperparameters, all in one leakage-safe cross-validation. This is the real-world pattern the preprocessing lesson was building toward: the pipeline is not a convenience for tidy code, it is the object that makes joint tuning of preprocessing and model honest.


Nested cross-validation: the honest estimate

Now the subtle, critical point that separates a careful practitioner from a fooled one — and the reason the red node sits in the middle of the diagram. When you run a search and read off best_score_, you are looking at the cross-validated score of the combination you selected because it had the best cross-validated score. That is circular, and the circularity biases the number upward. The search tried many combinations against the same folds and reported the luckiest; some of that luck is real signal, but some is just favourable noise in those particular folds, and noise does not generalize. Reporting best_score_ as your model’s performance is the same sin as tuning on the test set, only quieter.

The honest fix is nested cross-validation: two loops. An inner loop does the tuning (a full GridSearchCV), and an outer loop evaluates the tuned result on data the inner loop never saw. Each outer fold holds out a slice, the inner search tunes on the rest, and the freshly tuned model is scored on the held-out slice it never influenced. Average the outer scores and you have an estimate with the selection bias removed. In scikit-learn it is astonishingly little code — you cross_val_score a GridSearchCV:

# a small, noisy problem — plenty of room for the search to get lucky
Xn, yn = make_classification(n_samples=300, n_features=30, n_informative=4,
                             n_redundant=2, flip_y=0.12, class_sep=0.6, random_state=11)
inner = StratifiedKFold(5, shuffle=True, random_state=1)
outer = StratifiedKFold(5, shuffle=True, random_state=2)
grid  = {"svc__C": np.logspace(-1, 4, 7), "svc__gamma": np.logspace(-5, 1, 7)}

# FLAT: tune and read best_score_ off the SAME cv — optimistic
flat = GridSearchCV(pipe, grid, scoring="roc_auc", cv=inner, n_jobs=-1).fit(Xn, yn)

# NESTED: the outer loop scores a GridSearchCV whose inner loop does the tuning
search = GridSearchCV(pipe, grid, scoring="roc_auc", cv=inner, n_jobs=-1)
nested = cross_val_score(search, Xn, yn, scoring="roc_auc", cv=outer, n_jobs=-1)

print(f"FLAT   best_score_    = {flat.best_score_:.4f}   <- optimistic")
print(f"NESTED outer mean     = {nested.mean():.4f} ± {nested.std():.4f}  <- honest")
print(f"optimism gap          = {flat.best_score_ - nested.mean():+.4f}")

On a small, noisy problem (300 rows, a 7×7 grid — plenty of room for the search to get lucky) the gap is stark:

FLAT   best_score_    = 0.7199   <- optimistic
NESTED outer mean     = 0.6713 ± 0.0207  <- honest
optimism gap          = +0.0487

Nearly five points of pure self-deception. The flat search would let you promise 0.72; the honest, selection-unbiased estimate is 0.67. If you shipped on the strength of the 0.72, production would hand you the 0.67 and you would not understand why. But — and this is the nuance that makes the concept click — the size of the gap depends entirely on how much the search can overfit the folds. Run the identical experiment on the easy, saturated breast-cancer problem and the gap essentially vanishes:

Dataset (grid) Flat best_score_ Nested (honest) Optimism gap
Tiny, noisy — n=300, 7×7 grid 0.7199 0.6713 ± 0.021 +0.0487
Small, noisy — n=400, 6×6 grid 0.7816 0.7642 ± 0.026 +0.0174
Breast cancer — n=569, 5×5 grid 0.9960 0.9960 ± 0.005 −0.0000

The pattern is the lesson: the optimism grows with the noise, the smallness of the data, and the size of the grid — every one of which gives the search more room to find a fold-specific fluke. On easy, abundant data with a modest grid there is little to overfit and flat CV is nearly honest; on hard, scarce data with a big search it lies by five points.

So when do you pay for nested CV’s extra loop? It is expensive — outer folds times the entire inner search — so you do not run it every time. You run it when you need to report an honest estimate of a tuned model’s performance and you cannot spare a separate test set (small data), or when you are comparing tuning procedures and need each one’s honest score. For everyday work, the sealed test set from the workflow is your outer loop: tune with CV on train, report once on test. Nested CV is what you use when you have no such vault to draw on, or when a reviewer asks “but is that number honest?” and you need to prove it.

Flat CV (tune + report on same CV) Nested CV (inner tunes, outer reports)
What it estimates Biased — the selection peeked Unbiased — outer never influenced tuning
Cost One search Outer folds × one full search each
Report best_score_? No — optimistic The outer mean is the honest number
Use when Choosing between models/params Reporting honest performance on small data

Validation curves and learning curves

Two diagnostic plots turn tuning from guesswork into reading, and they answer two different questions. A validation curve varies one hyperparameter and plots training and validation score against it — it shows you where under- and overfitting live along that axis. A learning curve varies the training-set size and plots the two scores against it — it shows you whether more data would help. Confusing them is common; the axes are different and so are the cures.

Start with the validation curve, sweeping an SVM’s C across seven orders of magnitude:

from sklearn.model_selection import validation_curve
pipe = Pipeline([("scaler", StandardScaler()), ("svc", SVC(kernel="rbf", gamma=0.01))])
C_range = np.logspace(-3, 3, 7)
tr, va = validation_curve(pipe, X, y, param_name="svc__C", param_range=C_range,
                          cv=cv, scoring="roc_auc")
print("C        :", " ".join(f"{c:8.3g}" for c in C_range))
print("train AUC:", " ".join(f"{v:8.3f}" for v in tr.mean(1)))
print("valid AUC:", " ".join(f"{v:8.3f}" for v in va.mean(1)))
C        :    0.001     0.01      0.1        1       10      100    1e+03
train AUC:    0.987    0.987    0.991    0.996    0.998    1.000    1.000
valid AUC:    0.986    0.987    0.991    0.995    0.996    0.994    0.990

Read it left to right as a story about capacity. At tiny C the model is heavily regularized and slightly underfit — train and validation are low and together (0.987 / 0.986). As C grows the model gains capacity and both scores rise, until validation peaks at C=10 (0.996). Past that, train marches on to a perfect 1.000 while validation falls to 0.990 — the model is spending its new capacity memorizing, and the widening train-minus-validation gap is overfitting made visible. The best C is the one that maximizes validation, not train — here, 10 — and the curve shows you not just the answer but the shape of the tradeoff around it.

Region of a validation curve Train Validation Gap Meaning
Hyperparameter too “small” Low Low Small Underfit — not enough capacity
At the validation peak High Highest Small–moderate The value to choose
Hyperparameter too “large” Very high → 1.0 Falling Widening Overfit — capacity spent memorizing

Now the learning curve, which fixes the hyperparameters and grows the data instead:

from sklearn.model_selection import learning_curve
sizes, tr, va = learning_curve(
    Pipeline([("scaler", StandardScaler()), ("svc", SVC(kernel="rbf", C=10, gamma=0.01))]),
    X, y, cv=cv, scoring="roc_auc",
    train_sizes=np.linspace(0.1, 1.0, 6), random_state=0)
print("train size:", " ".join(f"{int(s):5d}" for s in sizes))
print("train AUC :", " ".join(f"{v:5.3f}" for v in tr.mean(1)))
print("valid AUC :", " ".join(f"{v:5.3f}" for v in va.mean(1)))
train size:    45   127   209   291   373   455
train AUC : 0.999 0.999 1.000 0.998 0.998 0.998
valid AUC : 0.984 0.992 0.994 0.995 0.995 0.996

The train score is flat and near-perfect; the validation score climbs from 0.984 at 45 samples to 0.996 at 455 as the gap narrows from 0.015 to 0.002. The two curves are converging and beginning to plateau — validation has nearly caught up to train and is levelling off — which is the signature of a model that has about enough data. If the two curves were still racing toward each other with a wide gap at the right edge, more data would clearly help; if they had met at a low score, you would be underfitting and more data would be useless — you would need a richer model instead. That single question, would more data help?, is what only a learning curve can answer, and it is often worth more than any hyperparameter.

Curve X-axis Diagnoses Cure it points to
Validation curve One hyperparameter Under/overfit along that knob Set the knob to the validation peak
Learning curve Training-set size Whether more data would help Gather data (converging, high) or enrich the model (converged, low)

The practical reality of tuning

Everything above is mechanism; this section is judgment, and judgment is what keeps you from burning a weekend of compute for a 0.001 gain. A handful of hard-won rules.

Go coarse, then fine. Do not start with a dense grid over a narrow range — you do not yet know where the good region is. Start with a wide, coarse search (log-spaced, a few points per decade), find the neighbourhood of the best score, then run a second, finer search zoomed into that neighbourhood. Concretely: a first pass over C = np.logspace(-3, 3, 7) might peak at C=10; the second pass then searches C = [3, 5, 10, 30, 50] around that winner. Two cheap searches beat one enormous one, and the coarse pass tells you whether you are even in the right range before you spend on precision — if the peak sits at the edge of your coarse range, extend the range rather than refining, because the real optimum is probably still outside it.

Do not tune everything. Most hyperparameters barely move the score; a few dominate. For an SVM it is C and gamma; for gradient boosting, learning_rate and n_estimators and max_depth; for a random forest, honestly, the defaults are hard to beat and max_depth/min_samples_leaf are where the leverage is. Tuning ten hyperparameters when two matter multiplies your search cost by orders of magnitude to explore noise. Identify the two or three that matter — the capacity dials — and tune those.

Respect diminishing returns and a compute budget. The first coarse search often captures most of the available gain; the second, refined one adds a little; a third adds almost nothing. Decide your budget before you start — “this search runs overnight, no more” — and let n_iter or a halving schedule fit the work to the budget rather than letting an exhaustive grid dictate a week. A model that is 0.001 better after eight more hours of tuning is not better in any way your users will notice.

And the rule that outranks all the others: better features and more data beat better hyperparameters. This is the honest, slightly deflating truth of applied machine learning. The gap between default and perfectly-tuned hyperparameters is usually a few percent; the gap between mediocre features and good ones, or between a thousand rows and a hundred thousand, is often the whole game. If your model is not good enough, your first instinct should be a better feature or more data, not a bigger grid — tuning is the polish you apply to a model that is already fundamentally sound, not the thing that makes an unsound one work. A learning curve that is still climbing steeply is telling you to go get data, not to tune harder.

Symptom The tempting move The move that usually wins
Model underperforms Tune harder, bigger grid Better features; check the learning curve for “more data”
Learning curve still climbing Tune the model Gather more data
Learning curves converged, both low More data A richer model / new features (you are underfitting)
Gain of 0.001 after a huge search Search even more Stop — you have hit diminishing returns
Score unstable run to run Trust the best run Pin random_state; report mean ± std across seeds

Finally, reproducibility, which threads through every example above. Three separate sources of randomness must be pinned or your results wander: the data split, the model’s own randomness, and the CV splitter. And one performance knob, n_jobs, has a cost worth knowing:

Knob Set it to Why / the catch
train_test_split(random_state=…) any fixed int Same split every run
model random_state=… (RF, trees, SGD) any fixed int Same learned model
CV splitter random_state=… (with shuffle=True) any fixed int Same folds — so the search is reproducible
RandomizedSearchCV(random_state=…) any fixed int Same sampled combinations
n_jobs=-1 all cores Fits run in parallel — but each worker copies the data, so a big dataset × many cores can exhaust RAM; drop to n_jobs=4 if memory spikes

Pin the first four and your entire search reproduces byte-for-byte; leave any one unpinned and “why did the score change?” becomes an unanswerable question mid-project.


Hands-on lab

One self-contained script, tune_lab.py. It builds a fresh imbalanced problem, seals a test set, tunes a scaling-plus-SVM pipeline with GridSearchCV (reading best_params_ and cv_results_), matches it with RandomizedSearchCV in a fraction of the fits, plots a validation curve and a learning curve headless, demonstrates the nested-versus-flat optimism gap, and finally unseals the test set exactly once. Every number below is from an actual run on scikit-learn 1.9.0.

Set up once:

python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
python -m pip install scikit-learn numpy pandas matplotlib scipy

Step 1 — a problem, a sealed test set, a pipeline

import numpy as np, matplotlib
matplotlib.use("Agg")                                  # headless: save, don't show
import matplotlib.pyplot as plt
import pandas as pd
from scipy.stats import loguniform
from sklearn.datasets import make_classification
from sklearn.model_selection import (train_test_split, GridSearchCV, RandomizedSearchCV,
                                     cross_val_score, validation_curve, learning_curve,
                                     StratifiedKFold)
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC

X, y = make_classification(n_samples=800, n_features=20, n_informative=6,
                           n_redundant=2, weights=[0.6], flip_y=0.06,
                           class_sep=0.9, random_state=42)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, stratify=y, random_state=42)
pipe = Pipeline([("scaler", StandardScaler()), ("svc", SVC(kernel="rbf"))])
cv = StratifiedKFold(5, shuffle=True, random_state=0)
print(f"[1] train={len(y_tr)}  test={len(y_te)} (sealed)  positive rate={y.mean():.2f}")
[1] train=600  test=200 (sealed)  positive rate=0.41

What just happened: the test set is sealed on line one and does not reappear until Step 7. Everything between tunes on the 600 training rows only.

Step 2 — GridSearchCV, reading best_params_ and cv_results_

grid = {"svc__C": [0.1, 1, 10, 100], "svc__gamma": [1e-3, 1e-2, 1e-1, 1]}
gs = GridSearchCV(pipe, grid, scoring="roc_auc", cv=cv, n_jobs=-1).fit(X_tr, y_tr)
print(f"[2] 16 combos × 5 folds = 80 fits")
print(f"    best_params_ = {gs.best_params_}")
print(f"    best_score_  = {gs.best_score_:.4f} (mean CV ROC-AUC)")
for _, r in pd.DataFrame(gs.cv_results_).sort_values("rank_test_score").head(3).iterrows():
    print(f"    rank {int(r['rank_test_score'])}: C={r['param_svc__C']:<5} "
          f"gamma={r['param_svc__gamma']:<6} CV={r['mean_test_score']:.4f} ± {r['std_test_score']:.4f}")
[2] 16 combos × 5 folds = 80 fits
    best_params_ = {'svc__C': 100, 'svc__gamma': 0.01}
    best_score_  = 0.8876 (mean CV ROC-AUC)
    rank 1: C=100.0 gamma=0.01   CV=0.8876 ± 0.0129
    rank 2: C=10.0  gamma=0.01   CV=0.8833 ± 0.0275
    rank 3: C=10.0  gamma=0.1    CV=0.8724 ± 0.0239

What just happened: the grid found (C=100, gamma=0.01) at CV ROC-AUC 0.888. Note rank 2’s ± 0.0275 is twice rank 1’s spread — the winner is not just higher, it is steadier, which matters as much as the mean.

Step 3 — RandomizedSearchCV, the same answer for fewer fits

dists = {"svc__C": loguniform(1e-1, 1e2), "svc__gamma": loguniform(1e-3, 1e0)}
print(f"    {'method':<24}{'fits':>5}{'best CV':>10}")
print(f"    {'GridSearchCV':<24}{80:>5}{gs.best_score_:>10.4f}")
for n in (5, 8, 12):
    rs = RandomizedSearchCV(pipe, dists, n_iter=n, scoring="roc_auc",
                            cv=cv, n_jobs=-1, random_state=0).fit(X_tr, y_tr)
    print(f"    {'RandomizedSearchCV n='+str(n):<24}{n*5:>5}{rs.best_score_:>10.4f}")
    method                   fits   best CV
    GridSearchCV               80    0.8876
    RandomizedSearchCV n=5     25    0.8859
    RandomizedSearchCV n=8     40    0.8859
    RandomizedSearchCV n=12    60    0.8859

What just happened: with 25 fits — under a third of the grid’s 80 — random search is within 0.002 ROC-AUC of the exhaustive answer. On a real problem where each fit costs minutes, that is the difference between an afternoon and a coffee.

Step 4 — a validation curve over C

C_range = np.logspace(-2, 3, 6)
tr, va = validation_curve(Pipeline([("scaler", StandardScaler()),
                                    ("svc", SVC(kernel="rbf", gamma=0.01))]),
                          X_tr, y_tr, param_name="svc__C", param_range=C_range,
                          cv=cv, scoring="roc_auc")
print("[4] C        :", " ".join(f"{c:7.3g}" for c in C_range))
print("    train AUC:", " ".join(f"{v:7.3f}" for v in tr.mean(1)))
print("    valid AUC:", " ".join(f"{v:7.3f}" for v in va.mean(1)))
[4] C        :    0.01     0.1       1      10     100   1e+03
    train AUC:   0.854   0.854   0.889   0.962   0.995   1.000
    valid AUC:   0.825   0.825   0.845   0.883   0.888   0.859

What just happened: validation peaks at C=100 (0.888) then falls to 0.859 at C=1000 even as train hits a perfect 1.000 — textbook overfitting past the peak. The grid in Step 2 was right to stop at 100.

Step 5 — a learning curve

sizes, ltr, lva = learning_curve(gs.best_estimator_, X_tr, y_tr, cv=cv, scoring="roc_auc",
                                 train_sizes=np.linspace(0.1, 1.0, 6), random_state=0)
print("[5] size     :", " ".join(f"{int(s):7d}" for s in sizes))
print("    train AUC:", " ".join(f"{v:7.3f}" for v in ltr.mean(1)))
print("    valid AUC:", " ".join(f"{v:7.3f}" for v in lva.mean(1)))

fig, (a1, a2) = plt.subplots(1, 2, figsize=(11, 4.5), layout="constrained")
a1.semilogx(C_range, tr.mean(1), "o-", label="train"); a1.semilogx(C_range, va.mean(1), "o-", label="validation")
a1.set(title="Validation curve (C)", xlabel="svc__C", ylabel="ROC-AUC"); a1.legend()
a2.plot(sizes, ltr.mean(1), "o-", label="train"); a2.plot(sizes, lva.mean(1), "o-", label="validation")
a2.set(title="Learning curve", xlabel="training samples", ylabel="ROC-AUC"); a2.legend()
fig.savefig("tune_curves.png", dpi=120, bbox_inches="tight")
print("    wrote tune_curves.png")
[5] size     :      48     134     220     307     393     480
    train AUC:   1.000   1.000   1.000   0.998   0.998   0.995
    valid AUC:   0.742   0.816   0.830   0.858   0.871   0.888
    wrote tune_curves.png

What just happened: validation climbs steadily from 0.742 to 0.888 as data grows, and the train-minus-validation gap is still a wide ~0.11 at the right edge. The curves have not converged — this model would genuinely benefit from more data, the most valuable thing a learning curve can tell you.

Step 6 — nested vs flat CV, the optimism gap

inner = StratifiedKFold(5, shuffle=True, random_state=1)
outer = StratifiedKFold(5, shuffle=True, random_state=2)
flat = GridSearchCV(pipe, grid, scoring="roc_auc", cv=inner, n_jobs=-1).fit(X_tr, y_tr)
nested = cross_val_score(GridSearchCV(pipe, grid, scoring="roc_auc", cv=inner, n_jobs=-1),
                         X_tr, y_tr, scoring="roc_auc", cv=outer, n_jobs=-1)
print(f"[6] FLAT   best_score_ = {flat.best_score_:.4f}  <- tempting to report")
print(f"    NESTED outer mean  = {nested.mean():.4f} ± {nested.std():.4f}  <- honest")
print(f"    optimism gap       = {flat.best_score_ - nested.mean():+.4f}")
[6] FLAT   best_score_ = 0.8912  <- tempting to report
    NESTED outer mean  = 0.8840 ± 0.0219  <- honest
    optimism gap       = +0.0072

What just happened: the flat search’s best_score_ is 0.007 optimistic versus the nested estimate — modest here because the problem is not tiny, but real and always in the same direction: up.

Step 7 — unseal the test set, exactly once

print(f"[7] chosen model {gs.best_params_}")
print(f"    CV said {gs.best_score_:.4f}   sealed test says {gs.score(X_te, y_te):.4f}")
[7] chosen model {'svc__C': 100, 'svc__gamma': 0.01}
    CV said 0.8876   sealed test says 0.8766

What just happened: the final, honest number is 0.877 — close to the CV estimate of 0.888 because the pipeline kept the whole search leakage-free. That single test evaluation is the only time X_te was touched, and now the lab is done. Report 0.877, not 0.888, and never re-open the test set to “improve” things.

⚠️ The gs.score(X_te, y_te) call is your one permitted look at the test set. Run it in a loop while tweaking the grid and you have silently converted your test set into a validation set — every subsequent number is fiction.


Common mistakes and troubleshooting

Symptom / message Cause Fix
AttributeError: This 'GridSearchCV' has no attribute 'predict' Called .predict with refit=False Leave refit=True (default), or refit manually from best_params_
AttributeError: 'GridSearchCV' object has no attribute 'best_estimator_' refit=False then read best_estimator_ Same — refit must be truthy to expose the winner
InvalidParameterError: The 'scoring' parameter … Got 'rock_auc' instead. Misspelled scoring string Use a valid name: "roc_auc", "f1", "average_precision", …
ValueError: Invalid parameter 'C' for estimator Pipeline(...) Grid key missing the step__ prefix Address pipeline params as "svc__C", not "C"
CV score high, sealed-test / production much lower Flat-CV optimism — reported best_score_ Report the sealed-test score; for small data use nested CV
Search runs for hours Grid too big (combinatorial blow-up) RandomizedSearchCV / halving; coarse-then-fine; tune fewer knobs
Pipeline-less search beats the Pipeline version Preprocessing leakage — scaler/selector fit on all data Put every preprocessing step inside the tuned Pipeline
UndefinedMetricWarning: … ill-defined during search A fold predicted no positives for precision/f1 Wrong scoring for imbalance — use average_precision/roc_auc; or accept zero_division
best_params_ different every run random_state unset on split / model / CV / search Pin all four random_states
RAM spikes / machine swaps with n_jobs=-1 Each parallel worker copies the dataset Lower to n_jobs=4; or shrink the data per fit
Tuned for an hour, model no better Saturated model — diminishing returns Stop; invest in features/data (check the learning curve)
FutureWarning: 'penalty' was deprecated in version 1.8 Set LogisticRegression(penalty=…) (removed in 1.10) Use C / l1_ratio instead; leave penalty at its default
cv=5 gives weird folds on ordered data Bare int → unshuffled StratifiedKFold Pass StratifiedKFold(5, shuffle=True, random_state=0)

Three of these deserve a longer look, because they are the ones that quietly corrupt results rather than loudly crashing.

Tuning without a Pipeline reintroduces the leakage you thought you’d escaped. It is tempting to StandardScaler().fit_transform(X) once, up front, and then run GridSearchCV on the scaled array — it looks cleaner. It is also leakage: the scaler learned its mean and standard deviation from all the data, including the rows that become each fold’s validation slice, so every CV score in the search is contaminated. Worse, the contamination is invisible — the search machinery runs perfectly on poisoned inputs and reports honest-looking numbers about a dishonest setup. The only fix is structural: put the scaler inside the estimator you hand to the search, so cross-validation refits it per fold on training rows only. This is the entire reason the preprocessing lesson insists preprocessing belongs inside the model, and it matters more during tuning, not less, because a search amplifies a small leak across hundreds of fits.

Reporting the CV score as the test score is the most common way tuned models disappoint. best_score_ is not your model’s performance; it is the performance of the luckiest combination you tried, measured on the folds it got lucky on. It is optimistic by construction — by five points on the small noisy problem above — and the optimism grows with grid size, noise, and data scarcity. Discipline: best_score_ is a selection number, used only to compare and choose; the number you report comes from the sealed test set (touched once) or, when you have no test set to spare, from the outer loop of a nested CV. If the only number you have is best_score_, you do not yet have an honest estimate of anything.

The wrong scoring optimizes for the wrong thing, silently. A search does exactly what you tell it: it maximizes scoring. Leave it at the default and a classifier optimizes accuracy — which, on the imbalanced data where tuning matters most, rewards a model that predicts the majority class and ignores the rare one you care about. The search will dutifully hand you the combination with the best accuracy, and it will be useless for catching fraud or disease. Choose scoring to match the cost of the errors you actually care about — average_precision or recall on imbalanced problems, neg_root_mean_squared_error when large regression errors are catastrophic — before you launch the search, because the search cannot know what you meant, only what you asked for.


Cheat-sheet

Task Code
Grid search a pipeline GridSearchCV(pipe, grid, scoring="roc_auc", cv=cv, n_jobs=-1).fit(X_tr, y_tr)
Grid keys (pipeline step params) {"svc__C": [1, 10], "svc__gamma": [0.01, 0.1]}
Randomized search RandomizedSearchCV(pipe, dists, n_iter=30, cv=cv, random_state=0)
Log-scaled distribution from scipy.stats import loguniform; loguniform(1e-4, 1e2)
Integer distribution from scipy.stats import randint; randint(50, 500)
Successive halving from sklearn.experimental import enable_halving_search_cvHalvingRandomSearchCV(...)
Read the winner search.best_params_, search.best_score_, search.best_estimator_
Read the whole landscape pd.DataFrame(search.cv_results_)
Predict with the tuned model search.predict(X_new) (needs refit=True)
Score the sealed test once search.score(X_te, y_te)
Tune model and preprocessing {"select__k": [10, 20], "clf__C": [0.1, 1]}
Swap the model itself {"clf": [LogisticRegression(), RandomForestClassifier()]}
Cross-validate (select) cross_val_score(pipe, X, y, cv=StratifiedKFold(5, shuffle=True, random_state=0))
Several metrics + train gap cross_validate(pipe, X, y, cv=cv, scoring=[...], return_train_score=True)
Nested CV (honest estimate) cross_val_score(GridSearchCV(pipe, grid, cv=inner), X, y, cv=outer)
Validation curve (one knob) validation_curve(est, X, y, param_name="svc__C", param_range=..., cv=cv)
Learning curve (data size) learning_curve(est, X, y, train_sizes=np.linspace(0.1,1,5), cv=cv)
Reproducibility Pin random_state on split, model, CV splitter, and search
Budget knob n_iter (randomized) or factor (halving) — fit the search to the clock

Interview and exam questions

Q: What is the difference between a parameter and a hyperparameter? A: A parameter is learned by fit from the data — the coefficients of a linear model, the splits of a tree — and lives in trailing-underscore attributes like coef_. A hyperparameter is set by you before fitting — C, max_depth, n_neighbors — and configures how learning happens; the model cannot learn it by ordinary fitting because it governs the fitting itself. Tuning is the search over hyperparameters.

Q: Why can’t you tune hyperparameters against a single train/validation split? A: Because a single split’s score depends on which rows landed where, and that noise is often larger than the effect you’re tuning. The same model varied by 0.010 across split seeds in this lesson — so a hyperparameter that moves the score by 0.005 is indistinguishable from luck. Cross-validation replaces the one lucky number with a mean ± std, and you only trust an improvement that clears the noise band.

Q: Walk through what GridSearchCV does, and name its four key attributes. A: It enumerates every combination in the grid, runs k-fold CV on each, and (with refit=True) retrains the best combination on all the data. best_params_ is the winning dict, best_score_ is its mean CV score (for selection, never reporting), best_estimator_ is the winner refit on all training data and ready to predict, and cv_results_ holds every combination’s per-fold scores, means, stds, and timings.

Q: A 5×5×5 grid with 5-fold CV — how many model fits, and what’s the concern? A: 125 combinations × 5 folds = 625 fits. The concern is the combinatorial blow-up: each hyperparameter you add multiplies the total, so grid search scales terribly. The fixes are RandomizedSearchCV (sample n_iter points and set your own budget), successive halving (eliminate weak candidates cheaply on small data slices), a coarse-then-fine strategy, and simply tuning fewer hyperparameters.

Q: When does RandomizedSearchCV beat GridSearchCV, and why? A: Almost always with three or more hyperparameters. When only a few hyperparameters matter — the usual case — random sampling covers those important dimensions far more densely than a grid, which wastes points enumerating the unimportant ones. In this lesson random search reached within 0.006 test ROC-AUC of the full grid using 17% of the fits, and tied it with 67%. It also lets you set the budget directly via n_iter.

Q: What is nested cross-validation, and what problem does it solve? A: It’s two CV loops — an inner loop that tunes (a full GridSearchCV) and an outer loop that scores each tuned model on data the inner loop never saw. It solves the optimism of flat CV: best_score_ is the score of the combination selected because it scored best on those folds, so it’s biased upward. The outer loop’s average is an unbiased estimate. In sklearn: cross_val_score(GridSearchCV(...), X, y, cv=outer).

Q: How big is the flat-CV optimism, and what makes it bigger? A: It varies from essentially zero to several points. On an easy, saturated problem the gap was −0.000; on a small, noisy problem with a big grid it was +0.049 ROC-AUC — five points of self-deception. The gap grows with anything that gives the search more room to overfit the folds: more noise, less data, and a larger grid. It is always in the same direction — the flat score is too high.

Q: You have a pipeline [scaler, select, clf]. Write a grid that tunes the number of selected features and the classifier’s C together, and explain why the pipeline makes it safe. A:

grid = {"select__k": [5, 10, 20], "clf__C": [0.1, 1, 10]}
GridSearchCV(pipe, grid, scoring="roc_auc", cv=cv).fit(X_tr, y_tr)

The step__param double-underscore syntax addresses each step’s hyperparameter. It’s leakage-safe because SelectKBest lives inside the pipeline: on every fold, cross-validation refits the selector on that fold’s training rows only, so choosing k never peeks at the validation labels — the feature-selection leakage that can manufacture a strong score from pure noise is structurally impossible.

Q: What’s the difference between a validation curve and a learning curve? A: A validation curve varies one hyperparameter and plots train/validation score against it — it shows under/overfitting along that knob, and you pick the value at the validation peak. A learning curve varies the training-set size and plots the scores against it — it answers “would more data help?” If the curves are still converging with a wide gap, gather data; if they’ve met at a low score, you’re underfitting and need a richer model instead.

Q: You call search.predict(X) and get AttributeError: This 'GridSearchCV' has no attribute 'predict'. Why? A: The search was created with refit=False, so it never retrained the winning combination and has no model to predict with — best_estimator_, predict, and score all become unavailable. Fix it by leaving refit=True (the default), which refits the winner on all the training data, or refit an estimator yourself from best_params_.

Q: Your tuned model gained 0.001 after an eight-hour search. What do you conclude? A: You’ve hit diminishing returns on a probably-saturated model, and more tuning is wasted effort. The honest next move is not a bigger grid but better features or more data — the gap between default and perfectly-tuned hyperparameters is usually a few percent, while better features or an order of magnitude more data is often the whole game. A learning curve tells you which: still climbing means get data; converged and low means enrich the model.

Q: Why must scoring be chosen deliberately before a search, especially on imbalanced data? A: The search maximizes exactly scoring and nothing else. The default for classifiers is accuracy, which on imbalanced data rewards predicting the majority class — so the search will hand you a model that ignores the rare class you actually care about. Set scoring="average_precision" or "recall" for imbalanced positives, or "neg_root_mean_squared_error" for regression where big misses hurt, so the search optimizes what you mean rather than what the default assumes.


Key takeaways

pythonscikit-learnhyperparameter-tuningcross-validationgridsearchcvrandomizedsearchcvnested-cross-validationpipelinevalidation-curvelearning-curvemodel-selectionmachine-learningadvanced
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments