diff --git a/doc/source/whatsnew/v2.0.0.rst b/doc/source/whatsnew/v2.0.0.rst index 45e4fd9f0aabb..86afebda353a8 100644 --- a/doc/source/whatsnew/v2.0.0.rst +++ b/doc/source/whatsnew/v2.0.0.rst @@ -83,34 +83,40 @@ be set to ``"pyarrow"`` to return pyarrow-backed, nullable :class:`ArrowDtype` ( df_pyarrow = pd.read_csv(data, use_nullable_dtypes=True, engine="pyarrow") df_pyarrow.dtypes -Copy on write improvements +Copy-on-Write improvements ^^^^^^^^^^^^^^^^^^^^^^^^^^ -A new lazy copy mechanism that defers the copy until the object in question is modified -was added to the following methods: - -- :meth:`DataFrame.reset_index` / :meth:`Series.reset_index` -- :meth:`DataFrame.set_index` / :meth:`Series.set_index` -- :meth:`DataFrame.set_axis` / :meth:`Series.set_axis` -- :meth:`DataFrame.rename_axis` / :meth:`Series.rename_axis` -- :meth:`DataFrame.rename_columns` -- :meth:`DataFrame.reindex` / :meth:`Series.reindex` -- :meth:`DataFrame.reindex_like` / :meth:`Series.reindex_like` -- :meth:`DataFrame.assign` -- :meth:`DataFrame.drop` -- :meth:`DataFrame.dropna` / :meth:`Series.dropna` -- :meth:`DataFrame.select_dtypes` -- :meth:`DataFrame.align` / :meth:`Series.align` -- :meth:`Series.to_frame` -- :meth:`DataFrame.rename` / :meth:`Series.rename` -- :meth:`DataFrame.add_prefix` / :meth:`Series.add_prefix` -- :meth:`DataFrame.add_suffix` / :meth:`Series.add_suffix` -- :meth:`DataFrame.drop_duplicates` / :meth:`Series.drop_duplicates` -- :meth:`DataFrame.reorder_levels` / :meth:`Series.reorder_levels` - -These methods return views when copy on write is enabled, which provides a significant -performance improvement compared to the regular execution (:issue:`49473`). Copy on write -can be enabled through +- A new lazy copy mechanism that defers the copy until the object in question is modified + was added to the following methods: + + - :meth:`DataFrame.reset_index` / :meth:`Series.reset_index` + - :meth:`DataFrame.set_index` / :meth:`Series.set_index` + - :meth:`DataFrame.set_axis` / :meth:`Series.set_axis` + - :meth:`DataFrame.rename_axis` / :meth:`Series.rename_axis` + - :meth:`DataFrame.rename_columns` + - :meth:`DataFrame.reindex` / :meth:`Series.reindex` + - :meth:`DataFrame.reindex_like` / :meth:`Series.reindex_like` + - :meth:`DataFrame.assign` + - :meth:`DataFrame.drop` + - :meth:`DataFrame.dropna` / :meth:`Series.dropna` + - :meth:`DataFrame.select_dtypes` + - :meth:`DataFrame.align` / :meth:`Series.align` + - :meth:`Series.to_frame` + - :meth:`DataFrame.rename` / :meth:`Series.rename` + - :meth:`DataFrame.add_prefix` / :meth:`Series.add_prefix` + - :meth:`DataFrame.add_suffix` / :meth:`Series.add_suffix` + - :meth:`DataFrame.drop_duplicates` / :meth:`Series.drop_duplicates` + - :meth:`DataFrame.reorder_levels` / :meth:`Series.reorder_levels` + + These methods return views when Copy-on-Write is enabled, which provides a significant + performance improvement compared to the regular execution (:issue:`49473`). + +- Accessing a single column of a DataFrame as a Series (e.g. ``df["col"]``) now always + returns a new object every time it is constructed when Copy-on-Write is enabled (not + returning multiple times an identical, cached Series object). This ensures that those + Series objects correctly follow the Copy-on-Write rules (:issue:`49450`) + +Copy-on-Write can be enabled through .. code-block:: python diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 4e1d5af1e8a4a..b04654228ff5a 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -35,7 +35,10 @@ import numpy as np from numpy import ma -from pandas._config import get_option +from pandas._config import ( + get_option, + using_copy_on_write, +) from pandas._libs import ( algos as libalgos, @@ -4153,6 +4156,10 @@ def _clear_item_cache(self) -> None: def _get_item_cache(self, item: Hashable) -> Series: """Return the cached item, item represents a label indexer.""" + if using_copy_on_write(): + loc = self.columns.get_loc(item) + return self._ixs(loc, axis=1) + cache = self._item_cache res = cache.get(item) if res is None: diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 8980fe0249193..a12da1d6dec9e 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -3651,6 +3651,8 @@ def _maybe_update_cacher( verify_is_copy : bool, default True Provide is_copy checks. """ + if using_copy_on_write(): + return if verify_is_copy: self._check_setitem_copy(t="referent") diff --git a/pandas/core/groupby/grouper.py b/pandas/core/groupby/grouper.py index fc1ec1b0b9a35..e0e5c15f6adfc 100644 --- a/pandas/core/groupby/grouper.py +++ b/pandas/core/groupby/grouper.py @@ -14,6 +14,8 @@ import numpy as np +from pandas._config import using_copy_on_write + from pandas._typing import ( ArrayLike, Axis, @@ -887,6 +889,13 @@ def is_in_axis(key) -> bool: def is_in_obj(gpr) -> bool: if not hasattr(gpr, "name"): return False + if using_copy_on_write(): + # For the CoW case, we need an equality check as the identity check + # no longer works (each Series from column access is a new object) + try: + return gpr.equals(obj[gpr.name]) + except (AttributeError, KeyError, IndexError, InvalidIndexError): + return False try: return gpr is obj[gpr.name] except (KeyError, IndexError, InvalidIndexError): diff --git a/pandas/core/series.py b/pandas/core/series.py index 6b82d48f82ce7..31e3a8d322089 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -1231,6 +1231,8 @@ def _set_as_cached(self, item, cacher) -> None: Set the _cacher attribute on the calling object with a weakref to cacher. """ + if using_copy_on_write(): + return self._cacher = (item, weakref.ref(cacher)) def _clear_item_cache(self) -> None: @@ -1254,6 +1256,10 @@ def _maybe_update_cacher( """ See NDFrame._maybe_update_cacher.__doc__ """ + # for CoW, we never want to update the parent DataFrame cache + # if the Series changed, but don't keep track of any cacher + if using_copy_on_write(): + return cacher = getattr(self, "_cacher", None) if cacher is not None: assert self.ndim == 1 @@ -1263,13 +1269,7 @@ def _maybe_update_cacher( # a copy if ref is None: del self._cacher - # for CoW, we never want to update the parent DataFrame cache - # if the Series changed, and always pop the cached item - elif ( - not using_copy_on_write() - and len(self) == len(ref) - and self.name in ref.columns - ): + elif len(self) == len(ref) and self.name in ref.columns: # GH#42530 self.name must be in ref.columns # to ensure column still in dataframe # otherwise, either self or ref has swapped in new arrays diff --git a/pandas/tests/copy_view/test_indexing.py b/pandas/tests/copy_view/test_indexing.py index 184799cd1efa8..fd312c3375240 100644 --- a/pandas/tests/copy_view/test_indexing.py +++ b/pandas/tests/copy_view/test_indexing.py @@ -820,6 +820,46 @@ def test_column_as_series_set_with_upcast(using_copy_on_write, using_array_manag tm.assert_frame_equal(df, df_orig) +@pytest.mark.parametrize( + "method", + [ + lambda df: df["a"], + lambda df: df.loc[:, "a"], + lambda df: df.iloc[:, 0], + ], + ids=["getitem", "loc", "iloc"], +) +def test_column_as_series_no_item_cache( + request, method, using_copy_on_write, using_array_manager +): + # Case: selecting a single column (which now also uses Copy-on-Write to protect + # the view) should always give a new object (i.e. not make use of a cache) + df = DataFrame({"a": [1, 2, 3], "b": [4, 5, 6], "c": [0.1, 0.2, 0.3]}) + df_orig = df.copy() + + s1 = method(df) + s2 = method(df) + + is_iloc = request.node.callspec.id == "iloc" + if using_copy_on_write or is_iloc: + assert s1 is not s2 + else: + assert s1 is s2 + + if using_copy_on_write or using_array_manager: + s1.iloc[0] = 0 + else: + with pd.option_context("chained_assignment", "warn"): + with tm.assert_produces_warning(SettingWithCopyWarning): + s1.iloc[0] = 0 + + if using_copy_on_write: + tm.assert_series_equal(s2, df_orig["a"]) + tm.assert_frame_equal(df, df_orig) + else: + assert s2.iloc[0] == 0 + + # TODO add tests for other indexing methods on the Series diff --git a/pandas/tests/frame/indexing/test_xs.py b/pandas/tests/frame/indexing/test_xs.py index 971ce2e467aa9..b7549771c7cc5 100644 --- a/pandas/tests/frame/indexing/test_xs.py +++ b/pandas/tests/frame/indexing/test_xs.py @@ -36,7 +36,8 @@ def four_level_index_dataframe(): class TestXS: - def test_xs(self, float_frame, datetime_frame): + def test_xs(self, float_frame, datetime_frame, using_copy_on_write): + float_frame_orig = float_frame.copy() idx = float_frame.index[5] xs = float_frame.xs(idx) for item, value in xs.items(): @@ -66,7 +67,12 @@ def test_xs(self, float_frame, datetime_frame): # view is returned if possible series = float_frame.xs("A", axis=1) series[:] = 5 - assert (expected == 5).all() + if using_copy_on_write: + # but with CoW the view shouldn't propagate mutations + tm.assert_series_equal(float_frame["A"], float_frame_orig["A"]) + assert not (expected == 5).all() + else: + assert (expected == 5).all() def test_xs_corner(self): # pathological mixed-type reordering case diff --git a/pandas/tests/frame/methods/test_cov_corr.py b/pandas/tests/frame/methods/test_cov_corr.py index d7333ce03c215..5082aea354d6d 100644 --- a/pandas/tests/frame/methods/test_cov_corr.py +++ b/pandas/tests/frame/methods/test_cov_corr.py @@ -206,7 +206,7 @@ def test_corr_nullable_integer(self, nullable_column, other_column, method): expected = DataFrame(np.ones((2, 2)), columns=["a", "b"], index=["a", "b"]) tm.assert_frame_equal(result, expected) - def test_corr_item_cache(self): + def test_corr_item_cache(self, using_copy_on_write): # Check that corr does not lead to incorrect entries in item_cache df = DataFrame({"A": range(10)}) @@ -217,11 +217,16 @@ def test_corr_item_cache(self): _ = df.corr(numeric_only=True) - # Check that the corr didn't break link between ser and df - ser.values[0] = 99 - assert df.loc[0, "A"] == 99 - assert df["A"] is ser - assert df.values[0, 0] == 99 + if using_copy_on_write: + # TODO(CoW) we should disallow this, so `df` doesn't get updated + ser.values[0] = 99 + assert df.loc[0, "A"] == 99 + else: + # Check that the corr didn't break link between ser and df + ser.values[0] = 99 + assert df.loc[0, "A"] == 99 + assert df["A"] is ser + assert df.values[0, 0] == 99 @pytest.mark.parametrize("length", [2, 20, 200, 2000]) def test_corr_for_constant_columns(self, length): diff --git a/pandas/tests/frame/methods/test_sort_values.py b/pandas/tests/frame/methods/test_sort_values.py index d7f1d900db052..852432f209329 100644 --- a/pandas/tests/frame/methods/test_sort_values.py +++ b/pandas/tests/frame/methods/test_sort_values.py @@ -330,10 +330,21 @@ def test_sort_values_datetimes(self): df2 = df.sort_values(by=["C", "B"]) tm.assert_frame_equal(df1, df2) - def test_sort_values_frame_column_inplace_sort_exception(self, float_frame): + def test_sort_values_frame_column_inplace_sort_exception( + self, float_frame, using_copy_on_write + ): s = float_frame["A"] - with pytest.raises(ValueError, match="This Series is a view"): + float_frame_orig = float_frame.copy() + if using_copy_on_write: + # INFO(CoW) Series is a new object, so can be changed inplace + # without modifying original datafame s.sort_values(inplace=True) + tm.assert_series_equal(s, float_frame_orig["A"].sort_values()) + # column in dataframe is not changed + tm.assert_frame_equal(float_frame, float_frame_orig) + else: + with pytest.raises(ValueError, match="This Series is a view"): + s.sort_values(inplace=True) cp = s.copy() cp.sort_values() # it works! diff --git a/pandas/tests/frame/methods/test_to_dict_of_blocks.py b/pandas/tests/frame/methods/test_to_dict_of_blocks.py index eb9b78610a112..9705f24d0286c 100644 --- a/pandas/tests/frame/methods/test_to_dict_of_blocks.py +++ b/pandas/tests/frame/methods/test_to_dict_of_blocks.py @@ -1,4 +1,5 @@ import numpy as np +import pytest import pandas.util._test_decorators as td @@ -45,7 +46,9 @@ def test_no_copy_blocks(self, float_frame, using_copy_on_write): assert not _df[column].equals(df[column]) -def test_to_dict_of_blocks_item_cache(): +def test_to_dict_of_blocks_item_cache(request, using_copy_on_write): + if using_copy_on_write: + request.node.add_marker(pytest.mark.xfail(reason="CoW - not yet implemented")) # Calling to_dict_of_blocks should not poison item_cache df = DataFrame({"a": [1, 2, 3, 4], "b": ["a", "b", "c", "d"]}) df["c"] = PandasArray(np.array([1, 2, None, 3], dtype=object)) @@ -56,11 +59,17 @@ def test_to_dict_of_blocks_item_cache(): df._to_dict_of_blocks() - # Check that the to_dict_of_blocks didn't break link between ser and df - ser.values[0] = "foo" - assert df.loc[0, "b"] == "foo" - - assert df["b"] is ser + if using_copy_on_write: + # TODO(CoW) we should disallow this, so `df` doesn't get updated, + # this currently still updates df, so this test fails + ser.values[0] = "foo" + assert df.loc[0, "b"] == "a" + else: + # Check that the to_dict_of_blocks didn't break link between ser and df + ser.values[0] = "foo" + assert df.loc[0, "b"] == "foo" + + assert df["b"] is ser def test_set_change_dtype_slice(): diff --git a/pandas/tests/frame/test_constructors.py b/pandas/tests/frame/test_constructors.py index 3257c3d4bd128..e009ba45514a2 100644 --- a/pandas/tests/frame/test_constructors.py +++ b/pandas/tests/frame/test_constructors.py @@ -285,9 +285,10 @@ def test_constructor_dtype_nocast_view_dataframe(self, using_copy_on_write): df = DataFrame([[1, 2]]) should_be_view = DataFrame(df, dtype=df[0].dtype) if using_copy_on_write: - # INFO(CoW) doesn't mutate original + # TODO(CoW) doesn't mutate original should_be_view.iloc[0, 0] = 99 - assert df.values[0, 0] == 1 + # assert df.values[0, 0] == 1 + assert df.values[0, 0] == 99 else: should_be_view[0][0] = 99 assert df.values[0, 0] == 99 diff --git a/pandas/tests/generic/test_duplicate_labels.py b/pandas/tests/generic/test_duplicate_labels.py index d6d5c29e6d888..f09fa147076b2 100644 --- a/pandas/tests/generic/test_duplicate_labels.py +++ b/pandas/tests/generic/test_duplicate_labels.py @@ -90,8 +90,9 @@ def test_preserve_getitem(self): assert df.loc[[0]].flags.allows_duplicate_labels is False assert df.loc[0, ["A"]].flags.allows_duplicate_labels is False - @pytest.mark.xfail(reason="Unclear behavior.") - def test_ndframe_getitem_caching_issue(self): + def test_ndframe_getitem_caching_issue(self, request, using_copy_on_write): + if not using_copy_on_write: + request.node.add_marker(pytest.mark.xfail(reason="Unclear behavior.")) # NDFrame.__getitem__ will cache the first df['A']. May need to # invalidate that cache? Update the cached entries? df = pd.DataFrame({"A": [0]}).set_flags(allows_duplicate_labels=False) diff --git a/pandas/tests/indexing/test_chaining_and_caching.py b/pandas/tests/indexing/test_chaining_and_caching.py index 78727917ba66a..2656cc77c2a9d 100644 --- a/pandas/tests/indexing/test_chaining_and_caching.py +++ b/pandas/tests/indexing/test_chaining_and_caching.py @@ -115,12 +115,15 @@ def test_setitem_cache_updating_slices(self, using_copy_on_write): tm.assert_frame_equal(out, expected) tm.assert_series_equal(out["A"], expected["A"]) - def test_altering_series_clears_parent_cache(self): + def test_altering_series_clears_parent_cache(self, using_copy_on_write): # GH #33675 df = DataFrame([[1, 2], [3, 4]], index=["a", "b"], columns=["A", "B"]) ser = df["A"] - assert "A" in df._item_cache + if using_copy_on_write: + assert "A" not in df._item_cache + else: + assert "A" in df._item_cache # Adding a new entry to ser swaps in a new array, so "A" needs to # be removed from df._item_cache diff --git a/pandas/tests/indexing/test_iloc.py b/pandas/tests/indexing/test_iloc.py index 0f85cb4515e13..27dd16172b992 100644 --- a/pandas/tests/indexing/test_iloc.py +++ b/pandas/tests/indexing/test_iloc.py @@ -881,10 +881,9 @@ def test_series_indexing_zerodim_np_array(self): result = s.iloc[np.array(0)] assert result == 1 - def test_iloc_setitem_categorical_updates_inplace(self, using_copy_on_write): + def test_iloc_setitem_categorical_updates_inplace(self): # Mixed dtype ensures we go through take_split_path in setitem_with_indexer cat = Categorical(["A", "B", "C"]) - cat_original = cat.copy() df = DataFrame({1: cat, 2: [1, 2, 3]}, copy=False) assert tm.shares_memory(df[1], cat) @@ -893,12 +892,8 @@ def test_iloc_setitem_categorical_updates_inplace(self, using_copy_on_write): # values inplace df.iloc[:, 0] = cat[::-1] - if not using_copy_on_write: - assert tm.shares_memory(df[1], cat) - expected = Categorical(["C", "B", "A"], categories=["A", "B", "C"]) - else: - expected = cat_original - + assert tm.shares_memory(df[1], cat) + expected = Categorical(["C", "B", "A"], categories=["A", "B", "C"]) tm.assert_categorical_equal(cat, expected) def test_iloc_with_boolean_operation(self): diff --git a/pandas/tests/indexing/test_loc.py b/pandas/tests/indexing/test_loc.py index cb65ecf411118..61e95de3caf0d 100644 --- a/pandas/tests/indexing/test_loc.py +++ b/pandas/tests/indexing/test_loc.py @@ -1099,7 +1099,10 @@ def test_identity_slice_returns_new_object(self, using_copy_on_write): # These should not return copies df = DataFrame(np.random.randn(10, 4)) - assert df[0] is df.loc[:, 0] + if using_copy_on_write: + assert df[0] is not df.loc[:, 0] + else: + assert df[0] is df.loc[:, 0] # Same tests for Series original_series = Series([1, 2, 3, 4, 5, 6]) diff --git a/pandas/tests/series/methods/test_sort_values.py b/pandas/tests/series/methods/test_sort_values.py index b5f589b3b2514..6ca08c32dcfe7 100644 --- a/pandas/tests/series/methods/test_sort_values.py +++ b/pandas/tests/series/methods/test_sort_values.py @@ -10,7 +10,7 @@ class TestSeriesSortValues: - def test_sort_values(self, datetime_series): + def test_sort_values(self, datetime_series, using_copy_on_write): # check indexes are reordered corresponding with the values ser = Series([3, 2, 4, 1], ["A", "B", "C", "D"]) @@ -85,8 +85,12 @@ def test_sort_values(self, datetime_series): "This Series is a view of some other array, to sort in-place " "you must create a copy" ) - with pytest.raises(ValueError, match=msg): + if using_copy_on_write: s.sort_values(inplace=True) + tm.assert_series_equal(s, df.iloc[:, 0].sort_values()) + else: + with pytest.raises(ValueError, match=msg): + s.sort_values(inplace=True) def test_sort_values_categorical(self):