Machine Learning · Module 20 of 26 · 11 concepts · about 21 min

Hyperparameter Optimization

Searching the settings you have to choose rather than fit. Why random beats grid, why cross-validation belongs inside the loop, and how to report a score you tuned without lying about it.

At work: Tuning a model's settings efficiently, getting most of the gain without burning days of compute and cloud budget.

What you are actually searching over

What it is

Parameters are fitted from the data by the learning algorithm — coefficients, split thresholds, centroids. Hyperparameters are the inputs to that algorithm, chosen before it runs: the penalty strength, the tree depth, the number of components, the resampling ratio, the threshold. Only the second kind is searched.

Why it matters

Drawing the line precisely is what tells you which quantities need a validation set. A fitted parameter is validated by the model's own objective; a hyperparameter has no such objective, so every one you choose consumes some of your validation data's credibility.

In the real world: Settings like tree depth and learning rate that decide how well the churn model generalises.

Visual example

Parameters vs hyperparameters

The same pipeline, split into what the fit decides and what you decide — and it is the right-hand column that costs validation data.

Searched, not fitted

(each one spends validation credibility)

pipe.get_params()

every searchable setting, with its double-underscore path

Pipeline([scale, pca, smote, clf])
quantitykinddecided by
scaler mean / stdparameterfit on the training fold
pca n_componentshyperparameterthe search
pca components_parameterfit on the training fold
clf max_depthhyperparameterthe search
decision thresholdhyperparameterthe search (often forgotten)

pca__n_components, clf__max_depth — the grid keys come straight from here

Why this matters

Two of the three searched rows are preprocessing, not the model. That is typical, and it is why tuning has to happen over the whole pipeline object rather than over the estimator at the end of it.

Common misconception

Wrong idea: hyperparameter tuning means tuning the model.

Correct idea: it means tuning every choice you made. The number of PCA components and the decision threshold are hyperparameters in exactly the same sense as max_depth, and leaving them out of the search does not make them unchosen.

from sklearn.linear_model import LogisticRegression

model = LogisticRegression(C=0.5, penalty="l2", max_iter=2000)   # hyperparameters
model.fit(X_train, y_train)

model.coef_, model.intercept_        # parameters: fitted, not chosen
model.get_params()                    # hyperparameters: chosen, not fitted
model.set_params(C=2.0)               # ...and therefore searchable

# Anything with a trailing underscore was learned. Anything in get_params() was set.
[k for k in vars(model) if k.endswith("_")]

Forgetting that preprocessing choices are hyperparameters too. SelectKBest(k=...), PCA(n_components=...), SMOTE(sampling_strategy=...) and the decision threshold are all settings you picked by looking at scores, and every one of them needs to sit inside the search rather than be decided once up front.

Grid search, and how its cost scales

What it is

GridSearchCV takes the Cartesian product of the values you list for each hyperparameter and cross-validates every combination. Coverage is exhaustive and reproducible, and the total number of fits is the product of the list lengths multiplied by the number of folds.

Why it matters

It is the right tool for one or two hyperparameters with a handful of plausible values each, which is a genuinely common situation. It is the wrong tool for six, and knowing which situation you are in is a multiplication.

In the real world: Trying every combination of five settings can mean thousands of training runs and a big cloud bill.

Visual example

GridSearchCV

Configurations against the number of hyperparameters. Every parameter you add multiplies; it does not add.

3 values each5 values each
12340100200300400500600700Hyperparameters in the gridConfigurations to fit3,125 fits at cv=5405 fits

The same explosion in minutes

One fit on this dataset: 4 seconds

2 params x 5 values, cv=5: 25 x 5 x 4s = 8 minutes

3 params x 5 values, cv=5: 125 x 5 x 4s = 42 minutes

4 params x 5 values, cv=5: 625 x 5 x 4s = 3.5 hours

5 params x 5 values, cv=5: 3125 x 5 x 4s = 17 hours

Why this matters

This is the curse of dimensionality from module 15 applied to a search space rather than a feature space, and it has the same cause: volume grows exponentially in the number of axes.

Common misconception

Wrong idea: a finer grid is a better search, so use more values per parameter.

Correct idea: refining a grid is the most expensive way to buy resolution — cost grows as values ** params. Sampling the same space randomly buys resolution on every axis at once, for a fixed budget.

from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import GridSearchCV, StratifiedKFold

grid = {
    "learning_rate": [0.01, 0.05, 0.1],
    "max_depth": [2, 3, 5],
    "subsample": [0.7, 1.0],
    "min_samples_leaf": [1, 5, 20],
}
n_configs = 3 * 3 * 2 * 3                     # 54
print(n_configs, "configs x 5 folds =", n_configs * 5, "fits")

search = GridSearchCV(
    GradientBoostingClassifier(random_state=0), grid,
    cv=StratifiedKFold(5, shuffle=True, random_state=0),
    scoring="average_precision", n_jobs=-1, refit=True,
).fit(X_train, y_train)
search.best_params_, round(search.best_score_, 4)

Adding one more hyperparameter 'while we are at it'. Each addition multiplies the cost, so a grid that took 20 minutes takes an hour with one extra three-valued parameter, and five hours with two. The same budget spent on random search covers a far larger space — which is the next concept.

Random search, and why it usually wins

What it is

RandomizedSearchCV draws n_iter configurations from distributions over the space instead of enumerating a lattice. Every draw has a distinct value on every axis, so a budget of 60 trials explores 60 values of each hyperparameter rather than the 3 or 4 a grid of the same size would.

Why it matters

Hyperparameter importance is almost always concentrated: one or two settings dominate and the rest barely matter. A grid spends its budget on combinations of the unimportant ones; random search spends the same budget sampling the important ones finely, without needing to know in advance which they are.

In the real world: Trying 60 random combinations usually finds settings as good as a huge grid, for a fraction of the cost.

Visual example

RandomizedSearchCV

Sixteen trials each. The grid tries 4 distinct values of the parameter that matters; random search tries 16 — and only one of them lands in the good region.

grid: 4 x 4random: 16 draws
Good region00.20.40.60.8100.20.40.60.81learning rate (this one matters)min_samples_leaf (this one barely does)random finds it

Counting the coverage

Grid: 16 trials, 4 distinct learning rates (0.2, 0.4, 0.6, 0.8)

Random: 16 trials, 16 distinct learning rates

The good band is 7% of the axis. Grid hits it with probability 0 — it misses by design.

Random misses it with probability 0.93 ** 16 = 0.31, so it finds it 69% of the time

Both searches cost exactly the same 16 x 5 = 80 fits

Why this matters

The grid's 16 points project onto only 4 positions on the axis that matters, because 12 of its trials are re-testing a learning rate it already tried at a different leaf size. Random search never repeats a value, so its projection onto every axis is as dense as its budget.

Common misconception

Wrong idea: random search is a lazy approximation to grid search — worse, but cheaper.

Correct idea: for the same budget it is usually better, and provably so once the space has more than two or three axes. Exhaustiveness over a coarse lattice is not the same thing as coverage.

from scipy.stats import loguniform, randint, uniform
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import RandomizedSearchCV, StratifiedKFold

space = {
    "learning_rate": loguniform(1e-3, 3e-1),   # log scale: 0.001 and 0.01 differ as much
    "max_depth": randint(2, 9),                #             as 0.1 and 1.0
    "subsample": uniform(0.5, 0.5),            # uniform(loc, scale) -> [0.5, 1.0]
    "min_samples_leaf": randint(1, 60),
}
search = RandomizedSearchCV(
    GradientBoostingClassifier(random_state=0), space,
    n_iter=60,                                  # the budget, stated directly
    cv=StratifiedKFold(5, shuffle=True, random_state=0),
    scoring="average_precision", n_jobs=-1, random_state=0,
).fit(X_train, y_train)
search.best_params_

Sampling a scale-sensitive parameter uniformly. uniform(1e-4, 1.0) puts 90% of its draws above 0.1, so a learning rate whose useful range is 0.001–0.01 is almost never tried. Use loguniform for anything spanning orders of magnitude — learning rates, regularisation strengths, gamma, C.

Bayesian optimization

What it is

Fit a cheap surrogate model — a Gaussian process or a tree ensemble — mapping hyperparameters to observed score, then choose the next configuration to try by maximising an acquisition function that balances 'where the surrogate predicts a high score' against 'where the surrogate is most uncertain'. Refit the surrogate after each trial.

Why it matters

Grid and random search are both blind: trial 50 knows nothing about trials 1 to 49. Bayesian optimization uses that history, so it reaches a comparable score in noticeably fewer trials — which matters when one trial is an hour of GPU time rather than four seconds.

