Skip to main content
Scikit-Learn · lesson 7 of 12
Scikit-Learn intermediate Lesson 7 of 12

Also part of: Machine Learning

Scikit-Learn Hyperparameter Tuning

Tune models with GridSearchCV, RandomizedSearchCV, successive halving and Optuna, and measure how optimistic a tuned model's best CV score really is.

By SkillByExample · Updated

Hyperparameters are the settings you choose before training, such as tree depth or learning rate. Every example here ran with scikit-learn 1.9.1 (Optuna 5.0.0 for one section), on the built-in breast cancer dataset unless noted.

GridSearchCV tries every combination in a grid and scores each one with cross-validation.

from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GridSearchCV, train_test_split

X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y)

grid = {"max_depth": [3, 6, None], "min_samples_leaf": [1, 5, 20]}
search = GridSearchCV(RandomForestClassifier(n_estimators=100, random_state=0),
                      grid, cv=5, scoring="roc_auc", n_jobs=-1)
search.fit(X_train, y_train)

print("combinations x folds:", len(search.cv_results_["params"]), "x 5")
print("best params:", search.best_params_)
print(f"best CV AUC: {search.best_score_:.4f}")
print(f"test AUC:    {search.score(X_test, y_test):.4f}")
$ python grid.py
combinations x folds: 9 x 5
best params: {'max_depth': 6, 'min_samples_leaf': 5}
best CV AUC: 0.9894
test AUC:    0.9917

Nine combinations times five folds is 45 fits, plus one final refit of the winner on all training rows. The search never saw the test set, and the test set is scored only once, at the end, so no choice was made by looking at it. That is what keeps the test AUC an honest estimate; score it after every tweak and it becomes one more thing you tuned against.

RandomizedSearchCV samples n_iter combinations, and it can sample from continuous distributions instead of fixed lists.

from scipy.stats import loguniform, randint
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import RandomizedSearchCV

X, y = load_breast_cancer(return_X_y=True)
dist = {
    "learning_rate": loguniform(0.01, 0.5),
    "max_leaf_nodes": randint(4, 64),
    "min_samples_leaf": randint(5, 60),
    "l2_regularization": loguniform(1e-4, 10),
}
search = RandomizedSearchCV(HistGradientBoostingClassifier(random_state=0), dist,
                            n_iter=25, cv=5, scoring="roc_auc", random_state=0, n_jobs=-1)
search.fit(X, y)
print(f"best CV AUC: {search.best_score_:.4f}")
for k, v in search.best_params_.items():
    print(f"  {k:18s} {v:.4g}")
$ python random_search.py
best CV AUC: 0.9952
  l2_regularization  0.0001262
  learning_rate      0.2598
  max_leaf_nodes     19
  min_samples_leaf   52

Four parameters, two of them continuous, would be impossible to grid exhaustively; 25 random draws cover the space well enough to find a strong model. loguniform spreads samples evenly on a log scale, which suits parameters whose plausible values range over several orders of magnitude, such as l2_regularization from 0.0001 to 10.

best_score_ Is Optimistic

The best of many scores is partly luck. On random labels, where the honest accuracy is 0.5, compare the search’s own best score with nested cross-validation.

import numpy as np
from sklearn.model_selection import GridSearchCV, cross_val_score
from sklearn.neighbors import KNeighborsClassifier

rng = np.random.default_rng(0)
X = rng.standard_normal((100, 10))
y = rng.integers(0, 2, 100)                    # labels are coin flips

grid = {"n_neighbors": list(range(1, 40)), "weights": ["uniform", "distance"]}
search = GridSearchCV(KNeighborsClassifier(), grid, cv=5).fit(X, y)
nested = cross_val_score(GridSearchCV(KNeighborsClassifier(), grid, cv=5), X, y, cv=5)

print("configurations tried:", len(search.cv_results_["params"]))
print(f"search.best_score_:  {search.best_score_:.3f}")
print(f"nested CV estimate:  {nested.mean():.3f}")
$ python optimism.py
configurations tried: 78
search.best_score_:  0.590
nested CV estimate:  0.490

With 78 configurations to choose from, the search found one that happened to fit these folds’ noise, and reported 0.59 on data with nothing to learn. Nested cross-validation runs the whole search again inside each outer fold and scores it on rows the search never saw, which brings the estimate back to about a coin flip. Report the nested number, or a single held-out test score, not best_score_.

Successive Halving

HalvingRandomSearchCV starts many candidates on a small budget and gives more samples only to the survivors of each round.

from scipy.stats import loguniform, randint
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.experimental import enable_halving_search_cv  # noqa: F401
from sklearn.model_selection import HalvingRandomSearchCV

X, y = load_breast_cancer(return_X_y=True)
dist = {"learning_rate": loguniform(0.01, 0.5), "max_leaf_nodes": randint(4, 64)}
search = HalvingRandomSearchCV(HistGradientBoostingClassifier(random_state=0), dist,
                               n_candidates=48, factor=3, cv=5, random_state=0)
search.fit(X, y)

for i, (n, r) in enumerate(zip(search.n_candidates_, search.n_resources_)):
    print(f"round {i}: {n:2d} candidates x {r:3d} samples")
print(f"best CV accuracy: {search.best_score_:.4f}")
$ python halving.py
round 0: 48 candidates x  20 samples
round 1: 16 candidates x  60 samples
round 2:  6 candidates x 180 samples
round 3:  2 candidates x 540 samples
best CV accuracy: 0.9740

Round 0 screens all 48 candidates on only 20 samples each, and every round keeps the best third and triples their samples. Only 2 candidates ever see 540 rows, so the search costs far less than giving all 48 the full data. The trade-off is that a candidate that only shines with lots of data can be dropped early.

Bayesian Optimisation with Optuna

Optuna (pip install optuna) proposes each new trial based on how earlier trials scored.

import optuna
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import cross_val_score

optuna.logging.set_verbosity(optuna.logging.WARNING)
X, y = load_breast_cancer(return_X_y=True)

def objective(trial):
    model = HistGradientBoostingClassifier(
        learning_rate=trial.suggest_float("learning_rate", 0.01, 0.5, log=True),
        max_leaf_nodes=trial.suggest_int("max_leaf_nodes", 4, 64),
        random_state=0)
    return cross_val_score(model, X, y, cv=5, scoring="roc_auc").mean()

study = optuna.create_study(direction="maximize",
                            sampler=optuna.samplers.TPESampler(seed=0))
study.optimize(objective, n_trials=25)
print(f"best CV AUC: {study.best_value:.4f}")
print("best params:", {k: round(v, 4) for k, v in study.best_params.items()})
$ python optuna_search.py
best CV AUC: 0.9949
best params: {'learning_rate': 0.4337, 'max_leaf_nodes': 27}

Optuna reaches the same level as the random search above in the same 25 trials. On a dataset this easy most reasonable settings score above 0.99, so smarter sampling has little room to help; its advantage shows on slow models and larger search spaces, where each saved trial matters.

Early Stopping Instead of Tuning n_estimators

For boosting, the number of trees doesn’t need a search: early stopping ends training when a held-out slice stops improving.

from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import train_test_split

X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

model = HistGradientBoostingClassifier(max_iter=1000, early_stopping=True,
                                       validation_fraction=0.1, n_iter_no_change=20,
                                       random_state=0).fit(X_train, y_train)
print(f"trees built: {model.n_iter_} of a possible 1000")
print(f"test accuracy: {model.score(X_test, y_test):.4f}")
$ python early_stopping.py
trees built: 66 of a possible 1000
test accuracy: 0.9561

The model stopped after 66 trees because 20 rounds in a row brought no improvement on the internal 10% validation slice. Early stopping picks the tree count per fit automatically, so searches can focus on learning rate and tree shape instead.

Practice

1. Print the three best rows of cv_results_ from the grid search, with their fold spread.
import pandas as pd
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GridSearchCV

X, y = load_breast_cancer(return_X_y=True)
grid = {"max_depth": [3, 6, None], "min_samples_leaf": [1, 5, 20]}
search = GridSearchCV(RandomForestClassifier(n_estimators=100, random_state=0),
                      grid, cv=5, scoring="roc_auc", n_jobs=-1).fit(X, y)

