diff --git a/python/cudf/cudf/core/groupby/groupby.py b/python/cudf/cudf/core/groupby/groupby.py index b826500b77fa..7b78ce7e54a3 100644 --- a/python/cudf/cudf/core/groupby/groupby.py +++ b/python/cudf/cudf/core/groupby/groupby.py @@ -1273,6 +1273,17 @@ def agg(self, func=None, *args, engine=None, engine_kwargs=None, **kwargs): ) elif agg_kind == "NUNIQUE": cast_dtype = np.dtype(np.int64) + elif ( + agg_name in {"cumsum", "cumprod"} + and is_pandas_nullable_extension_dtype(orig_dtype) + and orig_dtype.kind in {"i", "u"} + ): + # libcudf's SUM/PRODUCT scans promote narrow integers + # to 64-bit. pandas does the same for numpy dtypes + # (int8 -> int64, GH#37493) but preserves masked + # extension dtypes (Int16 stays Int16, GH#58811), + # wrapping on overflow. + cast_dtype = orig_dtype elif ( ( isinstance(agg_name, str) diff --git a/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py b/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py index f6d058cc1dda..d060263ba1c4 100644 --- a/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py +++ b/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py @@ -1838,20 +1838,13 @@ def pytest_unconfigure(config): "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[Float32-False-val1]": "TODO: Add a reason for failure", "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[Float64-False-val1]": "TODO: Add a reason for failure", "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[Int16-False-val1]": "TODO: Add a reason for failure", - "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[Int16-True-3]": "TODO: Add a reason for failure", "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[Int32-False-val1]": "TODO: Add a reason for failure", - "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[Int32-True-3]": "TODO: Add a reason for failure", "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[Int64-False-val1]": "TODO: Add a reason for failure", "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[Int8-False-val1]": "TODO: Add a reason for failure", - "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[Int8-True-3]": "TODO: Add a reason for failure", "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[UInt16-False-val1]": "TODO: Add a reason for failure", - "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[UInt16-True-3]": "TODO: Add a reason for failure", "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[UInt32-False-val1]": "TODO: Add a reason for failure", - "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[UInt32-True-3]": "TODO: Add a reason for failure", "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[UInt64-False-val1]": "TODO: Add a reason for failure", - "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[UInt64-True-3]": "TODO: Add a reason for failure", "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[UInt8-False-val1]": "TODO: Add a reason for failure", - "tests/groupby/test_groupby.py::test_groupby_cumsum_mask[UInt8-True-3]": "TODO: Add a reason for failure", "tests/groupby/test_groupby.py::test_groupby_cumsum_skipna_false": "TODO: Add a reason for failure", "tests/groupby/test_groupby.py::test_groupby_cumsum_timedelta64": "TODO: Add a reason for failure", "tests/groupby/test_groupby.py::test_groupby_groups_in_BaseGrouper": "TODO: Add a reason for failure", @@ -1918,7 +1911,6 @@ def pytest_unconfigure(config): "tests/groupby/test_reductions.py::test_sum_skipna_object[False]": "Inherent cudf.pandas None-vs-NaN difference for object-dtype null (skipna logic is correct)", "tests/groupby/test_timegrouper.py::TestGroupBy::test_groupby_with_timegrouper": "TODO: Add a reason for failure", "tests/groupby/test_timegrouper.py::TestGroupBy::test_scalar_call_versus_list_call": "TODO: Add a reason for failure", - "tests/groupby/transform/test_transform.py::test_nan_in_cumsum_group_label": "AssertionError: Attributes of Series are different", "tests/indexes/base_class/test_reshape.py::TestReshape::test_insert_missing[Decimal]": "TODO: Add a reason for failure", "tests/indexes/categorical/test_astype.py::TestAstype::test_categorical_date_roundtrip[False]": "TODO: Add a reason for failure", "tests/indexes/categorical/test_astype.py::TestAstype::test_categorical_date_roundtrip[True]": "TODO: Add a reason for failure", diff --git a/python/cudf/cudf/tests/groupby/test_cummulative.py b/python/cudf/cudf/tests/groupby/test_cummulative.py index c0135fe6c264..b44c273e16cd 100644 --- a/python/cudf/cudf/tests/groupby/test_cummulative.py +++ b/python/cudf/cudf/tests/groupby/test_cummulative.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import numpy as np import pandas as pd @@ -105,3 +105,27 @@ def test_scan_int_null_pandas_compatible(op): with cudf.option_context("mode.pandas_compatible", True): result = getattr(df_cudf.groupby("b")["a"], op)() assert_eq(result, expected) + + +@pytest.mark.parametrize("op", ["cumsum", "cumprod"]) +def test_groupby_cumscan_masked_dtype_preserved(op): + # pandas preserves masked extension dtypes for groupby cum-scans + # (Int16 stays Int16, GH#58811) while numpy ints promote to 64-bit + pdf = pd.DataFrame({"a": [1, 1, 2], "b": [1, pd.NA, 2]}, dtype="Int16") + gdf = cudf.DataFrame(pdf) + + expected = getattr(pdf.groupby("a")["b"], op)() + result = getattr(gdf.groupby("a")["b"], op)() + + assert_eq(expected, result) + + +def test_groupby_cumsum_numpy_dtype_promotes(): + # numpy int8 promotes to int64 (pandas GH#37493) + pdf = pd.DataFrame({"a": [1, 1], "b": [111, 111]}, dtype="int8") + gdf = cudf.DataFrame(pdf) + + expected = pdf.groupby("a").cumsum() + result = gdf.groupby("a").cumsum() + + assert_eq(expected, result)