iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Hyperparameter Tuning

Hyperparameter tuning is the difference between a model that’s 78% accurate and one that’s 88%. Grid search is dead for serious work — modern tooling (random search, Bayesian optimisation, Hyperband, Optuna) explores configurations smarter and faster.

sklearn search + Optuna + budget

EXAMPLE
from sklearn.model_selection import (
    train_test_split, GridSearchCV, RandomizedSearchCV,
    HalvingGridSearchCV, cross_val_score,
)
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.datasets import load_breast_cancer
from scipy.stats import loguniform, randint
import numpy as np

# 1) Always pipeline preprocessing + estimator
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)

pipe = Pipeline([
    ('scale', StandardScaler()),
    ('clf',    LogisticRegression(max_iter=1000)),
])

# 2) Grid search — exhaustive (only for SMALL spaces)
param_grid = {
    'clf__C':       [0.01, 0.1, 1, 10, 100],
    'clf__penalty': ['l1', 'l2'],
    'clf__solver':  ['liblinear', 'saga'],
}
gs = GridSearchCV(pipe, param_grid, cv=5, scoring='roc_auc', n_jobs=-1, verbose=1)
gs.fit(X_train, y_train)
print(gs.best_params_, gs.best_score_)
# Grid scales BAD: 5 × 2 × 2 × 5 folds = 100 fits. Adding one dim with 5 values → 500 fits.

# 3) Random search — usually wins per-budget
param_dist = {
    'clf__C':       loguniform(1e-3, 1e3),
    'clf__penalty': ['l1', 'l2'],
    'clf__solver':  ['liblinear', 'saga'],
}
rs = RandomizedSearchCV(pipe, param_dist, n_iter=50, cv=5, scoring='roc_auc',
                                                n_jobs=-1, random_state=42, verbose=1)
rs.fit(X_train, y_train)
print(rs.best_params_, rs.best_score_)
# 50 random samples often beats a 500-fit grid because random search hits the productive
# regions sooner — Bergstra & Bengio (2012).

# 4) Halving search — start with small data + many configs, prune the losers
from sklearn.experimental import enable_halving_search_cv   # noqa: F401

hs = HalvingGridSearchCV(pipe, param_grid, factor=3, cv=5, scoring='roc_auc',
                                                  n_jobs=-1, random_state=42, verbose=1)
hs.fit(X_train, y_train)
# Spends compute on promising configs, drops the rest early.

# 5) Score on TEST only after tuning is done
from sklearn.metrics import roc_auc_score
print('test AUC:', roc_auc_score(y_test, rs.predict_proba(X_test)[:, 1]))

# 6) Optuna — Bayesian / TPE, modern default for serious tuning
import optuna

def objective(trial):
    model_name = trial.suggest_categorical('model', ['lr', 'rf', 'gb'])
    if model_name == 'lr':
        clf = LogisticRegression(
            C=trial.suggest_float('lr_C', 1e-3, 1e3, log=True),
            penalty=trial.suggest_categorical('lr_pen', ['l1', 'l2']),
            solver='liblinear', max_iter=1000,
        )
    elif model_name == 'rf':
        clf = RandomForestClassifier(
            n_estimators=trial.suggest_int('rf_n', 100, 800),
            max_depth=trial.suggest_int('rf_d', 3, 16),
            min_samples_split=trial.suggest_int('rf_mss', 2, 10),
            random_state=42, n_jobs=-1,
        )
    else:
        clf = GradientBoostingClassifier(
            n_estimators=trial.suggest_int('gb_n', 100, 600),
            max_depth=trial.suggest_int('gb_d', 2, 6),
            learning_rate=trial.suggest_float('gb_lr', 1e-3, 0.3, log=True),
            random_state=42,
        )
    pipe = Pipeline([('scale', StandardScaler()), ('clf', clf)])
    return cross_val_score(pipe, X_train, y_train, cv=5, scoring='roc_auc', n_jobs=-1).mean()

study = optuna.create_study(direction='maximize', sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=200, timeout=60 * 30)
print('best:', study.best_value, study.best_params)

# 7) Optuna with pruning — kill bad trials early
from optuna.integration import OptunaSearchCV
from optuna.pruners import MedianPruner

study = optuna.create_study(pruner=MedianPruner(n_startup_trials=10, n_warmup_steps=20),
                                                      sampler=optuna.samplers.TPESampler(seed=42),
                                                      direction='maximize')
# Useful when each trial has an iterative process you can report progress for (boosters, NNs).

