Repository: pandas-dev/pandas

Issue:
Allow for dict-like argument to Categorical.rename_categories
#### Proposal

I think it would be great if [pandas.core.categorical.Categorical.rename_categories](http://pandas.pydata.org/pandas-docs/version/0.15.2/generated/pandas.core.categorical.Categorical.rename_categories.html) could take a dict-like argument as an alternative to the index-like argument that it currently requires. This would make the syntax more similar to that of [DataFrame.rename](https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.rename.html). The proposed change would allow for something like this:

#### Code Sample

```python
>>>> df['categorical_var']
0    apples
1    oranges
2    apples
3    apples

>>>> # rename categories from 'apples' and 'oranges' to 'red fruits' and 'orange fruits'
>>>> df['categorical_var'].rename_categories({ 'apples': 'red fruits', 'oranges': 'orange fruits'})
0    red fruits
1    orange fruits
2    red fruits
3    red fruits
```

#### Why?

**1. Allows the developer to execute the rename without any prior knowledge of the order of the categories.**

This:
```python
>>>> df['categorical_var']
0    apples
1    oranges
2    apples
3    apples

>>>> df['categorical_var'].rename_categories({ 'apples': 'red fruits', 'oranges': 'orange fruits'})
0    red fruits
1    orange fruits
2    red fruits
3    red fruits
```
instead of this:
```python
>>>> df['categorical_var']
0    apples
1    oranges
2    apples
3    apples

>>>> # to figure out current category order
>>>> print(df['categorical_var'].cat.categories)
['apples', 'oranges']

>>>> # rename based on order
>>>> df['categorical_var'].rename_categories(['red fruits', 'orange fruits'])
0    red fruits
1    orange fruits
2    red fruits
3    red fruits
```


**2. Allows the developer to much more easily rename one or a small subset of categories without having to worry about the rest of the category names.**

This:
```python
>>>> df['categorical_var']
0    apples
1    oranges
2    apples
3    apples
4    green fruits
5    yellow fruits
6    blue fruits
7    purple fruits

>>>> df['categorical_var'].rename_categories({ 'apples': 'red fruits', 'oranges': 'orange fruits'})
0    red fruits
1    orange fruits
2    red fruits
3    red fruits
4    green fruits
5    yellow fruits
6    blue fruits
7    purple fruits
```
instead of this:
```python
>>>> df['categorical_var']
0    apples
1    oranges
2    apples
3    apples
4    green fruits
5    yellow fruits
6    blue fruits
7    purple fruits

>>>> # to find full list of categories
>>>> print(df['categorical_var'].cat.categories)
['apples', 'oranges' 'green fruits', 'yellow fruits', 'blue fruits', 'purple fruits']

>>>> # rename must contain full category list
>>>> df['categorical_var'].rename_categories(['red fruits', 'orange fruits', 'green fruits', 'yellow fruits', 'blue fruits', 'purple fruits'])
0    red fruits
1    orange fruits
2    red fruits
3    red fruits
4    green fruits
5    yellow fruits
6    blue fruits
7    purple fruits
```


**3. As I mentioned above, this also makes the syntax more similar to the DataFrame.rename syntax, which I think increases overall usability**

I would be happy to start working on this if others agree that adding the feature makes sense.



Discussion:
@linusmarco : Thanks for reporting this!  I think that makes good sense, especially since it would be consistent with `DataFrame.rename`.  Have a look to see what you would need to do to make this work.

Resolving patch:
diff --git a/pandas/core/categorical.py b/pandas/core/categorical.py
--- a/pandas/core/categorical.py
+++ b/pandas/core/categorical.py
@@ -25,7 +25,8 @@
     is_categorical_dtype,
     is_integer_dtype, is_bool,
     is_list_like, is_sequence,
-    is_scalar)
+    is_scalar,
+    is_dict_like)
 from pandas.core.common import is_null_slice, _maybe_box_datetimelike
 
 from pandas.core.algorithms import factorize, take_1d, unique1d
@@ -792,19 +793,20 @@ def set_categories(self, new_categories, ordered=None, rename=False,
     def rename_categories(self, new_categories, inplace=False):
         """ Renames categories.
 
-        The new categories has to be a list-like object. All items must be
-        unique and the number of items in the new categories must be the same
-        as the number of items in the old categories.
+        The new categories can be either a list-like dict-like object.
+        If it is list-like, all items must be unique and the number of items
+        in the new categories must be the same as the number of items in the
+        old categories.
 
         Raises
         ------
         ValueError
-            If the new categories do not have the same number of items than the
-            current categories or do not validate as categories
+            If new categories are list-like and do not have the same number of
+            items than the current categories or do not validate as categories
 
         Parameters
         ----------
-        new_categories : Index-like
+        new_categories : Index-like or dict-like (>=0.21.0)
            The renamed categories.
         inplace : boolean (default: False)
            Whether or not to rename the categories inplace or return a copy of
@@ -824,7 +826,12 @@ def rename_categories(self, new_categories, inplace=False):
         """
         inplace = validate_bool_kwarg(inplace, 'inplace')
         cat = self if inplace else self.copy()
-        cat.categories = new_categories
+
+        if is_dict_like(new_categories):
+            cat.categories = [new_categories.get(item, item)
+                              for item in cat.categories]
+        else:
+            cat.categories = new_categories
         if not inplace:
             return cat
 


Test patch:
diff --git a/pandas/tests/test_categorical.py b/pandas/tests/test_categorical.py
--- a/pandas/tests/test_categorical.py
+++ b/pandas/tests/test_categorical.py
@@ -983,6 +983,40 @@ def test_rename_categories(self):
         with pytest.raises(ValueError):
             cat.rename_categories([1, 2])
 
+    def test_rename_categories_dict(self):
+        # GH 17336
+        cat = pd.Categorical(['a', 'b', 'c', 'd'])
+        res = cat.rename_categories({'a': 4, 'b': 3, 'c': 2, 'd': 1})
+        expected = Index([4, 3, 2, 1])
+        tm.assert_index_equal(res.categories, expected)
+
+        # Test for inplace
+        res = cat.rename_categories({'a': 4, 'b': 3, 'c': 2, 'd': 1},
+                                    inplace=True)
+        assert res is None
+        tm.assert_index_equal(cat.categories, expected)
+
+        # Test for dicts of smaller length
+        cat = pd.Categorical(['a', 'b', 'c', 'd'])
+        res = cat.rename_categories({'a': 1, 'c': 3})
+
+        expected = Index([1, 'b', 3, 'd'])
+        tm.assert_index_equal(res.categories, expected)
+
+        # Test for dicts with bigger length
+        cat = pd.Categorical(['a', 'b', 'c', 'd'])
+        res = cat.rename_categories({'a': 1, 'b': 2, 'c': 3,
+                                     'd': 4, 'e': 5, 'f': 6})
+        expected = Index([1, 2, 3, 4])
+        tm.assert_index_equal(res.categories, expected)
+
+        # Test for dicts with no items from old categories
+        cat = pd.Categorical(['a', 'b', 'c', 'd'])
+        res = cat.rename_categories({'f': 1, 'g': 3})
+
+        expected = Index(['a', 'b', 'c', 'd'])
+        tm.assert_index_equal(res.categories, expected)
+
     @pytest.mark.parametrize('codes, old, new, expected', [
         ([0, 1], ['a', 'b'], ['a', 'b'], [0, 1]),
         ([0, 1], ['b', 'a'], ['b', 'a'], [0, 1]),