Skip to content

Center RF split thresholds across empty quantile bins - #8283

Merged
rapids-bot[bot] merged 14 commits into
NVIDIA:mainfrom
RAMitchell:codex/bug-rf-empty-bin-center
Jun 26, 2026
Merged

Center RF split thresholds across empty quantile bins#8283
rapids-bot[bot] merged 14 commits into
NVIDIA:mainfrom
RAMitchell:codex/bug-rf-empty-bin-center

Conversation

@RAMitchell

Copy link
Copy Markdown
Contributor

Summary

Addresses #6416.

This updates the batched decision-tree builder so the selected split keeps track of the histogram bin that produced the best gain. When a best split lands inside a run of empty quantile bins, the training partition and gain are identical across that run, but the stored inference threshold is not. We now recenter the selected split to the middle bin of that equivalent plateau instead of leaving the threshold at an edge.

This is intended to better match sklearn's exact-tree behavior, where stored thresholds sit between observed values rather than at an observed endpoint.

Changes

  • Add binid to Split so the winning quantile bin survives reduction and host copies.
  • Refine the winning split threshold by finding the contiguous same-left-count plateau for the selected feature.
  • Share the same refinement helper for classification and regression.
  • Add C++ coverage for classification and regression empty-bin plateau behavior.

Experiments

All accuracy intervals below are 95% CI half-widths over 10 randomized train/test splits.

Original CoverType reproduction on latest main:

  • sklearn: 95.5372% +/- 0.0318%
  • cuML default: 95.3727% +/- 0.0341%
  • sklearn - cuML gap: +0.1645 +/- 0.0305 percentage points

Increasing bins did not remove the gap:

  • cuML n_bins=512: 95.4154% +/- 0.0426%
  • sklearn - cuML gap: +0.1219 +/- 0.0350 percentage points

Quantizing the input to 256 per-feature values also did not remove it:

  • sklearn quantized: 95.5128% +/- 0.0293%
  • cuML quantized, n_bins=512: 95.4097% +/- 0.0535%
  • sklearn - cuML gap: +0.1031 +/- 0.0289 percentage points

A post-hoc node-local midpoint diagnostic almost eliminated the CoverType gap:

  • original cuML/exported: 95.3511% +/- 0.0479%
  • midpoint-adjusted export: 95.5349% +/- 0.0525%
  • sklearn - adjusted cuML gap: +0.0023 +/- 0.0379 percentage points

With this empty-bin plateau-centering strategy on CoverType:

  • sklearn: 95.5359% +/- 0.0456%
  • cuML default: 95.5709% +/- 0.0344%
  • sklearn - cuML gap: -0.0349 +/- 0.0465 percentage points
  • cuML n_bins=512: 95.7071% +/- 0.0349%
  • sklearn - cuML n_bins=512 gap: -0.1712 +/- 0.0365 percentage points

A broader 5-dataset prototype run with 10 randomized splits did not show a remaining consistent cuML deficit across CoverType, phoneme, spambase, satimage, and a synthetic hard classification dataset.

Validation

  • pre-commit passed on the committed files.
  • Targeted C++ build was attempted for SG_RF_TEST; the edited CUDA objects compiled, but final link hit the existing local mixed-toolchain/nvforest link failure.

@copy-pr-bot

copy-pr-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@RAMitchell RAMitchell added improvement Improvement / enhancement to an existing function non-breaking Non-breaking change labels Jun 23, 2026
@RAMitchell
RAMitchell requested a review from Copilot June 23, 2026 13:29
@RAMitchell
RAMitchell force-pushed the codex/bug-rf-empty-bin-center branch from e5f0f5d to 386b963 Compare June 23, 2026 13:32

Copilot AI 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.

Pull request overview

This PR improves cuML’s batched decision-tree split selection for RandomForest by carrying the winning histogram bin through reduction and re-centering inference thresholds when the best split falls within a contiguous “empty quantile bin” plateau, aiming to better match scikit-learn’s threshold placement behavior.

Changes:

  • Extend DT::Split to retain the winning histogram binid and refine the final threshold across empty-bin plateaus.
  • Update objective functions and split-reduction plumbing to propagate binid end-to-end.
  • Add C++ tests covering the plateau-centering behavior for both classification and regression.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
