๐Ÿ’ก LightGBM

Histogram binning, leaf-wise growth, and GOSS - the same math, a faster search
Trees & Ensembles Advanced
Contents

    LightGBM keeps XGBoost's mathematics unchanged. The leaf weight is still $w^{*} = -G/(H+\lambda)$ and the split gain is still built from $G^2/(H+\lambda)$ terms.

    What LightGBM changes is how the search is done, and it changes three things:

    XGBoost (exact) LightGBM
    Finding thresholds sort every feature, try every midpoint bucket into a fixed number of bins
    Growing the tree level-wise — fill each depth before going deeper leaf-wise — always split the most promising leaf
    Using the rows all of them GOSS — keep large gradients, subsample small ones

    Each of these is an approximation traded for speed, and each is worked through below with the actual numbers. At the end the tree is matched against the real lightgbm.

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

    1. The Task and the Starting Point

    Same 12 EEG recordings and the same regression target as the XGBoost walkthrough.

    One difference in setup: LightGBM starts from the mean of $y$ rather than a fixed constant (boost_from_average). That has a neat consequence worth noticing immediately.

    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
    init_score = mean(y) = 3.725000
    
        id        y     pred0    g = p - y     h
    --------------------------------------------
     rec00     0.20    3.7250       3.5250     1
     rec01     0.50    3.7250       3.2250     1
     rec02     6.40    3.7250      -2.6750     1
     rec03     4.80    3.7250      -1.0750     1
     rec04     0.10    3.7250       3.6250     1
     rec05     9.10    3.7250      -5.3750     1
     rec06     3.50    3.7250       0.2250     1
     rec07     7.20    3.7250      -3.4750     1
     rec08     0.30    3.7250       3.4250     1
     rec09     6.90    3.7250      -3.1750     1
     rec10     1.60    3.7250       2.1250     1
     rec11     4.10    3.7250      -0.3750     1
    --------------------------------------------
       SUM    44.70   44.7000    -0.000000    12
    
    G = -0.0000000000   <- exactly zero, and not by accident:
    starting at the mean makes the total gradient vanish, because
    sum(mean(y) - y_i) = n*mean(y) - sum(y) = 0 by definition of the mean.
    
    So the root similarity G^2/(H+lambda) = 0.000000,
    and every bit of gain a split earns is pure separation of positive from
    negative gradients.

    2. Histogram Binning

    Exact split finding sorts a feature and tests every midpoint: $O(n \log n)$ per feature per node, and $n-1$ candidate thresholds.

    LightGBM instead pushes every value into one of max_bin buckets once, before training, and then only ever considers bin boundaries as thresholds. With 255 bins the number of candidates stops growing with $n$ entirely.

    To make the bucketing visible, max_bin is set to 4 below — so 12 distinct values collapse into 4 groups and only 3 thresholds survive.

    feature 'spike_rate' with max_bin = 4
    
    bin edges: [1.1   3.55  6.275]
    
        id     value   bin          g     h
    --------------------------------------
     rec04      0.30     0     3.6250     1
     rec00      0.50     0     3.5250     1
     rec08      0.80     0     3.4250     1
     rec01      1.20     1     3.2250     1
     rec06      1.90     1     0.2250     1
     rec10      2.40     1     2.1250     1
     rec11      4.70     2    -0.3750     1
     rec03      5.10     2    -1.0750     1
     rec09      6.10     2    -3.1750     1
     rec02      6.80     3    -2.6750     1
     rec07      7.40     3    -3.4750     1
     rec05      8.20     3    -5.3750     1
    --------------------------------------
    
    exact search would test 11 thresholds;
    histogram search tests 3.

    Building the histogram

    For each bin LightGBM accumulates just two numbers — the sum of gradients and the sum of Hessians:

    $$\text{Hist}[b] = \Big(\textstyle\sum_{i:\,\text{bin}(i)=b} g_i,\;\; \sum_{i:\,\text{bin}(i)=b} h_i\Big)$$

    This is a single $O(n)$ pass. Everything after it costs $O(\text{bins})$ and never touches the rows again.

    histogram for 'spike_rate'
    
     bin   count       sum g    sum h
    --------------------------------
       0       3     10.5750      3.0
       1       3      5.5750      3.0
       2       3     -4.6250      3.0
       3       3    -11.5250      3.0
    --------------------------------
     ALL      12     -0.0000     12.0
    
    totals match the full dataset: G True, H True

    Scanning the histogram

    Finding the best threshold is now a single left-to-right sweep accumulating prefix sums. The right side never has to be computed — it is the total minus the left:

    $$G_R = G_{\text{total}} - G_L, \qquad H_R = H_{\text{total}} - H_L$$

    prefix-sum scan over the 4 bins of 'spike_rate'
    
     split after bin        G_L     H_L        G_R     H_R        S_L        S_R       gain
    ----------------------------------------------------------------------------------------
                   0    10.5750     3.0   -10.5750     9.0    27.9577    11.1831    19.5704
                   1    16.1500     6.0   -16.1500     6.0    37.2604    37.2604    37.2604
                   2    11.5250     9.0   -11.5250     3.0    13.2826    33.2064    23.2445
    ----------------------------------------------------------------------------------------
    
    best: split after bin 1, gain 37.260357 (lightgbm would print 74.520714)
    which corresponds to the threshold 3.5500
    
    exact search over all 11 thresholds: gain 37.260357 at 3.550
    histogram search with only 4 bins : gain 37.260357 at 3.550
    
    same gain? True
    With 4 bins the approximation happens to land on the same split. Coarser
    bins lose accuracy; finer bins cost more. max_bin is exactly that dial.

    Why this is fast

    Exact split finding costs $O(n \log n)$ per feature per node. Histogram building costs $O(n)$ once, and every node after that costs $O(\#\text{bins})$ regardless of how many rows it holds.

    On 12 rows this is irrelevant. The table below scales the row count up to make the difference visible.

    rows   exact (ms)   histogram (ms)   speedup
    --------------------------------------------------
         1000         0.11             0.08      1.4x
        10000         0.42             0.10      4.3x
       100000         4.52             0.69      6.5x
       500000        32.82             6.11      5.4x
    --------------------------------------------------
    
    And the real saving is bigger than this: the binning pass happens ONCE for
    the whole training run, while the sort in exact mode happens at every node.

    The histogram subtraction trick

    A second saving, and it is nearly free. After splitting a node, LightGBM builds the histogram for the smaller child only, then obtains the other by subtraction:

    $$\text{Hist}_{\text{sibling}} = \text{Hist}_{\text{parent}} - \text{Hist}_{\text{child}}$$

    So the cost of a node pair is set by the smaller child, not the parent.

    parent has 12 rows; children have 6 and 6
    
     bin    parent G    small G   parent-small   direct G   equal
    ------------------------------------------------------------
       0     10.5750    10.5750         0.0000     0.0000    True
       1      5.5750     5.5750         0.0000     0.0000    True
       2     -4.6250     0.0000        -4.6250    -4.6250    True
       3    -11.5250     0.0000       -11.5250   -11.5250    True
    ------------------------------------------------------------
    
    subtraction reproduces the sibling histogram exactly: G True, H True, count True
    
    rows actually scanned: 6 instead of 6 (0 saved at this node alone)

    3. Leaf-wise Growth

    This is the change with the biggest effect on the shape of the tree.

    • Level-wise (XGBoost's default): split every node at depth $d$ before touching depth $d+1$. The tree stays balanced. Capacity is controlled by max_depth.
    • Leaf-wise (LightGBM): keep every splittable leaf in a queue, and always split the one with the highest gain, wherever it is. The tree becomes deep and lopsided. Capacity is controlled by num_leaves.

    For a fixed number of leaves, leaf-wise reaches a lower loss — it spends its budget where the gain is, instead of spreading it evenly. The cost is that it can drive a single branch very deep and overfit, which is why num_leaves (and min_data_in_leaf) matter far more than max_depth in LightGBM.

    LEAF-WISE growth (LightGBM), num_leaves = 4:
    
      step 1: split leaf holding 12 rows on spike_rate <= 3.55   gain 37.2604
      step 2: split leaf holding 6 rows on line_length <= 40.00   gain 3.3292   (next best leaf offered 1.3342)
      step 3: split leaf holding 6 rows on line_length <= 27.50   gain 1.3342
    
    resulting leaves: [2, 4, 4, 2]  depths [2, 2, 2, 2]
    objective: -41.923833
    
    
    LEVEL-WISE growth (XGBoost style), max_depth = 2 -> also 4 leaves:
    
    resulting leaves: [4, 2, 2, 4]  depths [2, 2, 2, 2]
    objective: -41.923833
    
        strategy   leaves   max depth    objective
    ----------------------------------------------
       leaf-wise        4           2     -41.9238
      level-wise        4           2     -41.9238
    ----------------------------------------------
    
    On THIS dataset the two strategies produce the identical tree - same
    objective, same depth. 12 rows with clean structure simply do not offer
    the highest-gain leaf anywhere for level-wise order to matter.
    
    That is worth stating plainly rather than asserting a difference that
    is not there. A dataset where it DOES matter is built below.
    leaves    leaf-wise   level-wise
    ---------------------------------
          2     -37.2604     -37.2604
          3     -40.5896     -41.9238
          4     -41.9238     -41.9238
          5     -42.1450     -42.1450
          6     -42.1450     -42.1450
          8     -42.1450     -42.1450
    ---------------------------------
    
    (lower is better; on this data the two curves lie exactly on top of
    each other - the leaf budget, not the growth order, is what binds here.)

    The two growth strategies, drawn

    The diagrams below are built with the same leaf budget of 4 and the same gain formula. Only the choice of which leaf to split next differs.

    Level-wise fills each depth completely before descending. Leaf-wise repeatedly picks the highest-gain leaf anywhere in the tree.

    On this 12-row table they happen to produce the same tree - the dataset is too small and too cleanly separated for the orders to diverge. The section after this builds a dataset where they genuinely differ.

    Both trees spent exactly 3 splits, and here they spent them the same way.
    On 12 well-separated rows the highest-gain leaf happens to be the next
    one in level order anyway, so the two strategies coincide.

    A dataset where the growth order really does matter

    The two strategies diverge when the signal is concentrated: one region of the feature needs many splits to describe, the rest needs almost none.

    Below, y oscillates sharply for $x < 0.3$ and is flat afterwards. Level-wise spends half its budget describing the flat region, because it is obliged to fill each depth. Leaf-wise pours the budget into the oscillating part, where the gain is.

    400 rows; y oscillates for x < 0.3 and is flat above it
    
     budget   leaf-wise obj   level-wise obj   lw depth   lv depth   leaf-wise better?
    ----------------------------------------------------------------------------------
          4         -199.47          -153.72          3          2                True
          6         -212.42          -212.42          3          3                True
          8         -218.35          -215.45          4          3                True
         12         -220.62          -220.00          5          4                True
         16         -220.62          -220.62          5          5                True
    ----------------------------------------------------------------------------------
    
    leaf-wise is never worse: True
    and at budget 4 it is far better (-199.5 vs -153.7)
    Red segments are the leaves. Level-wise wastes cuts on the flat right side;
    leaf-wise concentrates them on the left where the function actually moves.
    
    This is why num_leaves - not max_depth - is the capacity knob in LightGBM:
    a depth limit barely constrains a tree that grows in one direction, and a
    large num_leaves on a small dataset overfits quickly.

    4. GOSS — Gradient-based One-Side Sampling

    A row with a gradient near zero is already well predicted and contributes almost nothing to any split's gain. GOSS exploits that:

    1. Sort rows by $|g_i|$ and keep the top $a$ fraction — the under-fitted rows.
    2. Randomly sample a $b$ fraction of the remaining rows.
    3. Amplify the sampled ones by $\dfrac{1-a}{b}$ when accumulating $G$ and $H$.

    The amplification is the important part. Without it, throwing away most small-gradient rows would bias the gain estimate; with it, the histogram remains an approximately unbiased estimate of the full-data one.

    a = 0.3 -> keep the 3 largest |g|
    b = 0.3 -> sample 3 of the remaining 9
    amplification factor (1-a)/b = (0.7)/0.3 = 2.3333
    
        id          g       |g|       group    weight     g used
    ----------------------------------------------------------
     rec05    -5.3750    5.3750       LARGE    1.0000    -5.3750
     rec04     3.6250    3.6250       LARGE    1.0000     3.6250
     rec00     3.5250    3.5250       LARGE    1.0000     3.5250
     rec07    -3.4750    3.4750     sampled    2.3333    -8.1083
     rec08     3.4250    3.4250     sampled    2.3333     7.9917
     rec01     3.2250    3.2250     dropped    0.0000     0.0000
     rec09    -3.1750    3.1750     dropped    0.0000    -0.0000
     rec02    -2.6750    2.6750     dropped    0.0000    -0.0000
     rec10     2.1250    2.1250     sampled    2.3333     4.9583
     rec03    -1.0750    1.0750     dropped    0.0000    -0.0000
     rec11    -0.3750    0.3750     dropped    0.0000    -0.0000
     rec06     0.2250    0.2250     dropped    0.0000     0.0000
    ----------------------------------------------------------
    
    rows used: 6 of 12 (50%) - the rest are skipped entirely
    
    best split on FULL data : spike_rate <= 3.55   gain 37.2604
    best split under GOSS   : line_length <= 46.00   gain 45.3354
    same split chosen? False
    
    >>> On 12 rows GOSS throws away half the data and lands on a DIFFERENT
    >>> split, with a gain even larger than the full-data optimum. That is not
    >>> a bug in the method - it is a sample far too small for it to mean
    >>> anything. GOSS is a large-n technique, so it is tested properly below.

    Does the amplification actually work?

    The claim is that $\frac{1-a}{b}$ makes the subsampled gain an approximately unbiased estimate of the full-data gain. On 12 rows that cannot be seen. The experiment below uses 5000 rows and repeats the sampling 200 times, comparing:

    • GOSS with amplification — as LightGBM does it,
    • the same subset without amplification — i.e. naively dropping rows,

    against the true full-data gain.

    N = 5000 rows, a = 0.3, b = 0.3, 200 repetitions
    
                       estimator    mean gain       std   bias vs truth
    --------------------------------------------------------------------
               full data (truth)      582.191     0.000           0.000
               GOSS with (1-a)/b      547.837     6.114         -34.354
     same rows, no amplification      506.542     3.505         -75.649
    --------------------------------------------------------------------
    
    relative bias, amplified   :   5.90%
    relative bias, unamplified :  12.99%
    
    amplification reduces the bias by 55%   (closer to truth: True)
    
    Note that the amplified estimate is NOT exactly unbiased, and it should not
    be expected to be. The (1-a)/b factor makes the sampled SUMS G and H
    unbiased, but the gain is a nonlinear function of them - a ratio of squares -
    so a residual bias survives the correction. What matters in practice is that
    the same split still wins, and the ranking of candidate splits is preserved.
    
    The amplified histogram straddles the true gain; the unamplified one sits
    far to the left, because dropping rows removes their gradient mass entirely.
    That is the whole reason GOSS is not simply 'drop the easy rows'.

    5. Checking Against the Real LightGBM

    Training lightgbm with num_leaves = 3 and matching regularisation should reproduce the same split and the same leaf outputs.

    Two conventions to know when reading its dump:

    • leaf_value is the absolute prediction, i.e. $\text{init} + \eta\,w^{*}$, not the increment.
    • split_gain omits the $\tfrac12$, exactly like XGBoost's dump.
    lightgbm's tree 0:
    
    {
     "split_index": 0,
     "split_feature": 1,
     "split_gain": 74.52069854736328,
     "threshold": 3.5500000000000003,
     "decision_type": "<=",
     "default_left": true,
     "missing_type": "None",
     "internal_value": 3.725,
     "internal_weight": 12,
     "internal_count": 12,
     "left_child": {
      "leaf_index": 0,
      "leaf_value": 3.0328571761115675,
      "leaf_weight": 6,
      "leaf_count": 6
     },
     "right_child": {
      "split_index": 1,
      "split_feature": 0,
      "split_gain": 6.658480167388916,
      "threshold": 39.00000000000001,
      "decision_type": "<=",
      "default_left": true,
      "missing_type": "None",
      "internal_value": 4.41714,
      "internal_weight": 6,
      "internal_count": 6,
      "left_child": {
       "leaf_index": 1,
       "leaf_value": 3.8700000448773304,
       "leaf_weight": 1.9999999999999998,
       "leaf_count": 2
      },
      "right_child": {
       "leaf_index": 2,
       "leaf_value": 4.607000045950214,
       "leaf_weight": 4,
       "leaf_count": 4
    
    
    our leaf-wise tree with num_leaves = 3:
    
     leaf   rows           G      H          w*   init + eta*w*
    ----------------------------------------------------------
        0      6     16.1500      6    -2.30714        3.032857
        1      4    -14.7000      4     2.94000        4.607000
        2      2     -1.4500      2     0.48333        3.870000
    ----------------------------------------------------------
    
    root split : lightgbm = spike_rate <= 3.5500,  gain 74.5207
                 ours     = spike_rate <= 3.5500,  gain 74.5207 (x2 convention)
                 MATCH: True / gain True
    
    leaf values ours     : [np.float64(3.032857), np.float64(3.87), np.float64(4.607)]
    leaf values lightgbm : [3.032857, 3.87, 4.607]
    MATCH: True
    
    predictions MATCH lightgbm: True
       ours     : [3.0329 3.0329 4.607  3.87   3.0329 4.607  3.0329 4.607  3.0329 4.607  3.0329 3.87  ]
       lightgbm : [3.0329 3.0329 4.607  3.87   3.0329 4.607  3.0329 4.607  3.0329 4.607  3.0329 3.87  ]

    6. Summary

    Idea What it changes Cost of the approximation
    Histogram binning thresholds $n-1 \to \#\text{bins}$ splits limited to bin edges
    Histogram subtraction sibling is free none — exact
    Leaf-wise growth split the best leaf, not the next level deeper, easier to overfit
    GOSS uses a fraction of rows small variance, corrected by $\frac{1-a}{b}$
    EFB bundles mutually-exclusive sparse columns negligible on dense data

    The one idea: the mathematics of gradient boosting was already settled by XGBoost. LightGBM's contribution is that the search — not the objective — is where the time goes, so it approximates the search and leaves the objective alone.

    The practical consequence: because growth is leaf-wise, num_leaves is the real capacity knob. Setting max_depth alone does not constrain a LightGBM model the way it constrains an XGBoost one, and a large num_leaves on a small dataset overfits quickly.

    What is deliberately left out: Exclusive Feature Bundling in detail, native categorical handling, DART, and the distributed histogram-merging protocol.

    References

    1. LightGBM: A Highly Efficient Gradient Boosting Decision Tree โ€” Ke et al., 2017 (NeurIPS)