hessboost 0.1.1

A faithful, fast, pure-Rust reimplementation of XGBoost gradient boosting
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
//! Tree construction algorithms.
//!
//! Each builder grows a single [`crate::tree::RegTree`] from per-instance
//! gradients. The exact builder is the reference; the histogram builder shares
//! the same regularized gain math.

mod exact;
mod hist;

pub use exact::{ExactTreeBuilder, SortedColumns, all_features, all_rows};
pub use hist::HistTreeBuilder;
pub(crate) use hist::LeafRows;

use std::collections::BTreeSet;

use crate::objective::GradPair;
use crate::tree::constraints::{Bounds, calc_weight_bounded, gain_at_weight, satisfies};
use crate::tree::gain::{GradStats, RegParams, calc_gain, threshold_l1};
use crate::tree::regtree::RegTree;

/// Tiny epsilon guarding against accepting numerically-zero-gain splits, mirror
/// of XGBoost's `kRtEps`.
pub(super) const K_RT_EPS: f64 = 1e-6;

/// The best split found so far for one node.
///
/// Both builders share this. `threshold` is the exact split value while
/// `split_bin` is the histogram global-bin boundary (`Some(b)`: bins `<= b` go
/// left; `None`: no present bin goes left, only missing values); each builder
/// writes its own location field and leaves the other at its default.
#[derive(Debug, Clone)]
pub(super) struct BestSplit {
    pub(super) loss_chg: f64,
    pub(super) feature: u32,
    pub(super) threshold: f32,
    pub(super) split_bin: Option<usize>,
    pub(super) default_left: bool,
    pub(super) left: GradStats,
    pub(super) right: GradStats,
    /// Bounded child weights (used to derive monotone child bounds).
    pub(super) w_left: f64,
    pub(super) w_right: f64,
    /// Whether this is a categorical (set-membership) split.
    pub(super) is_categorical: bool,
    /// For a categorical split, the category values routed left.
    pub(super) cat_left: Vec<u32>,
}

impl BestSplit {
    pub(super) fn none() -> Self {
        BestSplit {
            loss_chg: 0.0,
            feature: 0,
            threshold: 0.0,
            split_bin: None,
            default_left: true,
            left: GradStats::default(),
            right: GradStats::default(),
            w_left: 0.0,
            w_right: 0.0,
            is_categorical: false,
            cat_left: Vec::new(),
        }
    }

    /// A numeric split candidate; `pos` carries the split location (value-space
    /// threshold for exact search, global-bin boundary for histogram search).
    #[allow(clippy::too_many_arguments)]
    pub(super) fn numeric(
        loss_chg: f64,
        feature: u32,
        pos: SplitPos,
        default_left: bool,
        left: GradStats,
        right: GradStats,
        w_left: f64,
        w_right: f64,
    ) -> Self {
        let (threshold, split_bin) = match pos {
            SplitPos::Value(t) => (t, None),
            SplitPos::Bin(b) => (0.0, Some(b)),
            SplitPos::BelowBins => (0.0, None),
        };
        BestSplit {
            loss_chg,
            feature,
            threshold,
            split_bin,
            default_left,
            left,
            right,
            w_left,
            w_right,
            is_categorical: false,
            cat_left: Vec::new(),
        }
    }

    /// A categorical (set-membership) split candidate; `cat_left` holds the
    /// category values routed left.
    #[allow(clippy::too_many_arguments)]
    pub(super) fn categorical(
        loss_chg: f64,
        feature: u32,
        left: GradStats,
        right: GradStats,
        w_left: f64,
        w_right: f64,
        cat_left: Vec<u32>,
    ) -> Self {
        BestSplit {
            loss_chg,
            feature,
            threshold: 0.0,
            split_bin: None,
            // Present categories not in the left set (and missing) go
            // right, as XGBoost defaults for categorical features.
            default_left: false,
            left,
            right,
            w_left,
            w_right,
            is_categorical: true,
            cat_left,
        }
    }

    #[inline]
    pub(super) fn found(&self) -> bool {
        self.loss_chg > K_RT_EPS
    }

