Repository: pandas-dev/pandas

Issue:
mergesort is not stable when sorting by a categorical column
```python
import pandas as pd
import numpy as np

n = 5  # not a problem for n < 5

df = pd.DataFrame({
    'x': pd.Categorical(np.repeat([1, 2, 3, 4], n), ordered=True)
})

df.sort_values('x', kind='mergesort')
```

output:
```
        x
0	1
1	1
2	1
3	1
4	1
8	2
7	2
9	2
5	2
6	2
10	3
11	3
12	3
13	3
14	3
18	4
15	4
16	4
17	4
19	4
```

#### Problem description

When sorting (using mergesort) a dataframe by an ordered categorical column, the sorting should be stable. In the example above `x==2` and `x==4` the values have been scrambled.

#### Expected Output
The index should remain in order since the column is already sorted.

#### Output of ``pd.show_versions()``

<details>

INSTALLED VERSIONS
------------------
commit: None
python: 3.5.3.final.0
python-bits: 64
OS: Linux
OS-release: 4.11.1-gentoo
machine: x86_64
processor: Intel(R)
byteorder: little
LC_ALL: en_US.utf8
LANG: en_US.utf8
LOCALE: en_US.UTF-8

pandas: 0.20.2
pytest: 3.1.2
pip: 9.0.1
setuptools: 36.0.1
Cython: 0.25.2
numpy: 1.13.0
scipy: 0.19.1
xarray: None
IPython: 6.1.0
sphinx: 1.6.2
patsy: 0.4.1
dateutil: 2.6.0
pytz: 2017.2
blosc: None
bottleneck: None
tables: None
numexpr: None
feather: None
matplotlib: 2.0.2
openpyxl: None
xlrd: None
xlwt: None
xlsxwriter: None
lxml: None
bs4: None
html5lib: 0.999999999
sqlalchemy: None
pymysql: None
psycopg2: None
jinja2: 2.9.6
s3fs: None
pandas_gbq: None
pandas_datareader: None

</details>



Discussion:
Yeah, looks like we throw away the `kind` argument on Categoricals, shouldn't be too hard to trace through, PR welcome!

https://github.com/pandas-dev/pandas/blob/664348c440ccb4dab3d4c420d6aaee7c688c9b0e/pandas/core/sorting.py#L236

Code currently forbids anything but the default kind='quicksort' from being passed to categorical argsort.  See validate_argsort_with_ascending.

Resolving patch:
diff --git a/pandas/compat/numpy/function.py b/pandas/compat/numpy/function.py
--- a/pandas/compat/numpy/function.py
+++ b/pandas/compat/numpy/function.py
@@ -107,6 +107,14 @@ def validate_argmax_with_skipna(skipna, args, kwargs):
 validate_argsort = CompatValidator(ARGSORT_DEFAULTS, fname='argsort',
                                    max_fname_arg_count=0, method='both')
 
+# two different signatures of argsort, this second validation
+# for when the `kind` param is supported
+ARGSORT_DEFAULTS_KIND = OrderedDict()
+ARGSORT_DEFAULTS_KIND['axis'] = -1
+ARGSORT_DEFAULTS_KIND['order'] = None
+validate_argsort_kind = CompatValidator(ARGSORT_DEFAULTS_KIND, fname='argsort',
+                                        max_fname_arg_count=0, method='both')
+
 
 def validate_argsort_with_ascending(ascending, args, kwargs):
     """
@@ -121,7 +129,7 @@ def validate_argsort_with_ascending(ascending, args, kwargs):
         args = (ascending,) + args
         ascending = True
 
-    validate_argsort(args, kwargs, max_fname_arg_count=1)
+    validate_argsort_kind(args, kwargs, max_fname_arg_count=3)
     return ascending
 
 
diff --git a/pandas/core/categorical.py b/pandas/core/categorical.py
--- a/pandas/core/categorical.py
+++ b/pandas/core/categorical.py
@@ -1288,7 +1288,7 @@ def check_for_ordered(self, op):
                             "you can use .as_ordered() to change the "
                             "Categorical to an ordered one\n".format(op=op))
 
-    def argsort(self, ascending=True, *args, **kwargs):
+    def argsort(self, ascending=True, kind='quicksort', *args, **kwargs):
         """
         Returns the indices that would sort the Categorical instance if
         'sort_values' was called. This function is implemented to provide
@@ -1309,7 +1309,7 @@ def argsort(self, ascending=True, *args, **kwargs):
         numpy.ndarray.argsort
         """
         ascending = nv.validate_argsort_with_ascending(ascending, args, kwargs)
-        result = np.argsort(self._codes.copy(), **kwargs)
+        result = np.argsort(self._codes.copy(), kind=kind, **kwargs)
         if not ascending:
             result = result[::-1]
         return result
diff --git a/pandas/core/sorting.py b/pandas/core/sorting.py
--- a/pandas/core/sorting.py
+++ b/pandas/core/sorting.py
@@ -233,7 +233,7 @@ def nargsort(items, kind='quicksort', ascending=True, na_position='last'):
 
     # specially handle Categorical
     if is_categorical_dtype(items):
-        return items.argsort(ascending=ascending)
+        return items.argsort(ascending=ascending, kind=kind)
 
     items = np.asanyarray(items)
     idx = np.arange(len(items))


Test patch:
diff --git a/pandas/tests/frame/test_sorting.py b/pandas/tests/frame/test_sorting.py
--- a/pandas/tests/frame/test_sorting.py
+++ b/pandas/tests/frame/test_sorting.py
@@ -238,6 +238,15 @@ def test_stable_descending_multicolumn_sort(self):
                                    kind='mergesort')
         assert_frame_equal(sorted_df, expected)
 
+    def test_stable_categorial(self):
+        # GH 16793
+        df = DataFrame({
+            'x': pd.Categorical(np.repeat([1, 2, 3, 4], 5), ordered=True)
+        })
+        expected = df.copy()
+        sorted_df = df.sort_values('x', kind='mergesort')
+        assert_frame_equal(sorted_df, expected)
+
     def test_sort_datetimes(self):
 
         # GH 3461, argsort / lexsort differences for a datetime column
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
@@ -585,9 +585,8 @@ def test_numpy_argsort(self):
         tm.assert_numpy_array_equal(np.argsort(c), expected,
                                     check_dtype=False)
 
-        msg = "the 'kind' parameter is not supported"
-        tm.assert_raises_regex(ValueError, msg, np.argsort,
-                               c, kind='mergesort')
+        tm.assert_numpy_array_equal(np.argsort(c, kind='mergesort'), expected,
+                                    check_dtype=False)
 
         msg = "the 'axis' parameter is not supported"
         tm.assert_raises_regex(ValueError, msg, np.argsort,