Ordered target statistics and ordered boosting

CatBoost · Advanced · 15 min

These ideas are why CatBoost resists overfitting on categorical data, and they explain several parameters and speed trade-offs.

Gradient boosting has a subtle leak called prediction shift. Each new tree is fitted to residuals computed by a model that was trained on the same rows. Those residuals are smaller than residuals on unseen data would be, so the model learns from an optimistic picture and overfits, especially on small datasets.

Target encoding of categories has the same flaw: if a row's category is replaced by the mean target over all rows, its own label is baked into its feature.

CatBoost's fix is ordering. It draws random permutations of the training rows and, for each row, uses only rows that come before it:

  • Ordered target statistics: the encoding of row i's category is (sum of targets of earlier rows with that category + prior) / (count of earlier rows + 1). The prior smooths rare categories; a row never sees its own label. Several permutations reduce the extra variance this creates for rows early in the order.
  • Ordered boosting (boosting_type="Ordered"): residuals for row i come from a model trained only on earlier rows in the permutation, so gradients behave like those on unseen data.

Ordered boosting costs time and memory, so the usual default is boosting_type="Plain", the classic scheme, which still applies ordered target statistics; set "Ordered" explicitly, mainly for small datasets where it helps most. has_time=True tells CatBoost to use the data's existing order instead of random permutations, which is what you want for time-ordered data.

Example

import numpy as np

# Ordered target statistic for one categorical column, as CatBoost computes it
cats = np.array(["a", "b", "a", "a", "b", "c", "a"])
y = np.array([1, 0, 1, 0, 1, 1, 1])
prior, a = y.mean(), 1.0                      # prior and its weight

def ordered_ts(cats, y, perm):
    ts = np.empty(len(cats))
    sums, counts = {}, {}
    for i in perm:                            # walk rows in permutation order
        c = cats[i]
        ts[i] = (sums.get(c, 0) + a * prior) / (counts.get(c, 0) + a)
        sums[c] = sums.get(c, 0) + y[i]       # only now add this row's label
        counts[c] = counts.get(c, 0) + 1
    return ts

print(ordered_ts(cats, y, np.arange(7)).round(2))
# [0.71 0.71 0.86 0.9  0.36 0.71 0.68]  first 'a' sees only the prior
greedy = np.array([y[cats == c].mean() for c in cats])
print(greedy.round(2))   # [0.75 0.5 0.75 0.75 0.5 1. 0.75]  leaks own label ('c' = 1.0!)

Remember

  • Prediction shift: residuals on training rows are optimistically small
  • Ordered target statistics encode each row using only earlier rows' labels
  • Ordered boosting computes residuals from models that never saw the row
  • boosting_type='Plain' is faster; has_time=True uses the natural row order

Watch out for

  • Shuffling time-ordered data and leaving has_time off, so encodings use 'future' rows.

Check yourself

  1. A category appears in exactly one training row. With greedy target encoding its feature equals that row's label. What does the ordered statistic use instead?
  2. Why does CatBoost use several permutations?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: catboost.ai/docs/en/concepts/algorithm-main-stages_cat-to-numberic