Skip to content

API: Introduce optional (and partial) NEP 50 weak scalar logic #21626

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

Merged
merged 34 commits into from
Jun 26, 2022

Conversation

seberg
Copy link
Member

@seberg seberg commented May 28, 2022

This is still a work-in-progress, but I also do not mean it to be quite complete (for PR size reasons). There is a reason this is all opt-in for now.

The current state introduces the new NEP 50 "weak scalar" logic to replace value-based casting. There may still be holes in the logic at this point.

The main points are:

  • Introducing the environment variable NPY_PROMOTION_STATE and functions np._set_promotion_state(state) and np._get_promotion_state() with the following options:
    • NPY_PROMOTION_STATE=legacy no change in behavior (default)
    • NPY_PROMOTION_STATE=weak new NEP 50 behavior
    • NPY_PROMOTION_STATE=weak_and_warn additional gives a warning in many cases where a change occurs (see details below).
  • Introducing np._no_nep50_warning() as a context manager to locally suppress the optional warning. Unlike NPY_PROMOTION_STATE this is thread- and context-safe.

Missing things are:

  • It does not yet introduce new warnings/errors for casts. I.e. some integer cases should raise an error, but will not. Some float ones should overflow (different open PR) and other integer cases should error out.
  • Scalar paths should use correct promotion, but the "long" route, which will make it slow and may lose integer overflow warnings.
  • np.can_cast does not take into account that technically np.can_cast(1, np.uint8) should probably return True!

Note on warnings:

  • Warnings will be given for changes in the result-dtype. This is subtle but important, because it means that the fact that np.float32(3.1) < 3.1 changes behavior is ignored by the warnings: The behavior only changes with respect to floating point precision! Similarly, I ignore the warnings that would be triggered inside np.isclose.

Painpoints:

  • np.arange juggles Python integers internally, which leads to warnings (even if it rarely changes things). Similarly np.linspace runs into warnings easily, although it probably also doesn't change things there (or only very little).
  • In many cases functions have shady promotions and just need some thoughts to be clear which way they should go. Is it right that np.quantile(float16_arr, 0.5) will return a float16?
  • Just the mass of changes is a bit tricky to deal with...

This is hopefully test-runnable on other projects though. The missing integer handling should hopefully break loudly on many good test-suites and you actually will get the transition warning for those cases.

@seberg
Copy link
Member Author

seberg commented May 31, 2022

I think this should actually be usable, now (not final, but usable for first tests). SciPy only shows 244 failures (most of them are clustered in either sparse or KDE tests; the KDE tests go to longdouble when they did not before and then fail because they use linalg which barfs on longdouble.

With warnings enabled, there are ~2400 failures, but even those should be very heavily clustered on parametrized tests. It may be that some of those should just be ignored (even in NumPy itself).

There are only a few serious ones (e.g. due to low-precision integers being used where they probably should not be used), I suspect the serious ones are easy to fix.
None of that is particularly surprising by now.

(EDIT: I am not even sure the 2400 warnings are even a lot of churn, they seem very clustered to a few heavily parametrized tests.)

Copy link
Member

@BvB93 BvB93 left a comment

Choose a reason for hiding this comment

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

Could you add annotations for the new functions to the main __init__.pyi file?
I do believe that contextmanager still has to be imported from contextlib.

@contextmanager
def no_nep50_warning() -> Generator[None, None, None]: ...
def get_promotion_state() -> str: ...
def set_promotion_state(state: str, /) -> None: ...

@seberg seberg force-pushed the weak-scalars branch 2 times, most recently from 2186a2b to f89aa56 Compare June 9, 2022 19:52
@seberg
Copy link
Member Author

seberg commented Jun 9, 2022

I think this should be reaching a merge-able state now. I would like to still add brief docs (either in global state, or the NEP itself).

There are a few changes, so to summarize:

  • In result_type, CanCastArrayTo and ufuncs arrays are now tagged if they used to be int, float, complex.
  • Both ResultType and ufunc dispatching, will run both old and new code paths (if necessary for warnings), or otherwise basically switch between the two.
  • The following new API functions exist:
    • np._get_promotion_state(), which can be legacy, weak, or weak_and_warn
    • np._set_promotion_state()
    • with np._no_nep50_warning():
  • Further, there is a new testing fixture:
    • weak_promotion, which is True or False and runs the test with the promotion state set to legacy and weak_and_warn

A bit more "indirect" changes are that I fixed up a few tests to at least include np._no_nep50_warning, but this is not complete. The test-suite cannot be run successfully with the new promotion state set.

The promotion of rational which is odd/broken, is a bit changed. I tried to make the new promotion path more robust (which also affects the "legacy" version in some cases). I think that is correct (could be expanded), in principle maybe even a fix, but until an issue is opened, I wouldn't plan on backporting it.
(There may be weird corner cases currently when a user-dtype has spotty casting rules defined, but I think they existed for multiple versions now.)

@seberg seberg marked this pull request as ready for review June 9, 2022 23:30
seberg and others added 21 commits June 15, 2022 11:42
Even the new promotion has to use the min-scalar logic to avoid
picking up a float16 loop for `np.int8(3) * 3.`.
We need to be able to query the state for testing, probably should
be renamed before the end, but need to have something for now.
Promotion in percentile will now more aggressively preserve the
input dtype for floating point types (rather than upgrading the
type to at least float64).
This ensures that the precision is not downcast, which could make
a small value zero (for float16 mostly).
This lets tests pass that check whether `np.float16(0)` is almost
equal to 0, which otherwise fail (because `float16(0.00000001)`
will evaluate to 0 exactly.
The issue is that bools are subclasses of ints, which triggers the
more general problem that we cannot use the `int` abstract dtype
if the input has a concrete dtype associated (e.g. bools, but also
general user defined DTypes in principle)
Forcing the output dtype does not work here, since it actually
can be integral (not that this is usually a good idea).
In practice, the change we are doing here is forcing 64bit (or
32bit depending on platform) of precision for the calculation.

This means that the change will only ever increase precision mildly
compared to the current situation.
If all are scalars, then legacy promotion is not forced but we would
use weak promotion internally suddenly (which we must not!).
Co-authored-by: Bas van Beek <[email protected]>
seberg added 8 commits June 15, 2022 11:42
This follows the tests (and actually goes hand in hand with them).
There are still some apparent issues here though, I suspect (but
am not sure), the that the legacy promotion may need to kick in
more agressively when errors occur.

Also, surprisingly this fixes things that maybe should fail in
legacy promotion, and I am not yet sure why...
…re robust

It seems that the (weird and probably non-existing in practice) case
of uint8 vs. int8 promotion when the input is a single integer was
broken at some point and this fixes it again.

This is only really relevant for rational, which defines only a very
selective number of integer promotions.

This fixes up the previous chunk that relaxes promotion fallbacks a lot
for legacy dtypes.
this also effectively fixes some corner cases in np.result_type
@rgommers
Copy link
Member

I'm testing this with SciPy and pre-emptively fixing up some things as needed. For one test failure (scipy/sparse/tests/test_sputils.py::TestSparseUtils::test_get_index_dtype ), I do see some unexpected behavior though:

>>> np.uint32(1) < np.iinfo(np.int32).min
True
>>> type(np.iinfo(np.int32).min)
<class 'int'>
>>> np.uint32(1) < -214783648
True

@seberg
Copy link
Member Author

seberg commented Jun 17, 2022

I'm testing this with SciPy and pre-emptively fixing up some things as needed.

Very cool, thanks! That result should really not be the final result there. Right now, my expectation for the final result is that it will raise an error.

I.e. raising errors (and many RuntimeWarning("overflow ..." for scalar integers) are still missing in this first step.

We could try to be smarter (for comparisons), but I have to think about it more. Maybe comparisons could always use the int64 for the the Python integer, but at least in the simple version that might mean:

  • Casting the uint32 (which may add "unnecessary" casting).
  • Promotion problems with uint64 and Python integer (now int64). Although for those it might be good to have all comparison loops for uint64 < int64 and int64 < uint64, etc. since the promotion to float64 leads to fairly crazy stuff anyways in those cases.

@rgommers
Copy link
Member

Right now, my expectation for the final result is that it will raise an error.

Thanks. That makes sense to me I think - and it's certainly better than the current result.

Maybe comparisons could always use the int64 for the the Python integer, but at least in the simple version that might mean:

It sounds logical, but perhaps it'll turn out later that special-casing casting behavior for comparisons was a mistake - hard to predict. So perhaps starting with an exception is better?

@rgommers
Copy link
Member

One more in the same vein:

>>> np.uint32(10) % 2**32  # expected answer: 10 (or an exception)
<ipython-input-5-e66c79a8614e>:1: RuntimeWarning: divide by zero encountered in remainder
  np.uint32(10) % 2**32
0

After fixing pretty much all of the failures in SciPy that show up with NPY_PROMOTION_STATE=weak I'd say that:

  • the issues are pretty minor and a cost worth paying to get the improved casting behavior
  • finding the issues is currently a real pain, and it will be valuable to have those added warnings/errors for where behavior has changed, to more easily pinpoint the source of each problem.

@seberg
Copy link
Member Author

seberg commented Jun 20, 2022

fnding the issues is currently a real pain, and it will be valuable to have those added warnings/errors for where behavior has changed, to more easily pinpoint the source of each problem.

The warnings exist as NPY_PROMOTION_STATE=weak_and_warn? The problem is they are noisy. So yes, probably useful to find where the change happened for a failed test, but likely not useful for the whole test run.

@rgommers
Copy link
Member

The warnings exist as NPY_PROMOTION_STATE=weak_and_warn? The problem is they are noisy. So yes, probably useful to find where the change happened for a failed test, but likely not useful for the whole test run.

Ah yes, thanks. That does help, when combined with exact test selection. Typically I run all tests in a file or submodule, but that's too noisy here. I just tried on test_filter_design.py, which had 1 failure. Running the file triggers 16 warnings (which get auto-upgraded to test errors). Making it specific enough with

python dev.py test -t scipy.signal.tests.test_filter_design::TestIIRFilter

gives a root cause for the failure of interest:

scipy/signal/_filter_design.py:4853: in besselap
    a_last = _falling_factorial(2*N, N) // 2**N
E   UserWarning: result dtype changed due to the removal of value-based promotion from NumPy. Changed from object to int64.

@charris
Copy link
Member

charris commented Jun 23, 2022

@seberg does the affect the array_api work?

@seberg
Copy link
Member Author

seberg commented Jun 23, 2022

This may be their biggest blocker. But its not set up to be e.g. usable with a context manage, so it is important but doesn't affect them right now (as it doesn't add any new API to use the new behavior).
That may well be possible, but probably needs some thoughts to make sure it is fast enough always. My priority right now is to allow initial tests to move NEP 50 forward, not making np.array_api easier to implement.

@charris
Copy link
Member

charris commented Jun 23, 2022

Could you add a release note for this so that folks know how to test against it?

@seberg
Copy link
Member Author

seberg commented Jun 23, 2022

Good point, I added a brief release note pointing to the NEP. The NEP has the actual note on how to test (seems more discoverable to me, and better to udpate).

@charris charris merged commit b65f0b7 into numpy:main Jun 26, 2022
@charris
Copy link
Member

charris commented Jun 26, 2022

Thanks Sebastian.

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.

4 participants