cpp/tests/sg/rf_test.cu Adds GPU-side unit tests validating plateau-centered split selection for classification/regression.
cpp/src/decisiontree/batched-levelalgo/split.cuh Adds binid to Split and introduces plateau-centering refinement logic.
cpp/src/decisiontree/batched-levelalgo/objectives.cuh Propagates binid from per-bin evaluation into Split updates; reuses shared CountLeft.
cpp/src/decisiontree/batched-levelalgo/kernels/builder_kernels_impl.cuh Updates evalBestSplit invocation to pass objective/hist/quantiles for refinement.
cpp/src/decisiontree/batched-levelalgo/builder.cuh Updates host-side split staging/copying to include the new binid field.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cpp/src/decisiontree/batched-levelalgo/split.cuh Outdated
@RAMitchell
RAMitchell marked this pull request as ready for review June 24, 2026 09:58
@RAMitchell
RAMitchell requested a review from a team as a code owner June 24, 2026 09:58
@RAMitchell
RAMitchell requested review from csadorf and jinsolp June 24, 2026 09:58
@RAMitchell
RAMitchell force-pushed the codex/bug-rf-empty-bin-center branch from 2bb7b5f to b3e69d0 Compare June 24, 2026 09:59
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds split-range tracking to split selection, threads the new range fields through objective scoring, kernel execution, and host compaction, and adds CUDA tests for classification and regression cases.

Changes

Equivalent split-range tracking

Layer / File(s) Summary
Split range state and reduction
cpp/src/decisiontree/batched-levelalgo/split.cuh
Split stores split_start and split_end, initializes and copies the new range fields, updates tie handling around equivalent ranges, reduces range state across warps, commits the midpoint split value, and prints the range in debug output. detail::CountLeft is added for aggregated left-count computation.
Objective scoring updates
cpp/src/decisiontree/batched-levelalgo/objectives.cuh
ClassificationObjectiveFunction::Gain and RegressionObjectiveFunction::Gain use detail::CountLeft for left-side counts and pass the current bin index into sp.update.
Kernel scratch and host compaction
cpp/src/decisiontree/batched-levelalgo/kernels/builder_kernels_impl.cuh, cpp/src/decisiontree/batched-levelalgo/builder.cuh
computeSplitKernel allocates typed shared-memory split scratch and calls the updated split evaluation entry point with quantiles and bin count. Builder::doSplit extends HostSplit, copies the new range fields during compaction, and recomputes split shared-memory sizing with the revised CUB-based sizing logic.
CUDA split-range tests
cpp/tests/sg/rf_test.cu
rf_test.cu adds the objective include, defines objectiveGainKernel, adds classification and regression tests that assert the resulting DT::Split fields, and updates histogram generation to use the split range end index.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • rapidsai/cuml#8233: Modifies ClassificationObjectiveFunction::Gain and RegressionObjectiveFunction::Gain split-scoring logic, overlapping with this PR's objective updates.
  • rapidsai/cuml#8247: Also changes decision-tree objective gain logic and related cpp/tests/sg/rf_test.cu coverage around split semantics.

Suggested reviewers

  • hcho3
  • viclafargue
  • lowener
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: recentring RF split thresholds over empty quantile-bin plateaus.
Description check ✅ Passed The description is directly related and accurately describes the same plateau-centering split-threshold update.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cpp/src/decisiontree/batched-levelalgo/kernels/builder_kernels_impl.cuh`:
- Around line 221-224: `computeSplitKernel` now uses static `__shared__`
`split_scratch_storage`, but `Builder::computeSplitSmemSize()` still only
accounts for the dynamic shared-memory region. Update the launch sizing/budget
check to include the `Split<DataT, IdxT>` scratch bytes explicitly, or move that
storage back under `extern __shared__` so the total per-block shared-memory
usage stays within device limits. Use the existing `computeSplitKernel`,
`split_scratch_storage`, and `computeSplitSmemSize()` symbols to keep the kernel
launch accounting consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 6f407564-786a-4c8d-890e-7b201d5bce09

📥 Commits

Reviewing files that changed from the base of the PR and between 6691f8a and 2bb7b5f.

📒 Files selected for processing (5)
  • cpp/src/decisiontree/batched-levelalgo/builder.cuh
  • cpp/src/decisiontree/batched-levelalgo/kernels/builder_kernels_impl.cuh
  • cpp/src/decisiontree/batched-levelalgo/objectives.cuh
  • cpp/src/decisiontree/batched-levelalgo/split.cuh
  • cpp/tests/sg/rf_test.cu

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cpp/src/decisiontree/batched-levelalgo/builder.cuh`:
- Around line 511-525: Use checked arithmetic for the shared-memory launch
sizing in the builder logic: the dynamic shared-memory computation in the sizing
code around the max_n_bins/dataset.num_outputs/bin size math should be rewritten
to use ML::checked_mul and ML::checked_add for every host-side
multiplication/addition, including the later alignment and CUB/scratch size
accumulation, so overflow cannot under-budget shared memory or corrupt the
returned dynamic smem size.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 8467b29b-42d2-41d8-ae4a-f3592bd70c67

📥 Commits

Reviewing files that changed from the base of the PR and between b3e69d0 and 57131cb.

📒 Files selected for processing (4)
  • cpp/src/decisiontree/batched-levelalgo/builder.cuh
  • cpp/src/decisiontree/batched-levelalgo/kernels/builder_kernels_impl.cuh
  • cpp/src/decisiontree/batched-levelalgo/split.cuh
  • cpp/tests/sg/rf_test.cu

Comment thread cpp/src/decisiontree/batched-levelalgo/builder.cuh Outdated
@csadorf
csadorf requested a review from chyunsu3 June 24, 2026 14:13
@csadorf

csadorf commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

@hcho3 Can you give this a first review pass, please?

@dantegd dantegd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Change looks good to me, just left a non blocking testing question

Comment thread cpp/tests/sg/rf_test.cu
split.evalBestSplit(split_scratch, out, mutex, quantiles, n_bins);
}

TEST(RFEquivalentSplitRangeTest, ClassificationChoosesUpperMiddleBin)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These tests cover the objective/reduction helper path, but not the PR’s end-to-end RF builder behavior. What do you think about adding a small fit/predict or tree-threshold test with quantiles that include an empty plateau, so we also exercise computeSplitKernel, the host split copy-back, node partitioning, and the threshold persisted into the tree?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done!

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
cpp/tests/sg/rf_test.cu (1)

1016-1028: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

HostSplit mirror is validated only by total size, not field layout.

static_assert(sizeof(HostSplit) == sizeof(DT::Split<...>)) followed by a raw cudaMemcpyAsync into HostSplit relies on the field offsets of DT::Split matching HostSplit exactly. A future field reorder/repad in Split that preserves size would silently make these tests read wrong members instead of failing to compile. This same block is duplicated in the regression test (Lines 1072-1083). Consider copying directly into a DT::Split<DataT, IdxT> (it is POD-compatible) or adding offsetof assertions per field.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cpp/tests/sg/rf_test.cu` around lines 1016 - 1028, The HostSplit mirror check
only compares total size and then memcpy’s the device object into a differently
laid out struct, so a field reorder or padding change in DT::Split<DataT, IdxT>
could break the test silently. Update the rf_test.cu validation blocks around
HostSplit/DT::Split to either copy directly into DT::Split<DataT, IdxT> or add
explicit offsetof/static layout assertions for each member (quesval, colid,
best_metric_val, nLeft, split_start, split_end), and apply the same fix to the
duplicated regression test block.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@cpp/tests/sg/rf_test.cu`:
- Around line 1016-1028: The HostSplit mirror check only compares total size and
then memcpy’s the device object into a differently laid out struct, so a field
reorder or padding change in DT::Split<DataT, IdxT> could break the test
silently. Update the rf_test.cu validation blocks around HostSplit/DT::Split to
either copy directly into DT::Split<DataT, IdxT> or add explicit offsetof/static
layout assertions for each member (quesval, colid, best_metric_val, nLeft,
split_start, split_end), and apply the same fix to the duplicated regression
test block.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 6b89f1f1-2351-46f7-8e71-b14c486a9369

📥 Commits

Reviewing files that changed from the base of the PR and between bfd45b0 and 9a4229a.

📒 Files selected for processing (1)
  • cpp/tests/sg/rf_test.cu

@jcrist

jcrist commented Jun 24, 2026

Copy link
Copy Markdown
Member

/merge

@RAMitchell

This comment has been minimized.

@csadorf

This comment has been minimized.

@csadorf
csadorf requested a review from a team as a code owner June 26, 2026 16:59
@csadorf
csadorf requested a review from viclafargue June 26, 2026 16:59
@github-actions github-actions Bot added the Cython / Python Cython or Python issue label Jun 26, 2026
@csadorf

csadorf commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

/ok to test e367ccb

@rapids-bot
rapids-bot Bot merged commit 020d8e3 into NVIDIA:main Jun 26, 2026
102 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CUDA/C++ Cython / Python Cython or Python issue improvement Improvement / enhancement to an existing function non-breaking Non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants