datarekha

Hyperparameter tuning

Grid search, random search, Bayesian search with Optuna. The pattern that prevents you from accidentally tuning on the test set.

7 min read Intermediate Machine Learning Lesson 21 of 33

What you'll learn

  • Grid search vs random search vs Bayesian search (Optuna) — when each wins
  • Tune inside a Pipeline with cross-validation, never on the test set
  • Optuna in 20 lines — define an objective, run trials, inspect the best
  • Nested CV — the only way to get an unbiased generalization estimate while also tuning

Before you start

You’ve trained a model. Now you have to pick max_depth=5 or =7, learning_rate=0.01 or 0.05, n_estimators=200 or 500. The default “I’ll try a few by hand” approach is fine for exploration; it falls apart when you have five hyperparameters and a deadline.

This lesson is about doing it systematically without falling into the classic trap: tuning on the test set.

The three strategies

StrategyHow it worksWhen it wins
Grid searchTry every combination on a fixed gridVery small search spaces (≤3 hyperparams, ≤4 values each)
Random searchSample combinations randomly from distributionsMost practical situations — beats grid for the same budget
Bayesian (Optuna)Use prior trial results to guide where to sample nextThe modern default — fewer trials, better results, supports conditional/categorical params

A surprising result from Bergstra & Bengio (2012): random search beats grid search for the same number of trials, because most hyperparameters don’t matter equally — grid wastes trials on the unimportant axes. So grid is mostly dead; the choice is random vs Bayesian. Random is simpler and works well; Bayesian (Optuna) is the right default once you’ve used it a few times.

Same nine trials, very different coverageGrid searchunimportant paramimportant param →3 distinct values triedRandom searchunimportant paramimportant param →9 distinct values tried

Bergstra and Bengio (2012): for the same budget, random search covers the axis that matters far better than a grid that keeps re-testing the same few values.

Rule zero: tune inside a Pipeline, with cross-validation

You never tune on the test set. You also never tune on a single validation set if you can help it — cross-validation gives a more stable estimate. And every preprocessing step must live inside the Pipeline so each CV fold preprocesses cleanly (see the data-leakage lesson).

The pattern looks like:

pipe = Pipeline([("scaler", StandardScaler()), ("clf", model)])
search = GridSearchCV(pipe, param_grid, cv=5, scoring="roc_auc")
search.fit(X_train, y_train)
# Best params evaluated on X_test ONLY ONCE, after tuning is fully done
final_score = search.score(X_test, y_test)

The test set is used exactly once, at the very end. Every “should I try a different hyperparam?” decision happens against cross-validated training data only.

Grid search and random search in sklearn

import numpy as np
from scipy.stats import randint
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split, GridSearchCV, RandomizedSearchCV

X, y = make_classification(n_samples=2000, n_features=15, n_informative=8, random_state=0)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, stratify=y, random_state=0)

pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("clf", RandomForestClassifier(n_jobs=-1, random_state=0)),
])

# --- Grid search: every combination ---
grid = {
    "clf__n_estimators": [100, 300],
    "clf__max_depth":    [5, 10, None],
    "clf__max_features": ["sqrt", "log2"],
}
gs = GridSearchCV(pipe, grid, cv=5, scoring="roc_auc", n_jobs=-1).fit(X_tr, y_tr)
print(f"Grid   - {len(gs.cv_results_['params'])} fits | best CV AUC: {gs.best_score_:.3f}")
print("  best:", gs.best_params_)

# --- Random search: sample from distributions ---
dist = {
    "clf__n_estimators": randint(50, 500),
    "clf__max_depth":    randint(3, 15),
    "clf__max_features": ["sqrt", "log2", 0.5, 0.8],
}
rs = RandomizedSearchCV(pipe, dist, n_iter=20, cv=5, scoring="roc_auc",
                        n_jobs=-1, random_state=0).fit(X_tr, y_tr)
print(f"\nRandom - {rs.n_iter} fits | best CV AUC: {rs.best_score_:.3f}")
print("  best:", rs.best_params_)

