Home / Basics of AI/ML / Basics of AI/ML
Basics of AI/MLHow-toBeginner
How to Tune a Machine Learning Model in Python: A Beginner's Guide to Hyperparameters and GridSearchCV (Food Delivery ML Series, Part 5)
The short answer
Tuning a model means choosing its settings, its hyperparameters, by testing many options on validation data and keeping the best. In this tutorial, tuning turns a gradient boosting model that lost to simple linear regression (4.61 vs 4.49 minutes of error) into one that ties it overall (4.50) and does better on the hardest orders. Along the way you'll learn why an honest "tie" is a real result.
Quick answer: To tune a machine learning model in Python: (1) keep the test set aside; (2) pick the hyperparameters to try, such asmax_depth,learning_rateandn_estimators; (3) score each combination with 5-fold cross-validation on the training data, using scikit-learn'sGridSearchCV(every combination) orRandomizedSearchCV(a random sample); (4) take the best settings and check them once on the test set; (5) compare with your old model using a range, not a single number, because small differences can be luck.
What you will build
By the end of this tutorial you will have:
| What | Result |
| A validation curve | Shows where gradient boosting stops learning and starts memorising: training error keeps falling, validation error turns back up |
| A grid search over 48 settings | Best: max_depth=2, learning_rate=0.1, n_estimators=400 |
| A random search over 6 hyperparameters | Finds a different winner with nearly the same score, a lesson in itself |
| An honest comparison | Tuned boosting beats default boosting; it ties linear regression overall |
| A promise buffer chosen on validation data | Add 7 minutes to each prediction and about 90% of orders arrive on time |
| A redeployed API | The Part 4 API serving the tuned model, with the promise time in the response |
The rainy Saturday biryani from Parts 3 and 4 now gets:
{
"predicted_minutes": 68.8,
"promised_minutes": 76,
"model_version": "part5-gb-tuned-v1"
}
How this part fits the series
This is Part 5 of the Delivery ML series. It takes the gradient boosting model from Part 3, Step 6, which scored worse than linear regression, tunes it properly, and swaps it into the API you built in Part 4. Part 6 (explaining predictions) works with either model, and Part 7 goes deeper into boosting with XGBoost and LightGBM, where tuning matters even more.
Prerequisites
You need Parts 2 and 3 of this series, Part 4 for the redeploy step, and about 90 minutes. No new libraries: everything in this part comes with scikit-learn.
From earlier parts
| You need | From | Why |
delivery_data.py (the messy 3,040-order generator) | Part 3 | The same data, so every number here can be compared with Part 3 |
delivery_features.py with clean_orders | Part 3 | The cleaning step inside every pipeline we tune |
| The linear pipeline and its test MAE (4.49 minutes) | Part 3 | The score to beat |
| The gradient boosting pipeline from Part 3, Step 6 | Part 3 | The model we tune |
The FastAPI project (app.py, test_app.py) | Part 4 | Step 7 swaps the tuned model into it |
Knowledge
| Concept | Where it was taught | Quick self-check |
| Train/test split | Part 1 | Why must the test set stay unseen? |
| MAE | Part 2 | What does an MAE of 4.5 minutes mean? |
| Parameters vs hyperparameters | Part 2, theory break | Is a model's weight a parameter or a hyperparameter? |
| Overfitting | Parts 1 and 2 | What does it look like when training error is much lower than test error? |
| Pipelines and leakage | Part 3 | Why does the imputer learn its median only from training data? |
Software
| Tool | Version tested | Notes |
| Python | 3.13 | 3.10 or newer works |
| scikit-learn | 1.9.1 | GridSearchCV, RandomizedSearchCV, cross_validate |
| pandas / NumPy | 3.0.5 / 2.5.3 | As in Part 3 |
| SciPy | installed with scikit-learn | Supplies the random distributions in Step 4 |
| FastAPI, pytest | As in Part 4 | Only for Step 7 |
Hardware, time and cost
- Any laptop from the last five years. Searches use all CPU cores (
n_jobs=-1); the biggest one takes about a minute. - Google Colab works too: run the same code in cells.
- Free: no cloud services or API keys.
Theory break: parameters, hyperparameters, and the three-way split
Parameters are what the model learns from the data; hyperparameters are the settings you choose before training. Tuning means trying different settings and keeping the best, but "best" has to be judged on data the model never trained on, and never on the test set.
A quick refresher from Part 2
| Parameters | Hyperparameters | |
| Who sets them? | The model, during .fit() | You, before .fit() |
| Examples in this series | Linear weights and bias; the split points inside each tree | max_depth, n_estimators, learning_rate |
| How many? | Thousands in a boosted model | Usually a handful worth tuning |
| Kitchen analogy | How the dish turns out | Oven temperature and cooking time |
Gradient boosting's three main dials
Gradient boosting (Part 3, Step 6) builds a team of small decision trees, one after another. Each new tree tries to fix the mistakes of the trees before it.
| Hyperparameter | What it controls | Too small | Too big |
max_depth | How many questions each tree may ask (depth 2 = "is it raining? and then is it peak hour?") | Trees too simple to capture interactions | Trees memorise quirks of the training orders |
n_estimators | How many trees in the team | Not enough corrections | Keeps fixing noise |
learning_rate | How much each new tree is allowed to correct | Needs many trees to get anywhere | Overshoots; wobbly results |
n_estimators and learning_rate work as a pair: smaller steps need more trees.
Why you can't tune on the test set
Imagine a student who sees the final exam, practises those exact questions, and scores 95%. The score says nothing about what the student knows.
If you try 50 settings and keep the one with the best test MAE, you've done the same thing. The test score is now optimistic, because you picked it partly for luck. The test set stops being a fair final exam.
The fix is a third pot of data for choosing settings:
| Data | Used for | Analogy |
| Training set | Learning the parameters | Studying the textbook |
| Validation data | Choosing the hyperparameters | Practice exams |
| Test set | One final, honest score, at the very end | The real exam, taken once |
In Step 2 we'll carve validation data out of the training set by hand. In the cross-validation theory break, we'll find a way to use all the training data for both learning and validating.
Step 1: Set up and measure the baseline
Rebuild Part 3's data, split and both models. Gradient boosting fits the training orders much more closely than linear regression (3.78 vs 4.50 minutes of error) but does worse on the test set (4.61 vs 4.49). That gap is the problem tuning will try to fix.
Create a new file, tune_delivery.py, in the same folder as Part 3's delivery_data.py and delivery_features.py:
import time
import joblib
import numpy as np
import pandas as pd
from scipy.stats import loguniform, randint, uniform
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import (GridSearchCV, KFold, RandomizedSearchCV, cross_val_predict,
cross_validate, train_test_split)
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import FunctionTransformer, OneHotEncoder, StandardScaler
from delivery_data import make_messy_orders
from delivery_features import clean_orders
orders = make_messy_orders().drop_duplicates()
X = orders.drop(columns="delivery_min")
y = orders["delivery_min"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
numeric_features = ["distance_km", "prep_time_min", "rider_experience_months", "is_raining",
"is_peak_hour", "is_weekend", "distance_x_rain", "distance_x_peak",
"distance_x_rain_x_peak"]
categorical_features = ["city_zone", "cuisine"]
def make_preprocess():
return ColumnTransformer([
("num", make_pipeline(SimpleImputer(strategy="median"), StandardScaler()), numeric_features),
("cat", make_pipeline(SimpleImputer(strategy="most_frequent"),
OneHotEncoder(handle_unknown="ignore")), categorical_features),
])
def make_boosted(**settings):
"""A Part 3 gradient boosting pipeline with the hyperparameters you choose."""
return make_pipeline(FunctionTransformer(clean_orders), make_preprocess(),
GradientBoostingRegressor(random_state=42, **settings))
linear = make_pipeline(FunctionTransformer(clean_orders), make_preprocess(), LinearRegression())
linear.fit(X_train, y_train)
default_boosted = make_boosted().fit(X_train, y_train)
for name, model in [("Linear", linear), ("Boosted (default)", default_boosted)]:
train_mae = mean_absolute_error(y_train, model.predict(X_train))
print(f"{name:18s} train MAE {train_mae:.2f}")
Output:
Linear train MAE 4.50
Boosted (default) train MAE 3.78
Most of this is Part 3's code. The new parts:
| Code | What it does |
The longer import lists | Everything this tutorial needs, loaded once. scipy.stats supplies random number ranges for Step 4. |
def make_boosted(**settings) | A small factory: builds a fresh gradient boosting pipeline with whatever hyperparameters you pass in. make_boosted(max_depth=2) is the same as Part 3's pipeline but with trees of depth 2. |
**settings | Python's way of saying "accept any named arguments and pass them all on". Here they go straight to GradientBoostingRegressor. |
random_state=42 | Gradient boosting has some randomness; fixing it means you get the same numbers as this article |
make_boosted() with nothing inside | All the default settings: max_depth=3, learning_rate=0.1, n_estimators=100 |
Reading the baseline
We already know the test scores from Part 3, so let's put everything side by side:
| Model | Training MAE | Test MAE (from Part 3) | Gap |
| Linear regression | 4.50 | 4.49 | 0.0 |
| Gradient boosting, default settings | 3.78 | 4.61 | 0.8 |
The linear model makes almost exactly the same size of error on orders it has seen and orders it hasn't. Gradient boosting is 0.8 minutes better on the orders it trained on, and 0.1 minutes worse on new ones. It has learned some patterns that are really just noise in the training orders.
Could different settings fix that? That's the question for the rest of this tutorial.
From here on, the test set is locked. We've looked at it once for these two models (in Part 3). Every decision from now until Step 5 is made without it.
Step 2: Tune one hyperparameter by hand
Split off 20% of the training orders as validation data and try six tree depths. Depth 2 wins with 4.51 minutes. But with a different random split, depth 4 wins instead, so one validation split isn't enough to trust.
We start with max_depth, the most important dial: how many questions each tree can ask.
X_fit, X_val, y_fit, y_val = train_test_split(X_train, y_train, test_size=0.2, random_state=0)
for depth in [1, 2, 3, 4, 5, 6]:
model = make_boosted(max_depth=depth).fit(X_fit, y_fit)
print(f"max_depth={depth} validation MAE {mean_absolute_error(y_val, model.predict(X_val)):.2f}")
Output:
max_depth=1 validation MAE 5.36
max_depth=2 validation MAE 4.51
max_depth=3 validation MAE 4.56
max_depth=4 validation MAE 4.59
max_depth=5 validation MAE 4.69
max_depth=6 validation MAE 4.81
| Code | What it does |
train_test_split(X_train, y_train, ...) | Splits the training set again: 1,920 orders to fit (X_fit), 480 to validate (X_val). The test set isn't touched. |
for depth in [1, 2, 3, 4, 5, 6] | Tries each depth in turn |
make_boosted(max_depth=depth) | A fresh pipeline with that depth, using the factory from Step 1 |
.fit(X_fit, y_fit) | Learns only from the fit part |
mean_absolute_error(y_val, ...) | Scores it on orders it hasn't seen |
What the numbers say
- Depth 1 is too simple (5.36). A tree that can ask only one question can't learn that rain matters more at rush hour. This is underfitting.
- Deeper than 2 gets steadily worse. Each extra level lets the trees memorise more quirks of these 1,920 orders. This is overfitting.
- Depth 2 is the sweet spot, beating the default depth 3.
The catch
Change one number, random_state=0 to random_state=1, so that a different 480 orders become the validation set:
max_depth=1 validation MAE 5.44
max_depth=2 validation MAE 4.36
max_depth=3 validation MAE 4.30
max_depth=4 validation MAE 4.27
max_depth=5 validation MAE 4.41
max_depth=6 validation MAE 4.58
Now depth 4 wins. Nothing about the model changed; only which orders were used for checking. With 480 validation orders, the luck of the draw moves the scores by about as much as the differences we're trying to measure.
We need a way to validate on more orders without touching the test set. That's cross-validation.
Theory break: cross-validation
Cross-validation splits the training data into 5 parts and takes turns: each part is the validation set once while the other 4 train the model. Every order gets used for validation, so the score is far less a matter of luck.
How 5-fold cross-validation works
Cut the 2,400 training orders into 5 equal folds of 480. Then run five rounds:
| Round | Trains on | Validates on |
| 1 | Folds 2, 3, 4, 5 | Fold 1 |
| 2 | Folds 1, 3, 4, 5 | Fold 2 |
| 3 | Folds 1, 2, 4, 5 | Fold 3 |
| 4 | Folds 1, 2, 3, 5 | Fold 4 |
| 5 | Folds 1, 2, 3, 4 | Fold 5 |
Average the five validation scores. Like a student who sits five different practice exams instead of one, the average tells you far more than any single result.
The cost: five times as much training. For our small dataset, that's seconds.
The same depth question, cross-validated
cv = KFold(n_splits=5, shuffle=True, random_state=42)
for depth in [1, 2, 3, 4, 5, 6, 8]:
scores = cross_validate(make_boosted(max_depth=depth), X_train, y_train, cv=cv,
scoring="neg_mean_absolute_error", return_train_score=True, n_jobs=-1)
print(f"max_depth={depth} train {-scores['train_score'].mean():.2f} "
f"validation {-scores['test_score'].mean():.2f}")
Output:
max_depth=1 train 5.13 validation 5.33
max_depth=2 train 4.11 validation 4.52
max_depth=3 train 3.71 validation 4.40
max_depth=4 train 3.33 validation 4.42
max_depth=5 train 2.83 validation 4.50
max_depth=6 train 2.16 validation 4.63
max_depth=8 train 0.88 validation 4.85
| Code | What it does |
KFold(n_splits=5, shuffle=True, random_state=42) | The recipe for the 5 folds. shuffle=True mixes the orders first; random_state makes the folds the same every run. |
cross_validate(model, X_train, y_train, cv=cv, ...) | Runs all five rounds for you and returns the scores |
scoring="neg_mean_absolute_error" | scikit-learn treats higher as better, so it reports MAE as a negative number. The - in the print flips it back. |
return_train_score=True | Also records the error on the training folds, so we can see overfitting |
n_jobs=-1 | Runs rounds in parallel on all your CPU cores |
scores['test_score'] | Here "test" means the validation fold, not our locked test set. scikit-learn's naming is confusing; keep that in mind. |
Because the pipeline (cleaning, imputing, scaling, model) is refitted inside every round, the imputer's median is learned only from that round's 4 training folds. That's Part 3's leakage lesson, handled automatically.
Reading the validation curve
This picture is called a validation curve, and it's one of the most useful plots in machine learning:
- Training error falls forever. At depth 8 the model is within 0.9 minutes on orders it has seen: it is memorising them.
- Validation error falls, then rises. The bottom of the U is the best setting. Left of it the model is too simple (underfitting); right of it, it learns noise (overfitting).
- The gap between the lines is overfitting you can see. At depth 3 it's 0.7 minutes; at depth 8 it's 4 minutes.
Cross-validation picks depth 3 (4.40), with depth 4 a hair behind (4.42). The single splits in Step 2 picked depth 2 and depth 4; averaging five rounds settles it.
Notice something else: at depth 3, cross-validation says boosting beats linear regression (4.40 vs 4.53), even though linear won on the test set. Hold that thought; it comes back in Step 5.
Step 3: Search a grid of settings with GridSearchCV
**GridSearchCV** tries every combination of the settings you list, cross-validates each one, and keeps the best. 4 depths × 4 learning rates × 3 tree counts = 48 settings and 240 fits, in under a minute. The winner: depth 2, learning rate 0.1, 400 trees, with a validation MAE of 4.36.
Depth isn't the only dial, and the dials affect each other: shallower trees may need more of them. So we search all three together.
param_grid = {
"gradientboostingregressor__max_depth": [1, 2, 3, 4],
"gradientboostingregressor__learning_rate": [0.03, 0.05, 0.1, 0.2],
"gradientboostingregressor__n_estimators": [100, 200, 400],
}
start = time.perf_counter()
grid = GridSearchCV(make_boosted(), param_grid, cv=cv, scoring="neg_mean_absolute_error", n_jobs=-1)
grid.fit(X_train, y_train)
print(f"Tried {len(grid.cv_results_['params'])} settings in {time.perf_counter() - start:.0f} s")
print("Best settings:", grid.best_params_)
print(f"Best validation MAE: {-grid.best_score_:.3f}")
Output (your timing will differ; ours is a 2-core machine):
Tried 48 settings in 57 s
Best settings: {'gradientboostingregressor__learning_rate': 0.1, 'gradientboostingregressor__max_depth': 2, 'gradientboostingregressor__n_estimators': 400}
Best validation MAE: 4.358
| Code | What it does |
param_grid = {...} | The settings to try. Each key is a hyperparameter; each value is a list of options. |
"gradientboostingregressor__max_depth" | The name says which step of the pipeline the setting belongs to: the step called gradientboostingregressor, then two underscores, then the setting. make_pipeline names each step after its class, in lowercase. |
GridSearchCV(make_boosted(), param_grid, cv=cv, ...) | Takes a starting pipeline, the grid, and the same 5 folds as before |
grid.fit(X_train, y_train) | Runs 48 settings × 5 folds = 240 fits, then refits the winner on all 2,400 training orders |
grid.best_params_ | The winning settings |
grid.best_score_ | The winner's average validation score (negative, so we flip the sign) |
grid.best_estimator_ | The winning pipeline, already refitted and ready to predict |
Look beyond the winner
The top five tell an important story. grid.cv_results_ holds every score; turn it into a table:
results = pd.DataFrame(grid.cv_results_)
top = results.sort_values("rank_test_score").head(5)
print(pd.DataFrame({
"max_depth": top["param_gradientboostingregressor__max_depth"],
"learning_rate": top["param_gradientboostingregressor__learning_rate"],
"n_estimators": top["param_gradientboostingregressor__n_estimators"],
"val_MAE": -top["mean_test_score"].round(3),
"spread": top["std_test_score"].round(3),
}).to_string(index=False))
Output:
max_depth learning_rate n_estimators val_MAE spread
2 0.10 400 4.358 0.093
3 0.03 400 4.373 0.074
2 0.05 400 4.374 0.087
3 0.05 400 4.378 0.071
3 0.10 200 4.381 0.073
**rank_test_score**is 1 for the best setting, 2 for the next, and so on.**spread**(std_test_score) is how much the score varied across the 5 folds.
Two things to notice:
- The top five are within 0.023 minutes of each other (about 1.4 seconds), while each one's score wobbles by about 0.08 between folds. They are effectively tied. The "winner" won by luck as much as by merit.
- The same pattern repeats: shallow trees + many small steps. Depth 2 with 400 trees and depth 3 with a slow learning rate both work, because both learn gradually. That's the
learning_rateandn_estimatorspartnership from the first theory break.
For comparison, linear regression on the same 5 folds:
linear_cv = cross_validate(linear, X_train, y_train, cv=cv, scoring="neg_mean_absolute_error")
print(f"Linear validation MAE: {-linear_cv['test_score'].mean():.3f}")
Linear validation MAE: 4.534
So cross-validation says the tuned model is about 0.18 minutes better than linear regression. Keep that number in mind.
Step 4: Search bigger spaces with RandomizedSearchCV
When there are too many combinations to try them all, **RandomizedSearchCV** tries a random sample. 40 random settings across 6 hyperparameters found a quite different model (Huber loss, depth 3, 180 trees) that scored 4.34, only 0.02 better than the grid. Many different settings are equally good.
Gradient boosting has more dials than the three we searched. Adding three more, with a handful of values each, would make thousands of combinations. Trying them all would take hours.
The trick: instead of a list of values, give each hyperparameter a range, and let the search pick random combinations.
param_distributions = {
"gradientboostingregressor__max_depth": randint(1, 6),
"gradientboostingregressor__learning_rate": loguniform(0.01, 0.3),
"gradientboostingregressor__n_estimators": randint(100, 800),
"gradientboostingregressor__min_samples_leaf": randint(1, 50),
"gradientboostingregressor__subsample": uniform(0.5, 0.5),
"gradientboostingregressor__loss": ["squared_error", "absolute_error", "huber"],
}
start = time.perf_counter()
search = RandomizedSearchCV(make_boosted(), param_distributions, n_iter=40, cv=cv,
scoring="neg_mean_absolute_error", n_jobs=-1, random_state=42)
search.fit(X_train, y_train)
print(f"Tried 40 settings in {time.perf_counter() - start:.0f} s")
for name, value in search.best_params_.items():
value = round(value, 3) if isinstance(value, float) else value
print(f" {name.split('__')[1]}: {value}")
print(f"Best validation MAE: {-search.best_score_:.3f}")
Output:
Tried 40 settings in 125 s
learning_rate: 0.08
loss: huber
max_depth: 3
min_samples_leaf: 14
n_estimators: 180
subsample: 0.629
Best validation MAE: 4.336
The ranges, explained
| Code | Means | Why this range |
randint(1, 6) | A whole number from 1 to 5 (the top is excluded) | Depths the validation curve says are sensible |
loguniform(0.01, 0.3) | A number between 0.01 and 0.3, spread evenly on a log scale | 0.01 to 0.03 is as big a change as 0.1 to 0.3, so each "tenfold" gets equal attention |
randint(100, 800) | 100 to 799 trees | |
min_samples_leaf: randint(1, 50) | New. The smallest group of orders a tree's final answer may be based on | Stops trees making rules from 1 or 2 odd orders, another brake on overfitting |
subsample: uniform(0.5, 0.5) | New. A number from 0.5 to 1.0 (start 0.5, width 0.5): the share of orders each tree sees | Each tree learning from a random 50–100% of orders adds healthy variety |
loss: a list | New. Picked at random from the three options | What "error" means while training (see below) |
n_iter=40 | Try 40 random combinations | More = better chance of a great setting, but slower |
split('__')[1] in the print | Keeps only the part after the double underscore, so gradientboostingregressor__loss prints as loss |
What is `loss="huber"`?
The loss is the error the model tries to shrink while it trains (Part 2 introduced squared error, the default). Squared error punishes big misses very hard, so a few freak orders, like a rider who got lost, can pull the trees around. Huber loss behaves like squared error for small misses and like absolute error for big ones, so it's calmer about freak orders. Since we judge the model by MAE, that's a sensible pick, and the search found it on its own.
Grid or random?
| Grid search | Random search | |
| Tries | Every combination | A fixed number of random ones |
| Good for | 2–3 hyperparameters with a few values each | Many hyperparameters, or wide ranges |
| Cost | Grows fast: one more hyperparameter with 4 values = 4× the time | You choose it with n_iter |
| Our result | 4.358 after 48 settings | 4.336 after 40 settings |
The two winners look completely different (depth 2 with 400 trees vs depth 3 with 180 trees and Huber loss) but score within 0.02 minutes of each other. That's typical: there's usually a wide plateau of good settings, not one perfect peak. Once you're on the plateau, more searching mostly finds luck.
We'll carry the grid winner forward. Its settings are simpler to explain and reproduce, and the 0.02-minute difference is far smaller than the fold-to-fold spread (about 0.09).
Step 5: Unlock the test set, once, and compare honestly
On the test set, tuning cuts gradient boosting's error from 4.61 to 4.50 minutes, but linear regression scores 4.49. The tuned model is genuinely better than the default one; against linear regression it's a tie. It does better on three of four hard groups of orders, but each group is too small to prove it.
The searches are finished, so now, and only now, we score the candidates on the 600 locked test orders:
tuned = grid.best_estimator_
candidates = {"Linear (Part 3)": linear, "Boosted, default": default_boosted,
"Boosted, grid-tuned": tuned, "Boosted, random-tuned": search.best_estimator_}
for name, model in candidates.items():
print(f"{name:22s} test MAE {mean_absolute_error(y_test, model.predict(X_test)):.2f}")
Output:
Linear (Part 3) test MAE 4.49
Boosted, default test MAE 4.61
Boosted, grid-tuned test MAE 4.50
Boosted, random-tuned test MAE 4.51
Cross-validation promised the tuned model would beat linear regression by about 0.18 minutes. On the test set it's 0.01 minutes behind. Which do we believe?
Is a difference real, or luck?
A test set is a sample of 600 orders. A different 600 would give slightly different scores. To see how much, we can bootstrap: draw 600 orders at random with replacement from the test set (some orders twice, some not at all), compare the two models on that sample, and repeat 2,000 times.
def compare(model_a, model_b, rounds=2000):
"""How much better is model_b than model_a on the test set? Returns a 95% range."""
err_a = np.abs(model_a.predict(X_test) - y_test.values)
err_b = np.abs(model_b.predict(X_test) - y_test.values)
rng = np.random.default_rng(0)
diffs = []
for _ in range(rounds):
idx = rng.integers(0, len(err_a), len(err_a))
diffs.append(err_a[idx].mean() - err_b[idx].mean())
return np.percentile(diffs, [2.5, 97.5]).round(2)
print("Tuned vs default, minutes saved:", compare(default_boosted, tuned))
print("Tuned vs linear, minutes saved: ", compare(linear, tuned))
Output:
Tuned vs default, minutes saved: [0.01 0.21]
Tuned vs linear, minutes saved: [-0.19 0.17]
| Code | What it does |
err_a, err_b | Each model's error on every test order, in minutes |
rng.integers(0, len(err_a), len(err_a)) | 600 random positions between 0 and 599; repeats allowed |
err_a[idx].mean() - err_b[idx].mean() | How many minutes model B saves on this resample. Using the same orders for both models keeps the comparison fair. |
np.percentile(diffs, [2.5, 97.5]) | The range that holds the middle 95% of the 2,000 results |
How to read the ranges:
- Tuned vs default: 0.01 to 0.21 minutes saved. The whole range is above zero, so tuning really did help, if only a little.
- Tuned vs linear: −0.19 to +0.17. The range comfortably includes zero. Neither model is reliably better. The 0.18 that cross-validation suggested sits at the very edge of what's plausible.
This is the most important lesson in this tutorial: a single score difference smaller than the noise is not a result. Many real projects "improve" a model by 1% on one test set and celebrate luck.
Where the models differ
An overall tie can hide real differences. Part 3 and Part 4 flagged the hardest orders; let's compare the models there:
cleaned_test = clean_orders(X_test)
hard_groups = {
"Old Town": cleaned_test["city_zone"] == "Old Town",
"Rain at peak hour": (cleaned_test["is_raining"] == 1) & (cleaned_test["is_peak_hour"] == 1),
"Prep time missing": cleaned_test["prep_time_min"].isna(),
"Longer than 10 km": cleaned_test["distance_km"] > 10,
}
rows = []
for group, mask in hard_groups.items():
row = {"group": group, "orders": int(mask.sum())}
for name, model in [("linear", linear), ("default", default_boosted), ("tuned", tuned)]:
errors = np.abs(model.predict(X_test[mask.values]) - y_test[mask.values])
row[name] = round(errors.mean(), 2)
rows.append(row)
print(pd.DataFrame(rows).to_string(index=False))
Output:
group orders linear default tuned
Old Town 82 5.75 5.59 5.23
Rain at peak hour 71 4.30 4.73 4.39
Prep time missing 51 6.55 6.50 6.27
Longer than 10 km 94 4.63 4.73 4.42
maskis a column ofTrue/False, one per test order;X_test[mask.values]keeps only that group's orders.- The tuned model is best in three groups, most clearly Old Town (0.5 minutes better than linear), the zone Part 3 found hardest.
- But these groups have only 51–94 orders. Bootstrapping Old Town alone gives a range of −0.24 to +1.34 minutes: promising, not proven.
The decision
| Question | Answer |
| Did tuning help gradient boosting? | Yes: 4.61 → 4.50, and the bootstrap range is above zero |
| Is the tuned model more accurate than linear regression overall? | No evidence either way: a tie |
| Is it better on the hard orders? | Better in 3 of 4 groups, including Old Town, but the groups are small |
| Does it cost more to run? | Hardly: about 11 ms per prediction vs 10 ms |
We'll deploy the tuned model in Step 7, because it ties overall and does better on the orders customers complain about most. We'll keep the linear model file so we can switch back in seconds, and Part 4's monitoring-by-group will show within weeks whether the Old Town improvement is real. That's a sensible engineering call, not a proof, and your reports should say so.
Step 6: Choose a decision threshold: the promise buffer
A prediction is an average guess, so about half of orders arrive later than predicted. To promise a time customers can trust, add a buffer. Chosen on validation predictions, a 7-minute buffer gets 89.9% of orders there on time; on the locked test set it scores 88.5%.
Why a prediction isn't a promise
Our model predicts the typical delivery time. Real deliveries scatter around it, so if the app promised exactly the prediction, about half the customers would be disappointed. Part 1 met the same idea for classification: the model gives a probability, and you choose the threshold that turns it into a decision. Here the decision is the promise, and the threshold is how many minutes to add.
The buffer is a setting we choose, so it's tuned like a hyperparameter: on validation data, never on the test set.
Get honest validation predictions
val_pred = cross_val_predict(make_boosted(**{k.split("__")[1]: v for k, v in grid.best_params_.items()}),
X_train, y_train, cv=cv, n_jobs=-1)
for buffer in [0, 2, 4, 6, 7, 8, 10]:
on_time = (y_train.values <= val_pred + buffer).mean()
print(f"buffer {buffer:2d} min on time {on_time:.1%} average promise {np.mean(val_pred + buffer):.1f} min")
Output:
buffer 0 min on time 49.7% average promise 40.1 min
buffer 2 min on time 64.2% average promise 42.1 min
buffer 4 min on time 76.8% average promise 44.1 min
buffer 6 min on time 85.9% average promise 46.1 min
buffer 7 min on time 89.9% average promise 47.1 min
buffer 8 min on time 93.2% average promise 48.1 min
buffer 10 min on time 96.9% average promise 50.1 min
| Code | What it does |
cross_val_predict(...) | Runs the 5 folds and returns, for every training order, the prediction made by the model that didn't train on it. These are "out-of-fold" predictions: as honest as validation scores. |
{k.split("__")[1]: v for k, v in grid.best_params_.items()} | Turns the grid's long names into plain ones (max_depth=2, ...) to pass to make_boosted |
y_train.values <= val_pred + buffer | True for each order that arrived by the promised time |
.mean() | The share of True values: the on-time rate |
{on_time:.1%} | Formats 0.899 as 89.9% |
Why not use grid.best_estimator_.predict(X_train)? That model trained on these very orders, so its predictions are too good. The buffer would come out too small, and customers would find out.
Choosing
There's no formula for the right buffer. It's a business decision the curve makes visible:
- +0 minutes: honest-looking promises, but half of customers wait longer than promised.
- +10 minutes: 97% on time, but every customer is told 10 minutes more than typical, and some will order elsewhere.
- +7 minutes reaches the 90% target that many delivery teams aim for. Each extra minute after that buys less: going from +7 to +10 adds 7 percentage points for 3 more minutes, while +4 to +7 bought 13.
We choose 7 minutes. Now, and only now, check it on the locked test set:
BUFFER = 7
test_on_time = (y_test.values <= tuned.predict(X_test) + BUFFER).mean()
print(f"Test set, buffer {BUFFER}: on time {test_on_time:.1%}")
Test set, buffer 7: on time 88.5%
Very close to the 89.9% the validation data promised: the buffer generalises. (A difference of 1.4 points is well within luck for 600 orders.)
A better buffer later: one buffer for every order is crude. Old Town orders are harder to predict, so they could use a bigger buffer. Gradient boosting can also predict the 90th percentile directly with loss="quantile", alpha=0.9. Both are good practice-project extensions.Step 7: Redeploy the tuned model
Save the tuned pipeline, point the Part 4 API at it, bump the model version and add the promise time. The old test for 63.8 minutes now fails, which is exactly what tests are for: you update it on purpose, and 7 tests pass.
Save the tuned pipeline
At the end of tune_delivery.py:
joblib.dump(tuned, "delivery_pipeline_tuned.joblib")
tuned is grid.best_estimator_: the whole pipeline (cleaning, preprocessing and the tuned gradient boosting model), refitted on all 2,400 training orders. Copy the file into your Part 4 project folder. Keep the old **delivery_pipeline.joblib** there too: it's your way back.
Update `app.py`
Only a few lines change. Everything else, including all the input checks, stays exactly as in Part 4:
"""Delivery ML Series, Part 5: the Part 4 API, now serving the tuned model."""
import math # NEW
import joblib
# ... the other imports are unchanged ...
MODEL_VERSION = "part5-gb-tuned-v1" # CHANGED
PROMISE_BUFFER_MIN = 7 # NEW: chosen in Part 5, Step 6: about 90% of orders arrive on time
pipeline = joblib.load("delivery_pipeline_tuned.joblib") # CHANGED
app = FastAPI(title="Delivery Time API", version="2.0") # CHANGED
# ... class Order is unchanged ...
class Prediction(BaseModel):
predicted_minutes: float
promised_minutes: int # NEW
model_version: str
# ... /health and the input checks in /predict are unchanged ...
minutes = float(pipeline.predict(row)[0])
promised = math.ceil(minutes + PROMISE_BUFFER_MIN) # NEW
return Prediction(predicted_minutes=round(minutes, 1), promised_minutes=promised, # CHANGED
model_version=MODEL_VERSION)
| Code | What it does |
MODEL_VERSION = "part5-gb-tuned-v1" | Every response and log line now says which model made it. Part 4's monitoring compares errors by version. |
PROMISE_BUFFER_MIN = 7 | The buffer from Step 6, written once as a named constant, with a comment saying where it came from |
promised_minutes: int | A new field in the response. Existing apps that only read predicted_minutes keep working. |
math.ceil(minutes + PROMISE_BUFFER_MIN) | Rounds up to a whole minute: 68.8 + 7 = 75.8 → 76. Promises should never be rounded down. |
version="2.0" | The API's own version, shown on /docs. The response gained a field, so it's a new version. |
Run the tests and watch one fail
Run Part 4's tests unchanged:
.F....
FAILED test_app.py::test_predict_matches_part3 - assert 68.8 == 63.8
1 failed, 5 passed
Good! The test was written to catch any change in the answer for the rainy biryani, and the model really has changed its answer: the tuned model predicts 68.8 minutes instead of 63.8. All five input-checking tests still pass, so the safety rules survived the swap.
A failing test after a deliberate change is a prompt to ask, "Is this change what I wanted?" Here it is (Step 5 explains why we accept it), so we update the test on purpose, and add one for the new field:
def test_predict_matches_part5():
response = client.post("/predict", json=MESSY_ORDER)
assert response.status_code == 200
body = response.json()
assert body["predicted_minutes"] == 68.8
assert body["promised_minutes"] == 76
assert body["model_version"] == "part5-gb-tuned-v1"
def test_promise_is_always_later_than_prediction():
for distance in [1, 5, 15, 30]:
body = client.post("/predict", json={**MESSY_ORDER, "distance_km": distance}).json()
assert body["promised_minutes"] >= body["predicted_minutes"] + 7
Replace the old test_predict_matches_part3 with these two and run pytest: 7 passed.
The second test checks a rule rather than one number: whatever the distance, the promise is at least 7 minutes after the prediction. Rule tests keep working when you retrain the model next month; number tests have to be updated each time.
Try it
Start the server (uvicorn app:app --reload) and send the rainy biryani from /docs:
{
"predicted_minutes": 68.8,
"promised_minutes": 76,
"model_version": "part5-gb-tuned-v1"
}
The app can now say "Arriving by 21:31" (20:15 + 76 minutes) and keep that promise about 9 times in 10.
Rolling back
If monitoring shows the tuned model doing worse, go back by changing two lines: the file name in joblib.load and MODEL_VERSION. That's why we kept the old file. (Docker users: rebuild the image as in Part 4.)
Using Part 6? Part 6 explains whichever pipeline the API loads. Its numbers were written for the Part 3 linear model; with the tuned model you'll see different, but equally additive, explanations.
Theory break: how to judge a tuned model
A tuned model's best validation score is always a little too optimistic, because you picked it as the best of many. Judge tuning with five questions: did the test set stay locked, is the gain bigger than the noise, how optimistic was the search, is there room left to improve at all, and is the simpler model just as good?
1. Did the test set stay locked?
Count how many times you looked at the test set while making choices. In this tutorial: zero between Step 1 and Step 5, then once for the models and once for the buffer, both after the choices were made. If you catch yourself re-running a search because the test score disappointed you, the test set has become a validation set, and you need a fresh one.
2. Is the gain bigger than the noise?
Two kinds of noise matter:
| Noise | How to see it | In this tutorial |
| Between folds | The spread column (std_test_score) | About 0.07–0.09 minutes, larger than the gap between the top 5 settings |
| In the test set | Bootstrap range (Step 5) | About ±0.18 minutes for 600 orders |
If an improvement is smaller than these, report it as a tie.
3. How optimistic was the search? (the winner's curse)
Imagine 48 students of equal ability taking the same practice exam. Someone gets the top mark, partly by luck, and on the real exam they'll probably score a bit lower. Hyperparameter search works the same way: the best of 48 validation scores is the best and the luckiest.
Compare validation and test scores:
| Model | Validation MAE (5-fold) | Test MAE | Change |
| Linear regression (nothing tuned) | 4.53 | 4.49 | −0.04 |
| Gradient boosting, best of 48 | 4.36 | 4.50 | +0.14 |
The untuned model did about as well on the test set as validation predicted. The tuned one did worse. Part of that is luck in our 600 test orders; part is the winner's curse. The more settings you try, the bigger the curse, which is one reason not to search thousands of settings on a small dataset.
The thorough fix is nested cross-validation: run the whole search inside each fold of an outer cross-validation, so every score comes from data the search never saw. It costs 5× the time, and it's worth knowing the name: you'll see it in research papers.
4. Is there room left to improve?
Part 2 introduced irreducible error: randomness no model can predict. Part 3's delivery_data.py adds random noise with a typical size of 5 minutes to every delivery. For that kind of noise, even a perfect model would have an MAE of about 4.0 minutes (roughly 0.8 × 5).
Our models score 4.36–4.50. So at most about half a minute is left to win, and some of that is lost for good to the missing prep times Part 3 imputed. When a model is this close to the floor, no amount of tuning will produce a big jump. Better data (real prep times, rider GPS) would help more than better settings.
5. Is the simpler model just as good?
When two models tie, the simpler one is usually the better choice: easier to explain, faster to retrain, fewer things to break. Here the choice was close, and we chose the tuned model for its edge on the hard orders (Step 5). Choosing linear regression would also have been defensible. What matters is that the reasoning is written down.
| Check | Question | Our result |
| Locked test set | Did the test set influence any choice? | No |
| Noise | Is the gain bigger than fold spread and the bootstrap range? | vs default: yes, just. vs linear: no |
| Winner's curse | How much worse is test than validation? | +0.14 for the tuned model |
| Room left | How far above the irreducible error floor? | About 0.4–0.5 minutes |
| Simplicity | Would a simpler model do as well? | Overall, yes. On hard orders, maybe not |
The complete code
One script, **tune_delivery.py**, runs every step of this tutorial: baseline, hand tuning, cross-validation, grid search, random search, the honest test, the promise buffer, and saving the tuned pipeline. It takes about 3–4 minutes on a 2-core machine; the random search is the slowest part.
Put it next to Part 3's delivery_data.py and delivery_features.py. Each section prints a STEP heading so you can match its output to this article.
"""Delivery ML Series, Part 5: tune the gradient boosting model."""
import time
import joblib
import numpy as np
import pandas as pd
from scipy.stats import loguniform, randint, uniform
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import (GridSearchCV, KFold, RandomizedSearchCV, cross_val_predict,
cross_validate, train_test_split)
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import FunctionTransformer, OneHotEncoder, StandardScaler
from delivery_data import make_messy_orders
from delivery_features import clean_orders
# ---- Step 1: same data, same split as Part 3 ----
orders = make_messy_orders().drop_duplicates()
X = orders.drop(columns="delivery_min")
y = orders["delivery_min"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
numeric_features = ["distance_km", "prep_time_min", "rider_experience_months", "is_raining",
"is_peak_hour", "is_weekend", "distance_x_rain", "distance_x_peak",
"distance_x_rain_x_peak"]
categorical_features = ["city_zone", "cuisine"]
def make_preprocess():
return ColumnTransformer([
("num", make_pipeline(SimpleImputer(strategy="median"), StandardScaler()), numeric_features),
("cat", make_pipeline(SimpleImputer(strategy="most_frequent"),
OneHotEncoder(handle_unknown="ignore")), categorical_features),
])
def make_boosted(**settings):
"""A Part 3 gradient boosting pipeline with the hyperparameters you choose."""
return make_pipeline(FunctionTransformer(clean_orders), make_preprocess(),
GradientBoostingRegressor(random_state=42, **settings))
linear = make_pipeline(FunctionTransformer(clean_orders), make_preprocess(), LinearRegression())
linear.fit(X_train, y_train)
default_boosted = make_boosted().fit(X_train, y_train)
print("STEP 1")
for name, model in [("Linear", linear), ("Boosted (default)", default_boosted)]:
train_mae = mean_absolute_error(y_train, model.predict(X_train))
print(f"{name:18s} train MAE {train_mae:.2f}")
# ---- Step 2: one validation split, by hand ----
print("STEP 2")
X_fit, X_val, y_fit, y_val = train_test_split(X_train, y_train, test_size=0.2, random_state=0)
for depth in [1, 2, 3, 4, 5, 6]:
model = make_boosted(max_depth=depth).fit(X_fit, y_fit)
print(f"max_depth={depth} validation MAE {mean_absolute_error(y_val, model.predict(X_val)):.2f}")
# ---- Theory break: cross-validation ----
print("CV")
cv = KFold(n_splits=5, shuffle=True, random_state=42)
for depth in [1, 2, 3, 4, 5, 6, 8]:
scores = cross_validate(make_boosted(max_depth=depth), X_train, y_train, cv=cv,
scoring="neg_mean_absolute_error", return_train_score=True, n_jobs=-1)
print(f"max_depth={depth} train {-scores['train_score'].mean():.2f} "
f"validation {-scores['test_score'].mean():.2f}")
# ---- Step 3: grid search ----
print("STEP 3")
param_grid = {
"gradientboostingregressor__max_depth": [1, 2, 3, 4],
"gradientboostingregressor__learning_rate": [0.03, 0.05, 0.1, 0.2],
"gradientboostingregressor__n_estimators": [100, 200, 400],
}
start = time.perf_counter()
grid = GridSearchCV(make_boosted(), param_grid, cv=cv, scoring="neg_mean_absolute_error", n_jobs=-1)
grid.fit(X_train, y_train)
print(f"Tried {len(grid.cv_results_['params'])} settings in {time.perf_counter() - start:.0f} s")
print("Best settings:", grid.best_params_)
print(f"Best validation MAE: {-grid.best_score_:.3f}")
linear_cv = cross_validate(linear, X_train, y_train, cv=cv, scoring="neg_mean_absolute_error")
print(f"Linear validation MAE: {-linear_cv['test_score'].mean():.3f}")
results = pd.DataFrame(grid.cv_results_)
top = results.sort_values("rank_test_score").head(5)
print(pd.DataFrame({
"max_depth": top["param_gradientboostingregressor__max_depth"],
"learning_rate": top["param_gradientboostingregressor__learning_rate"],
"n_estimators": top["param_gradientboostingregressor__n_estimators"],
"val_MAE": -top["mean_test_score"].round(3),
"spread": top["std_test_score"].round(3),
}).to_string(index=False))
# ---- Step 4: random search ----
print("STEP 4")
param_distributions = {
"gradientboostingregressor__max_depth": randint(1, 6),
"gradientboostingregressor__learning_rate": loguniform(0.01, 0.3),
"gradientboostingregressor__n_estimators": randint(100, 800),
"gradientboostingregressor__min_samples_leaf": randint(1, 50),
"gradientboostingregressor__subsample": uniform(0.5, 0.5),
"gradientboostingregressor__loss": ["squared_error", "absolute_error", "huber"],
}
start = time.perf_counter()
search = RandomizedSearchCV(make_boosted(), param_distributions, n_iter=40, cv=cv,
scoring="neg_mean_absolute_error", n_jobs=-1, random_state=42)
search.fit(X_train, y_train)
print(f"Tried 40 settings in {time.perf_counter() - start:.0f} s")
for name, value in search.best_params_.items():
value = round(value, 3) if isinstance(value, float) else value
print(f" {name.split('__')[1]}: {value}")
print(f"Best validation MAE: {-search.best_score_:.3f}")
# ---- Step 5: the test set, once ----
print("STEP 5")
tuned = grid.best_estimator_
candidates = {"Linear (Part 3)": linear, "Boosted, default": default_boosted,
"Boosted, grid-tuned": tuned, "Boosted, random-tuned": search.best_estimator_}
for name, model in candidates.items():
print(f"{name:22s} test MAE {mean_absolute_error(y_test, model.predict(X_test)):.2f}")
def compare(model_a, model_b, rounds=2000):
"""How much better is model_b than model_a on the test set? Returns a 95% range."""
err_a = np.abs(model_a.predict(X_test) - y_test.values)
err_b = np.abs(model_b.predict(X_test) - y_test.values)
rng = np.random.default_rng(0)
diffs = []
for _ in range(rounds):
idx = rng.integers(0, len(err_a), len(err_a))
diffs.append(err_a[idx].mean() - err_b[idx].mean())
return np.percentile(diffs, [2.5, 97.5]).round(2)
print("Tuned vs default, minutes saved:", compare(default_boosted, tuned))
print("Tuned vs linear, minutes saved: ", compare(linear, tuned))
cleaned_test = clean_orders(X_test)
hard_groups = {
"Old Town": cleaned_test["city_zone"] == "Old Town",
"Rain at peak hour": (cleaned_test["is_raining"] == 1) & (cleaned_test["is_peak_hour"] == 1),
"Prep time missing": cleaned_test["prep_time_min"].isna(),
"Longer than 10 km": cleaned_test["distance_km"] > 10,
}
rows = []
for group, mask in hard_groups.items():
row = {"group": group, "orders": int(mask.sum())}
for name, model in [("linear", linear), ("default", default_boosted), ("tuned", tuned)]:
errors = np.abs(model.predict(X_test[mask.values]) - y_test[mask.values])
row[name] = round(errors.mean(), 2)
rows.append(row)
print(pd.DataFrame(rows).to_string(index=False))
# ---- Step 6: choose the promise buffer on validation predictions ----
print("STEP 6")
val_pred = cross_val_predict(make_boosted(**{k.split("__")[1]: v for k, v in grid.best_params_.items()}),
X_train, y_train, cv=cv, n_jobs=-1)
for buffer in [0, 2, 4, 6, 7, 8, 10]:
on_time = (y_train.values <= val_pred + buffer).mean()
print(f"buffer {buffer:2d} min on time {on_time:.1%} average promise {np.mean(val_pred + buffer):.1f} min")
BUFFER = 7
test_on_time = (y_test.values <= tuned.predict(X_test) + BUFFER).mean()
print(f"Test set, buffer {BUFFER}: on time {test_on_time:.1%}")
# ---- Step 7: save for the API ----
joblib.dump(tuned, "delivery_pipeline_tuned.joblib")
print("Saved delivery_pipeline_tuned.joblib")
Run python tune_delivery.py. Every number should match this article except the timings.
Common mistakes
| Mistake | What happens | Fix |
| Choosing settings by test score | The test score becomes optimistic and stops being a fair final exam | Tune with cross-validation on training data; open the test set once at the end |
| Tuning on one validation split | The winner depends on which orders landed in validation (Step 2: depth 2 or depth 4) | Use 5-fold cross-validation |
| Preprocessing before splitting | The imputer and scaler learn from validation folds: leakage | Put cleaning and preprocessing inside the pipeline you search, as here |
Forgetting the step__ prefix | ValueError: Invalid parameter 'max_depth' | Use gradientboostingregressor__max_depth; see the names with pipeline.get_params().keys() |
Reading neg_mean_absolute_error as a positive number | "Best score −4.36" looks like nonsense | Flip the sign: MAE = −score |
| Searching a huge grid on a small dataset | Hours of computing, and a bigger winner's curse | Start with 2–3 important hyperparameters; use random search for more |
| Celebrating a tiny gain | A 0.01-minute "win" that a different test set would reverse | Bootstrap the difference; report ties as ties |
| Choosing a buffer from training predictions | Predictions on seen orders are too good, so the buffer is too small and promises are broken | Use cross_val_predict (out-of-fold predictions) |
| Deploying without updating the version and tests | Nobody can tell which model made which prediction | Change MODEL_VERSION; update tests deliberately |
| Deleting the old model file | No quick way back if the new model misbehaves | Keep it; rollback = two lines |
Practice project: tune the electricity model
Tune a gradient boosting model for the home-electricity data from the earlier practice projects, compare it honestly with linear regression, and choose a bill buffer so that about 90% of homes use less than the estimate. Here, unlike the delivery data, tuning uncovers a real weakness in the linear model.
In Parts 1–4 you predicted monthly electricity use (kWh) for homes in Mumbai, Bengaluru, Chennai and Pune, cleaned the messy smart-meter export in Part 3, and served a linear model as an API in Part 4.
What you start with
energy_data_messy.py(Part 3 practice) andenergy_features.pywithclean_homes(Part 4 practice).- Rebuild the data exactly as in the Part 4 practice: drop duplicates, divide Wh readings (≥ 20,000) by 1,000, then
train_test_split(test_size=0.2, random_state=42). You should have 1,600 training homes and 400 test homes. - The numeric columns:
home_size_sqm,residents,has_solar,works_from_home,appliance_age_years,ac_hours_per_day,is_summer,ac_x_size; the category column:city.
Your task
- 1. Baseline (Step 1). Write a
make_boosted(**settings)factory for the energy pipeline. Compare the training MAE of linear regression and default gradient boosting, and their 5-fold validation MAE (KFold(5, shuffle=True, random_state=42)). - 2. Validation curve (theory break). Cross-validate
max_depthfrom 1 to 6. Which depth is best? Where does the gap between training and validation error get large? - 3. Grid search (Step 3). Use the same 48-setting grid as this tutorial. What wins, and with what validation MAE?
- 4. Test once (Step 5). Score linear, default and tuned on the test set. Bootstrap tuned vs linear. Is it a clear win?
- 5. Look at groups (Step 5). Compare test MAE for homes with and without solar panels, for linear and tuned.
- 6. Bill buffer (Step 6). With
cross_val_predict, find the smallest buffer (in kWh) that keeps about 90% of training homes at or below the estimate. Check it once on the test set. - Stretch: add
estimate_with_buffer_kwhto your Part 4energy_app.py, bump the model version, and update the tests on purpose
Expected results
Your numbers should match these (timings will differ):
| Task | Expected result |
| 1. Training MAE | Linear 32.8 kWh; default boosting 24.0 kWh |
| 1. Validation MAE | Linear 33.1; default boosting 30.1 |
| 2. Best depth | 3 (30.1). Depth 6 fits training homes to 10.7 kWh but validates at 32.3 |
| 3. Grid winner | max_depth=2, learning_rate=0.1, n_estimators=400, validation MAE 29.62 (the same settings as the delivery winner, by coincidence) |
| 4. Test MAE | Linear 33.0; default 31.8; tuned 31.6 |
| 4. Tuned vs linear (bootstrap) | −0.6 to +3.5 kWh saved: probably better, not proven |
| 4. Tuned vs default | −1.0 to +1.3: tuning itself made no clear difference |
| 5. Homes with solar | Linear 43.0, tuned 29.5 kWh |
| 5. Homes without solar | Linear 29.5, tuned 32.4 kWh |
| 6. Buffer | 45 kWh: 89.8% of training homes at or below the estimate; 88.5% on the test set |
Check your thinking
- Task 4: here, switching to gradient boosting helps more than tuning it does. Default and tuned boosting are tied; both beat linear regression by about 1–1.5 kWh. Don't credit the tuning for what the algorithm did.
- Task 5: this is the real story. Solar panels cut a bill by a percentage, but a linear model can only subtract a fixed amount, so it gets solar homes badly wrong. Trees can learn "solar × size" on their own, and the error for solar homes drops from 43.0 to 29.5 kWh. The cost: homes without solar get a little worse (29.5 → 32.4). An overall tie can hide a big shift between groups, which is why you always check them.
- Your Part 4 home (the May home in "Bombay" with solar): linear said 636.0 kWh; the tuned model says 520.4. Which do you trust more for a solar home, given Task 5?
Frequently asked questions
What is hyperparameter tuning?
Choosing the settings a model can't learn by itself, such as tree depth or learning rate, by trying several options and keeping the one with the best validation score. The model's own parameters are still learned from the data as usual.
What is the difference between GridSearchCV and RandomizedSearchCV?
GridSearchCV tries every combination of the values you list. RandomizedSearchCV tries a fixed number of random combinations from ranges you give. Use grid search for 2–3 hyperparameters, random search for more.
Why use cross-validation instead of one validation set?
One validation set is a small sample, so its winner can be luck: in this tutorial, two different splits picked depth 2 and depth 4. Cross-validation uses every training row for validation once and averages the results, which is much more stable.
How many folds should I use?
5 is the usual default and a good balance. Use 10 for small datasets if you can afford the time; 3 if training is very slow.
Can I tune on the test set if I'm careful?
No. Any choice made by looking at the test score makes that score optimistic. Tune with cross-validation on the training data and use the test set once, at the end.
Why didn't tuning make a big difference here?
Because the models are already close to the irreducible error: random noise in delivery times that no model can predict. When that's the case, better data helps more than better settings.
Glossary
| Term | Meaning |
| Hyperparameter | A setting you choose before training, such as max_depth |
| Tuning | Searching for the hyperparameters that give the best validation score |
| Validation data | Data used to choose between settings; never used for training those settings, and not the test set |
| Cross-validation (k-fold) | Splitting training data into k folds and validating on each in turn |
| Fold | One of the k equal parts in cross-validation |
| Validation curve | Training and validation error plotted against one hyperparameter |
| Underfitting / overfitting | Too simple to learn the pattern / so flexible it learns the noise |
| Grid search | Trying every combination from lists of values |
| Random search | Trying random combinations from ranges |
| Out-of-fold prediction | A prediction for a row made by a model that didn't train on it |
| Bootstrap | Resampling the test set with replacement to see how much a score could vary |
| Winner's curse | The best of many validation scores is partly luck, so it tends to be optimistic |
| Nested cross-validation | Running the search inside each fold of an outer cross-validation, for an unbiased score |
| Huber loss | A training loss that treats small errors like squared error and large ones like absolute error |
| Decision threshold | The cut-off that turns a prediction into an action, such as the 7-minute promise buffer |
Series roadmap
| Part | Tutorial | What you'll learn |
| 1 | How to Build Your First Machine Learning Model | Classification, train/test split, confusion matrix, precision and recall |
| 2 | Predict the Exact Delivery Time | Regression, MAE, RMSE, R², interaction features |
| 3 | Clean Messy, Real-World Delivery Data | Missing values, categories, outliers, pipelines |
| 4 | Deploy Your Model as an API | FastAPI, Docker, tests, monitoring |
| 5 | Tune Your Model (you are here) | Hyperparameters, cross-validation, GridSearchCV, RandomizedSearchCV, decision thresholds |
| 6 | Explain Your Model's Predictions | Permutation importance, SHAP, fairness checks |
| 7 | When Simple Isn't Enough: Gradient Boosting | XGBoost/LightGBM, automatic interactions |
| 8 | Add Generative AI: Explain Delays to Customers | Calling an LLM API, prompting, guardrails |
Next up, Part 6: the API now promises "76 minutes", and customers will ask why so long? Part 6 opens up the model with permutation importance and SHAP, splitting a prediction into reasons like "rain +9 minutes, Old Town +8", and adds them to the API.
Conclusion
You can now tune a model the way professionals do, and, just as important, judge whether the tuning worked:
- Hyperparameters are tuned on validation data, never on the test set.
- Cross-validation gives stable scores; a single validation split can pick the wrong winner.
- The validation curve shows underfitting on one side and overfitting on the other; the best setting is the bottom of the U.
- Grid search tries everything; random search covers more ground for the same time. Both found a plateau of equally good settings.
- Test once, and compare with a range. Tuning made gradient boosting better than its default version, and tied it with linear regression overall.
- Turn predictions into decisions deliberately. A 7-minute buffer, chosen on out-of-fold predictions, keeps about 90% of promises.
The most valuable result in this part isn't a lower number. It's knowing when a number is real.
Sources
No sources recorded.
Keep exploring
View all →Basics of AI/ML
Build a Neural Network Without PyTorch. What Changes When It Learns?
Basics of AI/ML
How to Use XGBoost and LightGBM in Python: A Beginner's Guide to Gradient Boosting (Food Delivery ML Series, Part 7)
Basics of AI/ML
How to Explain Machine Learning Predictions in Python: A Beginner's Guide to Feature Importance and SHAP (Delivery ML Series, Part 6)
