Skip to content

Boosting: XGBoost, LightGBM, CatBoost

Chapter 20 14 code blocks 2639 words

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

The scene: learning from the mistakes

In Chapter 19 we grew a hundred trees in parallel. Each worked independently, then they voted.

Now a different idea: we grow the trees in sequence, and every new tree looks at the mistakes of the ones before it.

Imagine a teacher marking a test. The first student got 60% right. The teacher tells the second student: "the first one got these questions wrong: focus on exactly those."

That's boosting. And in practice it's the method that gives the best results on tabular data.

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), narx_usd monthly rent in USD. The target ijaraga_berildi_30k means rented within 30 days.

Boosting by hand: 20 lines of code

To understand the idea, we write it ourselves. With regression, because it's simpler.

python31 lines
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.tree import DecisionTreeRegressor
from sklearn.metrics import root_mean_squared_error

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

# Step 1: a simple prediction, the mean for everyone
pred_tr = np.full(len(y_tr), y_tr.mean())
pred_te = np.full(len(y_te), y_tr.mean())
trees = []
lr = 0.1

print(f"{'step':>6} {'train RMSE':>12} {'test RMSE':>11}")
print("-" * 32)
print(f"{0:>6} {root_mean_squared_error(y_tr, pred_tr):>12.1f} "
      f"{root_mean_squared_error(y_te, pred_te):>11.1f}")

for step in range(1, 101):
    resid = y_tr - pred_tr                            # the CURRENT error
    d = DecisionTreeRegressor(max_depth=3, random_state=42).fit(X_tr, resid)
    trees.append(d)
    pred_tr = pred_tr + lr * d.predict(X_tr)          # fix the error a little
    pred_te = pred_te + lr * d.predict(X_te)
    if step in (1, 5, 20, 50, 100):
        print(f"{step:>6} {root_mean_squared_error(y_tr, pred_tr):>12.1f} "
              f"{root_mean_squared_error(y_te, pred_te):>11.1f}")
Output
  step   train RMSE   test RMSE
--------------------------------
     0        229.8       228.6
     1        211.7       210.5
     5        157.4       158.0
    20         85.8        94.0
    50         69.4        84.0
   100         63.4        84.5

That's the whole of boosting. Three lines matter:

  1. resid = y - pred: the current error
  2. d.fit(X, resid): the new tree predicts the error
  3. pred += lr * d.predict(X): we fix the error a little

The tree learns the ERROR, not the target

This is the most important, and most often confused, point about boosting.

In a Random Forest every tree predicts y. In boosting, every tree after the first predicts the residual (y - pred).

That's why boosting trees are shallow (depth 3-6): they don't solve the whole task, they just shrink the remaining error a little.

Checking against sklearn

python6 lines
from sklearn.ensemble import GradientBoostingRegressor

sk = GradientBoostingRegressor(n_estimators=100, learning_rate=0.1,
                               max_depth=3, random_state=42).fit(X_tr, y_tr)
print(f"Our hand-written version: test RMSE = {root_mean_squared_error(y_te, pred_te):.1f}")
print(f"sklearn GradientBoosting: test RMSE = {root_mean_squared_error(y_te, sk.predict(X_te)):.1f}")
Output
Our hand-written version: test RMSE = 84.5
sklearn GradientBoosting: test RMSE = 84.3

A close result, so we implemented the idea correctly. The remaining gap comes from sklearn's extra optimisations.

The modern trio

python8 lines
show(pd.DataFrame([
    {"Library": "XGBoost", "Year": 2014, "Main strength": "The first popular one; regularisation built in",
     "Weakness": "Doesn't handle categories itself"},
    {"Library": "LightGBM", "Year": 2017, "Main strength": "THE FASTEST; wins on large data",
     "Weakness": "Prone to overfitting on small data"},
    {"Library": "CatBoost", "Year": 2017, "Main strength": "Handles categories ITSELF; good defaults",
     "Weakness": "Slower to train"},
]))
LibraryYearMain strengthWeakness
XGBoost2 014The first popular one; regularisation built inDoesn't handle categories itself
LightGBM2 017THE FASTEST; wins on large dataProne to overfitting on small data
CatBoost2 017Handles categories ITSELF; good defaultsSlower to train
python19 lines
import time
from xgboost import XGBRegressor
from lightgbm import LGBMRegressor
from catboost import CatBoostRegressor
from sklearn.ensemble import RandomForestRegressor

print(f"{'model':<26} {'CV R2':>9} {'±':>8} {'time':>8}")
print("-" * 54)
for name, m in [
    ("RandomForest (Chapter 19)", RandomForestRegressor(n_estimators=200, random_state=42)),
    ("GradientBoosting", GradientBoostingRegressor(random_state=42)),
    ("XGBoost", XGBRegressor(n_estimators=200, verbosity=0, random_state=42)),
    ("LightGBM", LGBMRegressor(n_estimators=200, verbose=-1, random_state=42)),
    ("CatBoost", CatBoostRegressor(iterations=200, verbose=0, random_seed=42)),
]:
    t0 = time.perf_counter()
    b = cross_val_score(m, X, y, cv=5, scoring="r2")
    dt = time.perf_counter() - t0
    print(f"{name:<26} {b.mean():>9.4f} {b.std():>8.4f} {dt:>7.1f}s")
Output
model                          CV R2        ±     time
------------------------------------------------------
RandomForest (Chapter 19)     0.8524   0.0105     1.5s
GradientBoosting              0.8656   0.0122     0.3s
XGBoost                       0.8242   0.0108     1.0s
LightGBM                      0.8307   0.0107     0.2s
CatBoost                      0.8577   0.0127     0.8s

Read the results with the Chapter 10 rule: are the gaps bigger than the fold spread? Our data is small and easy, so the gaps may well be small.

Boosting wins on Kaggle, but on YOUR data too?

"XGBoost is the best model" is a widespread saying. It really has won a lot of Kaggle competitions.

But Kaggle datasets are large (hundreds of thousands of rows) and complex. On your 1200-row data, a linear model may give the same result, and it's faster, understandable and breaks less.

The rule doesn't change: measure, compare with the Chapter 10 rule, and if the gap is within the spread, take the simpler model.

The most important parameter: learning_rate

python7 lines
from xgboost import XGBRegressor
print(f"{'learning_rate':>14} {'n_estimators':>13} {'CV R2':>9}")
print("-" * 40)
for lr_ in [0.01, 0.05, 0.1, 0.3, 1.0]:
    b = cross_val_score(XGBRegressor(n_estimators=200, learning_rate=lr_, verbosity=0,
                                     random_state=42), X, y, cv=5, scoring="r2")
    print(f"{lr_:>14} {200:>13} {b.mean():>9.4f}")
Output
 learning_rate  n_estimators     CV R2
----------------------------------------
          0.01           200    0.8365
          0.05           200    0.8478
           0.1           200    0.8387
           0.3           200    0.8242
           1.0           200    0.7605

learning_rate and n_estimators are linked: a smaller learning_rate needs more trees.

python7 lines
print("A small learning_rate + many trees:")
print(f"{'lr':>6} {'trees':>11} {'CV R2':>9}")
print("-" * 28)
for lr_, n in [(0.3, 50), (0.1, 150), (0.05, 300), (0.01, 1500)]:
    b = cross_val_score(XGBRegressor(n_estimators=n, learning_rate=lr_, verbosity=0,
                                     random_state=42), X, y, cv=5, scoring="r2")
    print(f"{lr_:>6} {n:>11} {b.mean():>9.4f}")
Output
A small learning_rate + many trees:
    lr       trees     CV R2
----------------------------
   0.3          50    0.8358
   0.1         150    0.8424
  0.05         300    0.8441
  0.01        1500    0.8451

In boosting, raising n_estimators DOES overfit

We said in Chapter 19: in a Random Forest, adding trees is safe.

In boosting it's the opposite. Every new tree shrinks the error further, and eventually it starts "fixing" the noise in the training data too.

That's why boosting uses early_stopping: training stops when the validation score stops improving.

python7 lines
X_tr2, X_val, y_tr2, y_val = train_test_split(X_tr, y_tr, test_size=0.25, random_state=42)
m_es = XGBRegressor(n_estimators=3000, learning_rate=0.05, early_stopping_rounds=50,
                    verbosity=0, random_state=42)
m_es.fit(X_tr2, y_tr2, eval_set=[(X_val, y_val)], verbose=False)
print(f"Trees allowed           : 3000")
print(f"Early stopping stopped at: {m_es.best_iteration} trees")
print(f"Test R2                 : {m_es.score(X_te, y_te):.4f}")
Output
Trees allowed           : 3000
Early stopping stopped at: 80 trees
Test R2                 : 0.8395

early_stopping_rounds MOVED in XGBoost 2.0

Old code (and old textbooks) write:

python1 lines
model.fit(X, y, early_stopping_rounds=50)      # an ERROR in XGBoost 2.0+

Now it goes to the constructor:

python1 lines
XGBRegressor(early_stopping_rounds=50).fit(X, y, eval_set=[(X_val, y_val)])

This was one of the mistakes in the old ML_GUIDE book. API changes break things quietly like this.

CatBoost: handles categories itself

python29 lines
from catboost import CatBoostClassifier
yc = df["ijaraga_berildi_30k"]
Xk = df[num + ["tuman"]]

# CatBoost accepts a text column DIRECTLY
cb = CatBoostClassifier(iterations=200, verbose=0, random_seed=42,
                        cat_features=["tuman"]).fit(Xk, yc)
