Skip to content

Hyperparameter tuning

Chapter 24 13 code blocks 3467 words

The code in this chapter uses these filesrun it on your own machine

The scene: are the default settings good?

Until now we used models with their default settings: RandomForestClassifier(), SVC(), XGBClassifier().

But there are dozens of settings inside them. RandomForest has max_depth, min_samples_leaf, max_features. SVC has C and gamma. XGBoost has more than twenty.

The question: are the defaults good? And how much does tuning help?

The answer is less than most people think, and I'll show it by measurement.

Where this sits in the protocol

This is one of the options in step 12: "Hyperparameter tuning: the model is good but not tuned".

Note: it's one of the last options. As we said in Chapter 11, jumping to tuning without error analysis is the most common way to waste time.

Column names

The data is synthetic Tashkent rental listings, and its column names are Uzbek. tuman district, xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1), qurilgan_yil year built. The target ijaraga_berildi_30k means rented within 30 days.

Parameters vs hyperparameters

A reminder from Chapter 2, because here it's vital:

python8 lines
import pandas as pd
import numpy as np
show(pd.DataFrame([
    {"": "Parameter (weight)", "Who finds it": "THE MODEL, during fit()",
     "Example": "A line's coefficient, a tree's threshold"},
    {"": "Hyperparameter", "Who finds it": "YOU, BEFORE fit()",
     "Example": "max_depth, C, learning_rate, n_neighbors"},
]))
Who finds itExample
Parameter (weight)THE MODEL, during fit()A line's coefficient, a tree's threshold
HyperparameterYOU, BEFORE fit()max_depth, C, learning_rate, n_neighbors

How good are the defaults

First we measure the baseline: untuned models.

python33 lines
from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV, RandomizedSearchCV
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC

df = pd.read_csv(DATA / "uy_ijara.csv")
num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel", "qurilgan_yil"]
cat = ["tuman"]
X, y = df[num + cat], df["ijaraga_berildi_30k"]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)

def pipe(model):
    return Pipeline([
        ("t", ColumnTransformer([
            ("num", Pipeline([("i", SimpleImputer(strategy="median")), ("sc", StandardScaler())]), num),
            ("cat", OneHotEncoder(handle_unknown="ignore"), cat),
        ])),
        ("m", model),
    ])

print(f"{'model (default settings)':<34} {'CV F1':>9} {'±':>8}")
print("-" * 54)
default = {}
for name, m in [("LogisticRegression", LogisticRegression(max_iter=3000)),
                ("RandomForest", RandomForestClassifier(random_state=42)),
                ("SVC", SVC(random_state=42))]:
    b = cross_val_score(pipe(m), X, y, cv=5, scoring="f1")
    default[name] = b
    print(f"{name:<34} {b.mean():>9.4f} {b.std():>8.4f}")
Output
model (default settings)               CV F1        ±
------------------------------------------------------
LogisticRegression                    0.7273   0.0118
RandomForest                          0.7032   0.0145
SVC                                   0.7263   0.0134

GridSearchCV: trying every combination

python26 lines
import time

grid = {
    "m__n_estimators": [100, 300],
    "m__max_depth": [None, 6, 12],
    "m__min_samples_leaf": [1, 5, 20],
    "m__max_features": ["sqrt", 0.5],
}
total = 1
for v in grid.values():
    total *= len(v)
print(f"Number of combinations: {total}")
print(f"CV folds              : 5")
print(f"Total trainings       : {total * 5}")

t0 = time.perf_counter()
gs = GridSearchCV(pipe(RandomForestClassifier(random_state=42)), grid,
                  cv=5, scoring="f1", n_jobs=-1)
gs.fit(X_tr, y_tr)
gs_time = time.perf_counter() - t0
print()
print(f"Time                  : {gs_time:.1f}s")
print(f"Best CV F1            : {gs.best_score_:.4f}")
print("Best settings:")
for k, v in gs.best_params_.items():
    print(f"  {k.replace('m__', ''):20s} = {v}")
Output
Number of combinations: 36
CV folds              : 5
Total trainings       : 180

Time                  : 4.6s
Best CV F1            : 0.6997
Best settings:
  max_depth            = 6
  max_features         = sqrt
  min_samples_leaf     = 20
  n_estimators         = 100
python9 lines
print(f"Default settings CV F1: {default['RandomForest'].mean():.4f} ±{default['RandomForest'].std():.4f}")
print(f"After tuning          : {gs.best_score_:.4f}")
print(f"Gain from tuning      : {gs.best_score_ - default['RandomForest'].mean():+.4f}")
print(f"CV spread             : ±{default['RandomForest'].std():.4f}")
print()
gain = gs.best_score_ - default["RandomForest"].mean()
print("Verdict:", "the gain is LARGER than the spread: tuning was worth it"
      if gain > default["RandomForest"].std()
      else "the gain is WITHIN the spread: tuning gave almost nothing")
