Skip to content

ENH: add a segmented_reduce method to ufuncs - #32243

Open
ikrommyd wants to merge 10 commits into
numpy:mainfrom
ikrommyd:segmented-reduce
Open

ikrommyd wants to merge 10 commits into
numpy:mainfrom
ikrommyd:segmented-reduce

Conversation

@ikrommyd

@ikrommyd ikrommyd commented Aug 10, 2026 •

Copy link
Copy Markdown
Member

PR summary

Taking over #25476.
Implements a new segmented_reduce method to ufuncs as it was discussed on that PR instead of enhancing the reduceat interface. I tried to make the code changes as minimal as possible and take most of the implementation from reduceat from helpers to reduce duplication.

AI Disclosure

I iterated with AI on most code outside the actual ufunc_object.c implementation like tests, typing, and boilerplate. The core implementation was handled more manually.

@ikrommyd ikrommyd added this to the 2.6.0 Release milestone Aug 10, 2026

@jorenham jorenham left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

sorry if premature, but I left some typing-related comments

Comment thread numpy/_core/umath.pyi Resolved
Comment thread numpy/_core/umath.pyi Resolved
Comment thread numpy/_core/_add_newdocs.py Outdated

add_newdoc('numpy._core', 'ufunc', ('segmented_reduce',
"""
segmented_reduce($self, array, /, starts, stops=None, axis=0, dtype=None, out=None, **kwargs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we make anything after stops=None keyword-only?

@ikrommyd ikrommyd Aug 10, 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 will actually make stops required btw. I don't like the None case. Regarding keyword-only, there's no precedent for that in ufunc methods. No other ufunc methods have keyword-only args as far as I can see. We have to follow what reduce, reduceat and accumulate do here regarding their signature.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It's what the stubs enforce though, because I checked, and axis is rarely ever used as positional argument in practice for e.g. reduce.

there's no precedent for that in ufunc methods

Personally I don't think that consistency is worth repeating the mistakes of the past (even though I think consistency is pretty important).

@github-actions

This comment has been minimized.

1 similar comment
@github-actions

This comment has been minimized.

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

I have not looked in any detail yet, but think it is important that we end up in a state where there is only one function that deals with the internals of both reduceat and segmented_reduce, i.e., we should factor out the innards. Otherwise we're nearly guaranteed that the code will diverge.

Otherwise, only bikeshedding about names and keywords so far...

over segments (slices) of an axis given by their start and stop offsets::

>>> a = np.arange(8)
>>> np.add.segmented_reduce(a, starts=[0, 5], stops=[3, 8])

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.

I'm not sure about the plural here, since a single value is in principle allowed.

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.

My idea is that it comes from "start offsets" and "stop offsets" and offsets is always plural so I called them starts and stops. CCCL calls them "start_offsets_inandend_offsets_in`. I don't care much but I'm personally a fan of plural tbh.

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.

My analogy is with slice, which has start and stop -- at some point I had a branch where for reduceat I let the user give a slice object with start and stop arrays. Also, things like np.linspace have start and stop which can be arrays.

But of course, for linspace, the expectation is that they are scalars, while here it is that you input multiple items with a single one an unusual possibility. Given that, I don't mind so much any more.

p.s. Actually, I think a single item should behave differently than a list of length 1: it should remove axis unless keepdims=True. If we do that, we can pass ufunc.reduce trivially through your new code... Relatedly, if the start and stop are >=2D arrays, one might want to insert multiple axes... For this PR, I suggest we insist on 1D arrays so that we do not prevent exploring use cases for different dimensions...

@ikrommyd ikrommyd Aug 11, 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 was planning on doing ONLY 1D starts/stops anyways here. If somebody wants to expand that sure but indices in reduceat is also 1D only. I had only thought of segmentes along axis and nothing more than that. I'm haven't even thought about more than 1D 🤣

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.

Excellent. The main thing is not to get starts and stops with something like the C-side equivalent of np.asanyarray(starts, ndmin=1), but rather explicitly check that they are 1D.


add_newdoc('numpy._core', 'ufunc', ('segmented_reduce',
"""
segmented_reduce($self, array, /, starts, stops, axis=0, dtype=None, out=None, **kwargs)

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.

I'm with @jorenham in thinking we should make everything starting with axis keyword only.

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.

Generally I agree. My disagreement here is that this this feels like an improved version of reduceat to me and it feels nicer to maintain the way arguments are parsed consistent between them. Happy to do what the maintainers think of course. If I was designing the API from scratch I'd go keyword only for sure.

@ikrommyd

Copy link
Copy Markdown
Member Author

I have not looked in any detail yet, but think it is important that we end up in a state where there is only one function that deals with the internals of both reduceat and segmented_reduce, i.e., we should factor out the innards. Otherwise we're nearly guaranteed that the code will diverge.

Otherwise, only bikeshedding about names and keywords so far...

Yes I started this by copying the reduceat code exactly as is. There's definitely a ton of duplicated code here overall. Reduceat also duplicates a lot of code from accumulate as well and there's a TODO in the code to fix that. I'd be willing to tackle this as well but I want to do this after the upgrade to handle reduction loops comes in.

@mhvk

mhvk commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Yes, good to get the reduction, etc., loops in first -- they're a very nice improvement!

@ikrommyd

Copy link
Copy Markdown
Member Author

Yes, good to get the reduction, etc., loops in first -- they're a very nice improvement!

Yeah treat this PR as "ready for design comments" basically 🤣. The generalization of the reduction loops to reduceat and accumulate (two PRs are open for this) will definitely come in first and this will be heavily rebased afterwards.

@ikrommyd

Copy link
Copy Markdown
Member Author

This is honestly mostly ready from my side. It just needs code-deduplication with reduceat which is easy when I look at the diff between the two. However, this would conflict with the work done on multi-output reductions for reduceat and accumulate and I think those should be merged first cause then we'd want to also make multi-output reductions work for segmented_reduce. But anyone is free to take a look as the core logic is down here now.

@ikrommyd

Copy link
Copy Markdown
Member Author

I'm keeping this a bit frozen until multi-output reductions land in main for reduceat but I had a question in the meantime. Do you think segmented reduce should support multi-output reductions? Sure completeness is an argument but I thought maybe not yet to keep the code of segmented reduce simpler? I don't know. I doubt anyone eagerly wants a segmented minmax for example.

@mhvk

mhvk commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

@ikrommyd - I think deduplication with reduceat would be the highest priority. Eventually, one would also like to deduplicate with reduce itself, and it would seem that at that point one would get the multi-output work automatically. But it seems reasonable (and probably in fact best) to do it as two steps.

@ikrommyd

Copy link
Copy Markdown
Member Author

@ikrommyd - I think deduplication with reduceat would be the highest priority. Eventually, one would also like to deduplicate with reduce itself, and it would seem that at that point one would get the multi-output work automatically. But it seems reasonable (and probably in fact best) to do it as two steps.

yeah part of why I keep this frozen for now is because reduceat is changing in #32212. So I want to restart with that PR (once merged) as a basis.

@ikrommyd

ikrommyd commented Nov 9, 2026

Copy link
Copy Markdown
Member Author

@jorenham should I implement the typing here or want me to leave it for a future PR or for you? it's currently in the diff as copy-pasted from reduceat with the names changed.

@jorenham

jorenham commented Nov 9, 2026

Copy link
Copy Markdown
Member

@jorenham should I implement the typing here or want me to leave it for a future PR or for you? it's currently in the diff as copy-pasted from reduceat with the names changed.

Copy-pasting is probably what I'd do myself, so I see no reason to defer that. I'll take a closer look at the diff once this is ready for review.

@ikrommyd

ikrommyd commented Nov 9, 2026

Copy link
Copy Markdown
Member Author

Yeah I just did a massive rebase as most of the reduceat core has been changed to add the multi-output support. This is now in sync with main's reduceat implementation and also supports multi-output loops out of the box. I want to do a pass myself to simplify more if possible and an AI review but I guess the typing shouldn't change so if you're ever bored you can do a pass on that already I guess.

@ikrommyd
ikrommyd marked this pull request as ready for review November 9, 2026 12:44
@ikrommyd
ikrommyd requested a review from ngoldbaum November 10, 2026 15:14
Signed-off-by: Iason Krommydas <[email protected]>
@jorenham
jorenham self-requested a review November 11, 2026 14:26
Comment thread numpy/_typing/_ufunc.pyi
axis: SupportsIndex = 0,
dtype: DTypeLike | None = None,
out: EllipsisType | None = None,
initial: Incomplete = ...,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

  • Below in the __init__.pyi changes for ufunc you added a comment saying that initial is keyword-only, but right now it can also be passed as positional arg here.
  • Could we narrow Incomplete to something like _ScalarLike_co or ArrayLike? Or, if it can accept anything, then we should use object instead of Incomplete, because Incomplete is basically intended as a # TODO: annotate me.
  • Is there a default value we could use here instead of the = ...? Or is it more like an if "initial" in kwargs: ... kinda situation?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

IIRC you said that the segmented_reduce signature in these ufunc stubs is pretty much equivalent to that of reduceat (modulo the extra params that aren't tested here). Because if that's the case, we don't need to also copy-paste their type-tests here, and we could just write a simple comment like we do elsewhere, e.g.:
https://github.com/ikrommyd/numpy/blob/36312d6d1611ef6a3878d4bbf4e849f476b8d4b9/numpy/typing/tests/data/reveal/ufuncs.pyi#L3928

Comment on lines +368 to +372
assert_type(np.frompyfunc(func21, n2, n1).segmented_reduce(AR_f4_2d, AR_i8, AR_i8), npt.Array2D[np.object_[int]])
assert_type(np.frompyfunc(func21, n2, n1).segmented_reduce([f8], AR_i8, AR_i8), npt.Array1D[np.object_[int]])
assert_type(np.frompyfunc(func21, n2, n1).segmented_reduce([[f8]], AR_i8, AR_i8), npt.Array2D[np.object_[int]])
assert_type(np.frompyfunc(func21, n2, n1).segmented_reduce(AR_f8, AR_i8, AR_i8, out=AR_f8), npt.NDArray[np.float64])
assert_type(np.frompyfunc(func21, n2, n1).segmented_reduce([AR_f8], AR_i8, AR_i8), npt.NDArray[np.object_[int]])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If this is also the same as reduceat here, then a comment would suffice

Comment thread numpy/_typing/_ufunc.pyi
Comment on lines +592 to +593
starts: _ArrayLikeInt_co,
stops: _ArrayLikeInt_co,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

_ArrayLikeInt_co also accepts boolean array-likes, which I'm guessing wouldn't work at runtime. So let's use _ArrayLikeInt here instead (without the _co), like you already did above in _core.umath. It's not yet imported though, so you'll have to import it from ._array_like.

Comment thread numpy/__init__.pyi
Comment on lines +16096 to +16097
# `initial` is passed through `kwargs` (like it is for `reduce`)
**kwargs: Incomplete,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No need to repeat the mistakes of the past: it's easy enough to instead write something like

Suggested change
# `initial` is passed through `kwargs` (like it is for `reduce`)
**kwargs: Incomplete,
*,
initial: _ScalarLike_co,

Comment thread numpy/__init__.pyi
Comment on lines +16091 to +16092
starts: _ArrayLikeInt_co,
stops: _ArrayLikeInt_co,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
starts: _ArrayLikeInt_co,
stops: _ArrayLikeInt_co,
starts: _ArrayLikeInt,
stops: _ArrayLikeInt,

Comment thread numpy/_core/umath.pyi Resolved
segmented_reduce($self, array, /, starts, stops, axis=0, dtype=None, out=None, **kwargs)
--

segmented_reduce(array, starts, stops, axis=0, dtype=None, out=None, initial=<no value>)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should we also mark the pos-only and kw-only params here?

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants