Skip to content

feat(training): opt-in exact quantile leaves (leaf_estimation_method = exact) - #164

Merged
brndnmtthws merged 4 commits into
mainfrom
feat/exact-quantile-leaves
Oct 20, 2026
Merged

brndnmtthws merged 4 commits into
mainfrom
feat/exact-quantile-leaves

Conversation

@brndnmtthws

@brndnmtthws brndnmtthws commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Was stacked on #163 (merged); now targets main, which is merged in.

Change

New opt-in setting TrainingParams::leaf_estimation, a LeafEstimation enum named after CatBoost's leaf_estimation_method (also the flat key). Newton stays the default and is bit-identical to before. With Exact, for reg:quantileerror and reg:absoluteerror, the surrogate gradients still grow each tree's structure. Once the structure is fixed, output j's value in each leaf is set to eta / num_parallel_tree × the alpha_j-quantile (the median for absolute error) of the residuals label - margin_j of the leaf's rows.

Where it's available:

  • Rust: TrainingParams::builder().leaf_estimation(LeafEstimation::Exact)
  • Python: leaf_estimation_method="exact" in params
  • scikit-learn: the estimators take leaf_estimation_method as a constructor parameter

Decisions

  • Which rows: every training row, sampled or not. Each row is routed through the finished tree by its raw values, the same way prediction routes it. XGBoost's adaptive leaves and LightGBM's leaf renewal use the round's sample instead. I measured both on the bands split (all rows vs. sampled rows, test pinball):

    eta q10, all rows q10, sampled q90, all rows q90, sampled
    0.06 1.37363 (161 rounds) 1.38350 (297 rounds) 1.44137 (123 rounds) 1.44107 (123 rounds)
    0.12 1.39870 (156 rounds) 1.39048 (114 rounds) 1.44395 (60 rounds) 1.44639 (66 rounds)

    All rows wins clearly on q10 at eta 0.06, the regime where exact leaves pay off. It also gives each leaf twice the rows at subsample = 0.5, and half of those rows did not choose the leaf's splits.

    The same routing pass then updates the training margins. In a sampled round that replaces the walk Newton leaves need anyway; an unsampled round adds one walk per tree, since Newton leaves read the builder's partitions there. On 1M × 20 rows (depth 6, 30 rounds, 64 threads), exact leaves add 0.12 s with or without sampling, so the walk costs little next to the quantile fit.

  • Quantile definition: the same as the intercept's, i.e. XGBoost's common::Quantile and WeightedQuantile.

    • Without weights: interpolation on the (n + 1)α grid.
    • With sample weights: the first residual whose f32 cumulative weight reaches α of the leaf's total.
    • Residuals are sorted ascending by total_cmp with a stable radix sort, so ties keep row order.
    • A leaf with no rows, or zero total weight, gets 0.
    • The intercept's weighted quantile now goes through the same weighted_step_quantile. Its result is unchanged; partition_point semantics are kept exactly, including for a NaN threshold.
  • Regularization:

    • lambda, alpha, gamma and min_child_weight affect only the structure, through the surrogate gradients.
    • A positive max_delta_step clamps the quantile to ±max_delta_step before eta scales it.
  • Refused (invalid_param("leaf_estimation_method")): every objective other than reg:quantileerror and reg:absoluteerror. Also dart, gblinear, Boulevard, EBMs, monotone constraints, linear_tree, path_smooth, quantized leaf renewal, SGLB / posterior sampling, and process_type = update, because these bound, replace or recompute the leaves themselves. Online updates and budget mode also refuse it, through their existing allow-lists.

  • Unchanged: the model format; models still export to XGBoost (leaves are plain values).

  • Determinism: leaves (and each output of a vector leaf) are fit in parallel. Each is computed from its own sorted residuals, so results do not depend on the thread count.

Numbers

Native bands benchmark: hbbench/exactbands.py, the bands split (2.77M train / 615k valid / 616k test rows), early stopping with patience 40, 16 threads, one scalar model per alpha. Columns are test pinball loss and rounds to early stop.

eta leaves q10 pinball q10 rounds q90 pinball q90 rounds train time (q10 + q90)
0.03 newton 1.37092 1484 1.44138 1281 174.3 s
0.03 exact 1.37374 295 1.44145 254 48.0 s
0.06 newton 1.37192 544 1.44153 719 70.3 s
0.06 exact 1.37363 161 1.44137 123 22.4 s
0.12 newton (shipped) 1.37346 560 1.44271 372 43.3 s
0.12 exact 1.39870 156 1.44395 60 18.9 s

Against the shipped default (Newton, eta 0.12):

  • Exact at eta 0.06, q90: better pinball (1.44137 vs. 1.44271) in 3.0× fewer rounds.
  • Exact at eta 0.06, q10: equal pinball (+0.00017) in 3.5× fewer rounds.
  • Training time: both bands train in about half the time (22.4 s vs. 43.3 s).

So the acceptance bar ("about 2× fewer rounds at equal test pinball, or better pinball at fewer rounds than the default at eta 0.12") is met at eta 0.06. Exact leaves want a smaller eta: at 0.12 the q10 band overshoots.

Vector leaves (one multi_output_tree model for both bands):

eta leaves q10 q90 rounds
0.06 newton 1.37024 1.44307 941
0.06 exact 1.37459 1.44343 203
0.12 newton 1.37028 1.44162 439
0.12 exact 1.37617 1.44475 65

With recency sample weights (WEIGHTED=1, half-life 72 h, eta 0.06): q10 newton 1.42985 at 780 rounds vs. exact 1.42523 at 200 rounds; q90 newton 1.44296 at 783 rounds vs. exact 1.44355 at 155 rounds.

Your Python emulation (leafrefit.py, 500k rows) found the same pattern: about a quarter of the rounds at eta 0.12 with slightly worse pinball, and better pinball at eta 0.03.

Tests

  • tests/exact_leaves.rs: a brute-force reference routes every row by raw value, recomputes each leaf's quantile at the margins of the iterations before its tree's, and compares bit for bit. Cases:

    • levels 0.1 / 0.5 / 0.9 with 25% NaN, subsample 0.6 and colsample_bytree 0.75, under every builder (hist depth-wise and symmetric, approx, exact)
    • sample weights including zero weights
    • vector leaves
    • forests (num_parallel_tree = 3), scalar and vector leaves: every tree of an iteration fits the same starting margins
    • reg:absoluteerror on a two-column label matrix (each output's own column)
    • max_delta_step clamping (the bound binds)
    • identical models at 1 and 4 threads
    • the refusals

    Perturbing alpha in the implementation makes five of the reference tests fail.

  • Review fix (Codex): a vector-leaf forest added each tree to the training margins before fitting the next, so its later trees took their exact leaves against margins that already held the earlier ones. The vector path now grows all of an iteration's trees before any joins the margins, as the scalar path does. The forest case above failed before the fix (tree 1 leaf 7 output 0). Newton vector-leaf forests (subsampled and not) and a scalar forest keep the same model and margin hashes.

  • Flat-key round trip in config/xgboost/tests.rs.

  • Default path: bitident.py (vector and scalar configs at 1, 4 and 16 threads) gives the same model and margin hashes as main. With the review fix: cargo nextest run --all-features (867 passed), cargo test --doc --all-features, clippy --all-features -D warnings (CUDA headers installed locally) and rustdoc -D warnings are clean. Python: pytest (435 passed), ruff and ty are clean.

GHistIndex::column_bins() is Some only for dense indexes, so any matrix
with missing values sent every vector-leaf (multi_output_tree) node
through the serial CSR loop, whatever the thread count.

NodeHistograms now sweeps the feature-major copy whenever the index keeps
one: dense columns, or missing_columns() skipping the width's sentinel.
Tasks of four pool features read each row's split gradients once, and only
the tree's colsample_bytree pool is built (every node's candidate features
are a subset of it). Each bin still adds its rows in one ascending chain,
so models are bit-identical at every thread count.
…= exact)

reg:quantileerror and reg:absoluteerror grow their structure on smooth
surrogates and keep Newton leaves by default. LeafEstimation::Exact
(CatBoost's leaf_estimation_method) sets output j of every leaf to eta
times the alpha_j residual quantile (the median for absolute error) of the
training rows the finished tree routes there: every row, sampled or not,
by raw value, with the intercept's interpolated or weighted-step quantile.
A positive max_delta_step bounds the quantile; lambda, alpha, gamma and
min_child_weight act on the structure only.

Scalar and vector leaves, several levels, label matrices and sample
weights are supported; the all-row partition also adds the tree to the
training margins. dart, gblinear, Boulevard, EBMs, monotone constraints,
linear_tree, path_smooth, quantized leaf renewal, SGLB and refresh are
refused. Python takes it as a params key, the scikit-learn estimators as a
constructor parameter. The default (Newton) path is unchanged bit for bit.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T17:26:07.294423Z 5386243 New commits
🔒 Security Review ✅ Completed 2026-10-20T17:27:37.211983Z 5386243 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2ed0b44b5a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/training/multi_output.rs Resolved
…n's margins

With `leaf_estimation_method = exact`, `multi_output_tree`, and
`num_parallel_tree > 1`, each vector-leaf tree was added to the training
margins before the next one was fitted, so later trees of the iteration took
their residual quantiles against margins that already held the earlier ones
while their gradients came from the iteration's start. The vector path now
grows all of an iteration's trees before any joins the margins, as the
scalar path does; Newton leaves never read the margins, so their models are
unchanged.

The exact-leaf reference tests now cover forests (scalar and vector leaves,
`eta / num_parallel_tree`, outputs grouped by forest) and every builder
(hist depth-wise and symmetric, approx, exact).
@brndnmtthws

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 23b1f1aa98

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 23b1f1aa98

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@brndnmtthws
brndnmtthws deleted the branch perf/vector-leaf-feature-sweep October 20, 2026 17:17
@brndnmtthws brndnmtthws reopened this Oct 20, 2026
@brndnmtthws
brndnmtthws changed the base branch from perf/vector-leaf-feature-sweep to main October 20, 2026 17:18
…eaves

# Conflicts:
#	AGENTS.md
#	src/data/sort.rs
#	src/tree/builder/multi.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant