Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/array_api_extra/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from . import testing
from ._delegation import (
apply_along_axis,
argpartition,
atleast_nd,
broadcast_shapes,
Expand Down Expand Up @@ -41,6 +42,7 @@
__all__ = [
"__version__",
"angle",
"apply_along_axis",
"apply_where",
"argpartition",
"at",
Expand Down
47 changes: 45 additions & 2 deletions src/array_api_extra/_delegation.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Delegation to existing implementations for Public API Functions."""

from collections.abc import Sequence
from typing import Literal
from collections.abc import Callable, Sequence
from typing import Any, Literal

from ._lib import _funcs
from ._lib._utils._compat import (
Expand All @@ -26,6 +26,7 @@
from ._lib._utils._typing import Array, ArrayNamespace, Device, DType

__all__ = [
"apply_along_axis",
"argpartition",
"atleast_nd",
"broadcast_shapes",
Expand Down Expand Up @@ -1732,3 +1733,45 @@ def nanmax(
return xp.nanmax(a, axis=axis)

return _funcs.nanmax(a, axis=axis, xp=xp)


def apply_along_axis(
func1d: Callable[..., Array],
axis: int,
arr: Array,
*args: Any,
xp: ArrayNamespace | None = None,
**kwargs: Any,
) -> Array:
"""
Apply a function to 1-D slices along a given axis.

Parameters
----------
func1d : callable
Function to apply to 1-D slices of `arr`.
axis : int
Axis along which `func1d` is applied.
arr : Array
Input array.
xp : array_namespace, optional
The standard-compatible namespace for `arr`. Default: infer.

Returns
-------
Array
The output array formed by applying `func1d` to each 1-D slice.
"""
if xp is None:
xp = array_namespace(arr)
if (
is_numpy_namespace(xp)
or is_cupy_namespace(xp)
or is_dask_namespace(xp)
or is_jax_namespace(xp)
):
return xp.apply_along_axis(func1d, axis, arr, *args, **kwargs)

return _funcs.apply_along_axis(
func1d, axis, arr, *args, **kwargs
)
44 changes: 43 additions & 1 deletion src/array_api_extra/_lib/_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import warnings
from collections.abc import Callable, Sequence
from types import NoneType
from typing import Literal, cast, overload
from typing import Any, Literal, cast, overload

from ._at import at
from ._utils import _compat, _helpers
Expand All @@ -24,6 +24,7 @@

__all__ = [
"angle",
"apply_along_axis",
"apply_where",
"argpartition",
"atleast_nd",
Expand Down Expand Up @@ -233,6 +234,47 @@
return x


def apply_along_axis(
func1d: Callable[..., Array],
axis: int,
arr: Array,
/,
*args: Any,
**kwargs: Any,
) -> Array:
"""Apply a function to 1-D slices along a given axis."""
if axis < 0:
axis += arr.ndim

if not 0 <= axis < arr.ndim:
msg = f"axis {axis} is out of bounds for array of dimension {arr.ndim}"
raise ValueError(msg)

if kwargs is None:

Check failure on line 253 in src/array_api_extra/_lib/_funcs.py

View workflow job for this annotation

GitHub Actions / Lint

Condition will always evaluate to False since the types "dict[str, Any]" and "None" have no overlap (reportUnnecessaryComparison)
kwargs = {}

if not isinstance(args, tuple):

Check failure on line 256 in src/array_api_extra/_lib/_funcs.py

View workflow job for this annotation

GitHub Actions / Lint

Unnecessary isinstance call; "tuple[Any, ...]" is always an instance of "tuple[Unknown, ...]" (reportUnnecessaryIsInstance)
args = (args,)

xp = array_namespace(arr)
perm = tuple(i for i in range(arr.ndim) if i != axis) + (axis,)
arr = xp.permute_dims(arr, perm)
leading_shape = arr.shape[:-1]
arr = xp.reshape(arr, (-1, arr.shape[-1]))
results = [func1d(slice, *args, **kwargs) for slice in xp.unstack(arr, axis=0)]

if not all(hasattr(result, "dtype") for result in results):
results = [xp.asarray(result, dtype=arr.dtype) for result in results]

out = xp.stack(results, axis=0)
out = xp.reshape(out, leading_shape + out.shape[1:])
before = tuple(range(axis))
after = tuple(range(axis, len(leading_shape)))
res = tuple(range(len(leading_shape), out.ndim))
perm = before + res + after
return xp.permute_dims(out, perm)


# `float` in signature to accept `math.nan` for Dask.
# `int`s are still accepted as `float` is a superclass of `int` in typing
def broadcast_shapes( # numpydoc ignore=PR01,RT01
Expand Down
74 changes: 74 additions & 0 deletions tests/test_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import array_api_extra._delegation as delegated_func
from array_api_extra import (
angle,
apply_along_axis,
apply_where,
argpartition,
at,
Expand Down Expand Up @@ -107,6 +108,79 @@
)


class TestApplyAlongAxis:
@pytest.mark.xfail_xp_backend(
Backend.DASK,
reason="Dask apply_along_axis infers output shape differently",
strict=False,
)
Comment on lines +112 to +116

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

test_simple[dask] - AssertionError: shapes do not match: (2, 1) != (2, 3)

def test_simple(self, xp: ArrayNamespace):
x = xp.asarray([[1, 2, 3], [4, 5, 6]])
actual = apply_along_axis(
lambda row: row + 1,
axis=1,
arr=x,
)
expected = xp.asarray([[2, 3, 4], [5, 6, 7]])
assert_equal(actual, expected)

@pytest.mark.xfail_xp_backend(
Backend.DASK,
reason="Dask implementation computes eagerly",
strict=False,
)
@pytest.mark.xfail_xp_backend(
Backend.SPARSE,
reason="Sparse cannot stack scalar arrays with different fill values",
strict=False,
)
Comment on lines +132 to +136

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

test_scalar_output[sparse] - ValueError: This operation requires consistent fill-values, but argument 1 had a fill value of 15, which is different from a fill_value of 6 in the first argument.

def test_scalar_output(self, xp: ArrayNamespace):
x = xp.asarray([[1, 2, 3], [4, 5, 6]])
actual = apply_along_axis(
xp.sum,
axis=1,
arr=x,
)
expected = xp.asarray([6, 15])
assert_equal(actual, expected)

@pytest.mark.xfail_xp_backend(
Backend.DASK,
reason="Dask implementation computes eagerly",
strict=False,
)
def test_negative_axis(self, xp: ArrayNamespace):
x = xp.asarray([[1, 2, 3], [4, 5, 6]])
actual = apply_along_axis(
lambda row: xp.flip(row, axis=0),
axis=-1,
arr=x,
)
expected = xp.asarray([[3, 2, 1], [6, 5, 4]])
assert_equal(actual, expected)

@pytest.mark.xfail_xp_backend(
Backend.SPARSE,
reason="Sparse cannot stack scalar arrays with different fill values",
strict=False,
)
def test_3d_scalar_output(self, xp: ArrayNamespace):
arr_np = np.arange(24).reshape(2, 3, 4)
x = xp.asarray(arr_np)
expected = np.apply_along_axis(

Check failure on line 170 in tests/test_funcs.py

View workflow job for this annotation

GitHub Actions / Lint

No overloads for "apply_along_axis" match the provided arguments (reportCallIssue)
np.sum,

Check failure on line 171 in tests/test_funcs.py

View workflow job for this annotation

GitHub Actions / Lint

Argument of type "Overload[(a: _ArrayLike[_ScalarT@sum], axis: None = None, dtype: None = None, out: None = None, keepdims: _NoValueType | Literal[False] = ..., initial: _NumberLike_co | _NoValueType = ..., where: _ArrayLikeBool_co | _NoValueType = ...) -> _ScalarT@sum, (a: _ArrayLike[_ScalarT@sum], axis: None = None, dtype: None = None, out: None = None, keepdims: builtins.bool | _NoValueType = ..., initial: _NumberLike_co | _NoValueType = ..., where: _ArrayLikeBool_co | _NoValueType = ...) -> (_ScalarT@sum | NDArray[_ScalarT@sum]), (a: ArrayLike, axis: None, dtype: _DTypeLike[_ScalarT@sum], out: None = None, keepdims: _NoValueType | Literal[False] = ..., initial: _NumberLike_co | _NoValueType = ..., where: _ArrayLikeBool_co | _NoValueType = ...) -> _ScalarT@sum, (a: ArrayLike, axis: None = None, *, dtype: _DTypeLike[_ScalarT@sum], out: None = None, keepdims: _NoValueType | Literal[False] = ..., initial: _NumberLike_co | _NoValueType = ..., where: _ArrayLikeBool_co | _NoValueType = ...) -> _ScalarT@sum, (a: ArrayLike, axis: _ShapeLike | None, dtype: _DTypeLike[_ScalarT@sum], out: None = None, keepdims: builtins.bool | _NoValueType = ..., initial: _NumberLike_co | _NoValueType = ..., where: _ArrayLikeBool_co | _NoValueType = ...) -> (_ScalarT@sum | NDArray[_ScalarT@sum]), (a: ArrayLike, axis: _ShapeLike | None = None, *, dtype: _DTypeLike[_ScalarT@sum], out: None = None, keepdims: builtins.bool | _NoValueType = ..., initial: _NumberLike_co | _NoValueType = ..., where: _ArrayLikeBool_co | _NoValueType = ...) -> (_ScalarT@sum | NDArray[_ScalarT@sum]), (a: ArrayLike, axis: _ShapeLike | None = None, dtype: DTypeLike | None = None, out: None = None, keepdims: builtins.bool | _NoValueType = ..., initial: _NumberLike_co | _NoValueType = ..., where: _ArrayLikeBool_co | _NoValueType = ...) -> Any, (a: ArrayLike, axis: _ShapeLike | None, dtype: DTypeLike | None, out: _ArrayT@sum, keepdims: builtins.bool | _NoValueType = ..., initial: _NumberLike_co | _NoValueType = ..., where: _ArrayLikeBool_co | _NoValueType = ...) -> _ArrayT@sum, (a: ArrayLike, axis: _ShapeLike | None = None, dtype: DTypeLike | None = None, *, out: _ArrayT@sum, keepdims: builtins.bool | _NoValueType = ..., initial: _NumberLike_co | _NoValueType = ..., where: _ArrayLikeBool_co | _NoValueType = ...) -> _ArrayT@sum]" cannot be assigned to parameter "func1d" of type "(NDArray[Any], **_P@apply_along_axis) -> Any" in function "apply_along_axis"   No overloaded function matches type "(NDArray[Any], **_P@apply_along_axis) -> Any" (reportArgumentType)
1,
arr_np,
None,
)
actual = apply_along_axis(
xp.sum,
axis=1,
arr=x,
)
assert_equal(actual, xp.asarray(expected))


class TestApplyWhere:
@staticmethod
def f1(x: Array, y: Array | int = 10) -> Array:
Expand Down
Loading