Thanks to visit codestin.com
Credit goes to github.com

Skip to content

FIX stabilize weighted confusion_matrix_at_thresholds on float32-only devices - #34827

Open
ogrisel wants to merge 16 commits into
scikit-learn:mainfrom
ogrisel:fix-34813-cmat-weighted-int-scale
Open

FIX stabilize weighted confusion_matrix_at_thresholds on float32-only devices#34827
ogrisel wants to merge 16 commits into
scikit-learn:mainfrom
ogrisel:fix-34813-cmat-weighted-int-scale

Conversation

@ogrisel

@ogrisel ogrisel commented Aug 27, 2026

Copy link
Copy Markdown
Member

Fixes the weighted half of #34813.

Complements #34817, which handles the unweighted case with integer cumsum.

On float32-only array API devices (e.g. array-api-strict's no_float64, torch MPS), confusion_matrix_at_thresholds accumulates weighted counts in float32 when float64 is unavailable. Float32 cumsums saturate past 2**24, which breaks ROC/PR curves and related metrics on large datasets. Weight normalization alone is not enough at that scale.

When sample_weight is set and the device only supports float32, this PR accumulates in fixed-point int64 (round(weight * 1e6)), then converts back to float32 at the output boundary.

The float64 path is unchanged, so this PR should result in a net numerical stability improvement for float32 devices. We could explore using the fixed-point code path also for devices that support float64 operations but I am not sure if this is a good idea or not.

Note to reviewers: I think we should review and merge the simpler #34817 fix first and then I can rebase this branch to review it more naturally.

ogrisel and others added 5 commits August 25, 2026 18:38
On float32-only Array API devices, float cumsum saturates past 2**24.
Weight normalization is not sufficient at that scale; use a fixed-point
integer cumulative sum instead when float64 is unavailable. Refs scikit-learn#34813.

Co-authored-by: Cursor <[email protected]>
Drop _max_precision_int_dtype; float32-only devices of interest
(e.g. torch MPS) provide int64.

Co-authored-by: Cursor <[email protected]>
Rely on _array_api_for_tests for skipping when array-api-strict or
SCIPY_ARRAY_API is unavailable.

Co-authored-by: Cursor <[email protected]>
@ogrisel

ogrisel commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

cc @david-cortes-intel as we discussed this during a meeting.

@ogrisel ogrisel moved this to In Progress in Array API Aug 27, 2026
@github-actions github-actions Bot removed the CUDA CI label Aug 27, 2026
Comment thread sklearn/metrics/_ranking.py Outdated
Comment on lines +1040 to +1042
# Micro-unit fixed point: enough resolution for typical weights while
# keeping scaled totals inside int64 for very large n. int64 is assumed
# available on float32-only devices of interest (e.g. torch MPS).

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.

Thanks. This could be mentioned in the documentation so that users would know that granularity of the weights is limited.

Comment thread sklearn/metrics/_ranking.py Outdated
# available on float32-only devices of interest (e.g. torch MPS).
scale = 1_000_000
y_true_i = xp.astype(y_true, xp.int64)
w_scaled = xp.astype(xp.round(weight * scale), xp.int64)

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.

weight = weight / weight.sum() gives mean of 1/n that get rounded to zero so
round(weight * 1e6) returns zero for any n>4e6.

I measured fo U(0,1) weights only 75% survive at n=1e6 and at n=1e7 tps[-1] is 0 and roc_curve returns a NaN where main returns 7.4e-4 relative error.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@Fazel94 I don't understand what you mean. This code does not divide by weight.sum(). Could you give a reproducer where this PR fails?

@ogrisel ogrisel Aug 28, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think I got what you meant. If the weights are normalized ahead of time (e.g. by the user), then using the 1e7 fixed scale can fail.

I iterated on this problem with an LLM in ogrisel#24. Let me merge this iteration into this PR as I actually think it's an improvement.

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.

Yes that is exactly what i meant

Choose the micro-unit scale from the mean sample weight (capped by int64
headroom via n * max(weight)) so pre-normalized or uniformly tiny weights
do not round to zero under a fixed 1e6 scale, while O(1) weights keep the
previous micro-unit resolution. Extend the float32-only large-n regression
test to cover those failure modes.

Co-authored-by: Olivier Grisel <[email protected]>
…-4d84

FIX adaptive fixed-point scale for weighted float32-only cumsums
@github-actions github-actions Bot added the CI:Linter failure The linter CI is failing on this PR label Aug 28, 2026
@Fazel94

Fazel94 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

In my tests your adaptive scale idea works great in all but one easily rectifiable case.

The case goes as w = 1e-8 on the top 10% by score, 1.0 on the rest, n = 1e7:

  scale=1.11e+06  round(1e-8*scale)=0
  tps[500000]  exact 0.0045005   #34827v2 0

tps would be wrong for the starting ones.
This can be fixed by consuming more of 64 bits you got by something in the line of

scale = max(int(0.9 * (2**63 - 1) / (n_samples * weight_max)), 1)

Generally summing in descending order is numerically problematic, the idea to use int64 is quiet good, the classic way of dealing with it is using compensated summing[1] that I tested a vectorized version[2] of it head to head to this version and the current pr is better.

def compensated_cumsum(x):
    c = np.cumsum(x, dtype=f32)
    a = np.concatenate([np.zeros(1, dtype=f32), c[:-1]])
    s = a + x
    bb = s - a
    e = (a - (s - bb)) + (x - bb)
    d = (s - c) + e
    return c + np.cumsum(d, dtype=f32)

[1]: Higham, Nicholas J. 4.6 In Accuracy and Stability of Numerical Algorithms, 2nd ed.
[2]: Knuth D. ch 4.2.2 The Art of Computer Programming, Vol. 2

Mean-based adaptive scale (~1e6 / mean(weight)) still rounds minority
tiny weights to zero when most weights are O(1). Choose the largest
scale such that n * max(weight) * scale fits in int64 (clipped to
int64 max when n * max(weight) < 1), and extend the float32-only test
with a skewed_tiny_top case that fails under the mean-based scheme.
Also fixes ruff RUF046 from the previous adaptive-scale change.

Co-authored-by: Olivier Grisel <[email protected]>
Drop the skewed_tiny_top special-case size so the parametrized test
always covers n_pos > 2**24 with a single sample count.

Co-authored-by: Olivier Grisel <[email protected]>
FIX max int64-safe scale for skewed tiny sample weights
@github-actions github-actions Bot removed the CI:Linter failure The linter CI is failing on this PR label Aug 31, 2026
@ogrisel

ogrisel commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

@Fazel94 this PR has been update with what you suggested. Let me know if you have any further feedback.

@david-cortes-intel

Copy link
Copy Markdown
Contributor

In my tests your adaptive scale idea works great in all but one easily rectifiable case.

The case goes as w = 1e-8 on the top 10% by score, 1.0 on the rest, n = 1e7:

  scale=1.11e+06  round(1e-8*scale)=0
  tps[500000]  exact 0.0045005   #34827v2 0

tps would be wrong for the starting ones. This can be fixed by consuming more of 64 bits you got by something in the line of

scale = max(int(0.9 * (2**63 - 1) / (n_samples * weight_max)), 1)

Generally summing in descending order is numerically problematic, the idea to use int64 is quiet good, the classic way of dealing with it is using compensated summing[1] that I tested a vectorized version[2] of it head to head to this version and the current pr is better.

def compensated_cumsum(x):
    c = np.cumsum(x, dtype=f32)
    a = np.concatenate([np.zeros(1, dtype=f32), c[:-1]])
    s = a + x
    bb = s - a
    e = (a - (s - bb)) + (x - bb)
    d = (s - c) + e
    return c + np.cumsum(d, dtype=f32)

[1]: Higham, Nicholas J. 4.6 In Accuracy and Stability of Numerical Algorithms, 2nd ed. [2]: Knuth D. ch 4.2.2 The Art of Computer Programming, Vol. 2

Wouldn't that also fail when the compensation terms exceed the mantissa? For example, cumsum of 40 million ones.

@Fazel94

Fazel94 commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

@david-cortes-intel It would break on 40M, nice catch.
For purely theoretical interest,
We have a self-similar problem of calculating compensation term, we can apply a round of Two Sum on the compensation term, essentially instead of np.cumsum(d, dtype=f32) we can recurse and alleviate the problem to some extend, but on arbitrary distributed inputs the needed recursion depth can vary and a fix depth can not be accurate on all inputs.
I suspect using Kahan method could help better but that would require an if branch on each term.
But again as Higham puts as his first solution to summation numerical stability, "Just use more bits"(4.6.1) is the correct approach that @ogrisel implemented here.

Resolve conflicts with scikit-learn#34817 by keeping both the unweighted int64
cumsum path and the weighted float32-only fixed-point scale path, and
retaining both corresponding large-n float32-only tests.

Co-authored-by: Olivier Grisel <[email protected]>
@github-actions github-actions Bot added the CI:Linter failure The linter CI is failing on this PR label Sep 1, 2026
Comment thread sklearn/metrics/_ranking.py
Comment thread sklearn/metrics/tests/test_ranking.py
@github-actions github-actions Bot removed the CI:Linter failure The linter CI is failing on this PR label Sep 1, 2026
@ogrisel
ogrisel marked this pull request as ready for review September 2, 2026 07:29
@ogrisel

ogrisel commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

I have the feeling that this PR is a net improvement over main and from my shallow understanding of the discussion it does not seem to be easy to do better on float32-only devices.

However, if volunteers would like to explore alternatives, feel free to open a concurrent PR and we can evaluate robustness/code complexity/speed tradeoffs.

@ogrisel

ogrisel commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

There is another alternative which would be to perform the weighted counts on CPU if the the device is float32 only.

@david-cortes-intel

Copy link
Copy Markdown
Contributor

There is another alternative which would be to perform the weighted counts on CPU if the the device is float32 only.

I like that alternative better. Not sure how it fits into the whole array API design though.

@Fazel94

Fazel94 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

There is another alternative which would be to perform the weighted counts on CPU if the the device is float32 only.

I like that alternative better. Not sure how it fits into the whole array API design though.

I don't see any other way than checking to see if the device doesn't support float64, then pulling the data back and using numpy. Since numpy code here is efficient it don't see a problem with it. Saying that it doesn't feel natural.

@Fazel94

Fazel94 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

If there is cases of algorithms facing similar kind of problems it would make sense to create some sort of fixed point helper functions to use in such cases.

@ogrisel

ogrisel commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

If there is cases of algorithms facing similar kind of problems it would make sense to create some sort of fixed point helper functions to use in such cases.

We have this note in our array API support document:

https://scikit-learn.org/dev/modules/array_api.html#note-on-device-support-for-float64

This note is typically linked to from estimators & function listed in this list:

https://scikit-learn.org/dev/modules/array_api.html#support-for-array-api-compatible-inputs

For metric functions that are often fast to compute, I think moving to CPU if needed is fine. For estimators where fit can often be the computational bottlneck of a user's workfload, the tradeoff is harder to break.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

4 participants