XGBoost gave gradient boosting a rigorous second-order objective; LightGBM made the same boosting fast. CatBoost completes the trio by attacking a subtler enemy: target leakage. It keeps the exact g, h gradient-boosting engine, but obsesses over two places where the label quietly leaks into training — and its fix, for both, is the same one word: ordering. I built a from-scratch demo that computes all of this live in JS, no library, so you can watch the leak open and close as you move sliders. Here's what it's showing.
The leak in categorical encoding
A high-cardinality categorical — user id, zip code, SKU — is tempting to encode by the mean target of its category. But the naive mean includes the current row, so for a category that appears once, that mean essentially is the row's own label. The feature has become a disguised copy of y. Raise the cardinality (fewer rows per category) and the leak explodes.
Ordered target statistics fix it by processing rows in a random permutation and encoding each row with the running target mean of only the rows that came before it, smoothed by a prior. No row ever contributes to its own encoding:
def ordered_target_stats(cat, y, a=1.0, prior=None, seed=0):
prior = y.mean() if prior is None else prior
perm = np.random.default_rng(seed).permutation(len(cat))
enc = np.zeros(len(cat)); seen_sum, seen_cnt = {}, {}
for i in perm: # walk rows in permutation order
c = cat[i]
s, k = seen_sum.get(c, 0.0), seen_cnt.get(c, 0)
enc[i] = (s + a*prior) / (k + a) # PRIOR rows only -> no self-leak
seen_sum[c] = s + y[i] # only now add this row
seen_cnt[c] = k + 1
return enc
The demo trains a tiny logistic model on each encoding and shows the tell: the naive encoding's train accuracy soars while validation stays flat — a huge train−val gap, the signature of overfitting — and corr(naive enc, y) climbs toward 1.0, the feature becoming the label. Ordered TS keeps train ≈ val: a smaller train number, but an honest one that survives to new data. The gap, not the raw train score, is the thing to watch.
The same leak in boosting
Classic boosting fits each new tree to the residual y − F(x). But F was trained including this row, so its residual is biased small — the model chases its own noise. This is "prediction shift". Ordered boosting computes every row's residual from a model that never trained on it. The out-of-fold version captures the identical leak-free principle:
def leak_free_residuals(x, y, F, folds):
upd = np.zeros_like(F)
for k in np.unique(folds):
tr = folds != k # everything except fold k
stump = fit_stump(x[tr], (y - F)[tr]) # fit on the OTHER rows
upd[folds == k] = stump(x[folds == k])# predict the held-out fold
return upd # F never influenced its own target
The demo runs two real gradient-boosting fits side by side and plots test MSE per round. The greedy curve dips, then climbs back up as it overfits its own noise; the ordered curve stays low. CatBoost keeps about log(n) prefix models per permutation so the cost of all this stays sane.
Symmetric (oblivious) trees
CatBoost's third idea is the only tree shape it grows. A normal CART tree picks the best split at every node independently, so nodes at the same depth split on different rules — flexible, but easy to overfit. A symmetric (oblivious) tree picks one (feature, threshold) per level and applies it to all nodes there. A depth-d tree is exactly 2ᵈ balanced leaves indexed by d yes/no tests, so inference is d comparisons with no branching, and the rigid shape is a strong built-in regularizer:
def leaf_index(x, levels):
idx = 0
for f, thr in levels: # d comparisons, no branching
idx = (idx << 1) | int(x[f] >= thr)
return idx
The demo grows both a per-node CART tree and an oblivious tree on the same data and compares their decision regions — jagged and asymmetric versus a clean grid of 2ᵈ cells.
Where it sits in the trio
| XGBoost | LightGBM | CatBoost | |
|---|---|---|---|
| Signature idea | 2nd-order objective | raw speed | leakage-free training |
| Categoricals | manual one-hot | native, can leak | ordered TS, no self-leak |
| Residuals | in-sample | in-sample | ordered, out-of-permutation |
In practice you pass raw string columns straight to CatBoost via cat_features — no manual one-hot, no leaky encoding — and trust the defaults, because ordered TS and ordered boosting are on by default. Its sweet spot is messy categorical data on small-to-medium sets, where those defaults win with almost no tuning. Together with XGBoost and LightGBM, it's one of the three boosters that take most tabular competitions.
Move the sliders and watch the naive train/val gap blow open while ordered stays flat:
https://dev48v.infy.uk/ml/day43-catboost.html
Top comments (0)