# Test-set evaluation — exactly ONCE, with the chosen model
print(f"\nHeld-out test AUC: {rs.score(X_te, y_te):.3f}")

A few things worth noting in that snippet:

  • pipe__clf__n_estimators syntax — step_name__param lets you tune parameters of any pipeline step.
  • cv=5 does 5-fold CV inside the search.
  • The held-out test set is touched once, at the end.

Bayesian tuning with Optuna

Optuna is the de facto modern default. You define an objective(trial) function that returns a score; Optuna samples hyperparameters intelligently and improves over trials. Unlike grid/random, it learns from previous results.

import numpy as np
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split, cross_val_score

X, y = make_classification(n_samples=2000, n_features=15, n_informative=8, random_state=0)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, stratify=y, random_state=0)

# Real Optuna usage looks like:
#   import optuna
#   def objective(trial):
#       n_estimators = trial.suggest_int("n_estimators", 50, 500)
#       max_depth    = trial.suggest_int("max_depth", 3, 15)
#       max_features = trial.suggest_categorical("max_features", ["sqrt", "log2"])
#       model = RandomForestClassifier(n_estimators=n_estimators,
#                                      max_depth=max_depth, max_features=max_features,
#                                      n_jobs=-1, random_state=0)
#       return cross_val_score(model, X_tr, y_tr, cv=5, scoring="roc_auc").mean()
#   study = optuna.create_study(direction="maximize")
#   study.optimize(objective, n_trials=30)

# Stand-in: a minimal trial loop (random search) with the same structure
rng = np.random.default_rng(0)
trials = []
for t in range(15):
    params = {
        "n_estimators": int(rng.integers(50, 500)),
        "max_depth":    int(rng.integers(3, 15)),
        "max_features": str(rng.choice(["sqrt", "log2"])),
    }
    model = RandomForestClassifier(n_jobs=-1, random_state=0, **params)
    score = cross_val_score(model, X_tr, y_tr, cv=5, scoring="roc_auc").mean()
    trials.append((score, params))
    best_so_far = max(trials, key=lambda x: x[0])
    print(f"trial {t:2d}: {score:.4f}  best so far: {best_so_far[0]:.4f}")

best_score, best_params = max(trials, key=lambda x: x[0])
print(f"\nbest params: {best_params}")
print(f"best CV AUC: {best_score:.4f}")

The key insight: a real Optuna study would prune unpromising trials early (with MedianPruner), reuse evaluations across similar parameter regions (TPE), and let you express conditional spaces (e.g., only sample min_samples_split when max_depth > 5). For ten lines more code than random search you get materially better results, especially as the number of hyperparameters grows.

Common tuning ranges to start from

When you don’t know the right range, these are sensible defaults to search over:

ModelHyperparameterSearch range
RandomForestn_estimators100 – 500
RandomForestmax_depth5 – 20 or None
RandomForestmax_features'sqrt', 'log2', 0.3 – 0.8
XGBoost / HGBn_estimators (with early stopping)100 – 2000
XGBoost / HGBlearning_ratelog-uniform 0.01 – 0.3
XGBoost / HGBmax_depth3 – 10
XGBoost / HGBmin_child_weight / min_samples_leaf1 – 50
LogisticRegressionClog-uniform 0.001 – 100

Use log-uniform sampling for any parameter that spans orders of magnitude (learning_rate, C, regularization weights). Linear sampling wastes most of its trials on the high end.

Nested CV — for an honest estimate of generalization

If you tune hyperparameters using CV on your training data and then report the same CV score, that score is optimistic — the hyperparameters were chosen because they happen to work well on those folds. The real generalization estimate is lower.

The fix is nested cross-validation:

  • Outer loop (e.g., 5 folds): produces unbiased generalization estimates.
  • Inner loop (e.g., 5 folds within each outer training fold): does the hyperparameter search.

