Repository: pandas-dev/pandas
Pull request: ENH: Return DatetimeIndex or TimedeltaIndex bins for q/cut when input is datelike
- [x] closes #19891
- [x] tests added / passed
- [x] passes `git diff upstream/master -u -- "*.py" | flake8 --diff`
- [x] whatsnew entry


Problem statement:
ENH: cut return bins should be Timestamp/Timedelta objects for datelike data
```
In [1]: s = pd.Series(pd.date_range('20180101', periods=3))

In [2]: s
Out[2]:
0   2018-01-01
1   2018-01-02
2   2018-01-03
dtype: datetime64[ns]

In [3]: res, bins = pd.cut(s, 2, retbins=True)

In [4]: pd.cut(s, bins)  # Bins do not "roundtrip"

ValueError: bins must be of datetime64 dtype

In [5]: pd.cut(s, [pd.Timestamp(b) for b in bins]) == res
Out[5]:
0    True
1    True
2    True
dtype: bool
```

For convenience and argument of "roundtripping",  I think `cut(..., retbins=True)` with datelike data (datetime or timedelta data) should return bins that is an array of `Timestamp`/`Timedelta` objects instead of an array of integers.

Hints from issue discussion:
related to #19872

Commit message: Clarify docstring

Patch to review:
diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt
index 979fbb5ddfdd0..5386e6c7cfb9d 100644
--- a/doc/source/whatsnew/v0.23.0.txt
+++ b/doc/source/whatsnew/v0.23.0.txt
@@ -524,6 +524,7 @@ Other Enhancements
 - Added new writer for exporting Stata dta files in version 117, ``StataWriter117``.  This format supports exporting strings with lengths up to 2,000,000 characters (:issue:`16450`)
 - :func:`to_hdf` and :func:`read_hdf` now accept an ``errors`` keyword argument to control encoding error handling (:issue:`20835`)
 - :func:`date_range` now returns a linearly spaced ``DatetimeIndex`` if ``start``, ``stop``, and ``periods`` are specified, but ``freq`` is not. (:issue:`20808`)
+- :func:`cut` and :func:`qcut` now returns a ``DatetimeIndex`` or ``TimedeltaIndex`` bins when the input is datetime or timedelta dtype respectively and ``retbins=True`` (:issue:`19891`)
 
 .. _whatsnew_0230.api_breaking:
 
diff --git a/pandas/core/reshape/tile.py b/pandas/core/reshape/tile.py
index 118198ea0320d..6acf7500bd211 100644
--- a/pandas/core/reshape/tile.py
+++ b/pandas/core/reshape/tile.py
@@ -332,6 +332,8 @@ def _bins_to_cuts(x, bins, right=True, labels=None,
             result = result.astype(np.float64)
             np.putmask(result, na_mask, np.nan)
 
+    bins = _convert_bin_to_datelike_type(bins, dtype)
+
     return result, bins
 
 
@@ -396,6 +398,30 @@ def _convert_bin_to_numeric_type(bins, dtype):
     return bins
 
 
+def _convert_bin_to_datelike_type(bins, dtype):
+    """
+    Convert bins to a DatetimeIndex or TimedeltaIndex if the orginal dtype is
+    datelike
+
+    Parameters
+    ----------
+    bins : list-like of bins
+    dtype : dtype of data
+
+    Returns
+    -------
+    bins : Array-like of bins, DatetimeIndex or TimedeltaIndex if dtype is
+           datelike
+    """
+    if is_datetime64tz_dtype(dtype):
+        bins = to_datetime(bins, utc=True).tz_convert(dtype.tz)
+    elif is_datetime64_dtype(dtype):
+        bins = to_datetime(bins)
+    elif is_timedelta64_dtype(dtype):
+        bins = to_timedelta(bins)
+    return bins
+
+
 def _format_labels(bins, precision, right=True,
                    include_lowest=False, dtype=None):
     """ based on the dtype, return our labels """
diff --git a/pandas/tests/reshape/test_tile.py b/pandas/tests/reshape/test_tile.py
index 8d093f2784ba1..d42300f465115 100644
--- a/pandas/tests/reshape/test_tile.py
+++ b/pandas/tests/reshape/test_tile.py
@@ -6,7 +6,8 @@
 
 from pandas import (DataFrame, Series, isna, to_datetime, DatetimeIndex, Index,
                     Timestamp, Interval, IntervalIndex, Categorical,
-                    cut, qcut, date_range, NaT, TimedeltaIndex)
+                    cut, qcut, date_range, timedelta_range, NaT,
+                    TimedeltaIndex)
 from pandas.tseries.offsets import Nano, Day
 import pandas.util.testing as tm
 from pandas.api.types import CategoricalDtype as CDT
@@ -589,3 +590,38 @@ def f():
         mask = result.isna()
         tm.assert_numpy_array_equal(
             mask, np.array([False, True, True, True, True]))
+
+    @pytest.mark.parametrize('tz', [None, 'UTC', 'US/Pacific'])
+    def test_datetime_cut_roundtrip(self, tz):
+        # GH 19891
+        s = Series(date_range('20180101', periods=3, tz=tz))
+        result, result_bins = cut(s, 2, retbins=True)
+        expected = cut(s, result_bins)
+        tm.assert_series_equal(result, expected)
+        expected_bins = DatetimeIndex(['2017-12-31 23:57:07.200000',
+                                       '2018-01-02 00:00:00',
+                                       '2018-01-03 00:00:00'])
+        expected_bins = expected_bins.tz_localize(tz)
+        tm.assert_index_equal(result_bins, expected_bins)
+
+    def test_timedelta_cut_roundtrip(self):
+        # GH 19891
+        s = Series(timedelta_range('1day', periods=3))
+        result, result_bins = cut(s, 2, retbins=True)
+        expected = cut(s, result_bins)
+        tm.assert_series_equal(result, expected)
+        expected_bins = TimedeltaIndex(['0 days 23:57:07.200000',
+                                        '2 days 00:00:00',
+                                        '3 days 00:00:00'])
+        tm.assert_index_equal(result_bins, expected_bins)
+
+    @pytest.mark.parametrize('arg, expected_bins', [
+        [timedelta_range('1day', periods=3),
+         TimedeltaIndex(['1 days', '2 days', '3 days'])],
+        [date_range('20180101', periods=3),
+         DatetimeIndex(['2018-01-01', '2018-01-02', '2018-01-03'])]])
+    def test_datelike_qcut_bins(self, arg, expected_bins):
+        # GH 19891
+        s = Series(arg)
+        result, result_bins = qcut(s, 2, retbins=True)
+        tm.assert_index_equal(result_bins, expected_bins)


Code review comments:
diff_hunk: @@ -524,6 +524,7 @@ Other Enhancements
 - Added new writer for exporting Stata dta files in version 117, ``StataWriter117``.  This format supports exporting strings with lengths up to 2,000,000 characters (:issue:`16450`)
 - :func:`to_hdf` and :func:`read_hdf` now accept an ``errors`` keyword argument to control encoding error handling (:issue:`20835`)
 - :func:`date_range` now returns a linearly spaced ``DatetimeIndex`` if ``start``, ``stop``, and ``periods`` are specified, but ``freq`` is not. (:issue:`20808`)
+- :func:`cut` and :func:`qcut` now returns a ``DatetimeIndex`` or ``TimedeltaIndex`` bins when the input is datetime or timedelta dtype respectively and ``retbins=True`` (:issue:`19891`)
path: doc/source/whatsnew/v0.23.0.txt
text: @user1:
can you add ``:class:`` for DTI / TDI