diff --git a/CHANGELOG.md b/CHANGELOG.md index 752bf2ad4b3f..33cae14e89ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.21.0] - MM/DD/2026 ### Added +* Added `dpnp.broadcast` class implementation [#2901](https://github.com/IntelPython/dpnp/pull/2901) ### Changed diff --git a/dpnp/__init__.py b/dpnp/__init__.py index d2ea158d4d44..cafbd972ff5e 100644 --- a/dpnp/__init__.py +++ b/dpnp/__init__.py @@ -304,6 +304,7 @@ unravel_index, ) from .dpnp_flatiter import flatiter +from .dpnp_broadcast import broadcast # ----------------------------------------------------------------------------- # Linear algebra @@ -691,6 +692,7 @@ "atleast_1d", "atleast_2d", "atleast_3d", + "broadcast", "broadcast_arrays", "broadcast_to", "column_stack", diff --git a/dpnp/dpnp_broadcast.py b/dpnp/dpnp_broadcast.py new file mode 100644 index 000000000000..a386483dad06 --- /dev/null +++ b/dpnp/dpnp_broadcast.py @@ -0,0 +1,170 @@ +# ***************************************************************************** +# Copyright (c) 2026, Intel Corporation +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# - Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# - Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# - Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +# THE POSSIBILITY OF SUCH DAMAGE. +# ***************************************************************************** + +"""Implementation of broadcast class.""" + +import dpnp +from dpnp.tensor._manipulation_functions import _broadcast_shapes + + +class broadcast: + """ + Produce an object that mimics broadcasting. + + For full documentation refer to :obj:`numpy.broadcast`. + + Parameters + ---------- + *args : array_like + Input parameters. + + 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 ``nd`` properties, and + may be used as an iterator. + + See Also + -------- + :obj:`dpnp.broadcast_arrays` : Broadcast any number of arrays against + each other. + :obj:`dpnp.broadcast_to` : Broadcast an array to a new shape. + :obj:`dpnp.broadcast_shapes` : Broadcast the input shapes into a single + shape. + + 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.nd + 2 + >>> b.size + 9 + + Notes + ----- + Iterator functionality is not supported. + + """ + + def __init__(self, *args): + # Convert all arguments to dpnp arrays + arrays = [] + for arg in args: + if not isinstance(arg, dpnp.ndarray): + # Convert array-like to dpnp.ndarray + arg = dpnp.asarray(arg) + arrays.append(arg) + + if len(arrays) == 0: + raise TypeError("broadcast() requires at least one array") + + self._arrays = tuple(arrays) + + # Compute the broadcasted shape using _broadcast_shapes + self._shape = _broadcast_shapes(*self._arrays) + + # Calculate size and ndim + self._size = 1 + for dim in self._shape: + self._size *= dim + self._nd = len(self._shape) + + @property + def shape(self): + """ + Shape of the broadcasted result. + + Returns + ------- + out : tuple + A tuple containing the shape of the broadcasted result. + + """ + 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. + + """ + return self._size + + @property + def nd(self): + """ + Number of dimensions of the broadcasted result. + + Returns + ------- + out : int + The number of dimensions of the broadcasted result. + + """ + return self._nd + + @property + def ndim(self): + """ + Number of dimensions of the broadcasted result. + + Returns + ------- + out : int + The number of dimensions of the broadcasted result. + + """ + return self._nd + + @property + def numiter(self): + """ + Number of iterators possessed by the broadcast object. + + Returns + ------- + out : int + The number of iterators. + + """ + return len(self._arrays) + + def __repr__(self): + return f"" diff --git a/dpnp/tests/test_manipulation.py b/dpnp/tests/test_manipulation.py index 4fc4b8cb1619..953ee1e702cf 100644 --- a/dpnp/tests/test_manipulation.py +++ b/dpnp/tests/test_manipulation.py @@ -1993,3 +1993,198 @@ 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.nd == b_np.nd + 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.nd == b_np.nd + 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.nd == b_np.nd + 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.nd == bc_np.nd + 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.nd == bc_np.nd + 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.nd == bc_np.nd + 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.nd == bc_np.nd + assert bc.size == bc_np.size + assert bc.numiter == 3 + + def test_broadcast_ndim_property(self): + # Test that ndim property equals nd property + a = dpnp.array([[1, 2], [3, 4]]) + b = dpnp.array([5, 6]) + + bc = dpnp.broadcast(a, b) + + assert bc.ndim == bc.nd + + 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.nd == bc_np.nd + assert bc.size == bc_np.size + + def test_broadcast_with_array_like(self): + # Test broadcast with array-like inputs (lists) + a = dpnp.array([1, 2, 3]) + b = [[1], [2]] + + bc = dpnp.broadcast(a, b) + bc_np = numpy.broadcast(a.asnumpy(), b) + + assert bc.shape == bc_np.shape + assert bc.nd == bc_np.nd + assert bc.size == bc_np.size + + @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.nd == bc_np.nd + assert bc.size == bc_np.size + + 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.nd == bc_np.nd + assert bc.size == bc_np.size + assert bc.numiter == 1 + + def test_broadcast_no_args(self): + # Test that broadcast with no arguments raises TypeError + with pytest.raises(TypeError): + dpnp.broadcast() + + 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 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 ae0f6ce18b47..9ee219630384 100644 --- a/dpnp/tests/third_party/cupy/manipulation_tests/test_dims.py +++ b/dpnp/tests/third_party/cupy/manipulation_tests/test_dims.py @@ -300,7 +300,6 @@ 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) @@ -340,7 +339,6 @@ def test_broadcast_arrays(self, xp, dtype): ) 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):