[cuda] implement basic monotone constraint enforcement on the CUDA tree learner#16
Conversation
|
Thank you, Max — and thank you, Claude Code (independently 🙂). This one's a clean bill of health. I ran a deep correctness review of the device math against the CPU
Non-blocking note: Only thing standing between this and merge: it fails the required P.S. — you wrote a 4,500-point monotonicity grid harness with bit-exact CPU parity… and then didn't run |
|
Thank you, Max — and thank you, Claude Code (separately 🙂). Rebase + lint nudge 👉 The monotone-constraint device math reviewed out clean. Two things before this can land:
Write access is live now, so once it's green you can merge it yourself. 🚀 |
b3accb1 to
025cceb
Compare
|
Rebased onto current Please re-review — there's one behavioural change, called out below. Your two lint items ✅
(For what it's worth — I'd been running
|
…ee learner Implements the "basic" monotone constraint method on CUDA so that device_type="cuda" enforces monotone_constraints like device_type="cpu": - CalculateSplittedLeafOutputMC / GetSplitGainsMC device functions mirror the CPU USE_MC formulas: candidate child outputs are clamped into the leaf's [min, max] constraint range and splits whose clamped outputs violate the requested monotone direction are rejected (gain 0) - each SplitFindTask carries its feature's monotone type (real-feature-indexed config mapped to inner feature indices) - per-leaf [min, max] constraint ranges are tracked on host, mirroring BasicLeafConstraints::Update midpoint propagation, and passed to the split kernels for the (smaller, larger) leaf pair each iteration - the leaf VALUES stored in CUDASplitInfo are the clamped outputs (used for the tree and for constraint propagation), while the leaf GAINS keep their unconstrained values (they form the child's parent_gain baseline, matching the CPU BeforeNumerical gain_shift computation) - unsupported monotone configurations on CUDA fail fast with a clear error: monotone_constraints_method=intermediate/advanced, monotone_penalty>0, and use_quantized_grad+monotone This replaces the previous Log::Fatal guard: basic-method monotone constraints now work on CUDA instead of being rejected. Before this change CUDA silently ignored monotone constraints: on a 600x3 regression with constraints [1,1,1] over 100 rounds, the CUDA model violated monotonicity at 456 of 4500 grid points checked (worst violation 0.39), with predictions diverging from CPU by up to 2.8. After this change CUDA models have ZERO monotonicity violations across all tested constraint vectors, round counts and seeds, with training MSE matching CPU within 3%. Note on tree-structure parity: CPU and CUDA can build different (both valid) constrained trees because CPU additionally prunes candidate features through its FeatureHistogram::is_splittable_ cache, a search-space heuristic whose exact replication on GPU would require per-(leaf, feature) state tracking with additional host-device round trips. The constraint guarantee itself - the property users rely on - is enforced identically, predictions match bit-for-bit when constraints are inactive ([0,0,0]), and constrained training quality is equivalent (MSE within 3% of CPU, sometimes better). Adds 18 regression tests to test_dual.py: 12 enforcement cases (zero violations required), 3 CPU-quality-equivalence cases, 2 noop-constraint bit-parity cases, and 1 unsupported-config rejection case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 9349df8)
025cceb to
8dd0deb
Compare
|
Re-rebased onto current Monotone constraints are gated off the batched pathSame class of problem as CEGB (#18). Monotone bounds are inherited: a leaf's Fix: fall back to the classic leaf-wise loop when if (!config_->monotone_constraints.empty()) {
use_hybrid_growth_ = false;
}The batched kernels correspondingly instantiate the inner split kernel with Integration notes
Your two lint items ✅
ValidationRTX 5090, CUDA 13.2: 18 monotone tests pass (enforcement, CPU quality parity, exact no-op parity, 4 rejection cases); Validated with Claude Code on real GPU runs; CI has no GPU and never exercises any of this. |
|
Verdict: needs rebase (now conflicting); the device math and host bookkeeping review out correct, so post-rebase this is mergeable pending gates. Correctness vs the CPU spec — verified line-by-line:
Items:
Tests are the strongest in the stack: enforcement sweeps (grid probes at 3 base points), CPU quality parity, all-zero no-op bit-parity, and loud-rejection coverage. md5 locks: unaffected at defaults (empty constraints short-circuits everything; the no-op test proves it). Gate runs tomorrow (post-rebase): full monotone suite + the all-zero bit-parity test + covtype md5 confirm. 🤖 Review drafted with Claude Code |
Problem: the delta
When training with
device_type="cuda",monotone_constraintswas silently ignored. The CUDA best-split finder andCUDALeafSplitshad no constraint plumbing — unlike the CPUFeatureHistogram, which clamps child outputs into the leaf's[min, max]constraint range and rejects monotonicity-violating splits.Measured delta before this PR (600×3 regression,
monotone_constraints_method="basic",gpu_use_dp=true; violations counted on 4,500 grid points per config — 3 base points × 500-point sweeps × constrained features):A user requesting monotone constraints on CUDA got a model that violates the requested relationship at hundreds of points — silently. For regulatory/safety uses of monotonicity this is a hard correctness failure.
Fix
Implements the basic monotone constraint method on the CUDA tree learner:
CalculateSplittedLeafOutputMC/GetSplitGainsMCdevice functions mirror the CPUUSE_MCformulas: candidate child outputs are clamped into the leaf's[min, max]constraint range, and splits whose clamped outputs violate the monotone direction are rejected (gain 0).SplitFindTaskcarries its feature's monotone type (config is real-feature-indexed; mapped to inner indices, matching the CPUFeatureMetainfosetup).[min, max]constraint ranges are tracked on host — mirroringBasicLeafConstraints::Updatemidpoint propagation — and passed to the split kernels for the (smaller, larger) leaf pair each iteration.CUDASplitInfoare the clamped outputs (used for the tree and constraint propagation); leaf gains keep their unconstrained values (they form the child'sparent_gainbaseline, matching CPU'sBeforeNumericalgain-shift computation). Getting this distinction wrong was the prototype's main bug.monotone_constraints_method=intermediate/advanced,monotone_penalty>0, anduse_quantized_grad+ monotone.This replaces the first commit's
Log::Fatalguard: basic-method monotone constraints now work on CUDA.Result: the delta after the fix
Same measurement, post-fix — across all constraint vectors × {1, 30, 100} rounds × 2 seeds:
Zero monotonicity violations in every configuration (24/24 test configs), with constrained training quality equivalent to CPU (MSE within 3%, often slightly better) and bit-exact parity when constraints are inactive.
Honest scope note: structure parity vs. constraint parity
CPU and CUDA can build different but equally valid constrained trees in multi-round training. The cause: CPU prunes candidate features through its
FeatureHistogram::is_splittable_cache (a feature found unsplittable on a parent is skipped for the whole subtree — a search-space heuristic, not part of the constraint definition). Replicating that cache on GPU requires per-(leaf, feature) state with extra host-device round trips; an experimental version made agreement worse because the cache semantics interact with histogram-subtraction timing. So this PR guarantees the property (zero violations + equivalent quality + bit-parity in the inactive case), not node-by-node tree equality. The PR description and tests state exactly that.Tests (in
test_dual.py, gated onTASK=cuda)test_cuda_monotone_constraints_are_enforcedtest_cuda_monotone_constraints_match_cpu_qualitytest_cuda_monotone_noop_constraints_match_cpu_exactlytest_cuda_monotone_unsupported_configs_raiseFull
test_dual.pysuite (72 tests) passes.🤖 Generated with Claude Code