summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSebastian Berg <sebastian@sipsolutions.net>2022-02-21 16:51:10 -0600
committerSebastian Berg <sebastian@sipsolutions.net>2022-05-25 07:24:42 -0700
commit27733042aed1338347469dbf0e916518139bb112 (patch)
treea2c1b66556beb215115968a5b881ebce9000e4ca
parent7e15fd77ca6c09989f219acf432c98a1036d14c5 (diff)
downloadnumpy-27733042aed1338347469dbf0e916518139bb112.tar.gz
NEP: Add early draft for fixing scalar promotion rules
Note, this really is a very early draft. For myself, I have not even 100% made up my mind that this is actually the best approach. However, to solicitate feedback and input, I would like to put it out there (and have a nice rendered version). For those mainly interested in the possible different approaches (i.e. making decisions), the "Alternatives" section is probably most interesting. Please make a note of blatend errors or unnecessary repitition, but don't be too surprised if you see them, I will not be surprised if this needs to be redone from scratch eventually.
-rw-r--r--doc/neps/nep-0050-scalar-promotion.rst443
1 files changed, 443 insertions, 0 deletions
diff --git a/doc/neps/nep-0050-scalar-promotion.rst b/doc/neps/nep-0050-scalar-promotion.rst
new file mode 100644
index 000000000..8045f7e48
--- /dev/null
+++ b/doc/neps/nep-0050-scalar-promotion.rst
@@ -0,0 +1,443 @@
+===========================================
+NEP 50 — Promotion rules for Python scalars
+===========================================
+:Author: Sebastian Berg
+:Status: Draft
+:Type: Standards Track
+:Created: 2021-01-10
+
+
+Abstract
+========
+
+Since NumPy 1.7 promotion rules are defined through the "safe casting"
+concept which in turn can depend on the actual values involved.
+While these choices were well intended, they lead to a complexity both
+for users and maintainers.
+
+There are two main ways this can lead to confusing results:
+
+1. This means that the value can matter::
+
+ np.result_type(np.int8, 1) == np.int8
+ np.result_type(np.int8, 255) == np.int16
+
+ which is even true when replacing the value with 0-D arrays::
+
+ int64_0d_array = np.array(1, dtype=np.int64)
+ np.result_type(np.int8, int64_0d_array) == np.int8
+
+ This logic arises, because ``1`` can be represented by an ``int8`` while
+ ``255`` can be represented by an ``int16`` *or* ``uint8``.
+ This means that for the 0-D array or NumPy scalars, the type is largely ignored.
+
+2. For a Python integer or float the value matters except when the other type is
+ also scalar or zero dimensional::
+
+ np.result_type(np.int8(1), 1) == np.int64
+
+ Because in this case value-based promotion is disabled causing the Python 1
+ to be considered an int64 (or default integer).
+
+Note that the examples apply just as well for operations like multiplication,
+addition, or comparisons and the corresponding functions like `np.multiply`.
+
+This NEPs proposes to refactor the behaviour around two guiding principles:
+
+1. The value must never influence the result type.
+2. NumPy scalars or 0-D arrays must always lead to the same behaviour as
+ their N-D counterparts.
+
+We propose to removes all value-based logic and add special handling for
+Python scalars to preserve some of the convenience that it provided.
+
+This changes also apply to ``np.can_cast(100, np.int8)``, however, we expect
+that the behaviour in functions (promotion) will in practice be far more
+relevant than this casting change.
+
+
+Motivation and Scope
+====================
+
+The motivation for changing the behaviour with respect to inspecting the value
+of Python scalars and NumPy scalars/0-D arrays is, again, two-fold:
+
+1. The special handling of NumPy scalars/0-D arrays as well as the value
+ inspection can be very surprising to users.
+2. The value-inspection logic is much harder to reason about or describe as
+ well as making it available to user defined DTypes through
+ :ref:`NEP 42 <NEP42>`.
+ Currently, this leads to a dual implementation of a new and an old (value
+ sensitive) system. Fixing this will greatly simplify the internal logic
+ and make results more consistent.
+
+
+Usage and Impact
+================
+
+There will be no transition period due to the difficulty and noise this is
+expected to create. In rare cases users may need to adjust code to avoid
+reduced precision or incorrect results.
+
+We plan to provide an *optional* warning mode capable of notifying users of
+potential changes in behavior in most relevant cases.
+
+
+Impact on ``can_cast``
+----------------------
+
+Can cast will never inspect the value anymore. So that the following results
+are expected to change from ``True`` to ``False``::
+
+ np.can_cast(100, np.uint8)
+ np.can_cast(np.int64(100), np.uint8)
+ np.can_cast(np.array(100, dtype=np.int64), np.uint8)
+
+We expect that the impact of this change will be small compared to that of
+the following changes.
+
+.. note::
+
+ The first example where the input is a Python scalar could be preserved
+ to some degree, but this is not currently planned.
+
+
+Impact on operators functions involving NumPy arrays or scalars
+---------------------------------------------------------------
+
+The main impact on operations not involving Python scalars (float, int, complex)
+will be that 0-D arrays and NumPy scalars will never behave value-sensitive.
+This removes currently surprising cases. For example::
+
+ np.arange(10, dtype=np.uint8) + np.int64(1)
+
+Will return an int64 array because the type of ``np.int64(1)`` is strictly
+honoured.
+
+
+Impact on operators involving Python ``int``, ``float``, and ``complex``
+------------------------------------------------------------------------
+
+This NEP attempts to preserve most of the convenience that the old behaviour
+gave for Python operators, but remove it for
+
+The current value-based logic has some nice properties when "untyped" Python
+scalars involved::
+
+ np.arange(10, dtype=np.int8) + 1 # returns an int8 array
+ nparray([1., 2.], dtype=np.float32) * 3.5 # returns a float32 array
+
+But led to complexity when it came to "unrepresentable" values:
+
+ np.arange(10, dtype=np.int8) + 256 # returns int16
+ nparray([1., 2.], dtype=np.float32) * 1e200 # returns float64
+
+The proposal is to preserve this behaviour for the most part. This is achieved
+by considering Python ``int``, ``float``, and ``complex`` to be "weakly" typed
+in these operations.
+To mitigate user surprises, we would further make conversion to the new type
+more strict. This means that the results will be unchanged in the first
+two examples. For the second one, the results will be the following::
+
+ np.arange(10, dtype=np.int8) + 256 # raises a TypeError
+ nparray([1., 2.], dtype=np.float32) * 1e200 # warning and returns infinity
+
+The second one will warn because ``np.float32(1e200)`` overflows to infinity.
+It will then do the calculation with ``inf`` as normally.
+
+
+Impact on functions involving Python ``int``, ``float``, and ``complex``
+------------------------------------------------------------------------
+
+Most functions, in particular ``ufuncs`` will also use this weakly typed
+logic.
+In some cases, functions will call `np.asarray()` on inputs before any operations
+and thus will s
+
+.. note::
+
+ There is a real alternative to not do this for `ufuncs` and limit the special
+ behaviour to Python operators. From a user perspective, we assume that most
+ functions effectively call `np.asarray()`.
+ Because Python operators allow more custom logic, this would ensure that an
+ overflow warning is given for all results with decreased precision.
+
+
+Backward compatibility
+======================
+
+In general, code which only uses the default dtypes float64, or int32/int64
+or more precise ones should not be affected.
+
+However, the proposed changes will modify results in quite a few cases where
+0-D or scalar values (with non-default dtypes) are mixed.
+In many cases, these will be bug-fixes, however, there are certain changes
+which may be particularly interesting.
+
+The most important failure is probably the following example::
+
+ arr = np.arange(100, dtype=np.uint8) # storage array with low precision
+ value = arr[10]
+
+ # calculation continues with "value" without considering where it came from
+ value * 100
+
+Where previously the ``value * 100`` would cause an up-cast to int32/int64
+(because value is a scalar). The new behaviour will preserve the lower
+precision unless explicitly dealt with (just as if ``value`` was an array).
+This can lead to integer overflows and thus incorrect results beyond precision.
+In many cases this may be silent, although NumPy usually gives warnings for the
+scalar operators.
+
+Similarliy, if the storage array is float32 a calculation may retain the lower
+float32 precision rather than use the default float64.
+
+Further issues can occure. For example:
+
+* Floating point comparisons, especially equality, may change when mixing
+ precisions:
+ ```python3
+ np.float32(1/3) == 1/3 # was False, will be True.
+ ```
+* Certain operations are expected to start failing:
+ ```python3
+ np.array([1], np.uint8) * 1000
+ np.array([1], np.uint8) == 1000 # possibly also
+ ```
+ to protect users in cases where previous value-based casting led to an
+ upcast.
+* Floating point overflow may occur in odder cases:
+ ```python3
+ np.float32(1e-30) * 1e50 # will return ``inf`` and a warning
+ ```
+ Because ``np.float32(1e50)`` returns ``inf``. Previously, this would return
+ a double precision result even if the ``1e50`` was not a 0-D array
+
+In other cases, increased precision may occur. For example::
+
+ np.multiple(float32_arr, 2.)
+ float32_arr * np.float64(2.)
+
+Will both return a float64 rather than float32. This improves precision but
+slightly changes results and uses double the memory.
+
+
+Detailed description
+====================
+
+The following provides some additional details on the current "value based"
+promotion logic, and then on the "weak scalar" promotion and how it is handled
+internally.
+
+State of the current "value based" promotion
+---------------------------------------------
+
+Before we can propose alternatives to the current datatype system,
+it is helpful to review how "value based promotion" is used and can be useful.
+Value based promotion allows for the following code to work::
+
+ # Create uint8 array, as this is sufficient:
+ uint8_arr = np.array([1, 2, 3], dtype=np.uint8)
+ result = uint8_arr + 4
+ result.dtype == np.uint8
+
+ result = uint8_arr * (-1)
+ result.dtype == np.int16 # upcast as little as possible.
+
+Where especially the first part can be useful: The user knows that the input
+is an integer array with a specific precision. Considering that plain ``+ 4``
+retaining the previous datatype is intuitive.
+Replacing this example with ``np.float32`` is maybe even more clear,
+as float will rarely have overflows.
+Without this behaviour, the above example would require writing ``np.uint8(4)``
+and lack of the behaviour would make the following suprising::
+
+ result = np.array([1, 2, 3], dtype=np.float32) * 2.
+ result.dtype == np.float32
+
+where lack of a special case would cause ``float64`` to be returned.
+
+It is important to note that the behaviour also applies to universal functions
+and zero dimensional arrays::
+
+ # This logic is also used for ufuncs:
+ np.add(uint8_arr, 4).dtype == np.uint8
+ # And even if the other array is explicitly typed:
+ np.add(uint8_arr, np.array(4, dtype=np.int64)).dtype == np.uint8
+
+To review, if we replace ``4`` with ``[4]`` to make it one dimensional, the
+result will be different::
+
+ # This logic is also used for ufuncs:
+ np.add(uint8_arr, [4]).dtype == np.int64 # platform dependend
+ # And even if the other array is explicitly typed:
+ np.add(uint8_arr, np.array([4], dtype=np.int64)).dtype == np.int64
+
+
+Proposed Weak Promotion
+-----------------------
+
+This proposal uses a "weak scalar" logic. This means that Python ``int``, ``float``,
+and ``complex`` are not assigned one of the typical dtypes, such as float64 or int64.
+Rather, they are assigned a special abstract DType, similar to the "scalar" hierarchy
+names: Integral, Floating, ComplexFloatin.
+
+When promotion occurs (as it does for ufuncs if no exact loop matches),
+the other DType is able to decide how to regard the Python
+scalar. E.g. a ``UInt16`` promoting with an `Integral` will give ``UInt16``.
+
+.. note::
+
+ A default will most likely be provided in the future for user defined DTypes.
+ Most likely this will end up being the default integer/float, but in principle
+ more complex schemes could be implemented.
+
+At no time is the value used to decide the result of this promotion. The value is only
+considered when it is converted to the new dtype; this may raise an error.
+
+
+
+
+Related Work
+============
+
+* `JAX promotion`_ also uses the weak-scalar concept. However, it makes use
+ of it also for most functions. JAX further stores the "weak-type" information
+ on the array: ``jnp.array(1)`` remains weakly typed.
+
+
+Implementation
+==============
+
+Implemeting this NEP requires some additional machinery to be added to all
+binary operators (or ufuncs), so that they attempt to use the "weak" logic
+if possible.
+There are two possible approaches to this:
+
+1. The binary operator simply tries to call ``np.result_type()`` if this
+ situation arises and converts the Python scalar to the result-type (if
+ defined).
+2. The binary operator indicates that an input was a Python scalar, and the
+ ufunc dispatching/promotion machinery is used for the rest (see
+ :ref:`NEP 42 <NEP42>`). This allows more flexibility, but requires some
+ additional logic in the ufunc machinery.
+
+.. note::
+ As of now, it is not quite clear which approach is better, either will
+ give fairl equivalent results and 1. could be extended by 2. in the future
+ if necessary.
+
+It further requires removing all current special value-based code paths.
+
+Unintuitively, a larger step in the implementation may be to implement a
+solution to allow an error to be raised in the following example::
+
+ np.arange(10, dtype=np.uint8) + 1000
+
+Even though ``np.uint8(1000)`` returns the same value as ``np.uint8(232)``.
+
+.. note::
+
+ See alternatives, we may yet decide that this silent overflow is acceptable
+ or at least a separate issue.
+
+
+Alternatives
+============
+
+There are several design axes where different choices are possible.
+The below sections outline these.
+
+Use strongly typed scalars or a mix of both
+-------------------------------------------
+
+The simplest solution to the value-based promotion/casting issue would be to use
+strongly typed Python scalars, i.e. Python floats are considered double precision
+and Python integers are always considered the same as the default integer dtype.
+
+This would be the simplest solution, however, it would lead to many upcasts when
+working with arrays of ``float32`` or ``int16``, etc. The solution for these cases
+would be to rely on in-place operations.
+We currently believe that while less dangerous, this change would affect many users
+and would be surprising more often than not (although expectations differ widely).
+
+In principle, the weak vs. strong behaviour need not be uniform. It would also
+be possible to make Python floats use the weak behaviour, but Python integers use the
+strong one, since integer overflows are far more surprising.
+
+
+Do not use weak scalar logic in functions
+-----------------------------------------
+
+One alternative to this NEPs proposal is to narrow the use of weak types
+to Python operators.
+
+This has advantages and disadvantages:
+
+* The main advantage is that limiting it to Python operators means that these
+ "weak" types/dtypes are clearly ephemeral to short Python statements.
+* A disadvantage is that ``np.multiply`` and ``*`` are less interchangable.
+* Using "weak" promotion only for operators means that libraries do not have
+ to worry about whether they want to "remember" that an input was a Python
+ scalar initially. On the other hand, it would add a the need for slightly
+ different (or additional) logic for Python operators.
+ (Technically, probably as a flag to the ufunc dispatching mechanism to toggle
+ the weak logic.)
+* ``__array_ufunc__`` is often used on its own to provide Python operator
+ support for array-likes implementing it. If operators are special, these
+ array-likes may need a mechanism to match NumPy (e.g. a kwarg to ufuncs to
+ enable weak promotion.)
+
+
+NumPy scalars could be special
+------------------------------
+
+Many users expect that NumPy scalars should be different from NumPy
+arrays, in that ``np.uint8(3) + 3`` should return an ``int64`` (or Python
+integer), when `uint8_arr + 3` preserves the ``uint8`` dtype.
+
+This alternative would be very close to the current behaviour for NumPy scalars
+but it would cement a distinction between arrays and scalars (NumPy arrays
+are "stronger" than Python scalars, but NumPy scalars are not).
+
+Such a distinction is very much possible, however, at this time NumPy will
+often (and silently) convert 0-D arrays to scalars.
+It may thus make sense, to only consider this alternative if we also
+change this silent conversion (sometimes refered to as "decay") behaviour.
+
+
+Handling conversion of scalars when unsafe
+------------------------------------------
+
+Cases such as::
+
+ np.arange(10, dtype=np.uint8) + 1000
+
+should raise an error as per this NEP. This could be relaxed to give a warning
+or even ignore the "unsafe" conversion which (on all relevant hardware) would
+lead to ``np.uint8(1000) == np.uint8(232)`` being used.
+
+
+Discussion
+==========
+
+* https://github.com/numpy/numpy/issues/2878
+* https://mail.python.org/archives/list/numpy-discussion@python.org/thread/R7D65SNGJW4PD6V7N3CEI4NJUHU6QP2I/#RB3JLIYJITVO3BWUPGLN4FJUUIKWKZIW
+* https://mail.python.org/archives/list/numpy-discussion@python.org/thread/NA3UBE3XAUTXFYBX6HPIOCNCTNF3PWSZ/#T5WAYQPRMI5UCK7PKPCE3LGK7AQ5WNGH
+* Poll about the desired future behavior: https://discuss.scientific-python.org/t/poll-future-numpy-behavior-when-mixing-arrays-numpy-scalars-and-python-scalars/202
+
+References and Footnotes
+========================
+
+.. [1] Each NEP must either be explicitly labeled as placed in the public domain (see
+ this NEP as an example) or licensed under the `Open Publication License`_.
+
+.. _Open Publication License: https://www.opencontent.org/openpub/
+
+.. _JAX promotion: https://jax.readthedocs.io/en/latest/type_promotion.html
+
+
+Copyright
+=========
+
+This document has been placed in the public domain. [1]_