Skip to content

Add NaN-aware binary loss and multilabel metrics - #30

Merged
dev-rinchin merged 7 commits into
sb-ai-lab:masterfrom
SergeevVladislav:feature/nan-target-losses
Oct 30, 2026
Merged

dev-rinchin merged 7 commits into
sb-ai-lab:masterfrom
SergeevVladislav:feature/nan-target-losses

Conversation

@SergeevVladislav

@SergeevVladislav SergeevVladislav commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Binary and multilabel training with missing targets currently requires custom loss/metric implementations. Add built-in NaN-aware training and evaluation so unknown labels can be excluded without replacing them with observed negatives.

BCEWithNaNLoss (bce_nan / multilabel_nan) gives missing labels zero gradient/Hessian contributions and initializes each output from its observed labels. Training and evaluation targets are validated once before training: observed labels must be 0 or 1. Every training output must contain an observed label; otherwise, a ValueError identifies the affected column indices. Entirely missing evaluation outputs remain valid.

The default metric is NaN-aware BCE; bce/logloss and auc/roc resolve to NaN-aware metrics for this loss. Add these public metrics:

  • NaNBCEMetric: observed-label BCE, optionally per output.
  • BCEWithNaNMetricWeighted: normalized weighted mean of per-output BCE.
  • NaNAucMetric / NaNAucMetricByClass: macro/per-output ROC AUC.
  • NaNGiniMetricByClass / NaNGiniMetricAtN: per-output and weighted macro Gini, with n selecting leading target columns.

Undefined per-output results are NaN and excluded from macro averages. Standalone scalar metrics remain NaN when no eligible output exists. Early stopping raises an explicit error if its selected scalar metric on the last evaluation set is NaN; with es=0, training continues and the history records NaN. Row weights are rescaled before BCE/AUC aggregation to avoid overflow and underflow. The shared binary AUC helper also uses stable class-wise normalization. Zero-weight exclusions and undefined all-zero-weight results are preserved.

FilterSketch excludes outputs with zero Hessian mass, falls back to uniform selection among eligible outputs when total importance is zero, and raises a clear error when none are eligible. Guard zero denominators in both initial and queued importance calculations, handle smooth=1, and allocate leaf accumulators using max_leaf_index + 1.

README and Tutorial 4 document these policies. Tutorial 4 now imports the built-in classes, demonstrates an entirely missing evaluation output, and uses a generic Python kernel. Per-output metrics are intended for reporting; AdaptiveESCV still requires an explicit scorer for missing labels.

Validation on RTX 5090, Python 3.12.3, CuPy 14.2.0 / CUDA 12.9, NumPy 2.5.4, Numba 0.68.0:

  • python -m pytest tests -q: 143 passed — 111 NaN loss/metric/sketch tests and 32 existing histogram tests from current master.
  • Without an accessible CUDA device: 143 skipped, successful pytest exit.
  • Tests cover gradients/Hessians, fully observed BCE parity, NumPy/scikit-learn references, extreme weights down to 1e-320 and up to 1e308, zero-weight observations, invalid train/evaluation labels, missing training columns/folds, undefined early-stopping metrics, FilterSketch, random projection and cross-validation.
  • All Tutorial 4 code cells executed on GPU (only IPython timing magics omitted): 50,000 train rows, 50,000 validation rows, 100 features, 10 targets, 1,000 trees. Predictions/history are finite; the deliberately unobserved validation output has NaN AUC.
  • Existing CuPy ndarray.scatter_add deprecation warnings remain.

Updated against current master, which includes #29 and #31.

@dev-rinchin

dev-rinchin commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

I would separate the API policy decisions from the correctness fixes.

Scope: the strict target-validation and weighted-initialization policies below apply to BCEWithNaNLoss. Keep the target validation and initialization of existing losses unchanged. Any changes to the shared FilterSketch and EarlyStopping behavior should be explicit and tested with existing losses as well.

API policy decisions

  1. Use strict handling of outputs without usable training labels. Each target column must have at least one observed label in a row with positive sample weight. Without sample_weight, treat all rows as having weight 1. If this condition is not met, raise ValueError identifying the affected columns before initialization or tree construction. This includes both all-NaN columns and columns whose observed labels all have zero weight. Apply the check to the training partition of each CV fold. Do not silently drop outputs or substitute a probability of 0.5.

  2. Initialize BCEWithNaNLoss using the weighted frequency of observed positive labels, so zero-weight rows do not determine the initial prediction. This is an explicit initialization policy for the new loss; it does not require changing BCELoss or other existing losses.

  3. Keep entirely unobserved columns valid in evaluation sets. Metrics should exclude undefined per-output results and return NaN if no eligible output remains. If the scalar metric used for early stopping is NaN, raise an explicit error instead of silently pruning the model to one tree.

  4. Treat zero FilterSketch importance separately from missing training labels: zero gradients can occur at an optimum. A uniform distribution over outputs is an acceptable fallback when total importance is zero; document this behavior.

  5. Keep BCEWithNaNMetricWeighted as the single public name and remove BCEwithNaNMetricWeighted from exports, documentation, and tests. This is an API naming choice rather than a numerical bug fix.

