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
39 changes: 36 additions & 3 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,6 +1374,15 @@ 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")
Expand All @@ -1376,6 +1393,12 @@ def set_names(self, names, level=None, inplace: bool = False):
if not is_list_like(names) and level is None and self.nlevels > 1:
raise TypeError("Must pass list-like as `names`.")

if is_dict_like(names) and not isinstance(self, ABCMultiIndex):
Copy link
Contributor

Choose a reason for hiding this comment

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

all of these conditions can be if/elif

Copy link
Member Author

Choose a reason for hiding this comment

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

Wanted to be consistent with the existing conditions, changed them to elif

raise TypeError("Can only pass dict-like as `names` for MultiIndex.")

if is_dict_like(names) and level is not None:
raise TypeError("Can not pass level when passing dict-like as `names`.")

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 +1408,16 @@ def set_names(self, names, level=None, inplace: bool = False):
idx = self
else:
idx = self._shallow_copy()

if isinstance(self, ABCMultiIndex) and is_dict_like(names):
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 what this condition is doing?

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

# Transform dict to list of new names and corresponding levels
level, names_adjusted = [], []
for i, name in enumerate(self.names):
Copy link
Contributor

Choose a reason for hiding this comment

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

see my comment above i think this condition needs to be on L1402

Copy link
Member Author

Choose a reason for hiding this comment

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

Moved it up a bit and added level is None

if name in names.keys():
level.append(i)
names_adjusted.append(names[name])
names = names_adjusted

idx._set_names(names, level=level)
if not inplace:
return idx
Expand Down
34 changes: 34 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,37 @@ 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(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)


def test_name_with_dict_like_raising():
# GH#20421
mi = MultiIndex.from_arrays([[1, 2], [3, 4]], names=["x", "y"])

msg = "Can not pass level when passing dict-like as `names`."
with pytest.raises(TypeError, match=msg):
mi.set_names({"x": "z"}, level=[0, 1])

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"})