    /// Whether this split should be taken: it was found, its loss change
    /// reaches `gamma` (XGBoost rejects `loss_chg < min_split_loss`), and both
    /// children have positive cover and meet `min_child_weight`.
    pub(super) fn valid(&self, gamma: f64, min_child_weight: f64) -> bool {
        self.found()
            && self.loss_chg >= gamma
            && self.left.hess > 0.0
            && self.right.hess > 0.0
            && self.left.hess >= min_child_weight
            && self.right.hess >= min_child_weight
    }
}

/// Gain of one candidate split plus its bounded child weights, or `None` when
/// a monotone constraint is violated. Unconstrained builds take the cheap
/// closed-form path (weights unused).
#[inline]
pub(super) fn candidate_gain(
    left: GradStats,
    right: GradStats,
    parent: f64,
    bounds: Bounds,
    dir: i8,
    constrained: bool,
    reg: &RegParams,
) -> Option<(f64, f64, f64)> {
    if constrained {
        let wl = calc_weight_bounded(left, reg, bounds);
        let wr = calc_weight_bounded(right, reg, bounds);
        if !satisfies(dir, wl, wr) {
            return None;
        }
        let g = gain_at_weight(left, reg, wl) + gain_at_weight(right, reg, wr) - parent;
        Some((g, wl, wr))
    } else {
        let g = calc_gain(left, reg) + calc_gain(right, reg) - parent;
        Some((g, 0.0, 0.0))
    }
}

/// Where a numeric candidate split falls. Exact search records the threshold
/// in value space; histogram search records the global-bin boundary (bins
/// `<= split_bin` go left) or `BelowBins`, XGBoost's backward-pass endpoint
/// (`NumericBinLowerBound` at the feature's first bin, `-inf`): every present
/// bin goes right and only missing values go left.
#[derive(Debug, Clone, Copy)]
pub(super) enum SplitPos {
    Value(f32),
    Bin(usize),
    BelowBins,
}

/// A finite threshold below every finite feature value: `x < BELOW_ALL_VALUES`
/// is false for all finite `x`, so a split at it routes every present row
/// right and only missing values (`default_left`) left. Stands in for the
/// `-inf` XGBoost stores (`NumericBinLowerBound` at a feature's first bin, or
/// an overflowed `ColMaker` endpoint) because trees here require a finite
/// `split_cond`.
pub(super) const BELOW_ALL_VALUES: f32 = f32::MIN;

/// XGBoost's `SplitEvaluator::CalcWeight`: the regularized optimum computed in
/// `f64`, rounded to `f32`, then clamped to the node's monotone bounds. The
/// `f32` rounding happens before bounding, exactly as upstream.
#[inline]
pub(super) fn xgb_weight(stats: GradStats, reg: &RegParams, bounds: Bounds) -> f32 {
    let w = if stats.hess <= 0.0 {
        0.0
    } else {
        let mut w = -threshold_l1(stats.grad, reg.alpha) / (stats.hess + reg.lambda);
        if reg.max_delta_step != 0.0 && w.abs() > reg.max_delta_step {
            w = reg.max_delta_step.copysign(w);
        }
        w
    } as f32;
    let (lower, upper) = (bounds.lower as f32, bounds.upper as f32);
    if w < lower {
        lower
    } else if w > upper {
        upper
    } else {
        w
    }
}

/// XGBoost's `CalcGainGivenWeight` with an `f32` weight: `−(2Gw + (H+λ)w² +
/// 2α|w|)` where `w²` is formed in `f32` (upstream `Sqr(float)`) and every
/// other operation runs in `f64`.
#[inline]
fn xgb_gain_given_weight(stats: GradStats, reg: &RegParams, w: f32) -> f64 {
    -(2.0 * stats.grad * f64::from(w)
        + (stats.hess + reg.lambda) * f64::from(w * w)
        + 2.0 * reg.alpha * f64::from(w.abs()))
}

