Skip to content

Commit e905c26

Browse files
ENH: add diag_indices, tril_indices, triu_indices
Resolves #686. Adds the three index-generating functions that numpy, jax, and cupy all have but that are missing from the array-api standard and (so far) from this library. Signatures follow array-api conventions: parameter `offset` (matching `xp.linalg.diagonal`) instead of numpy's `k`; keyword-only arguments for everything except `n`; `xp` is required (these functions have no input array to infer from, following the `default_dtype` precedent). Delegation: - numpy/cupy/jax: forward directly (signatures match verbatim). - dask: has tril/triu_indices but no diag_indices. - torch: has tril/triu_indices but with (row, col, *, offset) signature returning a 2xN tensor rather than a tuple; delegation translates. No torch.diag_indices exists; falls through to generic. - sparse, array-api-strictest: fall through to generic; marked xfail on those backends (no nonzero / data-dependent shapes). Generic implementation uses `xp.arange` + broadcasting + `xp.nonzero` for the triangle variants. Validation (n >= 0, ndim >= 1, m >= 0) happens in the delegation layer so all backends produce consistent ValueErrors. Also fixes a pre-existing bug in tests/conftest.py's NumPyReadOnly wrapper: `type(o)(*gen)` worked for namedtuples but failed for plain tuples of length >= 2. Exposed here because these are the first functions in the library that return a tuple of arrays.
1 parent 1e1f1ab commit e905c26

5 files changed

Lines changed: 315 additions & 2 deletions

File tree

src/array_api_extra/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
atleast_nd,
66
cov,
77
create_diagonal,
8+
diag_indices,
89
expand_dims,
910
isclose,
1011
isin,
@@ -15,6 +16,8 @@
1516
searchsorted,
1617
setdiff1d,
1718
sinc,
19+
tril_indices,
20+
triu_indices,
1821
union1d,
1922
)
2023
from ._lib._at import at
@@ -40,6 +43,7 @@
4043
"cov",
4144
"create_diagonal",
4245
"default_dtype",
46+
"diag_indices",
4347
"expand_dims",
4448
"isclose",
4549
"isin",
@@ -53,5 +57,7 @@
5357
"searchsorted",
5458
"setdiff1d",
5559
"sinc",
60+
"tril_indices",
61+
"triu_indices",
5662
"union1d",
5763
]

src/array_api_extra/_delegation.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,27 @@
2222
"atleast_nd",
2323
"cov",
2424
"create_diagonal",
25+
"diag_indices",
2526
"expand_dims",
2627
"isclose",
2728
"nan_to_num",
2829
"one_hot",
2930
"pad",
3031
"searchsorted",
3132
"sinc",
33+
"tril_indices",
34+
"triu_indices",
3235
]
3336

3437