In the real world: Choosing the next settings to try based on earlier results, which saves compute when each training run takes hours.

# optuna: define the space in code, so it can be conditional.
import optuna
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import cross_val_score

def objective(trial):
    params = {
        "learning_rate": trial.suggest_float("learning_rate", 1e-3, 3e-1, log=True),
        "max_depth": trial.suggest_int("max_depth", 2, 8),
        "subsample": trial.suggest_float("subsample", 0.5, 1.0),
    }
    model = GradientBoostingClassifier(random_state=0, **params)
    return cross_val_score(model, X_train, y_train, cv=5,
                           scoring="average_precision").mean()

study = optuna.create_study(direction="maximize", sampler=optuna.samplers.TPESampler(seed=0))
study.optimize(objective, n_trials=60)
study.best_params, study.best_value

Reaching for it too early. It is sequential, so it parallelises far worse than random search, and its advantage only appears once the surrogate has enough trials to be worth anything — typically 20 or 30. For a cheap model on eight cores, 200 random trials finish sooner and land in the same place as 60 Bayesian ones. Use it when a single trial is expensive.

Successive halving

What it is

Start many configurations on a small budget — few rows, few trees, few epochs — keep the best fraction, double their budget, and repeat. Poor configurations are discarded after cheap partial evaluations, so almost all of the compute goes to the survivors.

Why it matters

It attacks the cost of a trial rather than the number of trials, which makes it complementary to everything above: HalvingRandomSearchCV gets random search's coverage at a fraction of the compute.

In the real world: Giving many candidate models a small budget and continuing only with the promising ones.

Visual example

HalvingRandomSearchCV

One halving schedule at factor=3: 81 configurations for the cost of about 5 full fits.

search.n_candidates_

[81, 27, 9, 3, 1] — a third survives each rung

resource = n_samples, factor = 3
rungcandidatesrows eachtotal rows
081100081000
127300081000
29900081000
332700081000
418100081000

405,000 row-fits total, against 81 x 81,000 = 6.6M for a full random search

Where the saving comes from

Every rung costs the same: candidates x rows = 81,000

5 rungs x 81,000 = 405,000 row-fits, or about 5 full-data fits

Random search would spend 81 full fits to evaluate the same 81 candidates

So halving evaluates 16x more configurations per unit of compute

The price: rung 0's ranking is made on 1.2% of the data

Why this matters

The constant right-hand column is the mechanism. Each rung has the same budget, spent on fewer, better candidates — which is why the total is linear in the number of rungs rather than in the number of candidates.

Common misconception

Wrong idea: halving is an approximation that finds a worse configuration than a full search.

Correct idea: it finds the same configuration whenever early performance ranks candidates correctly. Its risk is entirely about that assumption, not about the amount of compute.

from sklearn.experimental import enable_halving_search_cv   # noqa: F401
from sklearn.model_selection import HalvingRandomSearchCV
from sklearn.ensemble import RandomForestClassifier
from scipy.stats import randint

search = HalvingRandomSearchCV(
    RandomForestClassifier(random_state=0),
    {"max_depth": randint(2, 30), "min_samples_leaf": randint(1, 40),
     "max_features": ["sqrt", "log2", 0.5]},
    resource="n_samples",      # or "n_estimators" to grow the forest instead
    factor=3,                   # keep 1/3 of candidates, triple their budget
    min_resources="exhaust",
    scoring="average_precision", cv=5, random_state=0, n_jobs=-1,
).fit(X_train, y_train)
search.n_resources_, search.n_candidates_

It assumes a configuration's ranking at a small budget predicts its ranking at the full budget. That is often true and sometimes badly false — a low learning rate looks terrible after 50 boosting rounds and wins after 2,000, and halving will have eliminated it in the first rung. Raise min_resources when the resource is one the model needs a lot of before it looks like itself.

Diminishing returns, and the search budget

What it is

Plot the best score found so far against the number of trials. The curve rises steeply, bends, and flattens — and the flat part is where extra compute buys score in the third decimal place while the fold-to-fold noise is in the second.

Why it matters

It turns 'how long should I search?' into something you can read off a plot you already have. cv_results_ contains every trial, so the curve costs nothing to draw, and it usually says the search could have stopped at a quarter of the budget.

In the real world: After a few hours of tuning, a demand model gains only 0.1%; the time is better spent on features.

Visual example

Search budget

Best score so far, against trials. Ten trials get 85% of the total gain; the remaining fifty get the rest.

random searchgrid search, same budget
Diminishing returns01020304050600.780.80.820.840.860.88Trials completedBest cross-validated score so farbest this space allows10 trials

Gains against the noise floor

Total gain available from trial 1 to 60: 0.8745 - 0.802 = 0.0725

Trials 1-10 capture 0.059 of that (81%)

Trials 20-60 capture 0.0055 — less than one standard error of the score (0.009)

So everything after trial 20 is inside the measurement noise

Stop when the marginal gain per trial drops below the standard error

Why this matters

The standard error of the score is the natural stopping rule, and it is a number you already have. Once the curve's remaining climb is smaller than that, extra trials are buying differences you cannot measure.

Common misconception

Wrong idea: the search should run until the curve is completely flat.

Correct idea: it should stop when the curve's remaining climb is smaller than the score's own uncertainty. Past that the ranking of candidates is noise, and the extra trials mostly buy the optimism the next concept describes.

import numpy as np

scores = search.cv_results_["mean_test_score"]
best_so_far = np.maximum.accumulate(scores)          # the curve to plot

noise = search.cv_results_["std_test_score"][search.best_index_] / np.sqrt(5)
reached = best_so_far >= best_so_far[-1] - noise
print(f"within one standard error of the final best after {int(np.argmax(reached)) + 1} trials")

# Optuna keeps the same history, and can prune hopeless trials mid-fit.
# optuna.visualization.plot_optimization_history(study)

Reading the flat tail as 'the search has converged on the optimum'. It has converged on the limit of what this *space* and this *score estimator* can distinguish. If the tail is flat and the score is not good enough, the fix is a wider space, better features, or a different model family — not more trials.

The winner's curse: optimism from searching

What it is

best_score_ is the maximum of many noisy estimates, so it is biased upward — the winner is partly the configuration that got the luckiest folds. The bias grows with the number of trials and shrinks with the amount of validation data.

Why it matters

It is why a tuned model reliably underperforms its reported cross-validated score on new data, by an amount nobody budgeted for. Understanding it is what makes the nested-CV concept below feel necessary rather than pedantic.

In the real world: The best score from a big search is usually a bit lucky; expect the live model to do slightly worse.

Visual example

Winner's curse

As trials accumulate the reported best keeps climbing while the winner's true test score does not. The gap is the optimism.

best_score_ (reported)that winner on held-out data
Optimism grows0204060801000.850.860.870.880.890.9Trials searchedScore+0.035

Reading the divergence

1 trial: reported 0.860, delivered 0.858 -> optimism 0.002

10 trials: reported 0.879, delivered 0.866 -> optimism 0.013

100 trials: reported 0.898, delivered 0.863 -> optimism 0.035

The blue line is flat past 25 trials, and slightly falling: nothing real is being found

Every further trial buys reported score and spends validation credibility

Why this matters

The blue line turning down while the red one rises is the whole phenomenon: the search is now optimising the validation folds' particular noise, which generalises negatively.

Common misconception

Wrong idea: more trials cannot hurt — the worst case is that the best score stops improving.

Correct idea: the reported score cannot stop improving, because it is a maximum over trials. What stops improving is the model, and the growing gap between those two is a number you will only discover after deployment.

import numpy as np

rng = np.random.default_rng(0)
truth = 0.865                             # every candidate is equally good
noise = 0.02                              # fold-to-fold std of the CV estimate

for n_trials in (1, 10, 50, 200):
    reported = [rng.normal(truth, noise, n_trials).max() for _ in range(2000)]
    print(f"{n_trials:>4} trials -> reported {np.mean(reported):.4f} "
          f"(optimism +{np.mean(reported) - truth:.4f})")

# The honest number for the tuned pipeline is an outer split it never touched.
# best_score_ is a selection statistic, not a performance estimate.

Quoting search.best_score_ as the model's expected performance. It is the best of n draws from a noisy estimator, which for 200 trials and a fold std of 0.02 runs 0.03–0.05 high even when every candidate is identically good. Report a score from data that took no part in the selection.

Paying for the honest number: nested CV in practice

What it is

The Cross-Validation module covers the inner/outer mechanics in full; here is the number it actually buys you. cross_val_score(GridSearchCV(...), X, y, cv=outer) runs the whole search fresh inside every outer fold, so each fold's score comes from data that took no part in choosing that fold's winner.

