ailiteracynepal 🇳🇵
Text size

Chapter 05 · Section II · 24 min read

Tuning a model's settings

Hyperparameters — the knobs you set before training — quietly determine how good your model can be. This section is the standard tools for tuning them: GridSearchCV, RandomizedSearchCV, and the practical wisdom that separates a 78% model from an 86% model.

Every model you train has settings — max_depth, n_neighbors, alpha, n_estimators — that you choose before training and that the optimisation procedure does not adjust. These are hyperparameters, and choosing them well is often the difference between a mediocre model and a strong one. This section is the standard scikit-learn toolkit for systematically searching the hyperparameter space and finding the best combination — without leaking test-set information into your choices.

Parameters vs hyperparameters

A useful distinction:

  • Parameters are the knobs inside the model that training adjusts — the weights in a logistic regression, the splits in a decision tree, the cluster centres in k-means. You don’t set these directly; model.fit(...) does.

  • Hyperparameters are the settings of the model that you choose before training — max_depth, learning_rate, n_neighbors. You set these explicitly; training does not touch them.

Tuning means finding the hyperparameter values that make the trained model best (highest cross-validation accuracy, lowest cross-validation MSE, etc.).

The wrong way

A common beginner approach:

# DON'T DO THIS
for depth in [3, 5, 10, 20]:
    model = DecisionTreeClassifier(max_depth=depth)
    model.fit(X_train, y_train)
    test_acc = model.score(X_test, y_test)
    print(f"depth={depth}: {test_acc:.3f}")

# Then deploy with the best depth

The problem: you tuned on the test set. The “best” depth is the one that happens to perform best on the specific test set you have. That number is no longer an honest estimate of how the model will generalise — you optimised the test-set score directly.

The correct approach is to evaluate hyperparameters on a separate validation set (or via cross-validation, the better version), keep the test set untouched until the final report, and only then check honest performance.

The right way: GridSearchCV

scikit-learn’s GridSearchCV does the heavy lifting:

from sklearn.model_selection import GridSearchCV
from sklearn.ensemble import RandomForestClassifier

param_grid = {
    "n_estimators": [50, 100, 200],
    "max_depth": [3, 5, 10, None],
    "min_samples_split": [2, 5, 10],
}

grid = GridSearchCV(
    RandomForestClassifier(random_state=42),
    param_grid=param_grid,
    cv=5,           # 5-fold cross-validation on the training data
    scoring="accuracy",
    n_jobs=-1,      # use all CPU cores
    verbose=1,
)

grid.fit(X_train, y_train)

print(f"Best params: {grid.best_params_}")
print(f"Best CV score: {grid.best_score_:.3f}")

# Honest evaluation on untouched test set, once, at the end
print(f"Test accuracy: {grid.score(X_test, y_test):.3f}")

What just happened:

  • GridSearchCV trains a fresh model for every combination of hyperparameters (here, 3×4×3 = 36 combinations).
  • For each combination, it does 5-fold cross-validation on the training data, computing the average score across the 5 folds.
  • It keeps the combination with the highest average CV score.
  • grid.best_estimator_ is the model trained on all the training data with those best hyperparameters.
  • You evaluate on the test set once at the end. The test set was never touched during tuning.

This is the gold standard for tuning. The only downside: with many hyperparameters and many values, the grid grows fast. A 36-combination grid with 5-fold CV trains 180 models. A 1000-combination grid trains 5000 models. On large datasets, this gets expensive.

RandomizedSearchCV — when the grid is too big

When the hyperparameter space is large, RandomizedSearchCV samples random combinations instead of trying every one:

from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint, uniform

param_dist = {
    "n_estimators": randint(50, 500),
    "max_depth": randint(3, 20),
    "min_samples_split": randint(2, 20),
    "max_features": uniform(0.1, 0.9),
}

search = RandomizedSearchCV(
    RandomForestClassifier(random_state=42),
    param_distributions=param_dist,
    n_iter=50,        # try 50 random combinations
    cv=5,
    scoring="accuracy",
    n_jobs=-1,
    random_state=42,
)

search.fit(X_train, y_train)
print(search.best_params_)

Counterintuitively, RandomizedSearch with 50 trials often finds nearly the same best hyperparameters as a full grid with 200+ trials. The reason: most hyperparameters have a few key values where the model performs well, and 50 random tries are usually enough to land on (or near) at least one of them.

For most projects, RandomizedSearchCV with 30-50 iterations is more practical than GridSearchCV. Use GridSearch when you have a small, well-understood hyperparameter space.

Which hyperparameters to tune?

A short guide for the models in this course:

Logistic regression: C (inverse of regularisation strength, smaller = more regularisation), penalty (l1 vs l2), class_weight.

Random forest: n_estimators (more trees = better, with diminishing returns), max_depth (controls overfitting), min_samples_split, max_features (how many features each tree considers per split — a key knob for forest diversity).

Gradient boosting (GradientBoostingClassifier or xgboost): learning_rate, n_estimators, max_depth. These three interact strongly; tune them together.

K-NN: n_neighbors, weights (uniform vs distance), metric.

Decision tree: max_depth, min_samples_split, min_samples_leaf.

For most tabular projects, you do not need to tune all hyperparameters. Picking the 2-3 that matter most for your model class is enough to capture 80% of the gain.

A worked example — tuning a random forest

Starting from a default random forest with test accuracy 0.78, we’ll tune and see how far we can get:

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint
import time

# Baseline
default = RandomForestClassifier(random_state=42)
default.fit(X_train, y_train)
print(f"Default: train={default.score(X_train, y_train):.3f}  test={default.score(X_test, y_test):.3f}")

# Tuned
param_dist = {
    "n_estimators": randint(100, 500),
    "max_depth": randint(3, 30),
    "min_samples_split": randint(2, 20),
    "min_samples_leaf": randint(1, 10),
    "max_features": ["sqrt", "log2", 0.3, 0.5],
}

start = time.time()
search = RandomizedSearchCV(
    RandomForestClassifier(random_state=42),
    param_distributions=param_dist,
    n_iter=40,
    cv=5,
    scoring="f1",
    n_jobs=-1,
    random_state=42,
)
search.fit(X_train, y_train)
elapsed = time.time() - start

print(f"Search took {elapsed:.1f}s")
print(f"Best params: {search.best_params_}")
print(f"Best CV F1: {search.best_score_:.3f}")
print(f"Tuned test F1: {search.score(X_test, y_test):.3f}")

Typical result:

Default: train=0.998  test=0.778
Search took 67.4s
Best params: {'max_depth': 11, 'max_features': 'sqrt', 'min_samples_leaf': 4, 'min_samples_split': 14, 'n_estimators': 312}
Best CV F1: 0.804
Tuned test F1: 0.811

The tuned model is 3 percentage points better on test F1, the train/test gap closed (less overfitting), and the model is more robust. Sixty seconds of compute time bought 3-5 points of real performance.

Tuning works inside a pipeline

You can tune hyperparameters of any step inside a Pipeline using the step_name__hyperparameter syntax:

pipeline = Pipeline([
    ("scale", StandardScaler()),
    ("clf", LogisticRegression(max_iter=1000)),
])

param_grid = {
    "clf__C": [0.01, 0.1, 1.0, 10.0, 100.0],
    "clf__penalty": ["l1", "l2"],
    "clf__solver": ["liblinear"],
}

grid = GridSearchCV(pipeline, param_grid, cv=5, scoring="f1")
grid.fit(X_train, y_train)

The double underscore (clf__C) tells the grid search “tune the C hyperparameter of the clf step in the pipeline.” This lets you combine preprocessing and tuning cleanly, with no leakage.

A note on time budgets

Hyperparameter search is constrained by your compute budget. A useful rule of thumb:

  • Quick exploration: RandomizedSearchCV with 20-30 iterations, 5-fold CV. Hours-to-overnight on a laptop.
  • Production model: GridSearchCV (or a more careful RandomizedSearch with 100+ iterations) on a focused subset of hyperparameters identified from quick exploration.
  • Very large models / very long training: don’t tune everything. Use defaults from published configurations, tune one or two hyperparameters that matter most, and accept that further tuning has diminishing returns.

The tuning-budget conversation deserves to be explicit with your team and your stakeholders.

Check your understanding

Quick check

A teammate runs a `for` loop over different max_depth values, training each model on the training set and scoring it on the test set. They pick the depth with the highest test score and report that test score as 'the model's accuracy.' What's wrong?

Quick check

You have 30 hyperparameters to potentially tune, each with 5 plausible values. A full grid search would require 5^30 trials — impossibly many. What's the practical approach?

What comes next

You can pick hyperparameters honestly. The last section of Chapter 5 makes formal the technique we’ve been quietly using throughout: cross-validation. The discipline of training and evaluating on multiple splits to get a more honest estimate of model performance — and the small reasons it matters even when you “have lots of data.”