/// XGBoost's scalar `TreeEvaluator::CalcGain` for a node: the given-weight
/// gain at the `f32` (bounded) weight, rounded to `f32` as upstream stores
/// `root_gain`. The histogram and exact updaters both use this form, so the
/// parent baseline carries the same `f32` weight rounding as every candidate.
pub(super) fn xgb_node_gain(stats: GradStats, reg: &RegParams, bounds: Bounds) -> f32 {
    if stats.hess <= 0.0 {
        return 0.0;
    }
    xgb_gain_given_weight(stats, reg, xgb_weight(stats, reg, bounds)) as f32
}

/// XGBoost's scalar `SplitEvaluator::CalcSplitGain` minus the parent's
/// `root_gain`, i.e. the `loss_chg` a candidate is compared and stored with.
/// Returns `None` when the split is invalid (a child without positive Hessian
/// or below `min_child_weight`) or violates the monotone direction `dir`, and
/// otherwise the `f32` loss change plus both bounded child weights.
#[inline]
pub(super) fn xgb_loss_chg(
    left: GradStats,
    right: GradStats,
    root_gain: f32,
    reg: &RegParams,
    bounds: Bounds,
    dir: i8,
) -> Option<(f32, f32, f32)> {
    let mcw = reg.min_child_weight;
    if !(left.hess > 0.0 && right.hess > 0.0 && left.hess >= mcw && right.hess >= mcw) {
        return None;
    }
    let wl = xgb_weight(left, reg, bounds);
    let wr = xgb_weight(right, reg, bounds);
    if !satisfies(dir, f64::from(wl), f64::from(wr)) {
        return None;
    }
    // Upstream's scalar `CalcGainGivenWeight` returns `float`: each child's
    // score is rounded before the two are added in `f32`.
    let gain =
        xgb_gain_given_weight(left, reg, wl) as f32 + xgb_gain_given_weight(right, reg, wr) as f32;
    Some((gain - root_gain, wl, wr))
}

/// XGBoost's `SplitEntry::Update`: replace the incumbent when the candidate's
/// loss change is strictly better, or equal on a lower feature index. Infinite
/// loss changes are never taken. `best.loss_chg` holds an `f32` value.
#[allow(clippy::too_many_arguments)]
pub(super) fn xgb_update(
    best: &mut BestSplit,
    loss_chg: f32,
    feature: u32,
    pos: SplitPos,
    default_left: bool,
    left: GradStats,
    right: GradStats,
    w_left: f32,
    w_right: f32,
) -> bool {
    if loss_chg.is_infinite() {
        return false;
    }
    let incumbent = best.loss_chg as f32;
    let replace = if best.feature <= feature {
        loss_chg > incumbent
    } else {
        incumbent.partial_cmp(&loss_chg) != Some(std::cmp::Ordering::Greater)
    };
    if replace {
        *best = BestSplit::numeric(
            f64::from(loss_chg),
            feature,
            pos,
            default_left,
            left,
            right,
            f64::from(w_left),
            f64::from(w_right),
        );
    }
    replace
}

/// Sweep prefix partitions of categories ordered by gradient/Hessian ratio
/// (XGBoost's sorted-partition strategy: the best subset is contiguous in
/// that order) and record the best set-membership split in `best`. Prefix
/// categories form the left set; every other present category — and missing
/// — goes right. Callers supply the `(category, stats)` pairs from their own
/// stat source (sorted-column map for exact search, histogram bins for
/// histogram search) and keep their own empty-bin filtering.
#[allow(clippy::too_many_arguments)]
pub(super) fn sweep_categorical(
    best: &mut BestSplit,
    cats: &mut [(u32, GradStats)],
    total: GradStats,
    parent_gain: f64,
    bounds: Bounds,
    dir: i8,
    constrained: bool,
    reg: &RegParams,
    feature: u32,
) {
    if cats.len() < 2 {
        return; // no interior partition
    }
    let ratio = |s: GradStats| s.grad / (s.hess + reg.lambda);
    cats.sort_by(|a, b| ratio(a.1).total_cmp(&ratio(b.1)));

    let mcw = reg.min_child_weight;
    let mut left = GradStats::default();
    let mut cats_left: Vec<u32> = Vec::new();
    // Sweep prefixes, always leaving at least one category on the right.
    for &(cat, s) in &cats[..cats.len() - 1] {
        left.add(s);
        cats_left.push(cat);
        // `total` includes any missing mass, which stays on the right.
        let right = total.sub(left);
        if left.hess < mcw || right.hess < mcw {
            continue;
        }
        let Some((g, wl, wr)) =
            candidate_gain(left, right, parent_gain, bounds, dir, constrained, reg)
        else {
            continue;
        };
        if g > best.loss_chg + K_RT_EPS {
            *best = BestSplit::categorical(g, feature, left, right, wl, wr, cats_left.clone());
        }
    }
}

/// XGBoost interaction-constraint state for one node: every split feature on
/// the root-to-node path and the features still permitted there.
#[derive(Clone)]
pub(super) struct InteractionState {
    path: Vec<u32>,
    allowed: Vec<u32>,
}

/// Normalize configured interaction groups. `None` disables filtering.
pub(super) fn build_interaction_sets(groups: &[Vec<u32>]) -> Option<Vec<Vec<u32>>> {
    if groups.is_empty() {
        return None;
    }
    Some(
        groups
            .iter()
            .map(|group| {
                let mut group = group.clone();
                group.sort_unstable();
                group.dedup();
                group
            })
            .collect(),
    )
}

/// Derive each child's state after splitting `feature`. XGBoost permits every
/// feature already used on the path plus every member of a constraint group
/// containing the *entire* updated path.
pub(super) fn next_allowed(
    parent: Option<&InteractionState>,
    feature: u32,
    groups: Option<&[Vec<u32>]>,
) -> Option<InteractionState> {
    let groups = groups?;
    let mut path = parent.map_or_else(Vec::new, |state| state.path.clone());
    if let Err(pos) = path.binary_search(&feature) {
        path.insert(pos, feature);
    }
    let mut allowed: BTreeSet<u32> = path.iter().copied().collect();
    for group in groups {
        if path
            .iter()
            .all(|feature| group.binary_search(feature).is_ok())
        {
            allowed.extend(group.iter().copied());
        }
    }
    Some(InteractionState {
        path,
        allowed: allowed.into_iter().collect(),
    })
}

/// Whether `feature` is permitted at a node. `None` means constraints inactive
/// or the root (where every feature is allowed).
pub(super) fn permits(state: Option<&InteractionState>, feature: u32) -> bool {
    state.is_none_or(|state| state.allowed.binary_search(&feature).is_ok())
}

/// Sum the gradient pairs of `rows`, in row order. Shared by both builders'
/// root-statistics accumulation.
pub(super) fn sum_rows(gpair: &[GradPair], rows: &[u32]) -> GradStats {
    let mut total = GradStats::default();
    for &r in rows {
        total.add(GradStats::from_pair(gpair[r as usize]));
    }
    total
}

/// Set every leaf's weight from its stored statistics, respecting each leaf's
/// monotone bounds. Shared by both builders' final pass.
pub(super) fn finalize_leaf_values(
    tree: &mut RegTree,
    stats: &[GradStats],
    bounds: &[Bounds],
    reg: &RegParams,
) {
    #[allow(clippy::needless_range_loop)]
    for id in 0..tree.num_nodes() {
        if tree.node(id).is_leaf() {
            let w = calc_weight_bounded(stats[id], reg, bounds[id]);
            tree.set_leaf_value(id, w as f32);
        }
    }
}

#[cfg(test)]
mod test_support {
    use crate::data::DMatrix;
    use crate::objective::GradPair;

    pub(super) fn gp(g: f32, h: f32) -> GradPair {
        GradPair::new(g, h)
    }

    /// Data where the *unconstrained* fit would be non-monotone: a V shape.
    /// y dips in the middle, so an unconstrained tree would go down then up.
    pub(super) fn monotone_v_shape_data() -> (DMatrix, Vec<GradPair>) {
        let n = 60;
        let mut x = Vec::new();
        let mut gpair = Vec::new();
        for i in 0..n {
            let xi = i as f32 / n as f32;
            x.push(xi);
            let target = (xi - 0.5).abs(); // V shape, non-monotone
            gpair.push(gp(-(target - 0.25), 1.0)); // pseudo-residual around mean
        }
        (DMatrix::from_dense(&x, n, 1).unwrap(), gpair)
    }
}