Why it matters

The winner's curse above says best_score_ is optimistic; nested CV is the concrete fix, and the fitted numbers below are what the gap between 'optimistic' and 'honest' actually looks like on one search.

In the real world: Reporting a credit model's true expected accuracy to a regulator, not the flattering tuned number.

Visual example

Nested CV

Three numbers for the same pipeline. Only the middle one is an estimate of what it will do next.

The honest number

(5 outer folds, none used for selection)

200-candidate grid, 8,000 rows
numberwhat it measuresvalue
inner best_score_the luckiest candidate's inner CV0.898
nested CV meanthe tuning procedure, on unseen folds0.868
held-out testone draw of the same thing0.865

search.best_score_

0.898 — a maximum over 200 noisy estimates

0.030 of the inner score was selection optimism

The fits, counted

Plain GridSearchCV: 200 configs x 5 inner folds = 1,000 fits

Nested: 5 outer folds x 1,000 = 5,000 fits, plus 5 refits

Nested CV mean 0.868 vs held-out test 0.865: agreement to 0.003

Inner best_score_ 0.898 overstates the test score by 0.033

Each outer fold may pick different best_params_ — that is expected, not a bug

Why this matters

The nested estimate and the untouched test score agree to three decimal places, and the inner score is 0.03 above both. That is the argument for paying the 5x compute in one table.

Common misconception

Wrong idea: nested cross-validation is for choosing better hyperparameters.

Correct idea: it chooses nothing. It measures the tuning procedure honestly. The hyperparameters still come from the inner loop, refitted once on all the data.

import numpy as np
from sklearn.model_selection import GridSearchCV, StratifiedKFold, cross_val_score

inner = StratifiedKFold(5, shuffle=True, random_state=0)
outer = StratifiedKFold(5, shuffle=True, random_state=1)

search = GridSearchCV(pipe, grid, cv=inner, scoring="average_precision", n_jobs=-1)
nested = cross_val_score(search, X, y, cv=outer, scoring="average_precision")

print(f"nested estimate {nested.mean():.4f} +/- {nested.std():.4f}")

# The optimism the inner loop alone would have reported:
search.fit(X, y)
print(f"inner best_score_ {search.best_score_:.4f}  <- do not quote this")

# Ship the pipeline refitted on everything, and report the nested number.
final = search.best_estimator_

The cost, and the thing people expect it to give them. It is outer * inner * n_configs fits — 25 times a plain grid search at 5x5 — and it produces *no single best hyperparameter set*, because each outer fold may choose different ones. That is not a defect: the estimate is of the procedure, and you then refit that procedure once on all the data to get the model you ship.

Designing the search space

What it is

The space is a modelling decision. Sample scale-sensitive parameters on a log scale, bound each range by what the model can actually use, make dependent parameters conditional rather than crossing them, and tune preprocessing and the estimator jointly in one space.

Why it matters

A search can only find what the space contains, and the most common reason a search disappoints is that the good region was never in it — or was in it at a density of one draw in five hundred. Space design buys more than any change of search algorithm.

In the real world: Searching learning rates on a sensible scale, like 0.01 to 0.3, so compute isn't wasted on useless values.

from scipy.stats import loguniform, randint, uniform

# Log scale for anything spanning orders of magnitude.
space = {
    "clf__C": loguniform(1e-3, 1e3),
    "clf__gamma": loguniform(1e-5, 1e0),
    "pca__n_components": randint(2, 40),
    "smote__sampling_strategy": uniform(0.1, 0.9),
}

# Conditional structure a flat grid cannot express: only search gamma for rbf.
search_list = [
    {"clf__kernel": ["linear"], "clf__C": [0.01, 0.1, 1, 10, 100]},
    {"clf__kernel": ["rbf"], "clf__C": [0.01, 0.1, 1, 10, 100],
     "clf__gamma": [1e-4, 1e-3, 1e-2, 1e-1]},
]
# GridSearchCV accepts a list of dicts and searches each independently.

# Always include the do-nothing option so you can tell whether a step helped.
{"pca": ["passthrough"], "clf__C": [1.0]}

A winner sitting on the edge of a range. If best_params_ reports C = 1000 and 1000 was your maximum, the search hit the wall and the optimum is outside the space — widen it and re-run. Log best_params_ against the bounds every time; it is the cheapest diagnostic in this module.