Output
Default settings CV F1: 0.7032 ±0.0145
After tuning          : 0.6997
Gain from tuning      : -0.0034
CV spread             : ±0.0145

Verdict: the gain is WITHIN the spread: tuning gave almost nothing

The gain to expect from tuning: usually small

Students put great hopes in GridSearchCV. In practice it usually gives 1-3%, sometimes nothing.

For comparison, the order of impact:

  1. Data quality: the biggest impact (Chapter 5, the Chapter 44 exercise)
  2. The right metric and threshold: a large impact (Chapters 8, 9)
  3. A new feature or a new source: medium (Chapter 23)
  4. Switching the model type: medium
  5. Hyperparameter tuning: the smallest

That's why tuning comes last. First you fix the data.

The table of best results

python13 lines
results = pd.DataFrame(gs.cv_results_)
cols = ["param_m__n_estimators", "param_m__max_depth",
        "param_m__min_samples_leaf", "param_m__max_features",
        "mean_test_score", "std_test_score"]
top = results[cols].sort_values("mean_test_score", ascending=False).head(8).copy()
top.columns = ["n_est", "depth", "min_leaf", "max_feat", "CV F1", "±"]
top["CV F1"] = top["CV F1"].round(4)
top["±"] = top["±"].round(4)
show(top)
print()
best = results.mean_test_score.max()
close = int((results.mean_test_score > best - results.std_test_score.max()).sum())
print(f"Combinations within one spread of the best score: {close} / {len(results)}")
Output
Combinations within one spread of the best score: 36 / 36
n_estdepthmin_leafmax_featCV F1±
100620sqrt0.70.014
10061sqrt0.70.024
1001220sqrt0.6980.013
100None20sqrt0.6980.013
30061sqrt0.6980.023
30065sqrt0.6980.025
10065sqrt0.6970.025
300620sqrt0.6960.013

Pay attention to the last line, because the result is stark: 36 of the 36 combinations are within one spread of the best score.

In other words, no setting in this grid is reliably better than any other. GridSearchCV picked one of them as "the best", but in practice that choice is chance.

So don't treat best_params_ as sacred. Look at the std_test_score column next to it: if the differences are within the spread, you can pick the simplest or the fastest setting, and the result will be the same.

RandomizedSearchCV: random, but faster

As the grid grows, GridSearchCV explodes. The fix: don't try every combination, try N random ones.

python22 lines
from scipy.stats import randint, uniform

big_grid = {
    "m__n_estimators": randint(50, 500),
    "m__max_depth": [None, 4, 6, 8, 12, 20],
    "m__min_samples_leaf": randint(1, 40),
    "m__min_samples_split": randint(2, 40),
    "m__max_features": ["sqrt", "log2", 0.3, 0.5, 0.8],
}
print("If we did it with GridSearch (roughly):")
print(f"  450 x 6 x 39 x 38 x 5 = {450*6*39*38*5:,} combinations".replace(",", " "))
print("  -> impossible in practice")
print()
t0 = time.perf_counter()
rs = RandomizedSearchCV(pipe(RandomForestClassifier(random_state=42)), big_grid,
                        n_iter=40, cv=5, scoring="f1", random_state=42, n_jobs=-1)
rs.fit(X_tr, y_tr)
rs_time = time.perf_counter() - t0
print(f"RandomizedSearchCV (40 tries): {rs_time:.1f}s")
print(f"  best CV F1: {rs.best_score_:.4f}")
print(f"GridSearchCV ({total} combinations): {gs_time:.1f}s")
print(f"  best CV F1: {gs.best_score_:.4f}")
Output
If we did it with GridSearch (roughly):
  450 x 6 x 39 x 38 x 5 = 20 007 000 combinations
  -> impossible in practice

RandomizedSearchCV (40 tries): 5.7s
  best CV F1: 0.6985
GridSearchCV (36 combinations): 4.6s
  best CV F1: 0.6997

In practice, start with RandomizedSearchCV

The reason: many hyperparameters barely affect the result. Grid search spends equal time on those unimportant parameters too.

Random search looks at more different values of the important parameter. So in the same time it often finds a better result.

The order: search widely with RandomizedSearchCV -> find the best zone -> run a small GridSearchCV in that zone.

HalvingGridSearchCV: a faster variant

