EduFlow

Section 4 · Doing It Right

Hyperparameter Search

20 min3 min readPython exercise

Lesson at a glance

Tune hyperparameters with GridSearchCV and RandomizedSearchCV using pipeline step names, read cv_results_, and keep the final test score honest after the search.

  1. 1Grid search
  2. 2A grid where it matters
  3. 3Random search
  4. 4Searching more than model settings

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Every model so far has had settings you picked by hand or left at defaults: C=1.0, max_depth=3, min_samples_leaf=5. Some of those choices matter a lot, some barely at all, and the only way to know is to try. Trying by hand means loops, bookkeeping and the temptation to peek at the test set. scikit-learn’s search tools do it properly, with cross-validation built in.

Grid search

GridSearchCV takes an estimator, a dictionary of parameter values and a cross-validation scheme. It cross-validates every combination, picks the one with the best mean score, and refits it on all the training data. Afterwards it behaves like the winning model.

Parameters inside a pipeline are addressed by step name, two underscores, and parameter name. That’s why naming your steps pays off.

Python
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, StratifiedKFold, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

churn = pd.read_csv("churn.csv")
features = ["tenure_days", "orders", "total_spent", "avg_order_value",
            "days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]
X_train, X_test, y_train, y_test = train_test_split(
    churn[features], churn["churned"], test_size=0.25, random_state=42,
    stratify=churn["churned"])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

pipe = Pipeline([("scale", StandardScaler()), ("model", LogisticRegression())])
search = GridSearchCV(pipe, {"model__C": [0.01, 0.03, 0.1, 0.3, 1, 3, 10]},
                      cv=cv, scoring="roc_auc")
search.fit(X_train, y_train)

results = pd.DataFrame(search.cv_results_)
print(results[["param_model__C", "mean_test_score", "std_test_score"]].round(3))
print("best:", search.best_params_, "CV AUC", round(search.best_score_, 3))

cv_results_ holds every combination’s fold scores; as a DataFrame it’s easy to scan. Here the scan is the finding: from C=0.03 to C=10 the mean AUC moves by less than half a point, far inside the fold-to-fold spread. Eight well-behaved features don’t give logistic regression room to overfit, so its penalty barely matters. Tuning can’t rescue a model that’s already at its ceiling; better features or a different model family might.

Notice the values are spaced by factors of about three. For parameters that act multiplicatively (C, alpha, learning rates), search on a log scale; 0.01, 0.1, 1, 10 covers far more ground than 1, 2, 3, 4.

A grid where it matters

Trees are another story. Depth and minimum leaf size control how much a tree can memorise, and the right values depend on the data.

Python
import pandas as pd
from sklearn.model_selection import GridSearchCV, StratifiedKFold, train_test_split
from sklearn.tree import DecisionTreeClassifier

churn = pd.read_csv("churn.csv")
features = ["tenure_days", "orders", "total_spent", "avg_order_value",
            "days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]
X_train, X_test, y_train, y_test = train_test_split(
    churn[features], churn["churned"], test_size=0.25, random_state=42,
    stratify=churn["churned"])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

grid = {"max_depth": [2, 3, 4, 5, None], "min_samples_leaf": [1, 5, 10, 20, 40]}
search = GridSearchCV(DecisionTreeClassifier(random_state=42), grid, cv=cv, scoring="roc_auc")
search.fit(X_train, y_train)

top = pd.DataFrame(search.cv_results_).sort_values("rank_test_score")
print(top[["param_max_depth", "param_min_samples_leaf", "mean_test_score"]].head(5).round(3))
print("worst:", round(top["mean_test_score"].min(), 3))
print("best:", search.best_params_, "| test AUC", round(search.score(X_test, y_test), 3))

25 combinations × 5 folds = 125 fits, plus one final refit. The spread is wide: the worst trees (unlimited depth, single-customer leaves) score far below the best. search.score uses the same scoring as the search, so it reports test ROC AUC here.

Random search

Grids grow multiplicatively. Four parameters with six values each is 1,296 combinations, times five folds. RandomizedSearchCV samples a fixed number of combinations (n_iter) instead, and it accepts distributions as well as lists.

Two squares with an important parameter on the horizontal axis and an unimportant one on the vertical. The grid places nine points in a 3 by 3 lattice, testing only three distinct values of the important parameter. Random search scatters nine points, testing nine distinct values. Grid: 3 values tried Random: 9 values tried important →
Grid search versus random search with the same nine trials

When only one or two parameters really matter, which is the usual case, random search explores many more distinct values of them for the same budget.

Python
import pandas as pd
from scipy.stats import randint
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import RandomizedSearchCV, StratifiedKFold, train_test_split

churn = pd.read_csv("churn.csv")
features = ["tenure_days", "orders", "total_spent", "avg_order_value",
            "days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]
X_train, X_test, y_train, y_test = train_test_split(
    churn[features], churn["churned"], test_size=0.25, random_state=42,
    stratify=churn["churned"])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

space = {"max_features": [1, 2, 3, 4, "sqrt"], "min_samples_leaf": randint(1, 30)}
search = RandomizedSearchCV(RandomForestClassifier(n_estimators=60, random_state=42), space,
                            n_iter=8, cv=cv, scoring="roc_auc", random_state=42)
search.fit(X_train, y_train)
print("best:", search.best_params_)
print("CV AUC", round(search.best_score_, 3), "| test AUC", round(search.score(X_test, y_test), 3))

randint(1, 30) from SciPy draws integers from 1 to 29; loguniform(0.01, 10) is the usual choice for C or learning rates. Set random_state on the search so it samples the same candidates each run.

Searching more than model settings

Because the search tunes the whole pipeline, preprocessing choices are fair game too. A grid entry like "prep__sat__simpleimputer__strategy": ["mean", "median"] compares imputation strategies with the same honest folds. You can even pass a list of grids, one per model family, and let the search swap the final step: [{"model": [LogisticRegression()], "model__C": [0.1, 1]}, {"model": [RandomForestClassifier()], "model__min_samples_leaf": [5, 20]}]. That’s tidy, but it multiplies fits quickly, and it hides the comparison table you’d want to show a colleague. For a handful of candidate models, a plain loop of cross_validate calls with the same splitter is often clearer.

Searches multiply the number of models you fit, and with them the number of chances for information to leak from validation data into training. Next lesson is a field guide to leakage: how it happens, how to spot it, and why it produces the best scores you’ll ever see.

Key takeaways

  • GridSearchCV tries every combination in a grid, cross-validating each; RandomizedSearchCV samples a fixed number of combinations from lists or distributions.
  • Inside a pipeline, parameters are addressed as step__parameter, for example model__C.
  • After the search, best_params_ holds the winner and best_estimator_ is that model refit on all the training data.
  • best_score_ is slightly optimistic because it’s the best of many tries; the test set gives the unbiased number.
  • Search on a log scale for parameters like C, alpha and learning_rate, and expect tuning to add points, not miracles.

Further reading

Check your understanding

4 questions

Question 1 of 4

Your pipeline is Pipeline([("prep", preprocess), ("clf", LogisticRegression())]). Which grid tunes the regularisation strength?

Question 2 of 4

A grid has 5 values for max_depth and 5 for min_samples_leaf, with cv=5. How many models are fitted, including the final refit?

Question 3 of 4

Why might RandomizedSearchCV with 20 iterations beat a 400-combination grid on the same budget of time?

Question 4 of 4

The search reports best_score_ = 0.813, and the refit model scores 0.805 on the test set. What’s going on?

Practice

Python exercise

Tune k-nearest neighbours

Find the best number of neighbours for a scaled k-NN pipeline.

  1. Fix grid so its keys address the pipeline’s knn step (the starter keys would raise an invalid-parameter error).
  2. search: a GridSearchCV over pipe with the provided cv and scoring="roc_auc", fitted on the training data.
  3. best_params, best_cv_auc: the winning settings and their mean cross-validated AUC. test_auc: the refit winner’s ROC AUC on the test set.

Data files: churn.csv

import pandas as pd
from sklearn.model_selection import GridSearchCV, StratifiedKFold, train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

churn = pd.read_csv("churn.csv")
features = ["tenure_days", "orders", "total_spent", "avg_order_value",
            "days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]
X_train, X_test, y_train, y_test = train_test_split(
    churn[features], churn["churned"], test_size=0.25, random_state=42,
    stratify=churn["churned"])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

pipe = Pipeline([("scale", StandardScaler()), ("knn", KNeighborsClassifier())])

# 1. Grid: n_neighbors in [5, 15, 25, 35, 51] and weights in ["uniform", "distance"]
grid = {"n_neighbors": [5, 15, 25, 35, 51], "weights": ["uniform", "distance"]}

# 2. Search with the provided cv, scoring ROC AUC, fitted on the training data
search = None

# 3. Results
best_params = None
best_cv_auc = None
test_auc = None   # ROC AUC of the refit best model on the test set
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

(no output)

Checks

  • grid keys use the knn__ prefix
  • search tried all 10 combinations with ROC AUC
  • best_params and best_cv_auc come from the search
  • test_auc is the refit winner’s test ROC AUC