diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 7c9d78acc0feb..9ef3a251aad9d 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -70,6 +70,7 @@ Other enhancements - :meth:`.DataFrameGroupBy.transform`, :meth:`.SeriesGroupBy.transform`, :meth:`.DataFrameGroupBy.agg`, :meth:`.SeriesGroupBy.agg`, :meth:`.SeriesGroupBy.apply`, :meth:`.DataFrameGroupBy.apply` now support ``kurt`` (:issue:`40139`) - :meth:`DataFrame.apply` supports using third-party execution engines like the Bodo.ai JIT compiler (:issue:`60668`) - :meth:`DataFrame.iloc` and :meth:`Series.iloc` now support boolean masks in ``__getitem__`` for more consistent indexing behavior (:issue:`60994`) +- :meth:`DataFrame.unset_index` is a new method that resets the index of the DataFrame to the default integer index (:issue:`60869`) - :meth:`DataFrameGroupBy.transform`, :meth:`SeriesGroupBy.transform`, :meth:`DataFrameGroupBy.agg`, :meth:`SeriesGroupBy.agg`, :meth:`RollingGroupby.apply`, :meth:`ExpandingGroupby.apply`, :meth:`Rolling.apply`, :meth:`Expanding.apply`, :meth:`DataFrame.apply` with ``engine="numba"`` now supports positional arguments passed as kwargs (:issue:`58995`) - :meth:`Rolling.agg`, :meth:`Expanding.agg` and :meth:`ExponentialMovingWindow.agg` now accept :class:`NamedAgg` aggregations through ``**kwargs`` (:issue:`28333`) - :meth:`Series.map` can now accept kwargs to pass on to func (:issue:`59814`) diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 6158e19737185..6da51b2aa2c49 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -150,6 +150,7 @@ DatetimeIndex, Index, PeriodIndex, + RangeIndex, default_index, ensure_index, ensure_index_from_sequences, @@ -6367,6 +6368,90 @@ class max type return None + def unset_index( + self, + *, + inplace: bool = False, + ) -> DataFrame | None: + """ + Remove the index and restore the default RangeIndex. + + This method resets the index of the DataFrame to the default integer index + (RangeIndex), always dropping the old index and never inserting it as a column. + If the DataFrame already has the default index, this is a no-op. + + Parameters + ---------- + inplace : bool, default False + Whether to modify the DataFrame in place or return a new DataFrame. + + Returns + ------- + DataFrame or None + DataFrame with the default integer index or None if ``inplace=True``. + + See Also + -------- + DataFrame.reset_index : Reset the index, with options to insert as columns. + DataFrame.set_index : Set the DataFrame index using existing columns. + + Examples + -------- + >>> df = pd.DataFrame( + ... [("bird", 389.0), ("bird", 24.0), ("mammal", 80.5), ("mammal", np.nan)], + ... index=["falcon", "parrot", "lion", "monkey"], + ... columns=("class", "max_speed"), + ... ) + >>> df + class max_speed + falcon bird 389.0 + parrot bird 24.0 + lion mammal 80.5 + monkey mammal NaN + + Remove the index and use the default integer index: + + >>> df.unset_index() + class max_speed + 0 bird 389.0 + 1 bird 24.0 + 2 mammal 80.5 + 3 mammal NaN + + If the DataFrame already has the default index, nothing changes: + + >>> df2 = df.unset_index() + >>> df2.unset_index() + class max_speed + 0 bird 389.0 + 1 bird 24.0 + 2 mammal 80.5 + 3 mammal NaN + + Modify the DataFrame in place: + + >>> df.unset_index(inplace=True) + >>> df + class max_speed + 0 bird 389.0 + 1 bird 24.0 + 2 mammal 80.5 + 3 mammal NaN + """ + + if ( + isinstance(self.index, RangeIndex) + and self.index.start == 0 + and self.index.step == 1 + ): + # Already default index, no change needed + if inplace: + return None + else: + return self.copy() + else: + return self.reset_index(drop=True, inplace=inplace) + # ---------------------------------------------------------------------- # Reindex-based selection methods diff --git a/pandas/tests/frame/test_unset_index.py b/pandas/tests/frame/test_unset_index.py new file mode 100644 index 0000000000000..f1532de4bcf7f --- /dev/null +++ b/pandas/tests/frame/test_unset_index.py @@ -0,0 +1,74 @@ +import numpy as np +import pytest + +import pandas as pd +from pandas.testing import assert_frame_equal + + +class TestUnsetIndex: + # GH: 60869 + @pytest.fixture + def df(self): + # Fixture with custom string index + return pd.DataFrame( + [("bird", 389.0), ("bird", 24.0), ("mammal", 80.5), ("mammal", np.nan)], + index=["falcon", "parrot", "lion", "monkey"], + columns=("class", "max_speed"), + ) + + def test_unset_index_returns_default_index(self, df): + result = df.unset_index() + expected = pd.DataFrame( + { + "class": ["bird", "bird", "mammal", "mammal"], + "max_speed": [389.0, 24.0, 80.5, np.nan], + }, + index=pd.RangeIndex(0, 4), + ) + assert_frame_equal(result, expected) + + def test_unset_index_inplace(self, df): + out = df.unset_index(inplace=True) + assert out is None + assert isinstance(df.index, pd.RangeIndex) + assert df.index.equals(pd.RangeIndex(0, 4)) + + def test_unset_index_on_default_index(self): + df = pd.DataFrame({"A": [1, 2], "B": [3, 4]}) + original = df.copy() + result = df.unset_index() + assert_frame_equal(result, original) + assert result is not df # Should return copy + + def test_unset_index_on_non_default_rangeindex(self): + # RangeIndex with start=2, step=1 + df = pd.DataFrame({"A": [1, 2, 3]}, index=pd.RangeIndex(start=2, stop=5)) + result = df.unset_index() + assert result.index.equals(pd.RangeIndex(0, 3)) + + def test_unset_index_on_rangeindex_with_step_2(self): + # RangeIndex with non-default step + df = pd.DataFrame( + {"A": [1, 2, 3]}, index=pd.RangeIndex(start=0, step=2, stop=6) + ) + result = df.unset_index() + assert result.index.equals(pd.RangeIndex(0, 3)) + + def test_unset_index_on_empty_dataframe(self): + df = pd.DataFrame(columns=["A", "B"]) + result = df.unset_index() + assert_frame_equal(result, df) + assert result.index.equals(pd.RangeIndex(0, 0)) + + def test_unset_index_on_multiindex(self): + index = pd.MultiIndex.from_tuples( + [("a", 1), ("a", 2)], names=["letter", "number"] + ) + df = pd.DataFrame({"data": [10, 20]}, index=index) + result = df.unset_index() + assert result.index.equals(pd.RangeIndex(0, 2)) + + def test_idempotent_on_default_index(self): + df = pd.DataFrame({"A": [1, 2]}) + result = df.unset_index().unset_index() + assert_frame_equal(result, df.unset_index())