res = pd.DataFrame(search.cv_results_)
cols = ["param_max_depth", "param_min_samples_leaf", "mean_test_score", "std_test_score"]
print(res.sort_values("rank_test_score")[cols].head(3).round(4).to_string(index=False))
$ python p1_results.py
param_max_depth  param_min_samples_leaf  mean_test_score  std_test_score
              6                       1           0.9912          0.0068
           None                       5           0.9911          0.0071
              6                       5           0.9908          0.0071

The top three are separated by less than 0.001 AUC, far smaller than their fold-to-fold spread of about 0.007. The “best” configuration is statistically tied with its neighbours; choosing the simpler or faster one among them is perfectly reasonable.

2. In optimism.py, shrink the grid to a single configuration. What happens to best_score_?
import numpy as np
from sklearn.model_selection import GridSearchCV
from sklearn.neighbors import KNeighborsClassifier

rng = np.random.default_rng(0)
X = rng.standard_normal((100, 10))
y = rng.integers(0, 2, 100)

grids = {"1 config": {"n_neighbors": [5]},
         "78 configs": {"n_neighbors": list(range(1, 40)),
                        "weights": ["uniform", "distance"]}}
for name, grid in grids.items():
    s = GridSearchCV(KNeighborsClassifier(), grid, cv=5).fit(X, y)
    print(f"{name:10s} best_score_ {s.best_score_:.3f}")
$ python p2_one_config.py
1 config   best_score_ 0.400
78 configs best_score_ 0.590

With a single configuration there is nothing to choose, so best_score_ is an ordinary cross-validation estimate: 0.40, noisy on 100 rows but unbiased. Picking the maximum of 78 such noisy estimates lifts it to 0.59. The optimism grows with the number of configurations the search picks from.

3. Tune a pipeline: search the C of an SVM that sits after a StandardScaler.
from scipy.stats import loguniform
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import RandomizedSearchCV
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC

X, y = load_breast_cancer(return_X_y=True)
pipe = make_pipeline(StandardScaler(), SVC())
print([name for name in pipe.named_steps])
search = RandomizedSearchCV(pipe, {"svc__C": loguniform(1e-2, 1e2),
                                   "svc__gamma": loguniform(1e-4, 1e-1)},
                            n_iter=20, cv=5, random_state=0).fit(X, y)
print(f"best CV accuracy {search.best_score_:.4f}")
print({k: round(float(v), 4) for k, v in search.best_params_.items()})
$ python p3_pipeline_search.py
['standardscaler', 'svc']
best CV accuracy 0.9737
{'svc__C': 14.6859, 'svc__gamma': 0.0039}

The pipeline’s step names come from make_pipeline, lower-cased class names, so the SVM’s parameters are svc__C and svc__gamma. The scaler is refit inside every fold of every candidate, so the search stays leak-free.

Frequently Asked Questions

Why use cross-validation during hyperparameter tuning instead of the test set?
Every time you pick settings by looking at a score, that score stops being an honest estimate, because you chose the settings that happened to do well on those rows. Cross-validation on the training data does the choosing, and the test set is used once at the end to report performance.
When should I use RandomizedSearchCV instead of GridSearchCV?
GridSearchCV tries every combination, so its cost multiplies with each parameter you add. RandomizedSearchCV samples a fixed number of combinations from lists or distributions, so you control the cost directly. With several parameters, only a few of which matter much, random sampling usually finds a comparable result with far fewer fits.
Is best_score_ from GridSearchCV an unbiased estimate?
No. best_score_ is the maximum over all the configurations tried, so it is biased upward, and more so as the search grows or the dataset shrinks. Nested cross-validation, with the search inside an outer cross-validation loop, gives an honest estimate of how the whole tuning procedure performs on new data.
What does Optuna add over RandomizedSearchCV?
Optuna's default TPE sampler uses the results of earlier trials to propose more promising settings, instead of sampling each trial independently. It also supports pruning unpromising trials early and conditional search spaces. For small searches the difference from random search is often modest.