From f086ac177e697404716abbb0e275a85e7dbf4325 Mon Sep 17 00:00:00 2001 From: Mohak Gupta Date: Sun, 6 Sep 2026 12:52:37 +0530 Subject: [PATCH 1/2] Implement Series.unstack Closes #10059. Delegates to DataFrame.unstack (self.to_frame().unstack()), which already handles arbitrary level selection, then drops the single-value outer column level to_frame() introduces, matching pandas' Series.unstack output exactly. Raises the same ValueError as pandas for a non-MultiIndex Series rather than the confusing internal DataFrame-side error that would otherwise surface. fill_value stays unimplemented, same as DataFrame.unstack already does, since Series.unstack just forwards it through. Verified against a real cudf install (26.08.01) on a real GPU: single and multi-level unstack (by position and by name), named and unnamed series, and the non-MultiIndex error case, all compared directly against real pandas output. Confirmed by reverting the change and re-running: 11/11 new tests failed with AttributeError, then passed again after restoring. Ran the full existing test_unstack.py file (DataFrame tests included): 29 passed, 4 xfailed, matching the pre-existing xfail marks exactly - no regressions. Signed-off-by: Mohak Gupta --- python/cudf/cudf/core/series.py | 49 +++++++++++++++++++ .../cudf/cudf/tests/reshape/test_unstack.py | 29 +++++++++++ 2 files changed, 78 insertions(+) diff --git a/python/cudf/cudf/core/series.py b/python/cudf/cudf/core/series.py index dcc6f0df164b..54b32404f4c7 100644 --- a/python/cudf/cudf/core/series.py +++ b/python/cudf/cudf/core/series.py @@ -1154,6 +1154,55 @@ def to_frame(self, name: Hashable = no_default) -> DataFrame: self._propagate_metadata(res) return res + @_performance_tracking + def unstack(self, level=-1, fill_value=None, sort: bool = True): + """ + Unstack, also known as pivot, Series with MultiIndex to produce + DataFrame. + + Parameters + ---------- + level : int, str, or list of these, default last level + Level(s) to unstack, can pass level name. + fill_value + Non-functional argument provided for compatibility with Pandas. + sort : bool, default True + Sort the level(s) in the resulting MultiIndex columns. + + Returns + ------- + DataFrame + Unstacked Series. + + Examples + -------- + >>> import cudf + >>> s = cudf.Series( + ... [1, 2, 3, 4], + ... index=cudf.MultiIndex.from_product([["one", "two"], ["a", "b"]]), + ... ) + >>> s + one a 1 + b 2 + two a 3 + b 4 + dtype: int64 + >>> s.unstack(level=-1) + a b + one 1 2 + two 3 4 + """ + if not isinstance(self.index, cudf.MultiIndex): + raise ValueError( + "index must be a MultiIndex to unstack, " + f"{type(self.index)} was passed" + ) + result = self.to_frame().unstack( + level=level, fill_value=fill_value, sort=sort + ) + result.columns = result.columns.droplevel(0) + return result + @_performance_tracking def memory_usage(self, index: bool = True, deep: bool = False) -> int: """ diff --git a/python/cudf/cudf/tests/reshape/test_unstack.py b/python/cudf/cudf/tests/reshape/test_unstack.py index 3a2462c4b49a..8a9287d4629b 100644 --- a/python/cudf/cudf/tests/reshape/test_unstack.py +++ b/python/cudf/cudf/tests/reshape/test_unstack.py @@ -105,3 +105,32 @@ def test_unstack_index_invalid(): ), ): gdf.unstack() + + +@pytest.mark.parametrize("level", [-1, 0, 1, "foo", "bar"]) +@pytest.mark.parametrize("name", [None, "quux"]) +def test_series_unstack_multiindex(level, name): + index = pd.MultiIndex.from_tuples( + [ + ("one", "a"), + ("one", "b"), + ("two", "a"), + ("two", "b"), + ], + names=["foo", "bar"], + ) + ps = pd.Series([1, 2, 3, 4], index=index, name=name) + gs = cudf.from_pandas(ps) + assert_eq(ps.unstack(level=level), gs.unstack(level=level)) + + +def test_series_unstack_index_invalid(): + gs = cudf.Series([1, 2, 3], index=["a", "b", "c"]) + with pytest.raises( + ValueError, + match=re.escape( + "index must be a MultiIndex to unstack, " + " was passed" + ), + ): + gs.unstack() From db37c5a9397f782ed4f1852a1282c8aa5052492c Mon Sep 17 00:00:00 2001 From: Mohak Gupta Date: Tue, 8 Sep 2026 12:24:10 +0530 Subject: [PATCH 2/2] Handle level=[] in Series.unstack as a no-op CodeRabbit flagged that an empty list-like level selection could fail. Verified: level=[] unstacks zero levels, so to_frame().unstack() never gains the extra column level droplevel(0) expects, and dropping the DataFrame's only remaining column level raised ValueError. pandas returns the original Series unchanged for this case; matched that by checking columns.nlevels before attempting to drop. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01F4pNSy3B9U7iYE6jd3bqFs Signed-off-by: Mohak Gupta --- python/cudf/cudf/core/series.py | 4 ++++ python/cudf/cudf/tests/reshape/test_unstack.py | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/python/cudf/cudf/core/series.py b/python/cudf/cudf/core/series.py index 54b32404f4c7..8d6f772e97c5 100644 --- a/python/cudf/cudf/core/series.py +++ b/python/cudf/cudf/core/series.py @@ -1200,6 +1200,10 @@ def unstack(self, level=-1, fill_value=None, sort: bool = True): result = self.to_frame().unstack( level=level, fill_value=fill_value, sort=sort ) + if result.columns.nlevels == 1: + # No level was actually unstacked (e.g. level=[]); pandas + # returns the original Series unchanged in that case. + return self.copy(deep=False) result.columns = result.columns.droplevel(0) return result diff --git a/python/cudf/cudf/tests/reshape/test_unstack.py b/python/cudf/cudf/tests/reshape/test_unstack.py index 8a9287d4629b..128f0da41a9e 100644 --- a/python/cudf/cudf/tests/reshape/test_unstack.py +++ b/python/cudf/cudf/tests/reshape/test_unstack.py @@ -134,3 +134,12 @@ def test_series_unstack_index_invalid(): ), ): gs.unstack() + + +def test_series_unstack_empty_level_is_a_noop(): + index = pd.MultiIndex.from_tuples( + [("one", "a"), ("one", "b"), ("two", "a"), ("two", "b")] + ) + ps = pd.Series([1, 2, 3, 4], index=index, name="v") + gs = cudf.from_pandas(ps) + assert_eq(ps.unstack(level=[]), gs.unstack(level=[]))