diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4779cdb96d..113b0060fdfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ This release is compatible with NumPy 2.5. * Added implementation of `dpnp.lib.stride_tricks.as_strided` [#2991](https://github.com/IntelPython/dpnp/pull/2991) * Added `dpnp.tensor.broadcast_shapes` to align with the 2025.12 version of the Python array API [#3009](https://github.com/IntelPython/dpnp/pull/3009) * Added support for free-threaded Python builds [gh-3026](https://github.com/IntelPython/dpnp/pull/3026) +* Added `dpnp.broadcast` class implementation [#2901](https://github.com/IntelPython/dpnp/pull/2901) ### Changed diff --git a/doc/_templates/autosummary/class_with_attributes.rst b/doc/_templates/autosummary/class_with_attributes.rst new file mode 100644 index 000000000000..2a10a7beb68f --- /dev/null +++ b/doc/_templates/autosummary/class_with_attributes.rst @@ -0,0 +1,28 @@ +{% extends "!autosummary/class.rst" %} + +{% block methods %} +{% if methods %} + .. HACK -- the point here is that we don't want this to appear in the output, but the autosummary should still generate the pages. + .. autosummary:: + :toctree: + {% for item in all_methods %} + {%- if not item.startswith('_') or item in ['__call__'] %} + {{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} +{% endif %} +{% endblock %} + +{% block attributes %} +{% if attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + :toctree: + {% for item in all_attributes %} + {%- if not item.startswith('_') %} + ~{{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} +{% endif %} +{% endblock %} diff --git a/doc/known_words.txt b/doc/known_words.txt index 7de17047c721..b8f0ec87e530 100644 --- a/doc/known_words.txt +++ b/doc/known_words.txt @@ -69,6 +69,7 @@ Nj Nk normed nuc +numiter numpy nx ny diff --git a/doc/reference/array-manipulation.rst b/doc/reference/array-manipulation.rst index 0490119dd295..00bffda6b63d 100644 --- a/doc/reference/array-manipulation.rst +++ b/doc/reference/array-manipulation.rst @@ -50,6 +50,13 @@ Transpose-like operations Changing number of dimensions ----------------------------- +.. autosummary:: + :toctree: generated/ + :nosignatures: + :template: autosummary/class_with_attributes.rst + + broadcast + .. autosummary:: :toctree: generated/ :nosignatures: @@ -57,7 +64,6 @@ Changing number of dimensions atleast_1d atleast_2d atleast_3d - broadcast broadcast_to broadcast_arrays expand_dims diff --git a/dpnp/__init__.py b/dpnp/__init__.py index d9ee2d014a75..8cb2858c8de2 100644 --- a/dpnp/__init__.py +++ b/dpnp/__init__.py @@ -189,6 +189,7 @@ atleast_1d, atleast_2d, atleast_3d, + broadcast, broadcast_arrays, broadcast_to, column_stack, @@ -691,6 +692,7 @@ "atleast_1d", "atleast_2d", "atleast_3d", + "broadcast", "broadcast_arrays", "broadcast_to", "column_stack", diff --git a/dpnp/dpnp_iface_manipulation.py b/dpnp/dpnp_iface_manipulation.py index f697449dd5b3..b2046ffc4940 100644 --- a/dpnp/dpnp_iface_manipulation.py +++ b/dpnp/dpnp_iface_manipulation.py @@ -56,6 +56,7 @@ from .dpnp_utils import get_usm_allocations from .dpnp_utils.dpnp_utils_pad import dpnp_pad from .exceptions import AxisError +from .tensor._manipulation_functions import _broadcast_shapes from .tensor._numpy_helper import ( normalize_axis_index, normalize_axis_tuple, @@ -1047,6 +1048,193 @@ def atleast_3d(*arys): return tuple(res) +class broadcast: # pylint: disable=invalid-name + """ + Produce an object that mimics broadcasting. + + For full documentation refer to :obj:`numpy.broadcast`. + + Parameters + ---------- + *args : {dpnp.ndarray, usm_ndarray} + Input arrays to broadcast against one another. + + Returns + ------- + broadcast : broadcast object + Broadcast the input parameters against one another, and + return an object that encapsulates the result. + Amongst others, it has ``shape`` and ``ndim`` properties. + + Limitations + ----------- + Input arrays are not coerced, so array-like objects and scalars are not + supported and ``TypeError`` exception will be raised. + + See Also + -------- + :obj:`dpnp.broadcast_arrays` : Broadcast any number of arrays against + each other. + :obj:`dpnp.broadcast_shapes` : Broadcast the input shapes into a single + shape. + :obj:`dpnp.broadcast_to` : Broadcast an array to a new shape. + + Notes + ----- + Iterator functionality is not supported. + + The legacy ``nd`` attribute of :obj:`numpy.broadcast` is not provided, + ``ndim`` has to be used instead. + + Examples + -------- + >>> import dpnp as np + >>> x = np.array([[1], [2], [3]]) + >>> y = np.array([4, 5, 6]) + >>> b = np.broadcast(x, y) + >>> b.shape + (3, 3) + >>> b.ndim + 2 + >>> b.size + 9 + + """ + + def __init__(self, *args): + dpnp.check_supported_arrays_type(*args) + + self._arrays = args + self._values = None + + # _broadcast_shapes() does not accept an empty sequence of arrays + self._shape = _broadcast_shapes(*args) if args else () + self._size = math.prod(self._shape) + self._ndim = len(self._shape) + + @property + def shape(self): + """ + Shape of the broadcasted result. + + Returns + ------- + out : tuple + A tuple containing the shape of the broadcasted result. + + Examples + -------- + >>> import dpnp as np + >>> x = np.array([[1], [2], [3]]) + >>> y = np.array([4, 5, 6]) + >>> np.broadcast(x, y).shape + (3, 3) + + """ + return self._shape + + @property + def size(self): + """ + Total size of the broadcasted result. + + Returns + ------- + out : int + The total size (number of elements) of the broadcasted result. + + Examples + -------- + >>> import dpnp as np + >>> x = np.array([[1], [2], [3]]) + >>> y = np.array([4, 5, 6]) + >>> np.broadcast(x, y).size + 9 + + """ + return self._size + + @property + def ndim(self): + """ + Number of dimensions of the broadcasted result. + + Returns + ------- + out : int + The number of dimensions of the broadcasted result. + + Examples + -------- + >>> import dpnp as np + >>> x = np.array([[1], [2], [3]]) + >>> y = np.array([4, 5, 6]) + >>> np.broadcast(x, y).ndim + 2 + + """ + return self._ndim + + @property + def numiter(self): + """ + Number of iterators possessed by the broadcast object. + + Returns + ------- + out : int + The number of iterators. + + Examples + -------- + >>> import dpnp as np + >>> x = np.array([[1], [2], [3]]) + >>> y = np.array([4, 5, 6]) + >>> np.broadcast(x, y).numiter + 2 + + """ + return len(self._arrays) + + @property + def values(self): + """ + The input arrays broadcast against one another. + + Returns + ------- + out : tuple of dpnp.ndarray + A tuple of arrays which are views on the original input arrays. + + Examples + -------- + >>> import dpnp as np + >>> x = np.array([[1], [2], [3]]) + >>> y = np.array([4, 5, 6]) + >>> b = np.broadcast(x, y) + >>> b.values[0] + array([[1, 1, 1], + [2, 2, 2], + [3, 3, 3]]) + >>> b.values[1] + array([[4, 5, 6], + [4, 5, 6], + [4, 5, 6]]) + + """ + if self._values is None: + self._values = tuple( + broadcast_to(a, self._shape) for a in self._arrays + ) + return self._values + + def __repr__(self): + return ( + f"" + ) + + def broadcast_arrays(*args, subok=False): """ Broadcast any number of arrays against each other. @@ -1055,7 +1243,7 @@ def broadcast_arrays(*args, subok=False): Parameters ---------- - args : {dpnp.ndarray, usm_ndarray} + *args : {dpnp.ndarray, usm_ndarray} A list of arrays to broadcast. Returns @@ -1070,6 +1258,9 @@ def broadcast_arrays(*args, subok=False): See Also -------- + :obj:`dpnp.broadcast` : Produce an object that mimics broadcasting. + :obj:`dpnp.broadcast_shapes` : Broadcast the input shapes into a single + shape. :obj:`dpnp.broadcast_to` : Broadcast an array to a new shape. Examples @@ -1112,6 +1303,7 @@ def broadcast_shapes(*args): See Also -------- + :obj:`dpnp.broadcast` : Produce an object that mimics broadcasting. :obj:`dpnp.broadcast_arrays` : Broadcast any number of arrays against each other. :obj:`dpnp.broadcast_to` : Broadcast an array to a new shape. @@ -1175,8 +1367,11 @@ def broadcast_to(array, /, shape, subok=False): See Also -------- + :obj:`dpnp.broadcast` : Produce an object that mimics broadcasting. :obj:`dpnp.broadcast_arrays` : Broadcast any number of arrays against each other. + :obj:`dpnp.broadcast_shapes` : Broadcast the input shapes into a single + shape. Examples -------- diff --git a/dpnp/tests/test_manipulation.py b/dpnp/tests/test_manipulation.py index a33af5594bcb..3dbd9691d4cc 100644 --- a/dpnp/tests/test_manipulation.py +++ b/dpnp/tests/test_manipulation.py @@ -24,6 +24,7 @@ get_unsigned_dtypes, has_support_aspect64, ) +from .tensor.helper import get_queue_or_skip from .third_party.cupy import testing @@ -2012,3 +2013,257 @@ def test_2D_array(self): expected = numpy.vsplit(a, 2) result = dpnp.vsplit(a_dp, 2) _compare_results(result, expected) + + +class TestBroadcast: + """Test cases for dpnp.broadcast class.""" + + def test_broadcast_basic(self): + # Test basic broadcast with compatible shapes + x = dpnp.array([[1], [2], [3]]) + y = dpnp.array([4, 5, 6]) + + b = dpnp.broadcast(x, y) + b_np = numpy.broadcast(x.asnumpy(), y.asnumpy()) + + assert b.shape == b_np.shape + assert b.ndim == b_np.ndim + assert b.size == b_np.size + assert b.numiter == b_np.numiter + + def test_broadcast_scalar(self): + # Test broadcast with scalar + a = dpnp.array([1, 2, 3]) + s = dpnp.array(5) + + b = dpnp.broadcast(a, s) + b_np = numpy.broadcast(a.asnumpy(), s.asnumpy()) + + assert b.shape == b_np.shape + assert b.ndim == b_np.ndim + assert b.size == b_np.size + + def test_broadcast_multiple_arrays(self): + # Test broadcast with multiple arrays + a1 = dpnp.array([1, 2, 3]) + a2 = dpnp.array([[1], [2]]) + + b = dpnp.broadcast(a1, a2) + b_np = numpy.broadcast(a1.asnumpy(), a2.asnumpy()) + + assert b.shape == b_np.shape + assert b.ndim == b_np.ndim + assert b.size == b_np.size + + def test_broadcast_same_shape(self): + # Test broadcast with arrays of the same shape + a = dpnp.array([[1, 2], [3, 4]]) + b = dpnp.array([[5, 6], [7, 8]]) + + bc = dpnp.broadcast(a, b) + bc_np = numpy.broadcast(a.asnumpy(), b.asnumpy()) + + assert bc.shape == bc_np.shape + assert bc.ndim == bc_np.ndim + assert bc.size == bc_np.size + + def test_broadcast_0d_arrays(self): + # Test broadcast with 0-D arrays + a = dpnp.array(5) + b = dpnp.array(10) + + bc = dpnp.broadcast(a, b) + bc_np = numpy.broadcast(a.asnumpy(), b.asnumpy()) + + assert bc.shape == bc_np.shape + assert bc.ndim == bc_np.ndim + assert bc.size == bc_np.size + + def test_broadcast_empty_arrays(self): + # Test broadcast with empty arrays + a = dpnp.array([]) + b = dpnp.array([]) + + bc = dpnp.broadcast(a, b) + bc_np = numpy.broadcast(a.asnumpy(), b.asnumpy()) + + assert bc.shape == bc_np.shape + assert bc.ndim == bc_np.ndim + assert bc.size == bc_np.size + + def test_broadcast_incompatible_shapes(self): + # Test that incompatible shapes raise ValueError + a = dpnp.array([1, 2, 3]) + b = dpnp.array([1, 2]) + + with pytest.raises(ValueError): + dpnp.broadcast(a, b) + + def test_broadcast_incompatible_shapes_2d(self): + # Test incompatible 2D shapes + a = dpnp.array([[1, 2, 3], [4, 5, 6]]) + b = dpnp.array([[1], [2], [3], [4]]) + + with pytest.raises(ValueError): + dpnp.broadcast(a, b) + + def test_broadcast_three_arrays(self): + # Test broadcast with three arrays + a = dpnp.array([1, 2, 3]) + b = dpnp.array([[1], [2]]) + c = dpnp.array(5) + + bc = dpnp.broadcast(a, b, c) + bc_np = numpy.broadcast(a.asnumpy(), b.asnumpy(), c.asnumpy()) + + assert bc.shape == bc_np.shape + assert bc.ndim == bc_np.ndim + assert bc.size == bc_np.size + assert bc.numiter == 3 + + def test_broadcast_ndim_property(self): + # unlike numpy, only ndim is exposed, because numpy itself states that + # the more consistent ndim is preferred over the legacy nd attribute + a = dpnp.array([[1, 2], [3, 4]]) + b = dpnp.array([5, 6]) + + bc = dpnp.broadcast(a, b) + bc_np = numpy.broadcast(a.asnumpy(), b.asnumpy()) + + assert bc.ndim == bc_np.ndim + assert not hasattr(bc, "nd") + + def test_broadcast_values_property(self): + # values mimics cupy.broadcast.values and holds the input arrays + # broadcast against one another + a = dpnp.array([[1], [2], [3]]) + b = dpnp.array([4, 5, 6]) + + bc = dpnp.broadcast(a, b) + expected = dpnp.broadcast_arrays(a, b) + + assert isinstance(bc.values, tuple) + # the property is evaluated once and then cached + assert bc.values is bc.values + + assert len(bc.values) == len(expected) + for res, exp in zip(bc.values, expected): + assert res.shape == bc.shape + assert_array_equal(res, exp) + + def test_broadcast_values_no_args(self): + assert dpnp.broadcast().values == () + + def test_broadcast_complex_shapes(self): + # Test broadcast with complex compatible shapes + a = dpnp.array([[[1]]]) + b = dpnp.array([[1, 2, 3]]) + c = dpnp.array([[1], [2]]) + + bc = dpnp.broadcast(a, b, c) + bc_np = numpy.broadcast(a.asnumpy(), b.asnumpy(), c.asnumpy()) + + assert bc.shape == bc_np.shape + assert bc.ndim == bc_np.ndim + assert bc.size == bc_np.size + + @pytest.mark.parametrize( + "arg", + [[[1], [2]], 3, numpy.ones((2, 1))], + ids=["list", "scalar", "numpy"], + ) + def test_broadcast_unsupported_type(self, arg): + # unlike numpy, input arrays are not coerced, so array-like objects, + # scalars and host arrays are rejected the same way as they are by + # dpnp.broadcast_to and dpnp.broadcast_arrays + a = dpnp.array([1, 2, 3]) + + with pytest.raises(TypeError): + dpnp.broadcast(a, arg) + + @pytest.mark.parametrize( + "shapes", + [ + ((), ()), + ((1,), (1,)), + ((2,), (2,)), + ((0,), (1,)), + ((2, 3), (1, 3)), + ((2, 1, 3, 4), (3, 1, 4)), + ((4, 3, 2, 3), (2, 3)), + ((2, 0, 1, 1, 3), (2, 1, 0, 0, 3)), + ], + ) + def test_broadcast_parametrized_shapes(self, shapes): + # Test various compatible shape combinations + arrays_dp = [dpnp.ones(s) for s in shapes] + arrays_np = [numpy.ones(s) for s in shapes] + + bc = dpnp.broadcast(*arrays_dp) + bc_np = numpy.broadcast(*arrays_np) + + assert bc.shape == bc_np.shape + assert bc.ndim == bc_np.ndim + assert bc.size == bc_np.size + + # numpy.broadcast has no counterpart of the values property, so the + # broadcasted arrays are compared against numpy.broadcast_arrays + expected = numpy.broadcast_arrays(*arrays_np) + assert len(bc.values) == len(expected) + for res, exp in zip(bc.values, expected): + assert res.shape == exp.shape + assert_array_equal(res, exp) + + def test_broadcast_single_array(self): + # Test broadcast with a single array + a = dpnp.array([[1, 2], [3, 4]]) + + bc = dpnp.broadcast(a) + bc_np = numpy.broadcast(a.asnumpy()) + + assert bc.shape == bc_np.shape + assert bc.ndim == bc_np.ndim + assert bc.size == bc_np.size + assert bc.numiter == 1 + + def test_broadcast_no_args(self): + # Test broadcast with no arguments. + bc = dpnp.broadcast() + + assert bc.shape == () + assert bc.ndim == 0 + assert bc.size == 1 + assert bc.numiter == 0 + + def test_broadcast_different_queues(self): + # Broadcasting is a shape-only query, so inputs are not required + # to share a common execution placement + q1 = get_queue_or_skip() + q2 = get_queue_or_skip() + + a = dpt.ones((2, 1), sycl_queue=q1) + b = dpt.ones((1, 2), sycl_queue=q2) + + bc = dpnp.broadcast(a, b) + + assert bc.shape == (2, 2) + assert bc.size == 4 + assert bc.ndim == 2 + + # each broadcasted array stays on the queue of its input array + assert bc.values[0].sycl_queue == q1 + assert bc.values[1].sycl_queue == q2 + + def test_broadcast_repr(self): + # Test __repr__ method + a = dpnp.array([1, 2, 3]) + b = dpnp.array([[1], [2]]) + + bc = dpnp.broadcast(a, b) + repr_str = repr(bc) + + assert "broadcast" in repr_str + assert "shape" in repr_str + assert str(bc.shape) in repr_str + assert f"ndim={bc.ndim}" in repr_str + assert f"size={bc.size}" in repr_str diff --git a/dpnp/tests/third_party/cupy/manipulation_tests/test_dims.py b/dpnp/tests/third_party/cupy/manipulation_tests/test_dims.py index 8790d4cbcc6a..4d23dbdd5546 100644 --- a/dpnp/tests/third_party/cupy/manipulation_tests/test_dims.py +++ b/dpnp/tests/third_party/cupy/manipulation_tests/test_dims.py @@ -300,14 +300,14 @@ def _broadcast(self, xp, dtype, shapes): arrays = [testing.shaped_arange(s, xp, dtype) for s in shapes] return xp.broadcast(*arrays) - @pytest.mark.skip("broadcast() is not supported yet") @testing.for_all_dtypes() def test_broadcast(self, dtype): broadcast_np = self._broadcast(numpy, dtype, self.shapes) broadcast_cp = self._broadcast(cupy, dtype, self.shapes) assert broadcast_np.shape == broadcast_cp.shape assert broadcast_np.size == broadcast_cp.size - assert broadcast_np.nd == broadcast_cp.nd + # `nd` is not exposed by dpnp, since NumPy prefers `ndim` over it + assert broadcast_np.ndim == broadcast_cp.ndim @testing.for_all_dtypes() @testing.numpy_cupy_array_equal() @@ -344,7 +344,6 @@ def test_broadcast_arrays_tuple(self): ) class TestInvalidBroadcast(unittest.TestCase): - @pytest.mark.skip("broadcast() is not supported yet") @testing.for_all_dtypes() def test_invalid_broadcast(self, dtype): for xp in (numpy, cupy):