ML: Gradient Boosting with XGBoost, LightGBM, CatBoost

Nearly every winning solution on Kaggle’s tabular leaderboards leans on the same trick, and it is not a neural network. It is gradient boosting: build one small tree, see what it got wrong, then build a second tree whose only job is to fix those mistakes, and repeat. This post shows you that engine in a short hand-rolled loop, then puts the three libraries that dominate it (XGBoost, LightGBM, and CatBoost) head to head so you know which one to reach for.

“Boosting works by sequentially applying a weak classification algorithm to repeatedly modified versions of the data.”

Jerome Friedman, Gradient Boosting

Last Updated: July 2026 | Tested on: Python 3.14.6, XGBoost 3.3.0, LightGBM 4.6.0, CatBoost 1.2.10 | Difficulty: Advanced | Reading Time: 14 minutes

Random Forest builds many trees in parallel and averages their predictions. Gradient boosting flips that idea around. It builds trees one at a time, and each new tree exists for one job: to fix the mistakes the earlier trees made. The first tree makes a rough guess. The second tree studies where the first tree was wrong (the residuals, that is, the leftover errors) and tries to correct them. The third tree corrects whatever the first two still got wrong. Round after round, the total error shrinks.

Think of it like a group of friends planning a road trip. Rahul sketches a rough route. Niranjan looks at it and says “you forgot the toll roads,” and patches that part. Viraj spots that the lunch stop is a two hour detour and fixes just that. Nobody redraws the whole map. Each person only corrects what the last person missed. By the end you have a route far better than any one of them could draw alone. That is exactly how boosting works: each tree is a teammate who only fixes the leftover mistakes.

This error-by-error approach usually beats Random Forest on tabular data. The catch is that boosting is fussier. It is more sensitive to hyperparameters, it overfits more easily, and it trains slower. But tuned well, it keeps winning machine learning competitions and runs in production at companies like Airbnb, Uber, and Amazon.

Three implementations dominate today: XGBoost (the original competition favorite), LightGBM (faster, great on large data), and CatBoost (the easiest with categorical columns). This post runs all three head to head and helps you decide which one to reach for.

Prerequisites

📋 Prerequisites:

How Gradient Boosting Works

Initial PredictionF0 = mean(y)ComputeResidualsTree 1fits residualsF1 = F0 + lr*h1ComputeNew ResidualsTree 2fits residualsF2 = F1 + lr*h2…repeat N times…Final ModelFN = F0 + lr*h1 +lr*h2 + + lr*hNPython Gradient Boosting: Sequential Trees Fitting Residuals from Mean to Final Model

The diagram shows how gradient boosting builds its ensemble one tree at a time. The first tree makes a prediction. The second tree fits the leftover errors of the first. The third tree fits whatever is still wrong, and so on. Notice the lr in each step (the learning rate): every tree’s contribution is scaled down before it gets added, so no single tree can swing the prediction too hard. That same error-by-error chaining is what lets boosting beat a random forest on most tabular data. It is also why boosting overfits more easily, which makes the learning rate and early stopping settings so important.

📄 gradient_boosting_concept.py (sequential error correction)

import numpy as np
from sklearn.tree import DecisionTreeRegressor

rng = np.random.default_rng(42)

# Simple regression: y = sin(x) + noise
X = rng.uniform(0, 6, 100).reshape(-1, 1)
y = np.sin(X.ravel()) + rng.normal(0, 0.2, 100)

# Manual gradient boosting (step by step)
learning_rate = 0.3
predictions = np.full(len(y), y.mean())  # Start with mean

print("Manual Gradient Boosting (3 rounds):")
for round_num in range(3):
    residuals = y - predictions
    mse_before = ((residuals) ** 2).mean()

    # Fit a shallow tree to the residuals
    tree = DecisionTreeRegressor(max_depth=3, random_state=42)
    tree.fit(X, residuals)

    # Update predictions with a fraction (learning rate) of tree output
    predictions += learning_rate * tree.predict(X)

    mse_after = ((y - predictions) ** 2).mean()
    print(f"  Round {round_num+1}: MSE {mse_before:.4f} to {mse_after:.4f} "
          f"(reduced by {(mse_before - mse_after) / mse_before:.0%})")

print(f"\nFinal MSE: {((y - predictions) ** 2).mean():.4f}")
print(f"Baseline (just mean): {((y - y.mean()) ** 2).mean():.4f}")

▶ Output

Manual Gradient Boosting (3 rounds):
  Round 1: MSE 0.5602 to 0.2937 (reduced by 48%)
  Round 2: MSE 0.2937 to 0.1609 (reduced by 45%)
  Round 3: MSE 0.1609 to 0.0929 (reduced by 42%)

Final MSE: 0.0929
Baseline (just mean): 0.5602

What happened here: We started with the dumbest possible model: just predict the mean of y for everyone. That gives a baseline Mean Squared Error (MSE) of 0.5602. Round 1’s tree looked at the residuals and knocked the error down by 48%. Round 2 looked at the remaining residuals and cut it again. Each round only chases whatever error is left over. After 3 rounds the MSE fell from 0.56 all the way to 0.09. The learning rate (0.3) decides how big a bite each tree takes.

A smaller learning rate means each tree nudges the prediction more gently, so you need more trees, but the final model usually generalizes better. This hand-rolled loop is the exact engine inside XGBoost, LightGBM, and CatBoost, just without the speed tricks and regularization.

XGBoost vs LightGBM vs CatBoost

Picking between these three is like choosing between three cars from the same brand. They all reach the destination fast, but one is tuned for raw speed on the open highway, one sips fuel on the long haul with huge loads, and one just works the moment you turn the key without any setup. The table below lines up their strengths, and the code right after it settles the argument on real data instead of opinions.

FeatureXGBoostLightGBMCatBoost
SpeedFastFastestSlower to train
MemoryModerateLowModerate
Categorical featuresNeeds encodingNative supportBest native support
Missing valuesNativeNativeNative
GPU supportYesYesYes
Default performanceGoodGoodBest out of box
Best forGeneral purposeLarge datasetsCategorical-heavy data

📄 three_libraries.py (head-to-head comparison)

import warnings
warnings.filterwarnings("ignore")  # quiet a harmless LightGBM feature-name warning
import numpy as np
import time
from sklearn.datasets import make_classification
from sklearn.model_selection import cross_val_score
from sklearn.ensemble import GradientBoostingClassifier
from xgboost import XGBClassifier
from lightgbm import LGBMClassifier
from catboost import CatBoostClassifier

X, y = make_classification(n_samples=5000, n_features=20, n_informative=10,
                            random_state=42)

models = {
    "sklearn GBM": GradientBoostingClassifier(n_estimators=100, random_state=42),
    "XGBoost": XGBClassifier(n_estimators=100, random_state=42, verbosity=0),
    "LightGBM": LGBMClassifier(n_estimators=100, random_state=42, verbose=-1),
    "CatBoost": CatBoostClassifier(n_estimators=100, random_state=42, verbose=0),
}

print(f"{'Model':<16} | {'CV Score':>10} | {'Time (s)':>10}")
print("-" * 42)

for name, model in models.items():
    start = time.time()
    scores = cross_val_score(model, X, y, cv=5)
    elapsed = time.time() - start
    print(f"{name:<16} | {scores.mean():>9.4f} | {elapsed:>9.2f}")

▶ Output

Model            |   CV Score |   Time (s)
------------------------------------------
sklearn GBM      |    0.9348 |     13.69
XGBoost          |    0.9602 |      1.26
LightGBM         |    0.9628 |      2.50
CatBoost         |    0.9664 |      3.80

What happened here: Two things jump out. First, accuracy: the three modern libraries (XGBoost 0.9602, LightGBM 0.9628, CatBoost 0.9664) all beat scikit-learn’s own GradientBoostingClassifier (0.9348) on this data, and CatBoost edges out in front. Second, speed: scikit-learn’s version took roughly 14 seconds while XGBoost finished in about 1.3 seconds, that is about 10x faster for a better score. LightGBM and CatBoost land in between on this small 5,000 row dataset; on much larger data LightGBM usually pulls ahead as the fastest of the bunch. The takeaway is simple: the old built-in scikit-learn booster is fine for a quick baseline, but for real work reach for one of the three dedicated libraries.

The scores are reproducible, the timings are not. The random_state=42 on every model and on make_classification pins the cross-validation (CV) scores, so you should see the same four numbers. The times in seconds depend on your CPU and what else your machine is doing, so yours will differ. What stays true is the ranking: scikit-learn’s built-in booster is by far the slowest, and the three dedicated libraries are several times faster.

Key Hyperparameters to Tune

Think of tuning like salting a pot of dal. You add a pinch, taste, add another pinch, taste again, until it is right. Dumping the whole spoon in at once ruins it. A low learning_rate is that pinch-at-a-time approach, and n_estimators is how many times you go back to add another pinch. Boosting has a lot of knobs, but only a handful actually matter. The two that move the needle most are learning_rate (how big a step each tree takes) and n_estimators (how many trees you build).

They work as a pair: lower the learning rate and you need more trees to make up for it. After that come max_depth (how complex each tree can get) and the sampling knobs like subsample and colsample_bytree, which hold back a slice of the rows and columns each round so the model does not memorize the training data. The script below tries a few sensible combinations on the same dataset so you can see the effect side by side.

📄 tuning.py (the parameters that move the needle)

from xgboost import XGBClassifier
from sklearn.model_selection import cross_val_score
from sklearn.datasets import make_classification

X, y = make_classification(n_samples=2000, n_features=15, random_state=42)

# The 3 most important hyperparameters:
configs = [
    ("Default", {}),
    ("More trees, small lr", {"n_estimators": 500, "learning_rate": 0.05}),
    ("Deeper trees", {"max_depth": 8}),
    ("Shallow + regularized", {"max_depth": 3, "reg_alpha": 1.0, "reg_lambda": 2.0}),
    ("Subsample (like dropout)", {"subsample": 0.8, "colsample_bytree": 0.8}),
    ("Production config", {"n_estimators": 300, "learning_rate": 0.1,
                           "max_depth": 5, "subsample": 0.8,
                           "colsample_bytree": 0.8, "reg_lambda": 1.0}),
]

print(f"{'Config':<25} | {'CV Score':>10}")
print("-" * 40)
for name, params in configs:
    xgb = XGBClassifier(random_state=42, verbosity=0, **params)
    scores = cross_val_score(xgb, X, y, cv=5)
    print(f"{name:<25} | {scores.mean():>9.4f}")

print(f"\nTuning priority: learning_rate, n_estimators, max_depth, subsample")

▶ Output

Config                    |   CV Score
----------------------------------------
Default                   |    0.9015
More trees, small lr      |    0.9075
Deeper trees              |    0.9035
Shallow + regularized     |    0.9050
Subsample (like dropout)  |    0.8980
Production config         |    0.9060

Tuning priority: learning_rate, n_estimators, max_depth, subsample

What happened here: The gaps are small on this clean synthetic data, but the pattern is real. The winner was “More trees, small lr” (0.9075): more trees with a lower learning rate edged out the default. “Deeper trees” barely moved, and “Subsample” actually scored lowest here (0.8980) because random sampling does the most good on noisy data, and this dataset is fairly clean. Two lessons hold across almost every dataset.

First, your biggest, safest win is usually lowering the learning rate and adding trees. Second, do not tune blindly on one split: these are cross-validated scores, and on your own data the ranking can shift, so always measure rather than guess. The exact numbers are reproducible because every model uses random_state=42.

Common Mistakes

❌ Mistake: High learning rate with many trees = overfitting

# BAD: learning_rate=0.3, n_estimators=1000
# Each tree contributes too much, 1000 trees compounds the overfitting

# GOOD: learning_rate=0.05-0.1, n_estimators=200-500
# Small steps + enough trees = stable convergence

# Rule of thumb: lower learning rate needs more trees
# lr=0.01 -> n_estimators=1000+
# lr=0.1  -> n_estimators=100-300
# lr=0.3  -> n_estimators=50-100

# Use early_stopping to find the right n_estimators automatically
print("Pass early_stopping_rounds=50 to the XGBClassifier constructor (not .fit()), with eval_set in .fit().")
print("Training stops when validation score stops improving.")

▶ Output

Pass early_stopping_rounds=50 to the XGBClassifier constructor (not .fit()), with eval_set in .fit().
Training stops when validation score stops improving.

What happened here: The trap is pairing a high learning rate with a huge number of trees. If every tree takes a big step (learning_rate=0.3) and you stack 1,000 of them, the model chases the training data so hard that it memorizes the noise and falls apart on new data. The fix is the opposite mindset: take small steps and let early stopping decide when to quit. You hand the model a validation set, set early_stopping_rounds=50, and training halts on its own the moment the validation score stops improving. Instead of guessing the right number of trees, you let the data tell you.

Practice Exercises

  1. Exercise 1: Load any real classification dataset (the built-in breast cancer set from sklearn.datasets.load_breast_cancer works well). Split it with train_test_split(random_state=42), train an XGBClassifier, and print accuracy plus a classification report. Then add early_stopping_rounds=50 with a validation set and see how many trees it actually keeps.
  2. Exercise 2: Run the same dataset through XGBoost, LightGBM, and CatBoost with default settings. Print each one’s accuracy and training time. Do the rankings from this post hold on real data, or does a different library win? Write down which one you would ship and why.
  3. Exercise 3: Take the winner from Exercise 2 and tune it. Lower the learning rate to 0.05, raise n_estimators, and pull out the top 10 features with model.feature_importances_. Can you match or beat the default score using fewer features?

Conclusion

You now know the one idea that powers every serious tabular model in production: build trees one after another, and let each new tree fix only the leftover errors of the ones before it. You saw that engine hand-rolled in a short loop, watched the three big libraries (XGBoost, LightGBM, and CatBoost) beat scikit-learn’s own booster head to head, learned which handful of hyperparameters actually move the needle, and saw the classic overfitting trap of a high learning rate stacked with too many trees. The short version: start with a low learning rate, add enough trees, and let early stopping decide when to quit.

Next up is SVM in Python: Support Vector Machines, Kernels and Margins, a very different family of models that draws the widest possible boundary between classes instead of stacking trees. If you want the full path from your first line of Python all the way to production machine learning, the Python + AI/ML tutorial series home lays out every post in order.

Frequently Asked Questions

Should I use XGBoost or LightGBM?

For datasets under 100K rows, either works, and as this XGBoost tutorial shows, the two land within a fraction of a point of each other. For large datasets (1M+), LightGBM is significantly faster. For datasets with many categorical features, CatBoost is best. In Kaggle competitions, XGBoost and LightGBM win about equally. Start with LightGBM for speed, switch if needed.

How is gradient boosting different from Random Forest?

Random Forest builds trees independently in parallel (bagging). Gradient Boosting builds trees sequentially, each correcting the previous errors (boosting). Random Forest reduces variance; Gradient Boosting reduces bias. Gradient Boosting typically achieves higher accuracy but is harder to tune and easier to overfit.

What is early stopping in gradient boosting?

Early stopping monitors a validation score during training and stops adding trees when the score stops improving. This automatically determines the optimal number of trees and prevents overfitting. Use eval_set and early_stopping_rounds parameters in XGBoost/LightGBM.

Can gradient boosting handle missing values?

XGBoost, LightGBM, and CatBoost all handle missing values natively. During tree construction, they learn the optimal direction for missing values at each split. You do not need to impute missing values, the algorithm handles them internally.

Interview Questions on Gradient Boosting

The same ideas as they show up in real interviews, framed as scenarios you can practice out loud.

Q: Your XGBoost model scores near-perfect accuracy on the training set but does poorly on the validation set. What do you check first?

This is textbook overfitting, which boosting is especially prone to. First check whether the learning rate is high and the tree count is large, since that combination memorizes noise. Lower the learning_rate, cap max_depth, and add early_stopping_rounds with a proper validation set so training halts on its own. Adding subsample and colsample_bytree below 1.0 and increasing reg_lambda also help pull the model back toward generalizing.

Q: What does the learning rate actually control, and why does lowering it usually help?

The learning rate (also called shrinkage) is a factor between 0 and 1 that scales down each tree’s contribution before it is added to the running prediction, which is the lr*h term in the update. Smaller steps mean no single tree can swing the model too hard, so the ensemble converges more smoothly and overfits less. The tradeoff is that you need more trees to reach the same fit, which is why learning_rate and n_estimators are always tuned as a pair.

Q: A teammate says LightGBM trains far faster than XGBoost but keeps overfitting on their small dataset. Why, and how would you fix it?

LightGBM grows trees leaf-wise (it splits the leaf with the highest loss reduction) rather than level-wise, so trees can get very deep and complex, which is fast and accurate on large data but overfits small data quickly. The fix is to constrain that growth: lower num_leaves, raise min_child_samples (also called min_data_in_leaf), and set a max_depth cap. On a small dataset XGBoost or CatBoost defaults are often safer out of the box.

Q: Why is gradient boosting said to reduce bias while Random Forest reduces variance?

Random Forest averages many independent, fully grown trees, so it mostly cancels out their random variance while leaving each tree’s bias in place. Gradient boosting instead starts with a weak, high-bias model and adds shallow trees that each chip away at the remaining error, directly lowering bias round after round. That is why boosting usually reaches higher accuracy but needs careful regularization to avoid swinging into high variance (overfitting).

Q: Your dataset has several high-cardinality categorical columns like city and product ID. Which of the three libraries would you reach for, and why?

CatBoost is the natural first pick here. It handles categorical columns natively using ordered target encoding, which computes category statistics on a permutation of the data so it avoids the target leakage that naive mean encoding causes. That means you skip manual one-hot or label encoding, which for high-cardinality columns would either blow up the feature space or leak information. LightGBM also has native categorical support and is a solid backup, while XGBoost needs the columns converted to pandas category dtype with enable_categorical=True, and its native handling is generally weaker than CatBoost’s ordered target encoding on high-cardinality columns.

Q: What role do second-order gradients (the Hessian) play in XGBoost?

XGBoost uses a second-order Taylor approximation of the loss, so each split is scored using both the gradient (first derivative) and the Hessian (second derivative) of the loss. This Newton-style boosting gives a more accurate estimate of how much a split reduces the loss than using the gradient alone, which improves split quality and lets the regularization terms plug in cleanly. It is one of the reasons XGBoost was more accurate and stable than the earlier gradient boosting implementations it replaced.

Series: Python + AI/ML Cookbook (Part 5: Machine Learning)

Go deeper: scikit-learn documentation covers every edge case of this topic.

Previous: ML: Random Forest, Bagging and Feature Importance

Next: SVM in Python: Support Vector Machines, Kernels and Margins

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *