Skip to content

Commit

Permalink
WIP at() method
Browse files Browse the repository at this point in the history
  • Loading branch information
crusaderky committed Dec 10, 2024
1 parent 343ebf4 commit 376ad49
Show file tree
Hide file tree
Showing 8 changed files with 467 additions and 8 deletions.
1 change: 1 addition & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
:nosignatures:
:toctree: generated
at
atleast_nd
cov
create_diagonal
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ ignore = [
"ISC001", # Conflicts with formatter
"N802", # Function name should be lowercase
"N806", # Variable in function should be lowercase
"PD008", # pandas-use-of-dot-at
]

[tool.ruff.lint.per-file-ignores]
Expand Down
12 changes: 11 additions & 1 deletion src/array_api_extra/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
from __future__ import annotations

from ._funcs import atleast_nd, cov, create_diagonal, expand_dims, kron, setdiff1d, sinc
from ._funcs import (
at,
atleast_nd,
cov,
create_diagonal,
expand_dims,
kron,
setdiff1d,
sinc,
)

__version__ = "0.3.3.dev0"

__all__ = [
"__version__",
"at",
"atleast_nd",
"cov",
"create_diagonal",
Expand Down
285 changes: 284 additions & 1 deletion src/array_api_extra/_funcs.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
from __future__ import annotations

import operator
import warnings
from collections.abc import Callable
from typing import Any

from ._lib import _utils
from ._lib._compat import array_namespace
from ._lib._compat import (
array_namespace,
is_array_api_obj,
is_dask_array,
is_writeable_array,
)
from ._lib._typing import Array, ModuleType

__all__ = [
"at",
"atleast_nd",
"cov",
"create_diagonal",
Expand Down Expand Up @@ -545,3 +554,277 @@ def sinc(x: Array, /, *, xp: ModuleType | None = None) -> Array:
xp.asarray(xp.finfo(x.dtype).eps, dtype=x.dtype, device=x.device),
)
return xp.sin(y) / y


_undef = object()


class at: # noqa: N801
"""
Update operations for read-only arrays.
This implements ``jax.numpy.ndarray.at`` for all backends.
Parameters
----------
x : array
Input array.
idx : index, optional
You may use two alternate syntaxes::
at(x, idx).set(value) # or get(), add(), etc.
at(x)[idx].set(value)
copy : bool, optional
True (default)
Ensure that the inputs are not modified.
False
Ensure that the update operation writes back to the input.
Raise ValueError if a copy cannot be avoided.
None
The array parameter *may* be modified in place if it is possible and
beneficial for performance.
You should not reuse it after calling this function.
xp : array_namespace, optional
The standard-compatible namespace for `x`. Default: infer
Additionally, if the backend supports an `at` method, any additional keyword
arguments are passed to it verbatim; e.g. this allows passing
``indices_are_sorted=True`` to JAX.
Returns
-------
Updated input array.
Examples
--------
Given either of these equivalent expressions::
x = at(x)[1].add(2, copy=None)
x = at(x, 1).add(2, copy=None)
If x is a JAX array, they are the same as::
x = x.at[1].add(2)
If x is a read-only numpy array, they are the same as::
x = x.copy()
x[1] += 2
Otherwise, they are the same as::
x[1] += 2
Warning
-------
When you use copy=None, you should always immediately overwrite
the parameter array::
x = at(x, 0).set(2, copy=None)
The anti-pattern below must be avoided, as it will result in different behaviour
on read-only versus writeable arrays::
x = xp.asarray([0, 0, 0])
y = at(x, 0).set(2, copy=None)
z = at(x, 1).set(3, copy=None)
In the above example, ``x == [0, 0, 0]``, ``y == [2, 0, 0]`` and z == ``[0, 3, 0]``
when x is read-only, whereas ``x == y == z == [2, 3, 0]`` when x is writeable!
Warning
-------
The behaviour of update methods when the index is an array of integers which
contains multiple occurrences of the same index is undefined;
e.g. ``at(x, [0, 0]).set(2)``
Note
----
`sparse <https://sparse.pydata.org/>`_ is not supported by update methods yet.
See Also
--------
`jax.numpy.ndarray.at <https://jax.readthedocs.io/en/latest/_autosummary/jax.numpy.ndarray.at.html>`_
"""

x: Array
idx: Any
__slots__ = ("idx", "x")

def __init__(self, x: Array, idx: Any = _undef, /):
self.x = x
self.idx = idx

def __getitem__(self, idx: Any) -> Any:
"""Allow for the alternate syntax ``at(x)[start:stop:step]``,
which looks prettier than ``at(x, slice(start, stop, step))``
and feels more intuitive coming from the JAX documentation.
"""
if self.idx is not _undef:
msg = "Index has already been set"
raise ValueError(msg)
self.idx = idx
return self

Check warning on line 668 in src/array_api_extra/_funcs.py

View check run for this annotation

Codecov / codecov/patch

src/array_api_extra/_funcs.py#L664-L668

Added lines #L664 - L668 were not covered by tests

def _common(
self,
at_op: str,
y: Array = _undef,
/,
copy: bool | None = True,
xp: ModuleType | None = None,
_is_update: bool = True,
**kwargs: Any,
) -> tuple[Any, None] | tuple[None, Array]:
"""Perform common prepocessing.
Returns
-------
If the operation can be resolved by at[], (return value, None)
Otherwise, (None, preprocessed x)
"""
if self.idx is _undef:
msg = (

Check warning on line 688 in src/array_api_extra/_funcs.py

View check run for this annotation

Codecov / codecov/patch

src/array_api_extra/_funcs.py#L688

Added line #L688 was not covered by tests
"Index has not been set.\n"
"Usage: either\n"
" at(x, idx).set(value)\n"
"or\n"
" at(x)[idx].set(value)\n"
"(same for all other methods)."
)
raise TypeError(msg)

Check warning on line 696 in src/array_api_extra/_funcs.py

View check run for this annotation

Codecov / codecov/patch

src/array_api_extra/_funcs.py#L696

Added line #L696 was not covered by tests

x = self.x

if copy is True:
writeable = None
elif copy is False:
writeable = is_writeable_array(x)
if not writeable:
msg = "Cannot modify parameter in place"
raise ValueError(msg)
elif copy is None:
writeable = is_writeable_array(x)
copy = _is_update and not writeable
else:
msg = f"Invalid value for copy: {copy!r}" # type: ignore[unreachable]
raise ValueError(msg)

Check warning on line 712 in src/array_api_extra/_funcs.py

View check run for this annotation

Codecov / codecov/patch

src/array_api_extra/_funcs.py#L711-L712

Added lines #L711 - L712 were not covered by tests

if copy:
try:
at_ = x.at
except AttributeError:
# Emulate at[] behaviour for non-JAX arrays
# with a copy followed by an update
if xp is None:
xp = array_namespace(x)
# Create writeable copy of read-only numpy array
x = xp.asarray(x, copy=True)
if writeable is False:
# A copy of a read-only numpy array is writeable
writeable = None
else:
# Use JAX's at[] or other library that with the same duck-type API
args = (y,) if y is not _undef else ()
return getattr(at_[self.idx], at_op)(*args, **kwargs), None

Check warning on line 730 in src/array_api_extra/_funcs.py

View check run for this annotation

Codecov / codecov/patch

src/array_api_extra/_funcs.py#L729-L730

Added lines #L729 - L730 were not covered by tests

if _is_update:
if writeable is None:
writeable = is_writeable_array(x)
if not writeable:
# sparse crashes here
msg = f"Array {x} has no `at` method and is read-only"
raise ValueError(msg)

Check warning on line 738 in src/array_api_extra/_funcs.py

View check run for this annotation

Codecov / codecov/patch

src/array_api_extra/_funcs.py#L737-L738

Added lines #L737 - L738 were not covered by tests

return None, x

def get(self, **kwargs: Any) -> Any:
"""Return ``x[idx]``. In addition to plain ``__getitem__``, this allows ensuring
that the output is either a copy or a view; it also allows passing
keyword arguments to the backend.
"""
if kwargs.get("copy") is False:
if is_array_api_obj(self.idx):
# Boolean index. Note that the array API spec
# https://data-apis.org/array-api/latest/API_specification/indexing.html
# does not allow for list, tuple, and tuples of slices plus one or more
# one-dimensional array indices, although many backends support them.
# So this check will encounter a lot of false negatives in real life,
# which can be caught by testing the user code vs. array-api-strict.
msg = "get() with an array index always returns a copy"
raise ValueError(msg)
if is_dask_array(self.x):
msg = "get() on Dask arrays always returns a copy"
raise ValueError(msg)

Check warning on line 759 in src/array_api_extra/_funcs.py

View check run for this annotation

Codecov / codecov/patch

src/array_api_extra/_funcs.py#L758-L759

Added lines #L758 - L759 were not covered by tests

res, x = self._common("get", _is_update=False, **kwargs)
if res is not None:
return res

Check warning on line 763 in src/array_api_extra/_funcs.py

View check run for this annotation

Codecov / codecov/patch

src/array_api_extra/_funcs.py#L763

Added line #L763 was not covered by tests
assert x is not None
return x[self.idx]

def set(self, y: Array, /, **kwargs: Any) -> Array:
"""Apply ``x[idx] = y`` and return the update array"""
res, x = self._common("set", y, **kwargs)
if res is not None:
return res

Check warning on line 771 in src/array_api_extra/_funcs.py

View check run for this annotation

Codecov / codecov/patch

src/array_api_extra/_funcs.py#L771

Added line #L771 was not covered by tests
assert x is not None
x[self.idx] = y
return x

def _iop(
self,
at_op: str,
elwise_op: Callable[[Array, Array], Array],
y: Array,
/,
**kwargs: Any,
) -> Array:
"""x[idx] += y or equivalent in-place operation on a subset of x
which is the same as saying
x[idx] = x[idx] + y
Note that this is not the same as
operator.iadd(x[idx], y)
Consider for example when x is a numpy array and idx is a fancy index, which
triggers a deep copy on __getitem__.
"""
res, x = self._common(at_op, y, **kwargs)
if res is not None:
return res

Check warning on line 795 in src/array_api_extra/_funcs.py

View check run for this annotation

Codecov / codecov/patch

src/array_api_extra/_funcs.py#L795

Added line #L795 was not covered by tests
assert x is not None
x[self.idx] = elwise_op(x[self.idx], y)
return x

def add(self, y: Array, /, **kwargs: Any) -> Array:
"""Apply ``x[idx] += y`` and return the updated array"""
return self._iop("add", operator.add, y, **kwargs)

def subtract(self, y: Array, /, **kwargs: Any) -> Array:
"""Apply ``x[idx] -= y`` and return the updated array"""
return self._iop("subtract", operator.sub, y, **kwargs)

def multiply(self, y: Array, /, **kwargs: Any) -> Array:
"""Apply ``x[idx] *= y`` and return the updated array"""
return self._iop("multiply", operator.mul, y, **kwargs)

def divide(self, y: Array, /, **kwargs: Any) -> Array:
"""Apply ``x[idx] /= y`` and return the updated array"""
return self._iop("divide", operator.truediv, y, **kwargs)

def power(self, y: Array, /, **kwargs: Any) -> Array:
"""Apply ``x[idx] **= y`` and return the updated array"""
return self._iop("power", operator.pow, y, **kwargs)

def min(self, y: Array, /, **kwargs: Any) -> Array:
"""Apply ``x[idx] = minimum(x[idx], y)`` and return the updated array"""
xp = array_namespace(self.x)
y = xp.asarray(y)
return self._iop("min", xp.minimum, y, **kwargs)

def max(self, y: Array, /, **kwargs: Any) -> Array:
"""Apply ``x[idx] = maximum(x[idx], y)`` and return the updated array"""
xp = array_namespace(self.x)
y = xp.asarray(y)
return self._iop("max", xp.maximum, y, **kwargs)
19 changes: 14 additions & 5 deletions src/array_api_extra/_lib/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,26 @@
from __future__ import annotations

try:
from ..._array_api_compat_vendor import (
array_namespace,
device,
from ..._array_api_compat_vendor import ( # pyright: ignore[reportMissingImports]
array_namespace, # pyright: ignore[reportUnknownVariableType]
device, # pyright: ignore[reportUnknownVariableType]
is_array_api_obj, # pyright: ignore[reportUnknownVariableType]
is_dask_array, # pyright: ignore[reportUnknownVariableType]
is_writeable_array, # pyright: ignore[reportUnknownVariableType]
)
except ImportError:
from array_api_compat import (
array_namespace,
device,
is_array_api_obj, # pyright: ignore[reportUnknownVariableType]
is_dask_array, # pyright: ignore[reportUnknownVariableType]
is_writeable_array, # pyright: ignore[reportUnknownVariableType,reportAttributeAccessIssue]
)

__all__ = [
__all__ = (
"array_namespace",
"device",
]
"is_array_api_obj",
"is_dask_array",
"is_writeable_array",
)
3 changes: 3 additions & 0 deletions src/array_api_extra/_lib/_compat.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ def array_namespace(
use_compat: bool | None = None,
) -> ArrayModule: ...
def device(x: Array, /) -> Device: ...
def is_array_api_obj(x: object, /) -> bool: ...
def is_dask_array(x: object, /) -> bool: ...
def is_writeable_array(x: object, /) -> bool: ...
Loading

0 comments on commit 376ad49

Please sign in to comment.