Ensembles II: Gradient Boosting, XGBoost, LightGBM and CatBoost
The algorithm family that wins most tabular-data problems. Boosting intuition (AdaBoost to gradient boosting), fitting trees to residuals by hand, learning rate and number of trees, early stopping, scikit-learn's HistGradientBoosting, XGBoost and LightGBM APIs and key hyperparameters, categorical features and CatBoost, and a tuned comparison on churn and house prices.
- Explain sequential boosting and how gradient boosting fits trees to residuals (gradients)
- Implement a few boosting rounds by hand to see the mechanism
- Tune learning rate, number of trees, depth, subsampling and regularisation with early stopping
- Use HistGradientBoosting, XGBoost and LightGBM with native categorical handling
- Decide when boosting is worth its extra complexity
Boosting: learn from mistakes
Bagging trains trees independently and averages them (reduces variance). Boosting trains small trees sequentially: each new tree focuses on what the current ensemble gets wrong (reduces bias).

- AdaBoost (1995) reweights misclassified rows so the next learner focuses on them.
- Gradient boosting (Friedman, 2001) fits each new tree to the negative gradient of the loss; for squared error, that is simply the residual.

Boosting by hand
import numpy as np, pandas as pd
from sklearn.tree import DecisionTreeRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error
h = pd.read_csv("house_prices.csv").dropna(subset=["area_sqft"]); h = h[h.price_lakh.between(5, 300)]
Xh = pd.get_dummies(h.drop(columns=["house_id", "price_lakh"]), drop_first=True).astype(float); yh = h.price_lakh
Xtr, Xte, ytr, yte = train_test_split(Xh, yh, test_size=0.2, random_state=0)
lr, pred_tr, pred_te = 0.3, np.full(len(ytr), ytr.mean()), np.full(len(yte), ytr.mean())
print(f"round 0 (mean only): test MAE {mean_absolute_error(yte, pred_te):.2f}")
for m in range(1, 31):
resid = ytr - pred_tr # negative gradient of squared error
tree = DecisionTreeRegressor(max_depth=3, random_state=0).fit(Xtr, resid)
pred_tr += lr * tree.predict(Xtr); pred_te += lr * tree.predict(Xte)
if m in (1, 2, 5, 10, 30): print(f"round {m:2d}: test MAE {mean_absolute_error(yte, pred_te):.2f}")round 0 (mean only): test MAE 25.00 round 1: test MAE 19.07 round 2: test MAE 15.39 round 5: test MAE 10.61 round 10: test MAE 8.01 round 30: test MAE 6.95
Thirty shallow trees, each correcting the last, beat a single deep tree.
The learning rate and number of trees