python13 lines
from sklearn.experimental import enable_halving_search_cv  # noqa: F401
from sklearn.model_selection import HalvingGridSearchCV

t0 = time.perf_counter()
hs = HalvingGridSearchCV(pipe(RandomForestClassifier(random_state=42)), grid,
                         cv=5, scoring="f1", random_state=42, n_jobs=-1, factor=3)
hs.fit(X_tr, y_tr)
hs_time = time.perf_counter() - t0
print(f"{'method':<24} {'time':>8} {'best CV F1':>18}")
print("-" * 52)
print(f"{'GridSearchCV':<24} {gs_time:>7.1f}s {gs.best_score_:>18.4f}")
print(f"{'RandomizedSearchCV':<24} {rs_time:>7.1f}s {rs.best_score_:>18.4f}")
print(f"{'HalvingGridSearchCV':<24} {hs_time:>7.1f}s {hs.best_score_:>18.4f}")
Output
method                       time         best CV F1
----------------------------------------------------
GridSearchCV                 4.6s             0.6997
RandomizedSearchCV           5.7s             0.6985
HalvingGridSearchCV          4.2s             0.6948

The Halving idea: in the first round it tries every combination on a small slice of data, drops the bad ones, then tries the rest on more data. Like a sports tournament.

The biggest mistake: tuning on the test set

python16 lines
print("The WRONG way: choosing the best setting on TEST:")
from sklearn.metrics import f1_score
best_test, best_setting = -1, None
for d in [None, 4, 6, 8, 12, 20]:
    for msl in [1, 5, 10, 20, 40]:
        m = pipe(RandomForestClassifier(max_depth=d, min_samples_leaf=msl, random_state=42))
        m.fit(X_tr, y_tr)
        score = f1_score(y_te, m.predict(X_te))
        if score > best_test:
            best_test, best_setting = score, (d, msl)
print(f"  30 combinations tried on TEST")
print(f"  best test F1: {best_test:.4f}  (max_depth={best_setting[0]}, min_samples_leaf={best_setting[1]})")
print()
print("The RIGHT way: choose with CV, then check on test ONCE:")
print(f"  the best chosen by CV : {gs.best_score_:.4f}")
print(f"  that model on test    : {f1_score(y_te, gs.best_estimator_.predict(X_te)):.4f}")
Output
The WRONG way: choosing the best setting on TEST:
  30 combinations tried on TEST
  best test F1: 0.7958  (max_depth=None, min_samples_leaf=40)

The RIGHT way: choose with CV, then check on test ONCE:
  the best chosen by CV : 0.6997
  that model on test    : 0.7842

Look at the test set 30 times and it's no longer a test

In the first method above I tried 30 settings on the test set and picked the best.

The result looks great, but it's a lie. I looked at the test set 30 times and chose the setting that fit it. That's another form of the leakage from Chapter 6.

In a real project it looks like this: you ship the model, the result on the server is lower than you expected, and you don't understand why.

The rule: settings are chosen in CV; the test set is opened once, only for the final report.

Nested CV: the most honest estimate

If you want an unbiased answer to "how good is my model after tuning?", one CV isn't enough, because it was used to choose the settings.

python15 lines
from sklearn.model_selection import KFold, cross_val_score as cvs

small_grid = {"m__max_depth": [None, 6, 12], "m__min_samples_leaf": [1, 5, 20]}

# plain CV: the very CV used to choose the settings
plain = GridSearchCV(pipe(RandomForestClassifier(random_state=42)), small_grid,
                     cv=5, scoring="f1", n_jobs=-1).fit(X, y)
print(f"GridSearchCV best_score_ (optimistic): {plain.best_score_:.4f}")

# nested CV: the outer loop NEVER sees the tuning process
inner = GridSearchCV(pipe(RandomForestClassifier(random_state=42)), small_grid,
                     cv=3, scoring="f1", n_jobs=-1)
nested = cvs(inner, X, y, cv=KFold(5, shuffle=True, random_state=42), scoring="f1")
print(f"Nested CV (unbiased estimate)        : {nested.mean():.4f} ±{nested.std():.4f}")
print(f"Difference                           : {plain.best_score_ - nested.mean():+.4f}")
Output
GridSearchCV best_score_ (optimistic): 0.7392
Nested CV (unbiased estimate)        : 0.7245 ±0.0219
Difference                           : +0.0147

best_score_ is always a little optimistic, because it picked the best combination on that same CV. Nested CV puts the tuning process itself to the test.

When you need nested CV

It's expensive (CV inside CV). So:

  • If you're writing a paper or research: nested CV is mandatory
  • In a production project: the plain approach is enough: tune with CV, then check once on a separate test set

