Skip to content

Commit 09e89bf

Browse files
committed
ENH: add edge and wrap modes to pad
1 parent 9a29e7b commit 09e89bf

3 files changed

Lines changed: 109 additions & 14 deletions

File tree

src/array_api_extra/_delegation.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -855,7 +855,7 @@ def one_hot(
855855
def pad(
856856
x: Array,
857857
pad_width: int | tuple[int, int] | Sequence[tuple[int, int]],
858-
mode: Literal["constant"] = "constant",
858+
mode: Literal["constant", "edge", "wrap"] = "constant",
859859
*,
860860
constant_values: complex = 0,
861861
xp: ArrayNamespace | None = None,
@@ -874,8 +874,9 @@ def pad(
874874
A single tuple, ``(before, after)``, is equivalent to a list of ``x.ndim``
875875
copies of this tuple.
876876
mode : str, optional
877-
Only "constant" mode is currently supported, which pads with
878-
the value passed to `constant_values`.
877+
Padding mode. "constant" pads with the value passed to
878+
`constant_values`, "edge" pads with the edge values of the array, and
879+
"wrap" pads by wrapping values from the opposite edge.
879880
constant_values : python scalar, optional
880881
Use this value to pad the input. Default is zero.
881882
xp : array_namespace, optional
@@ -885,12 +886,12 @@ def pad(
885886
-------
886887
array
887888
The input array,
888-
padded with ``pad_width`` elements equal to ``constant_values``.
889+
padded according to ``mode``.
889890
"""
890891
xp = array_namespace(x) if xp is None else xp
891892

892-
if mode != "constant":
893-
msg = "Only `'constant'` mode is currently supported"
893+
if mode not in {"constant", "edge", "wrap"}:
894+
msg = f"Unsupported padding mode {mode!r}"
894895
raise NotImplementedError(msg)
895896

896897
if (
@@ -899,17 +900,20 @@ def pad(
899900
or is_jax_namespace(xp)
900901
or is_pydata_sparse_namespace(xp)
901902
):
902-
return xp.pad(x, pad_width, mode, constant_values=constant_values)
903+
if mode == "constant":
904+
return xp.pad(x, pad_width, mode, constant_values=constant_values)
905+
if not is_pydata_sparse_namespace(xp):
906+
return xp.pad(x, pad_width, mode)
903907

904-
if is_torch_namespace(xp):
908+
if mode == "constant" and is_torch_namespace(xp):
905909
# normalize `pad_width` on the host rather than through a tensor as done in
906910
# `torch/_numpy`'s implementation (avoids device transfers)
907911
pad_width_seq = normalize_pad_width(pad_width, x.ndim)
908912
# torch.nn.functional.pad counts dimensions from the last one
909913
flat_pad_width = [w for pair in reversed(pad_width_seq) for w in pair]
910914
return xp.nn.functional.pad(x, tuple(flat_pad_width), value=constant_values)
911915

912-
return _funcs.pad(x, pad_width, constant_values=constant_values, xp=xp)
916+
return _funcs.pad(x, pad_width, mode=mode, constant_values=constant_values, xp=xp)
913917

914918

915919
def searchsorted(

src/array_api_extra/_lib/_funcs.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -585,19 +585,74 @@ def pad(
585585
x: Array,
586586
pad_width: int | tuple[int, int] | Sequence[tuple[int, int]],
587587
*,
588+
mode: Literal["constant", "edge", "wrap"] = "constant",
588589
constant_values: complex = 0,
589590
xp: ArrayNamespace,
590591
) -> Array: # numpydoc ignore=PR01,RT01
591592
"""See docstring in `array_api_extra._delegation.py`."""
592593
pad_width_seq = normalize_pad_width(pad_width, x.ndim)
593594

594-
slices: list[slice] = []
595-
newshape: list[int] = []
596-
for ax, w_tpl in enumerate(pad_width_seq):
595+
if len(pad_width_seq) != x.ndim:
596+
msg = f"expected {x.ndim} pairs of pad widths, got {len(pad_width_seq)}"
597+
raise ValueError(msg)
598+
599+
for w_tpl in pad_width_seq:
597600
if len(w_tpl) != 2:
598601
msg = f"expect a 2-tuple (before, after), got {w_tpl}."
599602
raise ValueError(msg)
603+
if w_tpl[0] < 0 or w_tpl[1] < 0:
604+
msg = "index can't contain negative values"
605+
raise ValueError(msg)
600606

607+
if mode != "constant":
608+
for axis, (before, after) in enumerate(pad_width_seq):
609+
if before == 0 and after == 0:
610+
continue
611+
612+
axis_size = eager_shape(x)[axis]
613+
if axis_size == 0:
614+
msg = f"can't extend empty axis {axis} using mode {mode!r}"
615+
raise ValueError(msg)
616+
617+
parts: list[Array] = []
618+
if mode == "edge":
619+
shape = list(eager_shape(x))
620+
if before:
621+
before_slice = [slice(None)] * x.ndim
622+
before_slice[axis] = slice(0, 1)
623+
shape[axis] = before
624+
parts.append(xp.broadcast_to(x[tuple(before_slice)], tuple(shape)))
625+
626+
parts.append(x)
627+
628+
if after:
629+
after_slice = [slice(None)] * x.ndim
630+
after_slice[axis] = slice(-1, None)
631+
shape[axis] = after
632+
parts.append(xp.broadcast_to(x[tuple(after_slice)], tuple(shape)))
633+
else:
634+
before_repeats, before_remainder = divmod(before, axis_size)
635+
after_repeats, after_remainder = divmod(after, axis_size)
636+
637+
if before_remainder:
638+
before_slice = [slice(None)] * x.ndim
639+
before_slice[axis] = slice(axis_size - before_remainder, None)
640+
parts.append(x[tuple(before_slice)])
641+
parts.extend([x] * before_repeats)
642+
parts.append(x)
643+
parts.extend([x] * after_repeats)
644+
if after_remainder:
645+
after_slice = [slice(None)] * x.ndim
646+
after_slice[axis] = slice(0, after_remainder)
647+
parts.append(x[tuple(after_slice)])
648+
649+
x = xp.concat(parts, axis=axis)
650+
651+
return x
652+
653+
slices: list[slice] = []
654+
newshape: list[int] = []
655+
for ax, w_tpl in enumerate(pad_width_seq):
601656
sh = eager_shape(x)[ax]
602657

603658
if w_tpl[0] == 0 and w_tpl[1] == 0:

tests/test_funcs.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1516,10 +1516,46 @@ def test_ndim(self, xp: ArrayNamespace):
15161516
padded = pad(a, 2)
15171517
assert padded.shape == (6, 7, 8)
15181518

1519+
def test_edge(self, xp: ArrayNamespace):
1520+
a = xp.asarray([1, 2, 3])
1521+
padded = pad(a, (2, 1), mode="edge")
1522+
assert_equal(padded, xp.asarray([1, 1, 1, 2, 3, 3]))
1523+
1524+
def test_edge_ndim(self, xp: ArrayNamespace):
1525+
a = xp.asarray([[1, 2], [3, 4]])
1526+
padded = pad(a, ((1, 2), (2, 1)), mode="edge")
1527+
expected = xp.asarray(
1528+
[
1529+
[1, 1, 1, 2, 2],
1530+
[1, 1, 1, 2, 2],
1531+
[3, 3, 3, 4, 4],
1532+
[3, 3, 3, 4, 4],
1533+
[3, 3, 3, 4, 4],
1534+
]
1535+
)
1536+
assert_equal(padded, expected)
1537+
1538+
def test_wrap(self, xp: ArrayNamespace):
1539+
a = xp.asarray([1, 2, 3])
1540+
padded = pad(a, (5, 4), mode="wrap")
1541+
assert_equal(padded, xp.asarray([2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1]))
1542+
1543+
def test_wrap_ndim(self, xp: ArrayNamespace):
1544+
a = xp.asarray([[1, 2], [3, 4]])
1545+
padded = pad(a, ((1, 1), (1, 1)), mode="wrap")
1546+
expected = xp.asarray([[4, 3, 4, 3], [2, 1, 2, 1], [4, 3, 4, 3], [2, 1, 2, 1]])
1547+
assert_equal(padded, expected)
1548+
1549+
@pytest.mark.parametrize("mode", ["edge", "wrap"])
1550+
def test_empty_axis(self, xp: ArrayNamespace, mode: str):
1551+
a = xp.asarray([])
1552+
with pytest.raises(ValueError, match="can't extend empty axis"):
1553+
_ = pad(a, 1, mode=mode) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
1554+
15191555
def test_mode_not_implemented(self, xp: ArrayNamespace):
15201556
a = xp.asarray([1, 2, 3])
1521-
with pytest.raises(NotImplementedError, match="Only `'constant'`"):
1522-
_ = pad(a, 2, mode="edge") # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
1557+
with pytest.raises(NotImplementedError, match="Unsupported padding mode"):
1558+
_ = pad(a, 2, mode="reflect") # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
15231559

15241560
def test_device(self, xp: ArrayNamespace, device: Device):
15251561
a = xp.asarray(0.0, device=device)

0 commit comments

Comments
 (0)