Skip to content

Commit 1eaeb2a

Browse files
committed
ENH: add edge and wrap modes to pad
1 parent 45cd0af commit 1eaeb2a

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
@@ -809,7 +809,7 @@ def one_hot(
809809
def pad(
810810
x: Array,
811811
pad_width: int | tuple[int, int] | Sequence[tuple[int, int]],
812-
mode: Literal["constant"] = "constant",
812+
mode: Literal["constant", "edge", "wrap"] = "constant",
813813
*,
814814
constant_values: complex = 0,
815815
xp: ModuleType | None = None,
@@ -828,8 +828,9 @@ def pad(
828828
A single tuple, ``(before, after)``, is equivalent to a list of ``x.ndim``
829829
copies of this tuple.
830830
mode : str, optional
831-
Only "constant" mode is currently supported, which pads with
832-
the value passed to `constant_values`.
831+
Padding mode. "constant" pads with the value passed to
832+
`constant_values`, "edge" pads with the edge values of the array, and
833+
"wrap" pads by wrapping values from the opposite edge.
833834
constant_values : python scalar, optional
834835
Use this value to pad the input. Default is zero.
835836
xp : array_namespace, optional
@@ -839,12 +840,12 @@ def pad(
839840
-------
840841
array
841842
The input array,
842-
padded with ``pad_width`` elements equal to ``constant_values``.
843+
padded according to ``mode``.
843844
"""
844845
xp = array_namespace(x) if xp is None else xp
845846

846-
if mode != "constant":
847-
msg = "Only `'constant'` mode is currently supported"
847+
if mode not in {"constant", "edge", "wrap"}:
848+
msg = f"Unsupported padding mode {mode!r}"
848849
raise NotImplementedError(msg)
849850

850851
if (
@@ -853,17 +854,20 @@ def pad(
853854
or is_jax_namespace(xp)
854855
or is_pydata_sparse_namespace(xp)
855856
):
856-
return xp.pad(x, pad_width, mode, constant_values=constant_values)
857+
if mode == "constant":
858+
return xp.pad(x, pad_width, mode, constant_values=constant_values)
859+
if not is_pydata_sparse_namespace(xp):
860+
return xp.pad(x, pad_width, mode)
857861

858-
if is_torch_namespace(xp):
862+
if mode == "constant" and is_torch_namespace(xp):
859863
# normalize `pad_width` on the host rather than through a tensor as done in
860864
# `torch/_numpy`'s implementation (avoids device transfers)
861865
pad_width_seq = normalize_pad_width(pad_width, x.ndim)
862866
# torch.nn.functional.pad counts dimensions from the last one
863867
flat_pad_width = [w for pair in reversed(pad_width_seq) for w in pair]
864868
return xp.nn.functional.pad(x, tuple(flat_pad_width), value=constant_values)
865869

866-
return _funcs.pad(x, pad_width, constant_values=constant_values, xp=xp)
870+
return _funcs.pad(x, pad_width, mode=mode, constant_values=constant_values, xp=xp)
867871

868872

869873
def searchsorted(

src/array_api_extra/_lib/_funcs.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -609,19 +609,74 @@ def pad(
609609
x: Array,
610610
pad_width: int | tuple[int, int] | Sequence[tuple[int, int]],
611611
*,
612+
mode: Literal["constant", "edge", "wrap"] = "constant",
612613
constant_values: complex = 0,
613614
xp: ModuleType,
614615
) -> Array: # numpydoc ignore=PR01,RT01
615616
"""See docstring in `array_api_extra._delegation.py`."""
616617
pad_width_seq = normalize_pad_width(pad_width, x.ndim)
617618

618-
slices: list[slice] = []
619-
newshape: list[int] = []
620-
for ax, w_tpl in enumerate(pad_width_seq):
619+
if len(pad_width_seq) != x.ndim:
620+
msg = f"expected {x.ndim} pairs of pad widths, got {len(pad_width_seq)}"
621+
raise ValueError(msg)
622+
623+
for w_tpl in pad_width_seq:
621624
if len(w_tpl) != 2:
622625
msg = f"expect a 2-tuple (before, after), got {w_tpl}."
623626
raise ValueError(msg)
627+
if w_tpl[0] < 0 or w_tpl[1] < 0:
628+
msg = "index can't contain negative values"
629+
raise ValueError(msg)
624630

631+
if mode != "constant":
632+
for axis, (before, after) in enumerate(pad_width_seq):
633+
if before == 0 and after == 0:
634+
continue
635+
636+
axis_size = eager_shape(x)[axis]
637+
if axis_size == 0:
638+
msg = f"can't extend empty axis {axis} using mode {mode!r}"
639+
raise ValueError(msg)
640+
641+
parts: list[Array] = []
642+
if mode == "edge":
643+
shape = list(eager_shape(x))
644+
if before:
645+
before_slice = [slice(None)] * x.ndim
646+
before_slice[axis] = slice(0, 1)
647+
shape[axis] = before
648+
parts.append(xp.broadcast_to(x[tuple(before_slice)], tuple(shape)))
649+
650+
parts.append(x)
651+
652+
if after:
653+
after_slice = [slice(None)] * x.ndim
654+
after_slice[axis] = slice(-1, None)
655+
shape[axis] = after
656+
parts.append(xp.broadcast_to(x[tuple(after_slice)], tuple(shape)))
657+
else:
658+
before_repeats, before_remainder = divmod(before, axis_size)
659+
after_repeats, after_remainder = divmod(after, axis_size)
660+
661+
if before_remainder:
662+
before_slice = [slice(None)] * x.ndim
663+
before_slice[axis] = slice(axis_size - before_remainder, None)
664+
parts.append(x[tuple(before_slice)])
665+
parts.extend([x] * before_repeats)
666+
parts.append(x)
667+
parts.extend([x] * after_repeats)
668+
if after_remainder:
669+
after_slice = [slice(None)] * x.ndim
670+
after_slice[axis] = slice(0, after_remainder)
671+
parts.append(x[tuple(after_slice)])
672+
673+
x = xp.concat(parts, axis=axis)
674+
675+
return x
676+
677+
slices: list[slice] = []
678+
newshape: list[int] = []
679+
for ax, w_tpl in enumerate(pad_width_seq):
625680
sh = eager_shape(x)[ax]
626681

627682
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
@@ -1496,10 +1496,46 @@ def test_ndim(self, xp: ModuleType):
14961496
padded = pad(a, 2)
14971497
assert padded.shape == (6, 7, 8)
14981498

1499+
def test_edge(self, xp: ModuleType):
1500+
a = xp.asarray([1, 2, 3])
1501+
padded = pad(a, (2, 1), mode="edge")
1502+
assert_equal(padded, xp.asarray([1, 1, 1, 2, 3, 3]))
1503+
1504+
def test_edge_ndim(self, xp: ModuleType):
1505+
a = xp.asarray([[1, 2], [3, 4]])
1506+
padded = pad(a, ((1, 2), (2, 1)), mode="edge")
1507+
expected = xp.asarray(
1508+
[
1509+
[1, 1, 1, 2, 2],
1510+
[1, 1, 1, 2, 2],
1511+
[3, 3, 3, 4, 4],
1512+
[3, 3, 3, 4, 4],
1513+
[3, 3, 3, 4, 4],
1514+
]
1515+
)
1516+
assert_equal(padded, expected)
1517+
1518+
def test_wrap(self, xp: ModuleType):
1519+
a = xp.asarray([1, 2, 3])
1520+
padded = pad(a, (5, 4), mode="wrap")
1521+
assert_equal(padded, xp.asarray([2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1]))
1522+
1523+
def test_wrap_ndim(self, xp: ModuleType):
1524+
a = xp.asarray([[1, 2], [3, 4]])
1525+
padded = pad(a, ((1, 1), (1, 1)), mode="wrap")
1526+
expected = xp.asarray([[4, 3, 4, 3], [2, 1, 2, 1], [4, 3, 4, 3], [2, 1, 2, 1]])
1527+
assert_equal(padded, expected)
1528+
1529+
@pytest.mark.parametrize("mode", ["edge", "wrap"])
1530+
def test_empty_axis(self, xp: ModuleType, mode: str):
1531+
a = xp.asarray([])
1532+
with pytest.raises(ValueError, match="can't extend empty axis"):
1533+
_ = pad(a, 1, mode=mode) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
1534+
14991535
def test_mode_not_implemented(self, xp: ModuleType):
15001536
a = xp.asarray([1, 2, 3])
1501-
with pytest.raises(NotImplementedError, match="Only `'constant'`"):
1502-
_ = pad(a, 2, mode="edge") # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
1537+
with pytest.raises(NotImplementedError, match="Unsupported padding mode"):
1538+
_ = pad(a, 2, mode="reflect") # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
15031539

15041540
def test_device(self, xp: ModuleType, device: Device):
15051541
a = xp.asarray(0.0, device=device)

0 commit comments

Comments
 (0)