print("CatBoost accepted the text column WITHOUT encoding: no error")
print(f"  train accuracy: {cb.score(Xk, yc):.4f}")
print()
# the others need OneHot
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from xgboost import XGBClassifier
q = Pipeline([
    ("t", ColumnTransformer([("m", OneHotEncoder(handle_unknown="ignore"), ["tuman"])],
                            remainder="passthrough")),
    ("m", XGBClassifier(n_estimators=200, verbosity=0, random_state=42)),
])
from sklearn.metrics import f1_score
Xk_tr, Xk_te, yk_tr, yk_te = train_test_split(Xk, yc, test_size=0.3, random_state=42, stratify=yc)

q.fit(Xk_tr, yk_tr)
print(f"XGBoost + OneHot:  test F1 = {f1_score(yk_te, q.predict(Xk_te)):.4f}")

cb2 = CatBoostClassifier(iterations=200, verbose=0, random_seed=42,
                         cat_features=["tuman"]).fit(Xk_tr, yk_tr)
print(f"CatBoost (itself):  test F1 = {f1_score(yk_te, cb2.predict(Xk_te)):.4f}")
Output
CatBoost accepted the text column WITHOUT encoding: no error
  train accuracy: 0.7883

XGBoost + OneHot:  test F1 = 0.6975
CatBoost (itself):  test F1 = 0.7247

Give CatBoost to cross_val_score and it falls over

Above I used a plain train/test split, not CV. There's a reason, and it's a trap you meet in practice.

python1 lines
cross_val_score(CatBoostClassifier(cat_features=["tuman"]), X, y, cv=5)

That code raises this error:

python2 lines
RuntimeError: Cannot clone object CatBoostClassifier(...), as the constructor
either does not set or modifies parameter cat_features

cross_val_score clones the model for each fold (sklearn.base.clone). Cloning requires that the parameters given to the constructor stay unchanged, but CatBoost modifies cat_features internally.

Fixes: - pass cat_features to fit(): cb.fit(X, y, cat_features=[...]) - or pass the column indices, not the names - or use CatBoost's own cv() function

This is the kind of problem that appears when a library doesn't fully follow the sklearn interface. In cases like this, read the error message: if it says "clone", that's where the problem is.

The main parameters

python8 lines
show(pd.DataFrame([
    {"Parameter": "learning_rate", "Meaning": "How much each tree corrects", "In practice": "0.01-0.1"},
    {"Parameter": "n_estimators", "Meaning": "The number of trees", "In practice": "found with early_stopping"},
    {"Parameter": "max_depth", "Meaning": "Tree depth", "In practice": "3-8 (SHALLOWER than RF)"},
    {"Parameter": "subsample", "Meaning": "Share of rows per tree", "In practice": "0.7-1.0"},
    {"Parameter": "colsample_bytree", "Meaning": "Share of columns per tree", "In practice": "0.7-1.0"},
    {"Parameter": "reg_lambda", "Meaning": "L2 penalty (Chapter 17)", "In practice": "1-10"},
]))
ParameterMeaningIn practice
learning_rateHow much each tree corrects0.01-0.1
n_estimatorsThe number of treesfound with early_stopping
max_depthTree depth3-8 (SHALLOWER than RF)
subsampleShare of rows per tree0.7-1.0
colsample_bytreeShare of columns per tree0.7-1.0
reg_lambdaL2 penalty (Chapter 17)1-10

What we learned today

python10 lines
show(pd.DataFrame([
    {"Concept": "The boosting idea", "In one sentence": "Each tree learns the previous trees' MISTAKES"},
    {"Concept": "Three lines of code", "In one sentence": "resid -> fit(X, resid) -> pred += lr * new"},
    {"Concept": "Tree depth", "In one sentence": "Shallow (3-6), unlike in RF"},
    {"Concept": "learning_rate", "In one sentence": "The smaller it is, the more trees you need"},
    {"Concept": "n_estimators", "In one sentence": "Raising it DOES overfit, unlike RF"},
    {"Concept": "early_stopping", "In one sentence": "Ends training when validation stops improving"},
    {"Concept": "XGBoost 2.0", "In one sentence": "early_stopping_rounds moved to the constructor"},
    {"Concept": "CatBoost", "In one sentence": "Handles a text column itself with cat_features"},
]))
ConceptIn one sentence
The boosting ideaEach tree learns the previous trees' MISTAKES
Three lines of coderesid -> fit(X, resid) -> pred += lr * new
Tree depthShallow (3-6), unlike in RF
learning_rateThe smaller it is, the more trees you need
n_estimatorsRaising it DOES overfit, unlike RF
early_stoppingEnds training when validation stops improving
XGBoost 2.0early_stopping_rounds moved to the constructor
CatBoostHandles a text column itself with cat_features

The next chapter covers two simple but still useful models: predicting by looking at the neighbours, and counting probabilities.

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