The second way is common in practice and honest enough.

Which parameters to tune

Tuning every parameter is a waste of time. Each model has 2-4 that matter.

python9 lines
show(pd.DataFrame([
    {"Model": "LogisticRegression", "Important": "C, penalty", "Can leave alone": "solver, max_iter"},
    {"Model": "RandomForest", "Important": "max_depth, min_samples_leaf, max_features",
     "Can leave alone": "n_estimators (set it large, Chapter 19)"},
    {"Model": "XGBoost / LightGBM", "Important": "learning_rate, max_depth, subsample, colsample",
     "Can leave alone": "n_estimators (early_stopping, Chapter 20)"},
    {"Model": "SVC", "Important": "C, gamma (TOGETHER, Chapter 22)", "Can leave alone": "kernel (start with rbf)"},
    {"Model": "KNN", "Important": "n_neighbors, weights", "Can leave alone": "algorithm"},
]))
ModelImportantCan leave alone
LogisticRegressionC, penaltysolver, max_iter
RandomForestmax_depth, min_samples_leaf, max_featuresn_estimators (set it large, Chapter 19)
XGBoost / LightGBMlearning_rate, max_depth, subsample, colsamplen_estimators (early_stopping, Chapter 20)
SVCC, gamma (TOGETHER, Chapter 22)kernel (start with rbf)
KNNn_neighbors, weightsalgorithm
python7 lines
print("Tuning C for logistic regression:")
gs_lr = GridSearchCV(pipe(LogisticRegression(max_iter=3000)),
                     {"m__C": [0.001, 0.01, 0.1, 1, 10, 100]},
                     cv=5, scoring="f1", n_jobs=-1).fit(X, y)
print(f"  default (C=1)    : {default['LogisticRegression'].mean():.4f}")
print(f"  best C={gs_lr.best_params_['m__C']:<10}: {gs_lr.best_score_:.4f}")
print(f"  gain             : {gs_lr.best_score_ - default['LogisticRegression'].mean():+.4f}")
Output
Tuning C for logistic regression:
  default (C=1)    : 0.7273
  best C=0.001     : 0.7315
  gain             : +0.0042

The final comparison

python12 lines
final = pd.DataFrame([
    {"Model": "LogisticRegression", "Default": round(default["LogisticRegression"].mean(), 4),
     "Tuned": round(gs_lr.best_score_, 4),
     "Gain": round(gs_lr.best_score_ - default["LogisticRegression"].mean(), 4)},
    {"Model": "RandomForest", "Default": round(default["RandomForest"].mean(), 4),
     "Tuned": round(gs.best_score_, 4),
     "Gain": round(gs.best_score_ - default["RandomForest"].mean(), 4)},
])
show(final)
print()
print(f"Largest CV spread: ±{max(b.std() for b in default.values()):.4f}")
print("Compare the gains with that spread (the Chapter 10 rule).")
Output
Largest CV spread: ±0.0145
Compare the gains with that spread (the Chapter 10 rule).
ModelDefaultTunedGain
LogisticRegression0.7270.7320.004
RandomForest0.7030.7-0.003

What we learned today

python10 lines
show(pd.DataFrame([
    {"Concept": "Hyperparameter", "In one sentence": "A setting you choose BEFORE fit()"},
    {"Concept": "GridSearchCV", "In one sentence": "Every combination: for a small grid"},
    {"Concept": "RandomizedSearchCV", "In one sentence": "N random ones: for a big grid, the first choice in practice"},
    {"Concept": "HalvingGridSearchCV", "In one sentence": "The tournament method: drops the bad ones early"},
    {"Concept": "The gain from tuning", "In one sentence": "Usually 1-3%, sometimes zero: do it last"},
    {"Concept": "The biggest mistake", "In one sentence": "Choosing the setting on TEST: leakage"},
    {"Concept": "best_score_", "In one sentence": "A little optimistic: nested CV is less biased"},
    {"Concept": "How many parameters", "In one sentence": "Each model has 2-4 that matter; the rest waste time"},
]))
ConceptIn one sentence
HyperparameterA setting you choose BEFORE fit()
GridSearchCVEvery combination: for a small grid
RandomizedSearchCVN random ones: for a big grid, the first choice in practice
HalvingGridSearchCVThe tournament method: drops the bad ones early
The gain from tuningUsually 1-3%, sometimes zero: do it last
The biggest mistakeChoosing the setting on TEST: leakage
best_score_A little optimistic: nested CV is less biased
How many parametersEach model has 2-4 that matter; the rest waste time

Next chapter: instead of one model, we combine several.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents