๐Ÿฑ CatBoost

Ordered target statistics, ordered boosting, and oblivious trees
Trees & Ensembles Advanced
Contents

    CatBoost uses the same objective as XGBoost and LightGBM — the leaf weight is still $w^{*} = -G/(H+\lambda)$. Its three distinctive ideas are all about leakage: places where a model accidentally uses information it would not have at prediction time.

    Idea The leak it closes
    Ordered target statistics encoding a category with its own row's target
    Ordered boosting computing a residual with a model that already saw that row
    Oblivious trees (not leakage) one split per level — fast, and heavily regularised

    The first two are subtle and cost accuracy when ignored on small data. The third changes what a tree even looks like. All three are worked through below, and the final tree is verified leaf-for-leaf against the real catboost.

    Display helpers ready: banner, step, check, expand, show_matrix, show_matmul, show_elementwise.
    Tree diagram helper ready: draw_tree(root, title).

    1. The Data

    The same 12 EEG recordings, but now the categorical column matters: region is one of frontal, temporal, occipital. Categories cannot be compared with $\le$, so a tree cannot split on them directly — they must first be turned into numbers.

    That conversion is where CatBoost's first idea lives.

    12 recordings x 3 features
    
        id      region   line_length    spike_rate           age   y_class    y_reg
    -------------------------------------------------------------------------------
     rec00     frontal          12.0           0.5          24.0         0      0.2
     rec01    temporal          15.0           1.2          31.0         0      0.5
     rec02    temporal          42.0           6.8          58.0         1      6.4
     rec03   occipital          38.0           5.1          47.0         1      4.8
     rec04     frontal          11.0           0.3          19.0         0      0.1
     rec05    temporal          55.0           8.2          63.0         1      9.1
     rec06   occipital          40.0           1.9          35.0         1      3.5
     rec07    temporal          47.0           7.4          52.0         1      7.2
     rec08     frontal          14.0           0.8          28.0         0      0.3
     rec09   occipital          51.0           6.1          60.0         1      6.9
     rec10     frontal          45.0           2.4          41.0         0      1.6
     rec11    temporal          35.0           4.7          30.0         1      4.1
    -------------------------------------------------------------------------------
    
    class balance: 5 negative, 7 positive
    y_reg mean = 3.7250, std = 3.0518
    category membership
    
       frontal (4 rows): ['rec00', 'rec04', 'rec08', 'rec10']
                y_class = [0 0 0 0]   mean = 0.0000
      temporal (5 rows): ['rec01', 'rec02', 'rec05', 'rec07', 'rec11']
                y_class = [0 1 1 1 1]   mean = 0.8000
     occipital (3 rows): ['rec03', 'rec06', 'rec09']
                y_class = [1 1 1]   mean = 1.0000
    
    global mean of y_class (the 'prior') = 0.583333

    2. The Leak in Naive Target Encoding

    The obvious way to turn a category into a number is to replace it with the mean target of that category (target encoding, or "greedy TS"):

    $$\hat x_i = \frac{\sum_{j} \mathbb{1}[\,x_j = x_i\,]\, y_j}{\sum_{j} \mathbb{1}[\,x_j = x_i\,]}$$

    The sum runs over all rows — including row $i$ itself. That is the leak: row $i$'s own label is baked into row $i$'s own feature.

    The effect is worst for rare categories. For a category with a single member the encoding is that member's target, so the feature becomes a perfect copy of the answer — on training data only.

    naive target encoding of 'region'
    
        id      region    y    encoded   includes own y?
    ----------------------------------------------------
     rec00     frontal    0     0.0000               YES
     rec01    temporal    0     0.8000               YES
     rec02    temporal    1     0.8000               YES
     rec03   occipital    1     1.0000               YES
     rec04     frontal    0     0.0000               YES
     rec05    temporal    1     0.8000               YES
     rec06   occipital    1     1.0000               YES
     rec07    temporal    1     0.8000               YES
     rec08     frontal    0     0.0000               YES
     rec09   occipital    1     1.0000               YES
     rec10     frontal    0     0.0000               YES
     rec11    temporal    1     0.8000               YES
    ----------------------------------------------------
    
    correlation between the encoded feature and the target: 0.8519
    
    Now the extreme case. Suppose one recording came from a region seen only once:
       rec06 is the only 'parietal' recording, y = 1
       its naive encoding = 1.0000
       equals its own label exactly: True
    
    A tree splitting on that feature would separate this row perfectly during
    training and learn nothing that transfers. At prediction time the row's label
    is unknown, so the feature cannot be computed the same way at all.

    3. Ordered Target Statistics

    CatBoost's fix: fix a random permutation of the rows and pretend they arrive in that order. Encode each row using only the rows that came before it, smoothed toward a prior $P$ with weight $a$:

    $$\hat x_{\sigma(i)} = \frac{\displaystyle\sum_{j < i} \mathbb{1}[\,x_{\sigma(j)} = x_{\sigma(i)}\,]\, y_{\sigma(j)} \;+\; a\,P} {\displaystyle\sum_{j < i} \mathbb{1}[\,x_{\sigma(j)} = x_{\sigma(i)}\,] \;+\; a}$$

    Two things fall out at once:

    • Row $i$'s own target never enters its own encoding — the leak is closed by construction.
    • The first occurrence of any category has an empty history, so it falls back to the prior $P$ — which is exactly the right behaviour for an unseen category at prediction time.

    $P$ is normally the global mean of $y$, and $a > 0$ keeps early, low-count estimates from being wild.

    prior P = 0.583333,  smoothing a = 1.0
    
    permutation (arrival order): ['rec01', 'rec00', 'rec08', 'rec02', 'rec10', 'rec09', 'rec07', 'rec06', 'rec04', 'rec03', 'rec05', 'rec11']
    
     pos      id      region    y   count before   sum y before    encoded
    ----------------------------------------------------------------------
       0   rec01    temporal    0              0            0.0     0.5833  <- no history, falls back to prior
       1   rec00     frontal    0              0            0.0     0.5833  <- no history, falls back to prior
       2   rec08     frontal    0              1            0.0     0.2917
       3   rec02    temporal    1              1            0.0     0.2917
       4   rec10     frontal    0              2            0.0     0.1944
       5   rec09   occipital    1              0            0.0     0.5833  <- no history, falls back to prior
       6   rec07    temporal    1              2            1.0     0.5278
       7   rec06   occipital    1              1            1.0     0.7917
       8   rec04     frontal    0              3            0.0     0.1458
       9   rec03   occipital    1              2            2.0     0.8611
      10   rec05    temporal    1              3            2.0     0.6458
      11   rec11    temporal    1              4            3.0     0.7167
    ----------------------------------------------------------------------
    
    worked example, position 5:
       row rec09, region 'occipital'
       rows before it in this permutation: ['rec01', 'rec00', 'rec08', 'rec02', 'rec10']
       of those, same region             : []  (y = [])
       encoded = (sum + a*P) / (count + a)
               = (0.0 + 1.0*0.5833) / (0 + 1.0)
               = 0.5833 / 1.0 = 0.583333
       note that y[rec09] = 1 was NOT used.
    
    
    correlation with target, naive  : +0.8519
    correlation with target, ordered: +0.5950
    
    The ordered encoding is less correlated with the target - and that is the
    point. The extra correlation in the naive version was leakage, not signal.

    Averaging over permutations

    A single permutation is noisy: the row that happens to arrive first always gets the prior, whatever its category. CatBoost therefore uses several independent permutations and averages the resulting statistics, which keeps the no-leak property while cutting the variance.

    id      region    y     naive    1 perm   avg of 200      std
    -----------------------------------------------------------------
     rec00     frontal    0    0.0000    0.5833       0.3048   0.1733
     rec01    temporal    0    0.8000    0.5833       0.7916   0.1300
     rec02    temporal    1    0.8000    0.2917       0.6713   0.1386
     rec03   occipital    1    1.0000    0.8611       0.7455   0.1173
     rec04     frontal    0    0.0000    0.1458       0.3055   0.1690
     rec05    temporal    1    0.8000    0.6458       0.6567   0.1522
     rec06   occipital    1    1.0000    0.7917       0.7295   0.1200
     rec07    temporal    1    0.8000    0.5278       0.6642   0.1495
     rec08     frontal    0    0.0000    0.2917       0.3050   0.1723
     rec09   occipital    1    1.0000    0.5833       0.7611   0.1147
     rec10     frontal    0    0.0000    0.1944       0.2999   0.1641
     rec11    temporal    1    0.8000    0.7167       0.6578   0.1367
    -----------------------------------------------------------------
    
    spread across permutations for a single row (rec00):
       min 0.1458, max 0.5833, mean 0.3048
    
    no permutation ever lets a row see its own target, so the average
    is still leak-free - it is just a lower-variance estimate.

    4. Ordered Boosting

    The same leak appears a second time, and it is easier to miss.

    In ordinary gradient boosting the residual for row $i$ is computed from a model that was trained on row $i$. So the residual is optimistically small — the model has already partly memorised that row. Bias accumulates over rounds; CatBoost's authors call it prediction shift.

    Ordered boosting closes it with the same trick: keep a set of models $M_0, M_1, \dots$ where $M_k$ has only ever seen the first $k$ rows of the permutation, and compute row $i$'s gradient with $M_{i-1}$:

    $$g_i = \frac{\partial\, l\big(y_i,\; M_{i-1}(x_i)\big)}{\partial \hat y}$$

    The row being scored was never in the training set of the model scoring it.

    Switching to the regression target y_reg for the boosting sections.
    
     pos      id        y   model saw row?   plain resid   ordered resid
    --------------------------------------------------------------------
       0   rec03     4.80              YES        1.0750          1.0750
       1   rec02     6.40              YES        2.6750          1.6000
       2   rec06     3.50              YES       -0.2250         -2.1000
       3   rec00     0.20              YES       -3.5250         -4.7000
       4   rec07     7.20              YES        3.4750          3.4750
       5   rec11     4.10              YES        0.3750         -0.3200
       6   rec09     6.90              YES        3.1750          2.5333
       7   rec08     0.30              YES       -3.4250         -4.4286
       8   rec05     9.10              YES        5.3750          4.9250
       9   rec10     1.60              YES       -2.1250         -3.1222
      10   rec04     0.10              YES       -3.6250         -4.3100
      11   rec01     0.50              YES       -3.2250         -3.5182
    --------------------------------------------------------------------
    
    mean |residual|, plain  : 2.6917
    mean |residual|, ordered: 3.0089
    
    The ordered residuals are LARGER. That is the honest number - the plain
    ones look smaller only because each row helped fit the model that scored it.
    
    CatBoost pays for this with bookkeeping: conceptually O(n) models per
    permutation. In practice it maintains them on a tree of prefixes so the
    cost stays close to ordinary boosting.

    5. Oblivious Trees

    CatBoost's trees are oblivious (also called symmetric): every node at the same depth uses the same feature and the same threshold.

    A normal depth-$d$ tree chooses up to $2^d - 1$ independent splits. An oblivious tree of depth $d$ chooses exactly $d$.

    That is a severe restriction, and it buys two things:

    Regularisation. With far fewer split decisions the tree cannot chase noise, which suits boosting where each tree is meant to be weak.

    Inference speed. Because the same comparison applies to every row at a given depth, the leaf index is just $d$ bits packed into an integer:

    $$\text{leaf}(x) \;=\; \sum_{k=0}^{d-1} 2^{k}\,\mathbb{1}\big[x_{f_k} > t_k\big]$$

    No branching, no pointer chasing — $d$ comparisons and one array lookup, fully vectorisable.

    depth 1:  normal tree picks up to   1 splits,  oblivious picks  1   ->  both have   2 leaves
    depth 2:  normal tree picks up to   3 splits,  oblivious picks  2   ->  both have   4 leaves
    depth 3:  normal tree picks up to   7 splits,  oblivious picks  3   ->  both have   8 leaves
    depth 4:  normal tree picks up to  15 splits,  oblivious picks  4   ->  both have  16 leaves
    depth 6:  normal tree picks up to  63 splits,  oblivious picks  6   ->  both have  64 leaves
    depth 8:  normal tree picks up to 255 splits,  oblivious picks  8   ->  both have 256 leaves
    
    
    An oblivious tree of depth 2, using CatBoost's own two splits:
    
       level 0: spike_rate > 1.55   ->  bit 0 (worth 1)
       level 1: age > 59.0   ->  bit 1 (worth 2)
    
        id    spike_rate>1.55           age>59.0    bits   leaf
    ------------------------------------------------------------
     rec00           no (0.5)            no (24)      00      0
     rec01           no (1.2)            no (31)      00      0
     rec02          yes (6.8)            no (58)      01      1
     rec03          yes (5.1)            no (47)      01      1
     rec04           no (0.3)            no (19)      00      0
     rec05          yes (8.2)           yes (63)      11      3
     rec06          yes (1.9)            no (35)      01      1
     rec07          yes (7.4)            no (52)      01      1
     rec08           no (0.8)            no (28)      00      0
     rec09          yes (6.1)           yes (60)      11      3
     rec10          yes (2.4)            no (41)      01      1
     rec11          yes (4.7)            no (30)      01      1
    ------------------------------------------------------------
    
    leaf occupancy: [4, 6, 0, 2]
       leaf 2 is EMPTY - no recording has spike_rate <= 1.55 AND age > 59.
       An oblivious tree cannot avoid such leaves: the same split is forced on
       every branch, whether or not it is useful there. That is the price.

    The oblivious tree, drawn

    Notice what is different from every other tree diagram in this series: both nodes at depth 1 ask the identical question (age > 59). That is the whole meaning of "oblivious" — the tree is a list of $d$ questions, not a branching structure of $2^d - 1$ independent ones.

    The leaf values shown are the ones computed in the next section and confirmed against the real library.

    Both depth-1 boxes ask the same thing. A normal tree would be free to ask
    two different questions there - an oblivious tree is not.
    
    a normal depth-2 tree chooses up to 3 splits; this one chooses 2.
    Fewer decisions means less capacity to chase noise, and it means the leaf
    index is just the answer bits read as a binary number.

    6. Checking Against the Real CatBoost

    CatBoost is trained below with one tree of depth 2. Two things are then verified by hand:

    1. the leaf assignment from the bit arithmetic above, and
    2. the leaf values, from the same formula every model in this series uses:

    $$\text{leaf value} \;=\; \eta \cdot \frac{\sum_{i \in \text{leaf}} (y_i - \hat y_i)}{n_{\text{leaf}} + \lambda}$$

    catboost bias (starting prediction) = 3.725000   mean(y) = 3.725000   MATCH: True
    
    catboost's splits, one per level:
       level 0: spike_rate > 1.550000
       level 1: age > 59.000000
    
    our assumed splits matched: True
    
     leaf                          rows    n   cb weight    our value     catboost   match
    --------------------------------------------------------------------------------------
        0      ['00', '01', '04', '08']    4           4    -0.828000    -0.828000    True
        1  ['02', '03', '06', '07', '10', '11']    6           6     0.225000     0.225000    True
        2                            []    0           0     0.000000     0.000000    True
        3                  ['05', '09']    2           2     0.855000     0.855000    True
    --------------------------------------------------------------------------------------
    
    leaf weights MATCH : True
    leaf values MATCH  : True
    
    worked example, leaf 0:
       rows ['rec00', 'rec01', 'rec04', 'rec08']
       y            = [0.2 0.5 0.1 0.3]
       y - bias     = [-3.525 -3.225 -3.625 -3.425]
       sum          = -13.800001
       / (n + l2)   = -13.800001 / (4 + 1.0) = -2.760000
       x eta        = 0.3 * -2.760000 = -0.828000
       catboost     = -0.828000
    id        y   leaf        ours     catboost        diff
    -----------------------------------------------------------
     rec00     0.20      0    2.897000     2.897000    3.19e-08
     rec01     0.50      0    2.897000     2.897000    3.19e-08
     rec02     6.40      1    3.950000     3.950000    9.96e-09
     rec03     4.80      1    3.950000     3.950000    9.96e-09
     rec04     0.10      0    2.897000     2.897000    3.19e-08
     rec05     9.10      3    4.580000     4.580000    8.17e-08
     rec06     3.50      1    3.950000     3.950000    9.96e-09
     rec07     7.20      1    3.950000     3.950000    9.96e-09
     rec08     0.30      0    2.897000     2.897000    3.19e-08
     rec09     6.90      3    4.580000     4.580000    8.17e-08
     rec10     1.60      1    3.950000     3.950000    9.96e-09
     rec11     4.10      1    3.950000     3.950000    9.96e-09
    -----------------------------------------------------------
    
    predictions MATCH catboost: True
    
    MSE before: 9.313542
    MSE after : 6.369570

    One caveat, stated plainly

    The leaf values above are reproduced exactly, because they follow the closed-form formula. The choice of splits is not re-derived here: CatBoost quantises features into borders with its own algorithm and adds a random component (random_strength) before comparing candidates, so its two splits are not simply the greedy best two.

    This walkthrough therefore takes CatBoost's splits as given and verifies everything downstream of them.


    7. Summary

    Idea Formula Effect
    Ordered target statistic $\dfrac{\sum_{j<i}\mathbb{1}[x_j=x_i]y_j + aP}{\sum_{j<i}\mathbb{1}[x_j=x_i] + a}$ category encoding never sees its own label
    Prior fallback first occurrence $\to P$ matches unseen-category behaviour at inference
    Ordered boosting $g_i$ from $M_{i-1}$ removes prediction shift
    Oblivious tree $\text{leaf} = \sum_k 2^k \mathbb{1}[x_{f_k} > t_k]$ $d$ splits, $2^d$ leaves, branch-free inference
    Leaf value $\eta\,\dfrac{\sum (y_i - \hat y_i)}{n + \lambda}$ identical to XGBoost

    The one idea: both of CatBoost's "ordered" mechanisms are the same insight applied twice — never let a row influence the number used to describe or score that row. It is the logic of a train/test split, pushed down to the level of the individual row.

    When it matters most: small datasets and high-cardinality categorical features, which is exactly where naive target encoding leaks hardest.

    What is deliberately left out: feature combinations (CatBoost builds categorical crosses automatically), random_strength and the details of border selection, GPU histogram computation, and the multi-permutation bookkeeping that makes ordered boosting affordable.


    The series in one table

    Model What it contributes Split rule
    Decision Tree the greedy split search itself maximise Gini gain
    Random Forest variance reduction by averaging same, on bootstrapped rows and random features
    XGBoost closed-form leaf and gain from a 2nd-order expansion maximise $\frac{G^2}{H+\lambda}$
    LightGBM the same math, a much faster search histogram + leaf-wise
    CatBoost leak-free encoding and residuals same math, oblivious structure

    Every one of them is the same greedy question — which split helps most? — with a different definition of "helps" and a different budget for finding out.

    References

    1. CatBoost: unbiased boosting with categorical features โ€” Prokhorenkova et al., 2018 (NeurIPS)