38+
def _check_nonneg(**kwargs: int | None) -> None: # numpydoc ignore=PR01
39+
"""Raise ``ValueError`` if any keyword argument is a negative int."""
40+
for name, value in kwargs.items():
41+
if value is not None and value < 0:
42+
msg = f"{name} must be non-negative, got {value}"
43+
raise ValueError(msg)
44+
45+
3546
def atleast_nd(x: Array, /, *, ndim: int, xp: ModuleType | None = None) -> Array:
3647
"""
3748
Recursively expand the dimension of an array to at least `ndim`.
@@ -238,6 +249,47 @@ def create_diagonal(
238249
return _funcs.create_diagonal(x, offset=offset, xp=xp)
239250

240251

252+
def diag_indices(n: int, /, *, ndim: int = 2, xp: ModuleType) -> tuple[Array, ...]:
253+
"""
254+
Return the indices to access the main diagonal of an array.
255+
256+
Equivalent to ``numpy.diag_indices``.
257+
258+
Parameters
259+
----------
260+
n : int
261+
The size of each dimension of the (hyper-)cube ``(n, n, ..., n)``
262+
that the returned indices index into.
263+
ndim : int, optional
264+
The number of dimensions. Default: ``2``.
265+
xp : array_namespace
266+
The standard-compatible namespace to create the indices in.
267+
268+
Returns
269+
-------
270+
tuple of array
271+
``ndim`` 1-D integer arrays of length ``n`` that together index
272+
the main diagonal of an array of shape ``(n,) * ndim``.
273+
274+
Examples
275+
--------
276+
>>> import array_api_strict as xp
277+
>>> import array_api_extra as xpx
278+
>>> rows, cols = xpx.diag_indices(3, xp=xp)
279+
>>> rows
280+
Array([0, 1, 2], dtype=array_api_strict.int64)
281+
>>> cols
282+
Array([0, 1, 2], dtype=array_api_strict.int64)
283+
"""
284+
_check_nonneg(n=n)
285+
if ndim < 1:
286+
msg = f"ndim must be >= 1, got {ndim}"
287+
raise ValueError(msg)
288+
if is_numpy_namespace(xp) or is_cupy_namespace(xp) or is_jax_namespace(xp):
289+
return xp.diag_indices(n, ndim=ndim)
290+
return _funcs.diag_indices(n, ndim=ndim, xp=xp)
291+
292+
241293
def expand_dims(
242294
a: Array, /, *, axis: int | tuple[int, ...] = (0,), xp: ModuleType | None = None
243295
) -> Array:
@@ -1150,3 +1202,109 @@ def union1d(a: Array, b: Array, /, *, xp: ModuleType | None = None) -> Array:
11501202
return xp.union1d(a, b)
11511203

11521204
return _funcs.union1d(a, b, xp=xp)
1205+
1206+
1207+
def tril_indices(
1208+
n: int, /, *, offset: int = 0, m: int | None = None, xp: ModuleType
1209+
) -> tuple[Array, Array]:
1210+
"""
1211+
Return the indices of the lower triangle of an ``(n, m)`` array.
1212+
1213+
Equivalent to ``numpy.tril_indices`` with parameter ``k`` renamed to
1214+
``offset`` to match ``xp.linalg.diagonal``'s naming.
1215+
1216+
Parameters
1217+
----------
1218+
n : int
1219+
The row dimension of the array.
1220+
offset : int, optional
1221+
Diagonal offset; ``0`` (default) is the main diagonal. Corresponds
1222+
to ``k`` in ``numpy.tril_indices``.
1223+
m : int, optional
1224+
The column dimension. If ``None`` (default), assumed equal to `n`.
1225+
xp : array_namespace
1226+
The standard-compatible namespace to create the indices in.
1227+
1228+
Returns
1229+
-------
1230+
tuple of array
1231+
Row and column indices ``(rows, cols)`` of the lower triangle of
1232+
the ``(n, m)`` matrix, shifted by `offset`.
1233+
1234+
Examples
1235+
--------
1236+
>>> import array_api_strict as xp
1237+
>>> import array_api_extra as xpx
1238+
>>> rows, cols = xpx.tril_indices(3, xp=xp)
1239+
>>> rows
1240+
Array([0, 1, 1, 2, 2, 2], dtype=array_api_strict.int64)
1241+
>>> cols
1242+
Array([0, 0, 1, 0, 1, 2], dtype=array_api_strict.int64)
1243+
"""
1244+
_check_nonneg(n=n, m=m)
1245+
if (
1246+
is_numpy_namespace(xp)
1247+
or is_cupy_namespace(xp)
1248+
or is_jax_namespace(xp)
1249+
or is_dask_namespace(xp)
1250+
):
1251+
return xp.tril_indices(n, k=offset, m=m)
1252+
if is_torch_namespace(xp):
1253+
# `torch.tril_indices` returns a 2xN tensor, not a tuple, and
1254+
# takes (row, col) rather than (n, *, m=None).
1255+
cols = n if m is None else m
1256+
idx = xp.tril_indices(n, cols, offset=offset)
1257+
return (idx[0], idx[1])
1258+
return _funcs.tril_indices(n, offset=offset, m=m, xp=xp)
1259+
1260+
1261+
def triu_indices(
1262+
n: int, /, *, offset: int = 0, m: int | None = None, xp: ModuleType
1263+
) -> tuple[Array, Array]:
1264+
"""
1265+
Return the indices of the upper triangle of an ``(n, m)`` array.
1266+
1267+
Equivalent to ``numpy.triu_indices`` with parameter ``k`` renamed to
1268+
``offset`` to match ``xp.linalg.diagonal``'s naming.
1269+
1270+
Parameters
1271+
----------
1272+
n : int
1273+
The row dimension of the array.
1274+
offset : int, optional
1275+
Diagonal offset; ``0`` (default) is the main diagonal. Corresponds
1276+
to ``k`` in ``numpy.triu_indices``.
1277+
m : int, optional
1278+
The column dimension. If ``None`` (default), assumed equal to `n`.
1279+
xp : array_namespace
1280+
The standard-compatible namespace to create the indices in.
1281+
1282+
Returns
1283+
-------
1284+
tuple of array
1285+
Row and column indices ``(rows, cols)`` of the upper triangle of
1286+
the ``(n, m)`` matrix, shifted by `offset`.
1287+
1288+
Examples
1289+
--------
1290+
>>> import array_api_strict as xp
1291+
>>> import array_api_extra as xpx
1292+
>>> rows, cols = xpx.triu_indices(3, xp=xp)
1293+
>>> rows
1294+
Array([0, 0, 0, 1, 1, 2], dtype=array_api_strict.int64)
1295+
>>> cols
1296+
Array([0, 1, 2, 1, 2, 2], dtype=array_api_strict.int64)
1297+
"""
1298+
_check_nonneg(n=n, m=m)
1299+
if (
1300+
is_numpy_namespace(xp)
1301+
or is_cupy_namespace(xp)
1302+
or is_jax_namespace(xp)
1303+
or is_dask_namespace(xp)
1304+
):
1305+
return xp.triu_indices(n, k=offset, m=m)
1306+
if is_torch_namespace(xp):
1307+
cols = n if m is None else m
1308+
idx = xp.triu_indices(n, cols, offset=offset)
1309+
return (idx[0], idx[1])
1310+
return _funcs.triu_indices(n, offset=offset, m=m, xp=xp)

src/array_api_extra/_lib/_funcs.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,16 @@
2828
"broadcast_shapes",
2929
"cov",
3030
"create_diagonal",
31+
"diag_indices",
3132
"expand_dims",
3233
"kron",
3334
"nunique",
3435
"pad",
3536
"searchsorted",
3637
"setdiff1d",
3738
"sinc",
39+
"tril_indices",
40+
"triu_indices",
3841
]
3942

4043

@@ -346,6 +349,41 @@ def create_diagonal(
346349
return xp.reshape(diag, (*batch_dims, n, n))
347350

348351

352+
def diag_indices(
353+
n: int, /, *, ndim: int = 2, xp: ModuleType
354+
) -> tuple[Array, ...]: # numpydoc ignore=PR01,RT01
355+
"""See docstring in array_api_extra._delegation."""
356+
idx = xp.arange(n)
357+
return (idx,) * ndim
358+
359+
360+
def _tri_indices(
361+
n: int, *, offset: int, m: int | None, upper: bool, xp: ModuleType
362+
) -> tuple[Array, Array]: # numpydoc ignore=PR01,RT01
363+
"""Shared implementation for `tril_indices` and `triu_indices`."""
364+
cols = n if m is None else m
365+
rows = xp.arange(n)[:, None]
366+
cols_a = xp.arange(cols)[None, :]
367+
delta = cols_a - rows
368+
mask = delta >= offset if upper else delta <= offset
369+
r, c = xp.nonzero(mask)
370+
return (r, c)
371+
372+
373+
def tril_indices(
374+
n: int, /, *, offset: int = 0, m: int | None = None, xp: ModuleType
375+
) -> tuple[Array, Array]: # numpydoc ignore=PR01,RT01
376+
"""See docstring in array_api_extra._delegation."""
377+
return _tri_indices(n, offset=offset, m=m, upper=False, xp=xp)
378+
379+
380+
def triu_indices(
381+
n: int, /, *, offset: int = 0, m: int | None = None, xp: ModuleType
382+
) -> tuple[Array, Array]: # numpydoc ignore=PR01,RT01
383+
"""See docstring in array_api_extra._delegation."""
384+
return _tri_indices(n, offset=offset, m=m, upper=True, xp=xp)
385+
386+
349387
def default_dtype(
350388
xp: ModuleType,
351389
kind: Literal[

tests/conftest.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,12 @@ def as_readonly(o: T) -> T: # numpydoc ignore=PR01,RT01
9696
# Cannot interpret as a data type
9797
return o
9898

99-
# This works with namedtuples too
10099
if isinstance(o, tuple | list):
101-
return type(o)(*(as_readonly(i) for i in o)) # type: ignore[arg-type,return-value] # pyright: ignore[reportArgumentType]
100+
# namedtuple wants positional args; plain tuple/list wants an iterable.
101+
items = (as_readonly(i) for i in o)
102+
if hasattr(o, "_fields"):
103+
return type(o)(*items) # type: ignore[arg-type,return-value] # pyright: ignore[reportArgumentType]
104+
return type(o)(items) # type: ignore[return-value]
102105

103106
return o
104107

0 commit comments

Comments
 (0)