Correctness fixes required under this policy

  1. Validate observed training and evaluation labels as 0 or 1, preserving NaN as the missing-label marker. Validate before lossy dtype conversion: for example, float64 label 1.00000001 must not silently become an accepted float32 value of 1. Validate row weights as finite and nonnegative, and report training weights that overflow to infinity or underflow from positive values to zero during conversion with a clear representation error.

  2. Make FilterSketch's importance calculation safe for zero Hessians and zero total importance. Use sufficient precision before squaring gradients and summing; casting an already overflowed or underflowed importance to float64 is too late. Keep the fallback distinct from the training-data validation error.

  3. Rescale weights safely when computing initial class frequencies and BCE/AUC aggregations. Metrics should be invariant to a common positive rescaling of row weights, provided the supplied weights remain finite and representable. Preserve zero-weight exclusions and infinite BCE errors with positive row or output weights. Preserve sufficient precision for evaluation weights through the public fit() path: fixing direct metric calls alone does not help if weights such as 1e308 or 1e-308 have already become infinity or zero before reaching the metric. Do not silently normalize training weights without accounting for the effect on regularization.

Documentation and validation

Update the README and Tutorial 4 to describe the policy and use the built-in loss and metrics instead of local implementations. Add CUDA regression coverage through fit() and CrossValidation, including all-NaN training outputs, observed labels with only zero weights, weighted initial frequencies, empty evaluation outputs, undefined early-stopping metrics, invalid float64 labels before conversion, extreme weights, and FilterSketch. Test direct metric calls as well, but do not rely on them as a substitute for testing the public training path. Include regression coverage for existing losses affected by changes to the shared sketching or early-stopping code.

@SergeevVladislav

Copy link
Copy Markdown
Contributor Author

@dev-rinchin Thanks for the detailed review. All six points are addressed in af3243e; the branch also includes current master.

  1. Unobserved training columns and undefined evaluation: base_score now raises a ValueError listing the zero-based indices of training columns with no observed labels. Evaluation columns may still be entirely missing and are excluded from macro metrics. EarlyStopping explicitly rejects a NaN scalar score on its selected (last) evaluation set. es=0 still allows training while recording NaN. Tests cover both evaluation-set orderings and a cross-validation fold without training labels for an output.

  2. FilterSketch: zero-Hessian outputs have zero selection probability, including during smoothing. Zero total importance falls back to a uniform distribution over eligible outputs; no eligible output raises a clear error. Both first-iteration and queued importance calculations guard zero denominators. CUDA tests cover partially/all-zero training weights, sampling and top-k selection, smooth=0/0.1/1, shared/per-output Hessians, and zero importance. The queued tests also exposed a related leaf-accumulator off-by-one: it now allocates max_leaf_index + 1, including the one-leaf case.

  3. Target validation: BCEWithNaNLoss.preprocess_input rejects observed values other than 0/1 and preserves NaN. It runs once for train and once for each evaluation set, before gradients are computed, including labels on zero-weight rows. Invalid training labels are tested with and without a valid evaluation set; invalid evaluation labels must fail before training starts.

  4. Numerical stability: BCE rescales weights after masking, with independent scales for per-output results. The shared AUC helper rescales positive/negative class weights independently and normalizes class masses before accumulating the area, avoiding the overflowing/underflowing product of class totals. Your [1e308, 1e308] example now gives BCE approximately 0.1053605157 and AUC 1; tests also cover 1e-320, common-scale invariance, extreme class imbalance, missing outputs, all-zero weights, and preservation of infinite BCE for impossible predictions.

  5. Public name: only BCEWithNaNMetricWeighted remains; the alternate spelling was removed from implementation, exports, documentation and tests.

  6. Tutorial 4: replaced the local loss/metric classes with package imports and documented observed negatives versus unknown labels, training-column validation, and undefined evaluation results. Cleared stale outputs and replaced the old environment-specific kernel metadata with python3.

Validation on RTX 5090 / CuPy 14.2.0 / CUDA 12.9: 143 GPU tests passed (111 loss/metric/sketch tests plus 32 existing histogram tests); 143 skipped cleanly without CUDA. A 105-test regression subset against the pre-review implementation reproduces 40 failures, all addressed by this update. I also executed all Tutorial 4 code cells, omitting only the timing magics, at its full dimensions: 50k train + 50k validation rows, 100 features, 10 outputs, and 1,000 trees. Predictions and scalar metric history are finite; the deliberately hidden evaluation output has NaN AUC as documented.

@dev-rinchin

Copy link
Copy Markdown
Contributor

After revisiting the numerical risks, I would revise the requirement for float64 importance calculations in my earlier review. The proposed follow-up restores float32 for gradient squaring and per-leaf gradient/Hessian accumulation in FilterSketch, while retaining float64 reductions and probability normalization. This avoids the additional GPU memory cost of the large float64 intermediates.

Catastrophic overflow or underflow should be very rare edge cases in ordinary BCE training with weights around one. With the default probability clipping, observed-label gradients have magnitudes between approximately 1e-7 and 1, so their squares are well within the float32 range. Even with one million rows, a leaf gradient sum squared is at most 1e12 in unweighted BCE, far below the float32 limit of approximately 3.4e38. Having 1000+ outputs does not itself increase the scale of each output's gradients.

The earlier examples with weights of 1e20 and 1e-30 demonstrate possible failures under extreme scales; they do not establish that these failures are common. Such scales, unscaled regression targets, or custom losses can still exceed the safe range of float32 calculations. Ordinary accumulation roundoff also remains possible, especially when positive and negative leaf gradients nearly cancel. We do not have production failure-rate data, so "very rare" is a conditional assessment for ordinary BCE usage, not a measured percentage.

In seven controlled GPU scenarios, including 100,000 rows with 1000 outputs, one million rows, moderate weights, and nearly cancelling gradients, the original float32 calculation produced finite importances and the same top eight outputs as float64. The largest absolute difference in normalized importances was approximately 3e-6. These are synthetic checks of importance calculations, not a guarantee of unchanged training quality.

The missing-label and zero-importance fixes should remain: reject training outputs without observed labels of positive weight, exclude zero-Hessian outputs from selection, and use uniform selection over eligible outputs when all their importances are zero. These address undefined operations such as 0/0; switching to float64 does not fix them. The proposed precision change also leaves the existing float64 handling of evaluation weights and metrics in place.

A possible intermediate option is to retain float64 calculations but process outputs in blocks, reusing temporary buffers for each block. Only the full vector of output importances needs to survive until global normalization and selection. This can reduce temporary memory substantially, at the cost of additional GPU operations and less favorable access to column slices. It could be an optional future mode; the current proposal does not add a blocking API.

The following measurements are from a simple CuPy prototype on an NVIDIA A100 80GB PCIe with CuPy 14.2.0. For an isolated importance calculation with 100,000 rows, 1000 outputs, 64 leaves, one previous tree, and separate Hessians for each output, medians over 20 warmed repetitions were:

Float64 variant Importance calculation Peak live temporary memory
All 1000 outputs together 3.38 ms 763.9 MiB
Blocks of 512 outputs 3.31 ms 391.1 MiB
Blocks of 256 outputs 4.16 ms 195.6 MiB
Blocks of 128 outputs 4.90 ms 97.8 MiB
Blocks of 32 outputs 9.99 ms 24.5 MiB

For complete fit on a separate synthetic BCE task with 80,000 training rows, 10,000 validation rows, 1000 outputs, 20 features, 25% missing labels, and 30 trees of depth 4, medians over seven warmed repetitions were:

Float64 variant Complete fit Increase over full float64
All outputs together 1.186 s baseline
Blocks of 512 outputs 1.229 s +3.6%
Blocks of 256 outputs 1.279 s +7.9%
Blocks of 128 outputs 1.319 s +11.2%
Blocks of 32 outputs 1.734 s +46.2%

These comparisons are full float64 versus blocked float64, not versus the restored float32 implementation. The memory figures include CuPy intermediates inside the importance calculation, not just the leaf-sum buffers; they exclude input arrays, other training allocations, the CUDA context, and allocator cache. They are not estimates of total training memory. The memory and fit tables use different row counts as specified above. Results depend on hardware and workload: the same prototype was substantially slower without tree history or on smaller inputs. In the measured fit runs, the maximum absolute prediction difference between full and blocked float64 was approximately 1.2e-7. An optimized implementation would need its own validation and benchmarks.

@dev-rinchin
dev-rinchin merged commit da8df75 into sb-ai-lab:master Oct 30, 2026
2 checks passed
@dev-rinchin

dev-rinchin commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark of PR #30's merge result against master immediately before the merge. All sketch cases use sketch_outputs=5. Synthetic data without NaN labels; 32 training iterations, one warmup and five timing repetitions. Python 3.12.3, NumPy 2.5.4, CuPy 14.2.0.

pyboost-gpu-v1-k5

Base: 6e4ceac47fdd1282bc6011e37a05f7f1ad2438b1; candidate: da8df750a7608250c20903f698b2951210338f30.

GPU count: 1; versions and cases execute sequentially.
GPU: NVIDIA A100 80GB PCIe; physical VRAM: 80.0 GiB; CUDA-visible: 79.1 GiB; compute capability: 8.0.
UUID: GPU-c82b879e-b2bd-fa68-99f8-7c8ff17e0679; PCI bus: 00000000:14:00.0.
SMs: 108; memory bus: 5120 bits; NVIDIA driver: 550.163.01.
Power limit: 300.00 W; max SM clock: 1410 MHz.
CUDA driver API: 12.4; CUDA runtime: 12.9.

K is the full target dimension; k is the sketch width used for structure search.
proj: k projected components; rand/filter: k draws with replacement (at most k distinct outputs);
topk: k distinct outputs. Full GradientBoosting uses all K outputs. Predictions always have K outputs.

Times are warmed wall-clock medians. GPU memory is peak live CuPy allocations,
including the private fit pool; CUDA context, pool cache and external library allocations are excluded.
Quality is a holdout metric (lower is better). These are measured changes, not statistical verdicts.

Case Outputs K Sketch k Fit s (base → PR) Δ fit Predict s (base → PR) Δ predict Fit MiB (base → PR) Δ memory Quality (base → PR)
mse-single 1 all 1 0.1924 → 0.1937 +0.6% 0.0054 → 0.0054 +0.1% 2.0522 → 2.0522 +0.0% 0.138708 → 0.138708
mse-full-128 128 all 128 1.0980 → 1.0943 -0.3% 0.0058 → 0.0058 +0.6% 68.6875 → 68.6875 +0.0% 0.589382 → 0.589382
mse-proj-32 32 5 (proj) 0.2373 → 0.2402 +1.2% 0.0055 → 0.0057 +2.9% 15.9092 → 15.9092 +0.0% 0.592292 → 0.592292
mse-proj-128 128 5 (proj) 0.2445 → 0.2436 -0.4% 0.0060 → 0.0060 -0.4% 57.9116 → 57.9116 +0.0% 0.590576 → 0.590576
mse-proj-1024 1024 5 (proj) 0.2875 → 0.2859 -0.5% 0.0091 → 0.0095 +4.2% 449.9458 → 449.9458 +0.0% 0.588748 → 0.588748
mse-proj-10000 10000 5 (proj) 1.0837 → 1.0765 -0.7% 0.0688 → 0.0679 -1.3% 4377.2896 → 4377.2896 +0.0% 0.590207 → 0.590207
bce-rand-1024 1024 5 (rand) 0.3636 → 0.3649 +0.4% 0.0112 → 0.0108 -3.0% 640.8403 → 640.8403 +0.0% 0.628859 → 0.628859
bce-filter-1024 1024 5 (filter) 0.3521 → 0.3842 +9.1% 0.0121 → 0.0107 -11.7% 640.9028 → 640.9028 +0.0% 0.63135 → 0.63135
mse-topk-10000 10000 5 (topk) 0.4129 → 0.4187 +1.4% 0.0287 → 0.0292 +1.5% 1250.4731 → 1250.4731 +0.0% 0.728541 → 0.728541
ce-proj-512 512 5 (proj) 0.2501 → 0.2485 -0.6% 0.0084 → 0.0077 -9.0% 248.9126 → 248.9126 +0.0% 0.522043 → 0.522043
validation-proj-1024 1024 5 (proj) 0.3632 → 0.3642 +0.3% 0.0102 → 0.0102 -0.2% 897.5122 → 897.5122 +0.0% 0.586207 → 0.586207
histogram-topk-128 128 5 (topk) 0.6675 → 0.6696 +0.3% 0.0102 → 0.0101 -0.1% 358.1494 → 358.1494 +0.0% 0.576591 → 0.57663

BCE/FilterSketch fit time increased by 9.1%; other fit-time medians changed by −0.7% to +1.4%. Peak live CuPy allocations were identical in all 12 scenarios. The maximum absolute holdout-metric change was approximately 4e-5. These workloads do not exercise the new NaN loss or NaN-specific metrics.

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.

2 participants