Skip to content

Commit 32b4d27

Browse files
Implement factorize for MultiIndex (#23139)
- Implements `MultiIndex._factorize` so `factorize` on a MultiIndex (and tuple data) returns codes and uniques across all levels, with first-appearance or lexicographic ordering per `sort`. - Removes 5 now-passing entries from the pandas-testing plugin and documents the reason for one remaining inherent failure. - Split out from #23138. Authors: - GALI PREM SAGAR (https://github.com/galipremsagar) Approvers: - Matthew Roeschke (https://github.com/mroeschke) URL: #23139
1 parent 2e41988 commit 32b4d27

3 files changed

Lines changed: 79 additions & 7 deletions

File tree

python/cudf/cudf/core/multiindex.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1793,6 +1793,55 @@ def unique(self, level: int | None = None) -> Self | Index:
17931793
else:
17941794
return self.get_level_values(level).unique()
17951795

1796+
def _factorize(
1797+
self, sort: bool, use_na_sentinel: bool
1798+
) -> tuple[cp.ndarray, MultiIndex]:
1799+
if any(col.has_nulls() for col in self._columns):
1800+
raise NotImplementedError(
1801+
"factorize on a MultiIndex with missing values is not yet "
1802+
"supported"
1803+
)
1804+
if len(self) == 0:
1805+
return cp.empty(0, dtype=np.intp), self.copy()
1806+
1807+
level_labels = list(range(self.nlevels))
1808+
df = cudf.DataFrame._from_data(
1809+
dict(zip(level_labels, self._columns, strict=True))
1810+
)
1811+
pos_label = self.nlevels
1812+
df[pos_label] = range(len(df))
1813+
1814+
# the uniques are the distinct rows in order of first appearance
1815+
# (or sorted lexicographically when requested)
1816+
uniques = (
1817+
df.groupby(level_labels, sort=False)
1818+
.agg({pos_label: "min"})
1819+
.reset_index()
1820+
)
1821+
uniques = uniques.sort_values(
1822+
by=level_labels if sort else pos_label, ignore_index=True
1823+
)
1824+
uid_label = self.nlevels + 1
1825+
uniques[uid_label] = range(len(uniques))
1826+
1827+
# codes: map each row to its unique-row id, in the original order
1828+
merged = df.merge(
1829+
uniques[[*level_labels, uid_label]],
1830+
on=level_labels,
1831+
how="left",
1832+
)
1833+
codes = (
1834+
merged.sort_values(by=pos_label)[uid_label]
1835+
.astype(np.dtype(np.intp))
1836+
.values
1837+
)
1838+
# pandas does not propagate the level names to the uniques
1839+
uniques_mi = MultiIndex._from_data(
1840+
{label: uniques._data[label] for label in level_labels}
1841+
)
1842+
uniques_mi.names = [None] * self.nlevels
1843+
return codes, uniques_mi
1844+
17961845
@_performance_tracking
17971846
def nunique(self, dropna: bool = True) -> int:
17981847
mi = self.dropna(how="all") if dropna else self

python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4312,12 +4312,7 @@ def pytest_unconfigure(config):
43124312
"tests/strings/test_split_partition.py::test_split_nan_expand[string=object]": "AssertionError: DataFrame.iloc[:, 0] (column name='0') are different",
43134313
"tests/strings/test_strings.py::test_index_str_accessor_multiindex_raises": "TODO: Add a reason for failure",
43144314
"tests/strings/test_strings.py::test_string_slice_out_of_bounds[string=object]": "AssertionError: Series are different",
4315-
"tests/test_algos.py::TestFactorize::test_factorize[multi-False]": "NotImplementedError: Fast implementation not available. Falling back to the slow implementation",
4316-
"tests/test_algos.py::TestFactorize::test_factorize[multi-True]": "NotImplementedError: Fast implementation not available. Falling back to the slow implementation",
4317-
"tests/test_algos.py::TestFactorize::test_factorize[tuples-False]": "AssertionError: Index are different",
4318-
"tests/test_algos.py::TestFactorize::test_factorize[tuples-True]": "AssertionError: Index are different",
4319-
"tests/test_algos.py::TestFactorize::test_int_factorize_use_na_sentinel_false[data0-expected_codes0-expected_uniques0]": "TODO: Add a reason for failure",
4320-
"tests/test_algos.py::TestUnique::test_factorize_multiindex_empty": "AssertionError: Index are different",
4315+
"tests/test_algos.py::TestFactorize::test_int_factorize_use_na_sentinel_false[data0-expected_codes0-expected_uniques0]": "cudf cannot represent an object column of Python ints with NaN; the uniques come back float64",
43214316
"tests/test_algos.py::TestValueCounts::test_value_counts_dropna": "pandas keeps bool-with-None data as object dtype; cudf stores it as a masked bool column",
43224317
"tests/test_algos.py::TestValueCounts::test_value_counts_stability": "asserts that kind='quicksort' produces an unstable order; cudf sorts are always stable",
43234318
"tests/test_col.py::test_cached_property": "AssertionError: assert False",

python/cudf/cudf/tests/general_functions/test_factorize.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION.
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

44
import cupy as cp
55
import numpy as np
66
import pandas as pd
7+
import pytest
78

89
import cudf
910
from cudf.testing import assert_eq
@@ -86,3 +87,30 @@ def test_factorize_rangeindex_preserves_class():
8687
assert isinstance(cats, cudf.RangeIndex)
8788
assert_eq(cats, p_cats, exact=True)
8889
np.testing.assert_array_equal(labels.get(), p_labels)
90+
91+
92+
@pytest.mark.parametrize("sort", [False, True])
93+
def test_factorize_multiindex(sort):
94+
# The uniques are the distinct rows (without level names), matching
95+
# pandas.
96+
pmi = pd.MultiIndex.from_arrays(
97+
[["bar", "baz", "foo", "bar"], [2, 3, 1, 2]], names=["a", "b"]
98+
)
99+
gmi = cudf.Index(pmi)
100+
101+
expected_codes, expected_uniques = pmi.factorize(sort=sort)
102+
codes, uniques = gmi.factorize(sort=sort)
103+
104+
np.testing.assert_array_equal(codes.get(), expected_codes)
105+
assert_eq(uniques, expected_uniques)
106+
107+
108+
def test_factorize_multiindex_empty():
109+
pmi = pd.MultiIndex.from_arrays(
110+
[pd.Index([], dtype=object), pd.Index([], dtype="f4")]
111+
)
112+
gmi = cudf.Index(pmi)
113+
114+
codes, uniques = gmi.factorize()
115+
assert len(codes) == 0
116+
assert_eq(uniques, gmi[:0])

0 commit comments

Comments
 (0)