Gradient Boosting with XGBoost: Predict Late Deliveries and Explain Every Prediction
Hands-on lab · IDE in your browser

Gradient Boosting with XGBoost: Predict Late Deliveries and Explain Every Prediction

Beat a logistic regression with gradient-boosted trees on messy delivery data, watch extra rounds memorise the training set and stop them early, tune tree shape with a randomised search, compare split-count, gain and permutation importance against a planted noise column, and explain single predictions with per-feature contributions.

Time
50 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit50 min · 5 stepsSession running
4 / 5 steps passingWhy this parcel? · step 5 of 5
boost.py▶ Run✓ Check
# ---------- Step 5: the final model, and why this parcel? ----------def final_model(X_train, y_train):    """Early-stop BEST to find the number of rounds, then fit booster(**BEST, n_estimators=that number) on the    whole training set."""   def explain(model, row):    """Why the model scored one parcel (a one-row DataFrame) as it did: xgboost's per-feature contributions to the    log-odds (predict with pred_contribs=True). Returns {"base": the bias term, "contributions": [(feature,    contribution rounded to 3), ...] largest absolute first}."""   
TerminalOutput

The job

One Swiftline parcel in five arrives late, and customer service wants to warn people before it happens. Lateness comes from combinations: rain at rush hour, one depot at weekends, a driver in their first months. You build the gradient-boosted model that learns them, tune it honestly, and make every prediction explain itself.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Trees against a linear model

    Swiftline's parcels arrive late about one time in five, and the customer service team wants a warning before it happens.

    You writeto_frame()booster()
  2. 2

    Rounds and early stopping

    Boosting adds trees one at a time, and each new tree corrects the mistakes of the ones before it.

    You writeround_curve()early_stopped()
  3. 3

    Tune the trees

    The biggest settings after the learning rate shape each tree: - max_depth sets how many conditions a tree can combine.

    You writetune()
  4. 4

    Which features matter?

    "Which features does the model use?" has three common answers, and they disagree: - Split count: how often the trees split on a feature.

    You writeimportances()
  5. 5

    Why this parcel?

    The service team will act on single predictions: this parcel, this morning.

    You writefinal_model()explain()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Trees against a linear model

Swiftline's parcels arrive late about one time in five, and the customer service team wants a warning before it happens. What makes a parcel late is rarely one thing: rain matters most at rush hour; one depot struggles only at weekends; new drivers are slow, and after a year they aren't. A linear model adds effects up. Gradient-boosted trees learn these combinations.

XGBoost can read pandas categories and missing values directly, so you don't need one-hot encoding, scaling or imputation.

Do this

1. Write to_frame(df): the features, with depot and address_type as the pandas category dtype.

2. Write booster(**params): an XGBClassifier set up for this lab, on one CPU thread.

3. Run it and compare the two cross-validated AUCs.

boost.py, the file you edit107 lines
"""Which Swiftline parcels will arrive late? Gradient-boosted trees on 6,000 past deliveries."""
import warnings

import numpy as np
import pandas as pd
import xgboost as xgb
from sklearn.compose import make_column_transformer
from sklearn.impute import SimpleImputer
from sklearn.inspection import permutation_importance
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import RandomizedSearchCV, StratifiedKFold, cross_val_score, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

warnings.filterwarnings("ignore", category=UserWarning)
TARGET = "late"
CATEGORICAL = ["depot", "address_type"]
FOLDS = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)


# ---------- Step 1: trees against a linear model ----------
def to_frame(df):
    """Features for the models: every column except the target, CATEGORICAL columns as pandas 'category' dtype,
    missing numbers left as NaN (the trees handle them)."""
    # TODO (Step 1): drop TARGET, convert each CATEGORICAL column with .astype("category"), leave NaN alone.
    raise NotImplementedError("Step 1: write to_frame()")


def load():
    """(X_train, X_test, y_train, y_test): 80/20, stratified, random_state=0."""
    df = pd.read_csv("parcels.csv")
    return train_test_split(to_frame(df), df[TARGET], test_size=0.2, stratify=df[TARGET], random_state=0)


def logistic():
    """The linear baseline: one-hot categories, median-imputed and scaled numbers, logistic regression."""
    numeric = make_pipeline(SimpleImputer(strategy="median"), StandardScaler())
    prep = make_column_transformer((OneHotEncoder(handle_unknown="ignore"), CATEGORICAL),
                                   (numeric, lambda X: [c for c in X.columns if c not in CATEGORICAL]))
    return make_pipeline(prep, LogisticRegression(max_iter=2000))


def booster(**params):
    """An XGBClassifier that reads pandas categories (enable_categorical=True, tree_method="hist"), on one CPU
    thread (n_jobs=1), random_state=0, eval_metric="logloss", with any extra params passed through."""
    # TODO (Step 1): return xgb.XGBClassifier(enable_categorical=True, tree_method="hist", n_jobs=1, random_state=0,
    #                                  eval_metric="logloss", **params)
    raise NotImplementedError("Step 1: write booster()")


def cv_auc(model, X, y):
    """Mean ROC AUC over FOLDS, a float rounded to 3."""
    return round(float(cross_val_score(model, X, y, cv=FOLDS, scoring="roc_auc").mean()), 3)


# ---------- Step 2: learning rate, rounds and early stopping ----------
def round_curve(X_train, y_train, learning_rate, rounds=1000):
    """Hold out 25% of the training set (stratified, random_state=0), fit booster(n_estimators=rounds,
    learning_rate=learning_rate, max_depth=6) with that holdout as eval_set, and read the log loss after every
    round. Returns {"best_round": the round (from 1) with the lowest holdout log loss, "best_val": that loss,
    "last_val": holdout loss after the final round, "last_train": training loss after the final round},
    losses rounded to 4."""
    raise NotImplementedError("round_curve() arrives in Step 2")


def early_stopped(X_train, y_train, **params):
    """booster(n_estimators=2000, early_stopping_rounds=50, **params) fitted on 75% of the training set with the
    same 25% holdout as eval_set. Returns the fitted model (its best_iteration says when to stop)."""
    raise NotImplementedError("early_stopped() arrives in Step 2")


# ---------- Step 3: tune the trees ----------
SPACE = {"max_depth": [2, 3, 4, 5, 6], "min_child_weight": [1, 3, 5, 10], "subsample": [0.6, 0.8, 1.0],
         "colsample_bytree": [0.6, 0.8, 1.0]}


def tune(X_train, y_train, n_iter=12):
    """RandomizedSearchCV over SPACE for booster(n_estimators=300, learning_rate=0.05): n_iter candidates,
    3-fold StratifiedKFold (shuffle, random_state=0), scoring roc_auc, random_state=0, n_jobs=1. Fitted."""
    raise NotImplementedError("tune() arrives in Step 3")


BEST = {"max_depth": 3, "min_child_weight": 3, "subsample": 0.8, "colsample_bytree": 1.0, "learning_rate": 0.05}


# ---------- Step 4: which features matter? ----------
def importances(model, X_val, y_val):
    """Three rankings of the features, most important first, each a list of names:
    "splits": how often the trees split on the feature (booster importance_type="weight"),
    "gain": the average loss reduction of those splits (importance_type="gain"),
    "permutation": how much ROC AUC on (X_val, y_val) drops when the feature's values are shuffled
    (permutation_importance, n_repeats=5, random_state=0). Features the trees never used rank last."""
    raise NotImplementedError("importances() arrives in Step 4")


# ---------- Step 5: the final model, and why this parcel? ----------
def final_model(X_train, y_train):
    """Early-stop BEST to find the number of rounds, then fit booster(**BEST, n_estimators=that number) on the
    whole training set."""
    raise NotImplementedError("final_model() arrives in Step 5")


def explain(model, row):
    """Why the model scored one parcel (a one-row DataFrame) as it did: xgboost's per-feature contributions to the
    log-odds (predict with pred_contribs=True). Returns {"base": the bias term, "contributions": [(feature,
    contribution rounded to 3), ...] largest absolute first}."""
    raise NotImplementedError("explain() arrives in Step 5")
Provided for you:parcels.csvtry_it.py

Frequently asked questions

How many trees should a gradient boosting model have?

As many as keep improving a held-out score. Use early stopping: train with a validation set and stop when its loss has not improved for a set number of rounds. A lower learning rate needs more trees but usually generalises a little better.

Which XGBoost feature importance should I trust?

Permutation importance on held-out data is the most honest. Split counts favour features with many distinct values, even random ones, and gain can overstate features used in a few early splits.

How do I explain a single XGBoost prediction?

Ask for per-feature contributions (SHAP values), with predict(..., pred_contribs=True) in XGBoost. The contributions plus a base value add up to the prediction's log-odds.

Gradient boosting in practice with XGBoost

Gradient-boosted trees are the strongest general-purpose models for tabular data. They learn interactions and thresholds that linear models miss, and they handle categories and missing values natively. They also overfit readily, and their default feature importance can mislead. In this lab you use XGBoost on delivery data: compare it with logistic regression, find the right number of rounds with early stopping, tune tree depth and sampling with a randomised search, compare three kinds of feature importance, and explain single predictions with SHAP-style contributions.