diff --git a/doc/release/upcoming_changes/25437.new_feature.rst b/doc/release/upcoming_changes/25437.new_feature.rst new file mode 100644 index 000000000000..ca46def9679c --- /dev/null +++ b/doc/release/upcoming_changes/25437.new_feature.rst @@ -0,0 +1,8 @@ +`numpy.linalg.martrix_rank`, `numpy.sort` and `numpy.argsort` new parameters +---------------------------------------------------------------------------- + +New keyword parameters were added to improve array API compatibility: + +* ``rtol`` keyword parameter was added to `numpy.linalg.martrix_rank`. + +* ``stable`` keyword parameter was added to `numpy.sort` and `numpy.argsort`. diff --git a/numpy/__init__.pyi b/numpy/__init__.pyi index 2e28f2a1c297..320c990b1dbc 100644 --- a/numpy/__init__.pyi +++ b/numpy/__init__.pyi @@ -1113,6 +1113,8 @@ class _ArrayOrScalarCommon: axis: None | SupportsIndex = ..., kind: None | _SortKind = ..., order: None | str | Sequence[str] = ..., + *, + stable: None | bool = ..., ) -> NDArray[Any]: ... @overload @@ -1640,6 +1642,8 @@ class ndarray(_ArrayOrScalarCommon, Generic[_ShapeType, _DType_co]): axis: SupportsIndex = ..., kind: None | _SortKind = ..., order: None | str | Sequence[str] = ..., + *, + stable: None | bool = ..., ) -> None: ... @overload diff --git a/numpy/_core/fromnumeric.py b/numpy/_core/fromnumeric.py index 66da41c2d807..1b779d99bb59 100644 --- a/numpy/_core/fromnumeric.py +++ b/numpy/_core/fromnumeric.py @@ -894,12 +894,12 @@ def argpartition(a, kth, axis=-1, kind='introselect', order=None): return _wrapfunc(a, 'argpartition', kth, axis=axis, kind=kind, order=order) -def _sort_dispatcher(a, axis=None, kind=None, order=None): +def _sort_dispatcher(a, axis=None, kind=None, order=None, *, stable=None): return (a,) @array_function_dispatch(_sort_dispatcher) -def sort(a, axis=-1, kind=None, order=None): +def sort(a, axis=-1, kind=None, order=None, *, stable=None): """ Return a sorted copy of an array. @@ -925,6 +925,13 @@ def sort(a, axis=-1, kind=None, order=None): be specified as a string, and not all fields need be specified, but unspecified fields will still be used, in the order in which they come up in the dtype, to break ties. + stable : bool, optional + Sort stability. If ``True``, the returned array will maintain + the relative order of ``a`` values which compare as equal. + If ``False`` or ``None``, this is not guaranteed. Internally, + this option selects ``kind='stable'``. Default: ``None``. + + .. versionadded:: 2.0.0 Returns ------- @@ -1053,16 +1060,16 @@ def sort(a, axis=-1, kind=None, order=None): axis = -1 else: a = asanyarray(a).copy(order="K") - a.sort(axis=axis, kind=kind, order=order) + a.sort(axis=axis, kind=kind, order=order, stable=stable) return a -def _argsort_dispatcher(a, axis=None, kind=None, order=None): +def _argsort_dispatcher(a, axis=None, kind=None, order=None, *, stable=None): return (a,) @array_function_dispatch(_argsort_dispatcher) -def argsort(a, axis=-1, kind=None, order=None): +def argsort(a, axis=-1, kind=None, order=None, *, stable=None): """ Returns the indices that would sort an array. @@ -1091,6 +1098,13 @@ def argsort(a, axis=-1, kind=None, order=None): be specified as a string, and not all fields need be specified, but unspecified fields will still be used, in the order in which they come up in the dtype, to break ties. + stable : bool, optional + Sort stability. If ``True``, the returned array will maintain + the relative order of ``a`` values which compare as equal. + If ``False`` or ``None``, this is not guaranteed. Internally, + this option selects ``kind='stable'``. Default: ``None``. + + .. versionadded:: 2.0.0 Returns ------- @@ -1169,8 +1183,9 @@ def argsort(a, axis=-1, kind=None, order=None): array([0, 1]) """ - return _wrapfunc(a, 'argsort', axis=axis, kind=kind, order=order) - + return _wrapfunc( + a, 'argsort', axis=axis, kind=kind, order=order, stable=stable + ) def _argmax_dispatcher(a, axis=None, out=None, *, keepdims=np._NoValue): return (a, out) diff --git a/numpy/_core/fromnumeric.pyi b/numpy/_core/fromnumeric.pyi index e7d376166749..59b48068f6d0 100644 --- a/numpy/_core/fromnumeric.pyi +++ b/numpy/_core/fromnumeric.pyi @@ -211,6 +211,8 @@ def sort( axis: None | SupportsIndex = ..., kind: None | _SortKind = ..., order: None | str | Sequence[str] = ..., + *, + stable: None | bool = ..., ) -> NDArray[_SCT]: ... @overload def sort( @@ -218,6 +220,8 @@ def sort( axis: None | SupportsIndex = ..., kind: None | _SortKind = ..., order: None | str | Sequence[str] = ..., + *, + stable: None | bool = ..., ) -> NDArray[Any]: ... def argsort( @@ -225,6 +229,8 @@ def argsort( axis: None | SupportsIndex = ..., kind: None | _SortKind = ..., order: None | str | Sequence[str] = ..., + *, + stable: None | bool = ..., ) -> NDArray[intp]: ... @overload diff --git a/numpy/_core/include/numpy/ndarraytypes.h b/numpy/_core/include/numpy/ndarraytypes.h index b5169d5cf969..4a66ac20ba65 100644 --- a/numpy/_core/include/numpy/ndarraytypes.h +++ b/numpy/_core/include/numpy/ndarraytypes.h @@ -155,6 +155,7 @@ enum NPY_TYPECHAR { * depend on the data type. */ typedef enum { + _NPY_SORT_UNDEFINED=-1, NPY_QUICKSORT=0, NPY_HEAPSORT=1, NPY_MERGESORT=2, diff --git a/numpy/_core/src/multiarray/_multiarray_tests.c.src b/numpy/_core/src/multiarray/_multiarray_tests.c.src index bf7381f56023..23fd68b6a1bb 100644 --- a/numpy/_core/src/multiarray/_multiarray_tests.c.src +++ b/numpy/_core/src/multiarray/_multiarray_tests.c.src @@ -2032,6 +2032,7 @@ run_sortkind_converter(PyObject* NPY_UNUSED(self), PyObject *args) return NULL; } switch (kind) { + case _NPY_SORT_UNDEFINED: return PyUnicode_FromString("_NPY_SORT_UNDEFINED"); case NPY_QUICKSORT: return PyUnicode_FromString("NPY_QUICKSORT"); case NPY_HEAPSORT: return PyUnicode_FromString("NPY_HEAPSORT"); case NPY_STABLESORT: return PyUnicode_FromString("NPY_STABLESORT"); diff --git a/numpy/_core/src/multiarray/conversion_utils.c b/numpy/_core/src/multiarray/conversion_utils.c index 12ed45853e64..8adbeabe3e3a 100644 --- a/numpy/_core/src/multiarray/conversion_utils.c +++ b/numpy/_core/src/multiarray/conversion_utils.c @@ -431,6 +431,28 @@ PyArray_BoolConverter(PyObject *object, npy_bool *val) return NPY_SUCCEED; } +/* + * Optionally convert an object to true / false + */ +NPY_NO_EXPORT int +PyArray_OptionalBoolConverter(PyObject *object, int *val) +{ + /* Leave the desired default from the caller for Py_None */ + if (object == Py_None) { + return NPY_SUCCEED; + } + if (PyObject_IsTrue(object)) { + *val = 1; + } + else { + *val = 0; + } + if (PyErr_Occurred()) { + return NPY_FAIL; + } + return NPY_SUCCEED; +} + static int string_converter_helper( PyObject *object, diff --git a/numpy/_core/src/multiarray/conversion_utils.h b/numpy/_core/src/multiarray/conversion_utils.h index 62f54749ef0d..b5cc38063910 100644 --- a/numpy/_core/src/multiarray/conversion_utils.h +++ b/numpy/_core/src/multiarray/conversion_utils.h @@ -27,6 +27,9 @@ PyArray_BufferConverter(PyObject *obj, PyArray_Chunk *buf); NPY_NO_EXPORT int PyArray_BoolConverter(PyObject *object, npy_bool *val); +NPY_NO_EXPORT int +PyArray_OptionalBoolConverter(PyObject *object, int *val); + NPY_NO_EXPORT int PyArray_ByteorderConverter(PyObject *obj, char *endian); diff --git a/numpy/_core/src/multiarray/methods.c b/numpy/_core/src/multiarray/methods.c index 0efc9311812f..34596074bda3 100644 --- a/numpy/_core/src/multiarray/methods.c +++ b/numpy/_core/src/multiarray/methods.c @@ -1235,18 +1235,20 @@ static PyObject * array_sort(PyArrayObject *self, PyObject *const *args, Py_ssize_t len_args, PyObject *kwnames) { - int axis=-1; + int axis = -1; int val; - NPY_SORTKIND sortkind = NPY_QUICKSORT; + NPY_SORTKIND sortkind = _NPY_SORT_UNDEFINED; PyObject *order = NULL; PyArray_Descr *saved = NULL; PyArray_Descr *newd; + int stable = -1; NPY_PREPARE_ARGPARSER; if (npy_parse_arguments("sort", args, len_args, kwnames, "|axis", &PyArray_PythonPyIntFromInt, &axis, "|kind", &PyArray_SortkindConverter, &sortkind, "|order", NULL, &order, + "$stable", &PyArray_OptionalBoolConverter, &stable, NULL, NULL, NULL) < 0) { return NULL; } @@ -1281,6 +1283,18 @@ array_sort(PyArrayObject *self, newd->names = new_name; ((PyArrayObject_fields *)self)->descr = newd; } + if (sortkind != _NPY_SORT_UNDEFINED && stable != -1) { + PyErr_SetString(PyExc_ValueError, + "`kind` and `stable` parameters can't be provided at " + "the same time. Use only one of them."); + return NULL; + } + else if ((sortkind == _NPY_SORT_UNDEFINED && stable == -1) || (stable == 0)) { + sortkind = NPY_QUICKSORT; + } + else if (stable == 1) { + sortkind = NPY_STABLESORT; + } val = PyArray_Sort(self, axis, sortkind); if (order != NULL) { @@ -1371,15 +1385,17 @@ array_argsort(PyArrayObject *self, PyObject *const *args, Py_ssize_t len_args, PyObject *kwnames) { int axis = -1; - NPY_SORTKIND sortkind = NPY_QUICKSORT; + NPY_SORTKIND sortkind = _NPY_SORT_UNDEFINED; PyObject *order = NULL, *res; PyArray_Descr *newd, *saved=NULL; + int stable = -1; NPY_PREPARE_ARGPARSER; if (npy_parse_arguments("argsort", args, len_args, kwnames, "|axis", &PyArray_AxisConverter, &axis, "|kind", &PyArray_SortkindConverter, &sortkind, "|order", NULL, &order, + "$stable", &PyArray_OptionalBoolConverter, &stable, NULL, NULL, NULL) < 0) { return NULL; } @@ -1414,6 +1430,18 @@ array_argsort(PyArrayObject *self, newd->names = new_name; ((PyArrayObject_fields *)self)->descr = newd; } + if (sortkind != _NPY_SORT_UNDEFINED && stable != -1) { + PyErr_SetString(PyExc_ValueError, + "`kind` and `stable` parameters can't be provided at " + "the same time. Use only one of them."); + return NULL; + } + else if ((sortkind == _NPY_SORT_UNDEFINED && stable == -1) || (stable == 0)) { + sortkind = NPY_QUICKSORT; + } + else if (stable == 1) { + sortkind = NPY_STABLESORT; + } res = PyArray_ArgSort(self, axis, sortkind); if (order != NULL) { diff --git a/numpy/_core/tests/test_multiarray.py b/numpy/_core/tests/test_multiarray.py index d62902468e1d..3860fa8784aa 100644 --- a/numpy/_core/tests/test_multiarray.py +++ b/numpy/_core/tests/test_multiarray.py @@ -2043,6 +2043,12 @@ def test_sort(self): b = np.sort(a) assert_equal(b, a[::-1], msg) + with assert_raises_regex( + ValueError, + "kind` and `stable` parameters can't be provided at the same time" + ): + np.sort(a, kind="stable", stable=True) + # all c scalar sorts use the same code with different types # so it suffices to run a quick check with one type. The number # of sorted items must be greater than ~50 to check the actual @@ -2481,6 +2487,12 @@ def test_argsort(self): a = np.array(['aaaaaaaaa' for i in range(100)], dtype=np.str_) assert_equal(a.argsort(kind='m'), r) + with assert_raises_regex( + ValueError, + "kind` and `stable` parameters can't be provided at the same time" + ): + np.argsort(a, kind="stable", stable=True) + def test_sort_unicode_kind(self): d = np.arange(10) k = b'\xc3\xa4'.decode("UTF8") diff --git a/numpy/linalg/_linalg.py b/numpy/linalg/_linalg.py index 1a217d0b1878..9ff3bf0d0130 100644 --- a/numpy/linalg/_linalg.py +++ b/numpy/linalg/_linalg.py @@ -1965,12 +1965,12 @@ def cond(x, p=None): return r -def _matrix_rank_dispatcher(A, tol=None, hermitian=None): +def _matrix_rank_dispatcher(A, tol=None, hermitian=None, *, rtol=None): return (A,) @array_function_dispatch(_matrix_rank_dispatcher) -def matrix_rank(A, tol=None, hermitian=False): +def matrix_rank(A, tol=None, hermitian=False, *, rtol=None): """ Return matrix rank of array using SVD method @@ -1998,6 +1998,11 @@ def matrix_rank(A, tol=None, hermitian=False): Defaults to False. .. versionadded:: 1.14 + rtol : (...) array_like, float, optional + Parameter for the relative tolerance component. Only ``tol`` or + ``rtol`` can be set at a time. Defaults to ``max(M, N) * eps``. + + .. versionadded:: 2.0.0 Returns ------- @@ -2067,12 +2072,12 @@ def matrix_rank(A, tol=None, hermitian=False): if A.ndim < 2: return int(not all(A == 0)) S = svd(A, compute_uv=False, hermitian=hermitian) + if rtol is not None and tol is not None: + raise ValueError("`tol` and `rtol` can't be both set.") + if rtol is None: + rtol = max(A.shape[-2:]) * finfo(S.dtype).eps if tol is None: - tol = ( - S.max(axis=-1, keepdims=True) * - max(A.shape[-2:]) * - finfo(S.dtype).eps - ) + tol = S.max(axis=-1, keepdims=True) * rtol else: tol = asarray(tol)[..., newaxis] return count_nonzero(S > tol, axis=-1) diff --git a/numpy/linalg/_linalg.pyi b/numpy/linalg/_linalg.pyi index 153477598e70..e9f00e226a94 100644 --- a/numpy/linalg/_linalg.pyi +++ b/numpy/linalg/_linalg.pyi @@ -265,6 +265,8 @@ def matrix_rank( A: _ArrayLikeComplex_co, tol: None | _ArrayLikeFloat_co = ..., hermitian: bool = ..., + *, + rtol: None | _ArrayLikeFloat_co = ..., ) -> Any: ... @overload diff --git a/numpy/linalg/tests/test_linalg.py b/numpy/linalg/tests/test_linalg.py index 66f4ca22e75c..a9548f47ac14 100644 --- a/numpy/linalg/tests/test_linalg.py +++ b/numpy/linalg/tests/test_linalg.py @@ -1624,6 +1624,11 @@ def test_matrix_rank(self): # works on scalar assert_equal(matrix_rank(1), 1) + with assert_raises_regex( + ValueError, "`tol` and `rtol` can\'t be both set." + ): + matrix_rank(I, tol=0.01, rtol=0.01) + def test_symmetric_rank(self): assert_equal(4, matrix_rank(np.eye(4), hermitian=True)) assert_equal(1, matrix_rank(np.ones((4, 4)), hermitian=True)) diff --git a/numpy/ma/core.py b/numpy/ma/core.py index da425935fa86..64a823f20ccf 100644 --- a/numpy/ma/core.py +++ b/numpy/ma/core.py @@ -5576,8 +5576,8 @@ def round(self, decimals=0, out=None): out.__setmask__(self._mask) return out - def argsort(self, axis=np._NoValue, kind=None, order=None, - endwith=True, fill_value=None): + def argsort(self, axis=np._NoValue, kind=None, order=None, endwith=True, + fill_value=None, *, stable=False): """ Return an ndarray of indices that sort the array along the specified axis. Masked values are filled beforehand to @@ -5610,6 +5610,8 @@ def argsort(self, axis=np._NoValue, kind=None, order=None, fill_value : scalar or None, optional Value used internally for the masked values. If ``fill_value`` is not None, it supersedes ``endwith``. + stable : bool, optional + Only for compatibility with ``np.argsort``. Ignored. Returns ------- @@ -5638,6 +5640,10 @@ def argsort(self, axis=np._NoValue, kind=None, order=None, array([1, 0, 2]) """ + if stable: + raise ValueError( + "`stable` parameter is not supported for masked arrays." + ) # 2017-04-11, Numpy 1.13.0, gh-8701: warn on axis default if axis is np._NoValue: @@ -5742,8 +5748,8 @@ def argmax(self, axis=None, fill_value=None, out=None, *, keepdims = False if keepdims is np._NoValue else bool(keepdims) return d.argmax(axis, out=out, keepdims=keepdims) - def sort(self, axis=-1, kind=None, order=None, - endwith=True, fill_value=None): + def sort(self, axis=-1, kind=None, order=None, endwith=True, + fill_value=None, *, stable=False): """ Sort the array, in-place @@ -5769,6 +5775,8 @@ def sort(self, axis=-1, kind=None, order=None, fill_value : scalar or None, optional Value used internally for the masked values. If ``fill_value`` is not None, it supersedes ``endwith``. + stable : bool, optional + Only for compatibility with ``np.sort``. Ignored. Returns ------- @@ -5813,6 +5821,11 @@ def sort(self, axis=-1, kind=None, order=None, fill_value=999999) """ + if stable: + raise ValueError( + "`stable` parameter is not supported for masked arrays." + ) + if self._mask is nomask: ndarray.sort(self, axis=axis, kind=kind, order=order) return @@ -7098,7 +7111,8 @@ def power(a, b, third=None): argmin = _frommethod('argmin') argmax = _frommethod('argmax') -def argsort(a, axis=np._NoValue, kind=None, order=None, endwith=True, fill_value=None): +def argsort(a, axis=np._NoValue, kind=None, order=None, endwith=True, + fill_value=None, *, stable=None): "Function version of the eponymous method." a = np.asanyarray(a) @@ -7107,13 +7121,14 @@ def argsort(a, axis=np._NoValue, kind=None, order=None, endwith=True, fill_value axis = _deprecate_argsort_axis(a) if isinstance(a, MaskedArray): - return a.argsort(axis=axis, kind=kind, order=order, - endwith=endwith, fill_value=fill_value) + return a.argsort(axis=axis, kind=kind, order=order, endwith=endwith, + fill_value=fill_value, stable=None) else: - return a.argsort(axis=axis, kind=kind, order=order) + return a.argsort(axis=axis, kind=kind, order=order, stable=None) argsort.__doc__ = MaskedArray.argsort.__doc__ -def sort(a, axis=-1, kind=None, order=None, endwith=True, fill_value=None): +def sort(a, axis=-1, kind=None, order=None, endwith=True, fill_value=None, *, + stable=None): """ Return a sorted copy of the masked array. @@ -7147,10 +7162,10 @@ def sort(a, axis=-1, kind=None, order=None, endwith=True, fill_value=None): axis = 0 if isinstance(a, MaskedArray): - a.sort(axis=axis, kind=kind, order=order, - endwith=endwith, fill_value=fill_value) + a.sort(axis=axis, kind=kind, order=order, endwith=endwith, + fill_value=fill_value, stable=stable) else: - a.sort(axis=axis, kind=kind, order=order) + a.sort(axis=axis, kind=kind, order=order, stable=stable) return a diff --git a/numpy/ma/core.pyi b/numpy/ma/core.pyi index 2d8813c44c4a..54ba4bf4d879 100644 --- a/numpy/ma/core.pyi +++ b/numpy/ma/core.pyi @@ -270,10 +270,10 @@ class MaskedArray(ndarray[_ShapeType, _DType_co]): def var(self, axis=..., dtype=..., out=..., ddof=..., keepdims=...): ... def std(self, axis=..., dtype=..., out=..., ddof=..., keepdims=...): ... def round(self, decimals=..., out=...): ... - def argsort(self, axis=..., kind=..., order=..., endwith=..., fill_value=...): ... + def argsort(self, axis=..., kind=..., order=..., endwith=..., fill_value=..., stable=...): ... def argmin(self, axis=..., fill_value=..., out=..., *, keepdims=...): ... def argmax(self, axis=..., fill_value=..., out=..., *, keepdims=...): ... - def sort(self, axis=..., kind=..., order=..., endwith=..., fill_value=...): ... + def sort(self, axis=..., kind=..., order=..., endwith=..., fill_value=..., stable=...): ... def min(self, axis=..., out=..., fill_value=..., keepdims=...): ... # NOTE: deprecated # def tostring(self, fill_value=..., order=...): ... @@ -415,8 +415,8 @@ maximum: _extrema_operation def take(a, indices, axis=..., out=..., mode=...): ... def power(a, b, third=...): ... -def argsort(a, axis=..., kind=..., order=..., endwith=..., fill_value=...): ... -def sort(a, axis=..., kind=..., order=..., endwith=..., fill_value=...): ... +def argsort(a, axis=..., kind=..., order=..., endwith=..., fill_value=..., stable=...): ... +def sort(a, axis=..., kind=..., order=..., endwith=..., fill_value=..., stable=...): ... def compressed(x): ... def concatenate(arrays, axis=...): ... def diag(v, k=...): ... diff --git a/tools/ci/array-api-skips.txt b/tools/ci/array-api-skips.txt index a2bf766bb316..7e63b8ec3584 100644 --- a/tools/ci/array-api-skips.txt +++ b/tools/ci/array-api-skips.txt @@ -57,16 +57,13 @@ array_api_tests/test_signatures.py::test_func_signature[ones] array_api_tests/test_signatures.py::test_func_signature[ones_like] array_api_tests/test_signatures.py::test_func_signature[zeros_like] array_api_tests/test_signatures.py::test_func_signature[reshape] -array_api_tests/test_signatures.py::test_func_signature[argsort] -array_api_tests/test_signatures.py::test_func_signature[sort] -array_api_tests/test_signatures.py::test_extension_func_signature[linalg.matrix_rank] array_api_tests/test_signatures.py::test_array_method_signature[__array_namespace__] array_api_tests/test_signatures.py::test_array_method_signature[to_device] # missing 'copy' keyword argument, 'newshape' should be named 'shape' array_api_tests/test_signatures.py::test_func_signature[reshape] -# missing 'descending' and 'stable' keyword arguments +# missing 'descending' keyword arguments array_api_tests/test_signatures.py::test_func_signature[argsort] array_api_tests/test_signatures.py::test_func_signature[sort]