Unlike random forests, boosting can overfit with too many rounds. Use a small learning rate (0.01–0.1) with early stopping on a validation set.
scikit-learn HistGradientBoosting
The fast, modern implementation in scikit-learn (inspired by LightGBM): bins features into histograms, handles missing values and categorical features natively, supports early stopping.
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import roc_auc_score
tc = pd.read_csv("telecom_churn.csv")
Xc = tc.drop(columns=["customer_id", "churn", "total_charges"]); yc = (tc.churn == "Yes").astype(int)
for col in ["contract", "internet_service", "payment_method"]: Xc[col] = Xc[col].astype("category")
Xa, Xb, ya, yb = train_test_split(Xc, yc, test_size=0.3, stratify=yc, random_state=1)
hgb = HistGradientBoostingClassifier(learning_rate=0.05, max_iter=1000, max_depth=None, max_leaf_nodes=15, min_samples_leaf=40, l2_regularization=1.0,
categorical_features="from_dtype", early_stopping=True, validation_fraction=0.15, n_iter_no_change=30, random_state=0).fit(Xa, ya)
print(f"HistGradientBoosting: stopped after {hgb.n_iter_} trees | test AUC {roc_auc_score(yb, hgb.predict_proba(Xb)[:, 1]):.3f}")HistGradientBoosting: stopped after 87 trees | test AUC 0.806
XGBoost
import xgboost as xgb
Xa_tr, Xa_va, ya_tr, ya_va = train_test_split(Xa, ya, test_size=0.2, stratify=ya, random_state=0)
xg = xgb.XGBClassifier(n_estimators=2000, learning_rate=0.03, max_depth=3, subsample=0.8, colsample_bytree=0.8, min_child_weight=5, reg_lambda=1.0,
enable_categorical=True, tree_method="hist", eval_metric="auc", early_stopping_rounds=100, random_state=0)
xg.fit(Xa_tr, ya_tr, eval_set=[(Xa_va, ya_va)], verbose=False)
print(f"XGBoost {xgb.__version__}: best iteration {xg.best_iteration} | test AUC {roc_auc_score(yb, xg.predict_proba(Xb)[:, 1]):.3f}")XGBoost 3.4.1: best iteration 6 | test AUC 0.806
LightGBM
import lightgbm as lgb
lg = lgb.LGBMClassifier(n_estimators=2000, learning_rate=0.03, num_leaves=15, min_child_samples=40, subsample=0.8, subsample_freq=1, colsample_bytree=0.8, reg_lambda=1.0, random_state=0, verbose=-1)
lg.fit(Xa_tr, ya_tr, eval_set=[(Xa_va, ya_va)], eval_metric="auc", callbacks=[lgb.early_stopping(100, verbose=False)])
print(f"LightGBM {lgb.__version__}: best iteration {lg.best_iteration_} | test AUC {roc_auc_score(yb, lg.predict_proba(Xb)[:, 1]):.3f}")LightGBM 4.7.0: best iteration 11 | test AUC 0.809
Early stopping chose very few trees for XGBoost and LightGBM here: with 2,000 training rows and a simple signal, validation AUC peaks early. On larger datasets the best iteration is typically in the hundreds or thousands.
LightGBM grows trees leaf-wise (it splits the leaf with the biggest gain), which is fast and accurate on large data; control complexity with num_leaves and min_child_samples. CatBoost uses ordered target statistics for categorical features and symmetric trees; it is often the best choice when there are many high-cardinality categories (pip install catboost).
Key hyperparameters (cheat sheet)
| Idea | XGBoost | LightGBM | HistGradientBoosting | Typical |
|---|---|---|---|---|
| Number of trees | n_estimators |
n_estimators |
max_iter |
Large + early stopping |
| Step size | learning_rate |
learning_rate |
learning_rate |
0.01–0.1 |
| Tree size | max_depth |
num_leaves, max_depth |
max_leaf_nodes, max_depth |
depth 3–8 / 15–63 leaves |
| Leaf size | min_child_weight |
min_child_samples |
min_samples_leaf |
5–100 |
| Row sampling | subsample |
subsample (+ subsample_freq) |
— | 0.6–0.9 |
| Column sampling | colsample_bytree |
colsample_bytree |
max_features |
0.5–0.9 |
| Regularisation | reg_lambda, reg_alpha, gamma |
reg_lambda, reg_alpha |
l2_regularization |
|
| Imbalance | scale_pos_weight |
is_unbalance, scale_pos_weight |
class_weight |
ratio of negatives to positives |
Tuning recipe: fix a learning rate (0.05), early-stop the number of trees, tune tree size and leaf size, then sampling and regularisation (Optuna works well), and finally lower the learning rate and raise trees for a last bit of accuracy.
Comparison on two problems
from sklearn.model_selection import cross_val_score, StratifiedKFold, KFold
from sklearn.linear_model import LogisticRegression, Ridge
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor, HistGradientBoostingRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
Xd = pd.get_dummies(Xc, drop_first=True).astype(float)
cv = StratifiedKFold(5, shuffle=True, random_state=0)
cls = {"logistic regression": make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)), "random forest": RandomForestClassifier(400, min_samples_leaf=20, random_state=0, n_jobs=-1),
"HistGradientBoosting": HistGradientBoostingClassifier(learning_rate=0.05, max_iter=200, max_leaf_nodes=15, min_samples_leaf=40, random_state=0),
"XGBoost": xgb.XGBClassifier(n_estimators=300, learning_rate=0.03, max_depth=3, subsample=0.8, colsample_bytree=0.8, random_state=0),
"LightGBM": lgb.LGBMClassifier(n_estimators=300, learning_rate=0.03, num_leaves=15, min_child_samples=40, random_state=0, verbose=-1)}
print("Churn (5-fold ROC-AUC):")
for n, m in cls.items(): s = cross_val_score(m, Xd, yc, cv=cv, scoring="roc_auc"); print(f" {n:22s} {s.mean():.3f} ± {s.std():.3f}")
reg = {"ridge": make_pipeline(StandardScaler(), Ridge()), "random forest": RandomForestRegressor(400, min_samples_leaf=2, max_features=0.6, random_state=0, n_jobs=-1),
"HistGradientBoosting": HistGradientBoostingRegressor(learning_rate=0.05, max_iter=500, random_state=0),
"XGBoost": xgb.XGBRegressor(n_estimators=600, learning_rate=0.05, max_depth=4, subsample=0.8, colsample_bytree=0.8, random_state=0),
"LightGBM": lgb.LGBMRegressor(n_estimators=600, learning_rate=0.05, num_leaves=15, random_state=0, verbose=-1)}
print("House prices (5-fold MAE, ₹ lakh):")
for n, m in reg.items(): s = -cross_val_score(m, Xh, yh, cv=KFold(5, shuffle=True, random_state=0), scoring="neg_mean_absolute_error"); print(f" {n:22s} {s.mean():.2f} ± {s.std():.2f}")Churn (5-fold ROC-AUC): logistic regression 0.812 ± 0.015 random forest 0.798 ± 0.014 HistGradientBoosting 0.790 ± 0.016 XGBoost 0.804 ± 0.012 LightGBM 0.791 ± 0.010 House prices (5-fold MAE, ₹ lakh): ridge 6.12 ± 0.25 random forest 6.95 ± 0.36 HistGradientBoosting 6.79 ± 0.25 XGBoost 6.48 ± 0.31 LightGBM 6.73 ± 0.26
Read results honestly: on these clean, mostly additive datasets, linear models are competitive. Boosting's advantage grows with more data, more features, interactions, missing values and messy real-world signals, which is why it dominates tabular competitions and industry credit, fraud, pricing and ranking models.
Summary
- Boosting adds shallow trees sequentially; gradient boosting fits each tree to the negative gradient (residuals for squared error).
- The learning rate scales each tree; small rates need more trees; use early stopping because boosting can overfit.
- HistGradientBoosting, XGBoost and LightGBM are fast histogram-based implementations with native missing-value and categorical support; CatBoost excels with many categories.
- Tune trees (via early stopping), tree/leaf size, sampling and regularisation.
- Always compare with simple baselines; boosting shines on large, messy, interaction-rich tabular data.
1. How does boosting differ from bagging?
2. For squared error, what does each new tree in gradient boosting fit?
3. What does a smaller learning rate require?
4. Can gradient boosting overfit by adding too many trees?
5. What does LightGBM's num_leaves control?
6. When is CatBoost particularly useful?
(1) Tune LightGBM on the bank loans data with early stopping; compare with logistic regression. (2) Use scale_pos_weight in XGBoost for the churn data and report recall at 0.5. (3) Plot validation AUC against boosting rounds from xg.evals_result().
Show solution
b = pd.read_csv("bank_loans.csv").dropna(subset=["default"]); fb = ["age", "ed", "employ", "address", "income", "debtinc", "creddebt", "othdebt"]
print(cross_val_score(lgb.LGBMClassifier(n_estimators=300, learning_rate=0.03, num_leaves=7, min_child_samples=30, verbose=-1), b[fb], b.default.astype(int), cv=cv, scoring="roc_auc").mean())
from sklearn.metrics import recall_score
w = (ya == 0).sum() / (ya == 1).sum()
xw = xgb.XGBClassifier(n_estimators=300, learning_rate=0.03, max_depth=3, scale_pos_weight=w, enable_categorical=True).fit(Xa, ya)
print(recall_score(yb, xw.predict(Xb)))
plt.plot(xg.evals_result()["validation_0"]["auc"])Q: Explain gradient boosting in simple terms. Start with a simple prediction, look at the errors, train a small tree to predict those errors, add a fraction of it, and repeat; the ensemble gradually corrects itself.
Q: XGBoost vs LightGBM vs random forest? Random forests average independent deep trees (robust, little tuning); XGBoost and LightGBM build trees sequentially with regularisation and histogram speed-ups; LightGBM grows leaf-wise and is usually fastest on large data.
Q: How do you prevent overfitting in boosting? Lower learning rate with early stopping, shallow trees, minimum leaf size, row and column subsampling, and L1/L2 regularisation.
Practise more online
Free, trusted places to test yourself on this topic. Links open the original site in a new tab.