# 8) Define a search space sensibly
# • Real-valued knobs with wide ranges → log scale (learning rate, regularisation strength)
# • Counts (n_estimators, max_depth) → integer range
# • Pick categorical params for genuinely discrete choices (boolean flags, solver name)
# • Don't include parameters that always perform best at a known value — fix them outside the search

# 9) Cross-validation strategy
# • StratifiedKFold for classification with imbalanced classes
# • TimeSeriesSplit for temporal data (no leakage from future)
# • GroupKFold when you have user/group IDs
# • Repeated K-Fold for small datasets where one fold split varies a lot

from sklearn.model_selection import StratifiedKFold, TimeSeriesSplit, GroupKFold

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
# rs = RandomizedSearchCV(pipe, param_dist, cv=cv, …)

# 10) Multi-metric optimisation
study = optuna.create_study(directions=['maximize', 'minimize'])
# Optimise AUC AND latency simultaneously — Optuna tracks the Pareto front.

# 11) Tune the budget, not just the model
# Decide ahead of time:
#   • Wall-clock budget: 30 minutes / 2 hours / overnight
#   • Trial count target: 100 / 500 / 2000
#   • CV folds: 3 (fast) / 5 (default) / 10 (robust on small data)
#   • Parallelism: n_jobs across cores; distributed: Optuna RDB storage + workers

# 12) Validation curves — eyeball one hyperparameter
from sklearn.model_selection import validation_curve
import matplotlib.pyplot as plt

C_values = np.logspace(-3, 3, 13)
train, test = validation_curve(
    LogisticRegression(max_iter=1000), X_train, y_train,
    param_name='C', param_range=C_values, scoring='roc_auc', cv=5,
)
plt.semilogx(C_values, train.mean(axis=1), label='train')
plt.semilogx(C_values, test.mean(axis=1),  label='val')
plt.xlabel('C'); plt.ylabel('AUC'); plt.legend()

# 13) Learning curves — is more data the answer?
from sklearn.model_selection import learning_curve
sizes, train_scores, test_scores = learning_curve(
    LogisticRegression(C=1, max_iter=1000), X_train, y_train,
    train_sizes=np.linspace(0.1, 1.0, 6), cv=5, scoring='roc_auc',
)
plt.plot(sizes, test_scores.mean(axis=1))
# Plateau → model can't use more data. Still climbing → collect more.

# 14) Avoiding leakage
# • Pipeline so preprocessing fits ONLY on training folds
# • Don't peek at the test set during tuning
# • Group-aware CV when records share an entity (same user across multiple rows)
# • Use a holdout set (train / val / test) for very long tuning runs

# 15) Reproducibility
# • random_state on every model + sampler
# • Pin library versions (requirements.txt with exact pins)
# • Set numpy seed and PYTHONHASHSEED for hash-based ops
# • Log all hyperparameters and seeds with MLflow / W&B / Aim

# 16) When tuning is over-engineering
# • Quick prototype: defaults of a strong model (XGBoost / LightGBM) + light random search
# • New problem: focus on data quality and features before deep tuning
# • Limited data: more folds, fewer hyperparams (over-tuning amplifies CV variance)
# • Production model: re-tune on a schedule with fresh data; don't ship a one-shot manual best

# 17) Common bugs
# • Tuning on the test set → optimistic, not deployable
# • Forgetting to fit preprocessing INSIDE the CV fold → leakage
# • Grid search with 6+ parameters → combinatorial explosion; switch to random / Optuna
# • Picking the search range too narrow → optimum at the edge; expand and rerun
# • Optimising for accuracy on imbalanced data → always shows 'class predicts majority'; use AUC, F1, log loss
# • Stochastic estimators without random_state → different scores every run; can't reproduce best
# • Not budgeting the run → 'I'll just let it cook'; finish at 3am with no actionable result

Why it matters

Skip grid search for anything past 4-5 hyperparameters — random search beats it per unit budget, and Optuna’s TPE plus pruning beats both. Always pipeline preprocessing inside CV to prevent leakage, pick a CV strategy that matches the data (stratified / time-series / group), and decide on a wall-clock budget before kicking off a run.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
from sklearn.model_selection import GridSearchCV
grid = GridSearchCV(
    pipe,
    param_grid={'clf__C': [0.01, 0.1, 1, 10]},
    cv=5, scoring='f1_macro',
).fit(X, y)
print(grid.best_params_, grid.best_score_)
Try it Yourself »

Exercise

Exhaustive grid search class.

from sklearn.model_selection import

Discussion

Loading…