You end up with five outer scores from five separately-tuned models. Their mean is an honest estimate of how a tuned-on-fresh-data model would do.

from sklearn.model_selection import cross_val_score, GridSearchCV, KFold
outer = KFold(n_splits=5, shuffle=True, random_state=0)
inner = KFold(n_splits=5, shuffle=True, random_state=1)
search = GridSearchCV(pipe, param_grid, cv=inner, scoring="roc_auc")
nested_scores = cross_val_score(search, X, y, cv=outer, scoring="roc_auc")
print("honest CV AUC:", nested_scores.mean())

Nested CV is expensive (you train outer × inner × |grid| models). Use it when reporting numbers in a paper, a benchmark, or a critical business decision. For day-to-day iteration, the simpler “tune with CV on train, evaluate once on test” pattern is fine.

A practical recipe

For a new tabular problem, this is what to do:

  1. Train a default RandomForest. Get a baseline CV score.
  2. If you need more accuracy, switch to HistGradientBoosting / LightGBM with default params + early stopping. Get a new baseline.
  3. Wrap in a Pipeline. Run RandomizedSearchCV (or Optuna) with 30-100 trials over the 4 key hyperparameters.
  4. Evaluate the best pipeline on the held-out test set once.
  5. If the gap between CV score and test score is large, suspect leakage or distribution shift — don’t just keep tuning.

In one breath

  • Tuning searches hyperparameter space — but the search itself uses data, so it must never touch the final test set.
  • Grid tries every combination; random samples and usually wins for the same budget, because few hyperparameters truly matter; Bayesian (Optuna) learns from past trials and pulls ahead once the space is large.
  • Tune inside a Pipeline with cross-validation, so each fold preprocesses cleanly and no leakage creeps in.
  • Sample log-uniform for anything spanning orders of magnitude — learning_rate, C, regularization weights.
  • The CV score you tuned on is optimistic. For an honest number, evaluate once on a held-out set, or use nested CV.

Quick check

Quick check

0/3
Q1Why does random search often beat grid search for the same number of trials?
Q2You tune hyperparameters with 5-fold CV on training data, get the best CV AUC of 0.892, then report that as the model's generalization performance. Why is this optimistic?
Q3Why log-uniform sampling for `learning_rate` and `C` instead of uniform?

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
When should you use grid search vs random search vs Bayesian optimisation for hyperparameter tuning?

Grid search exhaustively tries every combination in a predefined grid, which is only practical for 1–2 hyperparameters. Random search samples combinations uniformly at random and finds good values faster per compute budget, especially when only a few hyperparameters actually matter. Bayesian optimisation fits a surrogate model of the objective and proposes the next trial intelligently, giving the best sample efficiency for expensive evaluations.

Why do you need nested cross-validation, and what problem does it solve over regular cross-validation?

Nested cross-validation separates hyperparameter tuning from performance estimation using an inner loop for model selection and an outer loop for evaluation. It solves the optimistic-bias problem: if you tune and evaluate on the same folds, the validation data leaks into model selection and your reported score overestimates real-world performance. The inner loop never touches the outer test fold, giving an unbiased estimate of the whole pipeline's generalization.

Walk me through how you'd select between competing models without fooling yourself with data leakage.

Split data into train, validation, and test sets (or use cross-validation), tune and compare models only on train/validation, and touch the test set exactly once at the end. Fit all preprocessing inside the cross-validation pipeline so transformers never see validation data, and for tuning plus honest evaluation use nested cross-validation. For time series, use forward-chaining splits to avoid leaking future information.

What do the C and gamma hyperparameters control in an SVM, and how do they relate to overfitting?

C controls the soft-margin tradeoff: large C penalizes misclassifications heavily, producing a narrow margin that can overfit, while small C allows more slack for better generalization. Gamma (for RBF kernels) sets how far one training point's influence reaches: high gamma makes a wiggly boundary that overfits, low gamma makes it smoother. You tune both jointly via cross-validation after scaling features.

Related lessons

Explore further

Skip to content