pandas-dev/pandas · #71734

ENH: add Series.dt.shift (GH#31705)

jbrockmendel · merged Nov 1, 20264 files · 130 + / 2 −
doc/source/reference/series.rst2 + / 0 −
@@ -371,6 +371,7 @@ Datetime methods    Series.dt.month_name    Series.dt.day_name    Series.dt.as_unit+   Series.dt.shift  Period properties ^^^^^^^^^^^^^^^^^@@ -407,6 +408,7 @@ Timedelta methods    Series.dt.to_pytimedelta    Series.dt.total_seconds    Series.dt.as_unit+   Series.dt.shift   .. _api.series.str:
doc/source/whatsnew/v3.2.0.rst1 + / 0 −
@@ -35,6 +35,7 @@ Other enhancements - :meth:`Series.groupby` now accepts ``as_index=False``, returning aggregation results as a :class:`DataFrame` with the group labels as columns (:issue:`36507`) - Added :meth:`Categorical.to_dense` and :meth:`Series.cat.to_dense` to get the values with the dtype of the categories, including extension dtypes (:issue:`26410`) - Added :meth:`DatetimeIndex.set_freq` and :meth:`TimedeltaIndex.set_freq`, which return a new index with the given ``freq`` instead of modifying it in place (:issue:`61094`)+- Added :meth:`Series.dt.shift` to shift datetime, timedelta, and period values by a number of frequency increments, like :meth:`DatetimeIndex.shift` (:issue:`31705`) - Display formatting for complex numbers in ``object`` dtype and for ``interval`` dtype values with float endpoints now respects the ``display.precision`` and ``display.float_format`` options (:issue:`25920`). - Printing a :class:`.Styler` now shows the underlying data instead of the object's address, so the table also appears in notebooks exported to PDF with ``nbconvert`` (:issue:`55908`) - The :class:`ExtensionArray` base tests in ``pandas.tests.extension.base`` now issue a :class:`errors.PerformanceWarning` when the array uses the default, possibly slow, implementation of ``unique``, ``factorize``, ``argsort``, ``argmin``, ``argmax``, or ``searchsorted`` (:issue:`24433`)
pandas/core/indexes/accessors.py71 + / 0 −
@@ -44,11 +44,14 @@     PandasObject, ) from pandas.core.indexes.datetimes import DatetimeIndex+from pandas.core.indexes.period import PeriodIndex from pandas.core.indexes.timedeltas import TimedeltaIndex  from pandas.tseries import frequencies  if TYPE_CHECKING:+    from pandas._typing import Frequency+     from pandas import (         DataFrame,         Series,@@ -134,6 +137,74 @@ def _delegate_method(self, name: str, *args, **kwargs):             self._parent         ) +    def shift(self, periods: int = 1, freq: Frequency | None = None) -> Series:+        """+        Shift the values by desired number of time frequency increments.++        Unlike :meth:`Series.shift`, which moves values to different positions,+        this adds ``periods * freq`` to each value, like :meth:`DatetimeIndex.shift`.++        Parameters+        ----------+        periods : int, default 1+            Number of periods (or increments) to shift by,+            can be positive or negative.+        freq : pandas.DateOffset, pandas.Timedelta or str, optional+            Frequency increment to shift by. If None, the frequency inferred+            from the values (see :attr:`Series.dt.freq`) is used, so values in+            descending order move backward for positive ``periods``. Must be+            None for period values, which are shifted by their own frequency.++        Returns+        -------+        Series+            Series with the shifted values, indexed like the original Series.++        Raises+        ------+        NullFrequencyError+            If ``freq`` is None, ``periods`` is nonzero, and no frequency can+            be inferred from the values.+        TypeError+            If ``freq`` is passed for period values.++        See Also+        --------+        DatetimeIndex.shift : Shift values of DatetimeIndex.+        Series.shift : Shift values by desired number of positions.++        Examples+        --------+        >>> ser = pd.Series(pd.date_range("2020-01-01", periods=3, freq="D"))+        >>> ser.dt.shift(1)+        0   2020-01-02+        1   2020-01-03+        2   2020-01-04+        dtype: datetime64[us]++        >>> ser.dt.shift(2, freq="h")+        0   2020-01-01 02:00:00+        1   2020-01-02 02:00:00+        2   2020-01-03 02:00:00+        dtype: datetime64[us]+        """+        from pandas import Series++        values = self._get_values()+        if isinstance(values, PeriodArray):+            if freq is not None:+                raise TypeError(+                    "`freq` argument is not supported for Series.dt.shift "+                    "with period values"+                )+            values = PeriodIndex(values, copy=False)+        elif freq is None:+            freq = values._inferred_freq_str+        result = values.shift(periods, freq)+        return Series(+            result._values, index=self._parent.index, name=self.name+        ).__finalize__(self._parent)+  @delegate_names(     delegate=ArrowExtensionArray,
pandas/tests/series/accessors/test_dt_accessor.py56 + / 2 −
@@ -11,7 +11,10 @@ import pytest  from pandas._libs.tslibs.timezones import maybe_get_tz-from pandas.errors import Pandas4Warning+from pandas.errors import (+    NullFrequencyError,+    Pandas4Warning,+)  from pandas.core.dtypes.common import (     is_integer_dtype,@@ -30,7 +33,7 @@ _deprecated_dt_attrs = {"dayofweek", "dayofyear", "daysinmonth", "weekday"}  ok_for_period = PeriodArray._datetimelike_ops + list(_deprecated_dt_attrs)-ok_for_period_methods = ["strftime", "to_timestamp", "asfreq"]+ok_for_period_methods = ["strftime", "to_timestamp", "asfreq", "shift"] # ``freq`` is exposed on the dt accessor (DatetimeProperties) but lives there # directly rather than on the underlying array, so add it explicitly. ok_for_dt = [*DatetimeArray._datetimelike_ops, "freq", *list(_deprecated_dt_attrs)]@@ -48,6 +51,7 @@     "month_name",     "isocalendar",     "as_unit",+    "shift", ] ok_for_td = [*TimedeltaArray._datetimelike_ops, "freq"] ok_for_td_methods = [@@ -58,6 +62,7 @@     "floor",     "ceil",     "as_unit",+    "shift", ]  @@ -1064,3 +1069,52 @@ def test_deprecated_aliases(ser):     with tm.assert_produces_warning(Pandas4Warning, match=msg.format("daysinmonth")):         result = ser.dt.daysinmonth     tm.assert_series_equal(result, ser.dt.days_in_month)+++@pytest.mark.parametrize(+    "values",+    [+        pd.date_range("2020-01-01", periods=3, freq="D"),+        pd.date_range("2020-03-07", periods=3, freq="D", tz="US/Eastern"),+        pd.date_range("2020-01-31", periods=3, freq="ME"),+        pd.timedelta_range("1 day", periods=3, freq="h"),+        pd.period_range("2020-01", periods=3, freq="M"),+    ],+    ids=["datetime64", "datetime64tz", "month_end", "timedelta64", "period"],+)+@pytest.mark.parametrize("periods", [2, -1, 0])+def test_dt_shift(values, periods):+    # GH#31705 without freq, shift by the inferred frequency like Index.shift+    ser = pd.Series(values, index=list("abc"), name="xxx")+    expected = pd.Series(values.shift(periods), index=ser.index, name="xxx")++    result = ser.dt.shift(periods)+    tm.assert_series_equal(result, expected)++    result = ser.astype("category").dt.shift(periods)+    tm.assert_series_equal(result, expected)+++@pytest.mark.parametrize(+    "ser",+    [+        pd.Series(pd.to_datetime(["2020-01-01", "2020-01-03", pd.NaT])),+        pd.Series(pd.to_timedelta(["1 day", "3 days", pd.NaT])),+    ],+    ids=["datetime64", "timedelta64"],+)+def test_dt_shift_freq(ser):+    # GH#31705 an explicit freq does not require regular values+    result = ser.dt.shift(-2, freq="h")+    expected = ser - pd.Timedelta(hours=2)+    tm.assert_series_equal(result, expected)++    with pytest.raises(NullFrequencyError, match="Cannot shift with no freq"):+        ser.dt.shift(1)+    tm.assert_series_equal(ser.dt.shift(0), ser)+++def test_dt_shift_period_freq_raises():+    ser = pd.Series(pd.period_range("2020-01", periods=3, freq="M"))+    with pytest.raises(TypeError, match="not supported for Series.dt.shift"):+        ser.dt.shift(1, freq="M")