Skip to content

ENH: Implement dict-like support for rename and set_names in MultiIndex #38126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Jan 4, 2021
1 change: 1 addition & 0 deletions doc/source/whatsnew/v1.2.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ Other enhancements
- Improve numerical stability for :meth:`.Rolling.skew`, :meth:`.Rolling.kurt`, :meth:`Expanding.skew` and :meth:`Expanding.kurt` through implementation of Kahan summation (:issue:`6929`)
- Improved error reporting for subsetting columns of a :class:`.DataFrameGroupBy` with ``axis=1`` (:issue:`37725`)
- Implement method ``cross`` for :meth:`DataFrame.merge` and :meth:`DataFrame.join` (:issue:`5401`)
- Add support for dict-like names in :class:`MultiIndex.set_names` and :class:`MultiIndex.rename` (:issue:`20421`)

.. ---------------------------------------------------------------------------

Expand Down
40 changes: 35 additions & 5 deletions pandas/core/indexes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
ABCSeries,
ABCTimedeltaIndex,
)
from pandas.core.dtypes.inference import is_dict_like
from pandas.core.dtypes.missing import array_equivalent, isna

from pandas.core import missing, ops
Expand Down Expand Up @@ -1319,11 +1320,18 @@ def set_names(self, names, level=None, inplace: bool = False):

Parameters
----------
names : label or list of label

names : label or list of label or dict-like for MultiIndex
Name(s) to set.

.. versionchanged:: 1.2.0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change to 1.3

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thx

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


level : int, label or list of int or label, optional
If the index is a MultiIndex, level(s) to set (None for all
levels). Otherwise level must be None.
If the index is a MultiIndex and names is not dict-like, level(s) to set
(None for all levels). Otherwise level must be None.

.. versionchanged:: 1.2.0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1.3

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


inplace : bool, default False
Modifies the object directly, instead of creating a new Index or
MultiIndex.
Expand Down Expand Up @@ -1366,16 +1374,37 @@ def set_names(self, names, level=None, inplace: bool = False):
( 'cobra', 2018),
( 'cobra', 2019)],
names=['species', 'year'])

When renaming levels through a dictionary no level can't be passed.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When renaming levels with a dict, levels can not be passed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


>>> idx.set_names({'kind': 'snake'})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a comment on this example (skip a line)

Copy link
Member Author

@phofl phofl Nov 28, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

MultiIndex([('python', 2018),
('python', 2019),
( 'cobra', 2018),
( 'cobra', 2019)],
names=['snake', 'year'])
"""
if level is not None and not isinstance(self, ABCMultiIndex):
raise ValueError("Level must be None for non-MultiIndex")

if level is not None and not is_list_like(level) and is_list_like(names):
elif level is not None and not is_list_like(level) and is_list_like(names):
raise TypeError("Names must be a string when a single level is provided.")

if not is_list_like(names) and level is None and self.nlevels > 1:
elif not is_list_like(names) and level is None and self.nlevels > 1:
raise TypeError("Must pass list-like as `names`.")

elif is_dict_like(names) and not isinstance(self, ABCMultiIndex):
raise TypeError("Can only pass dict-like as `names` for MultiIndex.")

if isinstance(self, ABCMultiIndex) and is_dict_like(names) and level is None:
# Transform dict to list of new names and corresponding levels
level, names_adjusted = [], []
for i, name in enumerate(self.names):
if name in names.keys():
level.append(i)
names_adjusted.append(names[name])
names = names_adjusted

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't this hit a dict-like names?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yikes, yes you are right. have not though about dict like names. Removed this

if not is_list_like(names):
names = [names]
if level is not None and not is_list_like(level):
Expand All @@ -1385,6 +1414,7 @@ def set_names(self, names, level=None, inplace: bool = False):
idx = self
else:
idx = self._shallow_copy()

idx._set_names(names, level=level)
if not inplace:
return idx
Expand Down
46 changes: 46 additions & 0 deletions pandas/tests/indexes/multi/test_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,49 @@ def test_setting_names_from_levels_raises():
assert pd.Index._no_setting_name is False
assert pd.Int64Index._no_setting_name is False
assert pd.RangeIndex._no_setting_name is False


@pytest.mark.parametrize("func", ["rename", "set_names"])
@pytest.mark.parametrize(
"rename_dict, exp_names",
[
({"x": "z"}, ["z", "y", "z"]),
({"x": "z", "y": "x"}, ["z", "x", "z"]),
({"y": "z"}, ["x", "z", "x"]),
({}, ["x", "y", "x"]),
({"z": "a"}, ["x", "y", "x"]),
({"y": "z", "a": "b"}, ["x", "z", "x"]),
],
)
def test_name_mi_with_dict_like_duplicate_names(func, rename_dict, exp_names):
# GH#20421
mi = MultiIndex.from_arrays([[1, 2], [3, 4], [5, 6]], names=["x", "y", "x"])
result = getattr(mi, func)(rename_dict)
expected = MultiIndex.from_arrays([[1, 2], [3, 4], [5, 6]], names=exp_names)
tm.assert_index_equal(result, expected)


@pytest.mark.parametrize("func", ["rename", "set_names"])
@pytest.mark.parametrize(
"rename_dict, exp_names",
[
({"x": "z"}, ["z", "y"]),
({"x": "z", "y": "x"}, ["z", "x"]),
({"a": "z"}, ["x", "y"]),
({}, ["x", "y"]),
],
)
def test_name_mi_with_dict_like(func, rename_dict, exp_names):
# GH#20421
mi = MultiIndex.from_arrays([[1, 2], [3, 4]], names=["x", "y"])
result = getattr(mi, func)(rename_dict)
expected = MultiIndex.from_arrays([[1, 2], [3, 4]], names=exp_names)
tm.assert_index_equal(result, expected)


def test_name_with_dict_like_raising():
# GH#20421
ix = pd.Index([1, 2])
msg = "Can only pass dict-like as `names` for MultiIndex."
with pytest.raises(TypeError, match=msg):
ix.set_names({"x": "z"})