Skip to content

WIP: For testing, optionally preserve 0-D arrays in operations #29067

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from

Conversation

seberg
Copy link
Member

@seberg seberg commented May 27, 2025

This is a WIP, it works well enough for trying, but won't pass tests with environment variable set and has some smaller issues still.

We discussed with @mdhaber and others also to pursue removing the annoyance that 0-D arrays tend to be converted to scalars. This PR implements it, but hides it behind:

  • NUMPY_PRESERVE_0D_ARRAYS=1
  • np._get_preserve_0d_arrays()
  • np._set_preserve_0d_arrays()

All for testing and not thread/context-safe. We probably could do that but it isn't needed for testing. (I wouldn't want context use for indexing but it should be fine for anything else.)

This takes my "minimal change" approach, larger approaches were discussed, but the amount of test failures downstream didn't make it appealing on first sight. The changes are:

  • Any function/operator with 0-D arrays as input will return 0-D arrays. But functions that take only scalars will return scalar. (There are some subtlties, i.e. quantile(arr, q), only q dictates the result shape and thus only q is relevant.)
  • arr1d[0] is unchanged. arr[..., 0] or arr[0, ...] means that indexing already has a way to avoid getting scalars.
  • arr.sum(), i.e. axis=None is unchanged and returns scalars. But arr.sum(0) always returns arrays. The reason is that the first currently always returns a scalar, while the latter returns an (N-1) dimensional array or a scalar if 0-D.
    • ufuncs actually default to axis=-1, I think, but typical reductions use axis=None.
    • I think axis=None is special enough, although I admit it feels a bit different from the indexing ... use. (which may make sense as one lists all axis, while the other lists axes to be removed)

I would want to go with the above in a first merge, but we can discuss adding additional switches, e.g. for the behavior of reductions with axis=None. Indexing arr[()] could also be changed, but if we discuss this, we may want to start with adding something like arr.get_element(idx) first (because arr.item(), seems a bit awkward maybe).

Things to do here:

  • We need a CI run that passes with the environment variable
  • There is something wrong with masked arrays (have to investigate)
  • nanfuncs call np.asarray() early on and need to fixed if they are to return scalars for scalar inputs.
  • There are some other smaller bugs/test failures, at least in the env variable path right now.

This PR does "fix" that some functions return Python objects rather than NumPy scalars when inputs have dtype=object. I think this is OK, but it is a subtle change.

Copy link
Contributor

@mhvk mhvk left a comment

Choose a reason for hiding this comment

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

@seberg - overall, this looks great, and I think it also helps to see why it is good not to be too strict about every result becoming arrays.

Also, it is nice to see the array converter that you implemented coming to the fore. That said, the old_scalar argument does not look nice - as asked inline, what exactly goes wrong without that?

@@ -404,7 +403,11 @@ static PyMethodDef array_converter_methods[] = {
METH_FASTCALL | METH_KEYWORDS, NULL},
{"wrap",
(PyCFunction)array_converter_wrap,
METH_FASTCALL | METH_KEYWORDS, NULL},
METH_FASTCALL | METH_KEYWORDS,
"Apply array-wrap. Supports `to_scalar=None/True/False` and "
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't understand the docstring!


