Boosting: XGBoost, LightGBM, CatBoost
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.
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}")
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:
resid = y - pred: the current errord.fit(X, resid): the new tree predicts the errorpred += 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
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}")
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
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"}, ]))
| Library | Year | Main strength | Weakness |
|---|---|---|---|
| XGBoost | 2 014 | The first popular one; regularisation built in | Doesn't handle categories itself |
| LightGBM | 2 017 | THE FASTEST; wins on large data | Prone to overfitting on small data |
| CatBoost | 2 017 | Handles categories ITSELF; good defaults | Slower to train |
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")
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
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}")
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.
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}")
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.
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}")
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:
model.fit(X, y, early_stopping_rounds=50) # an ERROR in XGBoost 2.0+
Now it goes to the constructor:
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
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}")
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.
cross_val_score(CatBoostClassifier(cat_features=["tuman"]), X, y, cv=5)
That code raises this error:
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
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"}, ]))
| Parameter | Meaning | In practice |
|---|---|---|
| learning_rate | How much each tree corrects | 0.01-0.1 |
| n_estimators | The number of trees | found with early_stopping |
| max_depth | Tree depth | 3-8 (SHALLOWER than RF) |
| subsample | Share of rows per tree | 0.7-1.0 |
| colsample_bytree | Share of columns per tree | 0.7-1.0 |
| reg_lambda | L2 penalty (Chapter 17) | 1-10 |
What we learned today
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"}, ]))
| Concept | In one sentence |
|---|---|
| The boosting idea | Each tree learns the previous trees' MISTAKES |
| Three lines of code | resid -> fit(X, resid) -> pred += lr * new |
| Tree depth | Shallow (3-6), unlike in RF |
| learning_rate | The smaller it is, the more trees you need |
| n_estimators | Raising it DOES overfit, unlike RF |
| early_stopping | Ends training when validation stops improving |
| XGBoost 2.0 | early_stopping_rounds moved to the constructor |
| CatBoost | Handles 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.