/*
* all_inputs_were_scalars is used to decide if 0-D results should be
* unpacked to a scalar. But, `np.matmul(vector, vector)` should do this.
Copy link
Contributor

Choose a reason for hiding this comment

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

Also np.vecdot

* unpack. (unless keepdims=True, since that behave like a normal ufunc)
* So we pretend inputs were scalars...
*
* TODO: We may need a way to customize that at some point.
Copy link
Contributor

Choose a reason for hiding this comment

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

For gufunc, whether an argument is scalar would seem to have the logical condition that there are no outer dimensions being iterated over. The effect would be the same as what you have here, but perhaps easier to describe?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, maybe that is a better way to describe "no outer dimensions in the result".

Unfortunately, that is also a nice way to explain why we don't like this behavior :). I.e. the whole point of this is that it means the number of outer dimensions (zero or more) has never an impact on the result type!
Unfortunately, unlike arr.sum(), vecdot(x, y) doesn't have a way to signal that we should return a scalar (by default).
Maybe out=... has to be the one way unfortunately... Or maybe vecdot should return arrays. Also, we could (and probably should!) make it so that if axes= is passed we never return a scalar (mirroring arr.sum(0)).

Copy link
Contributor

Choose a reason for hiding this comment

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

I see... I guess my initial sense would be to just always return arrays, at least for this trial... Do many tests break?

Note that axes=None (or axis=None) might still be a possibility - currently, that errors, so we can change it to mean "default axes, but return scalar if possible". Of course, that then prevents actually passing in meaningful axes and getting a scalar out. Also, it really is a hack.

Instead, maybe we should have an equivalent of out=... that makes explicit one does want a scalar if possible, say, out=() (or out=((), ()) for multiple outputs)?

Copy link
Member Author

@seberg seberg May 28, 2025

Choose a reason for hiding this comment

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

Instead, maybe we should have an equivalent of out=... that makes explicit one does want a scalar if possible, say, out=() (or out=((), ()) for multiple outputs)

Maybe, I omitted multiple outputs for ... and I think it is better here as well. If we are going to go a step further here, I am wondering if even a return_scalar=True/False/None may not be better, though (and remove out=... again).

Maybe the main thing is if vec @ vec can change behavior. Because if we are willing to gamble on that, it may also be OK to just ask users to do np.matmul(vec, vec)[()] for starters. Or a more obvious arr.get_element()

EDIT: Part of me wonders if such a change might be OK to uncouple even. Also, we could allow gufuncs to opt out (always return arrays).

Copy link
Contributor

Choose a reason for hiding this comment

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

My sense would indeed be to uncouple, and just see if this causes big problems. I somewhat doubt it -- gufuncs are just not used as much and generally scalars are not really expected. (I definitely don't like the idea of a special keyword argument.)

assert type(np.sum(np.float32(2.5), axis=None)) is np.float32
assert type(np.max(np.float32(2.5), axis=None)) is np.float32
assert type(np.min(np.float32(2.5), axis=None)) is np.float32
# TODO: In a sense should return an array, but this is axis=0 is
Copy link
Contributor

Choose a reason for hiding this comment

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

Agree, arguably axis=0 on a 0-D array should just fail!

More relevantly here, are you sure you want to insist this returns a scalar? I guess just from being an operation on scalars only?


return _in1d(ar1, ar2, assume_unique, invert, kind=kind)
dt = conv.result_type()
Copy link
Contributor

Choose a reason for hiding this comment

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

dt does not seem to be used.

a = np.asanyarray(a)
conv = _array_converter(a, q)
a, q_arr = conv.as_arrays(pyscalars="convert")

if a.dtype.kind == "c":
raise TypeError("a must be an array of real numbers")

# Use dtype of array if possible (e.g., if q is a python int or float).
if isinstance(q, (int, float)) and a.dtype.kind == "f":
q = np.asanyarray(q, dtype=a.dtype)
Copy link
Contributor

Choose a reason for hiding this comment

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

Might as well change it to np.asarray here, like in the regular quantile.

@@ -1160,7 +1160,8 @@ def compare(x, y):
if not issubdtype(z.dtype, number):
z = z.astype(np.float64) # handle object arrays

return z < 1.5 * 10.0**(-decimal)
# the float64 ensures at least double precision for the comparison.
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this actually matter for this PR?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah 3 linalg tests or so fail otherwise. But maybe should check whether the problem is really here or not.

@@ -1289,3 +1289,6 @@ def nested_iters(
casting: _CastingKind = ...,
buffersize: SupportsIndex = ...,
) -> tuple[nditer, ...]: ...

def _get_preserve_0d_arrays() -> bool: ...
def _set_preserve_0d_arrays(state: bool, /) -> bool: ...
Copy link
Contributor

Choose a reason for hiding this comment

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

Add CR

@mhvk
Copy link
Contributor

mhvk commented May 27, 2025

Mixing numpy scalars with array subclasses currently will lead to incorrect behavior (return arrays when it should scalars with the env variable).

Isn't subclasses always the right answer? Wouldn't __array_wrap__ have to deal with producing the scalar?

@seberg
Copy link
Member Author

seberg commented May 27, 2025

Isn't subclasses always the right answer? Wouldn't array_wrap have to deal with producing the scalar?

Kind of, although here we already ignored array-wrap, which should be OK. But basically, array-wrap would be passed return_scalar=True and that information is lost.
But, since __array_wrap__ is ignored, I think I can just add a if was_pyscalar: PyArray_Return() to these scalar functions. Before it was just unconditional, which was also wrong.

@mhvk
Copy link
Contributor

mhvk commented May 27, 2025

  • Mixing numpy scalars with array subclasses currently will lead to incorrect behavior (return arrays when it should scalars with the env variable).

Your earlier answer left me confused - when exactly does this happen? (only with preserve_0d_arrays, right?)

@seberg
Copy link
Member Author

seberg commented May 27, 2025

  • Mixing numpy scalars with array subclasses currently will lead to incorrect behavior (return arrays when it should scalars with the env variable).

Your earlier answer left me confused - when exactly does this happen? (only with preserve_0d_arrays, right?)

The initial comment was just wrong, sorry. We always potentially ignored __array_wrap__ behavior, but this had never anything to do with subclasses.
The original push didn't return the correct scalar for some numpy_scalar * object combinations in the new branch, I'll push a fix shortly.

Right now, more tests than I expected are failing with the env variable set, though.

@seberg seberg changed the title WIP: Preserve 0-D arrays in operations WIP: For testing, optionally preserve 0-D arrays in operations May 27, 2025
@seberg
Copy link
Member Author

seberg commented May 27, 2025

OK, things are a bit better now. Should pass without env var. The main problems currently:

  • nanfuncs use np.asarray(), they may all need the _array_converter treatment...
  • np.linalg test fails, because it returns array vs. scalar (which is also an array in the test) depending on ord= (I am not sure which version is correct, yet).
  • The masked scalar (and maybe other MaskedConstant) misbehave... It's an array and e.g. comparisons with it return masked arrays (and not the scalar).
  • And of course I didn't do anything to refine gufuncs yet.

Co-authored-by: Joren Hammudoglu <jhammudoglu@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy